@moqtap/trace 0.3.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/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  // Binary .moqtrace format
2
2
 
3
- export type { MoqtraceWriter, ReadOptions } from './binary.js'
3
+ export type { MoqtraceWriter, ReadOptions, RecoveredRegion } from './binary.js'
4
4
  export {
5
5
  createMoqtraceWriter,
6
6
  FORMAT_VERSION,
7
+ MalformedHeaderError,
7
8
  readMoqtrace,
8
9
  readMoqtraceHeader,
9
10
  readMoqtraceSegments,
@@ -14,9 +15,12 @@ export {
14
15
  } from './binary.js'
15
16
  // JSON (convenience)
16
17
  export { traceToJSON } from './json.js'
17
- export type { TraceRecorder } from './recorder.js'
18
+ // Incremental reader, for a stream that arrives in pieces
19
+ export type { MoqtraceReader, ReadItem } from './reader.js'
20
+ export { createMoqtraceReader, TruncatedStreamError } from './reader.js'
21
+ export type { ErrorDetails, TraceRecorder } from './recorder.js'
18
22
  // Recorder
19
- export { createRecorder } from './recorder.js'
23
+ export { createRecorder, MAX_ERROR_RAW_BYTES } from './recorder.js'
20
24
 
21
25
  // Types
22
26
  export type {
@@ -25,6 +29,7 @@ export type {
25
29
  DerivationKind,
26
30
  DetailLevel,
27
31
  DropPolicy,
32
+ ErrorKind,
28
33
  ObjectHeaderEvent,
29
34
  ObjectPayloadEvent,
30
35
  PeerConnectedEvent,
@@ -46,4 +51,4 @@ export type {
46
51
  TraceHeader,
47
52
  UnknownEvent,
48
53
  } from './types.js'
49
- export { TRACE_ID_LENGTH } from './types.js'
54
+ export { controlMessageFields, TRACE_ID_LENGTH } from './types.js'
package/src/json.ts CHANGED
@@ -7,6 +7,13 @@ import type { Trace } from './types.js'
7
7
  * This is a one-way export for human inspection and debugging.
8
8
  * For lossless round-tripping, use the binary .moqtrace format
9
9
  * (`writeMoqtrace` / `readMoqtrace`).
10
+ *
11
+ * The unrecognised-key stores are part of the rendering rather than filtered
12
+ * out of it: `extra` appears on the header, on `segment`, on `sampling` and on
13
+ * each event, nested values and all. A view that showed only the keys this
14
+ * version names would reintroduce, for anyone reading a trace through it,
15
+ * exactly the loss the binary reader stopped making — and it is the view
16
+ * someone reaches for precisely when they suspect a key went missing.
10
17
  */
11
18
  export function traceToJSON(trace: Trace): string {
12
19
  return JSON.stringify(
package/src/reader.ts ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Incremental reader for the `.moqtrace` binary format.
3
+ *
4
+ * The three entry points in `binary.ts` all take the whole file at once, which
5
+ * a live capture does not have: the bytes arrive in chunks whose boundaries
6
+ * fall wherever the transport put them, almost never on an item boundary. This
7
+ * reader takes those chunks and hands back the items that have become whole,
8
+ * keeping the remainder for the next one.
9
+ *
10
+ * It is the read side of {@link createMoqtraceWriter}, which has been
11
+ * incremental from the start — the asymmetry this closes was one-sided.
12
+ *
13
+ * Decoding is not reimplemented here. The header and event decoders are
14
+ * imported from `binary.ts` so that a trace read in instalments and the same
15
+ * trace read whole produce the same values. A second decoder would be a second
16
+ * set of answers, and nothing tests two readers of one format against each
17
+ * other, so the disagreement would surface as a rendering bug somewhere far
18
+ * from here.
19
+ */
20
+
21
+ import {
22
+ cborToEvent,
23
+ cborToHeader,
24
+ decode,
25
+ MAGIC,
26
+ PREAMBLE_SIZE,
27
+ SUPPORTED_VERSIONS,
28
+ startsWithMagic,
29
+ } from './binary.js'
30
+ import { cborItemLength } from './cbor-scan.js'
31
+ import type { TraceEvent, TraceHeader } from './types.js'
32
+
33
+ /**
34
+ * The stream ended part-way through an item.
35
+ *
36
+ * Deliberately not `TruncatedTraceError`, whose `segments` field promises
37
+ * "everything that decoded before the cut". An incremental reader has already
38
+ * handed those events to its caller and does not keep them, so it cannot fill
39
+ * that field — and filling it with an empty array would report a trace that
40
+ * decoded nothing, which is a wrong answer rather than a missing one. The
41
+ * caller holds what decoded; this says only where the bytes stopped.
42
+ */
43
+ export class TruncatedStreamError extends Error {
44
+ /**
45
+ * Offset into the whole stream at which the incomplete item begins — not
46
+ * where the bytes ran out. It matches the Rust reader's
47
+ * `MoqTraceError::Truncated { offset }`, so the same cut is named the same
48
+ * number in both languages.
49
+ */
50
+ readonly offset: number
51
+
52
+ /**
53
+ * How many events reached the caller before the cut.
54
+ *
55
+ * The events themselves are gone — that is what makes this error different
56
+ * from {@link TruncatedTraceError} — but their count is not, and it costs one
57
+ * integer to keep. It is the difference between "the stream was cut" and "the
58
+ * stream was cut after 4,812 events at byte 190,224", and only the second
59
+ * tells whoever is holding a damaged capture how much of it they still have.
60
+ */
61
+ readonly events: number
62
+
63
+ constructor(offset: number, events: number) {
64
+ super(
65
+ `Trace stream truncated: the item starting at byte ${offset} is incomplete, ` +
66
+ `after ${events} event${events === 1 ? '' : 's'}`,
67
+ )
68
+ this.name = 'TruncatedStreamError'
69
+ this.offset = offset
70
+ this.events = events
71
+ }
72
+ }
73
+
74
+ /**
75
+ * One thing the reader has finished reading.
76
+ *
77
+ * The two kinds mirror the Rust crate's `ReadItem`, so a consumer that has read
78
+ * one implementation recognises the other. A `segment` arrives before any event
79
+ * that belongs to it, including the first one: unlike the Rust reader, which
80
+ * takes a blocking source and parses the opening header in its constructor,
81
+ * this one cannot have a header until bytes arrive, so every header reaches the
82
+ * caller the same way and there is no special case for the first.
83
+ */
84
+ export type ReadItem =
85
+ | { readonly kind: 'segment'; readonly header: TraceHeader }
86
+ | { readonly kind: 'event'; readonly event: TraceEvent }
87
+
88
+ /** Reads a `.moqtrace` stream in whatever instalments it arrives in. */
89
+ export interface MoqtraceReader {
90
+ /**
91
+ * Add the next chunk and take everything it completed.
92
+ *
93
+ * Returns an empty array when the chunk did not finish an item, which is
94
+ * ordinary and not a signal of anything. Bytes left over are kept.
95
+ *
96
+ * @throws {MalformedHeaderError} if a segment header carries no usable value
97
+ * for a key the format requires.
98
+ * @throws {MalformedCborError} if the bytes at an item boundary are not a
99
+ * well-formed CBOR item.
100
+ */
101
+ push(chunk: Uint8Array): ReadItem[]
102
+
103
+ /**
104
+ * Declare the stream complete and take any last item.
105
+ *
106
+ * This can return items. An event shorter than the magic cannot be told from
107
+ * the start of a segment preamble while more bytes might still arrive, so
108
+ * such an event waits here until `end()` rules the preamble out.
109
+ *
110
+ * @throws {TruncatedStreamError} if bytes remain that do not form a whole
111
+ * item — the file stops mid-event, mid-preamble or mid-header.
112
+ */
113
+ end(): ReadItem[]
114
+
115
+ /**
116
+ * The header of the segment now being read, or `undefined` before the first
117
+ * one is whole. In a segmented stream this changes at every boundary.
118
+ */
119
+ readonly header: TraceHeader | undefined
120
+
121
+ /** Bytes held back because they do not yet form a whole item. */
122
+ readonly pending: number
123
+ }
124
+
125
+ /**
126
+ * A reader that takes the stream in chunks.
127
+ *
128
+ * ```ts
129
+ * const reader = createMoqtraceReader()
130
+ * for await (const chunk of stream) {
131
+ * for (const item of reader.push(chunk)) {
132
+ * if (item.kind === 'event') render(item.event)
133
+ * }
134
+ * }
135
+ * for (const item of reader.end()) { ... }
136
+ * ```
137
+ *
138
+ * `ReadOptions.recover` has no counterpart here **on purpose.** Its behaviour
139
+ * in the whole-buffer readers — drop the damaged segment, return the rest, say
140
+ * nothing — is under revision, because a caller cannot tell three segments from
141
+ * four with one unreadable. Guessing an answer here would make an incremental
142
+ * reader the place that shipped it, and a live view is where a silently
143
+ * dropped segment does the most harm. Until that is settled this reader stops
144
+ * at damage and says where.
145
+ */
146
+ export function createMoqtraceReader(): MoqtraceReader {
147
+ // One buffer rather than a chunk list. After a consume it is `subarray`d, so
148
+ // the retained bytes are a view and not a copy, and in the steady state the
149
+ // remainder is smaller than one item — the concatenation on each push costs
150
+ // that remainder, not the stream.
151
+ let buffer = new Uint8Array(0)
152
+ // Where `buffer[0]` sits in the whole stream, so an error names an offset
153
+ // into the file rather than into whatever is left of it.
154
+ let consumed = 0
155
+ // Events handed to the caller, kept only so a truncation can say how much of
156
+ // the capture survived it.
157
+ let delivered = 0
158
+ let current: TraceHeader | undefined
159
+ let ended = false
160
+
161
+ function take(count: number): Uint8Array {
162
+ const slice = buffer.subarray(0, count)
163
+ buffer = buffer.subarray(count)
164
+ consumed += count
165
+ return slice
166
+ }
167
+
168
+ /**
169
+ * Read a preamble and its header if all of it is here.
170
+ *
171
+ * Returns `undefined` to mean "not yet", which is why the length checks are
172
+ * not `validatePreamble`'s: that function is written for a buffer that is the
173
+ * whole file, so it reads a short one as a truncated file and throws. Here a
174
+ * short buffer is the normal state between two chunks. What it does share is
175
+ * the part worth sharing — the version check against
176
+ * {@link SUPPORTED_VERSIONS} and, once the bytes are all present,
177
+ * `cborToHeader`.
178
+ */
179
+ function tryReadSegment(): TraceHeader | undefined {
180
+ if (buffer.length < PREAMBLE_SIZE) return undefined
181
+
182
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
183
+ const version = view.getUint32(8, true)
184
+ if (!SUPPORTED_VERSIONS.includes(version)) {
185
+ throw new Error(
186
+ `Unsupported format version: ${version} (supported: ${SUPPORTED_VERSIONS.join(', ')})`,
187
+ )
188
+ }
189
+
190
+ const headerLength = view.getUint32(12, true)
191
+ if (buffer.length < PREAMBLE_SIZE + headerLength) return undefined
192
+
193
+ take(PREAMBLE_SIZE)
194
+ const headerBytes = take(headerLength)
195
+ return cborToHeader(decode(headerBytes) as Record<string, unknown>)
196
+ }
197
+
198
+ function drain(atEnd: boolean): ReadItem[] {
199
+ const items: ReadItem[] = []
200
+
201
+ for (;;) {
202
+ if (current === undefined) {
203
+ // Nothing has been read yet, so the stream must open with a preamble.
204
+ // Saying so here means a caller who points this at some other file
205
+ // learns that from the magic rather than from a CBOR error deep in it.
206
+ if (buffer.length >= MAGIC.length && !startsWithMagic(buffer, 0)) {
207
+ throw new Error('Invalid magic bytes: not a .moqtrace file')
208
+ }
209
+ const header = tryReadSegment()
210
+ if (header === undefined) break
211
+ current = header
212
+ items.push({ kind: 'segment', header })
213
+ continue
214
+ }
215
+
216
+ if (buffer.length === 0) break
217
+
218
+ // A segment boundary is looked for only here, at a real item boundary —
219
+ // the same eight bytes inside a captured payload are not one. Deciding
220
+ // needs the whole magic: with fewer bytes than that in hand, "does not
221
+ // start with the magic" is not yet a fact about the stream, only about
222
+ // how much of it has arrived. Waiting is wrong at the end of the stream
223
+ // and right everywhere else, which is what `atEnd` distinguishes.
224
+ if (buffer.length < MAGIC.length && !atEnd) break
225
+
226
+ if (startsWithMagic(buffer, 0)) {
227
+ const header = tryReadSegment()
228
+ if (header === undefined) break
229
+ current = header
230
+ items.push({ kind: 'segment', header })
231
+ continue
232
+ }
233
+
234
+ const length = cborItemLength(buffer, 0)
235
+ if (length === null) break
236
+
237
+ const item = decode(take(length))
238
+ items.push({ kind: 'event', event: cborToEvent(item as Record<string, unknown>) })
239
+ delivered += 1
240
+ }
241
+
242
+ return items
243
+ }
244
+
245
+ return {
246
+ push(chunk: Uint8Array): ReadItem[] {
247
+ if (ended) throw new Error('Cannot push to a reader that has ended')
248
+ if (chunk.length > 0) {
249
+ const grown = new Uint8Array(buffer.length + chunk.length)
250
+ grown.set(buffer, 0)
251
+ grown.set(chunk, buffer.length)
252
+ buffer = grown
253
+ }
254
+ return drain(false)
255
+ },
256
+
257
+ end(): ReadItem[] {
258
+ if (ended) return []
259
+ ended = true
260
+ const items = drain(true)
261
+ // The one place a truncation is declared. `drain` only ever stops at
262
+ // bytes it cannot use yet, which is the ordinary state between chunks and
263
+ // a fault only once no more are coming — so it reports nothing and leaves
264
+ // the leftovers, and the verdict is taken here, where "no more are
265
+ // coming" is known. Raising it in both places, as an earlier revision
266
+ // did, gave one fault two throw sites that could drift apart.
267
+ //
268
+ // A reader that never produced a header is the same fault: it saw no
269
+ // preamble at all, which includes the empty stream. Returning no items
270
+ // for that would report a trace that recorded nothing, when what happened
271
+ // is that no trace arrived.
272
+ if (buffer.length > 0 || current === undefined) {
273
+ throw new TruncatedStreamError(consumed, delivered)
274
+ }
275
+ return items
276
+ },
277
+
278
+ get header(): TraceHeader | undefined {
279
+ return current
280
+ },
281
+
282
+ get pending(): number {
283
+ return buffer.length
284
+ },
285
+ }
286
+ }
package/src/recorder.ts CHANGED
@@ -1,5 +1,84 @@
1
1
  import type { SessionState } from '@moqtap/codec/session'
2
- import type { DetailLevel, RecorderOptions, Trace, TraceEvent, TraceHeader } from './types.js'
2
+ import type {
3
+ DetailLevel,
4
+ ErrorKind,
5
+ RecorderOptions,
6
+ Trace,
7
+ TraceErrorEvent,
8
+ TraceEvent,
9
+ TraceHeader,
10
+ } from './types.js'
11
+
12
+ /**
13
+ * The most bytes a recorder may put in an error event's `raw`.
14
+ *
15
+ * A MUST in the format, and a fixed number rather than a suggestion, because
16
+ * an error event is one of the types sampling must not drop: every other
17
+ * high-volume field is bounded by sampling, so if this one is not bounded here
18
+ * it is not bounded at all. A peer that opens ten thousand streams and sends
19
+ * garbage on each is what a fuzzer does to a relay, and the trace of it should
20
+ * not be larger than the attack.
21
+ *
22
+ * It lives here, in the recorder, and appears nowhere in `binary.ts`. The cap
23
+ * is addressed to the party deciding what to say about traffic it has just
24
+ * observed — {@link TraceRecorder.recordError}, and nothing else. A serializer
25
+ * cannot tell a freshly recorded event from one that arrived by being read, so
26
+ * a cap applied there would either truncate evidence on a rewrite or refuse a
27
+ * file the reader was required to accept. `writeMoqtrace` therefore writes a
28
+ * `raw` of any length, and {@link TraceRecorder.record} — which inserts an
29
+ * event a caller may well have read out of a file — does not apply it either.
30
+ */
31
+ export const MAX_ERROR_RAW_BYTES = 4096
32
+
33
+ /**
34
+ * What a recorder knows about an error beyond its code and reason.
35
+ *
36
+ * Every field is optional and each is gated at its own detail level, so one
37
+ * call site can hand over everything it has and let the recorder keep what its
38
+ * level allows.
39
+ */
40
+ export interface ErrorDetails {
41
+ /**
42
+ * QUIC stream the error was observed on.
43
+ *
44
+ * Omit it when there was no stream or none is known — that is what its
45
+ * absence says, and a reader must not read it as stream `0`.
46
+ */
47
+ readonly streamId?: bigint
48
+ /** What sort of failure this was. An open vocabulary; any string is legal. */
49
+ readonly errorKind?: ErrorKind
50
+ /**
51
+ * The offending bytes, in full and untruncated.
52
+ *
53
+ * Hand over everything held: the recorder applies {@link
54
+ * MAX_ERROR_RAW_BYTES} and reports the untruncated length in `rawLength`,
55
+ * so a caller that pre-truncates loses the one signal saying the capture is
56
+ * partial. Recorded only at `'full'`, and only once per flow.
57
+ */
58
+ readonly raw?: Uint8Array
59
+ /**
60
+ * The length to report, where the caller already holds less than it saw.
61
+ *
62
+ * Defaults to `raw.length`, which is right whenever `raw` is everything
63
+ * there was. Set it to state a length larger than the bytes on hand — a
64
+ * caller that hit a cap of its own upstream, or one that knows a message's
65
+ * declared length without having kept it.
66
+ *
67
+ * **Set it to `null` when the true length is unknown**, and the key is
68
+ * omitted. SPEC.md requires that rather than a guess, because the guess is
69
+ * not neutral: `rawlen` equal to the bytes present is this format's signal
70
+ * for *not truncated*, so defaulting it would assert a complete capture
71
+ * exactly where the caller knows least about whether the input was
72
+ * complete. Absent, a reader treats a `raw` of exactly
73
+ * {@link MAX_ERROR_RAW_BYTES} as possibly truncated, which is the truth.
74
+ *
75
+ * The caller this exists for is real rather than hypothetical: a recorder
76
+ * that stops *reading* a stream once it will not parse — the sensible thing
77
+ * to do with such a stream — never holds more than its own cap and cannot
78
+ * know what it did not read.
79
+ */
80
+ readonly rawLength?: number | null
81
+ }
3
82
 
4
83
  export interface TraceRecorder {
5
84
  /** Wrap a SessionState to auto-record control messages and state changes. */
@@ -7,7 +86,16 @@ export interface TraceRecorder {
7
86
  session: SessionState<M, T>,
8
87
  ): SessionState<M, T>
9
88
 
10
- /** Record an arbitrary event manually. */
89
+ /**
90
+ * Record an arbitrary event manually.
91
+ *
92
+ * A passthrough: no detail-level gate, and no cap on an error event's
93
+ * `raw`, because an event handed here may have been read out of a file
94
+ * rather than built from observed bytes, and shortening that one would
95
+ * destroy evidence to enforce a rule addressed to whoever recorded it.
96
+ * Callers building an event from bytes they just saw want
97
+ * {@link recordError}.
98
+ */
11
99
  record(event: TraceEvent): void
12
100
 
13
101
  /** Record a stream-opened event. Ignored at 'control' detail level. */
@@ -34,8 +122,15 @@ export interface TraceRecorder {
34
122
  payload?: Uint8Array,
35
123
  ): void
36
124
 
37
- /** Record a protocol error. */
38
- recordError(errorCode: number, reason: string): void
125
+ /**
126
+ * Record a protocol error.
127
+ *
128
+ * Recorded at every detail level — an error event is non-droppable — but
129
+ * `details` is gated key by key: `streamId` and `errorKind` at every
130
+ * level, `rawLength` from `'headers+sizes'`, and `raw` at `'full'`
131
+ * alone, capped at {@link MAX_ERROR_RAW_BYTES} and written once per flow.
132
+ */
133
+ recordError(errorCode: number, reason: string, details?: ErrorDetails): void
39
134
 
40
135
  /** Record a user-defined annotation. */
41
136
  annotate(label: string, data?: unknown): void
@@ -73,14 +168,47 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
73
168
  const messageTypeId = options.messageTypeId ?? (() => 0)
74
169
 
75
170
  const events: TraceEvent[] = []
171
+ /**
172
+ * Index of the oldest event still live in {@link events}.
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
178
+ * away in one pass every few thousand drops, which is O(1) amortised.
179
+ */
180
+ let head = 0
181
+ /** Events dropped to stay under `maxEvents`, reported as `SamplingInfo`. */
182
+ let dropped = 0
183
+ /**
184
+ * Flows whose bytes have already been recorded.
185
+ *
186
+ * The format allows `"raw"` once per flow — per stream where the error
187
+ * names one, per peer where it does not, and this recorder writes no peer
188
+ * identifier, so every error naming no stream shares one flow.
189
+ *
190
+ * The latch is on the field and not on the event: later errors on a flow are
191
+ * still recorded in full, they simply carry no bytes. Suppressing the events
192
+ * would discard the causal record precisely when a peer is misbehaving
193
+ * repeatedly, which is when it matters most. What grows without bound is the
194
+ * bytes, so that is what is bounded.
195
+ */
196
+ const rawRecorded = new Set<string>()
76
197
  let _recording = true
77
198
  let _seq = 0
78
199
  const startTime = Date.now()
79
200
 
80
201
  function addEvent(event: TraceEvent): void {
81
202
  if (!_recording) return
82
- if (events.length >= maxEvents) {
83
- events.shift()
203
+ if (events.length - head >= maxEvents) {
204
+ head++
205
+ dropped++
206
+ // Compact in one pass rather than per drop. Bounded both ways so a small
207
+ // `maxEvents` does not accumulate a long dead prefix.
208
+ if (head >= 4096 || head >= maxEvents) {
209
+ events.splice(0, head)
210
+ head = 0
211
+ }
84
212
  }
85
213
  events.push(event)
86
214
  }
@@ -89,6 +217,18 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
89
217
  return _seq++
90
218
  }
91
219
 
220
+ /** The flow an error belongs to, for the one-`raw`-per-flow latch. */
221
+ function rawFlow(streamId: bigint | undefined): string {
222
+ return streamId == null ? 'no-stream' : `stream:${streamId}`
223
+ }
224
+
225
+ /** Take a flow's one slot for recording bytes, or answer that it is spent. */
226
+ function claimRawSlot(flow: string): boolean {
227
+ if (rawRecorded.has(flow)) return false
228
+ rawRecorded.add(flow)
229
+ return true
230
+ }
231
+
92
232
  function wrapSession<M extends { type: string }, T extends string>(
93
233
  session: SessionState<M, T>,
94
234
  ): SessionState<M, T> {
@@ -122,7 +262,7 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
122
262
  timestamp: clock(),
123
263
  direction: 1, // rx
124
264
  messageType: messageTypeId(message.type),
125
- message: message as unknown as Record<string, unknown>,
265
+ message,
126
266
  })
127
267
 
128
268
  if (result.ok && result.phase !== prevPhase) {
@@ -152,7 +292,7 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
152
292
  timestamp: clock(),
153
293
  direction: 0, // tx
154
294
  messageType: messageTypeId(message.type),
155
- message: message as unknown as Record<string, unknown>,
295
+ message,
156
296
  })
157
297
 
158
298
  if (result.ok && result.phase !== prevPhase) {
@@ -231,14 +371,40 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
231
371
  addEvent(event)
232
372
  },
233
373
 
234
- recordError(errorCode, reason) {
235
- addEvent({
374
+ recordError(errorCode, reason, details) {
375
+ const streamId = details?.streamId
376
+ const raw = details?.raw
377
+ // What the recorder held, before the cap below — the whole point of the
378
+ // key, since a reader compares it against the bytes it got to learn that
379
+ // the capture is partial and by how much. An explicit `null` means the
380
+ // caller does not know, and omits the key rather than claiming the bytes
381
+ // present are all there were.
382
+ const rawLength =
383
+ details?.rawLength === null ? undefined : (details?.rawLength ?? raw?.length)
384
+ // Claimed only when the bytes are actually going to be written, so a
385
+ // recording below 'full' does not spend a flow's one slot on an event
386
+ // that carried nothing.
387
+ const writeRaw =
388
+ raw != null && detailRank >= DETAIL_RANK.full && claimRawSlot(rawFlow(streamId))
389
+
390
+ const event: TraceErrorEvent = {
236
391
  type: 'error',
237
392
  seq: nextSeq(),
238
393
  timestamp: clock(),
239
394
  errorCode,
240
395
  reason,
241
- })
396
+ ...(streamId != null ? { streamId } : {}),
397
+ ...(details?.errorKind != null ? { errorKind: details.errorKind } : {}),
398
+ // A size, gated with the other sizes and one level below the bytes: an
399
+ // error may name a data stream, so at 'control' this length would be a
400
+ // fact about media volume in a trace whose declared level excludes it.
401
+ // Written whether or not the bytes are, which is most of its value.
402
+ ...(rawLength != null && detailRank >= DETAIL_RANK['headers+sizes'] ? { rawLength } : {}),
403
+ ...(writeRaw && raw != null
404
+ ? { raw: raw.length > MAX_ERROR_RAW_BYTES ? raw.slice(0, MAX_ERROR_RAW_BYTES) : raw }
405
+ : {}),
406
+ }
407
+ addEvent(event)
242
408
  },
243
409
 
244
410
  annotate(label, data) {
@@ -263,8 +429,19 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
263
429
  ...(options.source != null ? { source: options.source } : {}),
264
430
  ...(options.endpoint != null ? { endpoint: options.endpoint } : {}),
265
431
  ...(options.sessionId != null ? { sessionId: options.sessionId } : {}),
432
+ // Carried through so a recorder with something to say the format has
433
+ // no key for can say it, and so a header this package builds has the
434
+ // same shape as one it reads. `writeMoqtrace` writes the store after
435
+ // the keys above and drops any entry naming one of them.
436
+ ...(options.extra != null ? { extra: options.extra } : {}),
437
+ // Only when something was actually dropped: the key's presence is
438
+ // what tells a reader this trace is incomplete, so writing it with a
439
+ // zero would make every complete trace look sampled.
440
+ ...(dropped > 0
441
+ ? { sampling: { dropPolicy: 'head' as const, droppedTotal: dropped } }
442
+ : {}),
266
443
  }
267
- return { header, events: [...events] }
444
+ return { header, events: events.slice(head) }
268
445
  },
269
446
 
270
447
  get recording() {