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); }
-
+