@moqtap/trace 0.4.1 → 0.5.0
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 +69 -240
- package/SPEC.md +30 -1
- package/dist/index.cjs +10 -7
- package/dist/index.d.cts +24 -2
- package/dist/index.d.ts +24 -2
- package/dist/index.js +10 -7
- package/package.json +2 -2
- package/src/binary.ts +17 -15
- package/src/recorder.ts +13 -6
- package/src/types.ts +17 -0
package/README.md
CHANGED
|
@@ -1,18 +1,9 @@
|
|
|
1
1
|
# @moqtap/trace
|
|
2
2
|
|
|
3
|
-
Record, serialize
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- Configurable detail levels from control-only to full wire captures
|
|
8
|
-
- Session recorder that wraps `@moqtap/codec` session state machines
|
|
9
|
-
- Human-readable JSON export for debugging
|
|
10
|
-
- Zero-copy streaming writer, and an incremental reader for a stream that
|
|
11
|
-
arrives in chunks
|
|
12
|
-
- Unrecognised keys, event types and enum values preserved through a
|
|
13
|
-
read-modify-write
|
|
14
|
-
|
|
15
|
-
Writes format version 2 and reads versions 1 and 2.
|
|
3
|
+
Record, serialize and analyze MoQT (Media over QUIC Transport) sessions as
|
|
4
|
+
`.moqtrace` files — a compact, streamable CBOR format that reads the same across
|
|
5
|
+
implementations. Includes a recorder that wraps `@moqtap/codec` session state
|
|
6
|
+
machines, streaming read and write, and a lossy JSON export for debugging.
|
|
16
7
|
|
|
17
8
|
## Install
|
|
18
9
|
|
|
@@ -20,271 +11,109 @@ Writes format version 2 and reads versions 1 and 2.
|
|
|
20
11
|
npm install @moqtap/trace @moqtap/codec
|
|
21
12
|
```
|
|
22
13
|
|
|
23
|
-
`@moqtap/codec` is a peer dependency — install it alongside
|
|
14
|
+
`@moqtap/codec` is a peer dependency — install it alongside.
|
|
24
15
|
|
|
25
16
|
## Quick Start
|
|
26
17
|
|
|
27
|
-
|
|
18
|
+
Wrap a codec session state machine, then serialize what it recorded:
|
|
28
19
|
|
|
29
20
|
```typescript
|
|
30
21
|
import { createRecorder, writeMoqtrace } from '@moqtap/trace'
|
|
31
|
-
import {
|
|
22
|
+
import { MESSAGE_ID_MAP } from '@moqtap/codec/draft21'
|
|
23
|
+
import { createDraft21SessionState } from '@moqtap/codec/draft21/session'
|
|
32
24
|
|
|
33
25
|
const recorder = createRecorder({
|
|
34
|
-
protocol: 'moq-transport-
|
|
26
|
+
protocol: 'moq-transport-21',
|
|
35
27
|
perspective: 'client',
|
|
36
28
|
detail: 'control',
|
|
29
|
+
messageTypeId: (name) => Number(MESSAGE_ID_MAP.get(name) ?? 0),
|
|
37
30
|
})
|
|
38
31
|
|
|
39
|
-
//
|
|
40
|
-
const
|
|
41
|
-
const traced = recorder.wrapSession(session)
|
|
42
|
-
|
|
43
|
-
// Use `traced` instead of `session` — all send/receive calls are recorded
|
|
32
|
+
// Every send/receive on the wrapper is recorded.
|
|
33
|
+
const traced = recorder.wrapSession(createDraft21SessionState('client'))
|
|
44
34
|
traced.send(setupMessage)
|
|
45
35
|
traced.receive(setupMessage)
|
|
46
36
|
|
|
47
|
-
//
|
|
48
|
-
recorder.recordStreamOpened(4n, 0, 0)
|
|
37
|
+
// Events the session layer never sees.
|
|
38
|
+
recorder.recordStreamOpened(4n, 0, 0)
|
|
49
39
|
recorder.annotate('connected', { relay: 'cdn.example.com' })
|
|
50
40
|
|
|
51
|
-
|
|
52
|
-
const trace = recorder.finalize()
|
|
53
|
-
const bytes = writeMoqtrace(trace) // → Uint8Array (.moqtrace binary)
|
|
41
|
+
const bytes = writeMoqtrace(recorder.finalize()) // Uint8Array
|
|
54
42
|
```
|
|
55
43
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
A report saying "your SUBSCRIBE_OK did not parse" is an assertion. The same
|
|
59
|
-
report carrying the bytes is evidence the other party can run against their own
|
|
60
|
-
encoder.
|
|
61
|
-
|
|
62
|
-
```typescript
|
|
63
|
-
recorder.recordError(0, 'SUBSCRIBE_OK did not parse', {
|
|
64
|
-
streamId: 4n,
|
|
65
|
-
errorKind: 'decode', // 'protocol' | 'transport' | 'decode' | any other string
|
|
66
|
-
raw: offendingBytes, // hand over everything held, untruncated
|
|
67
|
-
})
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Hand `raw` the full bytes. The recorder applies `MAX_ERROR_RAW_BYTES` (4096)
|
|
71
|
-
itself and reports the untruncated length separately, so a caller that
|
|
72
|
-
pre-truncates destroys the one signal saying the capture is partial. `rawLength`
|
|
73
|
-
is recorded from `'headers+sizes'` and `raw` only at `'full'`, and the bytes are
|
|
74
|
-
written once per flow.
|
|
75
|
-
|
|
76
|
-
### Reading a trace file
|
|
44
|
+
Read one back:
|
|
77
45
|
|
|
78
46
|
```typescript
|
|
79
47
|
import { readMoqtrace, readMoqtraceHeader } from '@moqtap/trace'
|
|
80
48
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
console.log(header.protocol) // "moq-transport-17"
|
|
84
|
-
console.log(header.perspective) // "client"
|
|
85
|
-
console.log(header.detail) // "control"
|
|
49
|
+
const header = readMoqtraceHeader(bytes) // metadata only, no event parsing
|
|
50
|
+
console.log(header.protocol, header.perspective, header.detail)
|
|
86
51
|
|
|
87
|
-
// Full parse
|
|
88
52
|
const trace = readMoqtrace(bytes)
|
|
89
53
|
for (const event of trace.events) {
|
|
90
54
|
console.log(event.type, event.timestamp)
|
|
91
55
|
}
|
|
92
56
|
```
|
|
93
57
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
`
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
### Segmented traces
|
|
136
|
-
|
|
137
|
-
A segment is a complete `.moqtrace` blob — magic, version, header, events —
|
|
138
|
-
appended after the one before it, so a reader can start at any segment without
|
|
139
|
-
the ones preceding it. That buys capture rotation without finalizing a file, a
|
|
140
|
-
natural cut point for live carriage, and recovery from a damaged region.
|
|
141
|
-
|
|
142
|
-
```typescript
|
|
143
|
-
import { readMoqtraceSegments, writeMoqtraceSegments } from '@moqtap/trace'
|
|
144
|
-
|
|
145
|
-
// Each segment header must carry `segment` metadata: it is what tells a reader
|
|
146
|
-
// that `seq` and `timestamp` restart at zero in every segment.
|
|
147
|
-
const bytes = writeMoqtraceSegments([
|
|
148
|
-
{ header: { ...header, segment: { sequence: 0 } }, events: firstBatch },
|
|
149
|
-
{ header: { ...header, segment: { sequence: 1, continues: true } }, events: secondBatch },
|
|
150
|
-
])
|
|
151
|
-
|
|
152
|
-
// Read the boundaries — you need them to order events across segments.
|
|
153
|
-
for (const segment of readMoqtraceSegments(bytes)) {
|
|
154
|
-
console.log(segment.header.segment?.sequence, segment.events.length)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Or ignore them: readMoqtrace flattens every segment's events in order.
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
### Damaged and truncated files
|
|
161
|
-
|
|
162
|
-
A file that stops part-way through an event throws `TruncatedTraceError`, which
|
|
163
|
-
carries everything that decoded before the cut — a truncated trace is still
|
|
164
|
-
evidence. On a segmented trace, `{ recover: true }` skips a damaged region and
|
|
165
|
-
resumes at the next segment instead of throwing; pass `onRecovered` alongside
|
|
166
|
-
it to be told what that cost, since the return value cannot say.
|
|
167
|
-
|
|
168
|
-
```typescript
|
|
169
|
-
import { readMoqtrace, readMoqtraceSegments, TruncatedTraceError } from '@moqtap/trace'
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
const trace = readMoqtrace(bytes)
|
|
173
|
-
} catch (error) {
|
|
174
|
-
if (error instanceof TruncatedTraceError) {
|
|
175
|
-
console.warn(`truncated at byte ${error.offset}`)
|
|
176
|
-
const survived = error.segments.reduce((n, segment) => n + segment.events.length, 0)
|
|
177
|
-
console.log(survived, 'events survived')
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const whatSurvived = readMoqtraceSegments(bytes, {
|
|
182
|
-
recover: true,
|
|
183
|
-
onRecovered: ({ offset, kind, resumedAt }) => {
|
|
184
|
-
console.warn(
|
|
185
|
-
resumedAt == null
|
|
186
|
-
? `${kind} damage at ${offset}: nothing readable followed, rest discarded`
|
|
187
|
-
: `${kind} damage at ${offset}, resumed at ${resumedAt}`,
|
|
188
|
-
)
|
|
189
|
-
},
|
|
190
|
-
})
|
|
191
|
-
```
|
|
58
|
+
## API
|
|
59
|
+
|
|
60
|
+
| Function | Purpose |
|
|
61
|
+
| ---------------------------------------------------- | ------------------------------------------------------------ |
|
|
62
|
+
| `createRecorder(options)` | Session recorder; wraps a codec session state machine |
|
|
63
|
+
| `writeMoqtrace(trace)` | Serialize a complete trace to bytes |
|
|
64
|
+
| `readMoqtrace(bytes)` | Parse a complete file, flattening every segment in order |
|
|
65
|
+
| `readMoqtraceHeader(bytes)` | Parse the header only |
|
|
66
|
+
| `createMoqtraceWriter(header)` | Streaming writer: `preamble()`, then `writeEvent()` per event |
|
|
67
|
+
| `createMoqtraceReader()` | Incremental reader for a stream arriving in chunks |
|
|
68
|
+
| `writeMoqtraceSegments()` / `readMoqtraceSegments()` | Segmented traces, for rotation, live carriage and recovery |
|
|
69
|
+
| `traceToJSON(trace)` | Human-readable export; lossy, as bigints and bytes stringify |
|
|
70
|
+
|
|
71
|
+
`createRecorder` requires `protocol`, `perspective` and `detail`. Pass
|
|
72
|
+
`messageTypeId` to map a message name to its wire id, which session-layer
|
|
73
|
+
recording needs; `maxEvents` bounds the event buffer.
|
|
74
|
+
|
|
75
|
+
Beyond `wrapSession`, the recorder takes events the session layer cannot see:
|
|
76
|
+
`recordStreamOpened`, `recordStreamClosed`, `recordObjectHeader`,
|
|
77
|
+
`recordObjectPayload`, `recordError` and `annotate`. `finalize()` returns the
|
|
78
|
+
trace and stops recording.
|
|
79
|
+
|
|
80
|
+
## Detail levels
|
|
81
|
+
|
|
82
|
+
| Level | What is recorded |
|
|
83
|
+
| --------------- | -------------------------------------- |
|
|
84
|
+
| `control` | Control messages only |
|
|
85
|
+
| `headers` | + data stream and object headers |
|
|
86
|
+
| `headers+sizes` | + payload byte lengths |
|
|
87
|
+
| `headers+data` | + full payload bytes |
|
|
88
|
+
| `full` | + raw wire bytes, pre-decode |
|
|
89
|
+
|
|
90
|
+
Errors and annotations are recorded at every level. `headers+data` and `full`,
|
|
91
|
+
and the `raw` field on any event, carry user content, so capturing them should
|
|
92
|
+
be an explicit choice rather than a default.
|
|
93
|
+
|
|
94
|
+
When recording an error, hand `recordError` the full bytes: it applies
|
|
95
|
+
`MAX_ERROR_RAW_BYTES` itself and reports the untruncated length separately, so a
|
|
96
|
+
caller that pre-truncates destroys the signal saying the capture is partial.
|
|
192
97
|
|
|
193
98
|
## Reading a trace you did not write
|
|
194
99
|
|
|
195
|
-
Nothing
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
policy or derivation kind is kept verbatim.
|
|
200
|
-
|
|
201
|
-
Keys are kept on the same terms. One this version does not recognise — on the
|
|
202
|
-
header, on `segment`, on `sampling`, or on any event — is preserved verbatim in
|
|
203
|
-
an `extra` map beside the fields and written back unchanged. Ignoring a key is
|
|
204
|
-
permitted; dropping one is not, or a redaction pass, a filter or an annotated
|
|
205
|
-
download emits a file that looks as though it never carried the key.
|
|
206
|
-
|
|
207
|
-
The one place that refuses is `createRecorder`, which cannot honour a detail
|
|
208
|
-
level it does not implement: it would either capture less than you asked for
|
|
209
|
-
while writing a header claiming otherwise, or — for the payload-bearing levels
|
|
210
|
-
— capture more.
|
|
100
|
+
Nothing is rejected for carrying something newer than this version knows. An
|
|
101
|
+
unrecognised event type arrives as an `UnknownEvent` with its fields intact, and
|
|
102
|
+
an unrecognised key is preserved verbatim in an `extra` map beside the fields, so
|
|
103
|
+
neither is dropped by a read-modify-write round trip.
|
|
211
104
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
| `control` | Control messages only (setup, subscribe, publish, goaway) |
|
|
217
|
-
| `headers` | + data stream headers (subgroup/fetch, object metadata) |
|
|
218
|
-
| `headers+sizes` | + payload byte lengths |
|
|
219
|
-
| `headers+data` | + full payload bytes |
|
|
220
|
-
| `full` | + raw wire bytes (pre-decode) |
|
|
221
|
-
|
|
222
|
-
`headers+data` and `full`, and the `raw` field on any event, carry user
|
|
223
|
-
content. Capturing them is optional for a conformant recorder and should be an
|
|
224
|
-
explicit choice, not a default.
|
|
225
|
-
|
|
226
|
-
## JSON Export
|
|
105
|
+
A file that stops part-way through an event throws `TruncatedTraceError`, which
|
|
106
|
+
carries everything that decoded before the cut. On a segmented trace,
|
|
107
|
+
`readMoqtraceSegments(bytes, { recover: true })` skips a damaged region and
|
|
108
|
+
resumes at the next segment instead of throwing.
|
|
227
109
|
|
|
228
|
-
|
|
110
|
+
## File format
|
|
229
111
|
|
|
230
|
-
|
|
231
|
-
import { traceToJSON } from '@moqtap/trace'
|
|
112
|
+
[SPEC.md](./SPEC.md) is the complete `.moqtrace` binary specification.
|
|
232
113
|
|
|
233
|
-
|
|
234
|
-
console.log(json)
|
|
235
|
-
```
|
|
114
|
+
## Documentation
|
|
236
115
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
### To 0.4.0 (from 0.3.0)
|
|
240
|
-
|
|
241
|
-
Two changes to what a reader accepts:
|
|
242
|
-
|
|
243
|
-
- `ControlMessageEvent.message` is `unknown` rather than
|
|
244
|
-
`Record<string, unknown>`, so anything reading a key straight off it stops
|
|
245
|
-
compiling. Narrow with `controlMessageFields()` first. The old type was a
|
|
246
|
-
claim the runtime did not keep — every real capture carries a text rendering
|
|
247
|
-
there, so `event.message.request_id` typechecked and read `undefined`.
|
|
248
|
-
- A header with no usable value for a required key throws
|
|
249
|
-
`MalformedHeaderError` instead of returning a header with `undefined`, `NaN`
|
|
250
|
-
or `0` standing in for it. Pass `recover` to skip that segment instead. No
|
|
251
|
-
file either implementation writes is affected.
|
|
252
|
-
|
|
253
|
-
Everything else is additive: `extra` on the header and its two sub-maps, the
|
|
254
|
-
stream-header identifiers on Event 1, the error fields on Event 6, and
|
|
255
|
-
`createMoqtraceReader`.
|
|
256
|
-
|
|
257
|
-
### To 0.3.0 (from 0.2.0)
|
|
258
|
-
|
|
259
|
-
The package now writes format version 2. A 0.2.0 reader rejects those files at
|
|
260
|
-
byte 8, which is the point of the bump: it turns what would have been silent
|
|
261
|
-
desynchronization on a segmented trace into a clear refusal. Reading is
|
|
262
|
-
unaffected in the other direction — 0.3.0 reads everything 0.2.0 wrote.
|
|
263
|
-
|
|
264
|
-
What changes for code already using it:
|
|
265
|
-
|
|
266
|
-
- `TraceEvent` gained four members (`peer-connected`, `peer-disconnected`,
|
|
267
|
-
`subscription-derivation`, `unknown`). A `switch` that handles only the
|
|
268
|
-
cases it cares about is unaffected; one that asserts exhaustiveness is not.
|
|
269
|
-
- `Perspective`, `DetailLevel` and the new string unions accept values they do
|
|
270
|
-
not name, so indexing a `Record<DetailLevel, T>` with one now yields
|
|
271
|
-
`T | undefined`.
|
|
272
|
-
- An unknown event type used to arrive as an annotation labelled
|
|
273
|
-
`unknown-event-<n>`. It now arrives as an `UnknownEvent` carrying its real
|
|
274
|
-
type and fields, so code matching that label should match `type === 'unknown'`
|
|
275
|
-
instead. The old shape was lossy in a way that mattered: writing the trace
|
|
276
|
-
back rewrote the event's type to 7.
|
|
277
|
-
- `createRecorder` throws on a detail level it does not implement, which the
|
|
278
|
-
types previously made unexpressible.
|
|
279
|
-
- The bytes changed in two ways that make files readable by the Rust
|
|
280
|
-
implementation for the first time: an integral value past 32 bits is now
|
|
281
|
-
written as a CBOR integer rather than a float64, and a byte string as major
|
|
282
|
-
type 2 rather than wrapped in the typed-array tag 64. Both readers accept
|
|
283
|
-
either form, so nothing already written becomes unreadable.
|
|
284
|
-
|
|
285
|
-
## File Format
|
|
286
|
-
|
|
287
|
-
See [SPEC.md](./SPEC.md) for the complete `.moqtrace` binary format specification.
|
|
116
|
+
<https://moqtap.com/npm-packages/moqtap-trace/>
|
|
288
117
|
|
|
289
118
|
## License
|
|
290
119
|
|
package/SPEC.md
CHANGED
|
@@ -44,7 +44,9 @@ and Event 4's `"pl"` begins after the object header, so the bytes of a
|
|
|
44
44
|
including `"full"`. What those headers carry is recorded as decoded fields
|
|
45
45
|
instead — see [Event 1](#event-1-stream-opened). Closing that gap needs a new
|
|
46
46
|
key and is not in this revision; the level's description no longer implies
|
|
47
|
-
otherwise.
|
|
47
|
+
otherwise. The one part of an object's framing recorded as bytes is its
|
|
48
|
+
property block, [Event 3's `"props"`](#event-3-object-header), because the
|
|
49
|
+
block's encoding is the draft's and has no decoded form this format could hold.
|
|
48
50
|
|
|
49
51
|
At any detail level, the recorder MAY be configured to **mask payloads** — replacing payload bytes with zeroes before writing. This preserves payload sizes for bandwidth analysis while stripping media content. Sources MUST advertise masking via `"payloadMasked": true` in the header's `"custom"` map when active.
|
|
50
52
|
|
|
@@ -261,6 +263,33 @@ Object header events and object payload events (event 4) for the same object sha
|
|
|
261
263
|
| `"o"` | integer | `headers`+ | Object ID |
|
|
262
264
|
| `"pp"` | integer | `headers`+ | Publisher priority |
|
|
263
265
|
| `"os"` | integer | `headers`+ | Object status (0=normal, 1=end-of-group, 2=end-of-track, 3=does-not-exist) |
|
|
266
|
+
| `"props"` | byte string | `headers`+ | The object's property block as the wire carried it, past its length field. **Optional.** See below. |
|
|
267
|
+
|
|
268
|
+
#### `"props"` — the property block
|
|
269
|
+
|
|
270
|
+
Object Properties — called Extension Headers before draft-17 — are the part of
|
|
271
|
+
an object a consumer reads before its payload: a LOC object's timestamp, frame
|
|
272
|
+
marking and codec configuration, the gaps a publisher signals after a restart,
|
|
273
|
+
the key ID of an encrypted object. Before this key no level recorded them, and
|
|
274
|
+
none of them can be recovered from the payload, so a trace could show an
|
|
275
|
+
object's bytes but not what decided how they were decoded.
|
|
276
|
+
|
|
277
|
+
- **Bytes, not a decoded list.** Every draft encodes the block differently —
|
|
278
|
+
absolute or delta types, two varint encodings — and a reader already holds
|
|
279
|
+
the codec for the session's draft. A decoded form would freeze one draft's
|
|
280
|
+
rules into this format, and the block MUST be recorded byte for byte so a
|
|
281
|
+
reader decodes exactly what the endpoint saw. Immutable Properties, which an
|
|
282
|
+
encrypted object authenticates, depend on that.
|
|
283
|
+
- **Absent and empty differ.** A writer MUST omit the key when the object had no
|
|
284
|
+
properties field, and MUST write an empty byte string when the field was
|
|
285
|
+
present with a length of 0 — on a subgroup stream with the PROPERTIES flag,
|
|
286
|
+
every object without properties carries one. A reader MUST keep the
|
|
287
|
+
distinction.
|
|
288
|
+
- **`headers`+, not payload-bearing.** The block is object metadata, not media.
|
|
289
|
+
A property value can still carry application data — a codec configuration is
|
|
290
|
+
a few dozen bytes of it — so a source that must not share any object content
|
|
291
|
+
MAY drop the key along with the payload. Dropping it does not change the
|
|
292
|
+
trace's detail level.
|
|
264
293
|
|
|
265
294
|
### Event 4: Object Payload
|
|
266
295
|
|
package/dist/index.cjs
CHANGED
|
@@ -329,10 +329,10 @@ function cborToHeader(obj) {
|
|
|
329
329
|
...optional(src, "sessionId", "sessionId", asText),
|
|
330
330
|
...optional(src, "segment", "segment", cborToSegment),
|
|
331
331
|
...optional(src, "sampling", "sampling", cborToSampling),
|
|
332
|
-
// Not `Record<string, unknown>` by assertion but by test.
|
|
333
|
-
// that is not a map
|
|
334
|
-
// first caller to iterate its keys
|
|
335
|
-
// occur. The bytes survive either way; only
|
|
332
|
+
// Not `Record<string, unknown>` by assertion but by test. Asserted, a
|
|
333
|
+
// `"custom"` that is not a map would reach this field wearing that type,
|
|
334
|
+
// and the first caller to iterate its keys would meet a shape it was told
|
|
335
|
+
// could not occur. The bytes survive either way; only the test keeps the
|
|
336
336
|
// field's type true.
|
|
337
337
|
...optional(src, "custom", "custom", asMap)
|
|
338
338
|
};
|
|
@@ -404,6 +404,7 @@ function eventToCbor(event) {
|
|
|
404
404
|
base.o = event.objectId;
|
|
405
405
|
base.pp = int(event.publisherPriority);
|
|
406
406
|
base.os = int(event.objectStatus);
|
|
407
|
+
if (event.properties != null) base.props = event.properties;
|
|
407
408
|
break;
|
|
408
409
|
}
|
|
409
410
|
case "object-payload": {
|
|
@@ -661,7 +662,8 @@ function decodeEvent(src) {
|
|
|
661
662
|
groupId: requireUint(src, "g"),
|
|
662
663
|
objectId: requireUint(src, "o"),
|
|
663
664
|
publisherPriority: Number(_nullishCoalesce(take(src, "pp"), () => ( 0))),
|
|
664
|
-
objectStatus: Number(_nullishCoalesce(take(src, "os"), () => ( 0)))
|
|
665
|
+
objectStatus: Number(_nullishCoalesce(take(src, "os"), () => ( 0))),
|
|
666
|
+
...optional(src, "props", "properties", asByteString)
|
|
665
667
|
};
|
|
666
668
|
case "object-payload":
|
|
667
669
|
return {
|
|
@@ -1215,7 +1217,7 @@ function createRecorder(options) {
|
|
|
1215
1217
|
errorCode
|
|
1216
1218
|
});
|
|
1217
1219
|
},
|
|
1218
|
-
recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus) {
|
|
1220
|
+
recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus, properties) {
|
|
1219
1221
|
if (detailRank < DETAIL_RANK.headers) return;
|
|
1220
1222
|
addEvent({
|
|
1221
1223
|
type: "object-header",
|
|
@@ -1225,7 +1227,8 @@ function createRecorder(options) {
|
|
|
1225
1227
|
groupId,
|
|
1226
1228
|
objectId,
|
|
1227
1229
|
publisherPriority,
|
|
1228
|
-
objectStatus
|
|
1230
|
+
objectStatus,
|
|
1231
|
+
...properties !== void 0 ? { properties } : {}
|
|
1229
1232
|
});
|
|
1230
1233
|
},
|
|
1231
1234
|
recordObjectPayload(streamId, groupId, objectId, size, payload) {
|
package/dist/index.d.cts
CHANGED
|
@@ -298,6 +298,23 @@ interface ObjectHeaderEvent extends BaseEvent {
|
|
|
298
298
|
readonly objectId: bigint;
|
|
299
299
|
readonly publisherPriority: number;
|
|
300
300
|
readonly objectStatus: number;
|
|
301
|
+
/**
|
|
302
|
+
* The Object's property block — Object Properties, Extension Headers before
|
|
303
|
+
* draft-17 — exactly as the wire carried it, past its length field.
|
|
304
|
+
*
|
|
305
|
+
* Absent when the Object had no properties field, which is not the same as an
|
|
306
|
+
* empty block: a subgroup stream with the PROPERTIES flag carries a zero
|
|
307
|
+
* length on every Object that has none. Kept as bytes rather than decoded
|
|
308
|
+
* because the encoding is the draft's, and a reader decodes it with the codec
|
|
309
|
+
* that reads the rest of the session; a decoded form would freeze one draft's
|
|
310
|
+
* rules into the format.
|
|
311
|
+
*
|
|
312
|
+
* Recorded from `'headers'` up. It is object metadata a payload's consumer
|
|
313
|
+
* reads before the payload — timestamps, frame marking, a codec
|
|
314
|
+
* configuration — and before this key no level recorded it, so a trace could
|
|
315
|
+
* not show what decided how its media was decoded.
|
|
316
|
+
*/
|
|
317
|
+
readonly properties?: Uint8Array;
|
|
301
318
|
}
|
|
302
319
|
interface ObjectPayloadEvent extends BaseEvent {
|
|
303
320
|
readonly type: 'object-payload';
|
|
@@ -917,8 +934,13 @@ interface TraceRecorder {
|
|
|
917
934
|
recordStreamOpened(streamId: bigint, direction: 0 | 1, streamType: 0 | 1 | 2): void;
|
|
918
935
|
/** Record a stream-closed event. Ignored at 'control' detail level. */
|
|
919
936
|
recordStreamClosed(streamId: bigint, errorCode?: number): void;
|
|
920
|
-
/**
|
|
921
|
-
|
|
937
|
+
/**
|
|
938
|
+
* Record an object header event. Ignored below 'headers' detail level.
|
|
939
|
+
*
|
|
940
|
+
* `properties` is the Object's property block as the wire carried it; omit it
|
|
941
|
+
* when the Object had no properties field.
|
|
942
|
+
*/
|
|
943
|
+
recordObjectHeader(streamId: bigint, groupId: bigint, objectId: bigint, publisherPriority: number, objectStatus: number, properties?: Uint8Array): void;
|
|
922
944
|
/** Record an object payload event. Ignored below 'headers+sizes' detail level. */
|
|
923
945
|
recordObjectPayload(streamId: bigint, groupId: bigint, objectId: bigint, size: number, payload?: Uint8Array): void;
|
|
924
946
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -298,6 +298,23 @@ interface ObjectHeaderEvent extends BaseEvent {
|
|
|
298
298
|
readonly objectId: bigint;
|
|
299
299
|
readonly publisherPriority: number;
|
|
300
300
|
readonly objectStatus: number;
|
|
301
|
+
/**
|
|
302
|
+
* The Object's property block — Object Properties, Extension Headers before
|
|
303
|
+
* draft-17 — exactly as the wire carried it, past its length field.
|
|
304
|
+
*
|
|
305
|
+
* Absent when the Object had no properties field, which is not the same as an
|
|
306
|
+
* empty block: a subgroup stream with the PROPERTIES flag carries a zero
|
|
307
|
+
* length on every Object that has none. Kept as bytes rather than decoded
|
|
308
|
+
* because the encoding is the draft's, and a reader decodes it with the codec
|
|
309
|
+
* that reads the rest of the session; a decoded form would freeze one draft's
|
|
310
|
+
* rules into the format.
|
|
311
|
+
*
|
|
312
|
+
* Recorded from `'headers'` up. It is object metadata a payload's consumer
|
|
313
|
+
* reads before the payload — timestamps, frame marking, a codec
|
|
314
|
+
* configuration — and before this key no level recorded it, so a trace could
|
|
315
|
+
* not show what decided how its media was decoded.
|
|
316
|
+
*/
|
|
317
|
+
readonly properties?: Uint8Array;
|
|
301
318
|
}
|
|
302
319
|
interface ObjectPayloadEvent extends BaseEvent {
|
|
303
320
|
readonly type: 'object-payload';
|
|
@@ -917,8 +934,13 @@ interface TraceRecorder {
|
|
|
917
934
|
recordStreamOpened(streamId: bigint, direction: 0 | 1, streamType: 0 | 1 | 2): void;
|
|
918
935
|
/** Record a stream-closed event. Ignored at 'control' detail level. */
|
|
919
936
|
recordStreamClosed(streamId: bigint, errorCode?: number): void;
|
|
920
|
-
/**
|
|
921
|
-
|
|
937
|
+
/**
|
|
938
|
+
* Record an object header event. Ignored below 'headers' detail level.
|
|
939
|
+
*
|
|
940
|
+
* `properties` is the Object's property block as the wire carried it; omit it
|
|
941
|
+
* when the Object had no properties field.
|
|
942
|
+
*/
|
|
943
|
+
recordObjectHeader(streamId: bigint, groupId: bigint, objectId: bigint, publisherPriority: number, objectStatus: number, properties?: Uint8Array): void;
|
|
922
944
|
/** Record an object payload event. Ignored below 'headers+sizes' detail level. */
|
|
923
945
|
recordObjectPayload(streamId: bigint, groupId: bigint, objectId: bigint, size: number, payload?: Uint8Array): void;
|
|
924
946
|
/**
|
package/dist/index.js
CHANGED
|
@@ -329,10 +329,10 @@ function cborToHeader(obj) {
|
|
|
329
329
|
...optional(src, "sessionId", "sessionId", asText),
|
|
330
330
|
...optional(src, "segment", "segment", cborToSegment),
|
|
331
331
|
...optional(src, "sampling", "sampling", cborToSampling),
|
|
332
|
-
// Not `Record<string, unknown>` by assertion but by test.
|
|
333
|
-
// that is not a map
|
|
334
|
-
// first caller to iterate its keys
|
|
335
|
-
// occur. The bytes survive either way; only
|
|
332
|
+
// Not `Record<string, unknown>` by assertion but by test. Asserted, a
|
|
333
|
+
// `"custom"` that is not a map would reach this field wearing that type,
|
|
334
|
+
// and the first caller to iterate its keys would meet a shape it was told
|
|
335
|
+
// could not occur. The bytes survive either way; only the test keeps the
|
|
336
336
|
// field's type true.
|
|
337
337
|
...optional(src, "custom", "custom", asMap)
|
|
338
338
|
};
|
|
@@ -404,6 +404,7 @@ function eventToCbor(event) {
|
|
|
404
404
|
base.o = event.objectId;
|
|
405
405
|
base.pp = int(event.publisherPriority);
|
|
406
406
|
base.os = int(event.objectStatus);
|
|
407
|
+
if (event.properties != null) base.props = event.properties;
|
|
407
408
|
break;
|
|
408
409
|
}
|
|
409
410
|
case "object-payload": {
|
|
@@ -661,7 +662,8 @@ function decodeEvent(src) {
|
|
|
661
662
|
groupId: requireUint(src, "g"),
|
|
662
663
|
objectId: requireUint(src, "o"),
|
|
663
664
|
publisherPriority: Number(take(src, "pp") ?? 0),
|
|
664
|
-
objectStatus: Number(take(src, "os") ?? 0)
|
|
665
|
+
objectStatus: Number(take(src, "os") ?? 0),
|
|
666
|
+
...optional(src, "props", "properties", asByteString)
|
|
665
667
|
};
|
|
666
668
|
case "object-payload":
|
|
667
669
|
return {
|
|
@@ -1215,7 +1217,7 @@ function createRecorder(options) {
|
|
|
1215
1217
|
errorCode
|
|
1216
1218
|
});
|
|
1217
1219
|
},
|
|
1218
|
-
recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus) {
|
|
1220
|
+
recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus, properties) {
|
|
1219
1221
|
if (detailRank < DETAIL_RANK.headers) return;
|
|
1220
1222
|
addEvent({
|
|
1221
1223
|
type: "object-header",
|
|
@@ -1225,7 +1227,8 @@ function createRecorder(options) {
|
|
|
1225
1227
|
groupId,
|
|
1226
1228
|
objectId,
|
|
1227
1229
|
publisherPriority,
|
|
1228
|
-
objectStatus
|
|
1230
|
+
objectStatus,
|
|
1231
|
+
...properties !== void 0 ? { properties } : {}
|
|
1229
1232
|
});
|
|
1230
1233
|
},
|
|
1231
1234
|
recordObjectPayload(streamId, groupId, objectId, size, payload) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moqtap/trace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "MoQT session trace format — record, serialize, and analyze .moqtrace files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"corpus": "bun run src/__tests__/corpus/generate.ts && bun run src/__tests__/corpus/manifest.ts"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@moqtap/codec": "
|
|
51
|
+
"@moqtap/codec": ">=0.11.0"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"cbor-x": "^1.6.0"
|
package/src/binary.ts
CHANGED
|
@@ -110,11 +110,11 @@ const COMMON_EVENT_KEYS = new Set(['n', 't', 'p', 'e'])
|
|
|
110
110
|
* "A defined key whose value has an unusable type is treated as unrecognised."
|
|
111
111
|
*
|
|
112
112
|
* Only the decode writes to the set, so there is no separate list of owned keys
|
|
113
|
-
* to keep in step with it.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
113
|
+
* to keep in step with it. A list would make adding a key to an event *reduce*
|
|
114
|
+
* what this reader preserves: a listed key counts as owned whatever it
|
|
115
|
+
* carries, so a wrong-typed one would be kept out of `extra` while the decode
|
|
116
|
+
* either threw on it, losing the whole file, or coerced it into a value the
|
|
117
|
+
* file never carried.
|
|
118
118
|
*/
|
|
119
119
|
interface Decoding {
|
|
120
120
|
readonly obj: Record<string, unknown>
|
|
@@ -315,8 +315,8 @@ function segmentToCbor(segment: SegmentInfo): Record<string, unknown> {
|
|
|
315
315
|
*
|
|
316
316
|
* `"sequence"` is the exception, and the only key inside here that can fail a
|
|
317
317
|
* header: it is the sole ordering key of a segmented stream, so a reader that
|
|
318
|
-
* cannot read it cannot place the segment, and
|
|
319
|
-
*
|
|
318
|
+
* cannot read it cannot place the segment, and defaulting it to `0` would
|
|
319
|
+
* invent an order the file never had.
|
|
320
320
|
*/
|
|
321
321
|
function cborToSegment(value: unknown): SegmentInfo | undefined {
|
|
322
322
|
const obj = asMap(value)
|
|
@@ -415,10 +415,10 @@ export function cborToHeader(obj: Record<string, unknown>): TraceHeader {
|
|
|
415
415
|
...optional(src, 'sessionId', 'sessionId', asText),
|
|
416
416
|
...optional(src, 'segment', 'segment', cborToSegment),
|
|
417
417
|
...optional(src, 'sampling', 'sampling', cborToSampling),
|
|
418
|
-
// Not `Record<string, unknown>` by assertion but by test.
|
|
419
|
-
// that is not a map
|
|
420
|
-
// first caller to iterate its keys
|
|
421
|
-
// occur. The bytes survive either way; only
|
|
418
|
+
// Not `Record<string, unknown>` by assertion but by test. Asserted, a
|
|
419
|
+
// `"custom"` that is not a map would reach this field wearing that type,
|
|
420
|
+
// and the first caller to iterate its keys would meet a shape it was told
|
|
421
|
+
// could not occur. The bytes survive either way; only the test keeps the
|
|
422
422
|
// field's type true.
|
|
423
423
|
...optional(src, 'custom', 'custom', asMap),
|
|
424
424
|
}
|
|
@@ -542,6 +542,7 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
|
|
|
542
542
|
base.o = event.objectId
|
|
543
543
|
base.pp = int(event.publisherPriority)
|
|
544
544
|
base.os = int(event.objectStatus)
|
|
545
|
+
if (event.properties != null) base.props = event.properties
|
|
545
546
|
break
|
|
546
547
|
}
|
|
547
548
|
case 'object-payload': {
|
|
@@ -808,10 +809,10 @@ function requireUint(src: Decoding, key: string): bigint {
|
|
|
808
809
|
/**
|
|
809
810
|
* Take a required header text key, or fail the header.
|
|
810
811
|
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
812
|
+
* Asserting the type instead — `obj.protocol as string` — would let a header
|
|
813
|
+
* with no `"protocol"` reach a caller with `undefined` sitting in a field
|
|
814
|
+
* declared `string`, and re-encode as CBOR `undefined`, a shape the format
|
|
815
|
+
* tells writers never to produce.
|
|
815
816
|
*/
|
|
816
817
|
function requireHeaderText(src: Decoding, key: string): string {
|
|
817
818
|
const present = Object.hasOwn(src.obj, key)
|
|
@@ -986,6 +987,7 @@ function decodeEvent(src: Decoding): TraceEvent {
|
|
|
986
987
|
objectId: requireUint(src, 'o'),
|
|
987
988
|
publisherPriority: Number(take(src, 'pp') ?? 0),
|
|
988
989
|
objectStatus: Number(take(src, 'os') ?? 0),
|
|
990
|
+
...optional(src, 'props', 'properties', asByteString),
|
|
989
991
|
}
|
|
990
992
|
|
|
991
993
|
case 'object-payload':
|
package/src/recorder.ts
CHANGED
|
@@ -104,13 +104,19 @@ export interface TraceRecorder {
|
|
|
104
104
|
/** Record a stream-closed event. Ignored at 'control' detail level. */
|
|
105
105
|
recordStreamClosed(streamId: bigint, errorCode?: number): void
|
|
106
106
|
|
|
107
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Record an object header event. Ignored below 'headers' detail level.
|
|
109
|
+
*
|
|
110
|
+
* `properties` is the Object's property block as the wire carried it; omit it
|
|
111
|
+
* when the Object had no properties field.
|
|
112
|
+
*/
|
|
108
113
|
recordObjectHeader(
|
|
109
114
|
streamId: bigint,
|
|
110
115
|
groupId: bigint,
|
|
111
116
|
objectId: bigint,
|
|
112
117
|
publisherPriority: number,
|
|
113
118
|
objectStatus: number,
|
|
119
|
+
properties?: Uint8Array,
|
|
114
120
|
): void
|
|
115
121
|
|
|
116
122
|
/** Record an object payload event. Ignored below 'headers+sizes' detail level. */
|
|
@@ -171,10 +177,10 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
|
|
|
171
177
|
/**
|
|
172
178
|
* Index of the oldest event still live in {@link events}.
|
|
173
179
|
*
|
|
174
|
-
* Dropping
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
180
|
+
* Dropping is not `events.shift()`, which moves the whole backlog on every
|
|
181
|
+
* call; at a hundred thousand retained events that is O(n) per recorded
|
|
182
|
+
* event, and it bites hardest exactly when the recorder is already under
|
|
183
|
+
* pressure. The head advances instead, and the dead prefix is spliced
|
|
178
184
|
* away in one pass every few thousand drops, which is O(1) amortised.
|
|
179
185
|
*/
|
|
180
186
|
let head = 0
|
|
@@ -342,7 +348,7 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
|
|
|
342
348
|
})
|
|
343
349
|
},
|
|
344
350
|
|
|
345
|
-
recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus) {
|
|
351
|
+
recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus, properties) {
|
|
346
352
|
if (detailRank < DETAIL_RANK.headers) return
|
|
347
353
|
addEvent({
|
|
348
354
|
type: 'object-header',
|
|
@@ -353,6 +359,7 @@ export function createRecorder(options: RecorderOptions): TraceRecorder {
|
|
|
353
359
|
objectId,
|
|
354
360
|
publisherPriority,
|
|
355
361
|
objectStatus,
|
|
362
|
+
...(properties !== undefined ? { properties } : {}),
|
|
356
363
|
})
|
|
357
364
|
},
|
|
358
365
|
|
package/src/types.ts
CHANGED
|
@@ -319,6 +319,23 @@ export interface ObjectHeaderEvent extends BaseEvent {
|
|
|
319
319
|
readonly objectId: bigint
|
|
320
320
|
readonly publisherPriority: number
|
|
321
321
|
readonly objectStatus: number
|
|
322
|
+
/**
|
|
323
|
+
* The Object's property block — Object Properties, Extension Headers before
|
|
324
|
+
* draft-17 — exactly as the wire carried it, past its length field.
|
|
325
|
+
*
|
|
326
|
+
* Absent when the Object had no properties field, which is not the same as an
|
|
327
|
+
* empty block: a subgroup stream with the PROPERTIES flag carries a zero
|
|
328
|
+
* length on every Object that has none. Kept as bytes rather than decoded
|
|
329
|
+
* because the encoding is the draft's, and a reader decodes it with the codec
|
|
330
|
+
* that reads the rest of the session; a decoded form would freeze one draft's
|
|
331
|
+
* rules into the format.
|
|
332
|
+
*
|
|
333
|
+
* Recorded from `'headers'` up. It is object metadata a payload's consumer
|
|
334
|
+
* reads before the payload — timestamps, frame marking, a codec
|
|
335
|
+
* configuration — and before this key no level recorded it, so a trace could
|
|
336
|
+
* not show what decided how its media was decoded.
|
|
337
|
+
*/
|
|
338
|
+
readonly properties?: Uint8Array
|
|
322
339
|
}
|
|
323
340
|
|
|
324
341
|
export interface ObjectPayloadEvent extends BaseEvent {
|