@proj-airi/trace-kit 0.0.0-stage → 0.15.3

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.
@@ -0,0 +1,601 @@
1
+ import { A as applyContentPolicy, j as conventionAttributes } from "../conventions-DLiC8rkL.mjs";
2
+ import { t as ClockGraph } from "../clock-hjTPEs21.mjs";
3
+ import { i as streamClockOf, r as spanKeyOf, t as epochKeyOf } from "../keys-DD_VE8Fk.mjs";
4
+ import { f as isTrackedRecord, l as traceRecordSchema, n as TraceSource } from "../sink-BoftsA-W.mjs";
5
+ import { n as SeriesRing, t as DEFAULT_SERIES_RETENTION } from "../series-ring-CaKfvyWQ.mjs";
6
+ import { safeParse } from "valibot";
7
+ import { DemandUnion, RetentionBuffer, SequenceTracker, Watermark, createCatchUpFeed, createCooldown, createLeaseTable } from "@proj-airi/stream-kit";
8
+ //#region src/collector/collector.ts
9
+ const DEFAULTS = {
10
+ clock: "mono:collector",
11
+ maxAgeMs: DEFAULT_SERIES_RETENTION.maxAgeMs,
12
+ maxBytes: 16777216,
13
+ seriesFullRateMs: DEFAULT_SERIES_RETENTION.fullRateMs,
14
+ allowedLatenessMs: 5e3,
15
+ heartbeatTimeoutMs: 6e4,
16
+ flightWindowMs: 6e4,
17
+ flightCooldownMs: 6e4,
18
+ flightMaxBytes: 2097152
19
+ };
20
+ /** Counts the JSON bytes of a buffered record. UTF-16 length undercounts CJK text up to three times. */
21
+ const utf8 = new TextEncoder();
22
+ /** The newest snapshots of each clock set in the pinned set. The drift fit uses this many. */
23
+ const PINNED_SNAPSHOTS_PER_CLOCK_SET = 16;
24
+ /**
25
+ * The most gaps that the collector keeps for late viewers. The buffer drops records oldest first, so the oldest gaps
26
+ * lie before every kept record, and merging them loses no kept record.
27
+ */
28
+ const MAX_GAPS = 64;
29
+ /**
30
+ * Receives the records of every source in one place, keeps recent records, and feeds viewers.
31
+ *
32
+ * State model:
33
+ * - Epochs: one {@link EpochState} for each `source/epoch`. A record with a seen seq is dropped.
34
+ * - Heartbeats: one lease for each `source/epoch`. Each new record and each `touch` renews it. A live epoch whose
35
+ * lease expires is lost.
36
+ * - Event buffer: every record except series, limited by age and bytes. Eviction records a gap.
37
+ * - Series rings: one {@link SeriesRing} for each `track` and value key of `sample` and `sample.chunk` records.
38
+ * - Pinned set: the latest `source.started` of each live epoch, live streams, recent clock snapshots and the
39
+ * discontinuities between them, open spans with merged updates, the latest state of each state track, and the latest
40
+ * definition of each kind and key. It stays outside the limits, so a late viewer and a dump can describe themselves.
41
+ * - Clock records: a reader picks the clock segment of a value from the snapshots and discontinuities that it applied
42
+ * before. So a backlog or a dump sends a pinned clock record only when it does not also send its buffered copy, and
43
+ * the clock history keeps its arrival order.
44
+ * - The collector is a source too. It writes `source.lost` with its own identity.
45
+ *
46
+ * The host calls {@link TraceCollector.tick} about once a second. It compacts series, evicts old records, checks
47
+ * heartbeats, and checks span deadlines. The collector reads no clock except `now`.
48
+ */
49
+ var TraceCollector = class {
50
+ traceClock;
51
+ clocks;
52
+ options;
53
+ self;
54
+ epochs = /* @__PURE__ */ new Map();
55
+ /** The live epoch of each source. */
56
+ liveEpochBySource = /* @__PURE__ */ new Map();
57
+ heartbeats;
58
+ buffer;
59
+ /** The groups that the buffer dropped in the current push or eviction. They become one gap. */
60
+ evicted = [];
61
+ gaps = [];
62
+ seriesRings = /* @__PURE__ */ new Map();
63
+ pinnedStarts = /* @__PURE__ */ new Map();
64
+ pinnedStreams = /* @__PURE__ */ new Map();
65
+ pinnedSnapshots = /* @__PURE__ */ new Map();
66
+ pinnedDiscontinuities = [];
67
+ arrivals = 0;
68
+ pinnedSpans = /* @__PURE__ */ new Map();
69
+ pinnedStates = /* @__PURE__ */ new Map();
70
+ pinnedDefinitions = /* @__PURE__ */ new Map();
71
+ attributeSensitivity = /* @__PURE__ */ new Map();
72
+ feed;
73
+ /** The tracks of each viewer, by subscription ID. */
74
+ demand;
75
+ nextSubscriptionId = 0;
76
+ /** The open spans that already triggered an overdue dump. A key goes when its span ends or its epoch ends. */
77
+ dumpedSpans = /* @__PURE__ */ new Set();
78
+ /** The flight recorder cooldown of each source. */
79
+ dumpCooldown;
80
+ lastDumpEnd = Number.NEGATIVE_INFINITY;
81
+ lateCount = 0;
82
+ droppedCount = 0;
83
+ /** Off: the collector forwards records to viewers but keeps only the pinned set and the flight window. */
84
+ buffering = true;
85
+ /**
86
+ * The records of the last flight window while buffering is off, for the flight recorder only. Viewers never read it,
87
+ * so a view that opens while buffering is off still has no history.
88
+ */
89
+ flightWindow;
90
+ constructor(options) {
91
+ this.options = options;
92
+ this.traceClock = options.clock ?? DEFAULTS.clock;
93
+ this.clocks = new ClockGraph(this.traceClock);
94
+ for (const attribute of conventionAttributes) this.attributeSensitivity.set(attribute.key, attribute.sensitivity);
95
+ this.heartbeats = createLeaseTable({ ttlMs: options.heartbeatTimeoutMs ?? DEFAULTS.heartbeatTimeoutMs });
96
+ this.buffer = new RetentionBuffer({
97
+ maxBytes: options.maxBytes ?? DEFAULTS.maxBytes,
98
+ maxAgeMs: options.maxAgeMs ?? DEFAULTS.maxAgeMs,
99
+ sizeOf: (entry) => entry.bytes,
100
+ ageOf: (entry) => entry.observedAt,
101
+ onEvict: (entries) => {
102
+ this.evicted.push(entries);
103
+ this.keepLinksOfEvictedUpdates(entries);
104
+ }
105
+ });
106
+ this.feed = createCatchUpFeed({
107
+ backlog: () => this.backlog(),
108
+ caughtUp: () => ({
109
+ kind: "caughtUp",
110
+ cursor: this.cursor(),
111
+ traceClock: this.traceClock,
112
+ now: this.options.now()
113
+ })
114
+ });
115
+ this.demand = new DemandUnion({ onChange: (union) => this.options.onWantedChange?.(union) });
116
+ this.dumpCooldown = createCooldown({ ms: options.flightRecorder?.cooldownMs ?? DEFAULTS.flightCooldownMs });
117
+ if (options.flightRecorder) this.flightWindow = new RetentionBuffer({
118
+ maxBytes: options.flightRecorder.maxBytes ?? DEFAULTS.flightMaxBytes,
119
+ maxAgeMs: options.flightRecorder.windowMs ?? DEFAULTS.flightWindowMs,
120
+ sizeOf: (entry) => entry.bytes,
121
+ ageOf: (entry) => entry.observedAt
122
+ });
123
+ this.self = new TraceSource({
124
+ source: "collector",
125
+ clock: this.traceClock,
126
+ runtime: "collector",
127
+ now: options.now,
128
+ createEpoch: options.createEpoch,
129
+ send: (record) => this.accept(record)
130
+ });
131
+ }
132
+ get stats() {
133
+ return {
134
+ bufferedRecords: this.buffer.size,
135
+ bufferedBytes: this.buffer.bytes,
136
+ openSpans: this.pinnedSpans.size,
137
+ seriesTracks: this.seriesRings.size,
138
+ late: this.lateCount,
139
+ dropped: this.droppedCount,
140
+ maxAgeMs: this.options.maxAgeMs ?? DEFAULTS.maxAgeMs,
141
+ maxBytes: this.options.maxBytes ?? DEFAULTS.maxBytes
142
+ };
143
+ }
144
+ /**
145
+ * Validates a record from a trust boundary, such as IPC from a window, then ingests it.
146
+ * Returns false and counts the record as dropped when it is not a valid record.
147
+ */
148
+ ingestUntrusted(value) {
149
+ const result = safeParse(traceRecordSchema, value);
150
+ if (!result.success) {
151
+ this.droppedCount++;
152
+ return false;
153
+ }
154
+ this.ingest(result.output);
155
+ return true;
156
+ }
157
+ /**
158
+ * Turns the event buffer and the series rings on or off. Off drops what they hold. Viewers still get live records,
159
+ * and the pinned set stays, so a viewer that opens later still knows the live sources and open spans. With a flight
160
+ * recorder, off keeps the last flight window of events for dumps only.
161
+ */
162
+ setBuffering(enabled) {
163
+ this.buffering = enabled;
164
+ this.flightWindow?.clear();
165
+ if (enabled) return;
166
+ this.buffer.clear();
167
+ this.gaps = [];
168
+ this.seriesRings.clear();
169
+ }
170
+ /** Writes a record of the collector itself, for example a discontinuity of its clock after a system resume. */
171
+ record(input) {
172
+ this.self.emit(input);
173
+ }
174
+ /** Ingests a record of a trusted producer in the same process. */
175
+ ingest(record) {
176
+ this.accept(record);
177
+ }
178
+ /** Records that a source is alive without a record, for example on a clock ping. */
179
+ touch(source) {
180
+ const epoch = this.liveEpochBySource.get(source);
181
+ if (epoch && this.epochs.has(epochKeyOf(source, epoch))) this.heartbeats.touch(epochKeyOf(source, epoch), this.options.now());
182
+ }
183
+ /** The transport of a source closed. Its live epoch is lost unless it stopped first. */
184
+ closeSource(source) {
185
+ this.loseSource(source, "ipc-closed");
186
+ }
187
+ /**
188
+ * Adds a viewer. It receives the pinned set, the gaps, the buffer, the series, `caughtUp`, and then each later
189
+ * record, in that order. Nothing arrives between the backlog and the first live record, because one call does both.
190
+ */
191
+ subscribe(listener, tracks = "*") {
192
+ const id = String(this.nextSubscriptionId++);
193
+ let wanted = tracks === "*" ? "*" : new Set(tracks);
194
+ const { unsubscribe } = this.feed.subscribe((message) => {
195
+ if (message.kind === "record" && !wantsRecord(wanted, message.record)) return;
196
+ if (message.kind === "series" && wanted !== "*" && !wanted.has(message.track)) return;
197
+ listener(message);
198
+ });
199
+ this.demand.set(id, wanted);
200
+ return {
201
+ unsubscribe: () => {
202
+ unsubscribe();
203
+ this.demand.delete(id);
204
+ },
205
+ setTracks: (next) => {
206
+ wanted = next === "*" ? "*" : new Set(next);
207
+ this.demand.set(id, wanted);
208
+ }
209
+ };
210
+ }
211
+ /**
212
+ * Runs the periodic work: series compaction, buffer eviction, heartbeat checks, and span deadline checks.
213
+ * The host calls it about once a second.
214
+ */
215
+ tick() {
216
+ const now = this.options.now();
217
+ for (const { ring } of this.seriesRings.values()) ring.compact(now);
218
+ this.buffer.evict(now);
219
+ this.flightWindow?.evict(now);
220
+ this.reportGap();
221
+ const expired = new Set(this.heartbeats.expired(now));
222
+ for (const [source, epoch] of this.liveEpochBySource) if (source !== this.self.source && expired.has(epochKeyOf(source, epoch))) this.loseSource(source, "heartbeat-timeout");
223
+ for (const [key, { begin }] of this.pinnedSpans) {
224
+ if (begin.deadlineMs === void 0) continue;
225
+ const start = this.timeOf(begin);
226
+ if (start !== void 0 && now - start > begin.deadlineMs) this.triggerDump(key, begin.source, "overdue", now);
227
+ }
228
+ }
229
+ /**
230
+ * The current state for a manual save: the pinned set and the buffer as records, and the series rings in the shape
231
+ * of the backlog. Samples go to the rings, not to the buffer, so the records alone do not hold them. A reader
232
+ * replays the save with `handleRecords(records)` and then `handle(message)` for each series message.
233
+ */
234
+ snapshot() {
235
+ const entries = [...this.buffer.values()];
236
+ return {
237
+ records: [...this.pinnedRecords(entries[0]?.order), ...entries.map((entry) => entry.record)],
238
+ series: [...this.seriesMessages()]
239
+ };
240
+ }
241
+ /** The sensitivity of an attribute key, from the conventions and the announced definitions. */
242
+ sensitivityOf = (key) => this.attributeSensitivity.get(key);
243
+ accept(incoming) {
244
+ const now = this.options.now();
245
+ const epochKey = epochKeyOf(incoming.source, incoming.epoch);
246
+ let state = this.epochs.get(epochKey);
247
+ if (incoming.type === "source.started" && !state) {
248
+ const previous = this.liveEpochBySource.get(incoming.source);
249
+ if (previous && previous !== incoming.epoch) this.loseSource(incoming.source, "epoch-replaced");
250
+ state = this.createEpoch(incoming, now);
251
+ this.liveEpochBySource.set(incoming.source, incoming.epoch);
252
+ }
253
+ if (!state) {
254
+ state = this.createEpoch(incoming, now);
255
+ if (!this.liveEpochBySource.has(incoming.source)) this.liveEpochBySource.set(incoming.source, incoming.epoch);
256
+ }
257
+ if (!state.seqs.accept(incoming.seq)) return;
258
+ this.heartbeats.touch(epochKey, now);
259
+ const record = applyContentPolicy({
260
+ ...incoming,
261
+ observedTs: now
262
+ }, {
263
+ keepContent: true,
264
+ sensitivityOf: this.sensitivityOf
265
+ });
266
+ this.updateClocks(record);
267
+ this.updateDefinitions(record);
268
+ const time = this.timeOf(record) ?? now;
269
+ const { late } = state.watermark.observe(time);
270
+ if (late) this.lateCount++;
271
+ const order = this.arrivals++;
272
+ this.updatePinned(record, time, order);
273
+ if (record.type === "source.stopped") {
274
+ state.live = false;
275
+ if (this.liveEpochBySource.get(record.source) === record.epoch) this.liveEpochBySource.delete(record.source);
276
+ this.heartbeats.delete(epochKey);
277
+ this.unpinEpoch(record.source, record.epoch);
278
+ }
279
+ const isSample = record.type === "sample" || record.type === "sample.chunk";
280
+ if (!this.buffering) {
281
+ if (!isSample && this.flightWindow) {
282
+ this.flightWindow.push({
283
+ record,
284
+ order,
285
+ time,
286
+ observedAt: now,
287
+ bytes: utf8.encode(JSON.stringify(record)).byteLength
288
+ });
289
+ this.flightWindow.evict(now);
290
+ }
291
+ } else if (isSample) this.appendSeries(record, time);
292
+ else {
293
+ const bytes = utf8.encode(JSON.stringify(record)).byteLength;
294
+ const overLimit = this.buffer.bytes + bytes > (this.options.maxBytes ?? DEFAULTS.maxBytes);
295
+ this.buffer.push({
296
+ record,
297
+ order,
298
+ time,
299
+ observedAt: now,
300
+ bytes
301
+ });
302
+ if (overLimit) this.buffer.evict(now);
303
+ this.reportGap();
304
+ }
305
+ this.feed.publish({
306
+ kind: "record",
307
+ record,
308
+ ...late ? { late } : {}
309
+ });
310
+ if (record.type === "span.end" && record.status === "error") this.triggerDump(spanKeyOf(record.source, record.epoch, record.spanId), record.source, "error", now);
311
+ }
312
+ createEpoch(record, now) {
313
+ const state = {
314
+ source: record.source,
315
+ epoch: record.epoch,
316
+ seqs: new SequenceTracker(),
317
+ live: true,
318
+ watermark: new Watermark({ allowedLatenessMs: this.options.allowedLatenessMs ?? DEFAULTS.allowedLatenessMs })
319
+ };
320
+ const epochKey = epochKeyOf(record.source, record.epoch);
321
+ this.epochs.set(epochKey, state);
322
+ this.heartbeats.touch(epochKey, now);
323
+ return state;
324
+ }
325
+ /** The last seq of each `source/epoch`. */
326
+ cursor() {
327
+ const cursor = {};
328
+ for (const [key, state] of this.epochs) cursor[key] = state.seqs.nextSeq - 1;
329
+ return cursor;
330
+ }
331
+ /** The backlog of a new viewer: the pinned set, the gaps, the buffer, and the series. */
332
+ *backlog() {
333
+ for (const record of this.pinnedRecords(this.buffer.values().next().value?.order)) yield {
334
+ kind: "record",
335
+ record,
336
+ pinned: true
337
+ };
338
+ for (const gap of this.gaps) yield {
339
+ kind: "gap",
340
+ ...gap
341
+ };
342
+ for (const entry of this.buffer.values()) yield {
343
+ kind: "record",
344
+ record: entry.record
345
+ };
346
+ yield* this.seriesMessages();
347
+ }
348
+ /** One message for each series ring that has points. */
349
+ *seriesMessages() {
350
+ for (const { source, track, key, ring } of this.seriesRings.values()) {
351
+ const { times, values } = ring.read();
352
+ if (times.length > 0) yield {
353
+ kind: "series",
354
+ source,
355
+ track,
356
+ key,
357
+ times,
358
+ values
359
+ };
360
+ }
361
+ }
362
+ /** Converts the event time of a record to the trace clock. */
363
+ timeOf(record) {
364
+ return this.clocks.convert(record.clock, record.ts)?.value;
365
+ }
366
+ updateClocks(record) {
367
+ if (record.type === "clock.snapshot") this.clocks.addSnapshot(record.values, record.uncertaintyMs, record.attrs?.estimated === true);
368
+ else if (record.type === "clock.discontinuity") this.clocks.addDiscontinuity(record.of);
369
+ else if (record.type === "stream.started" && record.rate) this.clocks.setUnitsPerMs(streamClockOf(record.streamId), record.rate / 1e3);
370
+ }
371
+ updateDefinitions(record) {
372
+ if (record.type !== "definition") return;
373
+ this.pinnedDefinitions.set(`${record.kind}/${record.key}`, record);
374
+ if (record.kind === "attribute") {
375
+ const data = record.data;
376
+ if (data?.sensitivity) this.attributeSensitivity.set(record.key, data.sensitivity);
377
+ }
378
+ }
379
+ updatePinned(record, time, order) {
380
+ const spanKey = "spanId" in record && record.spanId ? spanKeyOf(record.source, record.epoch, record.spanId) : void 0;
381
+ switch (record.type) {
382
+ case "source.started":
383
+ this.pinnedStarts.set(epochKeyOf(record.source, record.epoch), record);
384
+ break;
385
+ case "stream.started":
386
+ this.pinnedStreams.set(record.streamId, record);
387
+ break;
388
+ case "stream.stopped":
389
+ this.pinnedStreams.delete(record.streamId);
390
+ break;
391
+ case "clock.snapshot": {
392
+ const key = record.values.map((value) => value.clock).sort().join("|");
393
+ const list = this.pinnedSnapshots.get(key) ?? [];
394
+ list.push({
395
+ record,
396
+ order
397
+ });
398
+ if (list.length > PINNED_SNAPSHOTS_PER_CLOCK_SET) {
399
+ list.shift();
400
+ this.prunePinnedDiscontinuities();
401
+ }
402
+ this.pinnedSnapshots.set(key, list);
403
+ break;
404
+ }
405
+ case "clock.discontinuity":
406
+ this.pinnedDiscontinuities.push({
407
+ record,
408
+ order
409
+ });
410
+ this.prunePinnedDiscontinuities();
411
+ break;
412
+ case "span.begin":
413
+ this.pinnedSpans.set(spanKey, {
414
+ begin: record,
415
+ updated: record
416
+ });
417
+ break;
418
+ case "span.update": {
419
+ const open = this.pinnedSpans.get(spanKey);
420
+ if (open) open.updated = {
421
+ ...open.updated,
422
+ attrs: {
423
+ ...open.updated.attrs,
424
+ ...record.attrs
425
+ }
426
+ };
427
+ break;
428
+ }
429
+ case "span.end":
430
+ this.pinnedSpans.delete(spanKey);
431
+ this.dumpedSpans.delete(spanKey);
432
+ break;
433
+ case "state": {
434
+ const current = this.pinnedStates.get(record.track);
435
+ if (!current || current.time <= time) this.pinnedStates.set(record.track, {
436
+ record,
437
+ time
438
+ });
439
+ break;
440
+ }
441
+ }
442
+ }
443
+ /**
444
+ * Moves the links of evicted updates into the pinned copy of their open span. The pinned copy merges the attributes
445
+ * of each update on arrival. Links append, so they move only when the update leaves the buffer, and a late viewer
446
+ * does not get a link twice.
447
+ */
448
+ keepLinksOfEvictedUpdates(entries) {
449
+ for (const { record } of entries) {
450
+ if (record.type !== "span.update" || !record.links?.length) continue;
451
+ const open = this.pinnedSpans.get(spanKeyOf(record.source, record.epoch, record.spanId));
452
+ if (open) open.updated = {
453
+ ...open.updated,
454
+ links: [...open.updated.links ?? [], ...record.links]
455
+ };
456
+ }
457
+ }
458
+ /** A discontinuity matters only after a pinned snapshot of its clock. A reader has no earlier segment otherwise. */
459
+ prunePinnedDiscontinuities() {
460
+ this.pinnedDiscontinuities = this.pinnedDiscontinuities.filter(({ record, order }) => {
461
+ for (const list of this.pinnedSnapshots.values()) if (list[0] && list[0].order < order && list[0].record.values.some((value) => value.clock === record.of)) return true;
462
+ return false;
463
+ });
464
+ }
465
+ unpinEpoch(source, epoch) {
466
+ this.pinnedStarts.delete(epochKeyOf(source, epoch));
467
+ for (const [key, { begin }] of this.pinnedSpans) if (begin.source === source && begin.epoch === epoch) {
468
+ this.pinnedSpans.delete(key);
469
+ this.dumpedSpans.delete(key);
470
+ }
471
+ for (const [track, { record }] of this.pinnedStates) if (record.source === source && record.epoch === epoch) this.pinnedStates.delete(track);
472
+ for (const [streamId, record] of this.pinnedStreams) if (record.source === source && record.epoch === epoch) this.pinnedStreams.delete(streamId);
473
+ }
474
+ loseSource(source, reason) {
475
+ const epoch = this.liveEpochBySource.get(source);
476
+ if (!epoch || source === this.self.source) return;
477
+ const state = this.epochs.get(epochKeyOf(source, epoch));
478
+ this.liveEpochBySource.delete(source);
479
+ if (state) state.live = false;
480
+ this.heartbeats.delete(epochKeyOf(source, epoch));
481
+ this.unpinEpoch(source, epoch);
482
+ this.self.emit({
483
+ type: "source.lost",
484
+ ts: this.options.now(),
485
+ of: {
486
+ source,
487
+ epoch,
488
+ lastSeq: (state?.seqs.nextSeq ?? 0) - 1
489
+ },
490
+ reason
491
+ });
492
+ }
493
+ appendSeries(record, time) {
494
+ const maxAgeMs = this.options.maxAgeMs ?? DEFAULTS.maxAgeMs;
495
+ const fullRateMs = this.options.seriesFullRateMs ?? DEFAULTS.seriesFullRateMs;
496
+ const ringOf = (key) => {
497
+ const ringKey = `${record.source}/${record.track}/${key}`;
498
+ let entry = this.seriesRings.get(ringKey);
499
+ if (!entry) {
500
+ entry = {
501
+ source: record.source,
502
+ track: record.track,
503
+ key,
504
+ ring: new SeriesRing(maxAgeMs, fullRateMs)
505
+ };
506
+ this.seriesRings.set(ringKey, entry);
507
+ }
508
+ return entry.ring;
509
+ };
510
+ if (record.type === "sample") {
511
+ for (const [key, value] of Object.entries(record.values)) ringOf(key).append(time, value);
512
+ return;
513
+ }
514
+ for (const [key, values] of Object.entries(record.values)) {
515
+ const ring = ringOf(key);
516
+ for (const [index, value] of values.entries()) {
517
+ const offset = record.offsetsMs?.[index] ?? index * (record.intervalMs ?? 0);
518
+ ring.append(time + offset, value);
519
+ }
520
+ }
521
+ }
522
+ /** Turns the records that the buffer dropped since the last call into one gap, and tells the viewers. */
523
+ reportGap() {
524
+ if (this.evicted.length === 0) return;
525
+ let from = Number.POSITIVE_INFINITY;
526
+ let to = Number.NEGATIVE_INFINITY;
527
+ for (const group of this.evicted) for (const entry of group) {
528
+ from = Math.min(from, entry.time);
529
+ to = Math.max(to, entry.time);
530
+ }
531
+ this.evicted = [];
532
+ const last = this.gaps.at(-1);
533
+ if (last && from <= last.to) last.to = Math.max(last.to, to);
534
+ else this.gaps.push({
535
+ from,
536
+ to
537
+ });
538
+ if (this.gaps.length > MAX_GAPS) {
539
+ const [oldest, next] = this.gaps.splice(0, 2);
540
+ this.gaps.unshift({
541
+ from: Math.min(oldest.from, next.from),
542
+ to: Math.max(oldest.to, next.to)
543
+ });
544
+ }
545
+ this.feed.publish({
546
+ kind: "gap",
547
+ from,
548
+ to
549
+ });
550
+ }
551
+ /**
552
+ * Starts a flight recorder dump. Limits: an open span triggers one overdue dump, each source has a cooldown, and a
553
+ * window that overlaps the previous dump appends to it. An error comes from the one end of a span, so it needs no
554
+ * guard, and the guard does not keep the key of an ended span.
555
+ */
556
+ triggerDump(spanKey, source, reason, now) {
557
+ const recorder = this.options.flightRecorder;
558
+ if (!recorder) return;
559
+ if (reason === "overdue") {
560
+ if (this.dumpedSpans.has(spanKey)) return;
561
+ this.dumpedSpans.add(spanKey);
562
+ }
563
+ if (!this.dumpCooldown.tryAcquire(source, now)) return;
564
+ const from = now - (recorder.windowMs ?? DEFAULTS.flightWindowMs);
565
+ const append = from < this.lastDumpEnd;
566
+ const start = append ? this.lastDumpEnd : from;
567
+ const entries = [];
568
+ for (const entry of (this.buffering ? this.buffer : this.flightWindow ?? this.buffer).values()) if (entry.observedAt > start) entries.push(entry);
569
+ const records = entries.map((entry) => entry.record);
570
+ this.lastDumpEnd = now;
571
+ recorder.onDump({
572
+ records: append ? records : [...this.pinnedRecords(entries[0]?.order), ...records],
573
+ append,
574
+ reason,
575
+ from: start,
576
+ to: now
577
+ });
578
+ }
579
+ /**
580
+ * The pinned set. `sentFrom` is the arrival order of the first buffered record that the caller also sends. A clock
581
+ * record from that order on is left out, so the reader applies it once, at its place among the buffered records.
582
+ */
583
+ pinnedRecords(sentFrom = Number.POSITIVE_INFINITY) {
584
+ const clockRecords = [...this.pinnedSnapshots.values(), this.pinnedDiscontinuities].flat();
585
+ return [
586
+ ...this.pinnedStarts.values(),
587
+ ...this.pinnedDefinitions.values(),
588
+ ...this.pinnedStreams.values(),
589
+ ...clockRecords.filter((entry) => entry.order < sentFrom).sort((a, b) => a.order - b.order).map((entry) => entry.record),
590
+ ...[...this.pinnedSpans.values()].map((span) => span.updated),
591
+ ...[...this.pinnedStates.values()].map((state) => state.record)
592
+ ];
593
+ }
594
+ };
595
+ function wantsRecord(tracks, record) {
596
+ if (tracks === "*" || !isTrackedRecord(record)) return true;
597
+ if (record.type !== "sample" && record.type !== "sample.chunk") return true;
598
+ return tracks.has(record.track);
599
+ }
600
+ //#endregion
601
+ export { DEFAULT_SERIES_RETENTION, SeriesRing, TraceCollector };