@video-lab/telemetry 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,7 @@ pnpm add @video-lab/telemetry
20
20
  import type { PlayerEvent } from '@video-lab/protocol'
21
21
  declare const BUILD_SHA: string
22
22
  declare const event: PlayerEvent
23
+ declare const deliveredEvent: import('@video-lab/protocol').DeliveredPlayerEvent
23
24
  declare function queueForDelivery(input: unknown): void
24
25
  declare function queueSessionSummary(input: unknown): void
25
26
  -->
@@ -35,7 +36,9 @@ const telemetry = createTelemetrySession({
35
36
  },
36
37
  })
37
38
 
38
- // React / Vue / iframe 的 onPlayerEvent,以及静态 iframe 的 embed.onAny 都可直接绑定。
39
+ // 新 delivered 出口可直接绑定,保留 producer 时间、顺序与稳定投递 ID。
40
+ telemetry.recordDelivered(deliveredEvent)
41
+ // 旧 raw onPlayerEvent / player-event / embed.onAny 仍可调用 record(event)。
39
42
  telemetry.record(event)
40
43
  telemetry.recordIntent({ type: 'retry', origin: 'user' })
41
44
  telemetry.finish({ reason: 'page_unload' })
@@ -43,12 +46,13 @@ telemetry.finish({ reason: 'page_unload' })
43
46
 
44
47
  ## 数据与隐私
45
48
 
46
- 每条 record 有 `eventId`、递增 `sequence`、时间、应用版本和最近的脱敏播放上下文。`error.payload.cause`
49
+ 每条 record 有 `eventId`、本地递增 `sequence`、时间、应用版本和最近的脱敏播放上下文。`recordDelivered()` 产生的 record 还带 `producerDelivery`(producer session、delivery id、源端时间与 producer sequence),供 sink 排序、去重与识别迟到事件。`error.payload.cause`
47
50
  会被移除;不会输出 URL query、签名、cookie、user ID 或业务 tags。
48
51
 
49
52
  ## 会话行为
50
53
 
51
- 若宿主的传输层有稳定 delivery id,可传入 `telemetry.record(event, { deliveryId })` 来安全地去重重试。
54
+ 优先使用 `recordDelivered(deliveredEvent)`;它以 producer session 与 delivery id 去重,并在 `producerDelivery` 中保留完整生产端证据。raw `record()` 保持既有形状,不伪造该证据。
55
+ 若宿主的传输层另有稳定 delivery id,也可传入 `telemetry.record(event, { deliveryId })` 来安全地去重重试。
52
56
  没有该 id 时,相同 payload 仍可能是合法的不同媒体事件,本包不会猜测并丢弃它。
53
57
 
54
58
  `finish()` 是幂等的:同一会话重复调用会返回同一份 summary。新的 `contextchange.sessionId` 会自动以
@@ -68,7 +72,20 @@ telemetry.finish({ reason: 'page_unload' })
68
72
  `user` / `api` origin。任意额外字段不会进入 record。sink 的异常被隔离,不会中断播放器;摘要的
69
73
  `completeness` 会报告 context、delivery-id 或 sink 证据缺口。
70
74
 
75
+ `onRecord` / `onSummary` 可以返回 `Promise<void>`。同步抛错和异步拒绝都计入产生该次投递的会话;换源后返回的失败不会污染新会话。若拒绝发生在 `finish()` 之后,已返回 summary 的 `completeness` 会更新,但不会再次调用 `onSummary`。因此摘要序列化后的副本不会自动补写,上传结果及重传仍由宿主传输层管理。
76
+
71
77
  本包不是网络上报客户端。若使用 Sentry,请在业务项目中按自己的已初始化 client 实现 sink,
72
78
  不要把 `@sentry/*` 带进本包。跨框架映射规则与完整接入示例请向项目维护团队获取。
73
79
 
74
80
  MIT
81
+
82
+
83
+ ### schemaVersion 2.0
84
+
85
+ `record()` 接受未知输入并校验;非法事件返回 `null`,计入 `completeness.invalidEvents` 与 `invalid_event`,不会调用 sink。错误 message 投影为稳定 code,cause、内核 detail、兼容 UA/原文、字幕/清晰度标签和用户动作自由文本不进入自动外发记录。源 origin/path 沿用协议的脱敏事实;业务路径仍可能包含敏感段,宿主出口应按自身规则进一步省略。
86
+
87
+ `qoe.recovery` 除最近终态外包含 episodes、实际观测 attempts、automatic/manual、durationMs、unclosed、incomplete 和 overflowEvents。只有完整观测轮次进入 recovered/failed 分组;成功率按对应组 recovered/(recovered+failed) 计算,分母为零为 N/A。取消、晚订阅和未闭合不会伪造成功或失败。
88
+
89
+ 每会话最多保留 128 个恢复轮次、每轮 64 个动作标识、1024 个 delivery 缓存、128 条意图及 128 个 ready 轨道项;溢出在 `localOverflow` / `local_overflow` 中报告,去重无法证明时标记 unknown。error count 保留事件次数,codes 为有限错误码集合。未结束的 playback stall 标记 `unclosed_stall`,不把缺失时长当作零。
90
+
91
+ 参考 Sentry sink 把可恢复 error 作为诊断 breadcrumb,failed recovery 按 `(sessionId,recoveryId)` 去重为事故;不可恢复 error 直接上报。使用 telemetry 作为调用边界,以便记录 sink 失败;本包不发送 HTTP,也不保证服务器已经接收。
package/dist/index.cjs CHANGED
@@ -1,7 +1,95 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _video_lab_protocol = require("@video-lab/protocol");
3
+ //#region src/recovery-summary.ts
4
+ const MAX_EPISODES = 128;
5
+ const MAX_ATTEMPTS = 64;
6
+ const outcomes = () => ({
7
+ recovered: 0,
8
+ failed: 0,
9
+ cancelled: 0,
10
+ incomplete: 0
11
+ });
12
+ /** 会话内有界事实聚合;不依赖播放器内部类,也不推断缺失的动作。 */
13
+ var RecoverySummary = class {
14
+ episodes = /* @__PURE__ */ new Map();
15
+ overflowEvents = 0;
16
+ lastTerminal;
17
+ record(event) {
18
+ const key = JSON.stringify([event.sessionId, event.recoveryId]);
19
+ let episode = this.episodes.get(key);
20
+ if (!episode) {
21
+ if (this.episodes.size >= MAX_EPISODES) {
22
+ this.overflowEvents += 1;
23
+ return;
24
+ }
25
+ episode = {
26
+ detected: false,
27
+ manual: event.trigger === "manual",
28
+ attempts: /* @__PURE__ */ new Set(),
29
+ incomplete: event.phase !== "detected"
30
+ };
31
+ this.episodes.set(key, episode);
32
+ }
33
+ if (episode.terminal) {
34
+ if ([
35
+ "recovered",
36
+ "failed",
37
+ "cancelled"
38
+ ].includes(event.phase) && event.phase !== episode.terminal.phase) episode.incomplete = true;
39
+ return;
40
+ }
41
+ if (event.phase === "detected") episode.detected = true;
42
+ if (event.phase === "attempting") {
43
+ if (episode.attempts.size < MAX_ATTEMPTS) episode.attempts.add(event.attempt);
44
+ else if (!episode.attempts.has(event.attempt)) {
45
+ episode.incomplete = true;
46
+ this.overflowEvents += 1;
47
+ }
48
+ }
49
+ if (event.phase === "recovered" || event.phase === "failed" || event.phase === "cancelled") {
50
+ episode.terminal = event;
51
+ episode.incomplete ||= !episode.detected || episode.attempts.size !== event.attempt || [...episode.attempts].some((attempt) => attempt > event.attempt);
52
+ this.lastTerminal = event;
53
+ }
54
+ }
55
+ summarize() {
56
+ if (this.episodes.size === 0) return void 0;
57
+ const result = {
58
+ ...this.lastTerminal ? {
59
+ recoveryId: this.lastTerminal.recoveryId,
60
+ phase: this.lastTerminal.phase
61
+ } : {},
62
+ episodes: this.episodes.size,
63
+ attempts: 0,
64
+ automatic: outcomes(),
65
+ manual: outcomes(),
66
+ unclosed: 0,
67
+ incomplete: 0,
68
+ overflowEvents: this.overflowEvents,
69
+ durationMs: {
70
+ recovered: 0,
71
+ failed: 0,
72
+ cancelled: 0
73
+ }
74
+ };
75
+ for (const episode of this.episodes.values()) {
76
+ result.attempts += episode.attempts.size;
77
+ const group = episode.manual ? result.manual : result.automatic;
78
+ if (!episode.terminal) result.unclosed += 1;
79
+ if (episode.incomplete || !episode.terminal) {
80
+ result.incomplete += 1;
81
+ group.incomplete += 1;
82
+ } else {
83
+ group[episode.terminal.phase] += 1;
84
+ result.durationMs[episode.terminal.phase] += episode.terminal.elapsedMs;
85
+ }
86
+ }
87
+ return result;
88
+ }
89
+ };
90
+ //#endregion
3
91
  //#region src/index.ts
4
- const TELEMETRY_SCHEMA_VERSION = "1.0";
92
+ const TELEMETRY_SCHEMA_VERSION = "2.0";
5
93
  function createAggregate() {
6
94
  return {
7
95
  eventCount: 0,
@@ -9,8 +97,14 @@ function createAggregate() {
9
97
  generatedDelivery: false,
10
98
  unattributedRecord: false,
11
99
  sinkFailures: 0,
100
+ recovery: new RecoverySummary(),
101
+ unclosedStall: false,
102
+ incompleteStall: false,
12
103
  playbackStallCount: 0,
13
104
  playbackStallDurationMs: 0,
105
+ errorCount: 0,
106
+ localOverflow: 0,
107
+ invalidEvents: 0,
14
108
  errorCodes: [],
15
109
  qualityChangeCount: 0,
16
110
  autoQualityChangeCount: 0,
@@ -28,12 +122,57 @@ function createDefaultIdFactory() {
28
122
  };
29
123
  }
30
124
  function sanitizeEvent(event) {
31
- if (event.event !== "error") return event;
32
- const { cause: _cause, ...payload } = event.payload;
33
- return {
34
- event: "error",
35
- payload
36
- };
125
+ switch (event.event) {
126
+ case "error": {
127
+ const { code, category, retryable } = event.payload;
128
+ return {
129
+ event: "error",
130
+ payload: {
131
+ code,
132
+ category,
133
+ retryable,
134
+ message: code
135
+ }
136
+ };
137
+ }
138
+ case "kernelhealth": return {
139
+ ...event,
140
+ payload: {
141
+ ...event.payload,
142
+ detail: ""
143
+ }
144
+ };
145
+ case "compatwarning": return {
146
+ event: "compatwarning",
147
+ payload: {
148
+ code: event.payload.code,
149
+ message: event.payload.code,
150
+ ua: ""
151
+ }
152
+ };
153
+ case "ready": return {
154
+ ...event,
155
+ payload: {
156
+ ...event.payload,
157
+ quality: event.payload.quality.slice(0, 128).map(({ label: _label, ...quality }) => quality),
158
+ ...event.payload.subtitles ? { subtitles: event.payload.subtitles.slice(0, 128).map((track) => ({
159
+ ...track,
160
+ label: "",
161
+ locale: /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i.test(track.locale) ? track.locale.slice(0, 32) : "und"
162
+ })) } : {}
163
+ }
164
+ };
165
+ case "useraction": return {
166
+ ...event,
167
+ payload: {
168
+ ...event.payload,
169
+ source: "player",
170
+ from: typeof event.payload.from === "string" ? null : event.payload.from,
171
+ to: typeof event.payload.to === "string" ? null : event.payload.to
172
+ }
173
+ };
174
+ default: return event;
175
+ }
37
176
  }
38
177
  function unknown() {
39
178
  return { status: "unknown" };
@@ -108,37 +247,45 @@ function createTelemetrySession(options) {
108
247
  }
109
248
  aggregate.startedAtMs ??= now();
110
249
  }
111
- function markSinkFailure() {
112
- aggregate.sinkFailures += 1;
250
+ function markSinkFailure(target) {
251
+ target.sinkFailures += 1;
252
+ const summary = target.summary;
253
+ if (!summary) return;
254
+ summary.completeness.sinkFailures = target.sinkFailures;
255
+ summary.completeness.status = "partial";
256
+ if (!summary.completeness.missing.includes("sink_failure")) summary.completeness.missing.push("sink_failure");
113
257
  }
114
- function emitRecord(record) {
258
+ function deliver(target, send) {
115
259
  try {
116
- options.sink?.onRecord?.(record);
260
+ const pending = send();
261
+ if (pending) Promise.resolve(pending).catch(() => markSinkFailure(target));
117
262
  } catch {
118
- markSinkFailure();
263
+ markSinkFailure(target);
119
264
  }
120
265
  }
266
+ function emitRecord(record) {
267
+ deliver(aggregate, () => options.sink?.onRecord?.(record));
268
+ }
121
269
  function recordEventFact(event) {
122
270
  if (event.event === "firstframe") aggregate.firstFrameMs ??= event.payload.fvt;
123
- if (event.event === "stalled" && event.payload.phase === "end" && event.payload.kind === "playback") {
124
- aggregate.playbackStallCount += 1;
125
- aggregate.playbackStallDurationMs += event.payload.durationMs ?? 0;
271
+ if (event.event === "stalled" && event.payload.kind === "playback") {
272
+ aggregate.incompleteStall ||= event.payload.phase === "start" && aggregate.unclosedStall || event.payload.phase === "end" && event.payload.durationMs === void 0;
273
+ aggregate.unclosedStall = event.payload.phase === "start";
274
+ if (event.payload.phase === "end" && event.payload.durationMs !== void 0) {
275
+ aggregate.playbackStallCount += 1;
276
+ aggregate.playbackStallDurationMs += event.payload.durationMs;
277
+ }
126
278
  }
127
279
  if (event.event === "error") {
128
- aggregate.errorCodes.push(event.payload.code);
280
+ aggregate.errorCount += 1;
281
+ if (!aggregate.errorCodes.includes(event.payload.code)) aggregate.errorCodes.push(event.payload.code);
129
282
  if (aggregate.firstFrameMs === void 0 && !event.payload.retryable) aggregate.firstNonRetryableError ??= event.payload.code;
130
283
  }
131
284
  if (event.event === "qualitychange") {
132
285
  aggregate.qualityChangeCount += 1;
133
286
  if (event.payload.auto) aggregate.autoQualityChangeCount += 1;
134
287
  }
135
- if (event.event === "recovery") {
136
- const { phase } = event.payload;
137
- if (phase === "recovered" || phase === "failed" || phase === "cancelled") aggregate.lastRecovery = {
138
- recoveryId: event.payload.recoveryId,
139
- phase
140
- };
141
- }
288
+ if (event.event === "recovery") aggregate.recovery.record(event.payload);
142
289
  if (event.event === "kernelhealth") aggregate.kernelHealth = {
143
290
  degraded: event.payload.degraded,
144
291
  reason: event.payload.reason
@@ -158,12 +305,18 @@ function createTelemetrySession(options) {
158
305
  }
159
306
  function buildSummary(reason) {
160
307
  const missing = [];
308
+ const recovery = aggregate.recovery.summarize();
309
+ if (recovery?.unclosed) missing.push("unclosed_episode");
310
+ if (recovery?.incomplete) missing.push("recovery_incomplete");
311
+ if (recovery?.overflowEvents || aggregate.localOverflow) missing.push("local_overflow");
312
+ if (aggregate.invalidEvents) missing.push("invalid_event");
313
+ if (aggregate.unclosedStall || aggregate.incompleteStall) missing.push("unclosed_stall");
161
314
  const contextUnattributed = aggregate.unattributedRecord || !aggregate.currentContext;
162
315
  if (aggregate.generatedDelivery) missing.push("deduplication_unknown");
163
316
  if (contextUnattributed) missing.push("context_unattributed");
164
317
  if (aggregate.sinkFailures > 0) missing.push("sink_failure");
165
318
  return {
166
- schemaVersion: "1.0",
319
+ schemaVersion: "2.0",
167
320
  application: options.application,
168
321
  ...aggregate.currentContext || aggregate.sourceRoute ? { sessionId: aggregate.currentContext?.sessionId ?? aggregate.sourceRoute?.sessionId } : {},
169
322
  startedAtMs: aggregate.startedAtMs ?? now(),
@@ -180,7 +333,7 @@ function createTelemetrySession(options) {
180
333
  totalDurationMs: aggregate.playbackStallDurationMs
181
334
  }),
182
335
  errors: aggregate.errorCodes.length === 0 ? unknown() : observed({
183
- count: aggregate.errorCodes.length,
336
+ count: aggregate.errorCount,
184
337
  codes: aggregate.errorCodes
185
338
  }),
186
339
  qualityChanges: aggregate.qualityChangeCount === 0 ? unknown() : observed({
@@ -188,7 +341,7 @@ function createTelemetrySession(options) {
188
341
  autoCount: aggregate.autoQualityChangeCount,
189
342
  manualCount: aggregate.qualityChangeCount - aggregate.autoQualityChangeCount
190
343
  }),
191
- recovery: aggregate.lastRecovery ? observed(aggregate.lastRecovery) : unknown(),
344
+ recovery: recovery ? observed(recovery) : unknown(),
192
345
  health: {
193
346
  kernel: aggregate.kernelHealth ? observed(aggregate.kernelHealth) : unknown(),
194
347
  audio: aggregate.audioHealth ? observed(aggregate.audioHealth) : unknown(),
@@ -205,7 +358,9 @@ function createTelemetrySession(options) {
205
358
  missing,
206
359
  deduplication: aggregate.generatedDelivery ? "unknown" : "verified",
207
360
  context: contextUnattributed ? "unattributed" : "observed",
208
- sinkFailures: aggregate.sinkFailures
361
+ sinkFailures: aggregate.sinkFailures,
362
+ invalidEvents: aggregate.invalidEvents,
363
+ localOverflow: aggregate.localOverflow + (recovery?.overflowEvents ?? 0)
209
364
  }
210
365
  };
211
366
  }
@@ -213,27 +368,33 @@ function createTelemetrySession(options) {
213
368
  if (completedSummary) return completedSummary;
214
369
  const summary = buildSummary(reason);
215
370
  completedSummary = summary;
216
- try {
217
- options.sink?.onSummary?.(summary);
218
- } catch {
219
- markSinkFailure();
220
- summary.completeness.sinkFailures = aggregate.sinkFailures;
221
- summary.completeness.status = "partial";
222
- if (!summary.completeness.missing.includes("sink_failure")) summary.completeness.missing.push("sink_failure");
223
- }
371
+ aggregate.summary = summary;
372
+ deliver(aggregate, () => options.sink?.onSummary?.(summary));
224
373
  return summary;
225
374
  }
226
375
  return {
227
376
  record(event, metadata) {
228
- const parsed = _video_lab_protocol.PlayerEventSchema.parse(event);
377
+ const validation = _video_lab_protocol.PlayerEventSchema.safeParse(event);
378
+ if (!validation.success) {
379
+ prepareForInput();
380
+ aggregate.invalidEvents += 1;
381
+ return null;
382
+ }
383
+ const parsed = validation.data;
384
+ if (completedSummary) prepareForInput();
229
385
  const deliveryId = metadata?.deliveryId;
386
+ const producerDelivery = metadata?.producerDelivery;
387
+ const identitySession = "sessionId" in parsed.payload ? parsed.payload.sessionId : aggregate.currentContext?.sessionId ?? aggregate.sourceRoute?.sessionId ?? "";
388
+ const deliveryKey = JSON.stringify(producerDelivery ? [producerDelivery.producerSessionId, producerDelivery.deliveryId] : [identitySession, deliveryId]);
230
389
  if (deliveryId) {
231
- const duplicate = seenDeliveries.get(deliveryId);
390
+ const duplicate = seenDeliveries.get(deliveryKey);
232
391
  if (duplicate) return duplicate;
233
392
  }
234
393
  if (parsed.event === "contextchange" && aggregate.currentContext && aggregate.currentContext.sessionId !== parsed.payload.sessionId && !completedSummary) finish("source_changed");
235
394
  prepareForInput();
236
395
  const isPendingSourceRoute = parsed.event === "sourceroute" && !!aggregate.currentContext && aggregate.currentContext.sessionId !== parsed.payload.sessionId;
396
+ const currentSessionId = aggregate.currentContext?.sessionId ?? aggregate.sourceRoute?.sessionId;
397
+ const isForeignRecovery = parsed.event === "recovery" && currentSessionId !== void 0 && currentSessionId !== parsed.payload.sessionId;
237
398
  if (isPendingSourceRoute) pendingSourceRoute = parsed.payload;
238
399
  if (parsed.event === "sourceroute" && !isPendingSourceRoute) aggregate.sourceRoute = parsed.payload;
239
400
  if (parsed.event === "contextchange") {
@@ -241,31 +402,49 @@ function createTelemetrySession(options) {
241
402
  if (pendingSourceRoute?.sessionId === parsed.payload.sessionId) aggregate.sourceRoute = pendingSourceRoute;
242
403
  pendingSourceRoute = void 0;
243
404
  }
405
+ if (parsed.event === "ready" && (parsed.payload.quality.length > 128 || (parsed.payload.subtitles?.length ?? 0) > 128)) aggregate.localOverflow += 1;
244
406
  sequence += 1;
245
407
  const record = {
246
408
  kind: "player_event",
247
- eventId: deliveryId ?? idFactory(),
248
- schemaVersion: "1.0",
249
- occurredAtMs: metadata?.occurredAtMs ?? now(),
409
+ eventId: producerDelivery ? `${producerDelivery.producerSessionId}:${producerDelivery.deliveryId}` : deliveryId ?? idFactory(),
410
+ schemaVersion: "2.0",
411
+ occurredAtMs: producerDelivery?.occurredAtMs ?? metadata?.occurredAtMs ?? now(),
250
412
  sequence,
251
413
  application: options.application,
252
414
  event: sanitizeEvent(parsed),
253
- ...aggregate.currentContext && !isPendingSourceRoute ? {
415
+ ...aggregate.currentContext && !isPendingSourceRoute && !isForeignRecovery ? {
254
416
  context: aggregate.currentContext,
255
417
  contextStatus: "observed"
256
418
  } : { contextStatus: "unattributed" },
257
- delivery: deliveryId ? "stable" : "generated"
419
+ delivery: deliveryId ? "stable" : "generated",
420
+ ...producerDelivery ? { producerDelivery } : {}
258
421
  };
259
- if (!isPendingSourceRoute) {
422
+ if (!isPendingSourceRoute && !isForeignRecovery) {
260
423
  aggregate.eventCount += 1;
261
424
  aggregate.generatedDelivery ||= !deliveryId;
262
425
  aggregate.unattributedRecord ||= record.contextStatus === "unattributed" && parsed.event !== "sourceroute";
263
426
  recordEventFact(record.event);
264
427
  }
265
- if (deliveryId) seenDeliveries.set(deliveryId, record);
428
+ if (deliveryId) if (seenDeliveries.size < 1024) seenDeliveries.set(deliveryKey, record);
429
+ else {
430
+ aggregate.localOverflow += 1;
431
+ aggregate.generatedDelivery = true;
432
+ }
266
433
  emitRecord(record);
267
434
  return record;
268
435
  },
436
+ recordDelivered(event) {
437
+ return this.record(event.event, {
438
+ deliveryId: event.deliveryId,
439
+ occurredAtMs: event.occurredAtMs,
440
+ producerDelivery: {
441
+ producerSessionId: event.producerSessionId,
442
+ deliveryId: event.deliveryId,
443
+ occurredAtMs: event.occurredAtMs,
444
+ sequence: event.sequence
445
+ }
446
+ });
447
+ },
269
448
  recordIntent(intent) {
270
449
  prepareForInput();
271
450
  const projectedIntent = projectIntent(intent);
@@ -273,7 +452,7 @@ function createTelemetrySession(options) {
273
452
  const record = {
274
453
  kind: "intent",
275
454
  eventId: idFactory(),
276
- schemaVersion: "1.0",
455
+ schemaVersion: "2.0",
277
456
  occurredAtMs: now(),
278
457
  sequence,
279
458
  application: options.application,
@@ -287,7 +466,8 @@ function createTelemetrySession(options) {
287
466
  aggregate.intentCount += 1;
288
467
  aggregate.generatedDelivery = true;
289
468
  aggregate.unattributedRecord ||= record.contextStatus === "unattributed";
290
- aggregate.intents.push(projectedIntent);
469
+ if (aggregate.intents.length < 128) aggregate.intents.push(projectedIntent);
470
+ else aggregate.localOverflow += 1;
291
471
  emitRecord(record);
292
472
  return record;
293
473
  },
package/dist/index.d.cts CHANGED
@@ -1,6 +1,34 @@
1
- import { ErrorCode, PlaybackContext, PlayerEvent, SourceRoutePayload } from "@video-lab/protocol";
1
+ import { DeliveredPlayerEvent, ErrorCode, PlaybackContext, PlayerEvent, RecoveryPayload, SourceRoutePayload } from "@video-lab/protocol";
2
+ //#region src/recovery-summary.d.ts
3
+ type Terminal = Extract<RecoveryPayload, {
4
+ phase: 'recovered' | 'failed' | 'cancelled';
5
+ }>;
6
+ type Outcomes = {
7
+ recovered: number;
8
+ failed: number;
9
+ cancelled: number;
10
+ incomplete: number;
11
+ };
12
+ /** 完整 episode 才进入成功率分母;incomplete 和未闭合单独报告。 */
13
+ interface TelemetryRecoverySummary {
14
+ recoveryId?: number;
15
+ phase?: Terminal['phase'];
16
+ episodes: number;
17
+ attempts: number;
18
+ automatic: Outcomes;
19
+ manual: Outcomes;
20
+ unclosed: number;
21
+ incomplete: number;
22
+ overflowEvents: number;
23
+ durationMs: {
24
+ recovered: number;
25
+ failed: number;
26
+ cancelled: number;
27
+ };
28
+ }
29
+ //#endregion
2
30
  //#region src/index.d.ts
3
- declare const TELEMETRY_SCHEMA_VERSION: "1.0";
31
+ declare const TELEMETRY_SCHEMA_VERSION: "2.0";
4
32
  interface TelemetryApplication {
5
33
  /** 产生这些记录的宿主应用名称。 */
6
34
  name: string;
@@ -12,6 +40,15 @@ interface TelemetryRecordOptions {
12
40
  deliveryId?: string;
13
41
  /** 事件在播放器侧发生的时间;缺省时由会话时钟采样。 */
14
42
  occurredAtMs?: number;
43
+ /** `recordDelivered` 写入的生产端投递证据;raw `record` 默认不产生它。 */
44
+ producerDelivery?: TelemetryProducerDelivery;
45
+ }
46
+ /** 可随 telemetry record 外发的生产端排序、去重与迟到识别证据。 */
47
+ interface TelemetryProducerDelivery {
48
+ producerSessionId: string;
49
+ deliveryId: string;
50
+ occurredAtMs: number;
51
+ sequence: number;
15
52
  }
16
53
  type TelemetryIntentType = 'play' | 'pause' | 'seek' | 'retry' | 'source_change' | 'exit';
17
54
  type TelemetryIntentOrigin = 'user' | 'api';
@@ -33,6 +70,8 @@ interface TelemetryRecordBase {
33
70
  interface TelemetryPlayerRecord extends TelemetryRecordBase {
34
71
  kind: 'player_event';
35
72
  event: PlayerEvent;
73
+ /** 仅完整 delivered 流提供;raw `record` 保持既有输出形状。 */
74
+ producerDelivery?: TelemetryProducerDelivery;
36
75
  }
37
76
  interface TelemetryIntentRecord extends TelemetryRecordBase {
38
77
  kind: 'intent';
@@ -89,10 +128,7 @@ interface TelemetrySessionSummary {
89
128
  autoCount: number;
90
129
  manualCount: number;
91
130
  }>;
92
- recovery: TelemetryValue<{
93
- recoveryId: number;
94
- phase: 'recovered' | 'failed' | 'cancelled';
95
- }>;
131
+ recovery: TelemetryValue<TelemetryRecoverySummary>;
96
132
  health: {
97
133
  kernel: TelemetryValue<{
98
134
  degraded: boolean;
@@ -115,15 +151,17 @@ interface TelemetrySessionSummary {
115
151
  intents: TelemetryValue<readonly TelemetryIntent[]>;
116
152
  completeness: {
117
153
  status: 'complete' | 'partial';
118
- missing: Array<'deduplication_unknown' | 'context_unattributed' | 'sink_failure'>;
154
+ missing: Array<'deduplication_unknown' | 'context_unattributed' | 'sink_failure' | 'unclosed_episode' | 'recovery_incomplete' | 'local_overflow' | 'invalid_event' | 'unclosed_stall'>;
119
155
  deduplication: 'verified' | 'unknown';
120
156
  context: 'observed' | 'unattributed';
121
157
  sinkFailures: number;
158
+ invalidEvents: number;
159
+ localOverflow: number;
122
160
  };
123
161
  }
124
162
  interface TelemetrySink {
125
- onRecord?(record: TelemetryRecord): void;
126
- onSummary?(summary: TelemetrySessionSummary): void;
163
+ onRecord?(record: TelemetryRecord): void | Promise<void>;
164
+ onSummary?(summary: TelemetrySessionSummary): void | Promise<void>;
127
165
  }
128
166
  interface CreateTelemetrySessionOptions {
129
167
  application: TelemetryApplication;
@@ -133,7 +171,9 @@ interface CreateTelemetrySessionOptions {
133
171
  }
134
172
  interface TelemetrySession {
135
173
  /** 把完整 PlayerEvent 归一成可重试、可排序的安全 record。 */
136
- record(event: PlayerEvent, options?: TelemetryRecordOptions): TelemetryPlayerRecord;
174
+ record(event: unknown, options?: TelemetryRecordOptions): TelemetryPlayerRecord | null;
175
+ /** 直接消费五面同构的 producer delivery 证据。 */
176
+ recordDelivered(event: DeliveredPlayerEvent): TelemetryPlayerRecord | null;
137
177
  /** 记录有限、脱敏的宿主意图,不把任意业务数据带进遥测。 */
138
178
  recordIntent(intent: TelemetryIntent): TelemetryIntentRecord;
139
179
  /** 关闭当前播放会话;重复调用返回同一份 summary。 */
@@ -144,5 +184,5 @@ interface TelemetrySession {
144
184
  /** 创建宿主侧会话遥测入口。它不发网络请求;sink 仅接收已归一的结构化结果。 */
145
185
  declare function createTelemetrySession(options: CreateTelemetrySessionOptions): TelemetrySession;
146
186
  //#endregion
147
- export { CreateTelemetrySessionOptions, TELEMETRY_SCHEMA_VERSION, TelemetryApplication, TelemetryFinishReason, TelemetryIntent, TelemetryIntentOrigin, TelemetryIntentRecord, TelemetryIntentType, TelemetryPlayerRecord, TelemetryRecord, TelemetryRecordOptions, TelemetrySession, TelemetrySessionSummary, TelemetrySink, TelemetryStartup, TelemetryValue, createTelemetrySession };
187
+ export { CreateTelemetrySessionOptions, TELEMETRY_SCHEMA_VERSION, TelemetryApplication, TelemetryFinishReason, TelemetryIntent, TelemetryIntentOrigin, TelemetryIntentRecord, TelemetryIntentType, TelemetryPlayerRecord, TelemetryProducerDelivery, TelemetryRecord, TelemetryRecordOptions, type TelemetryRecoverySummary, TelemetrySession, TelemetrySessionSummary, TelemetrySink, TelemetryStartup, TelemetryValue, createTelemetrySession };
148
188
  //# sourceMappingURL=index.d.cts.map
package/dist/index.d.mts CHANGED
@@ -1,6 +1,34 @@
1
- import { ErrorCode, PlaybackContext, PlayerEvent, SourceRoutePayload } from "@video-lab/protocol";
1
+ import { DeliveredPlayerEvent, ErrorCode, PlaybackContext, PlayerEvent, RecoveryPayload, SourceRoutePayload } from "@video-lab/protocol";
2
+ //#region src/recovery-summary.d.ts
3
+ type Terminal = Extract<RecoveryPayload, {
4
+ phase: 'recovered' | 'failed' | 'cancelled';
5
+ }>;
6
+ type Outcomes = {
7
+ recovered: number;
8
+ failed: number;
9
+ cancelled: number;
10
+ incomplete: number;
11
+ };
12
+ /** 完整 episode 才进入成功率分母;incomplete 和未闭合单独报告。 */
13
+ interface TelemetryRecoverySummary {
14
+ recoveryId?: number;
15
+ phase?: Terminal['phase'];
16
+ episodes: number;
17
+ attempts: number;
18
+ automatic: Outcomes;
19
+ manual: Outcomes;
20
+ unclosed: number;
21
+ incomplete: number;
22
+ overflowEvents: number;
23
+ durationMs: {
24
+ recovered: number;
25
+ failed: number;
26
+ cancelled: number;
27
+ };
28
+ }
29
+ //#endregion
2
30
  //#region src/index.d.ts
3
- declare const TELEMETRY_SCHEMA_VERSION: "1.0";
31
+ declare const TELEMETRY_SCHEMA_VERSION: "2.0";
4
32
  interface TelemetryApplication {
5
33
  /** 产生这些记录的宿主应用名称。 */
6
34
  name: string;
@@ -12,6 +40,15 @@ interface TelemetryRecordOptions {
12
40
  deliveryId?: string;
13
41
  /** 事件在播放器侧发生的时间;缺省时由会话时钟采样。 */
14
42
  occurredAtMs?: number;
43
+ /** `recordDelivered` 写入的生产端投递证据;raw `record` 默认不产生它。 */
44
+ producerDelivery?: TelemetryProducerDelivery;
45
+ }
46
+ /** 可随 telemetry record 外发的生产端排序、去重与迟到识别证据。 */
47
+ interface TelemetryProducerDelivery {
48
+ producerSessionId: string;
49
+ deliveryId: string;
50
+ occurredAtMs: number;
51
+ sequence: number;
15
52
  }
16
53
  type TelemetryIntentType = 'play' | 'pause' | 'seek' | 'retry' | 'source_change' | 'exit';
17
54
  type TelemetryIntentOrigin = 'user' | 'api';
@@ -33,6 +70,8 @@ interface TelemetryRecordBase {
33
70
  interface TelemetryPlayerRecord extends TelemetryRecordBase {
34
71
  kind: 'player_event';
35
72
  event: PlayerEvent;
73
+ /** 仅完整 delivered 流提供;raw `record` 保持既有输出形状。 */
74
+ producerDelivery?: TelemetryProducerDelivery;
36
75
  }
37
76
  interface TelemetryIntentRecord extends TelemetryRecordBase {
38
77
  kind: 'intent';
@@ -89,10 +128,7 @@ interface TelemetrySessionSummary {
89
128
  autoCount: number;
90
129
  manualCount: number;
91
130
  }>;
92
- recovery: TelemetryValue<{
93
- recoveryId: number;
94
- phase: 'recovered' | 'failed' | 'cancelled';
95
- }>;
131
+ recovery: TelemetryValue<TelemetryRecoverySummary>;
96
132
  health: {
97
133
  kernel: TelemetryValue<{
98
134
  degraded: boolean;
@@ -115,15 +151,17 @@ interface TelemetrySessionSummary {
115
151
  intents: TelemetryValue<readonly TelemetryIntent[]>;
116
152
  completeness: {
117
153
  status: 'complete' | 'partial';
118
- missing: Array<'deduplication_unknown' | 'context_unattributed' | 'sink_failure'>;
154
+ missing: Array<'deduplication_unknown' | 'context_unattributed' | 'sink_failure' | 'unclosed_episode' | 'recovery_incomplete' | 'local_overflow' | 'invalid_event' | 'unclosed_stall'>;
119
155
  deduplication: 'verified' | 'unknown';
120
156
  context: 'observed' | 'unattributed';
121
157
  sinkFailures: number;
158
+ invalidEvents: number;
159
+ localOverflow: number;
122
160
  };
123
161
  }
124
162
  interface TelemetrySink {
125
- onRecord?(record: TelemetryRecord): void;
126
- onSummary?(summary: TelemetrySessionSummary): void;
163
+ onRecord?(record: TelemetryRecord): void | Promise<void>;
164
+ onSummary?(summary: TelemetrySessionSummary): void | Promise<void>;
127
165
  }
128
166
  interface CreateTelemetrySessionOptions {
129
167
  application: TelemetryApplication;
@@ -133,7 +171,9 @@ interface CreateTelemetrySessionOptions {
133
171
  }
134
172
  interface TelemetrySession {
135
173
  /** 把完整 PlayerEvent 归一成可重试、可排序的安全 record。 */
136
- record(event: PlayerEvent, options?: TelemetryRecordOptions): TelemetryPlayerRecord;
174
+ record(event: unknown, options?: TelemetryRecordOptions): TelemetryPlayerRecord | null;
175
+ /** 直接消费五面同构的 producer delivery 证据。 */
176
+ recordDelivered(event: DeliveredPlayerEvent): TelemetryPlayerRecord | null;
137
177
  /** 记录有限、脱敏的宿主意图,不把任意业务数据带进遥测。 */
138
178
  recordIntent(intent: TelemetryIntent): TelemetryIntentRecord;
139
179
  /** 关闭当前播放会话;重复调用返回同一份 summary。 */
@@ -144,5 +184,5 @@ interface TelemetrySession {
144
184
  /** 创建宿主侧会话遥测入口。它不发网络请求;sink 仅接收已归一的结构化结果。 */
145
185
  declare function createTelemetrySession(options: CreateTelemetrySessionOptions): TelemetrySession;
146
186
  //#endregion
147
- export { CreateTelemetrySessionOptions, TELEMETRY_SCHEMA_VERSION, TelemetryApplication, TelemetryFinishReason, TelemetryIntent, TelemetryIntentOrigin, TelemetryIntentRecord, TelemetryIntentType, TelemetryPlayerRecord, TelemetryRecord, TelemetryRecordOptions, TelemetrySession, TelemetrySessionSummary, TelemetrySink, TelemetryStartup, TelemetryValue, createTelemetrySession };
187
+ export { CreateTelemetrySessionOptions, TELEMETRY_SCHEMA_VERSION, TelemetryApplication, TelemetryFinishReason, TelemetryIntent, TelemetryIntentOrigin, TelemetryIntentRecord, TelemetryIntentType, TelemetryPlayerRecord, TelemetryProducerDelivery, TelemetryRecord, TelemetryRecordOptions, type TelemetryRecoverySummary, TelemetrySession, TelemetrySessionSummary, TelemetrySink, TelemetryStartup, TelemetryValue, createTelemetrySession };
148
188
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -1,6 +1,94 @@
1
1
  import { PlayerEventSchema } from "@video-lab/protocol";
2
+ //#region src/recovery-summary.ts
3
+ const MAX_EPISODES = 128;
4
+ const MAX_ATTEMPTS = 64;
5
+ const outcomes = () => ({
6
+ recovered: 0,
7
+ failed: 0,
8
+ cancelled: 0,
9
+ incomplete: 0
10
+ });
11
+ /** 会话内有界事实聚合;不依赖播放器内部类,也不推断缺失的动作。 */
12
+ var RecoverySummary = class {
13
+ episodes = /* @__PURE__ */ new Map();
14
+ overflowEvents = 0;
15
+ lastTerminal;
16
+ record(event) {
17
+ const key = JSON.stringify([event.sessionId, event.recoveryId]);
18
+ let episode = this.episodes.get(key);
19
+ if (!episode) {
20
+ if (this.episodes.size >= MAX_EPISODES) {
21
+ this.overflowEvents += 1;
22
+ return;
23
+ }
24
+ episode = {
25
+ detected: false,
26
+ manual: event.trigger === "manual",
27
+ attempts: /* @__PURE__ */ new Set(),
28
+ incomplete: event.phase !== "detected"
29
+ };
30
+ this.episodes.set(key, episode);
31
+ }
32
+ if (episode.terminal) {
33
+ if ([
34
+ "recovered",
35
+ "failed",
36
+ "cancelled"
37
+ ].includes(event.phase) && event.phase !== episode.terminal.phase) episode.incomplete = true;
38
+ return;
39
+ }
40
+ if (event.phase === "detected") episode.detected = true;
41
+ if (event.phase === "attempting") {
42
+ if (episode.attempts.size < MAX_ATTEMPTS) episode.attempts.add(event.attempt);
43
+ else if (!episode.attempts.has(event.attempt)) {
44
+ episode.incomplete = true;
45
+ this.overflowEvents += 1;
46
+ }
47
+ }
48
+ if (event.phase === "recovered" || event.phase === "failed" || event.phase === "cancelled") {
49
+ episode.terminal = event;
50
+ episode.incomplete ||= !episode.detected || episode.attempts.size !== event.attempt || [...episode.attempts].some((attempt) => attempt > event.attempt);
51
+ this.lastTerminal = event;
52
+ }
53
+ }
54
+ summarize() {
55
+ if (this.episodes.size === 0) return void 0;
56
+ const result = {
57
+ ...this.lastTerminal ? {
58
+ recoveryId: this.lastTerminal.recoveryId,
59
+ phase: this.lastTerminal.phase
60
+ } : {},
61
+ episodes: this.episodes.size,
62
+ attempts: 0,
63
+ automatic: outcomes(),
64
+ manual: outcomes(),
65
+ unclosed: 0,
66
+ incomplete: 0,
67
+ overflowEvents: this.overflowEvents,
68
+ durationMs: {
69
+ recovered: 0,
70
+ failed: 0,
71
+ cancelled: 0
72
+ }
73
+ };
74
+ for (const episode of this.episodes.values()) {
75
+ result.attempts += episode.attempts.size;
76
+ const group = episode.manual ? result.manual : result.automatic;
77
+ if (!episode.terminal) result.unclosed += 1;
78
+ if (episode.incomplete || !episode.terminal) {
79
+ result.incomplete += 1;
80
+ group.incomplete += 1;
81
+ } else {
82
+ group[episode.terminal.phase] += 1;
83
+ result.durationMs[episode.terminal.phase] += episode.terminal.elapsedMs;
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+ };
89
+ //#endregion
2
90
  //#region src/index.ts
3
- const TELEMETRY_SCHEMA_VERSION = "1.0";
91
+ const TELEMETRY_SCHEMA_VERSION = "2.0";
4
92
  function createAggregate() {
5
93
  return {
6
94
  eventCount: 0,
@@ -8,8 +96,14 @@ function createAggregate() {
8
96
  generatedDelivery: false,
9
97
  unattributedRecord: false,
10
98
  sinkFailures: 0,
99
+ recovery: new RecoverySummary(),
100
+ unclosedStall: false,
101
+ incompleteStall: false,
11
102
  playbackStallCount: 0,
12
103
  playbackStallDurationMs: 0,
104
+ errorCount: 0,
105
+ localOverflow: 0,
106
+ invalidEvents: 0,
13
107
  errorCodes: [],
14
108
  qualityChangeCount: 0,
15
109
  autoQualityChangeCount: 0,
@@ -27,12 +121,57 @@ function createDefaultIdFactory() {
27
121
  };
28
122
  }
29
123
  function sanitizeEvent(event) {
30
- if (event.event !== "error") return event;
31
- const { cause: _cause, ...payload } = event.payload;
32
- return {
33
- event: "error",
34
- payload
35
- };
124
+ switch (event.event) {
125
+ case "error": {
126
+ const { code, category, retryable } = event.payload;
127
+ return {
128
+ event: "error",
129
+ payload: {
130
+ code,
131
+ category,
132
+ retryable,
133
+ message: code
134
+ }
135
+ };
136
+ }
137
+ case "kernelhealth": return {
138
+ ...event,
139
+ payload: {
140
+ ...event.payload,
141
+ detail: ""
142
+ }
143
+ };
144
+ case "compatwarning": return {
145
+ event: "compatwarning",
146
+ payload: {
147
+ code: event.payload.code,
148
+ message: event.payload.code,
149
+ ua: ""
150
+ }
151
+ };
152
+ case "ready": return {
153
+ ...event,
154
+ payload: {
155
+ ...event.payload,
156
+ quality: event.payload.quality.slice(0, 128).map(({ label: _label, ...quality }) => quality),
157
+ ...event.payload.subtitles ? { subtitles: event.payload.subtitles.slice(0, 128).map((track) => ({
158
+ ...track,
159
+ label: "",
160
+ locale: /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i.test(track.locale) ? track.locale.slice(0, 32) : "und"
161
+ })) } : {}
162
+ }
163
+ };
164
+ case "useraction": return {
165
+ ...event,
166
+ payload: {
167
+ ...event.payload,
168
+ source: "player",
169
+ from: typeof event.payload.from === "string" ? null : event.payload.from,
170
+ to: typeof event.payload.to === "string" ? null : event.payload.to
171
+ }
172
+ };
173
+ default: return event;
174
+ }
36
175
  }
37
176
  function unknown() {
38
177
  return { status: "unknown" };
@@ -107,37 +246,45 @@ function createTelemetrySession(options) {
107
246
  }
108
247
  aggregate.startedAtMs ??= now();
109
248
  }
110
- function markSinkFailure() {
111
- aggregate.sinkFailures += 1;
249
+ function markSinkFailure(target) {
250
+ target.sinkFailures += 1;
251
+ const summary = target.summary;
252
+ if (!summary) return;
253
+ summary.completeness.sinkFailures = target.sinkFailures;
254
+ summary.completeness.status = "partial";
255
+ if (!summary.completeness.missing.includes("sink_failure")) summary.completeness.missing.push("sink_failure");
112
256
  }
113
- function emitRecord(record) {
257
+ function deliver(target, send) {
114
258
  try {
115
- options.sink?.onRecord?.(record);
259
+ const pending = send();
260
+ if (pending) Promise.resolve(pending).catch(() => markSinkFailure(target));
116
261
  } catch {
117
- markSinkFailure();
262
+ markSinkFailure(target);
118
263
  }
119
264
  }
265
+ function emitRecord(record) {
266
+ deliver(aggregate, () => options.sink?.onRecord?.(record));
267
+ }
120
268
  function recordEventFact(event) {
121
269
  if (event.event === "firstframe") aggregate.firstFrameMs ??= event.payload.fvt;
122
- if (event.event === "stalled" && event.payload.phase === "end" && event.payload.kind === "playback") {
123
- aggregate.playbackStallCount += 1;
124
- aggregate.playbackStallDurationMs += event.payload.durationMs ?? 0;
270
+ if (event.event === "stalled" && event.payload.kind === "playback") {
271
+ aggregate.incompleteStall ||= event.payload.phase === "start" && aggregate.unclosedStall || event.payload.phase === "end" && event.payload.durationMs === void 0;
272
+ aggregate.unclosedStall = event.payload.phase === "start";
273
+ if (event.payload.phase === "end" && event.payload.durationMs !== void 0) {
274
+ aggregate.playbackStallCount += 1;
275
+ aggregate.playbackStallDurationMs += event.payload.durationMs;
276
+ }
125
277
  }
126
278
  if (event.event === "error") {
127
- aggregate.errorCodes.push(event.payload.code);
279
+ aggregate.errorCount += 1;
280
+ if (!aggregate.errorCodes.includes(event.payload.code)) aggregate.errorCodes.push(event.payload.code);
128
281
  if (aggregate.firstFrameMs === void 0 && !event.payload.retryable) aggregate.firstNonRetryableError ??= event.payload.code;
129
282
  }
130
283
  if (event.event === "qualitychange") {
131
284
  aggregate.qualityChangeCount += 1;
132
285
  if (event.payload.auto) aggregate.autoQualityChangeCount += 1;
133
286
  }
134
- if (event.event === "recovery") {
135
- const { phase } = event.payload;
136
- if (phase === "recovered" || phase === "failed" || phase === "cancelled") aggregate.lastRecovery = {
137
- recoveryId: event.payload.recoveryId,
138
- phase
139
- };
140
- }
287
+ if (event.event === "recovery") aggregate.recovery.record(event.payload);
141
288
  if (event.event === "kernelhealth") aggregate.kernelHealth = {
142
289
  degraded: event.payload.degraded,
143
290
  reason: event.payload.reason
@@ -157,12 +304,18 @@ function createTelemetrySession(options) {
157
304
  }
158
305
  function buildSummary(reason) {
159
306
  const missing = [];
307
+ const recovery = aggregate.recovery.summarize();
308
+ if (recovery?.unclosed) missing.push("unclosed_episode");
309
+ if (recovery?.incomplete) missing.push("recovery_incomplete");
310
+ if (recovery?.overflowEvents || aggregate.localOverflow) missing.push("local_overflow");
311
+ if (aggregate.invalidEvents) missing.push("invalid_event");
312
+ if (aggregate.unclosedStall || aggregate.incompleteStall) missing.push("unclosed_stall");
160
313
  const contextUnattributed = aggregate.unattributedRecord || !aggregate.currentContext;
161
314
  if (aggregate.generatedDelivery) missing.push("deduplication_unknown");
162
315
  if (contextUnattributed) missing.push("context_unattributed");
163
316
  if (aggregate.sinkFailures > 0) missing.push("sink_failure");
164
317
  return {
165
- schemaVersion: "1.0",
318
+ schemaVersion: "2.0",
166
319
  application: options.application,
167
320
  ...aggregate.currentContext || aggregate.sourceRoute ? { sessionId: aggregate.currentContext?.sessionId ?? aggregate.sourceRoute?.sessionId } : {},
168
321
  startedAtMs: aggregate.startedAtMs ?? now(),
@@ -179,7 +332,7 @@ function createTelemetrySession(options) {
179
332
  totalDurationMs: aggregate.playbackStallDurationMs
180
333
  }),
181
334
  errors: aggregate.errorCodes.length === 0 ? unknown() : observed({
182
- count: aggregate.errorCodes.length,
335
+ count: aggregate.errorCount,
183
336
  codes: aggregate.errorCodes
184
337
  }),
185
338
  qualityChanges: aggregate.qualityChangeCount === 0 ? unknown() : observed({
@@ -187,7 +340,7 @@ function createTelemetrySession(options) {
187
340
  autoCount: aggregate.autoQualityChangeCount,
188
341
  manualCount: aggregate.qualityChangeCount - aggregate.autoQualityChangeCount
189
342
  }),
190
- recovery: aggregate.lastRecovery ? observed(aggregate.lastRecovery) : unknown(),
343
+ recovery: recovery ? observed(recovery) : unknown(),
191
344
  health: {
192
345
  kernel: aggregate.kernelHealth ? observed(aggregate.kernelHealth) : unknown(),
193
346
  audio: aggregate.audioHealth ? observed(aggregate.audioHealth) : unknown(),
@@ -204,7 +357,9 @@ function createTelemetrySession(options) {
204
357
  missing,
205
358
  deduplication: aggregate.generatedDelivery ? "unknown" : "verified",
206
359
  context: contextUnattributed ? "unattributed" : "observed",
207
- sinkFailures: aggregate.sinkFailures
360
+ sinkFailures: aggregate.sinkFailures,
361
+ invalidEvents: aggregate.invalidEvents,
362
+ localOverflow: aggregate.localOverflow + (recovery?.overflowEvents ?? 0)
208
363
  }
209
364
  };
210
365
  }
@@ -212,27 +367,33 @@ function createTelemetrySession(options) {
212
367
  if (completedSummary) return completedSummary;
213
368
  const summary = buildSummary(reason);
214
369
  completedSummary = summary;
215
- try {
216
- options.sink?.onSummary?.(summary);
217
- } catch {
218
- markSinkFailure();
219
- summary.completeness.sinkFailures = aggregate.sinkFailures;
220
- summary.completeness.status = "partial";
221
- if (!summary.completeness.missing.includes("sink_failure")) summary.completeness.missing.push("sink_failure");
222
- }
370
+ aggregate.summary = summary;
371
+ deliver(aggregate, () => options.sink?.onSummary?.(summary));
223
372
  return summary;
224
373
  }
225
374
  return {
226
375
  record(event, metadata) {
227
- const parsed = PlayerEventSchema.parse(event);
376
+ const validation = PlayerEventSchema.safeParse(event);
377
+ if (!validation.success) {
378
+ prepareForInput();
379
+ aggregate.invalidEvents += 1;
380
+ return null;
381
+ }
382
+ const parsed = validation.data;
383
+ if (completedSummary) prepareForInput();
228
384
  const deliveryId = metadata?.deliveryId;
385
+ const producerDelivery = metadata?.producerDelivery;
386
+ const identitySession = "sessionId" in parsed.payload ? parsed.payload.sessionId : aggregate.currentContext?.sessionId ?? aggregate.sourceRoute?.sessionId ?? "";
387
+ const deliveryKey = JSON.stringify(producerDelivery ? [producerDelivery.producerSessionId, producerDelivery.deliveryId] : [identitySession, deliveryId]);
229
388
  if (deliveryId) {
230
- const duplicate = seenDeliveries.get(deliveryId);
389
+ const duplicate = seenDeliveries.get(deliveryKey);
231
390
  if (duplicate) return duplicate;
232
391
  }
233
392
  if (parsed.event === "contextchange" && aggregate.currentContext && aggregate.currentContext.sessionId !== parsed.payload.sessionId && !completedSummary) finish("source_changed");
234
393
  prepareForInput();
235
394
  const isPendingSourceRoute = parsed.event === "sourceroute" && !!aggregate.currentContext && aggregate.currentContext.sessionId !== parsed.payload.sessionId;
395
+ const currentSessionId = aggregate.currentContext?.sessionId ?? aggregate.sourceRoute?.sessionId;
396
+ const isForeignRecovery = parsed.event === "recovery" && currentSessionId !== void 0 && currentSessionId !== parsed.payload.sessionId;
236
397
  if (isPendingSourceRoute) pendingSourceRoute = parsed.payload;
237
398
  if (parsed.event === "sourceroute" && !isPendingSourceRoute) aggregate.sourceRoute = parsed.payload;
238
399
  if (parsed.event === "contextchange") {
@@ -240,31 +401,49 @@ function createTelemetrySession(options) {
240
401
  if (pendingSourceRoute?.sessionId === parsed.payload.sessionId) aggregate.sourceRoute = pendingSourceRoute;
241
402
  pendingSourceRoute = void 0;
242
403
  }
404
+ if (parsed.event === "ready" && (parsed.payload.quality.length > 128 || (parsed.payload.subtitles?.length ?? 0) > 128)) aggregate.localOverflow += 1;
243
405
  sequence += 1;
244
406
  const record = {
245
407
  kind: "player_event",
246
- eventId: deliveryId ?? idFactory(),
247
- schemaVersion: "1.0",
248
- occurredAtMs: metadata?.occurredAtMs ?? now(),
408
+ eventId: producerDelivery ? `${producerDelivery.producerSessionId}:${producerDelivery.deliveryId}` : deliveryId ?? idFactory(),
409
+ schemaVersion: "2.0",
410
+ occurredAtMs: producerDelivery?.occurredAtMs ?? metadata?.occurredAtMs ?? now(),
249
411
  sequence,
250
412
  application: options.application,
251
413
  event: sanitizeEvent(parsed),
252
- ...aggregate.currentContext && !isPendingSourceRoute ? {
414
+ ...aggregate.currentContext && !isPendingSourceRoute && !isForeignRecovery ? {
253
415
  context: aggregate.currentContext,
254
416
  contextStatus: "observed"
255
417
  } : { contextStatus: "unattributed" },
256
- delivery: deliveryId ? "stable" : "generated"
418
+ delivery: deliveryId ? "stable" : "generated",
419
+ ...producerDelivery ? { producerDelivery } : {}
257
420
  };
258
- if (!isPendingSourceRoute) {
421
+ if (!isPendingSourceRoute && !isForeignRecovery) {
259
422
  aggregate.eventCount += 1;
260
423
  aggregate.generatedDelivery ||= !deliveryId;
261
424
  aggregate.unattributedRecord ||= record.contextStatus === "unattributed" && parsed.event !== "sourceroute";
262
425
  recordEventFact(record.event);
263
426
  }
264
- if (deliveryId) seenDeliveries.set(deliveryId, record);
427
+ if (deliveryId) if (seenDeliveries.size < 1024) seenDeliveries.set(deliveryKey, record);
428
+ else {
429
+ aggregate.localOverflow += 1;
430
+ aggregate.generatedDelivery = true;
431
+ }
265
432
  emitRecord(record);
266
433
  return record;
267
434
  },
435
+ recordDelivered(event) {
436
+ return this.record(event.event, {
437
+ deliveryId: event.deliveryId,
438
+ occurredAtMs: event.occurredAtMs,
439
+ producerDelivery: {
440
+ producerSessionId: event.producerSessionId,
441
+ deliveryId: event.deliveryId,
442
+ occurredAtMs: event.occurredAtMs,
443
+ sequence: event.sequence
444
+ }
445
+ });
446
+ },
268
447
  recordIntent(intent) {
269
448
  prepareForInput();
270
449
  const projectedIntent = projectIntent(intent);
@@ -272,7 +451,7 @@ function createTelemetrySession(options) {
272
451
  const record = {
273
452
  kind: "intent",
274
453
  eventId: idFactory(),
275
- schemaVersion: "1.0",
454
+ schemaVersion: "2.0",
276
455
  occurredAtMs: now(),
277
456
  sequence,
278
457
  application: options.application,
@@ -286,7 +465,8 @@ function createTelemetrySession(options) {
286
465
  aggregate.intentCount += 1;
287
466
  aggregate.generatedDelivery = true;
288
467
  aggregate.unattributedRecord ||= record.contextStatus === "unattributed";
289
- aggregate.intents.push(projectedIntent);
468
+ if (aggregate.intents.length < 128) aggregate.intents.push(projectedIntent);
469
+ else aggregate.localOverflow += 1;
290
470
  emitRecord(record);
291
471
  return record;
292
472
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@video-lab/telemetry",
3
- "version": "2.0.0",
3
+ "version": "3.0.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "sideEffects": false,
26
26
  "dependencies": {
27
- "@video-lab/protocol": "2.0.0"
27
+ "@video-lab/protocol": "3.0.1"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsdown": "^0.22.9",