@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/README.md +93 -5
- package/SPEC.md +707 -13
- package/dist/index.cjs +506 -95
- package/dist/index.d.cts +545 -6
- package/dist/index.d.ts +545 -6
- package/dist/index.js +504 -93
- package/package.json +5 -3
- package/src/binary.ts +776 -105
- package/src/index.ts +9 -4
- package/src/json.ts +7 -0
- package/src/reader.ts +286 -0
- package/src/recorder.ts +189 -12
- package/src/types.ts +263 -2
package/src/binary.ts
CHANGED
|
@@ -2,13 +2,8 @@ import { Encoder } from 'cbor-x'
|
|
|
2
2
|
import { cborItemLength, MalformedCborError } from './cbor-scan.js'
|
|
3
3
|
import type {
|
|
4
4
|
DerivationKind,
|
|
5
|
-
DetailLevel,
|
|
6
|
-
DropPolicy,
|
|
7
|
-
PeerRole,
|
|
8
|
-
Perspective,
|
|
9
5
|
SamplingInfo,
|
|
10
6
|
SegmentInfo,
|
|
11
|
-
Side,
|
|
12
7
|
SubscriptionRef,
|
|
13
8
|
Trace,
|
|
14
9
|
TraceEvent,
|
|
@@ -30,11 +25,32 @@ import { TRACE_ID_LENGTH } from './types.js'
|
|
|
30
25
|
// every payload-bearing field this package wrote: raw wire bytes, object
|
|
31
26
|
// payloads, track names, trace ids. Readers still accept the tagged form,
|
|
32
27
|
// because files carrying it already exist.
|
|
28
|
+
//
|
|
29
|
+
// `mapsAsObjects` has one cost this package cannot pay off, and it belongs
|
|
30
|
+
// here, where the decoder is chosen, rather than where the damage shows.
|
|
31
|
+
// cbor-x runs every decoded map key through an internal `safeKey`, which
|
|
32
|
+
// rewrites the text key `"__proto__"` to `"__proto_"` and stringifies a
|
|
33
|
+
// non-text key. Both are silent, both happen before any code in this file
|
|
34
|
+
// runs, and either can collide with a real key of the rewritten name and lose
|
|
35
|
+
// a value: a map carrying both `"__proto__"` and `"__proto_"` decodes to one
|
|
36
|
+
// entry, and so does one carrying both `1` and `"1"`. There is no option for
|
|
37
|
+
// it — in cbor-x 1.6.4 `safeKey` is unconditional on every `mapsAsObjects:
|
|
38
|
+
// true` path, and the one decoder that skips it is `mapsAsObjects: false`,
|
|
39
|
+
// which hands back a `Map` for every map in the file and is a different reader
|
|
40
|
+
// from this one. So a store here faithfully re-emits the renamed key, and this
|
|
41
|
+
// reader writes `"__proto_"` where the file said `"__proto__"` — including
|
|
42
|
+
// inside `"custom"`, which it otherwise hands back key for key, and inside any
|
|
43
|
+
// preserved value at any depth. SPEC.md, "Shapes a CBOR library may normalise
|
|
44
|
+
// before you see them", part 3: the normalisation happens below this
|
|
45
|
+
// implementation, so the reader is not non-conformant and nothing may depend
|
|
46
|
+
// on the outcome — but it is not invisible either, and the Rust reader keeps
|
|
47
|
+
// the two keys apart. The *write* half of the same key is ours, and is not
|
|
48
|
+
// excused: see {@link setKey}.
|
|
33
49
|
const codec = new Encoder({ useRecords: false, mapsAsObjects: true, tagUint8Array: false })
|
|
34
50
|
const encode = (value: unknown): Uint8Array => codec.encode(value)
|
|
35
|
-
const decode = (bytes: Uint8Array): unknown => codec.decode(bytes)
|
|
51
|
+
export const decode = (bytes: Uint8Array): unknown => codec.decode(bytes)
|
|
36
52
|
|
|
37
|
-
const MAGIC = new Uint8Array([0x4d, 0x4f, 0x51, 0x54, 0x52, 0x41, 0x43, 0x45]) // "MOQTRACE"
|
|
53
|
+
export const MAGIC = new Uint8Array([0x4d, 0x4f, 0x51, 0x54, 0x52, 0x41, 0x43, 0x45]) // "MOQTRACE"
|
|
38
54
|
|
|
39
55
|
/** Format version this package writes. */
|
|
40
56
|
export const FORMAT_VERSION = 2
|
|
@@ -48,7 +64,7 @@ export const FORMAT_VERSION = 2
|
|
|
48
64
|
*/
|
|
49
65
|
export const SUPPORTED_VERSIONS: readonly number[] = [1, FORMAT_VERSION]
|
|
50
66
|
|
|
51
|
-
const PREAMBLE_SIZE = 16 // 8 magic + 4 version + 4 header length
|
|
67
|
+
export const PREAMBLE_SIZE = 16 // 8 magic + 4 version + 4 header length
|
|
52
68
|
|
|
53
69
|
// Event type string ↔ integer mapping
|
|
54
70
|
const EVENT_TYPE_TO_INT: Record<Exclude<TraceEvent['type'], 'unknown'>, number> = {
|
|
@@ -82,6 +98,97 @@ const INT_TO_EVENT_TYPE: Record<number, Exclude<TraceEvent['type'], 'unknown'>>
|
|
|
82
98
|
/** Keys the common event fields own; everything else belongs to the variant. */
|
|
83
99
|
const COMMON_EVENT_KEYS = new Set(['n', 't', 'p', 'e'])
|
|
84
100
|
|
|
101
|
+
/**
|
|
102
|
+
* One decoded map — an event, the header, or one of the header's inner maps —
|
|
103
|
+
* and the keys the decode has taken from it.
|
|
104
|
+
*
|
|
105
|
+
* Everything left over is a key this version does not recognise, and goes to
|
|
106
|
+
* `extra` rather than being dropped. "Left over" is narrower than "not named by
|
|
107
|
+
* this document", and the difference is the whole point: a defined key whose
|
|
108
|
+
* value is of a type the reader cannot use was not taken either, and is
|
|
109
|
+
* preserved exactly as a key nobody had heard of would be — SPEC.md,
|
|
110
|
+
* "A defined key whose value has an unusable type is treated as unrecognised."
|
|
111
|
+
*
|
|
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.
|
|
118
|
+
*/
|
|
119
|
+
interface Decoding {
|
|
120
|
+
readonly obj: Record<string, unknown>
|
|
121
|
+
readonly used: Set<string>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Put a key carried by a file onto a plain object, as an own property.
|
|
126
|
+
*
|
|
127
|
+
* `target[key] = value` is not this, for one key in the whole of JavaScript.
|
|
128
|
+
* `__proto__` reaches the accessor `Object.prototype` defines, which sets the
|
|
129
|
+
* object's prototype instead of creating a property: `Object.hasOwn(target,
|
|
130
|
+
* '__proto__')` is then false, `Object.entries` does not list it, and the
|
|
131
|
+
* entry is gone. A caller whose store came from `JSON.parse('{"__proto__":
|
|
132
|
+
* "x"}')` — a real own property on the way in, since JSON has no such
|
|
133
|
+
* special case — therefore wrote a file with the key missing, silently, on
|
|
134
|
+
* the one code path whose entire purpose is preservation.
|
|
135
|
+
*
|
|
136
|
+
* `defineProperty` makes the own property for every key alike, and cbor-x
|
|
137
|
+
* encodes it: a store entry named `__proto__` reaches the file as the text key
|
|
138
|
+
* `"__proto__"`, which is what the Rust implementation writes for the same
|
|
139
|
+
* trace. Used for every key that comes from a file or from a store — never for
|
|
140
|
+
* a field name this format defines, which is a literal in the source and can
|
|
141
|
+
* be neither `__proto__` nor anything else surprising.
|
|
142
|
+
*
|
|
143
|
+
* This is only the write half. Reading `"__proto__"` back out of that file
|
|
144
|
+
* hands us `"__proto_"`, below this code and past helping — see the note on
|
|
145
|
+
* the codec above.
|
|
146
|
+
*/
|
|
147
|
+
function setKey(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
148
|
+
Object.defineProperty(target, key, {
|
|
149
|
+
value,
|
|
150
|
+
writable: true,
|
|
151
|
+
enumerable: true,
|
|
152
|
+
configurable: true,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Every key on a decoded map that the decode did not take. */
|
|
157
|
+
function unrecognisedKeys(src: Decoding): Record<string, unknown> | undefined {
|
|
158
|
+
let extra: Record<string, unknown> | undefined
|
|
159
|
+
for (const [key, value] of Object.entries(src.obj)) {
|
|
160
|
+
if (src.used.has(key)) continue
|
|
161
|
+
extra ??= {}
|
|
162
|
+
setKey(extra, key, value)
|
|
163
|
+
}
|
|
164
|
+
return extra
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Write a map's unrecognised-key store back into it, after its own keys.
|
|
169
|
+
*
|
|
170
|
+
* Last, so the map's own keys keep the positions a reader expects and the file
|
|
171
|
+
* stays diffable against one written without a store. An entry naming a key the
|
|
172
|
+
* map already wrote is dropped: a CBOR map carrying one key twice is a map no
|
|
173
|
+
* two readers need agree on, and the field is what a reader produced.
|
|
174
|
+
*
|
|
175
|
+
* The test is what the map *wrote*, not which keys the format defines. A
|
|
176
|
+
* defined key whose value the reader could not use is held in the store and no
|
|
177
|
+
* field wrote it, so on a defined-key test it would be dropped here — silently
|
|
178
|
+
* undoing the preservation the read side had just performed.
|
|
179
|
+
*/
|
|
180
|
+
function withStore(
|
|
181
|
+
map: Record<string, unknown>,
|
|
182
|
+
store: Record<string, unknown> | undefined,
|
|
183
|
+
): Record<string, unknown> {
|
|
184
|
+
if (store == null) return map
|
|
185
|
+
for (const [key, value] of Object.entries(store)) {
|
|
186
|
+
if (Object.hasOwn(map, key)) continue
|
|
187
|
+
setKey(map, key, value)
|
|
188
|
+
}
|
|
189
|
+
return map
|
|
190
|
+
}
|
|
191
|
+
|
|
85
192
|
/** The stream ended part-way through an item — the file was truncated. */
|
|
86
193
|
export class TruncatedTraceError extends Error {
|
|
87
194
|
/** Byte offset at which the incomplete item begins. */
|
|
@@ -106,23 +213,123 @@ export class TruncatedTraceError extends Error {
|
|
|
106
213
|
}
|
|
107
214
|
}
|
|
108
215
|
|
|
216
|
+
/**
|
|
217
|
+
* A header carried no usable value for a key there is no header without.
|
|
218
|
+
*
|
|
219
|
+
* Five keys reach here: the four the format requires — `"protocol"`,
|
|
220
|
+
* `"perspective"`, `"detail"`, `"startTime"` — and `"segment.sequence"`, the
|
|
221
|
+
* sole ordering key of a segmented stream. Every other key in the header is
|
|
222
|
+
* optional, and an optional key's value never fails a file: an unusable one
|
|
223
|
+
* goes to the store its map keeps and the read continues.
|
|
224
|
+
*
|
|
225
|
+
* The alternative is worse than the error. A reader that fills a missing
|
|
226
|
+
* required key with `undefined`, an empty string or `0` returns a header the
|
|
227
|
+
* file never carried, and nothing downstream can tell it from a real one — a
|
|
228
|
+
* fabricated trace reported as evidence. `"segment.sequence"` is the sharpest
|
|
229
|
+
* case: the default `0` turns a segment whose place in the stream is unknown
|
|
230
|
+
* into one that claims to be first.
|
|
231
|
+
*
|
|
232
|
+
* Malformed here means malformed for *that segment*. {@link ReadOptions.recover}
|
|
233
|
+
* skips it and carries on at the next one, on the same terms as a malformed
|
|
234
|
+
* event.
|
|
235
|
+
*
|
|
236
|
+
* The writer throws the same error for the same key, before it emits anything.
|
|
237
|
+
* `"startTime"` and `"segment.sequence"` are `number` fields and TypeScript
|
|
238
|
+
* admits `-5` and `1.5` into both, so a header built by hand could be written
|
|
239
|
+
* to a file this package's own reader then refused — a writer emitting files
|
|
240
|
+
* only some other tool can open, out of a caller mistake nothing reported at
|
|
241
|
+
* the point it was made. One error type for both directions because it is one
|
|
242
|
+
* fault about one key, and a read-modify-write catches it once.
|
|
243
|
+
*/
|
|
244
|
+
export class MalformedHeaderError extends Error {
|
|
245
|
+
/** The key at fault, dotted for one inside `"segment"`. */
|
|
246
|
+
readonly key: string
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param key the key at fault
|
|
250
|
+
* @param fault what was wrong with it, as a predicate on the key
|
|
251
|
+
*/
|
|
252
|
+
constructor(key: string, fault: string) {
|
|
253
|
+
// Absent and present-but-unusable are the same outcome — there is no
|
|
254
|
+
// header to construct either way — but they are different faults, and one
|
|
255
|
+
// message for both told whoever has to fix the file the wrong thing.
|
|
256
|
+
// "must be a text string" on a header that carries no `"protocol"` at all
|
|
257
|
+
// sends them looking at a value that is not there, and it is the message a
|
|
258
|
+
// person debugging a broken capture reads.
|
|
259
|
+
super(`Malformed header: ${JSON.stringify(key)} ${fault}`)
|
|
260
|
+
this.name = 'MalformedHeaderError'
|
|
261
|
+
this.key = key
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
109
265
|
// --- Header serialization ---
|
|
110
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Write a required header integer, or refuse to write the file.
|
|
269
|
+
*
|
|
270
|
+
* The writer's half of {@link requireHeaderUint}, and deliberately the same
|
|
271
|
+
* test: `"startTime"` and `"segment.sequence"` are the two header keys read as
|
|
272
|
+
* unsigned integers *and* required, so a negative or fractional one produces a
|
|
273
|
+
* file whose own reader throws `MalformedHeaderError` on it. This package
|
|
274
|
+
* emitting a trace it cannot open is a worse failure than a rejected call —
|
|
275
|
+
* the file is written, the process exits 0, and the fault surfaces wherever
|
|
276
|
+
* someone later tries to read it, if anyone does. The Rust implementation
|
|
277
|
+
* cannot reach the state at all: its field is a `u64`.
|
|
278
|
+
*
|
|
279
|
+
* It refuses only these two, and only the field. Every other integer in the
|
|
280
|
+
* header is optional, and a negative `"endTime"` written into one is read back
|
|
281
|
+
* into that map's store rather than failing the read — the value survives and
|
|
282
|
+
* so does the file, which is the contract the reader already states for an
|
|
283
|
+
* optional key. A store entry is never checked here either, for the reason
|
|
284
|
+
* SPEC.md gives for ranking a reader's obligations above a writer's: a value
|
|
285
|
+
* routed to a store is one the reader could not use and must hand back
|
|
286
|
+
* unchanged, and a writer that validated it would delete exactly what the
|
|
287
|
+
* store exists to carry. So a file that legitimately carries such a value can
|
|
288
|
+
* still be read, edited and written back — nothing that survives a read is
|
|
289
|
+
* refused on the way out.
|
|
290
|
+
*/
|
|
291
|
+
function requiredUint(value: number, reportedAs: string): number | bigint {
|
|
292
|
+
if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
|
|
293
|
+
throw new MalformedHeaderError(reportedAs, 'must be an unsigned integer')
|
|
294
|
+
}
|
|
295
|
+
return int(value)
|
|
296
|
+
}
|
|
297
|
+
|
|
111
298
|
function segmentToCbor(segment: SegmentInfo): Record<string, unknown> {
|
|
112
|
-
const map: Record<string, unknown> = {
|
|
299
|
+
const map: Record<string, unknown> = {
|
|
300
|
+
sequence: requiredUint(segment.sequence, 'segment.sequence'),
|
|
301
|
+
}
|
|
113
302
|
if (segment.durationMs != null) map.durationMs = int(segment.durationMs)
|
|
114
303
|
if (segment.streamId != null) map.streamId = segment.streamId
|
|
115
304
|
if (segment.continues != null) map.continues = segment.continues
|
|
116
|
-
return map
|
|
305
|
+
return withStore(map, segment.extra)
|
|
117
306
|
}
|
|
118
307
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Read the `"segment"` map, or answer `undefined` for a value that is not one.
|
|
310
|
+
*
|
|
311
|
+
* `undefined` leaves the key untaken, so a `"segment"` that is not a map goes
|
|
312
|
+
* to the *header's* store and the trace reads as non-segmented — which is what
|
|
313
|
+
* the format asks for. Failing the file instead would turn one unreadable
|
|
314
|
+
* metadata value into the loss of every event behind it.
|
|
315
|
+
*
|
|
316
|
+
* `"sequence"` is the exception, and the only key inside here that can fail a
|
|
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.
|
|
320
|
+
*/
|
|
321
|
+
function cborToSegment(value: unknown): SegmentInfo | undefined {
|
|
322
|
+
const obj = asMap(value)
|
|
323
|
+
if (obj == null) return undefined
|
|
324
|
+
const src: Decoding = { obj, used: new Set() }
|
|
325
|
+
const segment: SegmentInfo = {
|
|
326
|
+
sequence: requireHeaderUint(src, 'sequence', 'segment.sequence'),
|
|
327
|
+
...optional(src, 'durationMs', 'durationMs', asUintNumber),
|
|
328
|
+
...optional(src, 'streamId', 'streamId', asText),
|
|
329
|
+
...optional(src, 'continues', 'continues', asBoolean),
|
|
125
330
|
}
|
|
331
|
+
const extra = unrecognisedKeys(src)
|
|
332
|
+
return extra == null ? segment : { ...segment, extra }
|
|
126
333
|
}
|
|
127
334
|
|
|
128
335
|
function samplingToCbor(sampling: SamplingInfo): Record<string, unknown> {
|
|
@@ -135,20 +342,31 @@ function samplingToCbor(sampling: SamplingInfo): Record<string, unknown> {
|
|
|
135
342
|
if (sampling.rule != null) map.rule = sampling.rule
|
|
136
343
|
if (sampling.ruleLang != null) map.ruleLang = sampling.ruleLang
|
|
137
344
|
if (sampling.appliesTo != null) map.appliesTo = sampling.appliesTo.map(int)
|
|
138
|
-
return map
|
|
345
|
+
return withStore(map, sampling.extra)
|
|
139
346
|
}
|
|
140
347
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
348
|
+
/** The same for `"sampling"`; every key inside it is optional. */
|
|
349
|
+
function cborToSampling(value: unknown): SamplingInfo | undefined {
|
|
350
|
+
const obj = asMap(value)
|
|
351
|
+
if (obj == null) return undefined
|
|
352
|
+
const src: Decoding = { obj, used: new Set() }
|
|
353
|
+
const sampling: SamplingInfo = {
|
|
354
|
+
// A rate is a fraction, so this is the one header number that is not an
|
|
355
|
+
// integer — and the one whose range the format states, so it is the one
|
|
356
|
+
// key here checked for more than its type. It is tested rather than
|
|
357
|
+
// converted: `Number('half')` is `NaN`, a rate no file carried and none
|
|
358
|
+
// can be written back from.
|
|
359
|
+
...optional(src, 'effectiveRate', 'effectiveRate', asRate),
|
|
360
|
+
...optional(src, 'maxEventsPerSec', 'maxEventsPerSec', asUintNumber),
|
|
361
|
+
...optional(src, 'dropPolicy', 'dropPolicy', asText),
|
|
362
|
+
...optional(src, 'droppedTotal', 'droppedTotal', asUintNumber),
|
|
363
|
+
...optional(src, 'droppedSegment', 'droppedSegment', asUintNumber),
|
|
364
|
+
...optional(src, 'rule', 'rule', asText),
|
|
365
|
+
...optional(src, 'ruleLang', 'ruleLang', asText),
|
|
366
|
+
...optional(src, 'appliesTo', 'appliesTo', asUintNumbers),
|
|
151
367
|
}
|
|
368
|
+
const extra = unrecognisedKeys(src)
|
|
369
|
+
return extra == null ? sampling : { ...sampling, extra }
|
|
152
370
|
}
|
|
153
371
|
|
|
154
372
|
function headerToCbor(header: TraceHeader): Record<string, unknown> {
|
|
@@ -156,7 +374,7 @@ function headerToCbor(header: TraceHeader): Record<string, unknown> {
|
|
|
156
374
|
protocol: header.protocol,
|
|
157
375
|
perspective: header.perspective,
|
|
158
376
|
detail: header.detail,
|
|
159
|
-
startTime:
|
|
377
|
+
startTime: requiredUint(header.startTime, 'startTime'),
|
|
160
378
|
}
|
|
161
379
|
if (header.endTime != null) map.endTime = int(header.endTime)
|
|
162
380
|
if (header.transport != null) map.transport = header.transport
|
|
@@ -166,28 +384,46 @@ function headerToCbor(header: TraceHeader): Record<string, unknown> {
|
|
|
166
384
|
if (header.segment != null) map.segment = segmentToCbor(header.segment)
|
|
167
385
|
if (header.sampling != null) map.sampling = samplingToCbor(header.sampling)
|
|
168
386
|
if (header.custom != null) map.custom = header.custom
|
|
169
|
-
return map
|
|
387
|
+
return withStore(map, header.extra)
|
|
170
388
|
}
|
|
171
389
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
390
|
+
/**
|
|
391
|
+
* Decode the header, keeping every key the decode could not use.
|
|
392
|
+
*
|
|
393
|
+
* Three maps here have keys the format names — this one, `"segment"` and
|
|
394
|
+
* `"sampling"` — and each keeps its own store, because a private key on
|
|
395
|
+
* `"segment"` and a key of the same name at the top level are different keys.
|
|
396
|
+
* `"custom"` keeps none: every key in it belongs to whoever wrote the trace.
|
|
397
|
+
*
|
|
398
|
+
* The four required keys throw rather than default. Everything else that is
|
|
399
|
+
* unusable stays in the store of the map it appeared in, and the read
|
|
400
|
+
* continues: an optional key's type never fails a file.
|
|
401
|
+
*
|
|
402
|
+
* @throws {MalformedHeaderError} when a required key is absent or unusable.
|
|
403
|
+
*/
|
|
404
|
+
export function cborToHeader(obj: Record<string, unknown>): TraceHeader {
|
|
405
|
+
const src: Decoding = { obj, used: new Set() }
|
|
406
|
+
const header: TraceHeader = {
|
|
407
|
+
protocol: requireHeaderText(src, 'protocol'),
|
|
408
|
+
perspective: requireHeaderText(src, 'perspective'),
|
|
409
|
+
detail: requireHeaderText(src, 'detail'),
|
|
410
|
+
startTime: requireHeaderUint(src, 'startTime'),
|
|
411
|
+
...optional(src, 'endTime', 'endTime', asUintNumber),
|
|
412
|
+
...optional(src, 'transport', 'transport', asText),
|
|
413
|
+
...optional(src, 'source', 'source', asText),
|
|
414
|
+
...optional(src, 'endpoint', 'endpoint', asText),
|
|
415
|
+
...optional(src, 'sessionId', 'sessionId', asText),
|
|
416
|
+
...optional(src, 'segment', 'segment', cborToSegment),
|
|
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
|
|
422
|
+
// field's type true.
|
|
423
|
+
...optional(src, 'custom', 'custom', asMap),
|
|
190
424
|
}
|
|
425
|
+
const extra = unrecognisedKeys(src)
|
|
426
|
+
return extra == null ? header : { ...header, extra }
|
|
191
427
|
}
|
|
192
428
|
|
|
193
429
|
// --- Event serialization ---
|
|
@@ -200,7 +436,16 @@ function cborToSubscriptionRef(value: unknown): SubscriptionRef {
|
|
|
200
436
|
if (!Array.isArray(value) || value.length !== 2) {
|
|
201
437
|
throw new Error('Subscription reference must be [peer, requestId]')
|
|
202
438
|
}
|
|
203
|
-
|
|
439
|
+
// Both halves are required, so an unusable one is a malformed event rather
|
|
440
|
+
// than something to route elsewhere — there is no subscription to name
|
|
441
|
+
// without them. Checked rather than converted, for the reason `asUint` gives:
|
|
442
|
+
// `BigInt(true)` is `1n`, a request id no file ever carried.
|
|
443
|
+
const peer = asText(value[0])
|
|
444
|
+
const requestId = asUint(value[1])
|
|
445
|
+
if (peer == null || requestId == null) {
|
|
446
|
+
throw new Error('Subscription reference must be a [text, unsigned integer] pair')
|
|
447
|
+
}
|
|
448
|
+
return { peer, requestId }
|
|
204
449
|
}
|
|
205
450
|
|
|
206
451
|
/**
|
|
@@ -231,12 +476,14 @@ function int(value: number): number | bigint {
|
|
|
231
476
|
}
|
|
232
477
|
|
|
233
478
|
/**
|
|
234
|
-
*
|
|
479
|
+
* Write a trace id, refusing any length but the one the format fixes.
|
|
235
480
|
*
|
|
236
|
-
* A wrong-length value is
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
481
|
+
* A wrong-length value is not something to pad or truncate: the identifier
|
|
482
|
+
* exists so two implementations independently derive byte-identical values for
|
|
483
|
+
* one subscription chain, and reshaping it breaks exactly the property it is
|
|
484
|
+
* there for. The reader's counterpart is {@link asTraceId}, which keeps a
|
|
485
|
+
* wrong-length value instead of refusing it — `"traceId"` is optional, and an
|
|
486
|
+
* optional key's type never fails a file.
|
|
240
487
|
*/
|
|
241
488
|
function checkTraceId(value: unknown): Uint8Array {
|
|
242
489
|
const bytes = toBytes(value)
|
|
@@ -258,7 +505,15 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
|
|
|
258
505
|
case 'control': {
|
|
259
506
|
base.d = event.direction
|
|
260
507
|
base.mt = int(event.messageType)
|
|
261
|
-
|
|
508
|
+
// Always written, even when nothing was decoded: Event 0 is one of the
|
|
509
|
+
// types sampling must not drop, so a reader strict enough to treat a
|
|
510
|
+
// missing "msg" as a malformed event would discard exactly the events
|
|
511
|
+
// the format promises to keep. `undefined` is the one value not passed
|
|
512
|
+
// through — it encodes as CBOR `undefined`, which is not a map, and it
|
|
513
|
+
// means a caller left the field off rather than that a file carried it.
|
|
514
|
+
// Every other value is written as it stands, so a text `"msg"` read out
|
|
515
|
+
// of a pre-spec recording goes back to disk as the same text.
|
|
516
|
+
base.msg = event.message === undefined ? {} : event.message
|
|
262
517
|
if (event.streamId != null) base.sid = event.streamId
|
|
263
518
|
if (event.raw != null) base.raw = event.raw
|
|
264
519
|
break
|
|
@@ -267,6 +522,13 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
|
|
|
267
522
|
base.sid = event.streamId
|
|
268
523
|
base.d = event.direction
|
|
269
524
|
base.st = event.streamType
|
|
525
|
+
// Written only when set, and each is scoped to one stream type: a
|
|
526
|
+
// subgroup id on a fetch stream, or a group id on a subgroup stream,
|
|
527
|
+
// names something the header it came from never carried.
|
|
528
|
+
if (event.trackAlias != null) base.ta = event.trackAlias
|
|
529
|
+
if (event.subgroupId != null) base.sg = event.subgroupId
|
|
530
|
+
if (event.fetchRequestId != null) base.fri = event.fetchRequestId
|
|
531
|
+
if (event.groupId != null) base.g = event.groupId
|
|
270
532
|
break
|
|
271
533
|
}
|
|
272
534
|
case 'stream-closed': {
|
|
@@ -298,6 +560,18 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
|
|
|
298
560
|
case 'error': {
|
|
299
561
|
base.ec = int(event.errorCode)
|
|
300
562
|
base.reason = event.reason
|
|
563
|
+
if (event.streamId != null) base.sid = event.streamId
|
|
564
|
+
if (event.errorKind != null) base.ek = event.errorKind
|
|
565
|
+
if (event.rawLength != null) base.rawlen = int(event.rawLength)
|
|
566
|
+
// Written at whatever length it arrived. The format caps `"raw"` at 4096
|
|
567
|
+
// bytes and this is deliberately not where that happens: a serializer
|
|
568
|
+
// cannot tell an event a recorder just built from observed bytes from one
|
|
569
|
+
// that arrived by being read, so a cap here would either truncate
|
|
570
|
+
// evidence on a rewrite or refuse a file the reader was required to
|
|
571
|
+
// accept — whichever it did, it would do to the wrong events. The cap
|
|
572
|
+
// belongs where the event is constructed from bytes, which in this
|
|
573
|
+
// package is `recordError` in `recorder.ts`.
|
|
574
|
+
if (event.raw != null) base.raw = event.raw
|
|
301
575
|
break
|
|
302
576
|
}
|
|
303
577
|
case 'annotation': {
|
|
@@ -333,16 +607,288 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
|
|
|
333
607
|
case 'unknown': {
|
|
334
608
|
base.e = event.eventType
|
|
335
609
|
for (const [key, value] of Object.entries(event.fields)) {
|
|
336
|
-
base
|
|
610
|
+
setKey(base, key, value)
|
|
337
611
|
}
|
|
338
612
|
break
|
|
339
613
|
}
|
|
340
614
|
}
|
|
341
615
|
|
|
616
|
+
// Last, so the event's own keys keep the positions a reader expects and the
|
|
617
|
+
// file stays diffable against one written without them. An entry naming a key
|
|
618
|
+
// this event already wrote from a field is dropped: a CBOR map with a
|
|
619
|
+
// duplicate key is malformed, and the field is what a reader produced.
|
|
620
|
+
//
|
|
621
|
+
// The test is what the event *wrote*, not what its type owns. A defined key
|
|
622
|
+
// whose value the reader could not use is held in `extra` and no field wrote
|
|
623
|
+
// it, so on the owned-key test it would be dropped here — silently undoing
|
|
624
|
+
// the preservation the read side had just performed.
|
|
625
|
+
if (event.type !== 'unknown' && event.extra != null) {
|
|
626
|
+
for (const [key, value] of Object.entries(event.extra)) {
|
|
627
|
+
if (COMMON_EVENT_KEYS.has(key) || Object.hasOwn(base, key)) continue
|
|
628
|
+
setKey(base, key, value)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
342
632
|
return base
|
|
343
633
|
}
|
|
344
634
|
|
|
345
|
-
|
|
635
|
+
// --- Reading one key ---
|
|
636
|
+
//
|
|
637
|
+
// Each of these answers `undefined` for a value it cannot use, and the caller
|
|
638
|
+
// then leaves the key untaken so that `unrecognisedKeys` preserves it.
|
|
639
|
+
//
|
|
640
|
+
// They are type *tests*, not conversion attempts. The distinction is not
|
|
641
|
+
// pedantic even where a conversion throws on the obvious cases: `BigInt`
|
|
642
|
+
// answers `1n` for `true` and `4n` for the text `"4"`, so a `try`/`catch`
|
|
643
|
+
// around it would still put an identifier on the event that the file never
|
|
644
|
+
// carried — and the Rust reader, which coerces neither, would then read the
|
|
645
|
+
// same file differently.
|
|
646
|
+
|
|
647
|
+
/** A CBOR unsigned integer, as a `bigint`. */
|
|
648
|
+
function asUint(value: unknown): bigint | undefined {
|
|
649
|
+
if (typeof value === 'bigint') return value >= 0n ? value : undefined
|
|
650
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) return undefined
|
|
651
|
+
return BigInt(value)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* The same, where the field it feeds is a JS `number`.
|
|
656
|
+
*
|
|
657
|
+
* A value past `Number.MAX_SAFE_INTEGER` is refused rather than rounded: the
|
|
658
|
+
* rounded number is not what the file said, and refusing keeps the digits
|
|
659
|
+
* intact in `extra`.
|
|
660
|
+
*/
|
|
661
|
+
function asUintNumber(value: unknown): number | undefined {
|
|
662
|
+
const int = asUint(value)
|
|
663
|
+
return int == null || int > BigInt(Number.MAX_SAFE_INTEGER) ? undefined : Number(int)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** A CBOR text string. */
|
|
667
|
+
function asText(value: unknown): string | undefined {
|
|
668
|
+
return typeof value === 'string' ? value : undefined
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** A CBOR boolean. */
|
|
672
|
+
function asBoolean(value: unknown): boolean | undefined {
|
|
673
|
+
return typeof value === 'boolean' ? value : undefined
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* A CBOR number, for the one header key defined as a float.
|
|
678
|
+
*
|
|
679
|
+
* Non-finite values are refused along with everything that is not a number:
|
|
680
|
+
* CBOR can carry a NaN or an infinity, but neither is a fraction of the events
|
|
681
|
+
* retained, and a field holding one states a rate the file never gave.
|
|
682
|
+
*
|
|
683
|
+
* No test covers the finite check, and none can: {@link asRate} is the only
|
|
684
|
+
* caller, and its `> 0 && <= 1` already rejects NaN and both infinities, so
|
|
685
|
+
* deleting `Number.isFinite` here changes no observable behaviour today. It
|
|
686
|
+
* stays because the redundancy belongs to the caller and not to this function.
|
|
687
|
+
* `asRate` bounds its key by meaning, which the format does for that key and
|
|
688
|
+
* for no other; the next float key added here would arrive through this type
|
|
689
|
+
* test with no range behind it, and would take a NaN into its field. Rather
|
|
690
|
+
* than a test that cannot fail, this note.
|
|
691
|
+
*/
|
|
692
|
+
function asNumber(value: unknown): number | undefined {
|
|
693
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* A sampling rate: a number in `(0.0, 1.0]`, and no other number.
|
|
698
|
+
*
|
|
699
|
+
* The one value in the header the format bounds by meaning rather than by
|
|
700
|
+
* type, and the bound is checked here because consumers divide by it. A `0.0`
|
|
701
|
+
* handed to a caller reconstructing true event counts is a division by zero,
|
|
702
|
+
* and a `1.5` is a count larger than what was recorded — each a wrong answer
|
|
703
|
+
* produced silently, from a header the reader had already decided to trust.
|
|
704
|
+
*
|
|
705
|
+
* The interval is open at `0` and closed at `1`, so `1.0` — "no rate-based
|
|
706
|
+
* dropping", the commonest value there is — passes and `0.0` does not. A value
|
|
707
|
+
* outside it is unusable on an optional key, so it goes to the sampling map's
|
|
708
|
+
* store with its bytes intact rather than failing the file. NaN fails both
|
|
709
|
+
* comparisons, which is the wanted answer twice over: it is in no interval,
|
|
710
|
+
* and it is no fraction of the events retained.
|
|
711
|
+
*/
|
|
712
|
+
function asRate(value: unknown): number | undefined {
|
|
713
|
+
const rate = asNumber(value)
|
|
714
|
+
return rate != null && rate > 0 && rate <= 1 ? rate : undefined
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* A CBOR array of unsigned integers — the shape `"sampling.appliesTo"` takes.
|
|
719
|
+
*
|
|
720
|
+
* All or nothing, like {@link asByteStrings}. An array with one element that is
|
|
721
|
+
* not an event type ID is unusable *entire*: this key names the types a drop
|
|
722
|
+
* policy touched, and readers may treat every type absent from it as complete,
|
|
723
|
+
* so keeping `[3, 5]` out of `[3, "x", 5]` would report a sampled type as fully
|
|
724
|
+
* recorded — the opposite of what the file said, stated just as confidently.
|
|
725
|
+
*/
|
|
726
|
+
function asUintNumbers(value: unknown): number[] | undefined {
|
|
727
|
+
if (!Array.isArray(value)) return undefined
|
|
728
|
+
const ids: number[] = []
|
|
729
|
+
for (const item of value) {
|
|
730
|
+
const id = asUintNumber(item)
|
|
731
|
+
if (id == null) return undefined
|
|
732
|
+
ids.push(id)
|
|
733
|
+
}
|
|
734
|
+
return ids
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* A CBOR map, as the plain object this codec decodes one to.
|
|
739
|
+
*
|
|
740
|
+
* `typeof value === 'object'` is not the test: it also admits `null`, an array,
|
|
741
|
+
* a byte string and a tagged value, each of which would then be handed back as
|
|
742
|
+
* a field map and read `undefined` for every key. The prototype is what
|
|
743
|
+
* separates them — a decoded map is a plain object, and everything else this
|
|
744
|
+
* decoder produces for a non-map is an instance of something. A `Map` fails the
|
|
745
|
+
* same test, which is right for the same reason: a reader that cannot hold a
|
|
746
|
+
* map exactly must route it to a store rather than hand back a shape its own
|
|
747
|
+
* declared type denies.
|
|
748
|
+
*/
|
|
749
|
+
function asMap(value: unknown): Record<string, unknown> | undefined {
|
|
750
|
+
if (value === null || typeof value !== 'object') return undefined
|
|
751
|
+
const proto = Object.getPrototypeOf(value)
|
|
752
|
+
if (proto !== Object.prototype && proto !== null) return undefined
|
|
753
|
+
return value as Record<string, unknown>
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** A CBOR byte string, copied by {@link toBytes} so it owns its bytes. */
|
|
757
|
+
function asByteString(value: unknown): Uint8Array | undefined {
|
|
758
|
+
return value instanceof Uint8Array ? toBytes(value) : undefined
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** A CBOR array of byte strings — the shape a track namespace takes. */
|
|
762
|
+
function asByteStrings(value: unknown): Uint8Array[] | undefined {
|
|
763
|
+
if (!Array.isArray(value)) return undefined
|
|
764
|
+
const parts: Uint8Array[] = []
|
|
765
|
+
for (const item of value) {
|
|
766
|
+
const bytes = asByteString(item)
|
|
767
|
+
if (bytes == null) return undefined
|
|
768
|
+
parts.push(bytes)
|
|
769
|
+
}
|
|
770
|
+
return parts
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* A trace id: {@link TRACE_ID_LENGTH} bytes and no other length.
|
|
775
|
+
*
|
|
776
|
+
* The reader's half of {@link checkTraceId}. It keeps a wrong-length value
|
|
777
|
+
* rather than throwing, because `"traceId"` is optional and an optional key's
|
|
778
|
+
* type never fails a file; the value then survives in `extra` with its bytes
|
|
779
|
+
* intact, which a padded or truncated field would not.
|
|
780
|
+
*/
|
|
781
|
+
function asTraceId(value: unknown): Uint8Array | undefined {
|
|
782
|
+
const bytes = asByteString(value)
|
|
783
|
+
return bytes?.length === TRACE_ID_LENGTH ? bytes : undefined
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Take a key's value as it stands, whatever shape it has. */
|
|
787
|
+
function take(src: Decoding, key: string): unknown {
|
|
788
|
+
src.used.add(key)
|
|
789
|
+
return src.obj[key]
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Take a required integer key, or fail the event.
|
|
794
|
+
*
|
|
795
|
+
* The one case where an unusable value is not routed to `extra`: without the
|
|
796
|
+
* key there is no event to build, so the event is malformed exactly as it
|
|
797
|
+
* would be with the key absent. That is as far as it goes — a *file* is never
|
|
798
|
+
* failed over an optional key's type.
|
|
799
|
+
*/
|
|
800
|
+
function requireUint(src: Decoding, key: string): bigint {
|
|
801
|
+
const value = asUint(take(src, key))
|
|
802
|
+
if (value == null) {
|
|
803
|
+
throw new Error(`Event key ${JSON.stringify(key)} must be an unsigned integer`)
|
|
804
|
+
}
|
|
805
|
+
return value
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Take a required header text key, or fail the header.
|
|
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.
|
|
815
|
+
*/
|
|
816
|
+
function requireHeaderText(src: Decoding, key: string): string {
|
|
817
|
+
const present = Object.hasOwn(src.obj, key)
|
|
818
|
+
const value = asText(take(src, key))
|
|
819
|
+
if (value == null) {
|
|
820
|
+
throw new MalformedHeaderError(key, present ? 'must be a text string' : 'is missing')
|
|
821
|
+
}
|
|
822
|
+
return value
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Take a required header integer key, or fail the header.
|
|
827
|
+
*
|
|
828
|
+
* @param reportedAs the key's name in the error, dotted for one inside
|
|
829
|
+
* `"segment"`, where `"sequence"` alone would not say which map it was in.
|
|
830
|
+
*/
|
|
831
|
+
function requireHeaderUint(src: Decoding, key: string, reportedAs = key): number {
|
|
832
|
+
const present = Object.hasOwn(src.obj, key)
|
|
833
|
+
const value = asUintNumber(take(src, key))
|
|
834
|
+
if (value == null) {
|
|
835
|
+
throw new MalformedHeaderError(
|
|
836
|
+
reportedAs,
|
|
837
|
+
present ? 'must be an unsigned integer' : 'is missing',
|
|
838
|
+
)
|
|
839
|
+
}
|
|
840
|
+
return value
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Take an optional key, as the property to spread onto the event or header map
|
|
845
|
+
* being built — or nothing at all, when the key is absent or carries a value of
|
|
846
|
+
* a type this reader cannot use.
|
|
847
|
+
*
|
|
848
|
+
* Those two cases build the same result and differ in what they leave behind:
|
|
849
|
+
* an absent key leaves nothing, an unusable one stays untaken and is preserved
|
|
850
|
+
* verbatim as an unrecognised key. Neither throws. The events around this one
|
|
851
|
+
* decoded fine, and one key's type is no reason to lose them.
|
|
852
|
+
*
|
|
853
|
+
* `read` may itself throw, which is how `"segment.sequence"` fails a header
|
|
854
|
+
* from inside an otherwise optional `"segment"`.
|
|
855
|
+
*/
|
|
856
|
+
function optional<K extends string, V>(
|
|
857
|
+
src: Decoding,
|
|
858
|
+
key: string,
|
|
859
|
+
name: K,
|
|
860
|
+
read: (value: unknown) => V | undefined,
|
|
861
|
+
): { [P in K]?: V } {
|
|
862
|
+
const usable = read(src.obj[key])
|
|
863
|
+
if (usable === undefined) return {} as { [P in K]?: V }
|
|
864
|
+
src.used.add(key)
|
|
865
|
+
return { [name]: usable } as { [P in K]?: V }
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Decode one event, keeping every key the decode could not use.
|
|
870
|
+
*
|
|
871
|
+
* Those keys are kept on the event rather than dropped so that reading a trace
|
|
872
|
+
* and writing it back does not quietly strip what a newer writer put there.
|
|
873
|
+
* Two kinds land there, and the second is the one easily lost: a key this
|
|
874
|
+
* version has never heard of, and a key it knows whose value is of a type it
|
|
875
|
+
* cannot use — a text `"ta"`, say. Both are unrecognised as far as meaning
|
|
876
|
+
* goes, and both are written back unchanged.
|
|
877
|
+
*
|
|
878
|
+
* An `UnknownEvent` is returned untouched: its `fields` already hold every
|
|
879
|
+
* non-common key, and adding them to `extra` too would write each one twice.
|
|
880
|
+
*/
|
|
881
|
+
export function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
882
|
+
const src: Decoding = { obj, used: new Set(COMMON_EVENT_KEYS) }
|
|
883
|
+
const event = decodeEvent(src)
|
|
884
|
+
if (event.type === 'unknown') return event
|
|
885
|
+
|
|
886
|
+
const extra = unrecognisedKeys(src)
|
|
887
|
+
return extra == null ? event : { ...event, extra }
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function decodeEvent(src: Decoding): TraceEvent {
|
|
891
|
+
const obj = src.obj
|
|
346
892
|
const seq = Number(obj.n ?? 0)
|
|
347
893
|
const timestamp = Number(obj.t ?? 0)
|
|
348
894
|
const peerFields = obj.p != null ? { peer: obj.p as string } : {}
|
|
@@ -355,7 +901,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
355
901
|
// downstream of it, because a read-modify-write then rewrites the type.
|
|
356
902
|
const fields: Record<string, unknown> = {}
|
|
357
903
|
for (const [key, value] of Object.entries(obj)) {
|
|
358
|
-
if (!COMMON_EVENT_KEYS.has(key)) fields
|
|
904
|
+
if (!COMMON_EVENT_KEYS.has(key)) setKey(fields, key, value)
|
|
359
905
|
}
|
|
360
906
|
return {
|
|
361
907
|
type: 'unknown' as const,
|
|
@@ -374,11 +920,20 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
374
920
|
seq,
|
|
375
921
|
timestamp,
|
|
376
922
|
...peerFields,
|
|
377
|
-
direction:
|
|
378
|
-
messageType: Number(
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
923
|
+
direction: take(src, 'd') as 0 | 1,
|
|
924
|
+
messageType: Number(take(src, 'mt') ?? 0),
|
|
925
|
+
// Handed over exactly as it decoded. A `"msg"` that is not a map is
|
|
926
|
+
// still the only decode the recording has — the corpus captures carry
|
|
927
|
+
// a Rust `Debug` string here — and the format requires a reader to
|
|
928
|
+
// preserve it rather than reject the event or substitute a map. That
|
|
929
|
+
// includes `null`, which is a value a writer chose to put on the wire,
|
|
930
|
+
// not an absence; absence is the single case normalised, to `{}`, so
|
|
931
|
+
// that a caller reading keys off a conforming file never meets
|
|
932
|
+
// `undefined`. It is the one key with no unusable shape: the field's
|
|
933
|
+
// type is `unknown`, so every value is one it can hold.
|
|
934
|
+
message: Object.hasOwn(obj, 'msg') ? take(src, 'msg') : {},
|
|
935
|
+
...optional(src, 'sid', 'streamId', asUint),
|
|
936
|
+
...optional(src, 'raw', 'raw', asByteString),
|
|
382
937
|
}
|
|
383
938
|
|
|
384
939
|
case 'stream-opened':
|
|
@@ -387,9 +942,27 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
387
942
|
seq,
|
|
388
943
|
timestamp,
|
|
389
944
|
...peerFields,
|
|
390
|
-
streamId:
|
|
391
|
-
direction:
|
|
392
|
-
streamType:
|
|
945
|
+
streamId: requireUint(src, 'sid'),
|
|
946
|
+
direction: take(src, 'd') as 0 | 1,
|
|
947
|
+
streamType: take(src, 'st') as 0 | 1 | 2,
|
|
948
|
+
// `bigint`, not `number`, even though a writer that used a narrow CBOR
|
|
949
|
+
// integer hands these back as JS numbers. They are the same
|
|
950
|
+
// identifiers an object-header event carries, and that event converts;
|
|
951
|
+
// leaving one a number here makes `opened.groupId === header.groupId`
|
|
952
|
+
// read `42 === 42n` and answer `false` for values that match.
|
|
953
|
+
//
|
|
954
|
+
// Tested rather than converted, and optional rather than required.
|
|
955
|
+
// `BigInt(obj.ta)` threw on a text or fractional value and took the
|
|
956
|
+
// whole file down with it, since nothing catches one event's decode; it
|
|
957
|
+
// answered `1n` for `true` and `4n` for `"4"`, inventing an identifier
|
|
958
|
+
// the file never carried; and the owned-key list then kept the
|
|
959
|
+
// survivors out of `extra`, so adding these four keys *lost* values
|
|
960
|
+
// this reader had preserved before it knew them. A value of any other
|
|
961
|
+
// shape now stays where it was, on the event as an unrecognised key.
|
|
962
|
+
...optional(src, 'ta', 'trackAlias', asUint),
|
|
963
|
+
...optional(src, 'sg', 'subgroupId', asUint),
|
|
964
|
+
...optional(src, 'fri', 'fetchRequestId', asUint),
|
|
965
|
+
...optional(src, 'g', 'groupId', asUint),
|
|
393
966
|
}
|
|
394
967
|
|
|
395
968
|
case 'stream-closed':
|
|
@@ -398,8 +971,8 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
398
971
|
seq,
|
|
399
972
|
timestamp,
|
|
400
973
|
...peerFields,
|
|
401
|
-
streamId:
|
|
402
|
-
errorCode: Number(
|
|
974
|
+
streamId: requireUint(src, 'sid'),
|
|
975
|
+
errorCode: Number(take(src, 'ec') ?? 0),
|
|
403
976
|
}
|
|
404
977
|
|
|
405
978
|
case 'object-header':
|
|
@@ -408,11 +981,11 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
408
981
|
seq,
|
|
409
982
|
timestamp,
|
|
410
983
|
...peerFields,
|
|
411
|
-
streamId:
|
|
412
|
-
groupId:
|
|
413
|
-
objectId:
|
|
414
|
-
publisherPriority: Number(
|
|
415
|
-
objectStatus: Number(
|
|
984
|
+
streamId: requireUint(src, 'sid'),
|
|
985
|
+
groupId: requireUint(src, 'g'),
|
|
986
|
+
objectId: requireUint(src, 'o'),
|
|
987
|
+
publisherPriority: Number(take(src, 'pp') ?? 0),
|
|
988
|
+
objectStatus: Number(take(src, 'os') ?? 0),
|
|
416
989
|
}
|
|
417
990
|
|
|
418
991
|
case 'object-payload':
|
|
@@ -421,11 +994,11 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
421
994
|
seq,
|
|
422
995
|
timestamp,
|
|
423
996
|
...peerFields,
|
|
424
|
-
streamId:
|
|
425
|
-
groupId:
|
|
426
|
-
objectId:
|
|
427
|
-
size: Number(
|
|
428
|
-
...(
|
|
997
|
+
streamId: requireUint(src, 'sid'),
|
|
998
|
+
groupId: requireUint(src, 'g'),
|
|
999
|
+
objectId: requireUint(src, 'o'),
|
|
1000
|
+
size: Number(take(src, 'sz') ?? 0),
|
|
1001
|
+
...optional(src, 'pl', 'payload', asByteString),
|
|
429
1002
|
}
|
|
430
1003
|
|
|
431
1004
|
case 'state-change':
|
|
@@ -434,8 +1007,8 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
434
1007
|
seq,
|
|
435
1008
|
timestamp,
|
|
436
1009
|
...peerFields,
|
|
437
|
-
from:
|
|
438
|
-
to:
|
|
1010
|
+
from: take(src, 'from') as string,
|
|
1011
|
+
to: take(src, 'to') as string,
|
|
439
1012
|
}
|
|
440
1013
|
|
|
441
1014
|
case 'error':
|
|
@@ -444,8 +1017,24 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
444
1017
|
seq,
|
|
445
1018
|
timestamp,
|
|
446
1019
|
...peerFields,
|
|
447
|
-
errorCode: Number(
|
|
448
|
-
reason: (
|
|
1020
|
+
errorCode: Number(take(src, 'ec') ?? 0),
|
|
1021
|
+
reason: (take(src, 'reason') ?? '') as string,
|
|
1022
|
+
// Optional, so an unusable value costs the key and not the event: the
|
|
1023
|
+
// error code and reason are the point of the event and are fine.
|
|
1024
|
+
// `asUint` rather than a conversion, for the reason Event 1 gives —
|
|
1025
|
+
// `BigInt(true)` is `1n`, a stream this file never named.
|
|
1026
|
+
...optional(src, 'sid', 'streamId', asUint),
|
|
1027
|
+
// An error kind this version has never heard of is still text, and
|
|
1028
|
+
// still kept: the vocabulary is open and `ErrorKind` widens to any
|
|
1029
|
+
// string for exactly that. What `asText` refuses is a value that is
|
|
1030
|
+
// not text at all.
|
|
1031
|
+
...optional(src, 'ek', 'errorKind', asText),
|
|
1032
|
+
...optional(src, 'rawlen', 'rawLength', asUintNumber),
|
|
1033
|
+
// No length check. The 4096-byte cap binds a recorder; a reader that
|
|
1034
|
+
// refused a longer value — or shortened it — would reject a file it was
|
|
1035
|
+
// required to accept, or destroy the evidence the field exists to
|
|
1036
|
+
// carry.
|
|
1037
|
+
...optional(src, 'raw', 'raw', asByteString),
|
|
449
1038
|
}
|
|
450
1039
|
|
|
451
1040
|
case 'annotation':
|
|
@@ -454,8 +1043,8 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
454
1043
|
seq,
|
|
455
1044
|
timestamp,
|
|
456
1045
|
...peerFields,
|
|
457
|
-
label: (
|
|
458
|
-
data:
|
|
1046
|
+
label: (take(src, 'label') ?? '') as string,
|
|
1047
|
+
data: take(src, 'data'),
|
|
459
1048
|
}
|
|
460
1049
|
|
|
461
1050
|
case 'peer-connected':
|
|
@@ -464,10 +1053,13 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
464
1053
|
seq,
|
|
465
1054
|
timestamp,
|
|
466
1055
|
...peerFields,
|
|
467
|
-
...(
|
|
468
|
-
...(
|
|
469
|
-
|
|
470
|
-
|
|
1056
|
+
...optional(src, 'endpoint', 'endpoint', asText),
|
|
1057
|
+
...optional(src, 'transport', 'transport', asText),
|
|
1058
|
+
// A role or side this version has never heard of is still text, and
|
|
1059
|
+
// still kept: the unions they widen to exist for exactly that. What
|
|
1060
|
+
// `asText` refuses is a value that is not text at all.
|
|
1061
|
+
...optional(src, 'role', 'role', asText),
|
|
1062
|
+
...optional(src, 'side', 'side', asText),
|
|
471
1063
|
}
|
|
472
1064
|
|
|
473
1065
|
case 'peer-disconnected':
|
|
@@ -476,8 +1068,8 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
476
1068
|
seq,
|
|
477
1069
|
timestamp,
|
|
478
1070
|
...peerFields,
|
|
479
|
-
errorCode: Number(
|
|
480
|
-
...(
|
|
1071
|
+
errorCode: Number(take(src, 'ec') ?? 0),
|
|
1072
|
+
...optional(src, 'reason', 'reason', asText),
|
|
481
1073
|
}
|
|
482
1074
|
|
|
483
1075
|
case 'subscription-derivation':
|
|
@@ -486,16 +1078,16 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
486
1078
|
seq,
|
|
487
1079
|
timestamp,
|
|
488
1080
|
...peerFields,
|
|
489
|
-
upstream: cborToSubscriptionRef(
|
|
490
|
-
downstream: ((
|
|
491
|
-
kind: (
|
|
492
|
-
...(
|
|
493
|
-
...(
|
|
494
|
-
...(
|
|
495
|
-
...(
|
|
496
|
-
...(
|
|
497
|
-
...(
|
|
498
|
-
...(
|
|
1081
|
+
upstream: cborToSubscriptionRef(take(src, 'u')),
|
|
1082
|
+
downstream: ((take(src, 'd') ?? []) as unknown[]).map(cborToSubscriptionRef),
|
|
1083
|
+
kind: (take(src, 'kind') ?? '') as DerivationKind,
|
|
1084
|
+
...optional(src, 'traceId', 'traceId', asTraceId),
|
|
1085
|
+
...optional(src, 'ns', 'namespace', asByteStrings),
|
|
1086
|
+
...optional(src, 'tn', 'trackName', asByteString),
|
|
1087
|
+
...optional(src, 'tdr', 'tDownstreamReceived', asUintNumber),
|
|
1088
|
+
...optional(src, 'tus', 'tUpstreamSent', asUintNumber),
|
|
1089
|
+
...optional(src, 'tuo', 'tUpstreamOkReceived', asUintNumber),
|
|
1090
|
+
...optional(src, 'tdo', 'tDownstreamOkSent', asUintNumber),
|
|
499
1091
|
}
|
|
500
1092
|
}
|
|
501
1093
|
}
|
|
@@ -512,7 +1104,7 @@ function writePreamble(headerCbor: Uint8Array): Uint8Array {
|
|
|
512
1104
|
return buf
|
|
513
1105
|
}
|
|
514
1106
|
|
|
515
|
-
function startsWithMagic(bytes: Uint8Array, offset: number): boolean {
|
|
1107
|
+
export function startsWithMagic(bytes: Uint8Array, offset: number): boolean {
|
|
516
1108
|
if (offset + MAGIC.length > bytes.length) return false
|
|
517
1109
|
for (let i = 0; i < MAGIC.length; i++) {
|
|
518
1110
|
if (bytes[offset + i] !== MAGIC[i]) return false
|
|
@@ -520,6 +1112,21 @@ function startsWithMagic(bytes: Uint8Array, offset: number): boolean {
|
|
|
520
1112
|
return true
|
|
521
1113
|
}
|
|
522
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* Read and check a segment preamble, for a buffer that holds the whole file.
|
|
1117
|
+
*
|
|
1118
|
+
* **"Too short" means truncated here, and that is only true of a whole file.**
|
|
1119
|
+
* A caller reading a complete buffer has everything the file will ever have,
|
|
1120
|
+
* so a preamble that runs off the end is a cut recording. A caller reading a
|
|
1121
|
+
* *stream* is in the opposite situation: a short buffer is the ordinary state
|
|
1122
|
+
* between two chunks, and the missing bytes are on their way. An incremental
|
|
1123
|
+
* reader that reuses this function would report a truncation on every chunk
|
|
1124
|
+
* boundary.
|
|
1125
|
+
*
|
|
1126
|
+
* The distinction is invisible from the name, which is why it is written down:
|
|
1127
|
+
* `createMoqtraceReader` does its own length checks for this reason and shares
|
|
1128
|
+
* only the parts that carry meaning — the version check and `cborToHeader`.
|
|
1129
|
+
*/
|
|
523
1130
|
function validatePreamble(
|
|
524
1131
|
bytes: Uint8Array,
|
|
525
1132
|
offset = 0,
|
|
@@ -565,6 +1172,36 @@ export interface ReadOptions {
|
|
|
565
1172
|
* segment, so a damaged one still ends the read, just without an error.
|
|
566
1173
|
*/
|
|
567
1174
|
readonly recover?: boolean
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Called once for each region `recover` skipped.
|
|
1178
|
+
*
|
|
1179
|
+
* Without it a recovered read cannot be told from a clean one: the return
|
|
1180
|
+
* type carries no error channel, so a trace that lost a segment and a trace
|
|
1181
|
+
* that never had one are the same value.
|
|
1182
|
+
*/
|
|
1183
|
+
readonly onRecovered?: (region: RecoveredRegion) => void
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** A region `recover` could not read. */
|
|
1187
|
+
export interface RecoveredRegion {
|
|
1188
|
+
/** Byte offset the unreadable region began at. */
|
|
1189
|
+
readonly offset: number
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* What was lost: `header` drops the whole segment, `event` cuts the segment
|
|
1193
|
+
* in progress short, `truncated` means the item ran past end of file.
|
|
1194
|
+
*/
|
|
1195
|
+
readonly kind: 'header' | 'event' | 'truncated'
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Offset the read resumed at, or `undefined` when no further segment
|
|
1199
|
+
* followed — everything from `offset` on was then discarded.
|
|
1200
|
+
*/
|
|
1201
|
+
readonly resumedAt: number | undefined
|
|
1202
|
+
|
|
1203
|
+
/** The decode failure, absent when the region was merely truncated. */
|
|
1204
|
+
readonly cause?: unknown
|
|
568
1205
|
}
|
|
569
1206
|
|
|
570
1207
|
function flatten(segments: Trace[]): Trace | undefined {
|
|
@@ -588,6 +1225,15 @@ function readSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
|
|
|
588
1225
|
let events: TraceEvent[] = []
|
|
589
1226
|
let offset = 0
|
|
590
1227
|
|
|
1228
|
+
// Reports the skip before resuming, including the case where nothing follows
|
|
1229
|
+
// it: discarding the tail of a file is the largest loss recovery can inflict
|
|
1230
|
+
// and the one a caller most needs told about.
|
|
1231
|
+
const skip = (from: number, kind: RecoveredRegion['kind'], cause?: unknown): number => {
|
|
1232
|
+
const next = findNextSegment(bytes, from + 1)
|
|
1233
|
+
options?.onRecovered?.({ offset: from, kind, resumedAt: next < 0 ? undefined : next, cause })
|
|
1234
|
+
return next
|
|
1235
|
+
}
|
|
1236
|
+
|
|
591
1237
|
// The first thing in the buffer must be a preamble; failing that, the
|
|
592
1238
|
// errors below name why rather than reporting a stray byte.
|
|
593
1239
|
validatePreamble(bytes, 0)
|
|
@@ -600,21 +1246,35 @@ function readSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
|
|
|
600
1246
|
const { headerLength } = validatePreamble(bytes, offset)
|
|
601
1247
|
const start = offset + PREAMBLE_SIZE
|
|
602
1248
|
const headerBytes = bytes.subarray(start, start + headerLength)
|
|
1249
|
+
let header: TraceHeader
|
|
1250
|
+
try {
|
|
1251
|
+
header = cborToHeader(decode(headerBytes) as Record<string, unknown>)
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
// A header this reader cannot build is malformed for *that segment*,
|
|
1254
|
+
// and the segment is not presented as read — the alternative is a
|
|
1255
|
+
// header the file never carried, which nothing downstream can tell
|
|
1256
|
+
// from a real one. `recover` skips to the next segment, on the same
|
|
1257
|
+
// terms as a malformed event; without it the read stops here.
|
|
1258
|
+
if (!recover) throw error
|
|
1259
|
+
const next = skip(offset, 'header', error)
|
|
1260
|
+
if (next < 0) return segments
|
|
1261
|
+
offset = next
|
|
1262
|
+
continue
|
|
1263
|
+
}
|
|
603
1264
|
events = []
|
|
604
|
-
segments.push({
|
|
605
|
-
header: cborToHeader(decode(headerBytes) as Record<string, unknown>),
|
|
606
|
-
events,
|
|
607
|
-
})
|
|
1265
|
+
segments.push({ header, events })
|
|
608
1266
|
offset = start + headerLength
|
|
609
1267
|
continue
|
|
610
1268
|
}
|
|
611
1269
|
|
|
612
1270
|
let length: number | null
|
|
1271
|
+
let failure: unknown
|
|
613
1272
|
try {
|
|
614
1273
|
length = cborItemLength(bytes, offset)
|
|
615
1274
|
} catch (error) {
|
|
616
1275
|
if (!recover || !(error instanceof MalformedCborError)) throw error
|
|
617
1276
|
length = null
|
|
1277
|
+
failure = error
|
|
618
1278
|
}
|
|
619
1279
|
|
|
620
1280
|
if (length != null) {
|
|
@@ -625,12 +1285,15 @@ function readSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
|
|
|
625
1285
|
continue
|
|
626
1286
|
} catch (error) {
|
|
627
1287
|
if (!recover) throw error
|
|
1288
|
+
failure = error
|
|
628
1289
|
}
|
|
629
1290
|
} else if (!recover) {
|
|
630
1291
|
throw new TruncatedTraceError(offset, segments)
|
|
631
1292
|
}
|
|
632
1293
|
|
|
633
|
-
|
|
1294
|
+
// No failure means `cborItemLength` returned null: the item runs past the
|
|
1295
|
+
// end of the file rather than being malformed where it sits.
|
|
1296
|
+
const next = skip(offset, failure === undefined ? 'truncated' : 'event', failure)
|
|
634
1297
|
if (next < 0) return segments
|
|
635
1298
|
offset = next
|
|
636
1299
|
}
|
|
@@ -703,6 +1366,8 @@ export function writeMoqtraceSegments(segments: Trace[]): Uint8Array {
|
|
|
703
1366
|
*
|
|
704
1367
|
* @throws {TruncatedTraceError} if the file stops part-way through an item.
|
|
705
1368
|
* The error carries everything that decoded before the cut.
|
|
1369
|
+
* @throws {MalformedHeaderError} if a segment header carries no usable value
|
|
1370
|
+
* for a required key. Pass `recover` to skip that segment instead.
|
|
706
1371
|
*/
|
|
707
1372
|
export function readMoqtrace(bytes: Uint8Array, options?: ReadOptions): Trace {
|
|
708
1373
|
const trace = flatten(readSegments(bytes, options))
|
|
@@ -717,6 +1382,8 @@ export function readMoqtrace(bytes: Uint8Array, options?: ReadOptions): Trace {
|
|
|
717
1382
|
*
|
|
718
1383
|
* A non-segmented file yields exactly one entry, whose header carries no
|
|
719
1384
|
* `segment` field.
|
|
1385
|
+
*
|
|
1386
|
+
* @throws {MalformedHeaderError} as {@link readMoqtrace} does.
|
|
720
1387
|
*/
|
|
721
1388
|
export function readMoqtraceSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
|
|
722
1389
|
return readSegments(bytes, options)
|
|
@@ -726,6 +1393,10 @@ export function readMoqtraceSegments(bytes: Uint8Array, options?: ReadOptions):
|
|
|
726
1393
|
* Read only the header from a .moqtrace file (fast metadata peek).
|
|
727
1394
|
*
|
|
728
1395
|
* In a segmented file this is the first segment's header.
|
|
1396
|
+
*
|
|
1397
|
+
* @throws {MalformedHeaderError} if that header carries no usable value for a
|
|
1398
|
+
* required key. There is no partial header to hand back and no later segment
|
|
1399
|
+
* to fall forward to, so this one always throws.
|
|
729
1400
|
*/
|
|
730
1401
|
export function readMoqtraceHeader(bytes: Uint8Array): TraceHeader {
|
|
731
1402
|
const { headerLength } = validatePreamble(bytes)
|