@moqtap/trace 0.2.0 → 0.4.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
@@ -3,10 +3,16 @@
3
3
  Record, serialize, and analyze MoQT (Media over QUIC Transport) sessions using the `.moqtrace` binary format.
4
4
 
5
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
6
7
  - Configurable detail levels from control-only to full wire captures
7
8
  - Session recorder that wraps `@moqtap/codec` session state machines
8
9
  - Human-readable JSON export for debugging
9
- - Zero-copy streaming writer for large traces
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.
10
16
 
11
17
  ## Install
12
18
 
@@ -47,6 +53,26 @@ const trace = recorder.finalize()
47
53
  const bytes = writeMoqtrace(trace) // → Uint8Array (.moqtrace binary)
48
54
  ```
49
55
 
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
+
50
76
  ### Reading a trace file
51
77
 
52
78
  ```typescript
@@ -78,6 +104,111 @@ for (const event of events) {
78
104
  }
79
105
  ```
80
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
+ ```
192
+
193
+ ## Reading a trace you did not write
194
+
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.
211
+
81
212
  ## Detail Levels
82
213
 
83
214
  | Level | What's recorded |
@@ -88,6 +219,10 @@ for (const event of events) {
88
219
  | `headers+data` | + full payload bytes |
89
220
  | `full` | + raw wire bytes (pre-decode) |
90
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
+
91
226
  ## JSON Export
92
227
 
93
228
  For human-readable debugging (not lossless — bigint/Uint8Array become strings):
@@ -99,6 +234,54 @@ const json = traceToJSON(trace)
99
234
  console.log(json)
100
235
  ```
101
236
 
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
+
102
285
  ## File Format
103
286
 
104
287
  See [SPEC.md](./SPEC.md) for the complete `.moqtrace` binary format specification.