@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.
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/dist/clock-CewQJzn8.d.mts +545 -0
- package/dist/clock-hjTPEs21.mjs +212 -0
- package/dist/collector/index.d.mts +207 -0
- package/dist/collector/index.mjs +601 -0
- package/dist/conventions-DLiC8rkL.mjs +285 -0
- package/dist/index-DV04qeax.d.mts +49 -0
- package/dist/index.d.mts +782 -0
- package/dist/index.mjs +6 -0
- package/dist/keys-DD_VE8Fk.mjs +34 -0
- package/dist/profiles/agent.d.mts +70 -0
- package/dist/profiles/agent.mjs +295 -0
- package/dist/profiles/voice.d.mts +128 -0
- package/dist/profiles/voice.mjs +669 -0
- package/dist/projections/index.d.mts +3 -0
- package/dist/projections/index.mjs +1 -0
- package/dist/semconv-BOVWnou1.d.mts +832 -0
- package/dist/semconv.d.mts +2 -0
- package/dist/semconv.mjs +832 -0
- package/dist/series-ring-CaKfvyWQ.mjs +118 -0
- package/dist/series-ring-DCb7czkl.d.mts +49 -0
- package/dist/sink-BoftsA-W.mjs +501 -0
- package/dist/testing/index.d.mts +79 -0
- package/dist/testing/index.mjs +880 -0
- package/dist/timeline-B4Vc6qQf.mjs +766 -0
- package/dist/types-Fp54Yi6b.d.mts +319 -0
- package/dist/views/index.d.mts +97 -0
- package/dist/views/index.mjs +219 -0
- package/package.json +71 -3
|
@@ -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 };
|