@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 +184 -1
- package/SPEC.md +1083 -122
- package/dist/index.cjs +915 -97
- package/dist/index.d.cts +794 -9
- package/dist/index.d.ts +794 -9
- package/dist/index.js +914 -96
- package/package.json +6 -4
- package/src/binary.ts +1178 -97
- package/src/cbor-scan.ts +185 -0
- package/src/index.ts +25 -3
- package/src/json.ts +7 -0
- package/src/reader.ts +286 -0
- package/src/recorder.ts +199 -12
- package/src/types.ts +460 -3
package/src/recorder.ts
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
import type { SessionState } from '@moqtap/codec/session'
|
|
2
|
-
import type {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
38
|
-
|
|
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
|
|
@@ -58,19 +153,62 @@ const DETAIL_RANK: Record<DetailLevel, number> = {
|
|
|
58
153
|
export function createRecorder(options: RecorderOptions): TraceRecorder {
|
|
59
154
|
const detail = options.detail
|
|
60
155
|
const detailRank = DETAIL_RANK[detail]
|
|
156
|
+
// A reader must tolerate a detail level it does not know; a recorder cannot.
|
|
157
|
+
// Asked for a level this build does not implement, it would either capture
|
|
158
|
+
// less than the caller believes — writing a header that claims a detail the
|
|
159
|
+
// events do not have — or capture more, which for the payload-bearing
|
|
160
|
+
// levels is a privacy failure. Neither is worth a trace, so refuse instead.
|
|
161
|
+
if (detailRank === undefined) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Unknown detail level '${detail}': cannot record at a level this build does not implement`,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
61
166
|
const maxEvents = options.maxEvents ?? 100_000
|
|
62
167
|
const clock = options.clock ?? (() => Math.round(performance.now() * 1000))
|
|
63
168
|
const messageTypeId = options.messageTypeId ?? (() => 0)
|
|
64
169
|
|
|
65
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>()
|
|
66
197
|
let _recording = true
|
|
67
198
|
let _seq = 0
|
|
68
199
|
const startTime = Date.now()
|
|
69
200
|
|
|
70
201
|
function addEvent(event: TraceEvent): void {
|
|
71
202
|
if (!_recording) return
|
|
72
|
-
if (events.length >= maxEvents) {
|
|
73
|
-
|
|
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
|
+
}
|
|
74
212
|
}
|
|
75
213
|
events.push(event)
|
|
76
214
|
}
|
|
@@ -79,6 +217,18 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
|
|
|
79
217
|
return _seq++
|
|
80
218
|
}
|
|
81
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
|
+
|
|
82
232
|
function wrapSession<M extends { type: string }, T extends string>(
|
|
83
233
|
session: SessionState<M, T>,
|
|
84
234
|
): SessionState<M, T> {
|
|
@@ -112,7 +262,7 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
|
|
|
112
262
|
timestamp: clock(),
|
|
113
263
|
direction: 1, // rx
|
|
114
264
|
messageType: messageTypeId(message.type),
|
|
115
|
-
message
|
|
265
|
+
message,
|
|
116
266
|
})
|
|
117
267
|
|
|
118
268
|
if (result.ok && result.phase !== prevPhase) {
|
|
@@ -142,7 +292,7 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
|
|
|
142
292
|
timestamp: clock(),
|
|
143
293
|
direction: 0, // tx
|
|
144
294
|
messageType: messageTypeId(message.type),
|
|
145
|
-
message
|
|
295
|
+
message,
|
|
146
296
|
})
|
|
147
297
|
|
|
148
298
|
if (result.ok && result.phase !== prevPhase) {
|
|
@@ -221,14 +371,40 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
|
|
|
221
371
|
addEvent(event)
|
|
222
372
|
},
|
|
223
373
|
|
|
224
|
-
recordError(errorCode, reason) {
|
|
225
|
-
|
|
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 = {
|
|
226
391
|
type: 'error',
|
|
227
392
|
seq: nextSeq(),
|
|
228
393
|
timestamp: clock(),
|
|
229
394
|
errorCode,
|
|
230
395
|
reason,
|
|
231
|
-
|
|
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)
|
|
232
408
|
},
|
|
233
409
|
|
|
234
410
|
annotate(label, data) {
|
|
@@ -253,8 +429,19 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
|
|
|
253
429
|
...(options.source != null ? { source: options.source } : {}),
|
|
254
430
|
...(options.endpoint != null ? { endpoint: options.endpoint } : {}),
|
|
255
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
|
+
: {}),
|
|
256
443
|
}
|
|
257
|
-
return { header, events:
|
|
444
|
+
return { header, events: events.slice(head) }
|
|
258
445
|
},
|
|
259
446
|
|
|
260
447
|
get recording() {
|