@video-lab/telemetry 3.1.0 → 4.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
@@ -1,8 +1,8 @@
1
1
  # @video-lab/telemetry
2
2
 
3
- 宿主侧的播放器会话遥测核心。它只依赖
4
- [`@video-lab/protocol`](https://www.npmjs.com/package/@video-lab/protocol),不发送 HTTP,
5
- 不依赖 Sentry、React、Vue 或播放器内核。
3
+ 宿主应用的播放器会话遥测核心。它把五种接入方式交付的 `PlayerEvent` 归一为安全、可关联的 record,并在会话结束时生成 QoE summary。它只依赖 [`@video-lab/protocol`](https://www.npmjs.com/package/@video-lab/protocol),不依赖播放器内核、React、Vue、Sentry 或 HTTP 客户端。
4
+
5
+ 它解决“播放器发生了什么、用户体验如何、是否恢复、发生在哪个宿主版本”的本地事实归一;它不解决网络上传、鉴权、采样、队列、重试或告警平台初始化。这些仍由宿主应用的传输层负责。
6
6
 
7
7
  ## 安装
8
8
 
@@ -10,17 +10,39 @@
10
10
  pnpm add @video-lab/telemetry
11
11
  ```
12
12
 
13
- 业务应用通常将 `onRecord` 和 `onSummary` 接到已有的传输层;本包不负责网络请求或供应商 SDK 初始化。
13
+ ## 上报范围:四类事实,一层共同归因
14
+
15
+ | 维度 | 回答的问题 | 包输出的事实 |
16
+ | --- | --- | --- |
17
+ | Error | 什么失败了、能否重试? | 受控 `error` 事件、错误码、脱敏播放上下文;summary 的 `qoe.errors`。当前是 **19 个 `E_*` 错误码**。 |
18
+ | QoE | 用户是否顺畅观看? | 首帧、播放期卡顿、质量切换、换源、内核/音频/缓冲健康、PC 帧冻结,以及对应的 `unknown` coverage。 |
19
+ | Behavior | 用户或宿主做了什么? | SDK 控件产生的 `useraction` 原始事件;宿主显式写入的受限 `intent`。 |
20
+ | Recovery | 自动恢复有没有真正恢复观看? | `recovery` 生命周期、尝试/终态和“播放且位置推进”的恢复证据。 |
21
+ | 共同归因 | 发生在哪里、数据是否可信? | `application`、脱敏 `PlaybackContext`、`sessionId`、`eventId`、`sequence`、delivery 证据与 `completeness`。 |
22
+
23
+ 另有 **3 个 `W_*` 警告码**。错误码与警告码的完整定义、分类和可重试性以仓库的 `docs/protocol/errors.md` 和 `@video-lab/protocol` 的 `ERROR_META` 为准;不要自行拼错误文案或新增 code。
24
+
25
+ ### QoE 的具体行为
14
26
 
15
- ## 用法
27
+ `TelemetrySessionSummary.qoe` 覆盖:
16
28
 
17
- 提供安全的原始事件记录、QoE 会话摘要和受限业务 intent:
29
+ - `firstFrameMs`:首帧可见耗时;
30
+ - `playbackStalls`:只累计 `kind='playback'` 的已结束卡顿,不把首播或 seek 等待混入;
31
+ - `errors`:错误次数和有限错误码集合;
32
+ - `qualityChanges`:自动与手动清晰度切换次数;
33
+ - `sourceSwitches`:授权换源的尝试、成功/失败/取消、热切/重建和耗时;
34
+ - `recovery`:恢复轮次、策略、终态和不完整/溢出事实;
35
+ - `health.kernel`、`health.audio`、`health.buffer`、`health.frameFreezes`:内核、音频、缓冲和帧冻结健康信号。
36
+
37
+ 某个平台没有量具时,值是 `{ status: 'unknown' }`,不是 `0`。例如帧冻结目前是 PC-only;把缺失填成零会把覆盖率问题误判为体验良好。
38
+
39
+ ## 最小接入
40
+
41
+ 将唯一完整 `PlayerEvent` 事件流交给同一个会话。新版 SDK 事件的顶层 `delivery` 含生产端的 session、delivery ID、时间和 sequence,可让接收端可靠排序、去重和识别迟到事件。
18
42
 
19
43
  <!-- doc-snippet-preamble
20
44
  import type { PlayerEvent } from '@video-lab/protocol'
21
45
  declare const BUILD_SHA: string
22
- declare const event: PlayerEvent
23
- declare const deliveredEvent: import('@video-lab/protocol').DeliveredPlayerEvent
24
46
  declare function queueForDelivery(input: unknown): void
25
47
  declare function queueSessionSummary(input: unknown): void
26
48
  -->
@@ -29,6 +51,7 @@ declare function queueSessionSummary(input: unknown): void
29
51
  import { createTelemetrySession } from '@video-lab/telemetry'
30
52
 
31
53
  const telemetry = createTelemetrySession({
54
+ // release 是宿主应用版本,不是 telemetry 包或播放器包版本。
32
55
  application: { name: 'web-console', release: BUILD_SHA },
33
56
  sink: {
34
57
  onRecord: queueForDelivery,
@@ -36,56 +59,152 @@ const telemetry = createTelemetrySession({
36
59
  },
37
60
  })
38
61
 
39
- // 新 delivered 出口可直接绑定,保留 producer 时间、顺序与稳定投递 ID。
40
- telemetry.recordDelivered(deliveredEvent)
41
- // 旧 raw onPlayerEvent / player-event / embed.onAny 仍可调用 record(event)。
42
- telemetry.record(event)
62
+ // React / Vue / 静态 iframe 统一使用完整事件出口。
63
+ function onPlayerEvent(event: PlayerEvent): void {
64
+ telemetry.record(event)
65
+ }
66
+
67
+ // 业务自定义 UI、快捷键或 API 调用必须在发生处显式记录意图。
43
68
  telemetry.recordIntent({ type: 'retry', origin: 'user' })
44
- telemetry.finish({ reason: 'page_unload' })
69
+
70
+ // 页面退出时才收口本会话,不要在创建 telemetry 后立即 finish。
71
+ window.addEventListener('pagehide', () => {
72
+ telemetry.finish({ reason: 'page_unload' })
73
+ }, { once: true })
45
74
  ```
46
75
 
47
- ## 数据与隐私
76
+ 五种接入面的完整入口分别是 React 的 `onPlayerEvent`、Vue 的 `@player-event`,以及静态 iframe 的 `embed.onAny()`。旧 iframe 事件若无 `delivery`,`record()` 会如实标注去重不可验证。不要只监听 `onStalled` 等单项 UI 回调来拼会话。
77
+ 组件级播放器还应在 React effect cleanup 或 Vue `onScopeDispose()` 中以 `reason: 'destroyed'`
78
+ 收口;静态 iframe 同时退订监听器并销毁控制器。
79
+
80
+ ## 宿主需要准备什么
48
81
 
49
- 每条 record 有 `eventId`、本地递增 `sequence`、时间、应用版本和最近的脱敏播放上下文。`recordDelivered()` 产生的 record 还带 `producerDelivery`(producer session、delivery id、源端时间与 producer sequence),供 sink 排序、去重与识别迟到事件。`error.payload.cause`
50
- 会被移除;不会输出 URL query、签名、cookie、user ID 或业务 tags。
82
+ `application.name` 是稳定的宿主名称;`application.release` 是宿主构建/发布版本,未提供时本包不会猜测。这样上报系统可以按“哪个业务应用的哪个版本”分组。
51
83
 
52
- ## 会话行为
84
+ 播放器包版本、iframe artifact 版本、业务意图、用户/内容关联等不属于 `TelemetryApplication`。需要它们时,在宿主传输封装中添加,而不要伪造成 SDK 字段或反向塞进播放器配置:
85
+
86
+ <!-- doc-snippet-preamble
87
+ declare const packageRelease: string
88
+ declare const iframeArtifactVersion: string | undefined
89
+ -->
53
90
 
54
- 优先使用 `recordDelivered(deliveredEvent)`;它以 producer session 与 delivery id 去重,并在 `producerDelivery` 中保留完整生产端证据。raw `record()` 保持既有形状,不伪造该证据。
55
- 若宿主的传输层另有稳定 delivery id,也可传入 `telemetry.record(event, { deliveryId })` 来安全地去重重试。
56
- 没有该 id 时,相同 payload 仍可能是合法的不同媒体事件,本包不会猜测并丢弃它。
91
+ ```ts
92
+ import type { TelemetryRecord, TelemetrySessionSummary } from '@video-lab/telemetry'
93
+
94
+ type HostTelemetryEnvelope = {
95
+ receivedAtMs: number
96
+ player: {
97
+ packageRelease: string
98
+ iframeArtifactRelease?: string
99
+ }
100
+ payload:
101
+ | { kind: 'record'; value: TelemetryRecord }
102
+ | { kind: 'summary'; value: TelemetrySessionSummary }
103
+ }
104
+
105
+ function wrapRecord(record: TelemetryRecord): HostTelemetryEnvelope {
106
+ return {
107
+ receivedAtMs: Date.now(),
108
+ player: { packageRelease, iframeArtifactRelease: iframeArtifactVersion },
109
+ payload: { kind: 'record', value: record },
110
+ }
111
+ }
112
+ ```
57
113
 
58
- `finish()` 是幂等的:同一会话重复调用会返回同一份 summary。新的 `contextchange.sessionId` 会自动以
59
- `source_changed` 收口旧会话,并开始新会话。`TelemetrySessionSummary.qoe` 中的每一项都是
60
- `{ status: 'observed', value }` 或 `{ status: 'unknown' }`;没有收到平台专属信号(例如 PC-only
61
- `framefreeze`)时绝不会伪造 `0`。
114
+ 接收端至少应保存下列字段:
62
115
 
63
- `summary.support` 是收到 `sourceroute` 后的实际选源事实;它与是否显示画面分开。`summary.startup` 只有
64
- `firstframe` 才是 `started`,构造前 `unsupported` 或首帧前不可重试错误才是 `failed`,可重试错误和缺失
65
- 事件保持 `unknown`。计算启动成功率时,分母只能取 `started | failed`,必须单列 `unknown` coverage。
66
- 换源时,新 session 的 route 会等同 sessionId 的 `contextchange` 再归属,不能覆盖旧会话;未形成新 context
67
- 的尝试仍会作为原始 record 送给 sink。
116
+ | 字段 | 来源 | 用途 |
117
+ | --- | --- | --- |
118
+ | `application.name` / `application.release` | 创建 session 时由宿主提供 | 业务应用和宿主版本分桶。 |
119
+ | `eventId`、`occurredAtMs`、`sequence` | telemetry record | 幂等、排序、排查迟到事件。 |
120
+ | `producerDelivery` | `record(event)` 读取 `event.delivery` | iframe 跨重连时的稳定去重证据;旧事件没有时不能臆造。 |
121
+ | `context.sessionId`、kernel、媒体类型、脱敏 origin/path | SDK 实际上下文 | 会话、内核、内容路径归因。 |
122
+ | `event` / `intent` | player record | 原始播放器事实或受限业务动作。 |
123
+ | `qoe`、`support`、`startup`、`completeness` | session summary | QoE 聚合、能力 coverage 与数据可信度判断。 |
124
+ | 外层 `player.packageRelease` / `iframeArtifactRelease` | 宿主传输封装 | 播放器、iframe 版本的发布回归分析。 |
68
125
 
69
- ## 边界
126
+ 不要把 URL query/hash、签名、token、cookie、`userId`、任意业务 tags、原始 `error.cause` 或内核自由文本放进 record。包已经移除一部分敏感信息;宿主出口仍要按自己的隐私规范做二次控制。
70
127
 
71
- `recordIntent()` 只接受固定的 `play`、`pause`、`seek`、`retry`、`source_change`、`exit` 和
72
- `user` / `api` origin。任意额外字段不会进入 record。sink 的异常被隔离,不会中断播放器;摘要的
73
- `completeness` 会报告 context、delivery-id 或 sink 证据缺口。
128
+ ## 输出结构与行为边界
74
129
 
75
- `onRecord` / `onSummary` 可以返回 `Promise<void>`。同步抛错和异步拒绝都计入产生该次投递的会话;换源后返回的失败不会污染新会话。若拒绝发生在 `finish()` 之后,已返回 summary 的 `completeness` 会更新,但不会再次调用 `onSummary`。因此摘要序列化后的副本不会自动补写,上传结果及重传仍由宿主传输层管理。
130
+ `onRecord` 接收两种 `TelemetryRecord`:
76
131
 
77
- 本包不是网络上报客户端。若使用 Sentry,请在业务项目中按自己的已初始化 client 实现 sink,
78
- 不要把 `@sentry/*` 带进本包。跨框架映射规则与完整接入示例请向项目维护团队获取。
132
+ | `kind` | 结构 | 何时产生 |
133
+ | --- | --- | --- |
134
+ | `player_event` | 公共基字段 + 安全 `PlayerEvent`;有生产端证据时包含 `producerDelivery` | 每一个通过校验的原始播放器事件。 |
135
+ | `intent` | 公共基字段 + `{ type, origin }` | 宿主调用 `recordIntent()`。 |
79
136
 
80
- MIT
137
+ 公共基字段为 `eventId`、`schemaVersion: '2.0'`、`occurredAtMs`、递增 `sequence`、`application`、可选 `context`、`delivery: 'stable' | 'generated'` 和 `contextStatus: 'observed' | 'unattributed'`。
81
138
 
139
+ `onSummary` 接收一份 `TelemetrySessionSummary`:`support` 是实际选源事实,`startup` 只有首帧才为 `started`;不可重试首帧前错误或构造前不支持才为 `failed`,其余未知情况保持 `unknown`。`completeness` 会表明 `deduplication_unknown`、`context_unattributed`、`sink_failure`、未闭合卡顿/换源、恢复不完整、本地溢出或非法事件等缺口。接收端不能把 `partial` summary 当成完整 QoE 样本。
82
140
 
83
- ### schemaVersion 2.0
141
+ 行为观测分两部分:SDK 内置控件的 `useraction` 已在完整 `PlayerEvent` 中;宿主自定义按钮、快捷键和 API 调用应只写 `recordIntent({ type, origin })`。允许的 type 是 `play`、`pause`、`seek`、`retry`、`source_change`、`exit`,origin 是 `user` 或 `api`;任意 tags、用户 ID 和自由 payload 都不会进入 telemetry。
84
142
 
85
- `record()` 接受未知输入并校验;非法事件返回 `null`,计入 `completeness.invalidEvents` 与 `invalid_event`,不会调用 sink。错误 message 投影为稳定 code,cause、内核 detail、兼容 UA/原文、字幕/清晰度标签和用户动作自由文本不进入自动外发记录。源 origin/path 沿用协议的脱敏事实;业务路径仍可能包含敏感段,宿主出口应按自身规则进一步省略。
143
+ `recovery.recovered` 不是收到了单个 `playing` / `canplay` 就成立:它需要 `playing_position_advance` 验证。403/鉴权错误也不会自动变成“已恢复”;宿主刷新签名、换源或业务重试后,仍应以 recovery 生命周期的真实终态判断。
86
144
 
87
- `qoe.recovery` 除最近终态外包含 episodes、实际观测 attempts、automatic/manual、durationMs、unclosed、incomplete 和 overflowEvents。只有完整观测轮次进入 recovered/failed 分组;成功率按对应组 recovered/(recovered+failed) 计算,分母为零为 N/A。取消、晚订阅和未闭合不会伪造成功或失败。
145
+ ## 高频与网络安全:宿主必须做的事
88
146
 
89
- 每会话最多保留 128 个恢复轮次、每轮 64 个动作标识、1024 个 delivery 缓存、128 条意图及 128 个 ready 轨道项;溢出在 `localOverflow` / `local_overflow` 中报告,去重无法证明时标记 unknown。error count 保留事件次数,codes 为有限错误码集合。未结束的 playback stall 标记 `unclosed_stall`,不把缺失时长当作零。
147
+ `onRecord` 是**本地观察回调**,不是“一次回调发一次 HTTP”的授权。每个有效 `PlayerEvent` 都会调用它;`timeupdate` 常见约 4 次/秒,即单播放器约 240 次/分钟。包内的 128/1024 等上限是会话聚合和 delivery cache 的内存边界,**不是**网络批量、节流、采样或重试策略。
90
148
 
91
- 参考 Sentry sink 把可恢复 error 作为诊断 breadcrumb,failed recovery 按 `(sessionId,recoveryId)` 去重为事故;不可恢复 error 直接上报。使用 telemetry 作为调用边界,以便记录 sink 失败;本包不发送 HTTP,也不保证服务器已经接收。
149
+ 推荐按优先级分流:
150
+
151
+ | 优先级 | 数据 | 建议宿主处理 |
152
+ | --- | --- | --- |
153
+ | P0 | 不可重试 error、`recovery.failed` | 进入错误平台;可立即发送,但要去重、限并发和保留失败计数。 |
154
+ | P1 | `finish()` summary、已结束卡顿、健康退化、换源终态 | 入有界队列,按批量或短周期发送到 QoE/数仓管道。 |
155
+ | P2 | `timeupdate`、progress、一般播放事件、逐条 intent | 默认不逐条上网;只作本地聚合、抽样或按会话摘要消费。 |
156
+
157
+ 下面是 sink 的接口样板。`HostTelemetryQueue` 必须由业务基础设施实现;它的 `enqueue()` 应是同步、快速且有界的,`flush()` 才进行网络工作。`onSummary` 主动 flush,确保会话结束尽快出队:
158
+
159
+ ```ts
160
+ import type {
161
+ TelemetryRecord,
162
+ TelemetrySessionSummary,
163
+ TelemetrySink,
164
+ } from '@video-lab/telemetry'
165
+
166
+ type QueuedTelemetry =
167
+ | { kind: 'record'; value: TelemetryRecord }
168
+ | { kind: 'summary'; value: TelemetrySessionSummary }
169
+
170
+ interface HostTelemetryQueue {
171
+ enqueue(item: QueuedTelemetry): void
172
+ flush(): Promise<void>
173
+ }
174
+
175
+ function createBatchedTelemetrySink(queue: HostTelemetryQueue): TelemetrySink {
176
+ return {
177
+ onRecord(record) {
178
+ queue.enqueue({ kind: 'record', value: record })
179
+ },
180
+ async onSummary(summary) {
181
+ queue.enqueue({ kind: 'summary', value: summary })
182
+ await queue.flush()
183
+ },
184
+ }
185
+ }
186
+ ```
187
+
188
+ 实现这个队列时,至少明确并监控以下规则:
189
+
190
+ - 最大队列、最大 batch、最大 in-flight 数;满载时按优先级丢弃 P2,不能静默挤掉 P0/P1;
191
+ - 定时(例如 5–10 秒)或达到 batch 大小时 flush,页面卸载时尽力使用 `sendBeacon` / keepalive;
192
+ - 有上限的指数退避和重试次数,失败后停止无限重放;
193
+ - 上报 queue overflow、丢弃量、上传失败、重试次数和延迟;它们不是播放器 QoE;
194
+ - 服务端按 `eventId` 或稳定 `producerDelivery.deliveryId` 做幂等,并限制单请求 body/事件数。
195
+
196
+ sink 的同步异常和异步拒绝会被 telemetry 隔离,不会中断播放器;相应会话的 `completeness.sinkFailures` 会记录事实。但后台队列自己的失败发生在 sink 返回之后,必须由宿主队列自行监控,不能期待 telemetry 替你观测网络送达。
197
+
198
+ ## 会话生命周期与限制
199
+
200
+ 使用 `record(event)`;它从 `event.delivery` 读取 producer session 和 delivery ID 去重并保留生产端证据。若宿主从外部获得的事件没有该字段,仍可用 `record(event, { deliveryId })` 注入稳定 ID;没有该 ID 时,相同 payload 可能是两次真实媒体事实,本包不会猜测并丢弃它。
201
+
202
+ `finish()` 幂等;同一会话重复调用返回同一份 summary。新 `contextchange.sessionId` 会以 `source_changed` 收口旧会话并开始新会话。每会话最多保留 128 个恢复轮次、每轮 64 个动作标识、1024 个 delivery cache、128 条 intent 和 128 个 ready 轨道项;溢出会如实写入 `localOverflow` / `local_overflow`,不会把信息缺失伪造为成功。
203
+
204
+ ## 与 Sentry 的关系
205
+
206
+ 本包不是 Sentry adapter,也不应把 `@sentry/*` 依赖带进它。业务应用可把不可重试 error 映射为 exception,`recovery.failed` 映射为去重后的 message,把已结束卡顿/健康退化作为低频 breadcrumb,并将 summary 发往 QoE 管道。不要把每个 `timeupdate` 写成 breadcrumb;它会很快冲掉真正的错误前线索。
207
+
208
+ 参考实现位于 `examples/team-video-vue/reporting/sentry-telemetry-sink.ts`;跨框架绑定方式见仓库的 `docs/guides/USER-GUIDE.md` 与 `docs/guides/PLAYER-OBSERVABILITY-QOE.md`。
209
+
210
+ MIT
package/dist/index.cjs CHANGED
@@ -108,6 +108,18 @@ function createAggregate() {
108
108
  errorCodes: [],
109
109
  qualityChangeCount: 0,
110
110
  autoQualityChangeCount: 0,
111
+ sourceSwitchAttempts: 0,
112
+ sourceSwitchSucceeded: 0,
113
+ sourceSwitchFailed: 0,
114
+ sourceSwitchCancelled: 0,
115
+ sourceSwitchHotLoad: 0,
116
+ sourceSwitchRebuild: 0,
117
+ sourceSwitchElapsedMs: {
118
+ succeeded: 0,
119
+ failed: 0,
120
+ cancelled: 0
121
+ },
122
+ openSourceSwitches: /* @__PURE__ */ new Set(),
111
123
  frameFreezeCount: 0,
112
124
  frameFreezeDurationMs: 0,
113
125
  intents: []
@@ -236,6 +248,7 @@ function createTelemetrySession(options) {
236
248
  */
237
249
  let seenDeliveries = /* @__PURE__ */ new Map();
238
250
  let pendingSourceRoute;
251
+ let pendingSourceSwitches = /* @__PURE__ */ new Set();
239
252
  let aggregate = createAggregate();
240
253
  let sequence = 0;
241
254
  let completedSummary;
@@ -266,7 +279,7 @@ function createTelemetrySession(options) {
266
279
  function emitRecord(record) {
267
280
  deliver(aggregate, () => options.sink?.onRecord?.(record));
268
281
  }
269
- function recordEventFact(event) {
282
+ function recordEventFact(event, sourceSwitchScope) {
270
283
  if (event.event === "firstframe") aggregate.firstFrameMs ??= event.payload.fvt;
271
284
  if (event.event === "stalled" && event.payload.kind === "playback") {
272
285
  aggregate.incompleteStall ||= event.payload.phase === "start" && aggregate.unclosedStall || event.payload.phase === "end" && event.payload.durationMs === void 0;
@@ -285,6 +298,32 @@ function createTelemetrySession(options) {
285
298
  aggregate.qualityChangeCount += 1;
286
299
  if (event.payload.auto) aggregate.autoQualityChangeCount += 1;
287
300
  }
301
+ if (event.event === "sourceswitch") {
302
+ const key = `${sourceSwitchScope}:${event.payload.switchId}`;
303
+ if (event.payload.phase === "started") {
304
+ if (!aggregate.openSourceSwitches.has(key)) {
305
+ aggregate.openSourceSwitches.add(key);
306
+ aggregate.sourceSwitchAttempts += 1;
307
+ }
308
+ return;
309
+ }
310
+ if (!aggregate.openSourceSwitches.delete(key)) return;
311
+ if (event.payload.phase === "succeeded") {
312
+ aggregate.sourceSwitchSucceeded += 1;
313
+ aggregate.sourceSwitchElapsedMs.succeeded += event.payload.elapsedMs;
314
+ if (event.payload.strategy === "hot_load") aggregate.sourceSwitchHotLoad += 1;
315
+ else aggregate.sourceSwitchRebuild += 1;
316
+ }
317
+ if (event.payload.phase === "failed") {
318
+ aggregate.sourceSwitchFailed += 1;
319
+ aggregate.sourceSwitchElapsedMs.failed += event.payload.elapsedMs;
320
+ }
321
+ if (event.payload.phase === "cancelled") {
322
+ aggregate.sourceSwitchCancelled += 1;
323
+ aggregate.sourceSwitchElapsedMs.cancelled += event.payload.elapsedMs;
324
+ }
325
+ return;
326
+ }
288
327
  if (event.event === "recovery") aggregate.recovery.record(event.payload);
289
328
  if (event.event === "kernelhealth") aggregate.kernelHealth = {
290
329
  degraded: event.payload.degraded,
@@ -311,6 +350,7 @@ function createTelemetrySession(options) {
311
350
  if (recovery?.overflowEvents || aggregate.localOverflow) missing.push("local_overflow");
312
351
  if (aggregate.invalidEvents) missing.push("invalid_event");
313
352
  if (aggregate.unclosedStall || aggregate.incompleteStall) missing.push("unclosed_stall");
353
+ if (aggregate.openSourceSwitches.size > 0) missing.push("unclosed_source_switch");
314
354
  const contextUnattributed = aggregate.unattributedRecord || !aggregate.currentContext;
315
355
  if (aggregate.generatedDelivery) missing.push("deduplication_unknown");
316
356
  if (contextUnattributed) missing.push("context_unattributed");
@@ -341,6 +381,15 @@ function createTelemetrySession(options) {
341
381
  autoCount: aggregate.autoQualityChangeCount,
342
382
  manualCount: aggregate.qualityChangeCount - aggregate.autoQualityChangeCount
343
383
  }),
384
+ sourceSwitches: aggregate.sourceSwitchAttempts === 0 ? unknown() : observed({
385
+ attempts: aggregate.sourceSwitchAttempts,
386
+ succeeded: aggregate.sourceSwitchSucceeded,
387
+ failed: aggregate.sourceSwitchFailed,
388
+ cancelled: aggregate.sourceSwitchCancelled,
389
+ hotLoad: aggregate.sourceSwitchHotLoad,
390
+ rebuild: aggregate.sourceSwitchRebuild,
391
+ elapsedMs: aggregate.sourceSwitchElapsedMs
392
+ }),
344
393
  recovery: recovery ? observed(recovery) : unknown(),
345
394
  health: {
346
395
  kernel: aggregate.kernelHealth ? observed(aggregate.kernelHealth) : unknown(),
@@ -382,16 +431,26 @@ function createTelemetrySession(options) {
382
431
  }
383
432
  const parsed = validation.data;
384
433
  if (completedSummary) prepareForInput();
385
- const deliveryId = metadata?.deliveryId;
386
- const producerDelivery = metadata?.producerDelivery;
434
+ const producerDelivery = parsed.delivery ?? metadata?.producerDelivery;
435
+ const deliveryId = producerDelivery?.deliveryId ?? metadata?.deliveryId;
387
436
  const identitySession = "sessionId" in parsed.payload ? parsed.payload.sessionId : aggregate.currentContext?.sessionId ?? aggregate.sourceRoute?.sessionId ?? "";
388
437
  const deliveryKey = JSON.stringify(producerDelivery ? [producerDelivery.producerSessionId, producerDelivery.deliveryId] : [identitySession, deliveryId]);
389
438
  if (deliveryId) {
390
439
  const duplicate = seenDeliveries.get(deliveryKey);
391
440
  if (duplicate) return duplicate;
392
441
  }
393
- if (parsed.event === "contextchange" && aggregate.currentContext && aggregate.currentContext.sessionId !== parsed.payload.sessionId && !completedSummary) finish("source_changed");
442
+ if (parsed.event === "contextchange" && aggregate.currentContext && aggregate.currentContext.sessionId !== parsed.payload.sessionId && !completedSummary) {
443
+ pendingSourceSwitches = new Set(aggregate.openSourceSwitches);
444
+ aggregate.sourceSwitchAttempts -= pendingSourceSwitches.size;
445
+ aggregate.openSourceSwitches.clear();
446
+ finish("source_changed");
447
+ }
394
448
  prepareForInput();
449
+ if (pendingSourceSwitches.size > 0) {
450
+ aggregate.openSourceSwitches = pendingSourceSwitches;
451
+ aggregate.sourceSwitchAttempts += pendingSourceSwitches.size;
452
+ pendingSourceSwitches = /* @__PURE__ */ new Set();
453
+ }
395
454
  const isPendingSourceRoute = parsed.event === "sourceroute" && !!aggregate.currentContext && aggregate.currentContext.sessionId !== parsed.payload.sessionId;
396
455
  const currentSessionId = aggregate.currentContext?.sessionId ?? aggregate.sourceRoute?.sessionId;
397
456
  const isForeignRecovery = parsed.event === "recovery" && currentSessionId !== void 0 && currentSessionId !== parsed.payload.sessionId;
@@ -423,7 +482,7 @@ function createTelemetrySession(options) {
423
482
  aggregate.eventCount += 1;
424
483
  aggregate.generatedDelivery ||= !deliveryId;
425
484
  aggregate.unattributedRecord ||= record.contextStatus === "unattributed" && parsed.event !== "sourceroute";
426
- recordEventFact(record.event);
485
+ recordEventFact(record.event, producerDelivery?.producerSessionId ?? "raw");
427
486
  }
428
487
  if (deliveryId) if (seenDeliveries.size < 1024) seenDeliveries.set(deliveryKey, record);
429
488
  else {
@@ -433,18 +492,6 @@ function createTelemetrySession(options) {
433
492
  emitRecord(record);
434
493
  return record;
435
494
  },
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
- },
448
495
  recordIntent(intent) {
449
496
  prepareForInput();
450
497
  const projectedIntent = projectIntent(intent);
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { DeliveredPlayerEvent, ErrorCode, PlaybackContext, PlayerEvent, RecoveryPayload, SourceRoutePayload } from "@video-lab/protocol";
1
+ import { ErrorCode, PlaybackContext, PlayerEvent, RecoveryPayload, SourceRoutePayload } from "@video-lab/protocol";
2
2
  //#region src/recovery-summary.d.ts
3
3
  type Terminal = Extract<RecoveryPayload, {
4
4
  phase: 'recovered' | 'failed' | 'cancelled';
@@ -40,7 +40,7 @@ interface TelemetryRecordOptions {
40
40
  deliveryId?: string;
41
41
  /** 事件在播放器侧发生的时间;缺省时由会话时钟采样。 */
42
42
  occurredAtMs?: number;
43
- /** `recordDelivered` 写入的生产端投递证据;raw `record` 默认不产生它。 */
43
+ /** 外部注入的生产端投递证据;SDK 事件优先读取 `event.delivery`。 */
44
44
  producerDelivery?: TelemetryProducerDelivery;
45
45
  }
46
46
  /** 可随 telemetry record 外发的生产端排序、去重与迟到识别证据。 */
@@ -70,7 +70,7 @@ interface TelemetryRecordBase {
70
70
  interface TelemetryPlayerRecord extends TelemetryRecordBase {
71
71
  kind: 'player_event';
72
72
  event: PlayerEvent;
73
- /** 仅完整 delivered 流提供;raw `record` 保持既有输出形状。 */
73
+ /** 有生产端投递证据的事件提供;无证据事件省略。 */
74
74
  producerDelivery?: TelemetryProducerDelivery;
75
75
  }
76
76
  interface TelemetryIntentRecord extends TelemetryRecordBase {
@@ -128,6 +128,19 @@ interface TelemetrySessionSummary {
128
128
  autoCount: number;
129
129
  manualCount: number;
130
130
  }>;
131
+ sourceSwitches: TelemetryValue<{
132
+ attempts: number;
133
+ succeeded: number;
134
+ failed: number;
135
+ cancelled: number;
136
+ hotLoad: number;
137
+ rebuild: number;
138
+ elapsedMs: {
139
+ succeeded: number;
140
+ failed: number;
141
+ cancelled: number;
142
+ };
143
+ }>;
131
144
  recovery: TelemetryValue<TelemetryRecoverySummary>;
132
145
  health: {
133
146
  kernel: TelemetryValue<{
@@ -151,7 +164,7 @@ interface TelemetrySessionSummary {
151
164
  intents: TelemetryValue<readonly TelemetryIntent[]>;
152
165
  completeness: {
153
166
  status: 'complete' | 'partial';
154
- missing: Array<'deduplication_unknown' | 'context_unattributed' | 'sink_failure' | 'unclosed_episode' | 'recovery_incomplete' | 'local_overflow' | 'invalid_event' | 'unclosed_stall'>;
167
+ missing: Array<'deduplication_unknown' | 'context_unattributed' | 'sink_failure' | 'unclosed_episode' | 'recovery_incomplete' | 'local_overflow' | 'invalid_event' | 'unclosed_stall' | 'unclosed_source_switch'>;
155
168
  deduplication: 'verified' | 'unknown';
156
169
  context: 'observed' | 'unattributed';
157
170
  sinkFailures: number;
@@ -172,8 +185,6 @@ interface CreateTelemetrySessionOptions {
172
185
  interface TelemetrySession {
173
186
  /** 把完整 PlayerEvent 归一成可重试、可排序的安全 record。 */
174
187
  record(event: unknown, options?: TelemetryRecordOptions): TelemetryPlayerRecord | null;
175
- /** 直接消费五面同构的 producer delivery 证据。 */
176
- recordDelivered(event: DeliveredPlayerEvent): TelemetryPlayerRecord | null;
177
188
  /** 记录有限、脱敏的宿主意图,不把任意业务数据带进遥测。 */
178
189
  recordIntent(intent: TelemetryIntent): TelemetryIntentRecord;
179
190
  /** 关闭当前播放会话;重复调用返回同一份 summary。 */
@@ -185,4 +196,3 @@ interface TelemetrySession {
185
196
  declare function createTelemetrySession(options: CreateTelemetrySessionOptions): TelemetrySession;
186
197
  //#endregion
187
198
  export { CreateTelemetrySessionOptions, TELEMETRY_SCHEMA_VERSION, TelemetryApplication, TelemetryFinishReason, TelemetryIntent, TelemetryIntentOrigin, TelemetryIntentRecord, TelemetryIntentType, TelemetryPlayerRecord, TelemetryProducerDelivery, TelemetryRecord, TelemetryRecordOptions, type TelemetryRecoverySummary, TelemetrySession, TelemetrySessionSummary, TelemetrySink, TelemetryStartup, TelemetryValue, createTelemetrySession };
188
- //# sourceMappingURL=index.d.cts.map
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { DeliveredPlayerEvent, ErrorCode, PlaybackContext, PlayerEvent, RecoveryPayload, SourceRoutePayload } from "@video-lab/protocol";
1
+ import { ErrorCode, PlaybackContext, PlayerEvent, RecoveryPayload, SourceRoutePayload } from "@video-lab/protocol";
2
2
  //#region src/recovery-summary.d.ts
3
3
  type Terminal = Extract<RecoveryPayload, {
4
4
  phase: 'recovered' | 'failed' | 'cancelled';
@@ -40,7 +40,7 @@ interface TelemetryRecordOptions {
40
40
  deliveryId?: string;
41
41
  /** 事件在播放器侧发生的时间;缺省时由会话时钟采样。 */
42
42
  occurredAtMs?: number;
43
- /** `recordDelivered` 写入的生产端投递证据;raw `record` 默认不产生它。 */
43
+ /** 外部注入的生产端投递证据;SDK 事件优先读取 `event.delivery`。 */
44
44
  producerDelivery?: TelemetryProducerDelivery;
45
45
  }
46
46
  /** 可随 telemetry record 外发的生产端排序、去重与迟到识别证据。 */
@@ -70,7 +70,7 @@ interface TelemetryRecordBase {
70
70
  interface TelemetryPlayerRecord extends TelemetryRecordBase {
71
71
  kind: 'player_event';
72
72
  event: PlayerEvent;
73
- /** 仅完整 delivered 流提供;raw `record` 保持既有输出形状。 */
73
+ /** 有生产端投递证据的事件提供;无证据事件省略。 */
74
74
  producerDelivery?: TelemetryProducerDelivery;
75
75
  }
76
76
  interface TelemetryIntentRecord extends TelemetryRecordBase {
@@ -128,6 +128,19 @@ interface TelemetrySessionSummary {
128
128
  autoCount: number;
129
129
  manualCount: number;
130
130
  }>;
131
+ sourceSwitches: TelemetryValue<{
132
+ attempts: number;
133
+ succeeded: number;
134
+ failed: number;
135
+ cancelled: number;
136
+ hotLoad: number;
137
+ rebuild: number;
138
+ elapsedMs: {
139
+ succeeded: number;
140
+ failed: number;
141
+ cancelled: number;
142
+ };
143
+ }>;
131
144
  recovery: TelemetryValue<TelemetryRecoverySummary>;
132
145
  health: {
133
146
  kernel: TelemetryValue<{
@@ -151,7 +164,7 @@ interface TelemetrySessionSummary {
151
164
  intents: TelemetryValue<readonly TelemetryIntent[]>;
152
165
  completeness: {
153
166
  status: 'complete' | 'partial';
154
- missing: Array<'deduplication_unknown' | 'context_unattributed' | 'sink_failure' | 'unclosed_episode' | 'recovery_incomplete' | 'local_overflow' | 'invalid_event' | 'unclosed_stall'>;
167
+ missing: Array<'deduplication_unknown' | 'context_unattributed' | 'sink_failure' | 'unclosed_episode' | 'recovery_incomplete' | 'local_overflow' | 'invalid_event' | 'unclosed_stall' | 'unclosed_source_switch'>;
155
168
  deduplication: 'verified' | 'unknown';
156
169
  context: 'observed' | 'unattributed';
157
170
  sinkFailures: number;
@@ -172,8 +185,6 @@ interface CreateTelemetrySessionOptions {
172
185
  interface TelemetrySession {
173
186
  /** 把完整 PlayerEvent 归一成可重试、可排序的安全 record。 */
174
187
  record(event: unknown, options?: TelemetryRecordOptions): TelemetryPlayerRecord | null;
175
- /** 直接消费五面同构的 producer delivery 证据。 */
176
- recordDelivered(event: DeliveredPlayerEvent): TelemetryPlayerRecord | null;
177
188
  /** 记录有限、脱敏的宿主意图,不把任意业务数据带进遥测。 */
178
189
  recordIntent(intent: TelemetryIntent): TelemetryIntentRecord;
179
190
  /** 关闭当前播放会话;重复调用返回同一份 summary。 */
@@ -185,4 +196,3 @@ interface TelemetrySession {
185
196
  declare function createTelemetrySession(options: CreateTelemetrySessionOptions): TelemetrySession;
186
197
  //#endregion
187
198
  export { CreateTelemetrySessionOptions, TELEMETRY_SCHEMA_VERSION, TelemetryApplication, TelemetryFinishReason, TelemetryIntent, TelemetryIntentOrigin, TelemetryIntentRecord, TelemetryIntentType, TelemetryPlayerRecord, TelemetryProducerDelivery, TelemetryRecord, TelemetryRecordOptions, type TelemetryRecoverySummary, TelemetrySession, TelemetrySessionSummary, TelemetrySink, TelemetryStartup, TelemetryValue, createTelemetrySession };
188
- //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -107,6 +107,18 @@ function createAggregate() {
107
107
  errorCodes: [],
108
108
  qualityChangeCount: 0,
109
109
  autoQualityChangeCount: 0,
110
+ sourceSwitchAttempts: 0,
111
+ sourceSwitchSucceeded: 0,
112
+ sourceSwitchFailed: 0,
113
+ sourceSwitchCancelled: 0,
114
+ sourceSwitchHotLoad: 0,
115
+ sourceSwitchRebuild: 0,
116
+ sourceSwitchElapsedMs: {
117
+ succeeded: 0,
118
+ failed: 0,
119
+ cancelled: 0
120
+ },
121
+ openSourceSwitches: /* @__PURE__ */ new Set(),
110
122
  frameFreezeCount: 0,
111
123
  frameFreezeDurationMs: 0,
112
124
  intents: []
@@ -235,6 +247,7 @@ function createTelemetrySession(options) {
235
247
  */
236
248
  let seenDeliveries = /* @__PURE__ */ new Map();
237
249
  let pendingSourceRoute;
250
+ let pendingSourceSwitches = /* @__PURE__ */ new Set();
238
251
  let aggregate = createAggregate();
239
252
  let sequence = 0;
240
253
  let completedSummary;
@@ -265,7 +278,7 @@ function createTelemetrySession(options) {
265
278
  function emitRecord(record) {
266
279
  deliver(aggregate, () => options.sink?.onRecord?.(record));
267
280
  }
268
- function recordEventFact(event) {
281
+ function recordEventFact(event, sourceSwitchScope) {
269
282
  if (event.event === "firstframe") aggregate.firstFrameMs ??= event.payload.fvt;
270
283
  if (event.event === "stalled" && event.payload.kind === "playback") {
271
284
  aggregate.incompleteStall ||= event.payload.phase === "start" && aggregate.unclosedStall || event.payload.phase === "end" && event.payload.durationMs === void 0;
@@ -284,6 +297,32 @@ function createTelemetrySession(options) {
284
297
  aggregate.qualityChangeCount += 1;
285
298
  if (event.payload.auto) aggregate.autoQualityChangeCount += 1;
286
299
  }
300
+ if (event.event === "sourceswitch") {
301
+ const key = `${sourceSwitchScope}:${event.payload.switchId}`;
302
+ if (event.payload.phase === "started") {
303
+ if (!aggregate.openSourceSwitches.has(key)) {
304
+ aggregate.openSourceSwitches.add(key);
305
+ aggregate.sourceSwitchAttempts += 1;
306
+ }
307
+ return;
308
+ }
309
+ if (!aggregate.openSourceSwitches.delete(key)) return;
310
+ if (event.payload.phase === "succeeded") {
311
+ aggregate.sourceSwitchSucceeded += 1;
312
+ aggregate.sourceSwitchElapsedMs.succeeded += event.payload.elapsedMs;
313
+ if (event.payload.strategy === "hot_load") aggregate.sourceSwitchHotLoad += 1;
314
+ else aggregate.sourceSwitchRebuild += 1;
315
+ }
316
+ if (event.payload.phase === "failed") {
317
+ aggregate.sourceSwitchFailed += 1;
318
+ aggregate.sourceSwitchElapsedMs.failed += event.payload.elapsedMs;
319
+ }
320
+ if (event.payload.phase === "cancelled") {
321
+ aggregate.sourceSwitchCancelled += 1;
322
+ aggregate.sourceSwitchElapsedMs.cancelled += event.payload.elapsedMs;
323
+ }
324
+ return;
325
+ }
287
326
  if (event.event === "recovery") aggregate.recovery.record(event.payload);
288
327
  if (event.event === "kernelhealth") aggregate.kernelHealth = {
289
328
  degraded: event.payload.degraded,
@@ -310,6 +349,7 @@ function createTelemetrySession(options) {
310
349
  if (recovery?.overflowEvents || aggregate.localOverflow) missing.push("local_overflow");
311
350
  if (aggregate.invalidEvents) missing.push("invalid_event");
312
351
  if (aggregate.unclosedStall || aggregate.incompleteStall) missing.push("unclosed_stall");
352
+ if (aggregate.openSourceSwitches.size > 0) missing.push("unclosed_source_switch");
313
353
  const contextUnattributed = aggregate.unattributedRecord || !aggregate.currentContext;
314
354
  if (aggregate.generatedDelivery) missing.push("deduplication_unknown");
315
355
  if (contextUnattributed) missing.push("context_unattributed");
@@ -340,6 +380,15 @@ function createTelemetrySession(options) {
340
380
  autoCount: aggregate.autoQualityChangeCount,
341
381
  manualCount: aggregate.qualityChangeCount - aggregate.autoQualityChangeCount
342
382
  }),
383
+ sourceSwitches: aggregate.sourceSwitchAttempts === 0 ? unknown() : observed({
384
+ attempts: aggregate.sourceSwitchAttempts,
385
+ succeeded: aggregate.sourceSwitchSucceeded,
386
+ failed: aggregate.sourceSwitchFailed,
387
+ cancelled: aggregate.sourceSwitchCancelled,
388
+ hotLoad: aggregate.sourceSwitchHotLoad,
389
+ rebuild: aggregate.sourceSwitchRebuild,
390
+ elapsedMs: aggregate.sourceSwitchElapsedMs
391
+ }),
343
392
  recovery: recovery ? observed(recovery) : unknown(),
344
393
  health: {
345
394
  kernel: aggregate.kernelHealth ? observed(aggregate.kernelHealth) : unknown(),
@@ -381,16 +430,26 @@ function createTelemetrySession(options) {
381
430
  }
382
431
  const parsed = validation.data;
383
432
  if (completedSummary) prepareForInput();
384
- const deliveryId = metadata?.deliveryId;
385
- const producerDelivery = metadata?.producerDelivery;
433
+ const producerDelivery = parsed.delivery ?? metadata?.producerDelivery;
434
+ const deliveryId = producerDelivery?.deliveryId ?? metadata?.deliveryId;
386
435
  const identitySession = "sessionId" in parsed.payload ? parsed.payload.sessionId : aggregate.currentContext?.sessionId ?? aggregate.sourceRoute?.sessionId ?? "";
387
436
  const deliveryKey = JSON.stringify(producerDelivery ? [producerDelivery.producerSessionId, producerDelivery.deliveryId] : [identitySession, deliveryId]);
388
437
  if (deliveryId) {
389
438
  const duplicate = seenDeliveries.get(deliveryKey);
390
439
  if (duplicate) return duplicate;
391
440
  }
392
- if (parsed.event === "contextchange" && aggregate.currentContext && aggregate.currentContext.sessionId !== parsed.payload.sessionId && !completedSummary) finish("source_changed");
441
+ if (parsed.event === "contextchange" && aggregate.currentContext && aggregate.currentContext.sessionId !== parsed.payload.sessionId && !completedSummary) {
442
+ pendingSourceSwitches = new Set(aggregate.openSourceSwitches);
443
+ aggregate.sourceSwitchAttempts -= pendingSourceSwitches.size;
444
+ aggregate.openSourceSwitches.clear();
445
+ finish("source_changed");
446
+ }
393
447
  prepareForInput();
448
+ if (pendingSourceSwitches.size > 0) {
449
+ aggregate.openSourceSwitches = pendingSourceSwitches;
450
+ aggregate.sourceSwitchAttempts += pendingSourceSwitches.size;
451
+ pendingSourceSwitches = /* @__PURE__ */ new Set();
452
+ }
394
453
  const isPendingSourceRoute = parsed.event === "sourceroute" && !!aggregate.currentContext && aggregate.currentContext.sessionId !== parsed.payload.sessionId;
395
454
  const currentSessionId = aggregate.currentContext?.sessionId ?? aggregate.sourceRoute?.sessionId;
396
455
  const isForeignRecovery = parsed.event === "recovery" && currentSessionId !== void 0 && currentSessionId !== parsed.payload.sessionId;
@@ -422,7 +481,7 @@ function createTelemetrySession(options) {
422
481
  aggregate.eventCount += 1;
423
482
  aggregate.generatedDelivery ||= !deliveryId;
424
483
  aggregate.unattributedRecord ||= record.contextStatus === "unattributed" && parsed.event !== "sourceroute";
425
- recordEventFact(record.event);
484
+ recordEventFact(record.event, producerDelivery?.producerSessionId ?? "raw");
426
485
  }
427
486
  if (deliveryId) if (seenDeliveries.size < 1024) seenDeliveries.set(deliveryKey, record);
428
487
  else {
@@ -432,18 +491,6 @@ function createTelemetrySession(options) {
432
491
  emitRecord(record);
433
492
  return record;
434
493
  },
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
- },
447
494
  recordIntent(intent) {
448
495
  prepareForInput();
449
496
  const projectedIntent = projectIntent(intent);
@@ -478,4 +525,3 @@ function createTelemetrySession(options) {
478
525
  //#endregion
479
526
  export { TELEMETRY_SCHEMA_VERSION, createTelemetrySession };
480
527
 
481
- //# sourceMappingURL=index.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@video-lab/telemetry",
3
- "version": "3.1.0",
3
+ "version": "4.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": "3.1.0"
27
+ "@video-lab/protocol": "4.0.1"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsdown": "^0.22.9",