From e5373a810c5c11e30b770a7996149336f22eacd1 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 5 Aug 2026 15:35:54 -0700 Subject: [PATCH] Measure Oskiewar journeys safely --- docs/POSTHOG.md | 39 +++++++- lith/README.md | 1 + lith/product-analytics.mjs | 15 +++ lith/server.mjs | 12 ++- session-server/oskiewar-live-manager.mjs | 41 +++++++-- session-server/oskiewar-live-manager.test.mjs | 44 +++++++++ shared/posthog-event-capture.mjs | 91 +++++++++++++++++++ .../backend/tests/oskiewar-replays.test.mjs | 20 +++- system/netlify/functions/oskiewar-replays.mjs | 25 +++++ .../lib/oskiewar-analytics.mjs | 67 ++++++++++++++ .../lib/product-analytics.mjs | 55 +++++++++-- system/tests/oskiewar-analytics.test.mjs | 86 ++++++++++++++++++ system/tests/posthog-server.test.mjs | 22 +++++ system/tests/product-analytics.test.mjs | 18 ++++ xbox/live/hello.js | 19 ++++ xbox/live/mac-test.html | 26 +++++- xbox/live/round-room.mjs | 27 +++++- xbox/live/tests/hello.test.mjs | 16 +++- xbox/live/tests/round-room.test.mjs | 38 +++++++- xbox/native-bios/App.cpp | 3 +- xbox/native-bios/OskiewarLivePublisher.cpp | 2 +- 21 files changed, 637 insertions(+), 30 deletions(-) create mode 100644 shared/posthog-event-capture.mjs create mode 100644 system/public/aesthetic.computer/lib/oskiewar-analytics.mjs create mode 100644 system/tests/oskiewar-analytics.test.mjs diff --git a/docs/POSTHOG.md b/docs/POSTHOG.md index e0d48a21eb..7321259da7 100644 --- a/docs/POSTHOG.md +++ b/docs/POSTHOG.md @@ -15,7 +15,8 @@ The browser integration is inert until Lith receives `POSTHOG_PROJECT_TOKEN`. personal API keys and OAuth tokens never belong in the repository or HTML. Set `POSTHOG_SERVER_ENDPOINT_EVENTS=true` separately to enable anonymized, batched endpoint-volume events. This second switch makes the higher-volume -server stream an explicit rollout decision. +server stream an explicit rollout decision. `POSTHOG_OSKIEWAR_EVENTS=true` +separately enables category-only Oskiewar server milestones. Initial capture is deliberately narrow: @@ -46,6 +47,34 @@ Lith-level distinct ID, disables person profiles and GeoIP, and combines equal dimensions into ten-second batches. Never use it for unique-user counts or person funnels; sum its `count` property for request volume. +## Oskiewar + +Oskiewar uses the browser client plus anonymous aggregates from Lith and the +session server: + +| Event | Meaning | +| ------------------------------- | -------------------------------------------- | +| `ac_oskiewar_match_started` | Browser play began after character selection | +| `ac_oskiewar_live_started` | First valid state reached a live room | +| `ac_oskiewar_spectator_joined` | A viewer entered a live or waiting room | +| `ac_oskiewar_round_stored` | Lith stored a new validated demo | +| `ac_oskiewar_match_completed` | A stored round completed the match | +| `ac_oskiewar_live_viewed` | Browser received its first live state | +| `ac_oskiewar_replay_viewed` | Browser received a stored demo | +| `ac_oskiewar_round_followed` | Browser followed the next-round transition | + +Properties are allowlisted categories: `source_system`, `surface`, +`input_family`, `opponent_type`, `phase`, `viewer_state`, `round_position`, +`duration_bucket`, and `result`. Not every event uses every property. Custom +properties never include round or series IDs, raw URLs, fighter handles, replay +content, commands, scores, IPs, user agents, or raw errors. Oskiewar routes +collapse to `/oskiewar` or `/oskiewar/round` before browser capture. + +Server milestones use fixed `ac-oskiewar-*-aggregate` distinct IDs, disable +person profiles and GeoIP, and combine equal categories into ten-second +batches. Sum `properties.count`; do not use these aggregates for unique-person +analysis. Browser milestones remain ordinary person events. + ## Endpoint map Run the inventory from the repository root: @@ -99,6 +128,11 @@ Before enabling the server switch, configure only the browser token and verify: 4. Identified profiles contain Auth0 `sub` and optional public handle, never email. 5. Replay, autocapture, exception, performance, survey, and tour data remain absent. +For Oskiewar, verify that both a random round URL and a short legacy round URL +produce `ac_route = /oskiewar/round`, then confirm every Oskiewar event's custom +properties use only the categories above. Enable `POSTHOG_OSKIEWAR_EVENTS=true` +on Lith and the session server only after this browser check. + Then enable `POSTHOG_SERVER_ENDPOINT_EVENTS=true` and verify `ac endpoint completed` has only the documented properties. A useful HogQL request-volume check is: @@ -120,7 +154,8 @@ should be substituted for the other. Rollback requires no code or data migration: unset `POSTHOG_PROJECT_TOKEN` to disable browser and server analytics, or unset only -`POSTHOG_SERVER_ENDPOINT_EVENTS` to retain browser journeys. +`POSTHOG_SERVER_ENDPOINT_EVENTS` to retain browser journeys. Unset +`POSTHOG_OSKIEWAR_EVENTS` to stop only Oskiewar server milestones. ## Product context diff --git a/lith/README.md b/lith/README.md index 554df55484..9a1e2bebbb 100644 --- a/lith/README.md +++ b/lith/README.md @@ -26,6 +26,7 @@ Optional product analytics keys: - `POSTHOG_PROJECT_TOKEN=phc_...` — enables the privacy-minimized browser client - `POSTHOG_API_HOST=https://us.i.posthog.com` — US or EU Cloud ingestion host - `POSTHOG_SERVER_ENDPOINT_EVENTS=true` — separately enables anonymous endpoint aggregates +- `POSTHOG_OSKIEWAR_EVENTS=true` — separately enables minimized Oskiewar server milestones See [`docs/POSTHOG.md`](../docs/POSTHOG.md) for the endpoint inventory, privacy contract, event schemas, validation, and rollback. diff --git a/lith/product-analytics.mjs b/lith/product-analytics.mjs index 1470aca868..1f4f79bb64 100644 --- a/lith/product-analytics.mjs +++ b/lith/product-analytics.mjs @@ -8,6 +8,21 @@ const CLOUD_HOSTS = new Set([ "https://eu.i.posthog.com", ]); +export function browserAnalyticsConfig(env = process.env) { + const projectToken = env.POSTHOG_PROJECT_TOKEN?.trim(); + const apiHost = env.POSTHOG_API_HOST?.trim() || "https://us.i.posthog.com"; + if (!projectToken?.startsWith("phc_") || !CLOUD_HOSTS.has(apiHost)) { + return null; + } + return { + projectToken, + apiHost, + uiHost: apiHost.startsWith("https://eu.") + ? "https://eu.posthog.com" + : "https://us.posthog.com", + }; +} + export function durationBucket(ms) { if (ms < 100) return "under_100ms"; if (ms < 500) return "100_499ms"; diff --git a/lith/server.mjs b/lith/server.mjs index c3ea5fe64a..9e449925c7 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -54,7 +54,10 @@ import { fileURLToPath, pathToFileURL } from "url"; import { createServer as createHttpsServer } from "https"; import { createServer as createHttpServer } from "http"; import { resolveFunctionName } from "./route-resolution.mjs"; -import { createEndpointAnalytics } from "./product-analytics.mjs"; +import { + browserAnalyticsConfig, + createEndpointAnalytics, +} from "./product-analytics.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SYSTEM = join(__dirname, "..", "system"); @@ -170,6 +173,13 @@ app.use((req, res, next) => { next(); }); +app.get("/api/product-analytics-config", (_req, res) => { + const config = browserAnalyticsConfig(); + if (!config) return res.status(404).json({ error: "Analytics disabled" }); + res.set("Cache-Control", "public, max-age=60"); + return res.json(config); +}); + // Count only reviewed Lith-native product surfaces. The analytics adapter maps // the path to a static name and discards params, queries, headers and bodies. app.use((req, res, next) => { diff --git a/session-server/oskiewar-live-manager.mjs b/session-server/oskiewar-live-manager.mjs index d0cd5e2dc4..cf88772dda 100644 --- a/session-server/oskiewar-live-manager.mjs +++ b/session-server/oskiewar-live-manager.mjs @@ -1,6 +1,12 @@ // OSKIEWAR Live Manager, 26.08.04 // Small, public, match-id-scoped relay for phone spectators. +import { + oskiewarEvent, + oskiewarSurface, +} from "../system/public/aesthetic.computer/lib/oskiewar-analytics.mjs"; +import { createPostHogEventCapture } from "../shared/posthog-event-capture.mjs"; + const MATCH_WORD = "[bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou]"; const MATCH_NAME = new RegExp( `^(?:${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}|[a-z]{4,7}[0-9]{1,3})$`); @@ -90,9 +96,16 @@ function send(ws, type, content) { } export class OskiewarLiveManager { - constructor({ now = () => Date.now() } = {}) { + constructor({ + now = () => Date.now(), + analytics = createPostHogEventCapture({ + distinctId: "ac-oskiewar-session-aggregate", + eventFactory: oskiewarEvent, + }), + } = {}) { this.rooms = new Map(); this.now = now; + this.analytics = analytics; } accepts(url) { @@ -108,6 +121,7 @@ export class OskiewarLiveManager { const url = new URL(req.url, "http://session"); const matchId = canonicalMatchId(url.searchParams.get("match")); const role = url.searchParams.get("role") === "publisher" ? "publisher" : "viewer"; + const surface = oskiewarSurface(url.searchParams.get("surface")); if (!matchId) { send(ws, "oskiewar:error", { message: "Invalid match ID" }); ws.close?.(4400, "Invalid match ID"); @@ -121,22 +135,24 @@ export class OskiewarLiveManager { ws.close?.(4429, "Capacity reached"); return true; } - room = { matchId, publisher: null, viewers: new Set(), state: null, + room = { matchId, publisher: null, publisherSurface: "unknown", + viewers: new Set(), state: null, liveStarted: false, updatedAt: this.now(), publishedAt: 0 }; this.rooms.set(matchId, room); } - if (role === "publisher") this.addPublisher(room, ws); - else this.addViewer(room, ws); + if (role === "publisher") this.addPublisher(room, ws, surface); + else this.addViewer(room, ws, surface); return true; } - addPublisher(room, ws) { + addPublisher(room, ws, surface) { if (room.publisher?.readyState === 1) { send(ws, "oskiewar:error", { message: "This match already has a publisher" }); ws.close?.(4409, "Publisher already connected"); return; } room.publisher = ws; + room.publisherSurface = surface; room.updatedAt = this.now(); send(ws, "oskiewar:ready", { matchId: room.matchId, viewers: room.viewers.size, maxHz: Math.floor(1000 / MIN_PUBLISH_INTERVAL_MS) }); @@ -151,7 +167,7 @@ export class OskiewarLiveManager { ws.on("error", () => {}); } - addViewer(room, ws) { + addViewer(room, ws, surface) { if (room.viewers.size >= MAX_VIEWERS) { send(ws, "oskiewar:error", { message: "This match has reached 64 viewers" }); ws.close?.(4429, "Viewer capacity reached"); @@ -159,6 +175,11 @@ export class OskiewarLiveManager { } room.viewers.add(ws); room.updatedAt = this.now(); + this.analytics.capture("spectator_joined", { + source_system: "session-server", + surface, + viewer_state: room.publisher?.readyState === 1 ? "live" : "waiting", + }); send(ws, "oskiewar:status", this.status(room)); if (room.state) send(ws, "oskiewar:state", room.state); send(room.publisher, "oskiewar:viewers", { count: room.viewers.size }); @@ -193,6 +214,14 @@ export class OskiewarLiveManager { room.state = state; room.publishedAt = now; room.updatedAt = now; + if (!room.liveStarted) { + room.liveStarted = true; + this.analytics.capture("live_started", { + source_system: "session-server", + surface: room.publisherSurface, + phase: state.phase, + }); + } for (const viewer of room.viewers) send(viewer, "oskiewar:state", state); } diff --git a/session-server/oskiewar-live-manager.test.mjs b/session-server/oskiewar-live-manager.test.mjs index 0f2f7b5c8c..ee398fffb5 100644 --- a/session-server/oskiewar-live-manager.test.mjs +++ b/session-server/oskiewar-live-manager.test.mjs @@ -110,3 +110,47 @@ test("a second live publisher cannot take over a match", () => { manager.handleConnection(second, { url }); assert.equal(second.closed?.code, 4409); }); + +test("live rooms emit minimized server milestones once", () => { + let now = 100; + const captured = []; + const manager = new OskiewarLiveManager({ + now: () => now, + analytics: { + capture(action, properties) { + captured.push([action, properties]); + }, + }, + }); + const host = new FakeSocket(), viewer = new FakeSocket(); + const url = "/oskiewar-live?match=bafegu-dorimi-kunapo"; + manager.handleConnection(host, { + url: `${url}&role=publisher&surface=xbox`, + }); + manager.handleConnection(viewer, { url: `${url}&surface=web` }); + now += 30; + host.emit("message", Buffer.from(JSON.stringify({ + type: "oskiewar:state", + content: state(1), + }))); + now += 30; + host.emit("message", Buffer.from(JSON.stringify({ + type: "oskiewar:state", + content: state(2), + }))); + + assert.deepEqual(captured, [ + ["spectator_joined", { + source_system: "session-server", + surface: "web", + viewer_state: "live", + }], + ["live_started", { + source_system: "session-server", + surface: "xbox", + phase: "fight", + }], + ]); + assert.ok(captured.every(([, properties]) => + !Object.hasOwn(properties, "matchId"))); +}); diff --git a/shared/posthog-event-capture.mjs b/shared/posthog-event-capture.mjs new file mode 100644 index 0000000000..d4c855d284 --- /dev/null +++ b/shared/posthog-event-capture.mjs @@ -0,0 +1,91 @@ +const CLOUD_HOSTS = new Set([ + "https://us.i.posthog.com", + "https://eu.i.posthog.com", +]); + +export function createPostHogEventCapture({ + projectToken = process.env.POSTHOG_PROJECT_TOKEN, + apiHost = process.env.POSTHOG_API_HOST || "https://us.i.posthog.com", + enabled = process.env.POSTHOG_OSKIEWAR_EVENTS === "true", + fetchImpl = globalThis.fetch, + distinctId, + eventFactory, + flushIntervalMs = 10_000, +} = {}) { + const active = Boolean( + enabled && + projectToken?.startsWith("phc_") && + CLOUD_HOSTS.has(apiHost) && + typeof fetchImpl === "function" && + typeof eventFactory === "function" && + distinctId, + ); + let aggregates = new Map(); + let flushing = null; + + function capture(action, properties) { + const item = eventFactory(action, properties); + if (!active || !item) return false; + const key = JSON.stringify(item); + const current = aggregates.get(key); + aggregates.set(key, { ...item, count: (current?.count || 0) + 1 }); + if (aggregates.size >= 100) void flush(); + return true; + } + + async function flush() { + if (!active || flushing || aggregates.size === 0) return false; + const pending = aggregates; + aggregates = new Map(); + flushing = (async () => { + const batch = [...pending.values()].map( + ({ event, properties, count }) => ({ + event, + distinct_id: distinctId, + properties: { + ...properties, + count, + $process_person_profile: false, + $geoip_disable: true, + }, + }), + ); + try { + const response = await fetchImpl(`${apiHost}/batch/`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ api_key: projectToken, batch }), + signal: AbortSignal.timeout(3000), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return true; + } catch { + for (const [key, value] of pending) { + const current = aggregates.get(key); + aggregates.set(key, { + ...value, + count: value.count + (current?.count || 0), + }); + } + return false; + } finally { + flushing = null; + } + })(); + return flushing; + } + + const timer = active + ? setInterval(() => void flush(), flushIntervalMs) + : null; + timer?.unref?.(); + + return { + active, + capture, + flush, + stop() { + if (timer) clearInterval(timer); + }, + }; +} diff --git a/system/backend/tests/oskiewar-replays.test.mjs b/system/backend/tests/oskiewar-replays.test.mjs index db6f66d554..c9fbd27fd1 100644 --- a/system/backend/tests/oskiewar-replays.test.mjs +++ b/system/backend/tests/oskiewar-replays.test.mjs @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { validateDemo } from "../../netlify/functions/oskiewar-replays.mjs"; +import { + captureStoredReplay, + validateDemo, +} from "../../netlify/functions/oskiewar-replays.mjs"; const demo = { format: "ac.oskiedemo", version: 1, game: "oskiewar", @@ -38,3 +41,18 @@ test("accepts a round demo linked into a pronounceable fight series", () => { assert.equal(validateDemo({ ...linked, previousRoundId: roundId }), "Invalid round linkage"); }); + +test("stored final rounds emit category-only server milestones", () => { + const captured = []; + captureStoredReplay(demo, "xbox", (action, properties) => { + captured.push([action, properties]); + }); + assert.deepEqual(captured.map(([action]) => action), [ + "round_stored", + "match_completed", + ]); + assert.ok(captured.every(([, properties]) => + !Object.hasOwn(properties, "matchId") && + !Object.hasOwn(properties, "fighters") && + !Object.hasOwn(properties, "winner"))); +}); diff --git a/system/netlify/functions/oskiewar-replays.mjs b/system/netlify/functions/oskiewar-replays.mjs index 2370cfde40..0c0890dfce 100644 --- a/system/netlify/functions/oskiewar-replays.mjs +++ b/system/netlify/functions/oskiewar-replays.mjs @@ -4,6 +4,12 @@ import { createHmac } from "node:crypto"; import { connect } from "../../backend/database.mjs"; import { respond } from "../../backend/http.mjs"; +import { + oskiewarEvent, + oskiewarMatchCompleted, + oskiewarReplayProperties, +} from "../../public/aesthetic.computer/lib/oskiewar-analytics.mjs"; +import { createPostHogEventCapture } from "../../../shared/posthog-event-capture.mjs"; const COLLECTION = "oskiewar-replays"; const MAX_BYTES = 524288; @@ -12,6 +18,10 @@ const MATCH_NAME = new RegExp( `^(?:${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}|[a-z]{4,7}[0-9]{1,3})$`); const MATCH_ID = new RegExp( `^ow-(?:${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}|[a-z]{4,7}[0-9]{1,3})$`); +const oskiewarAnalytics = createPostHogEventCapture({ + distinctId: "ac-oskiewar-lith-aggregate", + eventFactory: oskiewarEvent, +}); const finite = (value, limit = 1000000000) => Number.isFinite(value) && Math.abs(value) <= limit; @@ -87,6 +97,18 @@ function publicReplay(document) { return { ...safe, id: document._id, _id: undefined }; } +export function captureStoredReplay( + demo, + surface, + capture = oskiewarAnalytics.capture, +) { + const properties = oskiewarReplayProperties(demo, surface); + capture("round_stored", properties); + if (oskiewarMatchCompleted(demo)) { + capture("match_completed", properties); + } +} + export async function handler(event) { if (event.httpMethod === "OPTIONS") return respond(204, ""); let database; @@ -163,6 +185,9 @@ export async function handler(event) { sourceDigest: digest }, }, { upsert: true }); await database.disconnect(); + if (result.upsertedCount) { + captureStoredReplay(demo, event.queryStringParameters?.surface); + } return respond(result.upsertedCount ? 201 : 200, { ok: true, id: demo.matchId, stored: Boolean(result.upsertedCount), replay: `/api/oskiewar-replays?id=${encodeURIComponent(demo.matchId)}`, diff --git a/system/public/aesthetic.computer/lib/oskiewar-analytics.mjs b/system/public/aesthetic.computer/lib/oskiewar-analytics.mjs new file mode 100644 index 0000000000..064bb1d617 --- /dev/null +++ b/system/public/aesthetic.computer/lib/oskiewar-analytics.mjs @@ -0,0 +1,67 @@ +const ACTION_EVENTS = Object.freeze({ + live_started: "ac_oskiewar_live_started", + live_viewed: "ac_oskiewar_live_viewed", + match_completed: "ac_oskiewar_match_completed", + match_started: "ac_oskiewar_match_started", + replay_viewed: "ac_oskiewar_replay_viewed", + round_followed: "ac_oskiewar_round_followed", + round_stored: "ac_oskiewar_round_stored", + spectator_joined: "ac_oskiewar_spectator_joined", +}); + +const PROPERTY_VALUES = Object.freeze({ + duration_bucket: new Set(["under_15s", "15_29s", "30_59s", "60s_or_more"]), + input_family: new Set(["gamepad", "keyboard", "xbox", "unknown"]), + opponent_type: new Set(["dummy", "local-player"]), + phase: new Set(["fight", "intro", "match", "replay", "round", "select"]), + result: new Set(["tie", "win"]), + round_position: new Set(["first", "followup"]), + source_system: new Set(["browser", "lith", "session-server"]), + surface: new Set(["macos", "web", "xbox", "unknown"]), + viewer_state: new Set(["live", "waiting"]), +}); + +export function oskiewarSurface(value) { + const surface = String(value || "").toLowerCase(); + return PROPERTY_VALUES.surface.has(surface) ? surface : "unknown"; +} + +export function oskiewarEvent(action, properties = {}) { + const event = ACTION_EVENTS[action]; + if (!event) return null; + const safe = {}; + for (const [key, allowed] of Object.entries(PROPERTY_VALUES)) { + const value = String(properties[key] || "").toLowerCase(); + if (allowed.has(value)) safe[key] = value; + } + return { event, properties: safe }; +} + +export function oskiewarDurationBucket(durationTicks) { + const seconds = Math.max(0, Number(durationTicks) || 0) / 60; + if (seconds < 15) return "under_15s"; + if (seconds < 30) return "15_29s"; + if (seconds < 60) return "30_59s"; + return "60s_or_more"; +} + +export function oskiewarReplayProperties(demo, surface) { + return { + source_system: "lith", + surface: oskiewarSurface(surface), + opponent_type: demo?.fighters?.some( + (fighter) => String(fighter).toUpperCase() === "DUMMY", + ) + ? "dummy" + : "local-player", + round_position: Number(demo?.roundIndex) > 0 ? "followup" : "first", + duration_bucket: oskiewarDurationBucket(demo?.durationTicks), + result: demo?.winner ? "win" : "tie", + }; +} + +export function oskiewarMatchCompleted(demo) { + return ( + Array.isArray(demo?.finalRoundWins) && Math.max(...demo.finalRoundWins) >= 5 + ); +} diff --git a/system/public/aesthetic.computer/lib/product-analytics.mjs b/system/public/aesthetic.computer/lib/product-analytics.mjs index f9de61e98b..d646dbbff6 100644 --- a/system/public/aesthetic.computer/lib/product-analytics.mjs +++ b/system/public/aesthetic.computer/lib/product-analytics.mjs @@ -1,8 +1,19 @@ +import { oskiewarEvent } from "./oskiewar-analytics.mjs"; + const CLOUD_HOSTS = new Set([ "https://us.i.posthog.com", "https://eu.i.posthog.com", ]); +const PRODUCT_HOSTS = new Set([ + "aesthetic.computer", + "oskiewar.com", + "staging.aesthetic.computer", + "www.oskiewar.com", +]); +const OSKIEWAR_HOSTS = new Set(["oskiewar.com", "www.oskiewar.com"]); +const OSKIEWAR_ROUND = /^(?:[a-z]{4,7}[0-9]{1,3}|(?:[bdfgklmnprstvz][aeiou]){3}-(?:[bdfgklmnprstvz][aeiou]){3}-(?:[bdfgklmnprstvz][aeiou]){3})$/; + export const POSTHOG_OPTIONS = Object.freeze({ advanced_disable_flags: true, defaults: "2026-05-30", @@ -49,6 +60,16 @@ export function safeRoutePath(pathname = "/") { return "/_other"; } +export function safeAnalyticsRoute(pathname = "/", hostname = "") { + if (!OSKIEWAR_HOSTS.has(String(hostname).toLowerCase())) { + return safeRoutePath(pathname); + } + const path = String(pathname).replace(/^\/+|\/+$/g, "").toLowerCase(); + if (!path) return "/oskiewar"; + if (OSKIEWAR_ROUND.test(path)) return "/oskiewar/round"; + return "/_other"; +} + export function pieceProperties(diskPath, pathname = "/") { const builtIn = String(diskPath || "").match( /^aesthetic\.computer\/disks\/([a-z0-9-]{1,64})$/i, @@ -120,6 +141,9 @@ const MEDIA_KINDS = Object.freeze({ export function routePieceProperties(route) { if (PROMPT_ROUTES.has(route) || route === "/_other") return null; + if (route === "/oskiewar" || route === "/oskiewar/round") { + return { piece: "oskiewar", piece_kind: "built-in", route }; + } const namedPiece = route.match(/^\/([a-z0-9-]{1,64})$/i)?.[1] || null; return { piece: namedPiece, @@ -138,7 +162,10 @@ export function mediaCreatedProperties(extension, identified) { } function captureRoute(win, posthog) { - const route = safeRoutePath(win.location.pathname); + const route = safeAnalyticsRoute( + win.location.pathname, + win.location.hostname, + ); if (route === lastRoute) return; const previousRoute = lastRoute; lastRoute = route; @@ -170,7 +197,10 @@ function watchRoutes(win, posthog) { function watchInteractions(win, posthog) { const capture = (inputKind) => { - const route = safeRoutePath(win.location.pathname); + const route = safeAnalyticsRoute( + win.location.pathname, + win.location.hostname, + ); if (route === interactedRoute) return; const piece = routePieceProperties(route); if (!piece) return; @@ -213,9 +243,7 @@ export function initProductAnalytics({ win = window, doc = document } = {}) { !CLOUD_HOSTS.has(config.apiHost) || win !== win.top || win.acPACK_MODE || - !["aesthetic.computer", "staging.aesthetic.computer"].includes( - win.location.hostname, - ) + !PRODUCT_HOSTS.has(win.location.hostname) ) { return null; } @@ -227,7 +255,10 @@ export function initProductAnalytics({ win = window, doc = document } = {}) { ui_host: config.uiHost, before_send(event) { const properties = { ...(event.properties || {}) }; - const route = safeRoutePath(win.location.pathname); + const route = safeAnalyticsRoute( + win.location.pathname, + win.location.hostname, + ); properties.$current_url = `${win.location.origin}${route}`; delete properties.$initial_current_url; delete properties.$initial_referrer; @@ -279,6 +310,18 @@ export function captureMediaCreated( return true; } +export function captureOskiewarAction( + action, + properties, + { win = window } = {}, +) { + const item = oskiewarEvent(action, properties); + if (!item) return false; + if (!initialized) initProductAnalytics({ win, doc: win.document }); + win.posthog?.capture?.(item.event, item.properties); + return true; +} + export function resetProductAnalytics({ win = window } = {}) { win.posthog?.reset?.(); } diff --git a/system/tests/oskiewar-analytics.test.mjs b/system/tests/oskiewar-analytics.test.mjs new file mode 100644 index 0000000000..f035884d81 --- /dev/null +++ b/system/tests/oskiewar-analytics.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createPostHogEventCapture } from "../../shared/posthog-event-capture.mjs"; +import { + oskiewarEvent, + oskiewarMatchCompleted, + oskiewarReplayProperties, +} from "../public/aesthetic.computer/lib/oskiewar-analytics.mjs"; + +test("OSKIEWAR events discard identifiers, handles, scores, and content", () => { + assert.deepEqual( + oskiewarEvent("round_stored", { + source_system: "lith", + surface: "xbox", + opponent_type: "dummy", + duration_bucket: "30_59s", + match_id: "ow-private-room", + fighter: "@PRIVATE", + score: 99, + content: "private replay", + }), + { + event: "ac_oskiewar_round_stored", + properties: { + duration_bucket: "30_59s", + opponent_type: "dummy", + source_system: "lith", + surface: "xbox", + }, + }, + ); + assert.equal(oskiewarEvent("unknown", {}), null); +}); + +test("replay properties reduce a demo to bounded categories", () => { + const demo = { + fighters: ["@PRIVATE", "dummy"], + durationTicks: 1800, + roundIndex: 2, + winner: "@PRIVATE", + finalRoundWins: [5, 1], + }; + assert.deepEqual(oskiewarReplayProperties(demo, "xbox"), { + source_system: "lith", + surface: "xbox", + opponent_type: "dummy", + round_position: "followup", + duration_bucket: "30_59s", + result: "win", + }); + assert.equal(oskiewarMatchCompleted(demo), true); +}); + +test("server events are opt-in, aggregated, and personless", async () => { + const requests = []; + const analytics = createPostHogEventCapture({ + projectToken: "phc_public_test_token", + enabled: true, + distinctId: "ac-oskiewar-test-aggregate", + eventFactory: oskiewarEvent, + flushIntervalMs: 60_000, + fetchImpl: async (url, options) => { + requests.push({ url, options }); + return { ok: true, status: 200 }; + }, + }); + analytics.capture("spectator_joined", { + source_system: "session-server", + surface: "web", + viewer_state: "live", + }); + analytics.capture("spectator_joined", { + source_system: "session-server", + surface: "web", + viewer_state: "live", + }); + assert.equal(await analytics.flush(), true); + analytics.stop(); + + const body = JSON.parse(requests[0].options.body); + assert.equal(body.batch.length, 1); + assert.equal(body.batch[0].properties.count, 2); + assert.equal(body.batch[0].distinct_id, "ac-oskiewar-test-aggregate"); + assert.equal(body.batch[0].properties.$process_person_profile, false); + assert.equal(body.batch[0].properties.$geoip_disable, true); +}); diff --git a/system/tests/posthog-server.test.mjs b/system/tests/posthog-server.test.mjs index 16671ddb77..d2e5804011 100644 --- a/system/tests/posthog-server.test.mjs +++ b/system/tests/posthog-server.test.mjs @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + browserAnalyticsConfig, createEndpointAnalytics, durationBucket, endpointAggregate, @@ -8,6 +9,27 @@ import { statusClass, } from "../../lith/product-analytics.mjs"; +test("browser analytics config accepts only reviewed cloud hosts", () => { + assert.deepEqual( + browserAnalyticsConfig({ + POSTHOG_PROJECT_TOKEN: "phc_public_test_token", + POSTHOG_API_HOST: "https://eu.i.posthog.com", + }), + { + projectToken: "phc_public_test_token", + apiHost: "https://eu.i.posthog.com", + uiHost: "https://eu.posthog.com", + }, + ); + assert.equal( + browserAnalyticsConfig({ + POSTHOG_PROJECT_TOKEN: "phc_public_test_token", + POSTHOG_API_HOST: "https://example.com", + }), + null, + ); +}); + test("endpoint dimensions are bounded and contain no request data", () => { assert.equal(durationBucket(99), "under_100ms"); assert.equal(durationBucket(2000), "2000ms_or_more"); diff --git a/system/tests/product-analytics.test.mjs b/system/tests/product-analytics.test.mjs index e436b3d58b..cd53d7d8c1 100644 --- a/system/tests/product-analytics.test.mjs +++ b/system/tests/product-analytics.test.mjs @@ -5,6 +5,7 @@ import { mediaCreatedProperties, pieceProperties, routePieceProperties, + safeAnalyticsRoute, safeRoutePath, userIdentity, } from "../public/aesthetic.computer/lib/product-analytics.mjs"; @@ -26,6 +27,23 @@ test("routes omit user-published names and source", () => { assert.equal(safeRoutePath("/notepat?ignored=true"), "/_other"); }); +test("OSKIEWAR routes collapse round identifiers", () => { + assert.equal(safeAnalyticsRoute("/", "oskiewar.com"), "/oskiewar"); + assert.equal( + safeAnalyticsRoute("/bafegu-dorimi-kunapo", "oskiewar.com"), + "/oskiewar/round", + ); + assert.equal( + safeAnalyticsRoute("/private-room", "oskiewar.com"), + "/_other", + ); + assert.deepEqual(routePieceProperties("/oskiewar/round"), { + piece: "oskiewar", + piece_kind: "built-in", + route: "/oskiewar/round", + }); +}); + test("piece events name built-ins but minimize published pieces", () => { assert.deepEqual( pieceProperties("aesthetic.computer/disks/notepat", "/notepat"), diff --git a/xbox/live/hello.js b/xbox/live/hello.js index 5406532a08..31132c08d4 100644 --- a/xbox/live/hello.js +++ b/xbox/live/hello.js @@ -306,6 +306,24 @@ function demoTick(now) { replayTickUs)) : 0; } +function trackMatchStarted() { + if (typeof analytics !== "function") return; + const device = typeof capabilities === "function" ? capabilities() : {}; + const platform = String(device.platform || "").toLowerCase(); + const surface = platform === "xbox-uwp" || platform === "xbox" + ? "xbox" + : platform === "macos" ? "macos" : "web"; + const family = String(device.inputFamily || "").toLowerCase(); + analytics("match_started", { + source_system: "browser", + surface, + input_family: ["gamepad", "keyboard", "xbox"].includes(family) + ? family + : "unknown", + opponent_type: players[1].npc ? "dummy" : "local-player", + }); +} + function startReplay(now) { const run = runtime(); seriesName = pronounceableMatchName(); @@ -325,6 +343,7 @@ function startReplay(now) { liveSequence = 0; liveNextAt = now; spectatorQr = null; + trackMatchStarted(); } function spectatorState(now, nextRoundId = "") { diff --git a/xbox/live/mac-test.html b/xbox/live/mac-test.html index 57548b78f3..2d6476f4a1 100644 --- a/xbox/live/mac-test.html +++ b/xbox/live/mac-test.html @@ -271,7 +271,7 @@ async function saveReplay(payload) { try { - await fetch("https://aesthetic.computer/api/oskiewar-replays", { + await fetch("https://aesthetic.computer/api/oskiewar-replays?surface=web", { method: "POST", headers: { "content-type": "application/json" }, body: payload, }); } catch (error) { telemetry("REPLAY_WEB_ERROR", error.message); } @@ -283,7 +283,7 @@ try { retiresRoom = Boolean(JSON.parse(payload).nextRoundId); } catch {} let state = liveSockets.get(matchId); if (!state) { - const socket = new WebSocket(`wss://session-server.aesthetic.computer/oskiewar-live?match=${encodeURIComponent(matchId)}&role=publisher`); + const socket = new WebSocket(`wss://session-server.aesthetic.computer/oskiewar-live?match=${encodeURIComponent(matchId)}&role=publisher&surface=web`); state = { socket, pending: payload, retire: retiresRoom }; liveSockets.set(matchId, state); socket.addEventListener("open", () => { @@ -328,21 +328,37 @@