@moqtap/trace 0.4.1 → 0.5.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 CHANGED
@@ -1,18 +1,9 @@
1
1
  # @moqtap/trace
2
2
 
3
- Record, serialize, and analyze MoQT (Media over QUIC Transport) sessions using the `.moqtrace` binary format.
4
-
5
- - Binary `.moqtrace` format with CBOR encoding (compact, streamable, cross-language)
6
- - Segmented traces for capture rotation, live carriage, and recovery from a damaged region
7
- - Configurable detail levels from control-only to full wire captures
8
- - Session recorder that wraps `@moqtap/codec` session state machines
9
- - Human-readable JSON export for debugging
10
- - Zero-copy streaming writer, and an incremental reader for a stream that
11
- arrives in chunks
12
- - Unrecognised keys, event types and enum values preserved through a
13
- read-modify-write
14
-
15
- Writes format version 2 and reads versions 1 and 2.
3
+ Record, serialize and analyze MoQT (Media over QUIC Transport) sessions as
4
+ `.moqtrace` files — a compact, streamable CBOR format that reads the same across
5
+ implementations. Includes a recorder that wraps `@moqtap/codec` session state
6
+ machines, streaming read and write, and a lossy JSON export for debugging.
16
7
 
17
8
  ## Install
18
9
 
@@ -20,271 +11,109 @@ Writes format version 2 and reads versions 1 and 2.
20
11
  npm install @moqtap/trace @moqtap/codec
21
12
  ```
22
13
 
23
- `@moqtap/codec` is a peer dependency — install it alongside `@moqtap/trace`.
14
+ `@moqtap/codec` is a peer dependency — install it alongside.
24
15
 
25
16
  ## Quick Start
26
17
 
27
- ### Recording a session
18
+ Wrap a codec session state machine, then serialize what it recorded:
28
19
 
29
20
  ```typescript
30
21
  import { createRecorder, writeMoqtrace } from '@moqtap/trace'
31
- import { createDraft17SessionState } from '@moqtap/codec/draft17/session'
22
+ import { MESSAGE_ID_MAP } from '@moqtap/codec/draft21'
23
+ import { createDraft21SessionState } from '@moqtap/codec/draft21/session'
32
24
 
33
25
  const recorder = createRecorder({
34
- protocol: 'moq-transport-17',
26
+ protocol: 'moq-transport-21',
35
27
  perspective: 'client',
36
28
  detail: 'control',
29
+ messageTypeId: (name) => Number(MESSAGE_ID_MAP.get(name) ?? 0),
37
30
  })
38
31
 
39
- // Wrap a session to auto-capture control messages and state transitions
40
- const session = createDraft17SessionState('client')
41
- const traced = recorder.wrapSession(session)
42
-
43
- // Use `traced` instead of `session` — all send/receive calls are recorded
32
+ // Every send/receive on the wrapper is recorded.
33
+ const traced = recorder.wrapSession(createDraft21SessionState('client'))
44
34
  traced.send(setupMessage)
45
35
  traced.receive(setupMessage)
46
36
 
47
- // Manually record events the session layer doesn't see
48
- recorder.recordStreamOpened(4n, 0, 0) // outgoing subgroup stream
37
+ // Events the session layer never sees.
38
+ recorder.recordStreamOpened(4n, 0, 0)
49
39
  recorder.annotate('connected', { relay: 'cdn.example.com' })
50
40
 
51
- // Finalize and serialize
52
- const trace = recorder.finalize()
53
- const bytes = writeMoqtrace(trace) // → Uint8Array (.moqtrace binary)
41
+ const bytes = writeMoqtrace(recorder.finalize()) // Uint8Array
54
42
  ```
55
43
 
56
- ### Recording an error with the bytes behind it
57
-
58
- A report saying "your SUBSCRIBE_OK did not parse" is an assertion. The same
59
- report carrying the bytes is evidence the other party can run against their own
60
- encoder.
61
-
62
- ```typescript
63
- recorder.recordError(0, 'SUBSCRIBE_OK did not parse', {
64
- streamId: 4n,
65
- errorKind: 'decode', // 'protocol' | 'transport' | 'decode' | any other string
66
- raw: offendingBytes, // hand over everything held, untruncated
67
- })
68
- ```
69
-
70
- Hand `raw` the full bytes. The recorder applies `MAX_ERROR_RAW_BYTES` (4096)
71
- itself and reports the untruncated length separately, so a caller that
72
- pre-truncates destroys the one signal saying the capture is partial. `rawLength`
73
- is recorded from `'headers+sizes'` and `raw` only at `'full'`, and the bytes are
74
- written once per flow.
75
-
76
- ### Reading a trace file
44
+ Read one back:
77
45
 
78
46
  ```typescript
79
47
  import { readMoqtrace, readMoqtraceHeader } from '@moqtap/trace'
80
48
 
81
- // Quick metadata peek (no event parsing)
82
- const header = readMoqtraceHeader(bytes)
83
- console.log(header.protocol) // "moq-transport-17"
84
- console.log(header.perspective) // "client"
85
- console.log(header.detail) // "control"
49
+ const header = readMoqtraceHeader(bytes) // metadata only, no event parsing
50
+ console.log(header.protocol, header.perspective, header.detail)
86
51
 
87
- // Full parse
88
52
  const trace = readMoqtrace(bytes)
89
53
  for (const event of trace.events) {
90
54
  console.log(event.type, event.timestamp)
91
55
  }
92
56
  ```
93
57
 
94
- ### Streaming writer (for large traces)
95
-
96
- ```typescript
97
- import { createMoqtraceWriter } from '@moqtap/trace'
98
-
99
- const writer = createMoqtraceWriter(header)
100
- outputStream.write(writer.preamble())
101
-
102
- for (const event of events) {
103
- outputStream.write(writer.writeEvent(event))
104
- }
105
- ```
106
-
107
- ### Streaming reader (for a stream that arrives in chunks)
108
-
109
- `readMoqtrace` and the functions beside it take a finished file. A live capture
110
- does not have one: chunk boundaries fall wherever the transport put them,
111
- almost never on an item boundary.
112
-
113
- ```typescript
114
- import { createMoqtraceReader, TruncatedStreamError } from '@moqtap/trace'
115
-
116
- const reader = createMoqtraceReader()
117
-
118
- for await (const chunk of incoming) {
119
- // Returns whatever the chunk completed — often nothing, which is ordinary.
120
- for (const item of reader.push(chunk)) {
121
- if (item.kind === 'segment') console.log('segment', item.header.sessionId)
122
- else console.log(item.event.type, item.event.timestamp)
123
- }
124
- }
125
-
126
- // `end()` can still yield items, and throws if bytes are left over that do not
127
- // form a whole one.
128
- try {
129
- for (const item of reader.end()) console.log(item.kind)
130
- } catch (error) {
131
- if (error instanceof TruncatedStreamError) console.warn('stream cut mid-item')
132
- }
133
- ```
134
-
135
- ### Segmented traces
136
-
137
- A segment is a complete `.moqtrace` blob — magic, version, header, events —
138
- appended after the one before it, so a reader can start at any segment without
139
- the ones preceding it. That buys capture rotation without finalizing a file, a
140
- natural cut point for live carriage, and recovery from a damaged region.
141
-
142
- ```typescript
143
- import { readMoqtraceSegments, writeMoqtraceSegments } from '@moqtap/trace'
144
-
145
- // Each segment header must carry `segment` metadata: it is what tells a reader
146
- // that `seq` and `timestamp` restart at zero in every segment.
147
- const bytes = writeMoqtraceSegments([
148
- { header: { ...header, segment: { sequence: 0 } }, events: firstBatch },
149
- { header: { ...header, segment: { sequence: 1, continues: true } }, events: secondBatch },
150
- ])
151
-
152
- // Read the boundaries — you need them to order events across segments.
153
- for (const segment of readMoqtraceSegments(bytes)) {
154
- console.log(segment.header.segment?.sequence, segment.events.length)
155
- }
156
-
157
- // Or ignore them: readMoqtrace flattens every segment's events in order.
158
- ```
159
-
160
- ### Damaged and truncated files
161
-
162
- A file that stops part-way through an event throws `TruncatedTraceError`, which
163
- carries everything that decoded before the cut — a truncated trace is still
164
- evidence. On a segmented trace, `{ recover: true }` skips a damaged region and
165
- resumes at the next segment instead of throwing; pass `onRecovered` alongside
166
- it to be told what that cost, since the return value cannot say.
167
-
168
- ```typescript
169
- import { readMoqtrace, readMoqtraceSegments, TruncatedTraceError } from '@moqtap/trace'
170
-
171
- try {
172
- const trace = readMoqtrace(bytes)
173
- } catch (error) {
174
- if (error instanceof TruncatedTraceError) {
175
- console.warn(`truncated at byte ${error.offset}`)
176
- const survived = error.segments.reduce((n, segment) => n + segment.events.length, 0)
177
- console.log(survived, 'events survived')
178
- }
179
- }
180
-
181
- const whatSurvived = readMoqtraceSegments(bytes, {
182
- recover: true,
183
- onRecovered: ({ offset, kind, resumedAt }) => {
184
- console.warn(
185
- resumedAt == null
186
- ? `${kind} damage at ${offset}: nothing readable followed, rest discarded`
187
- : `${kind} damage at ${offset}, resumed at ${resumedAt}`,
188
- )
189
- },
190
- })
191
- ```
58
+ ## API
59
+
60
+ | Function | Purpose |
61
+ | ---------------------------------------------------- | ------------------------------------------------------------ |
62
+ | `createRecorder(options)` | Session recorder; wraps a codec session state machine |
63
+ | `writeMoqtrace(trace)` | Serialize a complete trace to bytes |
64
+ | `readMoqtrace(bytes)` | Parse a complete file, flattening every segment in order |
65
+ | `readMoqtraceHeader(bytes)` | Parse the header only |
66
+ | `createMoqtraceWriter(header)` | Streaming writer: `preamble()`, then `writeEvent()` per event |
67
+ | `createMoqtraceReader()` | Incremental reader for a stream arriving in chunks |
68
+ | `writeMoqtraceSegments()` / `readMoqtraceSegments()` | Segmented traces, for rotation, live carriage and recovery |
69
+ | `traceToJSON(trace)` | Human-readable export; lossy, as bigints and bytes stringify |
70
+
71
+ `createRecorder` requires `protocol`, `perspective` and `detail`. Pass
72
+ `messageTypeId` to map a message name to its wire id, which session-layer
73
+ recording needs; `maxEvents` bounds the event buffer.
74
+
75
+ Beyond `wrapSession`, the recorder takes events the session layer cannot see:
76
+ `recordStreamOpened`, `recordStreamClosed`, `recordObjectHeader`,
77
+ `recordObjectPayload`, `recordError` and `annotate`. `finalize()` returns the
78
+ trace and stops recording.
79
+
80
+ ## Detail levels
81
+
82
+ | Level | What is recorded |
83
+ | --------------- | -------------------------------------- |
84
+ | `control` | Control messages only |
85
+ | `headers` | + data stream and object headers |
86
+ | `headers+sizes` | + payload byte lengths |
87
+ | `headers+data` | + full payload bytes |
88
+ | `full` | + raw wire bytes, pre-decode |
89
+
90
+ Errors and annotations are recorded at every level. `headers+data` and `full`,
91
+ and the `raw` field on any event, carry user content, so capturing them should
92
+ be an explicit choice rather than a default.
93
+
94
+ When recording an error, hand `recordError` the full bytes: it applies
95
+ `MAX_ERROR_RAW_BYTES` itself and reports the untruncated length separately, so a
96
+ caller that pre-truncates destroys the signal saying the capture is partial.
192
97
 
193
98
  ## Reading a trace you did not write
194
99
 
195
- Nothing here rejects a file for carrying something newer than this version
196
- knows. An unrecognised event type arrives as an `UnknownEvent` with its fields
197
- intact, so it survives a read-modify-write round trip rather than being dropped
198
- or relabelled; an unrecognised perspective, detail level, role, side, drop
199
- policy or derivation kind is kept verbatim.
200
-
201
- Keys are kept on the same terms. One this version does not recognise — on the
202
- header, on `segment`, on `sampling`, or on any event — is preserved verbatim in
203
- an `extra` map beside the fields and written back unchanged. Ignoring a key is
204
- permitted; dropping one is not, or a redaction pass, a filter or an annotated
205
- download emits a file that looks as though it never carried the key.
206
-
207
- The one place that refuses is `createRecorder`, which cannot honour a detail
208
- level it does not implement: it would either capture less than you asked for
209
- while writing a header claiming otherwise, or — for the payload-bearing levels
210
- — capture more.
100
+ Nothing is rejected for carrying something newer than this version knows. An
101
+ unrecognised event type arrives as an `UnknownEvent` with its fields intact, and
102
+ an unrecognised key is preserved verbatim in an `extra` map beside the fields, so
103
+ neither is dropped by a read-modify-write round trip.
211
104
 
212
- ## Detail Levels
213
-
214
- | Level | What's recorded |
215
- | --------------- | --------------------------------------------------------- |
216
- | `control` | Control messages only (setup, subscribe, publish, goaway) |
217
- | `headers` | + data stream headers (subgroup/fetch, object metadata) |
218
- | `headers+sizes` | + payload byte lengths |
219
- | `headers+data` | + full payload bytes |
220
- | `full` | + raw wire bytes (pre-decode) |
221
-
222
- `headers+data` and `full`, and the `raw` field on any event, carry user
223
- content. Capturing them is optional for a conformant recorder and should be an
224
- explicit choice, not a default.
225
-
226
- ## JSON Export
105
+ A file that stops part-way through an event throws `TruncatedTraceError`, which
106
+ carries everything that decoded before the cut. On a segmented trace,
107
+ `readMoqtraceSegments(bytes, { recover: true })` skips a damaged region and
108
+ resumes at the next segment instead of throwing.
227
109
 
228
- For human-readable debugging (not lossless — bigint/Uint8Array become strings):
110
+ ## File format
229
111
 
230
- ```typescript
231
- import { traceToJSON } from '@moqtap/trace'
112
+ [SPEC.md](./SPEC.md) is the complete `.moqtrace` binary specification.
232
113
 
233
- const json = traceToJSON(trace)
234
- console.log(json)
235
- ```
114
+ ## Documentation
236
115
 
237
- ## Upgrading
238
-
239
- ### To 0.4.0 (from 0.3.0)
240
-
241
- Two changes to what a reader accepts:
242
-
243
- - `ControlMessageEvent.message` is `unknown` rather than
244
- `Record<string, unknown>`, so anything reading a key straight off it stops
245
- compiling. Narrow with `controlMessageFields()` first. The old type was a
246
- claim the runtime did not keep — every real capture carries a text rendering
247
- there, so `event.message.request_id` typechecked and read `undefined`.
248
- - A header with no usable value for a required key throws
249
- `MalformedHeaderError` instead of returning a header with `undefined`, `NaN`
250
- or `0` standing in for it. Pass `recover` to skip that segment instead. No
251
- file either implementation writes is affected.
252
-
253
- Everything else is additive: `extra` on the header and its two sub-maps, the
254
- stream-header identifiers on Event 1, the error fields on Event 6, and
255
- `createMoqtraceReader`.
256
-
257
- ### To 0.3.0 (from 0.2.0)
258
-
259
- The package now writes format version 2. A 0.2.0 reader rejects those files at
260
- byte 8, which is the point of the bump: it turns what would have been silent
261
- desynchronization on a segmented trace into a clear refusal. Reading is
262
- unaffected in the other direction — 0.3.0 reads everything 0.2.0 wrote.
263
-
264
- What changes for code already using it:
265
-
266
- - `TraceEvent` gained four members (`peer-connected`, `peer-disconnected`,
267
- `subscription-derivation`, `unknown`). A `switch` that handles only the
268
- cases it cares about is unaffected; one that asserts exhaustiveness is not.
269
- - `Perspective`, `DetailLevel` and the new string unions accept values they do
270
- not name, so indexing a `Record<DetailLevel, T>` with one now yields
271
- `T | undefined`.
272
- - An unknown event type used to arrive as an annotation labelled
273
- `unknown-event-<n>`. It now arrives as an `UnknownEvent` carrying its real
274
- type and fields, so code matching that label should match `type === 'unknown'`
275
- instead. The old shape was lossy in a way that mattered: writing the trace
276
- back rewrote the event's type to 7.
277
- - `createRecorder` throws on a detail level it does not implement, which the
278
- types previously made unexpressible.
279
- - The bytes changed in two ways that make files readable by the Rust
280
- implementation for the first time: an integral value past 32 bits is now
281
- written as a CBOR integer rather than a float64, and a byte string as major
282
- type 2 rather than wrapped in the typed-array tag 64. Both readers accept
283
- either form, so nothing already written becomes unreadable.
284
-
285
- ## File Format
286
-
287
- See [SPEC.md](./SPEC.md) for the complete `.moqtrace` binary format specification.
116
+ <https://moqtap.com/npm-packages/moqtap-trace/>
288
117
 
289
118
  ## License
290
119
 
package/SPEC.md CHANGED
@@ -44,7 +44,9 @@ and Event 4's `"pl"` begins after the object header, so the bytes of a
44
44
  including `"full"`. What those headers carry is recorded as decoded fields
45
45
  instead — see [Event 1](#event-1-stream-opened). Closing that gap needs a new
46
46
  key and is not in this revision; the level's description no longer implies
47
- otherwise.
47
+ otherwise. The one part of an object's framing recorded as bytes is its
48
+ property block, [Event 3's `"props"`](#event-3-object-header), because the
49
+ block's encoding is the draft's and has no decoded form this format could hold.
48
50
 
49
51
  At any detail level, the recorder MAY be configured to **mask payloads** — replacing payload bytes with zeroes before writing. This preserves payload sizes for bandwidth analysis while stripping media content. Sources MUST advertise masking via `"payloadMasked": true` in the header's `"custom"` map when active.
50
52
 
@@ -261,6 +263,33 @@ Object header events and object payload events (event 4) for the same object sha
261
263
  | `"o"` | integer | `headers`+ | Object ID |
262
264
  | `"pp"` | integer | `headers`+ | Publisher priority |
263
265
  | `"os"` | integer | `headers`+ | Object status (0=normal, 1=end-of-group, 2=end-of-track, 3=does-not-exist) |
266
+ | `"props"` | byte string | `headers`+ | The object's property block as the wire carried it, past its length field. **Optional.** See below. |
267
+
268
+ #### `"props"` — the property block
269
+
270
+ Object Properties — called Extension Headers before draft-17 — are the part of
271
+ an object a consumer reads before its payload: a LOC object's timestamp, frame
272
+ marking and codec configuration, the gaps a publisher signals after a restart,
273
+ the key ID of an encrypted object. Before this key no level recorded them, and
274
+ none of them can be recovered from the payload, so a trace could show an
275
+ object's bytes but not what decided how they were decoded.
276
+
277
+ - **Bytes, not a decoded list.** Every draft encodes the block differently —
278
+ absolute or delta types, two varint encodings — and a reader already holds
279
+ the codec for the session's draft. A decoded form would freeze one draft's
280
+ rules into this format, and the block MUST be recorded byte for byte so a
281
+ reader decodes exactly what the endpoint saw. Immutable Properties, which an
282
+ encrypted object authenticates, depend on that.
283
+ - **Absent and empty differ.** A writer MUST omit the key when the object had no
284
+ properties field, and MUST write an empty byte string when the field was
285
+ present with a length of 0 — on a subgroup stream with the PROPERTIES flag,
286
+ every object without properties carries one. A reader MUST keep the
287
+ distinction.
288
+ - **`headers`+, not payload-bearing.** The block is object metadata, not media.
289
+ A property value can still carry application data — a codec configuration is
290
+ a few dozen bytes of it — so a source that must not share any object content
291
+ MAY drop the key along with the payload. Dropping it does not change the
292
+ trace's detail level.
264
293
 
265
294
  ### Event 4: Object Payload
266
295
 
package/dist/index.cjs CHANGED
@@ -329,10 +329,10 @@ function cborToHeader(obj) {
329
329
  ...optional(src, "sessionId", "sessionId", asText),
330
330
  ...optional(src, "segment", "segment", cborToSegment),
331
331
  ...optional(src, "sampling", "sampling", cborToSampling),
332
- // Not `Record<string, unknown>` by assertion but by test. A `"custom"`
333
- // that is not a map used to reach this field wearing that type, so the
334
- // first caller to iterate its keys met a shape it was told could not
335
- // occur. The bytes survive either way; only one of the two keeps the
332
+ // Not `Record<string, unknown>` by assertion but by test. Asserted, a
333
+ // `"custom"` that is not a map would reach this field wearing that type,
334
+ // and the first caller to iterate its keys would meet a shape it was told
335
+ // could not occur. The bytes survive either way; only the test keeps the
336
336
  // field's type true.
337
337
  ...optional(src, "custom", "custom", asMap)
338
338
  };
@@ -404,6 +404,7 @@ function eventToCbor(event) {
404
404
  base.o = event.objectId;
405
405
  base.pp = int(event.publisherPriority);
406
406
  base.os = int(event.objectStatus);
407
+ if (event.properties != null) base.props = event.properties;
407
408
  break;
408
409
  }
409
410
  case "object-payload": {
@@ -661,7 +662,8 @@ function decodeEvent(src) {
661
662
  groupId: requireUint(src, "g"),
662
663
  objectId: requireUint(src, "o"),
663
664
  publisherPriority: Number(_nullishCoalesce(take(src, "pp"), () => ( 0))),
664
- objectStatus: Number(_nullishCoalesce(take(src, "os"), () => ( 0)))
665
+ objectStatus: Number(_nullishCoalesce(take(src, "os"), () => ( 0))),
666
+ ...optional(src, "props", "properties", asByteString)
665
667
  };
666
668
  case "object-payload":
667
669
  return {
@@ -1215,7 +1217,7 @@ function createRecorder(options) {
1215
1217
  errorCode
1216
1218
  });
1217
1219
  },
1218
- recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus) {
1220
+ recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus, properties) {
1219
1221
  if (detailRank < DETAIL_RANK.headers) return;
1220
1222
  addEvent({
1221
1223
  type: "object-header",
@@ -1225,7 +1227,8 @@ function createRecorder(options) {
1225
1227
  groupId,
1226
1228
  objectId,
1227
1229
  publisherPriority,
1228
- objectStatus
1230
+ objectStatus,
1231
+ ...properties !== void 0 ? { properties } : {}
1229
1232
  });
1230
1233
  },
1231
1234
  recordObjectPayload(streamId, groupId, objectId, size, payload) {
package/dist/index.d.cts CHANGED
@@ -298,6 +298,23 @@ interface ObjectHeaderEvent extends BaseEvent {
298
298
  readonly objectId: bigint;
299
299
  readonly publisherPriority: number;
300
300
  readonly objectStatus: number;
301
+ /**
302
+ * The Object's property block — Object Properties, Extension Headers before
303
+ * draft-17 — exactly as the wire carried it, past its length field.
304
+ *
305
+ * Absent when the Object had no properties field, which is not the same as an
306
+ * empty block: a subgroup stream with the PROPERTIES flag carries a zero
307
+ * length on every Object that has none. Kept as bytes rather than decoded
308
+ * because the encoding is the draft's, and a reader decodes it with the codec
309
+ * that reads the rest of the session; a decoded form would freeze one draft's
310
+ * rules into the format.
311
+ *
312
+ * Recorded from `'headers'` up. It is object metadata a payload's consumer
313
+ * reads before the payload — timestamps, frame marking, a codec
314
+ * configuration — and before this key no level recorded it, so a trace could
315
+ * not show what decided how its media was decoded.
316
+ */
317
+ readonly properties?: Uint8Array;
301
318
  }
302
319
  interface ObjectPayloadEvent extends BaseEvent {
303
320
  readonly type: 'object-payload';
@@ -917,8 +934,13 @@ interface TraceRecorder {
917
934
  recordStreamOpened(streamId: bigint, direction: 0 | 1, streamType: 0 | 1 | 2): void;
918
935
  /** Record a stream-closed event. Ignored at 'control' detail level. */
919
936
  recordStreamClosed(streamId: bigint, errorCode?: number): void;
920
- /** Record an object header event. Ignored below 'headers' detail level. */
921
- recordObjectHeader(streamId: bigint, groupId: bigint, objectId: bigint, publisherPriority: number, objectStatus: number): void;
937
+ /**
938
+ * Record an object header event. Ignored below 'headers' detail level.
939
+ *
940
+ * `properties` is the Object's property block as the wire carried it; omit it
941
+ * when the Object had no properties field.
942
+ */
943
+ recordObjectHeader(streamId: bigint, groupId: bigint, objectId: bigint, publisherPriority: number, objectStatus: number, properties?: Uint8Array): void;
922
944
  /** Record an object payload event. Ignored below 'headers+sizes' detail level. */
923
945
  recordObjectPayload(streamId: bigint, groupId: bigint, objectId: bigint, size: number, payload?: Uint8Array): void;
924
946
  /**
package/dist/index.d.ts CHANGED
@@ -298,6 +298,23 @@ interface ObjectHeaderEvent extends BaseEvent {
298
298
  readonly objectId: bigint;
299
299
  readonly publisherPriority: number;
300
300
  readonly objectStatus: number;
301
+ /**
302
+ * The Object's property block — Object Properties, Extension Headers before
303
+ * draft-17 — exactly as the wire carried it, past its length field.
304
+ *
305
+ * Absent when the Object had no properties field, which is not the same as an
306
+ * empty block: a subgroup stream with the PROPERTIES flag carries a zero
307
+ * length on every Object that has none. Kept as bytes rather than decoded
308
+ * because the encoding is the draft's, and a reader decodes it with the codec
309
+ * that reads the rest of the session; a decoded form would freeze one draft's
310
+ * rules into the format.
311
+ *
312
+ * Recorded from `'headers'` up. It is object metadata a payload's consumer
313
+ * reads before the payload — timestamps, frame marking, a codec
314
+ * configuration — and before this key no level recorded it, so a trace could
315
+ * not show what decided how its media was decoded.
316
+ */
317
+ readonly properties?: Uint8Array;
301
318
  }
302
319
  interface ObjectPayloadEvent extends BaseEvent {
303
320
  readonly type: 'object-payload';
@@ -917,8 +934,13 @@ interface TraceRecorder {
917
934
  recordStreamOpened(streamId: bigint, direction: 0 | 1, streamType: 0 | 1 | 2): void;
918
935
  /** Record a stream-closed event. Ignored at 'control' detail level. */
919
936
  recordStreamClosed(streamId: bigint, errorCode?: number): void;
920
- /** Record an object header event. Ignored below 'headers' detail level. */
921
- recordObjectHeader(streamId: bigint, groupId: bigint, objectId: bigint, publisherPriority: number, objectStatus: number): void;
937
+ /**
938
+ * Record an object header event. Ignored below 'headers' detail level.
939
+ *
940
+ * `properties` is the Object's property block as the wire carried it; omit it
941
+ * when the Object had no properties field.
942
+ */
943
+ recordObjectHeader(streamId: bigint, groupId: bigint, objectId: bigint, publisherPriority: number, objectStatus: number, properties?: Uint8Array): void;
922
944
  /** Record an object payload event. Ignored below 'headers+sizes' detail level. */
923
945
  recordObjectPayload(streamId: bigint, groupId: bigint, objectId: bigint, size: number, payload?: Uint8Array): void;
924
946
  /**
package/dist/index.js CHANGED
@@ -329,10 +329,10 @@ function cborToHeader(obj) {
329
329
  ...optional(src, "sessionId", "sessionId", asText),
330
330
  ...optional(src, "segment", "segment", cborToSegment),
331
331
  ...optional(src, "sampling", "sampling", cborToSampling),
332
- // Not `Record<string, unknown>` by assertion but by test. A `"custom"`
333
- // that is not a map used to reach this field wearing that type, so the
334
- // first caller to iterate its keys met a shape it was told could not
335
- // occur. The bytes survive either way; only one of the two keeps the
332
+ // Not `Record<string, unknown>` by assertion but by test. Asserted, a
333
+ // `"custom"` that is not a map would reach this field wearing that type,
334
+ // and the first caller to iterate its keys would meet a shape it was told
335
+ // could not occur. The bytes survive either way; only the test keeps the
336
336
  // field's type true.
337
337
  ...optional(src, "custom", "custom", asMap)
338
338
  };
@@ -404,6 +404,7 @@ function eventToCbor(event) {
404
404
  base.o = event.objectId;
405
405
  base.pp = int(event.publisherPriority);
406
406
  base.os = int(event.objectStatus);
407
+ if (event.properties != null) base.props = event.properties;
407
408
  break;
408
409
  }
409
410
  case "object-payload": {
@@ -661,7 +662,8 @@ function decodeEvent(src) {
661
662
  groupId: requireUint(src, "g"),
662
663
  objectId: requireUint(src, "o"),
663
664
  publisherPriority: Number(take(src, "pp") ?? 0),
664
- objectStatus: Number(take(src, "os") ?? 0)
665
+ objectStatus: Number(take(src, "os") ?? 0),
666
+ ...optional(src, "props", "properties", asByteString)
665
667
  };
666
668
  case "object-payload":
667
669
  return {
@@ -1215,7 +1217,7 @@ function createRecorder(options) {
1215
1217
  errorCode
1216
1218
  });
1217
1219
  },
1218
- recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus) {
1220
+ recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus, properties) {
1219
1221
  if (detailRank < DETAIL_RANK.headers) return;
1220
1222
  addEvent({
1221
1223
  type: "object-header",
@@ -1225,7 +1227,8 @@ function createRecorder(options) {
1225
1227
  groupId,
1226
1228
  objectId,
1227
1229
  publisherPriority,
1228
- objectStatus
1230
+ objectStatus,
1231
+ ...properties !== void 0 ? { properties } : {}
1229
1232
  });
1230
1233
  },
1231
1234
  recordObjectPayload(streamId, groupId, objectId, size, payload) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moqtap/trace",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "MoQT session trace format — record, serialize, and analyze .moqtrace files",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,7 +48,7 @@
48
48
  "corpus": "bun run src/__tests__/corpus/generate.ts && bun run src/__tests__/corpus/manifest.ts"
49
49
  },
50
50
  "peerDependencies": {
51
- "@moqtap/codec": "*"
51
+ "@moqtap/codec": ">=0.11.0"
52
52
  },
53
53
  "dependencies": {
54
54
  "cbor-x": "^1.6.0"
package/src/binary.ts CHANGED
@@ -110,11 +110,11 @@ const COMMON_EVENT_KEYS = new Set(['n', 't', 'p', 'e'])
110
110
  * "A defined key whose value has an unusable type is treated as unrecognised."
111
111
  *
112
112
  * Only the decode writes to the set, so there is no separate list of owned keys
113
- * to keep in step with it. The list there used to be is what made adding
114
- * `"ta"`, `"sg"`, `"fri"` and `"g"` to Event 1 *reduce* what this reader
115
- * preserved: those keys counted as owned whatever they carried, so a
116
- * wrong-typed one was kept out of `extra` while the decode either threw on it,
117
- * losing the whole file, or coerced it into a value the file never carried.
113
+ * to keep in step with it. A list would make adding a key to an event *reduce*
114
+ * what this reader preserves: a listed key counts as owned whatever it
115
+ * carries, so a wrong-typed one would be kept out of `extra` while the decode
116
+ * either threw on it, losing the whole file, or coerced it into a value the
117
+ * file never carried.
118
118
  */
119
119
  interface Decoding {
120
120
  readonly obj: Record<string, unknown>
@@ -315,8 +315,8 @@ function segmentToCbor(segment: SegmentInfo): Record<string, unknown> {
315
315
  *
316
316
  * `"sequence"` is the exception, and the only key inside here that can fail a
317
317
  * header: it is the sole ordering key of a segmented stream, so a reader that
318
- * cannot read it cannot place the segment, and the `0` this function used to
319
- * default to invents an order the file never had.
318
+ * cannot read it cannot place the segment, and defaulting it to `0` would
319
+ * invent an order the file never had.
320
320
  */
321
321
  function cborToSegment(value: unknown): SegmentInfo | undefined {
322
322
  const obj = asMap(value)
@@ -415,10 +415,10 @@ export function cborToHeader(obj: Record<string, unknown>): TraceHeader {
415
415
  ...optional(src, 'sessionId', 'sessionId', asText),
416
416
  ...optional(src, 'segment', 'segment', cborToSegment),
417
417
  ...optional(src, 'sampling', 'sampling', cborToSampling),
418
- // Not `Record<string, unknown>` by assertion but by test. A `"custom"`
419
- // that is not a map used to reach this field wearing that type, so the
420
- // first caller to iterate its keys met a shape it was told could not
421
- // occur. The bytes survive either way; only one of the two keeps the
418
+ // Not `Record<string, unknown>` by assertion but by test. Asserted, a
419
+ // `"custom"` that is not a map would reach this field wearing that type,
420
+ // and the first caller to iterate its keys would meet a shape it was told
421
+ // could not occur. The bytes survive either way; only the test keeps the
422
422
  // field's type true.
423
423
  ...optional(src, 'custom', 'custom', asMap),
424
424
  }
@@ -542,6 +542,7 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
542
542
  base.o = event.objectId
543
543
  base.pp = int(event.publisherPriority)
544
544
  base.os = int(event.objectStatus)
545
+ if (event.properties != null) base.props = event.properties
545
546
  break
546
547
  }
547
548
  case 'object-payload': {
@@ -808,10 +809,10 @@ function requireUint(src: Decoding, key: string): bigint {
808
809
  /**
809
810
  * Take a required header text key, or fail the header.
810
811
  *
811
- * The reader used to assert the type instead — `obj.protocol as string` — which
812
- * let a header with no `"protocol"` reach a caller with `undefined` sitting in
813
- * a field declared `string`, and re-encode as CBOR `undefined`, a shape the
814
- * format tells writers never to produce.
812
+ * Asserting the type instead — `obj.protocol as string` — would let a header
813
+ * with no `"protocol"` reach a caller with `undefined` sitting in a field
814
+ * declared `string`, and re-encode as CBOR `undefined`, a shape the format
815
+ * tells writers never to produce.
815
816
  */
816
817
  function requireHeaderText(src: Decoding, key: string): string {
817
818
  const present = Object.hasOwn(src.obj, key)
@@ -986,6 +987,7 @@ function decodeEvent(src: Decoding): TraceEvent {
986
987
  objectId: requireUint(src, 'o'),
987
988
  publisherPriority: Number(take(src, 'pp') ?? 0),
988
989
  objectStatus: Number(take(src, 'os') ?? 0),
990
+ ...optional(src, 'props', 'properties', asByteString),
989
991
  }
990
992
 
991
993
  case 'object-payload':
package/src/recorder.ts CHANGED
@@ -104,13 +104,19 @@ export interface TraceRecorder {
104
104
  /** Record a stream-closed event. Ignored at 'control' detail level. */
105
105
  recordStreamClosed(streamId: bigint, errorCode?: number): void
106
106
 
107
- /** Record an object header event. Ignored below 'headers' detail level. */
107
+ /**
108
+ * Record an object header event. Ignored below 'headers' detail level.
109
+ *
110
+ * `properties` is the Object's property block as the wire carried it; omit it
111
+ * when the Object had no properties field.
112
+ */
108
113
  recordObjectHeader(
109
114
  streamId: bigint,
110
115
  groupId: bigint,
111
116
  objectId: bigint,
112
117
  publisherPriority: number,
113
118
  objectStatus: number,
119
+ properties?: Uint8Array,
114
120
  ): void
115
121
 
116
122
  /** Record an object payload event. Ignored below 'headers+sizes' detail level. */
@@ -171,10 +177,10 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
171
177
  /**
172
178
  * Index of the oldest event still live in {@link events}.
173
179
  *
174
- * Dropping used to be `events.shift()`, which moves the whole backlog on
175
- * every call; at a hundred thousand retained events that is O(n) per
176
- * recorded event, and it bites hardest exactly when the recorder is already
177
- * under pressure. The head advances instead, and the dead prefix is spliced
180
+ * Dropping is not `events.shift()`, which moves the whole backlog on every
181
+ * call; at a hundred thousand retained events that is O(n) per recorded
182
+ * event, and it bites hardest exactly when the recorder is already under
183
+ * pressure. The head advances instead, and the dead prefix is spliced
178
184
  * away in one pass every few thousand drops, which is O(1) amortised.
179
185
  */
180
186
  let head = 0
@@ -342,7 +348,7 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
342
348
  })
343
349
  },
344
350
 
345
- recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus) {
351
+ recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus, properties) {
346
352
  if (detailRank < DETAIL_RANK.headers) return
347
353
  addEvent({
348
354
  type: 'object-header',
@@ -353,6 +359,7 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
353
359
  objectId,
354
360
  publisherPriority,
355
361
  objectStatus,
362
+ ...(properties !== undefined ? { properties } : {}),
356
363
  })
357
364
  },
358
365
 
package/src/types.ts CHANGED
@@ -319,6 +319,23 @@ export interface ObjectHeaderEvent extends BaseEvent {
319
319
  readonly objectId: bigint
320
320
  readonly publisherPriority: number
321
321
  readonly objectStatus: number
322
+ /**
323
+ * The Object's property block — Object Properties, Extension Headers before
324
+ * draft-17 — exactly as the wire carried it, past its length field.
325
+ *
326
+ * Absent when the Object had no properties field, which is not the same as an
327
+ * empty block: a subgroup stream with the PROPERTIES flag carries a zero
328
+ * length on every Object that has none. Kept as bytes rather than decoded
329
+ * because the encoding is the draft's, and a reader decodes it with the codec
330
+ * that reads the rest of the session; a decoded form would freeze one draft's
331
+ * rules into the format.
332
+ *
333
+ * Recorded from `'headers'` up. It is object metadata a payload's consumer
334
+ * reads before the payload — timestamps, frame marking, a codec
335
+ * configuration — and before this key no level recorded it, so a trace could
336
+ * not show what decided how its media was decoded.
337
+ */
338
+ readonly properties?: Uint8Array
322
339
  }
323
340
 
324
341
  export interface ObjectPayloadEvent extends BaseEvent {