Only collect telemetry when search param

This commit is contained in:
Kalle
2026-08-08 15:25:48 +03:00
parent c0d778e6cd
commit d9d0237c85
7 changed files with 85 additions and 32 deletions

View File

@@ -116,8 +116,10 @@ sequenceDiagram
the gate. `checkIntervalS` hard-caps both phases; `attachFrame: false`
keeps continuously-firing events from storing a frame PNG each. Frames no
detector is due for skip canvas readback, and everything is counted in
`core/detectors/telemetry.ts` (VoD tab's telemetry panel). A match's
objective reads render as one step-line timeline
`core/detectors/telemetry.ts` — but only when the VoD tab is opened with
`?telemetry=true` (nothing links there); otherwise the workers skip
collection and the panel stays hidden. A match's objective reads render
as one step-line timeline
(`~/components/ObjectiveTimeline.tsx`, shared with the match page).
- VoD scans (`components/VodPage.tsx`): on the WebCodecs path each worker
demuxes + decodes its own contiguous slice (mediabunny in the worker — no

View File

@@ -22,6 +22,7 @@ import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
import { useSearchParam } from "~/modules/search-params/hooks";
import { openSeekScan, probeWebCodecs } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
@@ -36,6 +37,7 @@ import {
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import {
deleteVod,
@@ -133,6 +135,9 @@ export function VodPage({
// in-flight thumbnail work; awaited before persisting a finished scan
const sideWorkRef = useRef<Promise<void>[]>([]);
const nextMatchKeyRef = useRef(0);
// telemetry collection is baked into the workers at init, so a change of
// the search param needs a fresh pool
const clientsCollectTelemetryRef = useRef(false);
const [fileName, setFileName] = useState<string | null>(null);
/** live scan vs. reopened saved VoD (no video element for the latter) */
@@ -149,6 +154,9 @@ export function VodPage({
const [eventsOpen, setEventsOpen] = useState(false);
const [resultsSend, setResultsSend] = useState<ResultsSend | null>(null);
// opt-in via ?telemetry=true only; nothing in the UI links to it
const [collectTelemetry] = useSearchParam(scannerSearchParams, "telemetry");
const formatSavedAt = useEventDateTimeFormatter();
const abilityMap = connectAbilities(matches.map((m) => m.event));
@@ -259,6 +267,11 @@ export function VodPage({
urlRef.current = URL.createObjectURL(file);
video.src = urlRef.current;
if (clientsCollectTelemetryRef.current !== collectTelemetry) {
for (const client of clientsRef.current) client.dispose();
clientsRef.current = [];
clientsCollectTelemetryRef.current = collectTelemetry;
}
if (clientsRef.current.length === 0) {
clientsRef.current = Array.from(
{ length: defaultScanWorkerCount() },
@@ -310,6 +323,7 @@ export function VodPage({
frameDoneRef.current?.();
frameDoneRef.current = null;
},
{ collectTelemetry },
),
);
}
@@ -365,10 +379,12 @@ export function VodPage({
abortScanRef.current = () => {
for (const client of clients) client.abortChunk();
};
const mergedTelemetry = () =>
mergeScanTelemetry(
chunks.flatMap((c) => (c.telemetry ? [c.telemetry] : [])),
const mergedTelemetry = () => {
const parts = chunks.flatMap((c) =>
c.telemetry ? [c.telemetry] : [],
);
return parts.length > 0 ? mergeScanTelemetry(parts) : null;
};
let lastUiUpdate = Number.NEGATIVE_INFINITY;
const pushUiUpdate = () => {
const now = performance.now();

View File

@@ -10,6 +10,7 @@ describe("scannerSearchParams", () => {
assertRoundTrips(scannerSearchParams, {
tab: ["live", "screenshot", "vod"],
inspect: ["1723456789012-abc123", null],
telemetry: [true, false],
});
});

View File

@@ -10,4 +10,9 @@ export const scannerSearchParams = SearchParams.define({
tab: SP.param(z.enum(SCANNER_TABS), { default: "live", loader: false }),
/** Inspect handoff key: the screenshot tab claims this frame on load */
inspect: SP.param(z.string().max(100).nullable(), { loader: false }),
/**
* Opt-in scan telemetry: counters are accumulated and the panel is shown
* only when this is set by hand in the URL (no link points at it)
*/
telemetry: SP.param(z.boolean(), { default: false, loader: false }),
});

View File

@@ -28,6 +28,7 @@ import { DetectorScheduler } from "../core/detectors/scheduler";
import {
createScanTelemetry,
detectorTelemetry,
type ScanTelemetry,
} from "../core/detectors/telemetry";
import type { Detector } from "../core/detectors/types";
import { normalizeFrame, toMat } from "../core/image";
@@ -53,7 +54,9 @@ const PREVIEW_HEIGHT = 270;
let detectors: Detector<unknown>[] = [];
let scheduler: DetectorScheduler | null = null;
let telemetry = createScanTelemetry();
/** null unless the init message asked for telemetry */
let telemetry: ScanTelemetry | null = null;
let collectTelemetry = false;
let chunkAborted = false;
/** last per-frame t, to reset telemetry when a new session rewinds the clock */
let lastFrameT = Number.NEGATIVE_INFINITY;
@@ -65,6 +68,7 @@ function post(message: WorkerResponse, transfer: Transferable[] = []): void {
async function init({
assetsBaseUrl,
suppressSteadyFrames = true,
collectTelemetry: collect = false,
}: InitRequest): Promise<void> {
try {
await loadOpenCV();
@@ -75,7 +79,8 @@ async function init({
matchOpeningTypes: [MAP_START_EVENT_TYPE],
matchClosingTypes: SCOREBOARD_EVENT_TYPES,
});
telemetry = createScanTelemetry();
collectTelemetry = collect;
telemetry = freshTelemetry();
post({ kind: "ready" });
} catch (error) {
post({ kind: "error", message: `init failed: ${String(error)}` });
@@ -115,7 +120,7 @@ async function analyzeFrame(
} finally {
src.delete();
}
telemetry.analyzedFrames++;
if (telemetry) telemetry.analyzedFrames++;
// On detection, ship back the exact analyzed pixels (lossless, at capture
// resolution) so the UI never has to re-grab a later frame — encoded at
@@ -127,21 +132,27 @@ async function analyzeFrame(
try {
for (const detector of detectors) {
if (!due.includes(detector.id)) continue;
const counters = detectorTelemetry(telemetry, detector.id);
counters.checks++;
const gateStart = performance.now();
const counters = telemetry
? detectorTelemetry(telemetry, detector.id)
: null;
const gateStart = counters ? performance.now() : 0;
const gate = detector.gate(frame);
counters.gateMs += performance.now() - gateStart;
if (counters) {
counters.checks++;
counters.gateMs += performance.now() - gateStart;
}
scheduler!.recordGate(detector.id, t, gate.pass, gate.signature);
if (gate.pass) counters.gatePasses++;
if (counters && gate.pass) counters.gatePasses++;
const runParse = gate.pass && scheduler!.shouldParse(detector.id, t);
if (gate.pass && !runParse) counters.suppressedParses++;
if (counters && gate.pass && !runParse) counters.suppressedParses++;
let events: ReturnType<typeof detector.parse> = [];
if (runParse) {
const parseStart = performance.now();
const parseStart = counters ? performance.now() : 0;
events = detector.parse(frame, t, gate);
counters.parses++;
counters.parseMs += performance.now() - parseStart;
if (counters) {
counters.parses++;
counters.parseMs += performance.now() - parseStart;
}
scheduler!.recordParse(detector.id, t, events);
}
const blob =
@@ -163,7 +174,7 @@ async function analyzeFrame(
}
async function analyze({ bitmap, t }: AnalyzeRequest): Promise<void> {
if (t + 5 < lastFrameT) telemetry = createScanTelemetry();
if (t + 5 < lastFrameT) telemetry = freshTelemetry();
lastFrameT = t;
try {
await analyzeFrame(bitmap, t);
@@ -181,7 +192,7 @@ async function scanChunk({
}: ScanChunkRequest): Promise<void> {
chunkAborted = false;
scheduler!.reset(tStart);
telemetry = createScanTelemetry();
telemetry = freshTelemetry();
const wallStart = performance.now();
let lastProgressAt = 0;
let lastPreviewAt = 0;
@@ -201,11 +212,13 @@ async function scanChunk({
const packets = new EncodedPacketSink(track);
const handleSample = async (sample: VideoSample): Promise<void> => {
telemetry.decodedFrames++;
const t = sample.timestamp;
const span = Math.max(0, t - cursor);
if (mode === "active") telemetry.activeVideoS += span;
else telemetry.skimVideoS += span;
if (telemetry) {
telemetry.decodedFrames++;
const span = Math.max(0, t - cursor);
if (mode === "active") telemetry.activeVideoS += span;
else telemetry.skimVideoS += span;
}
cursor = Math.max(cursor, t);
const frame = sample.toVideoFrame();
sample.close();
@@ -225,7 +238,7 @@ async function scanChunk({
}
if (preview || now - lastProgressAt >= PROGRESS_POST_INTERVAL_MS) {
lastProgressAt = now;
telemetry.wallMs = performance.now() - wallStart;
if (telemetry) telemetry.wallMs = performance.now() - wallStart;
post(
{
kind: "chunkProgress",
@@ -282,7 +295,7 @@ async function scanChunk({
}
}
telemetry.wallMs = performance.now() - wallStart;
if (telemetry) telemetry.wallMs = performance.now() - wallStart;
post({ kind: "chunkDone", chunkIndex, telemetry });
} catch (error) {
post({
@@ -294,6 +307,10 @@ async function scanChunk({
}
}
function freshTelemetry(): ScanTelemetry | null {
return collectTelemetry ? createScanTelemetry() : null;
}
self.onmessage = (e: MessageEvent) => {
const msg = e.data as WorkerRequest;
if (msg.kind === "init") void init(msg);

View File

@@ -16,7 +16,8 @@ export type ResultHandler = (
export type ErrorHandler = (message: string) => void;
export interface DoneInfo {
calm: boolean;
telemetry: ScanTelemetry;
/** null unless the client was created with collectTelemetry */
telemetry: ScanTelemetry | null;
}
export type DoneHandler = (t: number, info: DoneInfo) => void;
export type ChunkProgress = Extract<WorkerResponse, { kind: "chunkProgress" }>;
@@ -29,7 +30,7 @@ export function defaultScanWorkerCount(): number {
}
interface PendingChunk {
resolve(telemetry: ScanTelemetry): void;
resolve(telemetry: ScanTelemetry | null): void;
reject(error: Error): void;
onProgress?: ChunkProgressHandler;
}
@@ -51,7 +52,10 @@ export class AnalyzerClient {
// biome-ignore lint/suspicious/noConsole: default sink for worker errors when no handler is passed
onError: ErrorHandler = console.error,
onDone?: DoneHandler,
options: { suppressSteadyFrames?: boolean } = {},
options: {
suppressSteadyFrames?: boolean;
collectTelemetry?: boolean;
} = {},
) {
this.#onResult = onResult;
this.#onError = onError;
@@ -104,6 +108,7 @@ export class AnalyzerClient {
kind: "init",
assetsBaseUrl: Config.staticAssetsUrl,
suppressSteadyFrames: options.suppressSteadyFrames ?? true,
collectTelemetry: options.collectTelemetry ?? false,
});
}
@@ -141,7 +146,7 @@ export class AnalyzerClient {
scanChunk(
request: { file: File; chunkIndex: number; tStart: number; tEnd: number },
onProgress?: ChunkProgressHandler,
): Promise<ScanTelemetry> {
): Promise<ScanTelemetry | null> {
if (this.busy) {
return Promise.reject(new Error("analyzer is busy"));
}

View File

@@ -16,6 +16,12 @@ export interface InitRequest {
* off to get every detector on every frame
*/
suppressSteadyFrames?: boolean;
/**
* accumulate scan telemetry counters (and time the detectors) so they can
* be reported back with progress and done messages; default false — the
* VoD tab only asks for them when the telemetry panel is opted into
*/
collectTelemetry?: boolean;
}
export interface AnalyzeRequest {
@@ -70,7 +76,8 @@ export type WorkerResponse =
t: number;
/** scheduler sees dead air — the caller may widen its sampling stride */
calm: boolean;
telemetry: ScanTelemetry;
/** null when the worker was not asked to collect telemetry */
telemetry: ScanTelemetry | null;
}
| {
kind: "chunkProgress";
@@ -78,9 +85,9 @@ export type WorkerResponse =
/** seconds of video the chunk scan has reached */
t: number;
mode: "active" | "skim";
telemetry: ScanTelemetry;
telemetry: ScanTelemetry | null;
/** small bitmap of the latest decoded frame, for the preview canvas */
preview?: ImageBitmap;
}
| { kind: "chunkDone"; chunkIndex: number; telemetry: ScanTelemetry }
| { kind: "chunkDone"; chunkIndex: number; telemetry: ScanTelemetry | null }
| { kind: "error"; message: string };