@autter/otlp-ingester 1.0.0 → 1.2.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 +23 -2
- package/dist/clickhouse.d.ts +2 -1
- package/dist/clickhouse.js +62 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.js +8 -0
- package/dist/fingerprint.d.ts +22 -0
- package/dist/fingerprint.js +216 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -1
- package/dist/llm-pricing.d.ts +20 -0
- package/dist/llm-pricing.js +120 -0
- package/dist/llm.d.ts +20 -0
- package/dist/llm.js +131 -0
- package/dist/migrations.js +37 -0
- package/dist/normalize-browser.js +41 -9
- package/dist/normalize-otlp.d.ts +3 -1
- package/dist/normalize-otlp.js +79 -5
- package/dist/otlp-proto.js +1 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.js +42 -43
- package/dist/sink.d.ts +103 -0
- package/dist/sink.js +314 -0
- package/dist/stack-fixtures.d.ts +25 -0
- package/dist/stack-fixtures.js +231 -0
- package/dist/types.d.ts +33 -0
- package/package.json +3 -2
package/dist/sink.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { IngesterConfig } from "./config.js";
|
|
2
|
+
import type { IngestContext, RuntimeLlmCall, RuntimeMetricPoint, RuntimeOccurrence } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* At-least-once delivery to the sink webhook.
|
|
5
|
+
*
|
|
6
|
+
* The sink feeds the consumer's issue grouping and incident detection, so a
|
|
7
|
+
* lost batch means silently missing error occurrences — a single
|
|
8
|
+
* fire-and-forget POST is not enough (a routine consumer deploy is longer
|
|
9
|
+
* than one request timeout). Batches therefore queue in memory and retry
|
|
10
|
+
* with exponential backoff until delivered, permanently rejected, or the
|
|
11
|
+
* bounded buffer overflows.
|
|
12
|
+
*
|
|
13
|
+
* Durability boundary: the queue is in-memory only. Everything forwarded
|
|
14
|
+
* here was already written to ClickHouse (ingest 503s otherwise), so after
|
|
15
|
+
* a process crash or an overflow/permanent drop the consumer recovers by
|
|
16
|
+
* replaying the logged time range from ClickHouse — see
|
|
17
|
+
* docs/ARCHITECTURE.md "Sink webhook". Every batch carries a unique
|
|
18
|
+
* `batchId` so consumers can deduplicate retried deliveries.
|
|
19
|
+
*/
|
|
20
|
+
/** Delivery timing/concurrency knobs; overridable for tests. */
|
|
21
|
+
export interface SinkTuning {
|
|
22
|
+
/** Base delay before the second attempt; doubles per attempt to the cap. */
|
|
23
|
+
retryBaseMs: number;
|
|
24
|
+
retryCapMs: number;
|
|
25
|
+
requestTimeoutMs: number;
|
|
26
|
+
/** Deliveries run concurrently while healthy, serially while failing. */
|
|
27
|
+
healthyConcurrency: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Operational counters for /healthz. Failure detail is reduced to a fixed
|
|
31
|
+
* category (`timeout`, `connection_error`, `http_<status>`, `error`) — the
|
|
32
|
+
* health endpoint is unauthenticated, so raw transport/exception text stays
|
|
33
|
+
* in server-side logs only.
|
|
34
|
+
*/
|
|
35
|
+
export interface SinkStats {
|
|
36
|
+
queued: number;
|
|
37
|
+
queuedBytes: number;
|
|
38
|
+
inFlight: number;
|
|
39
|
+
delivered: number;
|
|
40
|
+
retried: number;
|
|
41
|
+
droppedOverflow: number;
|
|
42
|
+
droppedPermanent: number;
|
|
43
|
+
consecutiveFailures: number;
|
|
44
|
+
lastFailureAt: string | null;
|
|
45
|
+
lastFailureReason: string | null;
|
|
46
|
+
oldestQueuedSince: string | null;
|
|
47
|
+
}
|
|
48
|
+
export declare class SinkForwarder {
|
|
49
|
+
private readonly config;
|
|
50
|
+
private readonly fetchImpl;
|
|
51
|
+
/** Always sorted by `seq`: queue[0] is the oldest batch. */
|
|
52
|
+
private readonly queue;
|
|
53
|
+
private queuedBytes;
|
|
54
|
+
/** Per-org share of queuedBytes — overflow evicts from the heaviest org. */
|
|
55
|
+
private readonly queuedBytesByOrg;
|
|
56
|
+
private inFlight;
|
|
57
|
+
private timer;
|
|
58
|
+
private stopped;
|
|
59
|
+
private seqCounter;
|
|
60
|
+
private delivered;
|
|
61
|
+
private retried;
|
|
62
|
+
private droppedOverflow;
|
|
63
|
+
private droppedPermanent;
|
|
64
|
+
private consecutiveFailures;
|
|
65
|
+
/**
|
|
66
|
+
* Bumped on every failure. A success only clears consecutiveFailures if
|
|
67
|
+
* no failure happened after that request STARTED — a concurrent success
|
|
68
|
+
* that overlapped a failure proves nothing about current sink health and
|
|
69
|
+
* must not reopen full concurrency mid-outage.
|
|
70
|
+
*/
|
|
71
|
+
private failureEpoch;
|
|
72
|
+
private lastFailureAt;
|
|
73
|
+
private lastFailureReason;
|
|
74
|
+
private readonly tuning;
|
|
75
|
+
constructor(config: IngesterConfig, fetchImpl?: typeof fetch, tuning?: Partial<SinkTuning>);
|
|
76
|
+
/** Queue a batch for delivery. No-op when there is nothing to send. */
|
|
77
|
+
enqueue(ctx: IngestContext, occurrences: RuntimeOccurrence[], metricPoints?: RuntimeMetricPoint[], llmCalls?: RuntimeLlmCall[]): void;
|
|
78
|
+
stats(): SinkStats;
|
|
79
|
+
pendingCount(): number;
|
|
80
|
+
/** Stop scheduling new deliveries (shutdown). Queued batches are logged
|
|
81
|
+
* by the caller — they are recoverable from ClickHouse, not from here. */
|
|
82
|
+
stop(): void;
|
|
83
|
+
/** Serialize one delivery payload; `seq` pins its age for ordering. */
|
|
84
|
+
private buildBatch;
|
|
85
|
+
private maxBufferedBytes;
|
|
86
|
+
private track;
|
|
87
|
+
private untrack;
|
|
88
|
+
/** Re-insert a retrying batch at its age position (queue is seq-sorted),
|
|
89
|
+
* so overflow eviction still drops the genuinely oldest signals first. */
|
|
90
|
+
private insertBySeq;
|
|
91
|
+
/**
|
|
92
|
+
* Oldest-first eviction, charged to the heaviest tenant: the freshest
|
|
93
|
+
* signals matter most to grouping, and one org flooding the shared
|
|
94
|
+
* buffer must not evict everyone else's batches.
|
|
95
|
+
*/
|
|
96
|
+
private enforceBounds;
|
|
97
|
+
/** The oldest batch of the org holding the most buffered bytes. */
|
|
98
|
+
private evictOne;
|
|
99
|
+
private currentLimit;
|
|
100
|
+
private pump;
|
|
101
|
+
private scheduleWake;
|
|
102
|
+
private send;
|
|
103
|
+
}
|
package/dist/sink.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const DEFAULT_TUNING = {
|
|
3
|
+
retryBaseMs: 1000,
|
|
4
|
+
retryCapMs: 60_000,
|
|
5
|
+
requestTimeoutMs: 10_000,
|
|
6
|
+
healthyConcurrency: 4,
|
|
7
|
+
};
|
|
8
|
+
/** Fixed failure category — safe for the unauthenticated health response. */
|
|
9
|
+
function failureReason(err) {
|
|
10
|
+
if (err instanceof Error) {
|
|
11
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
12
|
+
return "timeout";
|
|
13
|
+
}
|
|
14
|
+
// fetch surfaces DNS/TLS/socket failures as TypeError.
|
|
15
|
+
if (err instanceof TypeError)
|
|
16
|
+
return "connection_error";
|
|
17
|
+
}
|
|
18
|
+
return "error";
|
|
19
|
+
}
|
|
20
|
+
export class SinkForwarder {
|
|
21
|
+
config;
|
|
22
|
+
fetchImpl;
|
|
23
|
+
/** Always sorted by `seq`: queue[0] is the oldest batch. */
|
|
24
|
+
queue = [];
|
|
25
|
+
queuedBytes = 0;
|
|
26
|
+
/** Per-org share of queuedBytes — overflow evicts from the heaviest org. */
|
|
27
|
+
queuedBytesByOrg = new Map();
|
|
28
|
+
inFlight = 0;
|
|
29
|
+
timer = null;
|
|
30
|
+
stopped = false;
|
|
31
|
+
seqCounter = 0;
|
|
32
|
+
delivered = 0;
|
|
33
|
+
retried = 0;
|
|
34
|
+
droppedOverflow = 0;
|
|
35
|
+
droppedPermanent = 0;
|
|
36
|
+
consecutiveFailures = 0;
|
|
37
|
+
/**
|
|
38
|
+
* Bumped on every failure. A success only clears consecutiveFailures if
|
|
39
|
+
* no failure happened after that request STARTED — a concurrent success
|
|
40
|
+
* that overlapped a failure proves nothing about current sink health and
|
|
41
|
+
* must not reopen full concurrency mid-outage.
|
|
42
|
+
*/
|
|
43
|
+
failureEpoch = 0;
|
|
44
|
+
lastFailureAt = null;
|
|
45
|
+
lastFailureReason = null;
|
|
46
|
+
tuning;
|
|
47
|
+
constructor(config, fetchImpl = fetch, tuning = {}) {
|
|
48
|
+
this.config = config;
|
|
49
|
+
this.fetchImpl = fetchImpl;
|
|
50
|
+
this.tuning = { ...DEFAULT_TUNING, ...tuning };
|
|
51
|
+
}
|
|
52
|
+
/** Queue a batch for delivery. No-op when there is nothing to send. */
|
|
53
|
+
enqueue(ctx, occurrences, metricPoints = [], llmCalls = []) {
|
|
54
|
+
if (!this.config.sinkUrl || this.stopped)
|
|
55
|
+
return;
|
|
56
|
+
if (occurrences.length === 0 &&
|
|
57
|
+
metricPoints.length === 0 &&
|
|
58
|
+
llmCalls.length === 0) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const batch = this.buildBatch(ctx, occurrences, metricPoints, llmCalls);
|
|
62
|
+
// A batch bigger than the whole buffer could never be admitted without
|
|
63
|
+
// evicting everyone else — and the consumer's body cap would reject it
|
|
64
|
+
// anyway. Drop it alone instead of letting it flush the queue.
|
|
65
|
+
if (batch.bytes > this.maxBufferedBytes()) {
|
|
66
|
+
this.droppedOverflow += 1;
|
|
67
|
+
console.warn(`sink batch ${batch.batchId} (org ${batch.orgId}) exceeds the buffer cap (${batch.bytes} bytes) — dropped; replay signals ${batch.signalsFrom ?? "?"} .. ${batch.signalsTo ?? "?"} from ClickHouse`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.queue.push(batch);
|
|
71
|
+
this.track(batch);
|
|
72
|
+
this.enforceBounds();
|
|
73
|
+
this.pump();
|
|
74
|
+
}
|
|
75
|
+
stats() {
|
|
76
|
+
const oldest = this.queue[0]?.enqueuedAt ?? null;
|
|
77
|
+
return {
|
|
78
|
+
queued: this.queue.length,
|
|
79
|
+
queuedBytes: this.queuedBytes,
|
|
80
|
+
inFlight: this.inFlight,
|
|
81
|
+
delivered: this.delivered,
|
|
82
|
+
retried: this.retried,
|
|
83
|
+
droppedOverflow: this.droppedOverflow,
|
|
84
|
+
droppedPermanent: this.droppedPermanent,
|
|
85
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
86
|
+
lastFailureAt: this.lastFailureAt,
|
|
87
|
+
lastFailureReason: this.lastFailureReason,
|
|
88
|
+
oldestQueuedSince: oldest ? new Date(oldest).toISOString() : null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
pendingCount() {
|
|
92
|
+
return this.queue.length + this.inFlight;
|
|
93
|
+
}
|
|
94
|
+
/** Stop scheduling new deliveries (shutdown). Queued batches are logged
|
|
95
|
+
* by the caller — they are recoverable from ClickHouse, not from here. */
|
|
96
|
+
stop() {
|
|
97
|
+
this.stopped = true;
|
|
98
|
+
if (this.timer) {
|
|
99
|
+
clearTimeout(this.timer);
|
|
100
|
+
this.timer = null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Serialize one delivery payload; `seq` pins its age for ordering. */
|
|
104
|
+
buildBatch(ctx, occurrences, metricPoints, llmCalls) {
|
|
105
|
+
const batchId = randomUUID();
|
|
106
|
+
const body = JSON.stringify({
|
|
107
|
+
version: 1,
|
|
108
|
+
batchId,
|
|
109
|
+
orgId: ctx.orgId,
|
|
110
|
+
repositoryId: ctx.repositoryId,
|
|
111
|
+
occurrences: occurrences.map((o) => ({
|
|
112
|
+
...o,
|
|
113
|
+
occurredAt: o.occurredAt.toISOString(),
|
|
114
|
+
})),
|
|
115
|
+
metrics: metricPoints.map((p) => ({
|
|
116
|
+
...p,
|
|
117
|
+
bucketAt: p.bucketAt.toISOString(),
|
|
118
|
+
})),
|
|
119
|
+
llmCalls: llmCalls.map((c) => ({
|
|
120
|
+
...c,
|
|
121
|
+
startedAt: c.startedAt.toISOString(),
|
|
122
|
+
})),
|
|
123
|
+
});
|
|
124
|
+
const range = signalRange(occurrences, metricPoints, llmCalls);
|
|
125
|
+
return {
|
|
126
|
+
batchId,
|
|
127
|
+
orgId: ctx.orgId,
|
|
128
|
+
body,
|
|
129
|
+
bytes: Buffer.byteLength(body),
|
|
130
|
+
seq: ++this.seqCounter,
|
|
131
|
+
attempts: 0,
|
|
132
|
+
nextAttemptAt: Date.now(),
|
|
133
|
+
signalsFrom: range.from,
|
|
134
|
+
signalsTo: range.to,
|
|
135
|
+
enqueuedAt: Date.now(),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
maxBufferedBytes() {
|
|
139
|
+
return this.config.sinkMaxBufferedMb * 1024 * 1024;
|
|
140
|
+
}
|
|
141
|
+
track(batch) {
|
|
142
|
+
this.queuedBytes += batch.bytes;
|
|
143
|
+
this.queuedBytesByOrg.set(batch.orgId, (this.queuedBytesByOrg.get(batch.orgId) ?? 0) + batch.bytes);
|
|
144
|
+
}
|
|
145
|
+
untrack(batch) {
|
|
146
|
+
this.queuedBytes -= batch.bytes;
|
|
147
|
+
const left = (this.queuedBytesByOrg.get(batch.orgId) ?? 0) - batch.bytes;
|
|
148
|
+
if (left > 0)
|
|
149
|
+
this.queuedBytesByOrg.set(batch.orgId, left);
|
|
150
|
+
else
|
|
151
|
+
this.queuedBytesByOrg.delete(batch.orgId);
|
|
152
|
+
}
|
|
153
|
+
/** Re-insert a retrying batch at its age position (queue is seq-sorted),
|
|
154
|
+
* so overflow eviction still drops the genuinely oldest signals first. */
|
|
155
|
+
insertBySeq(batch) {
|
|
156
|
+
const at = this.queue.findIndex((b) => b.seq > batch.seq);
|
|
157
|
+
if (at === -1)
|
|
158
|
+
this.queue.push(batch);
|
|
159
|
+
else
|
|
160
|
+
this.queue.splice(at, 0, batch);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Oldest-first eviction, charged to the heaviest tenant: the freshest
|
|
164
|
+
* signals matter most to grouping, and one org flooding the shared
|
|
165
|
+
* buffer must not evict everyone else's batches.
|
|
166
|
+
*/
|
|
167
|
+
enforceBounds() {
|
|
168
|
+
const maxBytes = this.maxBufferedBytes();
|
|
169
|
+
while (this.queue.length > this.config.sinkMaxBufferedBatches ||
|
|
170
|
+
(this.queuedBytes > maxBytes && this.queue.length > 1)) {
|
|
171
|
+
const dropped = this.evictOne();
|
|
172
|
+
if (!dropped)
|
|
173
|
+
break;
|
|
174
|
+
this.droppedOverflow += 1;
|
|
175
|
+
console.warn(`sink buffer overflow: dropped batch ${dropped.batchId} (org ${dropped.orgId}, signals ${dropped.signalsFrom ?? "?"} .. ${dropped.signalsTo ?? "?"}) — replay this range from ClickHouse`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** The oldest batch of the org holding the most buffered bytes. */
|
|
179
|
+
evictOne() {
|
|
180
|
+
let heaviest = null;
|
|
181
|
+
let heaviestBytes = -1;
|
|
182
|
+
for (const [orgId, bytes] of this.queuedBytesByOrg) {
|
|
183
|
+
if (bytes > heaviestBytes) {
|
|
184
|
+
heaviest = orgId;
|
|
185
|
+
heaviestBytes = bytes;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const at = heaviest
|
|
189
|
+
? this.queue.findIndex((b) => b.orgId === heaviest)
|
|
190
|
+
: 0;
|
|
191
|
+
const [dropped] = this.queue.splice(at === -1 ? 0 : at, 1);
|
|
192
|
+
if (!dropped)
|
|
193
|
+
return null;
|
|
194
|
+
this.untrack(dropped);
|
|
195
|
+
return dropped;
|
|
196
|
+
}
|
|
197
|
+
currentLimit() {
|
|
198
|
+
return this.consecutiveFailures > 0 ? 1 : this.tuning.healthyConcurrency;
|
|
199
|
+
}
|
|
200
|
+
pump() {
|
|
201
|
+
if (this.stopped || !this.config.sinkUrl)
|
|
202
|
+
return;
|
|
203
|
+
const limit = this.currentLimit();
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
while (this.inFlight < limit) {
|
|
206
|
+
const index = this.queue.findIndex((b) => b.nextAttemptAt <= now);
|
|
207
|
+
if (index === -1)
|
|
208
|
+
break;
|
|
209
|
+
const [batch] = this.queue.splice(index, 1);
|
|
210
|
+
if (!batch)
|
|
211
|
+
break;
|
|
212
|
+
this.untrack(batch);
|
|
213
|
+
this.inFlight += 1;
|
|
214
|
+
void this.send(batch).finally(() => {
|
|
215
|
+
this.inFlight -= 1;
|
|
216
|
+
this.pump();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
this.scheduleWake();
|
|
220
|
+
}
|
|
221
|
+
scheduleWake() {
|
|
222
|
+
if (this.timer) {
|
|
223
|
+
clearTimeout(this.timer);
|
|
224
|
+
this.timer = null;
|
|
225
|
+
}
|
|
226
|
+
if (this.stopped || this.queue.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
// At capacity there is nothing to wake for: every completion pumps
|
|
229
|
+
// again anyway, and a 0 ms timer here would spin the event loop while
|
|
230
|
+
// a ready batch waits on a slow in-flight request.
|
|
231
|
+
if (this.inFlight >= this.currentLimit())
|
|
232
|
+
return;
|
|
233
|
+
const next = Math.min(...this.queue.map((b) => b.nextAttemptAt));
|
|
234
|
+
this.timer = setTimeout(() => {
|
|
235
|
+
this.timer = null;
|
|
236
|
+
this.pump();
|
|
237
|
+
}, Math.max(0, next - Date.now()));
|
|
238
|
+
this.timer.unref();
|
|
239
|
+
}
|
|
240
|
+
async send(batch) {
|
|
241
|
+
batch.attempts += 1;
|
|
242
|
+
const epochAtStart = this.failureEpoch;
|
|
243
|
+
let reason = null;
|
|
244
|
+
/** Raw failure text — logged server-side, never surfaced in stats. */
|
|
245
|
+
let detail = null;
|
|
246
|
+
let permanent = false;
|
|
247
|
+
try {
|
|
248
|
+
const res = await this.fetchImpl(this.config.sinkUrl, {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: {
|
|
251
|
+
"content-type": "application/json",
|
|
252
|
+
...(this.config.sinkToken
|
|
253
|
+
? { authorization: `Bearer ${this.config.sinkToken}` }
|
|
254
|
+
: {}),
|
|
255
|
+
},
|
|
256
|
+
body: batch.body,
|
|
257
|
+
signal: AbortSignal.timeout(this.tuning.requestTimeoutMs),
|
|
258
|
+
});
|
|
259
|
+
// Drain the body so keep-alive sockets are reusable; its content is
|
|
260
|
+
// irrelevant to delivery.
|
|
261
|
+
await res.text().catch(() => { });
|
|
262
|
+
if (res.ok) {
|
|
263
|
+
this.delivered += 1;
|
|
264
|
+
if (epochAtStart === this.failureEpoch) {
|
|
265
|
+
this.consecutiveFailures = 0;
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
reason = `http_${res.status}`;
|
|
270
|
+
detail = `sink responded ${res.status}`;
|
|
271
|
+
// 4xx (except timeout/rate-limit) means the consumer rejected the
|
|
272
|
+
// batch — retrying the same body cannot succeed.
|
|
273
|
+
permanent = res.status < 500 && res.status !== 408 && res.status !== 429;
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
reason = failureReason(err);
|
|
277
|
+
detail = err instanceof Error ? err.message : String(err);
|
|
278
|
+
}
|
|
279
|
+
this.failureEpoch += 1;
|
|
280
|
+
this.consecutiveFailures += 1;
|
|
281
|
+
this.lastFailureAt = new Date().toISOString();
|
|
282
|
+
this.lastFailureReason = reason;
|
|
283
|
+
if (permanent || batch.attempts >= this.config.sinkMaxAttempts) {
|
|
284
|
+
this.droppedPermanent += 1;
|
|
285
|
+
console.error(`sink delivery gave up after ${batch.attempts} attempt(s) (${detail}): batch ${batch.batchId} (signals ${batch.signalsFrom ?? "?"} .. ${batch.signalsTo ?? "?"}) — replay this range from ClickHouse`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
this.retried += 1;
|
|
289
|
+
// Full jitter avoids retry stampedes when the consumer comes back.
|
|
290
|
+
const backoff = Math.min(this.tuning.retryCapMs, this.tuning.retryBaseMs * 2 ** (batch.attempts - 1));
|
|
291
|
+
batch.nextAttemptAt = Date.now() + backoff / 2 + Math.random() * (backoff / 2);
|
|
292
|
+
this.insertBySeq(batch);
|
|
293
|
+
this.track(batch);
|
|
294
|
+
if (batch.attempts === 1 || batch.attempts % 5 === 0) {
|
|
295
|
+
console.warn(`sink delivery failed (attempt ${batch.attempts}/${this.config.sinkMaxAttempts}, will retry): ${detail}`);
|
|
296
|
+
}
|
|
297
|
+
this.enforceBounds();
|
|
298
|
+
this.scheduleWake();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** ISO range across every signal in the batch — the replay hint on drops. */
|
|
302
|
+
function signalRange(occurrences, metricPoints, llmCalls) {
|
|
303
|
+
const timestamps = [
|
|
304
|
+
...occurrences.map((o) => o.occurredAt),
|
|
305
|
+
...metricPoints.map((p) => p.bucketAt),
|
|
306
|
+
...llmCalls.map((c) => c.startedAt),
|
|
307
|
+
].map((d) => d.getTime());
|
|
308
|
+
if (timestamps.length === 0)
|
|
309
|
+
return { from: null, to: null };
|
|
310
|
+
return {
|
|
311
|
+
from: new Date(Math.min(...timestamps)).toISOString(),
|
|
312
|
+
to: new Date(Math.max(...timestamps)).toISOString(),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Representative raw stack traces for every officially-supported runtime,
|
|
3
|
+
* exactly as their OpenTelemetry SDKs put them on the `exception.stacktrace`
|
|
4
|
+
* span attribute (or the browser relay's `stack` field). These drive the
|
|
5
|
+
* golden fingerprint tests in fingerprint.test.ts.
|
|
6
|
+
*
|
|
7
|
+
* Each language provides three variants of the SAME family of errors:
|
|
8
|
+
* - `primary` — the defect under test.
|
|
9
|
+
* - `sibling` — a DIFFERENT defect (different top function/file) that
|
|
10
|
+
* carries the SAME error message. It must fingerprint separately: this is
|
|
11
|
+
* the regression the pipeline exists to prevent (before per-language
|
|
12
|
+
* parsing, Go/Rust frames were dropped and these collided into one issue).
|
|
13
|
+
* - `redeploy` — the primary defect after a rebuild that shifted every line
|
|
14
|
+
* number (and, for Go, pointer offsets/addresses/goroutine ids). It must
|
|
15
|
+
* fingerprint IDENTICALLY to `primary`, proving grouping is stable across
|
|
16
|
+
* re-deploys and repeated ingestion.
|
|
17
|
+
*/
|
|
18
|
+
export interface StackFixture {
|
|
19
|
+
primary: string;
|
|
20
|
+
sibling: string;
|
|
21
|
+
redeploy: string;
|
|
22
|
+
/** Golden normalised top frames for `primary` (locks the parser output). */
|
|
23
|
+
primaryFrames: string[];
|
|
24
|
+
}
|
|
25
|
+
export declare const STACK_FIXTURES: Record<string, StackFixture>;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Representative raw stack traces for every officially-supported runtime,
|
|
3
|
+
* exactly as their OpenTelemetry SDKs put them on the `exception.stacktrace`
|
|
4
|
+
* span attribute (or the browser relay's `stack` field). These drive the
|
|
5
|
+
* golden fingerprint tests in fingerprint.test.ts.
|
|
6
|
+
*
|
|
7
|
+
* Each language provides three variants of the SAME family of errors:
|
|
8
|
+
* - `primary` — the defect under test.
|
|
9
|
+
* - `sibling` — a DIFFERENT defect (different top function/file) that
|
|
10
|
+
* carries the SAME error message. It must fingerprint separately: this is
|
|
11
|
+
* the regression the pipeline exists to prevent (before per-language
|
|
12
|
+
* parsing, Go/Rust frames were dropped and these collided into one issue).
|
|
13
|
+
* - `redeploy` — the primary defect after a rebuild that shifted every line
|
|
14
|
+
* number (and, for Go, pointer offsets/addresses/goroutine ids). It must
|
|
15
|
+
* fingerprint IDENTICALLY to `primary`, proving grouping is stable across
|
|
16
|
+
* re-deploys and repeated ingestion.
|
|
17
|
+
*/
|
|
18
|
+
// ── Go ──────────────────────────────────────────────────────────────────────
|
|
19
|
+
const GO_PRIMARY = `panic: runtime error: invalid memory address or nil pointer dereference
|
|
20
|
+
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x1a2b3c]
|
|
21
|
+
|
|
22
|
+
goroutine 42 [running]:
|
|
23
|
+
main.(*OrderService).Process(0xc0000b4000, 0xc0000d2000)
|
|
24
|
+
/app/orders/service.go:128 +0x1a5
|
|
25
|
+
main.(*Handler).ServeHTTP(0xc0000a2000, {0x8f2a40, 0xc0000b0000})
|
|
26
|
+
/app/web/handler.go:64 +0x2c8
|
|
27
|
+
net/http.(*conn).serve(0xc0001a4000, {0x8f2b20, 0xc0000c2000})
|
|
28
|
+
/usr/local/go/src/net/http/server.go:2092 +0x1a5
|
|
29
|
+
created by net/http.(*Server).Serve in goroutine 1
|
|
30
|
+
/usr/local/go/src/net/http/server.go:3285 +0x33e`;
|
|
31
|
+
const GO_SIBLING = `panic: runtime error: invalid memory address or nil pointer dereference
|
|
32
|
+
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x4d5e6f]
|
|
33
|
+
|
|
34
|
+
goroutine 88 [running]:
|
|
35
|
+
main.(*PaymentService).Charge(0xc0000f4000, 0xc000102000)
|
|
36
|
+
/app/payments/service.go:212 +0x9c
|
|
37
|
+
main.(*Handler).ServeHTTP(0xc0000a2000, {0x8f2a40, 0xc0000b0000})
|
|
38
|
+
/app/web/handler.go:64 +0x2c8
|
|
39
|
+
net/http.(*conn).serve(0xc0001a4000, {0x8f2b20, 0xc0000c2000})
|
|
40
|
+
/usr/local/go/src/net/http/server.go:2092 +0x1a5`;
|
|
41
|
+
const GO_REDEPLOY = `panic: runtime error: invalid memory address or nil pointer dereference
|
|
42
|
+
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x7a8b9c]
|
|
43
|
+
|
|
44
|
+
goroutine 15 [running]:
|
|
45
|
+
main.(*OrderService).Process(0xc000200000, 0xc000210000)
|
|
46
|
+
/app/orders/service.go:140 +0x1f2
|
|
47
|
+
main.(*Handler).ServeHTTP(0xc000202000, {0x8f2a40, 0xc000208000})
|
|
48
|
+
/app/web/handler.go:71 +0x300
|
|
49
|
+
net/http.(*conn).serve(0xc000300000, {0x8f2b20, 0xc000310000})
|
|
50
|
+
/usr/local/go/src/net/http/server.go:2092 +0x1a5
|
|
51
|
+
created by net/http.(*Server).Serve in goroutine 1
|
|
52
|
+
/usr/local/go/src/net/http/server.go:3285 +0x33e`;
|
|
53
|
+
// ── Rust ────────────────────────────────────────────────────────────────────
|
|
54
|
+
const RUST_PRIMARY = `thread 'actix-rt|system:0|arbiter:1' panicked at src/orders/service.rs:88:21:
|
|
55
|
+
called \`Result::unwrap()\` on an \`Err\` value: PoolTimedOut
|
|
56
|
+
stack backtrace:
|
|
57
|
+
0: rust_begin_unwind
|
|
58
|
+
at /rustc/abc123/library/std/src/panicking.rs:665:5
|
|
59
|
+
1: core::panicking::panic_fmt
|
|
60
|
+
at /rustc/abc123/library/core/src/panicking.rs:74:14
|
|
61
|
+
2: core::result::unwrap_failed
|
|
62
|
+
at /rustc/abc123/library/core/src/result.rs:1679:5
|
|
63
|
+
3: myapp::orders::service::OrderService::process
|
|
64
|
+
at ./src/orders/service.rs:88:21
|
|
65
|
+
4: myapp::web::handler::handle_request
|
|
66
|
+
at ./src/web/handler.rs:42:9`;
|
|
67
|
+
const RUST_SIBLING = `thread 'main' panicked at src/payments/service.rs:143:10:
|
|
68
|
+
called \`Result::unwrap()\` on an \`Err\` value: PoolTimedOut
|
|
69
|
+
stack backtrace:
|
|
70
|
+
0: rust_begin_unwind
|
|
71
|
+
at /rustc/abc123/library/std/src/panicking.rs:665:5
|
|
72
|
+
1: core::panicking::panic_fmt
|
|
73
|
+
at /rustc/abc123/library/core/src/panicking.rs:74:14
|
|
74
|
+
2: core::result::unwrap_failed
|
|
75
|
+
at /rustc/abc123/library/core/src/result.rs:1679:5
|
|
76
|
+
3: myapp::payments::service::PaymentService::charge
|
|
77
|
+
at ./src/payments/service.rs:143:10
|
|
78
|
+
4: myapp::web::handler::handle_request
|
|
79
|
+
at ./src/web/handler.rs:42:9`;
|
|
80
|
+
const RUST_REDEPLOY = `thread 'main' panicked at src/orders/service.rs:95:21:
|
|
81
|
+
called \`Result::unwrap()\` on an \`Err\` value: PoolTimedOut
|
|
82
|
+
stack backtrace:
|
|
83
|
+
0: rust_begin_unwind
|
|
84
|
+
at /rustc/abc123/library/std/src/panicking.rs:665:5
|
|
85
|
+
1: core::panicking::panic_fmt
|
|
86
|
+
at /rustc/abc123/library/core/src/panicking.rs:74:14
|
|
87
|
+
2: core::result::unwrap_failed
|
|
88
|
+
at /rustc/abc123/library/core/src/result.rs:1679:5
|
|
89
|
+
3: myapp::orders::service::OrderService::process
|
|
90
|
+
at ./src/orders/service.rs:95:21
|
|
91
|
+
4: myapp::web::handler::handle_request
|
|
92
|
+
at ./src/web/handler.rs:47:9`;
|
|
93
|
+
// ── Java / JVM ──────────────────────────────────────────────────────────────
|
|
94
|
+
const JAVA_PRIMARY = `java.lang.NullPointerException: Cannot invoke "com.example.model.Order.total()" because "order" is null
|
|
95
|
+
at com.example.orders.OrderService.process(OrderService.java:88)
|
|
96
|
+
at com.example.web.RequestHandler.handle(RequestHandler.java:42)
|
|
97
|
+
at com.example.web.RequestHandler.doGet(RequestHandler.java:31)
|
|
98
|
+
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:687)
|
|
99
|
+
at java.base/java.lang.Thread.run(Thread.java:1583)
|
|
100
|
+
Caused by: java.lang.IllegalStateException: order not loaded
|
|
101
|
+
at com.example.orders.OrderLoader.require(OrderLoader.java:55)
|
|
102
|
+
... 4 more`;
|
|
103
|
+
const JAVA_SIBLING = `java.lang.NullPointerException: Cannot invoke "com.example.model.Order.total()" because "order" is null
|
|
104
|
+
at com.example.billing.InvoiceService.render(InvoiceService.java:140)
|
|
105
|
+
at com.example.web.RequestHandler.handle(RequestHandler.java:42)
|
|
106
|
+
at com.example.web.RequestHandler.doGet(RequestHandler.java:31)
|
|
107
|
+
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:687)
|
|
108
|
+
at java.base/java.lang.Thread.run(Thread.java:1583)`;
|
|
109
|
+
const JAVA_REDEPLOY = `java.lang.NullPointerException: Cannot invoke "com.example.model.Order.total()" because "order" is null
|
|
110
|
+
at com.example.orders.OrderService.process(OrderService.java:92)
|
|
111
|
+
at com.example.web.RequestHandler.handle(RequestHandler.java:45)
|
|
112
|
+
at com.example.web.RequestHandler.doGet(RequestHandler.java:33)
|
|
113
|
+
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:690)
|
|
114
|
+
at java.base/java.lang.Thread.run(Thread.java:1589)`;
|
|
115
|
+
// ── .NET ────────────────────────────────────────────────────────────────────
|
|
116
|
+
const DOTNET_PRIMARY = `System.NullReferenceException: Object reference not set to an instance of an object.
|
|
117
|
+
at MyApp.Orders.OrderService.Process(Order order) in C:\\src\\MyApp\\Orders\\OrderService.cs:line 88
|
|
118
|
+
at MyApp.Web.RequestHandler.HandleAsync(HttpContext context) in C:\\src\\MyApp\\Web\\RequestHandler.cs:line 42
|
|
119
|
+
at MyApp.Web.RequestHandler.<HandleAsync>d__4.MoveNext() in C:\\src\\MyApp\\Web\\RequestHandler.cs:line 39
|
|
120
|
+
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](ref TStateMachine stateMachine)
|
|
121
|
+
at System.Threading.Tasks.Task.ExecuteWithThreadLocal(ref Task currentTaskSlot)`;
|
|
122
|
+
const DOTNET_SIBLING = `System.NullReferenceException: Object reference not set to an instance of an object.
|
|
123
|
+
at MyApp.Billing.InvoiceService.Render(Invoice invoice) in C:\\src\\MyApp\\Billing\\InvoiceService.cs:line 205
|
|
124
|
+
at MyApp.Web.RequestHandler.HandleAsync(HttpContext context) in C:\\src\\MyApp\\Web\\RequestHandler.cs:line 42
|
|
125
|
+
at MyApp.Web.RequestHandler.<HandleAsync>d__4.MoveNext() in C:\\src\\MyApp\\Web\\RequestHandler.cs:line 39
|
|
126
|
+
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](ref TStateMachine stateMachine)
|
|
127
|
+
at System.Threading.Tasks.Task.ExecuteWithThreadLocal(ref Task currentTaskSlot)`;
|
|
128
|
+
const DOTNET_REDEPLOY = `System.NullReferenceException: Object reference not set to an instance of an object.
|
|
129
|
+
at MyApp.Orders.OrderService.Process(Order order) in C:\\src\\MyApp\\Orders\\OrderService.cs:line 94
|
|
130
|
+
at MyApp.Web.RequestHandler.HandleAsync(HttpContext context) in C:\\src\\MyApp\\Web\\RequestHandler.cs:line 47
|
|
131
|
+
at MyApp.Web.RequestHandler.<HandleAsync>d__4.MoveNext() in C:\\src\\MyApp\\Web\\RequestHandler.cs:line 44
|
|
132
|
+
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](ref TStateMachine stateMachine)
|
|
133
|
+
at System.Threading.Tasks.Task.ExecuteWithThreadLocal(ref Task currentTaskSlot)`;
|
|
134
|
+
// ── JavaScript / Node (V8) ──────────────────────────────────────────────────
|
|
135
|
+
const NODE_PRIMARY = `TypeError: Cannot read properties of undefined (reading 'total')
|
|
136
|
+
at OrderService.process (/app/dist/orders/service.js:128:35)
|
|
137
|
+
at RequestHandler.handle (/app/dist/web/handler.js:64:20)
|
|
138
|
+
at /app/dist/web/router.js:22:9
|
|
139
|
+
at processTicksAndRejections (node:internal/process/task_queues:95:5)`;
|
|
140
|
+
const NODE_SIBLING = `TypeError: Cannot read properties of undefined (reading 'total')
|
|
141
|
+
at PaymentService.charge (/app/dist/payments/service.js:212:18)
|
|
142
|
+
at RequestHandler.handle (/app/dist/web/handler.js:64:20)
|
|
143
|
+
at /app/dist/web/router.js:22:9
|
|
144
|
+
at processTicksAndRejections (node:internal/process/task_queues:95:5)`;
|
|
145
|
+
const NODE_REDEPLOY = `TypeError: Cannot read properties of undefined (reading 'total')
|
|
146
|
+
at OrderService.process (/app/dist/orders/service.js:131:35)
|
|
147
|
+
at RequestHandler.handle (/app/dist/web/handler.js:70:20)
|
|
148
|
+
at /app/dist/web/router.js:25:9
|
|
149
|
+
at processTicksAndRejections (node:internal/process/task_queues:95:5)`;
|
|
150
|
+
// ── Python ──────────────────────────────────────────────────────────────────
|
|
151
|
+
const PYTHON_PRIMARY = `Traceback (most recent call last):
|
|
152
|
+
File "/app/web/handler.py", line 42, in handle
|
|
153
|
+
return self.service.process(order)
|
|
154
|
+
File "/app/orders/service.py", line 88, in process
|
|
155
|
+
raise ValueError(f"order {order_id} is invalid")
|
|
156
|
+
ValueError: order 4821 is invalid`;
|
|
157
|
+
const PYTHON_SIBLING = `Traceback (most recent call last):
|
|
158
|
+
File "/app/web/handler.py", line 42, in handle
|
|
159
|
+
return self.service.process(order)
|
|
160
|
+
File "/app/billing/invoice.py", line 205, in render
|
|
161
|
+
raise ValueError(f"order {order_id} is invalid")
|
|
162
|
+
ValueError: order 7734 is invalid`;
|
|
163
|
+
export const STACK_FIXTURES = {
|
|
164
|
+
go: {
|
|
165
|
+
primary: GO_PRIMARY,
|
|
166
|
+
sibling: GO_SIBLING,
|
|
167
|
+
redeploy: GO_REDEPLOY,
|
|
168
|
+
primaryFrames: [
|
|
169
|
+
"main.(*OrderService).Process (/app/orders/service.go)",
|
|
170
|
+
"main.(*Handler).ServeHTTP (/app/web/handler.go)",
|
|
171
|
+
"net/http.(*conn).serve (/usr/local/go/src/net/http/server.go)",
|
|
172
|
+
"net/http.(*Server).Serve (/usr/local/go/src/net/http/server.go)",
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
rust: {
|
|
176
|
+
primary: RUST_PRIMARY,
|
|
177
|
+
sibling: RUST_SIBLING,
|
|
178
|
+
redeploy: RUST_REDEPLOY,
|
|
179
|
+
primaryFrames: [
|
|
180
|
+
"rust_begin_unwind (/rustc/abc123/library/std/src/panicking.rs)",
|
|
181
|
+
"core::panicking::panic_fmt (/rustc/abc123/library/core/src/panicking.rs)",
|
|
182
|
+
"core::result::unwrap_failed (/rustc/abc123/library/core/src/result.rs)",
|
|
183
|
+
"myapp::orders::service::OrderService::process (./src/orders/service.rs)",
|
|
184
|
+
"myapp::web::handler::handle_request (./src/web/handler.rs)",
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
java: {
|
|
188
|
+
primary: JAVA_PRIMARY,
|
|
189
|
+
sibling: JAVA_SIBLING,
|
|
190
|
+
redeploy: JAVA_REDEPLOY,
|
|
191
|
+
primaryFrames: [
|
|
192
|
+
"com.example.orders.OrderService.process(OrderService.java)",
|
|
193
|
+
"com.example.web.RequestHandler.handle(RequestHandler.java)",
|
|
194
|
+
"com.example.web.RequestHandler.doGet(RequestHandler.java)",
|
|
195
|
+
"jakarta.servlet.http.HttpServlet.service(HttpServlet.java)",
|
|
196
|
+
"java.base/java.lang.Thread.run(Thread.java)",
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
dotnet: {
|
|
200
|
+
primary: DOTNET_PRIMARY,
|
|
201
|
+
sibling: DOTNET_SIBLING,
|
|
202
|
+
redeploy: DOTNET_REDEPLOY,
|
|
203
|
+
primaryFrames: [
|
|
204
|
+
"MyApp.Orders.OrderService.Process (C:\\src\\MyApp\\Orders\\OrderService.cs)",
|
|
205
|
+
"MyApp.Web.RequestHandler.HandleAsync (C:\\src\\MyApp\\Web\\RequestHandler.cs)",
|
|
206
|
+
"MyApp.Web.RequestHandler.<HandleAsync>d__4.MoveNext (C:\\src\\MyApp\\Web\\RequestHandler.cs)",
|
|
207
|
+
"System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine]",
|
|
208
|
+
"System.Threading.Tasks.Task.ExecuteWithThreadLocal",
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
node: {
|
|
212
|
+
primary: NODE_PRIMARY,
|
|
213
|
+
sibling: NODE_SIBLING,
|
|
214
|
+
redeploy: NODE_REDEPLOY,
|
|
215
|
+
primaryFrames: [
|
|
216
|
+
"at OrderService.process (/app/dist/orders/service.js",
|
|
217
|
+
"at RequestHandler.handle (/app/dist/web/handler.js",
|
|
218
|
+
"at /app/dist/web/router.js",
|
|
219
|
+
"at processTicksAndRejections (node:internal/process/task_queues",
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
python: {
|
|
223
|
+
primary: PYTHON_PRIMARY,
|
|
224
|
+
sibling: PYTHON_SIBLING,
|
|
225
|
+
redeploy: PYTHON_PRIMARY,
|
|
226
|
+
primaryFrames: [
|
|
227
|
+
'File "/app/web/handler.py", line 42, in handle',
|
|
228
|
+
'File "/app/orders/service.py", line 88, in process',
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
};
|