@video-lab/telemetry 1.0.1 → 3.0.0
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 +37 -5
- package/dist/index.cjs +226 -46
- package/dist/index.d.cts +51 -11
- package/dist/index.d.mts +51 -11
- package/dist/index.mjs +226 -46
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,12 +4,23 @@
|
|
|
4
4
|
[`@video-lab/protocol`](https://www.npmjs.com/package/@video-lab/protocol),不发送 HTTP,
|
|
5
5
|
不依赖 Sentry、React、Vue 或播放器内核。
|
|
6
6
|
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @video-lab/telemetry
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
业务应用通常将 `onRecord` 和 `onSummary` 接到已有的传输层;本包不负责网络请求或供应商 SDK 初始化。
|
|
14
|
+
|
|
15
|
+
## 用法
|
|
16
|
+
|
|
7
17
|
提供安全的原始事件记录、QoE 会话摘要和受限业务 intent:
|
|
8
18
|
|
|
9
19
|
<!-- doc-snippet-preamble
|
|
10
20
|
import type { PlayerEvent } from '@video-lab/protocol'
|
|
11
21
|
declare const BUILD_SHA: string
|
|
12
22
|
declare const event: PlayerEvent
|
|
23
|
+
declare const deliveredEvent: import('@video-lab/protocol').DeliveredPlayerEvent
|
|
13
24
|
declare function queueForDelivery(input: unknown): void
|
|
14
25
|
declare function queueSessionSummary(input: unknown): void
|
|
15
26
|
-->
|
|
@@ -25,16 +36,23 @@ const telemetry = createTelemetrySession({
|
|
|
25
36
|
},
|
|
26
37
|
})
|
|
27
38
|
|
|
28
|
-
//
|
|
39
|
+
// 新 delivered 出口可直接绑定,保留 producer 时间、顺序与稳定投递 ID。
|
|
40
|
+
telemetry.recordDelivered(deliveredEvent)
|
|
41
|
+
// 旧 raw onPlayerEvent / player-event / embed.onAny 仍可调用 record(event)。
|
|
29
42
|
telemetry.record(event)
|
|
30
43
|
telemetry.recordIntent({ type: 'retry', origin: 'user' })
|
|
31
44
|
telemetry.finish({ reason: 'page_unload' })
|
|
32
45
|
```
|
|
33
46
|
|
|
34
|
-
|
|
47
|
+
## 数据与隐私
|
|
48
|
+
|
|
49
|
+
每条 record 有 `eventId`、本地递增 `sequence`、时间、应用版本和最近的脱敏播放上下文。`recordDelivered()` 产生的 record 还带 `producerDelivery`(producer session、delivery id、源端时间与 producer sequence),供 sink 排序、去重与识别迟到事件。`error.payload.cause`
|
|
35
50
|
会被移除;不会输出 URL query、签名、cookie、user ID 或业务 tags。
|
|
36
51
|
|
|
37
|
-
|
|
52
|
+
## 会话行为
|
|
53
|
+
|
|
54
|
+
优先使用 `recordDelivered(deliveredEvent)`;它以 producer session 与 delivery id 去重,并在 `producerDelivery` 中保留完整生产端证据。raw `record()` 保持既有形状,不伪造该证据。
|
|
55
|
+
若宿主的传输层另有稳定 delivery id,也可传入 `telemetry.record(event, { deliveryId })` 来安全地去重重试。
|
|
38
56
|
没有该 id 时,相同 payload 仍可能是合法的不同媒体事件,本包不会猜测并丢弃它。
|
|
39
57
|
|
|
40
58
|
`finish()` 是幂等的:同一会话重复调用会返回同一份 summary。新的 `contextchange.sessionId` 会自动以
|
|
@@ -48,12 +66,26 @@ telemetry.finish({ reason: 'page_unload' })
|
|
|
48
66
|
换源时,新 session 的 route 会等同 sessionId 的 `contextchange` 再归属,不能覆盖旧会话;未形成新 context
|
|
49
67
|
的尝试仍会作为原始 record 送给 sink。
|
|
50
68
|
|
|
69
|
+
## 边界
|
|
70
|
+
|
|
51
71
|
`recordIntent()` 只接受固定的 `play`、`pause`、`seek`、`retry`、`source_change`、`exit` 和
|
|
52
72
|
`user` / `api` origin。任意额外字段不会进入 record。sink 的异常被隔离,不会中断播放器;摘要的
|
|
53
73
|
`completeness` 会报告 context、delivery-id 或 sink 证据缺口。
|
|
54
74
|
|
|
75
|
+
`onRecord` / `onSummary` 可以返回 `Promise<void>`。同步抛错和异步拒绝都计入产生该次投递的会话;换源后返回的失败不会污染新会话。若拒绝发生在 `finish()` 之后,已返回 summary 的 `completeness` 会更新,但不会再次调用 `onSummary`。因此摘要序列化后的副本不会自动补写,上传结果及重传仍由宿主传输层管理。
|
|
76
|
+
|
|
55
77
|
本包不是网络上报客户端。若使用 Sentry,请在业务项目中按自己的已初始化 client 实现 sink,
|
|
56
|
-
不要把 `@sentry/*`
|
|
57
|
-
请联系项目维护团队获取会话遥测接入的内部使用手册。
|
|
78
|
+
不要把 `@sentry/*` 带进本包。跨框架映射规则与完整接入示例请向项目维护团队获取。
|
|
58
79
|
|
|
59
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 = "
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
258
|
+
function deliver(target, send) {
|
|
115
259
|
try {
|
|
116
|
-
|
|
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.
|
|
124
|
-
aggregate.
|
|
125
|
-
aggregate.
|
|
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.
|
|
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: "
|
|
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.
|
|
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:
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
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(
|
|
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: "
|
|
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(
|
|
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: "
|
|
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: "
|
|
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:
|
|
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: "
|
|
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:
|
|
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 = "
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
257
|
+
function deliver(target, send) {
|
|
114
258
|
try {
|
|
115
|
-
|
|
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.
|
|
123
|
-
aggregate.
|
|
124
|
-
aggregate.
|
|
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.
|
|
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: "
|
|
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.
|
|
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:
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
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(
|
|
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: "
|
|
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(
|
|
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: "
|
|
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": "
|
|
3
|
+
"version": "3.0.0",
|
|
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": "
|
|
27
|
+
"@video-lab/protocol": "3.0.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"tsdown": "^0.22.9",
|