diff --git a/cloudflare/README.md b/cloudflare/README.md index 4b55249..463fcb7 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -48,6 +48,43 @@ A late `play`/`playing` event while hidden is stopped as well. No background tim is added to the reservation gate. This prevents audio-only background playback while browsers suspend video rendering. Player release: `20260910-visibility-1`. +## Screening measurement (prepared locally, not deployed) + +Release `20260911-measurement-1` adds events through the site's existing Google +tag queue, targeting the verified `G-DEMO000000` stream. It does not install another +tag, change consent settings, or mark events as conversions. + +| Event | Trigger | +| --- | --- | +| `demo_screening_video_start` | First actual foreground playback | +| `demo_screening_video_progress` | 25%, 50%, 75% credited viewing (`video_percent`) | +| `demo_screening_booking_ready` | Existing viewing gate reveals the booking link | +| `demo_screening_booking_click` | First click on the revealed booking link | +| `demo_screening_video_error` | Classified media load, play rejection or HLS fallback | + +Normal events are deduplicated per page; each progress milestone and error class +has its own key. Progress reuses the viewing gate's credited time, excluding seeks, +pauses and hidden-page time. The source/UTM booking routing and viewing thresholds +are unchanged. Booking clicks describe outbound intent, not completed reservations. + +Measurement runs only on `https://example.invalid/screening-lp/`. Standalone and local +previews send nothing. The site's existing `?measurement_test=1` convention uses +the separate `measurement_test` event with `demo_expected_event` and `debug_mode`. +The event page URL excludes query data, and errors include only a closed category, +never raw exception text. A missing or failing tag cannot interrupt playback. + +Six added tests cover milestones, deduplication, seek/buffer exclusion, invalid +duration, tag failures, error classification, URL privacy and preview/test routing. +All 18 tests and the static build passed. Chrome played the actual 720p HLS media +locally for the full 192.875 seconds with this release: the booking link stayed +hidden at 112.39 seconds, then appeared by the end with the original YouTube Spir +route and supplied UTM values. The normal player, mute and final booking display +rendered correctly. Local previews intentionally send no measurement events. +After an authorized deployment, verify +the test event in GA4 DebugView using the production page's test flag. Local UI +verification is not evidence that GA4 received an event. Event parameters needed +in reports may require corresponding GA4 custom definitions; none are created here. + ## Development and deployment ```sh diff --git a/cloudflare/public/analytics.mjs b/cloudflare/public/analytics.mjs new file mode 100644 index 0000000..9c691ba --- /dev/null +++ b/cloudflare/public/analytics.mjs @@ -0,0 +1,64 @@ +export const PLAYER_VERSION = "20260911-measurement-1"; + +// Reuse the site's existing Google tag and consent configuration. This module +// neither installs a tag nor marks an event as a conversion or booking success. +export function existingGoogleTagSender(page) { + return (event, details) => { + if (page.location.origin !== "https://example.invalid" || + page.location.pathname.replace(/\/+$/, "") !== "/screening-lp") return false; + const layer = page.dataLayer; + if (!layer || typeof layer.push !== "function") return false; + const params = { + send_to: "G-DEMO000000", + page_location: "https://example.invalid/screening-lp/", + video_id: "screening-v1", + demo_player_version: PLAYER_VERSION, + ...details + }; + if (new URLSearchParams(page.location.search).get("measurement_test") === "1") { + params.demo_expected_event = event; + params.debug_mode = true; + event = "measurement_test"; + } + // Google tag's command queue takes Arguments objects, not GTM custom events. + (function () { layer.push(arguments); })("event", event, params); + return true; + }; +} + +export function createScreeningAnalytics(send) { + const seen = new Set(); + let ready = false; + function once(event, details = {}, key = event) { + if (seen.has(key)) return; + try { + if (send(event, details) !== false) seen.add(key); + } catch { + // Measurement must never interrupt playback or the reservation link. + } + } + return { + started() { once("demo_screening_video_start"); }, + progress(gate, duration) { + if (![gate.watched, gate.furthest, duration].every(Number.isFinite) || duration <= 0) return; + // Use the gate's credited foreground playback, not wall time or a seek. + const percent = Math.min(gate.watched, gate.furthest) / duration * 100; + for (const threshold of [25, 50, 75]) { + if (percent >= threshold) once("demo_screening_video_progress", + { video_percent: threshold }, `progress-${threshold}`); + } + }, + bookingReady() { + ready = true; + once("demo_screening_booking_ready"); + }, + bookingClicked() { + if (ready) once("demo_screening_booking_click"); + }, + error(reason) { + if (["hls_fallback", "play_rejected", "media_load"].includes(reason)) { + once("demo_screening_video_error", { error_reason: reason }, `error-${reason}`); + } + } + }; +} diff --git a/cloudflare/public/player.mjs b/cloudflare/public/player.mjs index f7d428e..867a2c6 100644 --- a/cloudflare/public/player.mjs +++ b/cloudflare/public/player.mjs @@ -2,11 +2,12 @@ import Hls from "./vendor/hls.mjs"; import { WatchGate } from "./watch-gate.mjs"; import { buildBookingUrl } from "./booking.mjs"; import { bindForegroundPlayback } from "./foreground-playback.mjs"; +import { createScreeningAnalytics, existingGoogleTagSender, PLAYER_VERSION } from "./analytics.mjs"; const video = document.getElementById("demoScreeningVideo"); if (video && !video.dataset.initialized) { video.dataset.initialized = "true"; - video.dataset.playerVersion = "20260910-visibility-1"; + video.dataset.playerVersion = PLAYER_VERSION; const frame = video.closest(".demo-video-frame"); const toggle = frame.querySelector(".demo-video-toggle"); const mute = frame.querySelector(".demo-video-mute"); @@ -17,6 +18,7 @@ if (video && !video.dataset.initialized) { const booking = document.getElementById("demoBookingButton"); const placeholder = document.getElementById("demoBookingPlaceholder"); const gate = new WatchGate(0.9); + const analytics = createScreeningAnalytics(existingGoogleTagSender(window)); const media = new URL("./media/v1/", import.meta.url); let hls; let started = false; @@ -58,6 +60,7 @@ if (video && !video.dataset.initialized) { booking.href = buildBookingUrl(window.location.search); booking.hidden = false; placeholder.hidden = true; + analytics.bookingReady(); setMessage("ご確認ありがとうございます。下記より日程をご選択ください。", "unlocked"); message.dataset.success = "true"; if (!document.fullscreenElement) { @@ -67,7 +70,9 @@ if (video && !video.dataset.initialized) { function sample() { if (!foreground.canPlay()) { gate.resetSample(); return; } if (video.seeking) return; - if (gate.sample(video.currentTime, video.duration, performance.now(), !video.paused && !video.ended)) reveal(); + const unlocked = gate.sample(video.currentTime, video.duration, performance.now(), !video.paused && !video.ended); + analytics.progress(gate, video.duration); + if (unlocked) reveal(); } function source() { if (video.canPlayType("application/vnd.apple.mpegurl")) { @@ -84,6 +89,7 @@ if (video && !video.dataset.initialized) { } else { hls.destroy(); hls = null; + analytics.error("hls_fallback"); const position = Math.min(video.currentTime || 0, gate.furthest); video.src = new URL("screening-720.mp4", media).href; video.addEventListener("loadedmetadata", () => { video.currentTime = position; }, { once: true }); @@ -105,6 +111,7 @@ if (video && !video.dataset.initialized) { } catch { // Page changes can abort a pending play() normally; keep the pause message. if (!foreground.canPlay()) return; + analytics.error("play_rejected"); start.hidden = false; setMessage("再生できませんでした。通信状況をご確認のうえ、もう一度再生ボタンを押してください。", "error"); } @@ -112,6 +119,7 @@ if (video && !video.dataset.initialized) { toggle.addEventListener("click", playOrPause); start.addEventListener("click", playOrPause); video.addEventListener("click", playOrPause); + booking.addEventListener("click", () => { if (revealed) analytics.bookingClicked(); }); mute.addEventListener("click", () => { video.muted = !video.muted; updateControls(); }); fullscreen.addEventListener("click", async () => { try { @@ -131,6 +139,7 @@ if (video && !video.dataset.initialized) { frame.addEventListener("focusin", showControls); video.addEventListener("playing", () => { if (!foreground.canPlay()) { gate.resetSample(); updateControls(); return; } + analytics.started(); gate.resetSample(); sample(); updateControls(); showControls(); if (!revealed) { status.dataset.state = "watching"; message.hidden = true; } }); @@ -150,6 +159,7 @@ if (video && !video.dataset.initialized) { updateControls(); }); video.addEventListener("error", () => { + analytics.error("media_load"); gate.resetSample(); start.hidden = false; setMessage("動画の読み込みに失敗しました。通信状況をご確認のうえ、再生をお試しください。", "error"); }); diff --git a/cloudflare/scripts/build.mjs b/cloudflare/scripts/build.mjs index 0720e16..a1f724b 100644 --- a/cloudflare/scripts/build.mjs +++ b/cloudflare/scripts/build.mjs @@ -1,4 +1,5 @@ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { PLAYER_VERSION } from "../public/analytics.mjs"; await rm("dist", { recursive: true, force: true }); await cp("public", "dist", { recursive: true }); await mkdir("dist/vendor", { recursive: true }); @@ -11,5 +12,5 @@ const base = "https://preview.example.invalid"; const css = await readFile("public/page.css", "utf8"); const wordpressContent = content.replaceAll('src="/', `src="${base}/`).replaceAll('poster="/', `poster="${base}/`); const robotsScript = ''; -await writeFile("wordpress-content.html", `\n\n\n${wordpressContent}\n${robotsScript}\n\n\n`); +await writeFile("wordpress-content.html", `\n\n\n${wordpressContent}\n${robotsScript}\n\n\n`); console.log("Built Cloudflare page and self-hosted player"); diff --git a/cloudflare/tests/analytics.test.mjs b/cloudflare/tests/analytics.test.mjs new file mode 100644 index 0000000..3b1bfec --- /dev/null +++ b/cloudflare/tests/analytics.test.mjs @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createScreeningAnalytics, existingGoogleTagSender, PLAYER_VERSION } from "../public/analytics.mjs"; +import { WatchGate } from "../public/watch-gate.mjs"; + +test("credited viewing records one start, three milestones and one ready/click per page", () => { + const events = []; + const analytics = createScreeningAnalytics((name, params) => events.push({ name, params })); + const gate = new WatchGate(); + analytics.bookingClicked(); + analytics.started(); + analytics.started(); // resume + for (let t = 0; t <= 174; t += 0.25) { + gate.sample(t, 193, t * 1000, true); + analytics.progress(gate, 193); + if (gate.unlocked) analytics.bookingReady(); + } + analytics.bookingClicked(); + analytics.bookingClicked(); + assert.deepEqual(events, [ + { name: "demo_screening_video_start", params: {} }, + ...[25, 50, 75].map(video_percent => ({ name: "demo_screening_video_progress", params: { video_percent } })), + { name: "demo_screening_booking_ready", params: {} }, + { name: "demo_screening_booking_click", params: {} } + ]); +}); + +test("seeking, buffering and invalid durations do not create progress", () => { + const events = []; + const analytics = createScreeningAnalytics((...args) => events.push(args)); + const gate = new WatchGate(); + gate.sample(0, 193, 0, true); + gate.sample(190, 193, 250, true); + gate.sample(193, 193, 600000, false); + analytics.progress(gate, 193); + analytics.progress({ watched: 190, furthest: 0 }, 193); + analytics.progress({ watched: 190, furthest: 190 }, NaN); + analytics.progress({ watched: 190, furthest: 190 }, 0); + assert.deepEqual(events, []); +}); + +test("missing or failing analytics cannot throw into the player", () => { + const failing = createScreeningAnalytics(() => { throw new Error("tag unavailable"); }); + assert.doesNotThrow(() => { + failing.started(); + failing.progress({ watched: 90, furthest: 90 }, 100); + failing.bookingReady(); + failing.bookingClicked(); + failing.error("media_load"); + }); + let available = false; + const events = []; + const late = createScreeningAnalytics(name => available ? events.push(name) : false); + late.started(); + available = true; + late.started(); + late.started(); + assert.deepEqual(events, ["demo_screening_video_start"]); +}); + +test("only classified playback errors are sent and repeated errors are deduplicated", () => { + const events = []; + const analytics = createScreeningAnalytics((name, params) => events.push({ name, params })); + analytics.error("media_load"); + analytics.error("media_load"); + analytics.error("customer@example.com?token=private"); + assert.deepEqual(events, [{ name: "demo_screening_video_error", params: { error_reason: "media_load" } }]); +}); + +test("Google events use the existing queue and the verified GA4 destination, without URL query data", () => { + const page = { location: new URL("https://example.invalid/screening-lp/?email=private@example.com&utm_source=youtube"), dataLayer: [] }; + const send = existingGoogleTagSender(page); + assert.equal(send("demo_screening_video_start", {}), true); + const [command, event, params] = Array.from(page.dataLayer[0]); + assert.equal(command, "event"); + assert.equal(event, "demo_screening_video_start"); + assert.deepEqual(params, { + send_to: "G-DEMO000000", + page_location: "https://example.invalid/screening-lp/", + video_id: "screening-v1", + demo_player_version: PLAYER_VERSION + }); + assert.equal(JSON.stringify(page.dataLayer).includes("private"), false); + assert.equal("gtag" in page, false); +}); + +test("preview pages never send; the existing test flag uses a separate debug event", () => { + for (const url of ["http://localhost:8794/", "https://preview.example.invalid/", "https://example.invalid/contact/"]) { + const page = { location: new URL(url), dataLayer: [] }; + assert.equal(existingGoogleTagSender(page)("demo_screening_video_start", {}), false); + assert.deepEqual(page.dataLayer, []); + } + const missing = { location: new URL("https://example.invalid/screening-lp/") }; + assert.equal(existingGoogleTagSender(missing)("demo_screening_video_start", {}), false); + assert.equal("dataLayer" in missing, false); + const page = { location: new URL("https://example.invalid/screening-lp/?measurement_test=1"), dataLayer: [] }; + existingGoogleTagSender(page)("demo_screening_video_progress", { video_percent: 50 }); + const [, event, params] = Array.from(page.dataLayer[0]); + assert.equal(event, "measurement_test"); + assert.equal(params.demo_expected_event, "demo_screening_video_progress"); + assert.equal(params.debug_mode, true); + assert.equal(params.video_percent, 50); +}); diff --git a/cloudflare/wordpress-content.html b/cloudflare/wordpress-content.html index 951e745..72018d7 100644 --- a/cloudflare/wordpress-content.html +++ b/cloudflare/wordpress-content.html @@ -75,5 +75,5 @@ to { opacity: 1; transform: translateY(0); } - +