From 085ac8af8b2160d22c1383678dd8ce81b29ead9d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 00:39:47 -0700 Subject: [PATCH] feat(core): add emitPerformanceMetric bridge for runtime telemetry Extends the runtime analytics bridge with a numeric performance metric channel for scrub latency, sustained fps, dropped frames, decoder count, composition load time, and media sync drift. Metrics flow through the existing postMessage transport (one bridge, two channels) so hosts can aggregate per session (p50/p95) and forward to their observability pipeline. Also writes performance.mark() with value+tags on detail so metrics surface in the DevTools Performance panel's User Timing track for local debugging. No PostHog dependency in core. Player-side aggregation and flush land in a follow-up PR per the player-perf proposal. --- packages/core/src/runtime/analytics.test.ts | 93 ++++++++++++++++++++- packages/core/src/runtime/analytics.ts | 80 +++++++++++++++--- packages/core/src/runtime/types.ts | 18 +++- 3 files changed, 179 insertions(+), 12 deletions(-) diff --git a/packages/core/src/runtime/analytics.test.ts b/packages/core/src/runtime/analytics.test.ts index 376e0f345..d72c67955 100644 --- a/packages/core/src/runtime/analytics.test.ts +++ b/packages/core/src/runtime/analytics.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics"; +import { initRuntimeAnalytics, emitAnalyticsEvent, emitPerformanceMetric } from "./analytics"; describe("runtime analytics", () => { let postMessage: ReturnType; @@ -58,3 +58,94 @@ describe("runtime analytics", () => { expect(postMessage).toHaveBeenCalledTimes(events.length); }); }); + +describe("runtime performance metrics", () => { + let postMessage: ReturnType; + + beforeEach(() => { + postMessage = vi.fn(); + initRuntimeAnalytics(postMessage); + // Clean up DevTools marks between tests to avoid cross-test interference. + if (typeof performance !== "undefined" && typeof performance.clearMarks === "function") { + performance.clearMarks(); + } + }); + + it("emits a perf metric via postMessage", () => { + emitPerformanceMetric("player_scrub_latency", 12.5); + expect(postMessage).toHaveBeenCalledWith({ + source: "hf-preview", + type: "perf", + name: "player_scrub_latency", + value: 12.5, + tags: {}, + }); + }); + + it("passes tags through", () => { + emitPerformanceMetric("player_decoder_count", 3, { + composition_id: "abc123", + mode: "isolated", + }); + expect(postMessage).toHaveBeenCalledWith({ + source: "hf-preview", + type: "perf", + name: "player_decoder_count", + value: 3, + tags: { composition_id: "abc123", mode: "isolated" }, + }); + }); + + it("normalizes missing tags to an empty object", () => { + emitPerformanceMetric("player_playback_fps", 60); + expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ tags: {} })); + }); + + it("supports zero and negative values", () => { + emitPerformanceMetric("player_dropped_frames", 0); + emitPerformanceMetric("player_media_sync_drift", -8.3); + expect(postMessage).toHaveBeenNthCalledWith(1, expect.objectContaining({ value: 0 })); + expect(postMessage).toHaveBeenNthCalledWith(2, expect.objectContaining({ value: -8.3 })); + }); + + it("does not throw when postMessage is not set", () => { + initRuntimeAnalytics(null as unknown as (payload: unknown) => void); + expect(() => emitPerformanceMetric("player_load_time", 250)).not.toThrow(); + }); + + it("does not throw when postMessage throws", () => { + postMessage.mockImplementation(() => { + throw new Error("channel closed"); + }); + expect(() => emitPerformanceMetric("player_scrub_latency", 12)).not.toThrow(); + }); + + it("does not throw when performance.mark throws", () => { + const original = performance.mark; + // Vitest provides a real performance API; replace mark with a thrower for this test. + performance.mark = vi.fn(() => { + throw new Error("mark failed"); + }) as typeof performance.mark; + try { + expect(() => emitPerformanceMetric("player_load_time", 100)).not.toThrow(); + // Even though performance.mark threw, the bridge should still receive the metric. + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "perf", name: "player_load_time", value: 100 }), + ); + } finally { + performance.mark = original; + } + }); + + it("writes a User Timing mark with detail for DevTools visibility", () => { + if (typeof performance.getEntriesByName !== "function") { + // Older test environments — skip the DevTools assertion but don't fail. + return; + } + emitPerformanceMetric("player_composition_switch", 42, { from: "a", to: "b" }); + const entries = performance.getEntriesByName("player_composition_switch", "mark"); + expect(entries.length).toBeGreaterThan(0); + const mark = entries[entries.length - 1] as PerformanceMark; + expect(mark.detail).toEqual({ value: 42, tags: { from: "a", to: "b" } }); + }); +}); diff --git a/packages/core/src/runtime/analytics.ts b/packages/core/src/runtime/analytics.ts index 4d313ddb4..845970157 100644 --- a/packages/core/src/runtime/analytics.ts +++ b/packages/core/src/runtime/analytics.ts @@ -1,5 +1,5 @@ /** - * Runtime analytics — vendor-agnostic event emission. + * Runtime analytics & performance telemetry — vendor-agnostic event emission. * * The runtime emits structured events via postMessage. The host application * decides what to do with them: forward to PostHog, Mixpanel, Amplitude, @@ -13,15 +13,18 @@ * * ```javascript * window.addEventListener("message", (e) => { - * if (e.data?.source !== "hf-preview" || e.data?.type !== "analytics") return; - * const { event, properties } = e.data; + * if (e.data?.source !== "hf-preview") return; * - * // PostHog: - * posthog.capture(event, properties); - * // Mixpanel: - * mixpanel.track(event, properties); - * // Custom: - * myLogger.track(event, properties); + * if (e.data.type === "analytics") { + * // discrete lifecycle events: composition_loaded, played, seeked, etc. + * posthog.capture(e.data.event, e.data.properties); + * } + * + * if (e.data.type === "perf") { + * // numeric performance metrics: scrub latency, fps, decoder count, etc. + * // Aggregate per-session (p50/p95) and forward on flush. + * myMetrics.observe(e.data.name, e.data.value, e.data.tags); + * } * }); * ``` */ @@ -36,10 +39,22 @@ export type RuntimeAnalyticsEvent = export type RuntimeAnalyticsProperties = Record; +/** + * Tags attached to a performance metric — small, low-cardinality identifiers + * (composition id hash, media count bucket, browser version, etc.). Same shape + * as analytics properties so hosts can forward both through one pipeline. + */ +export type RuntimePerformanceTags = Record; + // Stored reference to the postRuntimeMessage function, set during init. -// Avoids a circular import between analytics ↔ bridge. +// Avoids a circular import between analytics ↔ bridge. Shared by both +// emitAnalyticsEvent and emitPerformanceMetric — one bridge, two channels. let _postMessage: ((payload: unknown) => void) | null = null; +/** + * Wire the analytics + performance bridge to the runtime's postMessage transport. + * Called once during runtime bootstrap from `init.ts`. + */ export function initRuntimeAnalytics(postMessage: (payload: unknown) => void): void { _postMessage = postMessage; } @@ -64,3 +79,48 @@ export function emitAnalyticsEvent( // Never let analytics failures affect the runtime } } + +/** + * Emit a numeric performance metric through the bridge. + * + * Used for player-perf telemetry — scrub latency, sustained fps, dropped + * frames, decoder count, composition load time, media sync drift. The host + * aggregates per-session values (p50/p95) and forwards to its observability + * pipeline on flush. + * + * Also writes a `performance.mark()` so the metric shows up under the + * DevTools Performance panel's "User Timing" track for local debugging, + * with `value` and `tags` available on the entry's `detail` field. + * + * @param name Metric name, e.g. "player_scrub_latency", "player_playback_fps" + * @param value Numeric value (units are metric-specific: ms for latency, fps for rate, etc.) + * @param tags Optional low-cardinality tags (composition id, media count bucket, etc.) + */ +export function emitPerformanceMetric( + name: string, + value: number, + tags?: RuntimePerformanceTags, +): void { + // Local DevTools breadcrumb. Wrapped because performance.mark() can throw on + // strict CSP, when the document is not yet ready, or when `detail` is non-cloneable. + try { + if (typeof performance !== "undefined" && typeof performance.mark === "function") { + performance.mark(name, { detail: { value, tags: tags ?? {} } }); + } + } catch { + // performance API unavailable or rejected — keep going + } + + if (!_postMessage) return; + try { + _postMessage({ + source: "hf-preview", + type: "perf", + name, + value, + tags: tags ?? {}, + }); + } catch { + // Never let telemetry failures affect the runtime + } +} diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index f92504934..da94ffbe1 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -170,6 +170,21 @@ export type RuntimeAnalyticsMessage = { properties: Record; }; +/** + * Numeric performance metrics emitted by the runtime — scrub latency, sustained + * fps, dropped frames, decoder count, composition load time, media sync drift. + * The host aggregates per-session values (p50/p95) and forwards to its + * observability pipeline. Distinct from `analytics` events because perf data + * is continuous and numeric, not discrete. + */ +export type RuntimePerformanceMessage = { + source: "hf-preview"; + type: "perf"; + name: string; + value: number; + tags: Record; +}; + export type RuntimeOutboundMessage = | RuntimeStateMessage | RuntimeTimelineMessage @@ -181,7 +196,8 @@ export type RuntimeOutboundMessage = | RuntimePickerCancelledMessage | RuntimeStageSizeMessage | RuntimeMediaAutoplayBlockedMessage - | RuntimeAnalyticsMessage; + | RuntimeAnalyticsMessage + | RuntimePerformanceMessage; export type RuntimePlayer = { _timeline: RuntimeTimelineLike | null;