@moqtap/trace 0.4.1 → 0.4.2

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: 'moqt-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/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
  };
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moqtap/trace",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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
  }
@@ -808,10 +808,10 @@ function requireUint(src: Decoding, key: string): bigint {
808
808
  /**
809
809
  * Take a required header text key, or fail the header.
810
810
  *
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.
811
+ * Asserting the type instead — `obj.protocol as string` — would let a header
812
+ * with no `"protocol"` reach a caller with `undefined` sitting in a field
813
+ * declared `string`, and re-encode as CBOR `undefined`, a shape the format
814
+ * tells writers never to produce.
815
815
  */
816
816
  function requireHeaderText(src: Decoding, key: string): string {
817
817
  const present = Object.hasOwn(src.obj, key)
package/src/recorder.ts CHANGED
@@ -171,10 +171,10 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
171
171
  /**
172
172
  * Index of the oldest event still live in {@link events}.
173
173
  *
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
174
+ * Dropping is not `events.shift()`, which moves the whole backlog on every
175
+ * call; at a hundred thousand retained events that is O(n) per recorded
176
+ * event, and it bites hardest exactly when the recorder is already under
177
+ * pressure. The head advances instead, and the dead prefix is spliced
178
178
  * away in one pass every few thousand drops, which is O(1) amortised.
179
179
  */
180
180
  let head = 0