@aaac/observability 0.1.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/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/chunk-FVFGVBXM.js +1307 -0
- package/dist/chunk-FVFGVBXM.js.map +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +425 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +959 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
// src/types/ids.ts
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
function generateId() {
|
|
4
|
+
const ms = BigInt(Date.now());
|
|
5
|
+
const buf = randomBytes(16);
|
|
6
|
+
buf[0] = Number(ms >> 40n & 0xffn);
|
|
7
|
+
buf[1] = Number(ms >> 32n & 0xffn);
|
|
8
|
+
buf[2] = Number(ms >> 24n & 0xffn);
|
|
9
|
+
buf[3] = Number(ms >> 16n & 0xffn);
|
|
10
|
+
buf[4] = Number(ms >> 8n & 0xffn);
|
|
11
|
+
buf[5] = Number(ms & 0xffn);
|
|
12
|
+
buf[6] = buf[6] & 15 | 112;
|
|
13
|
+
buf[8] = buf[8] & 63 | 128;
|
|
14
|
+
const hex = buf.toString("hex");
|
|
15
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
16
|
+
}
|
|
17
|
+
function currentTimeUnixNano() {
|
|
18
|
+
return BigInt(Date.now()) * 1000000n;
|
|
19
|
+
}
|
|
20
|
+
function isoToUnixNano(iso) {
|
|
21
|
+
const ms = Date.parse(iso);
|
|
22
|
+
if (Number.isNaN(ms)) {
|
|
23
|
+
return currentTimeUnixNano();
|
|
24
|
+
}
|
|
25
|
+
return BigInt(ms) * 1000000n;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/collector/event-collector.ts
|
|
29
|
+
var EventCollector = class {
|
|
30
|
+
handler;
|
|
31
|
+
constructor(handler) {
|
|
32
|
+
this.handler = handler;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* In-process observer entry point.
|
|
36
|
+
* Called by @aaac/runtime (or any in-process producer) with an event payload.
|
|
37
|
+
*/
|
|
38
|
+
emit(event) {
|
|
39
|
+
const raw = {
|
|
40
|
+
...event,
|
|
41
|
+
receivedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
42
|
+
};
|
|
43
|
+
this.handler(raw);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* External event registration API.
|
|
47
|
+
* Called by git hooks, Cursor hooks, CI, or the `aaac-observ record` CLI.
|
|
48
|
+
* Identical wire format to emit(); separated to aid future routing logic.
|
|
49
|
+
*/
|
|
50
|
+
registerExternalEvent(event) {
|
|
51
|
+
const raw = {
|
|
52
|
+
...event,
|
|
53
|
+
receivedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
54
|
+
};
|
|
55
|
+
this.handler(raw);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// src/collector/external-registrar.ts
|
|
60
|
+
var DefaultExternalRegistrar = class {
|
|
61
|
+
constructor(collector) {
|
|
62
|
+
this.collector = collector;
|
|
63
|
+
}
|
|
64
|
+
collector;
|
|
65
|
+
registerEvent(event) {
|
|
66
|
+
this.collector.registerExternalEvent(event);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// src/normalize/validator.ts
|
|
71
|
+
var VALID_LIFECYCLES = /* @__PURE__ */ new Set(["open", "close", "event", "instant"]);
|
|
72
|
+
var NormalizationError = class extends Error {
|
|
73
|
+
constructor(message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = "NormalizationError";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
function validateRawEvent(event) {
|
|
79
|
+
const errors = [];
|
|
80
|
+
if (!event.source || typeof event.source !== "string") {
|
|
81
|
+
errors.push("'source' must be a non-empty string");
|
|
82
|
+
}
|
|
83
|
+
if (!event.eventType || typeof event.eventType !== "string") {
|
|
84
|
+
errors.push("'eventType' must be a non-empty string");
|
|
85
|
+
}
|
|
86
|
+
if (!event.lifecycle || !VALID_LIFECYCLES.has(event.lifecycle)) {
|
|
87
|
+
errors.push(
|
|
88
|
+
`'lifecycle' must be one of: ${[...VALID_LIFECYCLES].join(", ")}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (!event.receivedAt || typeof event.receivedAt !== "string") {
|
|
92
|
+
errors.push("'receivedAt' must be a non-empty string");
|
|
93
|
+
}
|
|
94
|
+
if (event.attributes === null || typeof event.attributes !== "object" || Array.isArray(event.attributes)) {
|
|
95
|
+
errors.push("'attributes' must be a plain object");
|
|
96
|
+
}
|
|
97
|
+
if (errors.length > 0) {
|
|
98
|
+
throw new NormalizationError(
|
|
99
|
+
`Invalid RawEvent: ${errors.join("; ")}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/normalize/normalizer.ts
|
|
105
|
+
function coerceAttrValue(value) {
|
|
106
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
return String(value);
|
|
110
|
+
}
|
|
111
|
+
function coerceAttributes(raw) {
|
|
112
|
+
const result = {};
|
|
113
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
114
|
+
result[k] = coerceAttrValue(v);
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
var Normalizer = class {
|
|
119
|
+
/**
|
|
120
|
+
* Validate and normalise a single RawEvent.
|
|
121
|
+
* Throws NormalizationError on invalid input.
|
|
122
|
+
*/
|
|
123
|
+
normalize(raw) {
|
|
124
|
+
validateRawEvent(raw);
|
|
125
|
+
const id = generateId();
|
|
126
|
+
const timeUnixNano = isoToUnixNano(raw.receivedAt);
|
|
127
|
+
const spanId = raw.spanId ?? generateId();
|
|
128
|
+
const traceId = generateId();
|
|
129
|
+
const attributes = coerceAttributes(
|
|
130
|
+
raw.attributes
|
|
131
|
+
);
|
|
132
|
+
const sessionId = extractString(attributes, "session_id");
|
|
133
|
+
const taskId = extractString(attributes, "task_id");
|
|
134
|
+
const runId = extractString(attributes, "run_id");
|
|
135
|
+
return {
|
|
136
|
+
id,
|
|
137
|
+
timeUnixNano,
|
|
138
|
+
source: raw.source,
|
|
139
|
+
eventType: raw.eventType,
|
|
140
|
+
lifecycle: raw.lifecycle,
|
|
141
|
+
traceId,
|
|
142
|
+
spanId,
|
|
143
|
+
parentSpanId: raw.parentSpanId,
|
|
144
|
+
runId,
|
|
145
|
+
sessionId,
|
|
146
|
+
taskId,
|
|
147
|
+
attributes,
|
|
148
|
+
links: raw.links ?? []
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
function extractString(attrs, key) {
|
|
153
|
+
const v = attrs[key];
|
|
154
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/correlate/correlator.ts
|
|
158
|
+
var Correlator = class {
|
|
159
|
+
/** Open spans awaiting a matching 'close' event (keyed by spanId). */
|
|
160
|
+
openSpans = /* @__PURE__ */ new Map();
|
|
161
|
+
/** General-purpose short-term key-value cache (architecture.md §8). */
|
|
162
|
+
cache = /* @__PURE__ */ new Map();
|
|
163
|
+
/**
|
|
164
|
+
* Correlate a single CanonicalEvent.
|
|
165
|
+
*
|
|
166
|
+
* Mutates the event's traceId if a parent span is found in the open set.
|
|
167
|
+
* Updates open span state according to the event's lifecycle.
|
|
168
|
+
*
|
|
169
|
+
* Returns the (possibly updated) CanonicalEvent.
|
|
170
|
+
*/
|
|
171
|
+
correlate(event) {
|
|
172
|
+
let result = { ...event };
|
|
173
|
+
if (result.parentSpanId !== void 0) {
|
|
174
|
+
const parentEntry = this.openSpans.get(result.parentSpanId);
|
|
175
|
+
if (parentEntry !== void 0) {
|
|
176
|
+
result = { ...result, traceId: parentEntry.event.traceId };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
switch (result.lifecycle) {
|
|
180
|
+
case "open":
|
|
181
|
+
this.openSpans.set(result.spanId, { event: result });
|
|
182
|
+
break;
|
|
183
|
+
case "close": {
|
|
184
|
+
this.openSpans.delete(result.spanId);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "event":
|
|
188
|
+
break;
|
|
189
|
+
case "instant":
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Retrieve the currently-open entry for a span (undefined if not open).
|
|
196
|
+
* Useful for SqliteSink to look up start_time without a DB round-trip.
|
|
197
|
+
*/
|
|
198
|
+
getOpenSpan(spanId) {
|
|
199
|
+
return this.openSpans.get(spanId)?.event;
|
|
200
|
+
}
|
|
201
|
+
// ── Generic key-value cache (architecture.md §8) ──────────────────────────
|
|
202
|
+
/** Store a value under an arbitrary key (TTL not enforced in Phase 0). */
|
|
203
|
+
cacheSet(key, value) {
|
|
204
|
+
this.cache.set(key, value);
|
|
205
|
+
}
|
|
206
|
+
/** Retrieve a cached value; returns undefined if absent. */
|
|
207
|
+
cacheGet(key) {
|
|
208
|
+
return this.cache.get(key);
|
|
209
|
+
}
|
|
210
|
+
/** Remove a cached entry. */
|
|
211
|
+
cacheDel(key) {
|
|
212
|
+
this.cache.delete(key);
|
|
213
|
+
}
|
|
214
|
+
/** Return the number of currently-open spans (useful for testing). */
|
|
215
|
+
get openSpanCount() {
|
|
216
|
+
return this.openSpans.size;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// src/correlate/keys.ts
|
|
221
|
+
var keys = {
|
|
222
|
+
/** Per-run context: run:{id} */
|
|
223
|
+
run: (id) => `run:${id}`,
|
|
224
|
+
/** Latest span in a trace: trace:{id}:latest */
|
|
225
|
+
traceLatest: (id) => `trace:${id}:latest`,
|
|
226
|
+
/** Per-session context: session:{id} */
|
|
227
|
+
session: (id) => `session:${id}`,
|
|
228
|
+
/** Tool call tracking: toolcall:{id} */
|
|
229
|
+
toolCall: (id) => `toolcall:${id}`,
|
|
230
|
+
/** Request tracking: request:{id} */
|
|
231
|
+
request: (id) => `request:${id}`
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// src/enrich/rules/artifact.ts
|
|
235
|
+
function globSegmentToRegex(segment) {
|
|
236
|
+
return segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
|
|
237
|
+
}
|
|
238
|
+
function globToRegex(pattern) {
|
|
239
|
+
if (!pattern.includes("/")) {
|
|
240
|
+
const base = globSegmentToRegex(pattern);
|
|
241
|
+
return new RegExp(`(?:^|/)${base}$`);
|
|
242
|
+
}
|
|
243
|
+
const parts = pattern.split("**/");
|
|
244
|
+
let result = "^";
|
|
245
|
+
for (let i = 0; i < parts.length; i++) {
|
|
246
|
+
if (i > 0) {
|
|
247
|
+
result += "(?:.*/)?";
|
|
248
|
+
}
|
|
249
|
+
result += globSegmentToRegex(parts[i]);
|
|
250
|
+
}
|
|
251
|
+
result += "$";
|
|
252
|
+
return new RegExp(result);
|
|
253
|
+
}
|
|
254
|
+
function lookupArtifact(filePath, patterns) {
|
|
255
|
+
let bestArtifactId;
|
|
256
|
+
let bestLen = -1;
|
|
257
|
+
for (const entry of patterns) {
|
|
258
|
+
const rx = globToRegex(entry.pattern);
|
|
259
|
+
if (rx.test(filePath)) {
|
|
260
|
+
if (entry.pattern.length > bestLen) {
|
|
261
|
+
bestLen = entry.pattern.length;
|
|
262
|
+
bestArtifactId = entry.artifact_id;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return bestArtifactId ?? "unknown";
|
|
267
|
+
}
|
|
268
|
+
function createArtifactRule(patterns) {
|
|
269
|
+
return function artifactRule(event, ctx) {
|
|
270
|
+
if (event.eventType !== "process.edit" || event.lifecycle !== "instant") {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const filePath = event.attributes["edit.path"];
|
|
274
|
+
if (typeof filePath !== "string" || filePath.length === 0) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const existing = event.attributes["edit.artifact_id"];
|
|
278
|
+
if (typeof existing === "string" && existing.length > 0 && existing !== "unknown") {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const artifactId = ctx.api.lookupArtifact(filePath);
|
|
282
|
+
event.attributes["edit.artifact_id"] = artifactId;
|
|
283
|
+
ctx.api.logDebug(
|
|
284
|
+
`R7: resolved artifact_id=${artifactId} for path=${filePath}`
|
|
285
|
+
);
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/enrich/rules/process.ts
|
|
290
|
+
function activeTasksKey(sessionId) {
|
|
291
|
+
return `session:${sessionId}:active_tasks`;
|
|
292
|
+
}
|
|
293
|
+
function allTaskSpansKey(sessionId) {
|
|
294
|
+
return `session:${sessionId}:all_task_spans`;
|
|
295
|
+
}
|
|
296
|
+
function taskFilesKey(sessionId, taskSpanId) {
|
|
297
|
+
return `session:${sessionId}:task_files:${taskSpanId}`;
|
|
298
|
+
}
|
|
299
|
+
function createProcessRule() {
|
|
300
|
+
return function processRule(event, ctx) {
|
|
301
|
+
const sid = event.sessionId;
|
|
302
|
+
if (event.eventType === "process.task") {
|
|
303
|
+
if (!sid) return;
|
|
304
|
+
if (event.lifecycle === "open") {
|
|
305
|
+
const tasks = ctx.api.cacheGet(activeTasksKey(sid)) ?? [];
|
|
306
|
+
tasks.push({ spanId: event.spanId, taskId: event.taskId });
|
|
307
|
+
ctx.api.cacheSet(activeTasksKey(sid), tasks);
|
|
308
|
+
const allSpans = ctx.api.cacheGet(allTaskSpansKey(sid)) ?? [];
|
|
309
|
+
if (!allSpans.includes(event.spanId)) {
|
|
310
|
+
allSpans.push(event.spanId);
|
|
311
|
+
ctx.api.cacheSet(allTaskSpansKey(sid), allSpans);
|
|
312
|
+
}
|
|
313
|
+
ctx.api.cacheSet(taskFilesKey(sid, event.spanId), []);
|
|
314
|
+
ctx.api.logDebug(
|
|
315
|
+
`R3/R4: pushed task spanId=${event.spanId} session=${sid}`
|
|
316
|
+
);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (event.lifecycle === "close") {
|
|
320
|
+
const tasks = ctx.api.cacheGet(activeTasksKey(sid)) ?? [];
|
|
321
|
+
const updated = tasks.filter((t) => t.spanId !== event.spanId);
|
|
322
|
+
ctx.api.cacheSet(activeTasksKey(sid), updated);
|
|
323
|
+
ctx.api.logDebug(
|
|
324
|
+
`R3/R4: popped task spanId=${event.spanId} session=${sid}`
|
|
325
|
+
);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (event.eventType === "process.edit" && event.lifecycle === "instant") {
|
|
331
|
+
if (event.parentSpanId) return;
|
|
332
|
+
if (!sid) return;
|
|
333
|
+
const tasks = ctx.api.cacheGet(activeTasksKey(sid)) ?? [];
|
|
334
|
+
if (tasks.length === 0) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (tasks.length === 1) {
|
|
338
|
+
event.parentSpanId = tasks[0].spanId;
|
|
339
|
+
event.taskId = tasks[0].taskId;
|
|
340
|
+
const fp = event.attributes["edit.path"];
|
|
341
|
+
if (typeof fp === "string" && fp.length > 0) {
|
|
342
|
+
const files = ctx.api.cacheGet(
|
|
343
|
+
taskFilesKey(sid, tasks[0].spanId)
|
|
344
|
+
) ?? [];
|
|
345
|
+
if (!files.includes(fp)) {
|
|
346
|
+
files.push(fp);
|
|
347
|
+
ctx.api.cacheSet(taskFilesKey(sid, tasks[0].spanId), files);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
ctx.api.logDebug(
|
|
351
|
+
`R3: resolved parent=${tasks[0].spanId} for process.edit`
|
|
352
|
+
);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const artifactId = event.attributes["edit.artifact_id"];
|
|
356
|
+
if (typeof artifactId === "string" && artifactId !== "unknown") {
|
|
357
|
+
const owner = tasks.find(
|
|
358
|
+
(t) => {
|
|
359
|
+
const taskArtifact = ctx.api.cacheGet(
|
|
360
|
+
`session:${sid}:task_artifact:${t.spanId}`
|
|
361
|
+
);
|
|
362
|
+
return taskArtifact === artifactId;
|
|
363
|
+
}
|
|
364
|
+
);
|
|
365
|
+
if (owner) {
|
|
366
|
+
event.parentSpanId = owner.spanId;
|
|
367
|
+
event.taskId = owner.taskId;
|
|
368
|
+
const fp = event.attributes["edit.path"];
|
|
369
|
+
if (typeof fp === "string" && fp.length > 0) {
|
|
370
|
+
const files = ctx.api.cacheGet(taskFilesKey(sid, owner.spanId)) ?? [];
|
|
371
|
+
if (!files.includes(fp)) {
|
|
372
|
+
files.push(fp);
|
|
373
|
+
ctx.api.cacheSet(taskFilesKey(sid, owner.spanId), files);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
ctx.api.logDebug(
|
|
377
|
+
`R3: disambiguated parent=${owner.spanId} by artifact=${artifactId}`
|
|
378
|
+
);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
event.attributes["reconcile.status"] = "ambiguous";
|
|
383
|
+
ctx.api.logDebug(
|
|
384
|
+
`R3: ambiguous (${tasks.length} active tasks, no unique artifact match)`
|
|
385
|
+
);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (event.eventType === "process.tool" && event.lifecycle === "instant") {
|
|
389
|
+
if (event.parentSpanId) return;
|
|
390
|
+
if (!sid) return;
|
|
391
|
+
const tasks = ctx.api.cacheGet(activeTasksKey(sid)) ?? [];
|
|
392
|
+
if (tasks.length === 0) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (tasks.length === 1) {
|
|
396
|
+
event.parentSpanId = tasks[0].spanId;
|
|
397
|
+
event.taskId = tasks[0].taskId;
|
|
398
|
+
ctx.api.logDebug(
|
|
399
|
+
`R4: resolved parent=${tasks[0].spanId} for process.tool`
|
|
400
|
+
);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
event.attributes["reconcile.status"] = "ambiguous";
|
|
404
|
+
ctx.api.logDebug(
|
|
405
|
+
`R4: ambiguous (${tasks.length} active tasks)`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function recordTaskArtifact(sessionId, taskSpanId, artifactId, cacheSet) {
|
|
411
|
+
cacheSet(`session:${sessionId}:task_artifact:${taskSpanId}`, artifactId);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// src/enrich/rules/cross-axis.ts
|
|
415
|
+
function createCrossAxisRule() {
|
|
416
|
+
return function crossAxisRule(event, ctx) {
|
|
417
|
+
if (event.eventType !== "promotion.commit" || event.lifecycle !== "close") {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const sid = event.sessionId;
|
|
421
|
+
const committedFilesRaw = event.attributes["committed_files"];
|
|
422
|
+
if (!sid || typeof committedFilesRaw !== "string") {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
let committedFiles;
|
|
426
|
+
try {
|
|
427
|
+
const parsed = JSON.parse(committedFilesRaw);
|
|
428
|
+
if (!Array.isArray(parsed)) {
|
|
429
|
+
ctx.api.logDebug(
|
|
430
|
+
"R5: committed_files is not an array",
|
|
431
|
+
committedFilesRaw
|
|
432
|
+
);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
committedFiles = parsed.filter((x) => typeof x === "string");
|
|
436
|
+
} catch {
|
|
437
|
+
ctx.api.logDebug("R5: failed to parse committed_files", committedFilesRaw);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (committedFiles.length === 0) return;
|
|
441
|
+
const committedSet = new Set(committedFiles);
|
|
442
|
+
const allTaskSpans = ctx.api.cacheGet(allTaskSpansKey(sid)) ?? [];
|
|
443
|
+
for (const taskSpanId of allTaskSpans) {
|
|
444
|
+
const taskFiles = ctx.api.cacheGet(taskFilesKey(sid, taskSpanId)) ?? [];
|
|
445
|
+
const hasIntersection = taskFiles.some((f) => committedSet.has(f));
|
|
446
|
+
if (hasIntersection) {
|
|
447
|
+
ctx.api.addLink({
|
|
448
|
+
linkType: "materializes_as_commit",
|
|
449
|
+
targetSpanId: taskSpanId
|
|
450
|
+
});
|
|
451
|
+
ctx.api.logDebug(
|
|
452
|
+
`R5: added materializes_as_commit \u2192 task=${taskSpanId}`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/enrich/rules/promotion.ts
|
|
460
|
+
function changesKey(sessionId, filePath) {
|
|
461
|
+
return `session:${sessionId}:changes:${filePath}`;
|
|
462
|
+
}
|
|
463
|
+
function createPromotionRule() {
|
|
464
|
+
return function promotionRule(event, ctx) {
|
|
465
|
+
if (event.eventType === "promotion.change" && event.lifecycle === "instant") {
|
|
466
|
+
const sid = event.sessionId;
|
|
467
|
+
const fp = event.attributes["file.path"];
|
|
468
|
+
if (!sid || typeof fp !== "string" || fp.length === 0) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const key = changesKey(sid, fp);
|
|
472
|
+
const existing = ctx.api.cacheGet(key) ?? [];
|
|
473
|
+
existing.push({ spanId: event.spanId, traceId: event.traceId });
|
|
474
|
+
ctx.api.cacheSet(key, existing);
|
|
475
|
+
ctx.api.logDebug(
|
|
476
|
+
`R1: cached promotion.change spanId=${event.spanId} file=${fp} session=${sid}`
|
|
477
|
+
);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (event.eventType === "promotion.commit" && event.lifecycle === "close") {
|
|
481
|
+
const sid = event.sessionId;
|
|
482
|
+
const committedFilesRaw = event.attributes["committed_files"];
|
|
483
|
+
if (!sid || typeof committedFilesRaw !== "string") {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
let committedFiles;
|
|
487
|
+
try {
|
|
488
|
+
const parsed = JSON.parse(committedFilesRaw);
|
|
489
|
+
if (!Array.isArray(parsed)) {
|
|
490
|
+
ctx.api.logDebug("R1/R2: committed_files is not an array", committedFilesRaw);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
committedFiles = parsed.filter((x) => typeof x === "string");
|
|
494
|
+
} catch {
|
|
495
|
+
ctx.api.logDebug("R1/R2: failed to parse committed_files", committedFilesRaw);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const commitSpanId = event.spanId;
|
|
499
|
+
const commitTraceId = event.traceId;
|
|
500
|
+
for (const fp of committedFiles) {
|
|
501
|
+
const cacheKey = changesKey(sid, fp);
|
|
502
|
+
const changeRefs = ctx.api.cacheGet(cacheKey) ?? [];
|
|
503
|
+
const links = changeRefs.map((ref) => ({
|
|
504
|
+
linkType: "contains_change",
|
|
505
|
+
targetSpanId: ref.spanId,
|
|
506
|
+
// Omit targetTraceId if same trace as the commit (most common case)
|
|
507
|
+
targetTraceId: ref.traceId !== commitTraceId ? ref.traceId : void 0
|
|
508
|
+
}));
|
|
509
|
+
const fileEvent = {
|
|
510
|
+
id: generateId(),
|
|
511
|
+
timeUnixNano: ctx.api.nowUnixNano(),
|
|
512
|
+
source: "enricher",
|
|
513
|
+
eventType: "promotion.file",
|
|
514
|
+
lifecycle: "instant",
|
|
515
|
+
traceId: commitTraceId,
|
|
516
|
+
spanId: generateId(),
|
|
517
|
+
parentSpanId: commitSpanId,
|
|
518
|
+
// R2: parent = commit
|
|
519
|
+
sessionId: sid,
|
|
520
|
+
runId: event.runId,
|
|
521
|
+
taskId: event.taskId,
|
|
522
|
+
attributes: {
|
|
523
|
+
"file.path": fp,
|
|
524
|
+
"commit.span_id": commitSpanId
|
|
525
|
+
},
|
|
526
|
+
links
|
|
527
|
+
// R1: contains_change links
|
|
528
|
+
};
|
|
529
|
+
ctx.api.emitRecord(fileEvent);
|
|
530
|
+
ctx.api.logDebug(
|
|
531
|
+
`R2: emitted promotion.file file=${fp} commit=${commitSpanId} changes=${links.length}`
|
|
532
|
+
);
|
|
533
|
+
ctx.api.cacheSet(cacheKey, []);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/enrich/enricher.ts
|
|
540
|
+
var Enricher = class {
|
|
541
|
+
correlator;
|
|
542
|
+
artifactPatterns;
|
|
543
|
+
rules;
|
|
544
|
+
/**
|
|
545
|
+
* @param options Correlator + optional artifact patterns
|
|
546
|
+
* @param rules Enrichment rules to apply in order.
|
|
547
|
+
* Use createDefaultRules() for the standard Phase 5 rule set.
|
|
548
|
+
*/
|
|
549
|
+
constructor(options, rules) {
|
|
550
|
+
this.correlator = options.correlator;
|
|
551
|
+
this.artifactPatterns = options.artifactPatterns ?? [];
|
|
552
|
+
this.rules = rules;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Enrich a single CanonicalEvent.
|
|
556
|
+
*
|
|
557
|
+
* Returns a shallow copy of the event (with mutated fields from rules) and
|
|
558
|
+
* a list of derived events queued by api.emitRecord().
|
|
559
|
+
*
|
|
560
|
+
* fail-open: each rule runs in a try/catch; a throwing rule logs to stderr
|
|
561
|
+
* and does NOT prevent the event from reaching the sinks.
|
|
562
|
+
*/
|
|
563
|
+
enrich(event) {
|
|
564
|
+
const mutableEvent = {
|
|
565
|
+
...event,
|
|
566
|
+
attributes: { ...event.attributes },
|
|
567
|
+
links: [...event.links]
|
|
568
|
+
};
|
|
569
|
+
const derived = [];
|
|
570
|
+
const patterns = this.artifactPatterns;
|
|
571
|
+
const correlator = this.correlator;
|
|
572
|
+
const api = {
|
|
573
|
+
cacheGet: (k) => correlator.cacheGet(k),
|
|
574
|
+
cacheSet: (k, v) => correlator.cacheSet(k, v),
|
|
575
|
+
emitRecord: (e) => derived.push(e),
|
|
576
|
+
addLink: (link) => mutableEvent.links.push(link),
|
|
577
|
+
lookupArtifact: (path) => lookupArtifact(path, patterns),
|
|
578
|
+
logDebug: (msg, data) => {
|
|
579
|
+
if (data !== void 0) {
|
|
580
|
+
console.debug(`[Enricher] ${msg}`, data);
|
|
581
|
+
} else {
|
|
582
|
+
console.debug(`[Enricher] ${msg}`);
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
nowUnixNano: () => currentTimeUnixNano()
|
|
586
|
+
};
|
|
587
|
+
const ctx = { event: mutableEvent, api };
|
|
588
|
+
for (const rule of this.rules) {
|
|
589
|
+
try {
|
|
590
|
+
rule(mutableEvent, ctx);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
console.error("[Enricher] rule threw (fail-open):", err);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return { enriched: mutableEvent, derived };
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
function createDefaultRules(artifactPatterns = []) {
|
|
599
|
+
return [
|
|
600
|
+
createArtifactRule(artifactPatterns),
|
|
601
|
+
// R7
|
|
602
|
+
createProcessRule(),
|
|
603
|
+
// R3/R4
|
|
604
|
+
createCrossAxisRule(),
|
|
605
|
+
// R5
|
|
606
|
+
createPromotionRule()
|
|
607
|
+
// R1/R2
|
|
608
|
+
];
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/sink/sqlite-sink.ts
|
|
612
|
+
import { DatabaseSync } from "node:sqlite";
|
|
613
|
+
import { mkdirSync } from "fs";
|
|
614
|
+
import { dirname, resolve } from "path";
|
|
615
|
+
var DEFAULT_DB_PATH = ".agent-logs/observability.db";
|
|
616
|
+
var SCHEMA = `
|
|
617
|
+
CREATE TABLE IF NOT EXISTS canonical_events (
|
|
618
|
+
id TEXT PRIMARY KEY,
|
|
619
|
+
time_unix_nano INTEGER NOT NULL,
|
|
620
|
+
source TEXT NOT NULL,
|
|
621
|
+
event_type TEXT NOT NULL,
|
|
622
|
+
lifecycle TEXT NOT NULL,
|
|
623
|
+
trace_id TEXT,
|
|
624
|
+
span_id TEXT NOT NULL,
|
|
625
|
+
parent_span_id TEXT,
|
|
626
|
+
run_id TEXT,
|
|
627
|
+
session_id TEXT,
|
|
628
|
+
task_id TEXT,
|
|
629
|
+
attributes TEXT NOT NULL DEFAULT '{}'
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
CREATE INDEX IF NOT EXISTS idx_ce_trace_id ON canonical_events (trace_id);
|
|
633
|
+
CREATE INDEX IF NOT EXISTS idx_ce_span_id ON canonical_events (span_id);
|
|
634
|
+
CREATE INDEX IF NOT EXISTS idx_ce_event_type ON canonical_events (event_type);
|
|
635
|
+
CREATE INDEX IF NOT EXISTS idx_ce_session_id ON canonical_events (session_id);
|
|
636
|
+
CREATE INDEX IF NOT EXISTS idx_ce_time ON canonical_events (time_unix_nano);
|
|
637
|
+
|
|
638
|
+
CREATE TABLE IF NOT EXISTS spans (
|
|
639
|
+
span_id TEXT PRIMARY KEY,
|
|
640
|
+
trace_id TEXT,
|
|
641
|
+
parent_span_id TEXT,
|
|
642
|
+
event_type TEXT NOT NULL,
|
|
643
|
+
start_time INTEGER,
|
|
644
|
+
end_time INTEGER,
|
|
645
|
+
duration_ns INTEGER,
|
|
646
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
647
|
+
run_id TEXT,
|
|
648
|
+
session_id TEXT,
|
|
649
|
+
task_id TEXT,
|
|
650
|
+
attributes TEXT NOT NULL DEFAULT '{}'
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
CREATE INDEX IF NOT EXISTS idx_spans_trace_id ON spans (trace_id);
|
|
654
|
+
CREATE INDEX IF NOT EXISTS idx_spans_parent ON spans (parent_span_id);
|
|
655
|
+
CREATE INDEX IF NOT EXISTS idx_spans_event_type ON spans (event_type);
|
|
656
|
+
CREATE INDEX IF NOT EXISTS idx_spans_session_id ON spans (session_id);
|
|
657
|
+
CREATE INDEX IF NOT EXISTS idx_spans_task_id ON spans (task_id);
|
|
658
|
+
CREATE INDEX IF NOT EXISTS idx_spans_status ON spans (status);
|
|
659
|
+
|
|
660
|
+
CREATE TABLE IF NOT EXISTS canonical_links (
|
|
661
|
+
id TEXT PRIMARY KEY,
|
|
662
|
+
source_event_id TEXT NOT NULL,
|
|
663
|
+
source_span_id TEXT NOT NULL,
|
|
664
|
+
target_span_id TEXT NOT NULL,
|
|
665
|
+
target_trace_id TEXT,
|
|
666
|
+
link_type TEXT NOT NULL,
|
|
667
|
+
attributes TEXT NOT NULL DEFAULT '{}'
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
CREATE INDEX IF NOT EXISTS idx_links_source ON canonical_links (source_span_id);
|
|
671
|
+
CREATE INDEX IF NOT EXISTS idx_links_target ON canonical_links (target_span_id);
|
|
672
|
+
CREATE INDEX IF NOT EXISTS idx_links_type ON canonical_links (link_type);
|
|
673
|
+
`;
|
|
674
|
+
var SqliteSink = class {
|
|
675
|
+
db;
|
|
676
|
+
constructor(dbPath = DEFAULT_DB_PATH) {
|
|
677
|
+
if (dbPath !== ":memory:") {
|
|
678
|
+
const abs = resolve(dbPath);
|
|
679
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
680
|
+
this.db = new DatabaseSync(abs);
|
|
681
|
+
} else {
|
|
682
|
+
this.db = new DatabaseSync(":memory:");
|
|
683
|
+
}
|
|
684
|
+
this.db.exec(SCHEMA);
|
|
685
|
+
}
|
|
686
|
+
// ── Public write API ───────────────────────────────────────────────────────
|
|
687
|
+
/**
|
|
688
|
+
* Persist a single CanonicalEvent.
|
|
689
|
+
* 1. Append to canonical_events (immutable)
|
|
690
|
+
* 2. Upsert spans materialised view
|
|
691
|
+
* 3. Insert canonical_links rows
|
|
692
|
+
*/
|
|
693
|
+
write(event) {
|
|
694
|
+
this.insertCanonicalEvent(event);
|
|
695
|
+
this.upsertSpan(event);
|
|
696
|
+
if (event.links.length > 0) {
|
|
697
|
+
this.insertLinks(event);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/** Close the database connection. */
|
|
701
|
+
close() {
|
|
702
|
+
this.db.close();
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Expose the underlying DatabaseSync for testing / read-path adapters.
|
|
706
|
+
* Should not be used in production write paths.
|
|
707
|
+
*/
|
|
708
|
+
getDb() {
|
|
709
|
+
return this.db;
|
|
710
|
+
}
|
|
711
|
+
// ── Private helpers ────────────────────────────────────────────────────────
|
|
712
|
+
insertCanonicalEvent(event) {
|
|
713
|
+
const stmt = this.db.prepare(`
|
|
714
|
+
INSERT INTO canonical_events
|
|
715
|
+
(id, time_unix_nano, source, event_type, lifecycle,
|
|
716
|
+
trace_id, span_id, parent_span_id, run_id, session_id, task_id, attributes)
|
|
717
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
718
|
+
`);
|
|
719
|
+
stmt.run(
|
|
720
|
+
event.id,
|
|
721
|
+
event.timeUnixNano,
|
|
722
|
+
event.source,
|
|
723
|
+
event.eventType,
|
|
724
|
+
event.lifecycle,
|
|
725
|
+
event.traceId ?? null,
|
|
726
|
+
event.spanId,
|
|
727
|
+
event.parentSpanId ?? null,
|
|
728
|
+
event.runId ?? null,
|
|
729
|
+
event.sessionId ?? null,
|
|
730
|
+
event.taskId ?? null,
|
|
731
|
+
JSON.stringify(event.attributes)
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
upsertSpan(event) {
|
|
735
|
+
switch (event.lifecycle) {
|
|
736
|
+
case "open":
|
|
737
|
+
this.db.prepare(`
|
|
738
|
+
INSERT INTO spans
|
|
739
|
+
(span_id, trace_id, parent_span_id, event_type, start_time,
|
|
740
|
+
status, run_id, session_id, task_id, attributes)
|
|
741
|
+
VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?)
|
|
742
|
+
`).run(
|
|
743
|
+
event.spanId,
|
|
744
|
+
event.traceId ?? null,
|
|
745
|
+
event.parentSpanId ?? null,
|
|
746
|
+
event.eventType,
|
|
747
|
+
event.timeUnixNano,
|
|
748
|
+
event.runId ?? null,
|
|
749
|
+
event.sessionId ?? null,
|
|
750
|
+
event.taskId ?? null,
|
|
751
|
+
JSON.stringify(event.attributes)
|
|
752
|
+
);
|
|
753
|
+
break;
|
|
754
|
+
case "close":
|
|
755
|
+
this.db.prepare(`
|
|
756
|
+
UPDATE spans
|
|
757
|
+
SET end_time = ?,
|
|
758
|
+
duration_ns = (? - start_time),
|
|
759
|
+
status = 'closed'
|
|
760
|
+
WHERE span_id = ?
|
|
761
|
+
`).run(event.timeUnixNano, event.timeUnixNano, event.spanId);
|
|
762
|
+
break;
|
|
763
|
+
case "instant":
|
|
764
|
+
this.db.prepare(`
|
|
765
|
+
INSERT OR IGNORE INTO spans
|
|
766
|
+
(span_id, trace_id, parent_span_id, event_type,
|
|
767
|
+
start_time, end_time, duration_ns, status,
|
|
768
|
+
run_id, session_id, task_id, attributes)
|
|
769
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, 'instant', ?, ?, ?, ?)
|
|
770
|
+
`).run(
|
|
771
|
+
event.spanId,
|
|
772
|
+
event.traceId ?? null,
|
|
773
|
+
event.parentSpanId ?? null,
|
|
774
|
+
event.eventType,
|
|
775
|
+
event.timeUnixNano,
|
|
776
|
+
event.timeUnixNano,
|
|
777
|
+
event.runId ?? null,
|
|
778
|
+
event.sessionId ?? null,
|
|
779
|
+
event.taskId ?? null,
|
|
780
|
+
JSON.stringify(event.attributes)
|
|
781
|
+
);
|
|
782
|
+
break;
|
|
783
|
+
case "event":
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
insertLinks(event) {
|
|
788
|
+
const stmt = this.db.prepare(`
|
|
789
|
+
INSERT INTO canonical_links
|
|
790
|
+
(id, source_event_id, source_span_id, target_span_id, target_trace_id, link_type, attributes)
|
|
791
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
792
|
+
`);
|
|
793
|
+
for (const link of event.links) {
|
|
794
|
+
stmt.run(
|
|
795
|
+
generateId(),
|
|
796
|
+
event.id,
|
|
797
|
+
event.spanId,
|
|
798
|
+
link.targetSpanId,
|
|
799
|
+
link.targetTraceId ?? null,
|
|
800
|
+
link.linkType,
|
|
801
|
+
JSON.stringify(link.attributes ?? {})
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
// src/otel/otel-emitter.ts
|
|
808
|
+
import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
809
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
810
|
+
import { trace, SpanKind, SpanStatusCode, ROOT_CONTEXT } from "@opentelemetry/api";
|
|
811
|
+
var OtelEmitter = class {
|
|
812
|
+
provider;
|
|
813
|
+
tracer;
|
|
814
|
+
enabled;
|
|
815
|
+
constructor(options = {}) {
|
|
816
|
+
const endpoint = options.endpoint ?? process.env["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
|
817
|
+
if (!endpoint) {
|
|
818
|
+
this.provider = null;
|
|
819
|
+
this.tracer = null;
|
|
820
|
+
this.enabled = false;
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const serviceName = options.serviceName ?? "@aaac/observability";
|
|
824
|
+
const exporter = new OTLPTraceExporter({ url: `${endpoint}/v1/traces` });
|
|
825
|
+
const processor = new BatchSpanProcessor(exporter);
|
|
826
|
+
this.provider = new BasicTracerProvider({
|
|
827
|
+
resource: {
|
|
828
|
+
attributes: { "service.name": serviceName }
|
|
829
|
+
},
|
|
830
|
+
spanProcessors: [processor]
|
|
831
|
+
});
|
|
832
|
+
trace.setGlobalTracerProvider(this.provider);
|
|
833
|
+
this.tracer = this.provider.getTracer("@aaac/observability", "0.1.0");
|
|
834
|
+
this.enabled = true;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Emit a single MappedSpan to the configured OTLP backend.
|
|
838
|
+
*
|
|
839
|
+
* fail-open: any error is caught, logged to stderr, and swallowed.
|
|
840
|
+
* This method never throws into the caller.
|
|
841
|
+
*/
|
|
842
|
+
emit(span) {
|
|
843
|
+
if (!this.enabled || this.tracer === null) return;
|
|
844
|
+
try {
|
|
845
|
+
this._emitSpan(span);
|
|
846
|
+
} catch (err) {
|
|
847
|
+
console.error("[OtelEmitter] emit error (swallowed):", err);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Emit a batch of MappedSpans.
|
|
852
|
+
* Each span is emitted independently; one failure does not block others.
|
|
853
|
+
*/
|
|
854
|
+
emitBatch(spans) {
|
|
855
|
+
for (const span of spans) {
|
|
856
|
+
this.emit(span);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Flush pending spans and shut down the OTEL provider.
|
|
861
|
+
* Returns a promise that resolves when the flush is complete (or on error).
|
|
862
|
+
*/
|
|
863
|
+
async shutdown() {
|
|
864
|
+
if (this.provider === null) return;
|
|
865
|
+
try {
|
|
866
|
+
await this.provider.shutdown();
|
|
867
|
+
} catch (err) {
|
|
868
|
+
console.error("[OtelEmitter] shutdown error (swallowed):", err);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// ── Private helpers ────────────────────────────────────────────────────────
|
|
872
|
+
_emitSpan(mapped) {
|
|
873
|
+
if (this.tracer === null) return;
|
|
874
|
+
const parentCtx = mapped.parentSpanId ? this._buildParentContext(mapped.traceId, mapped.parentSpanId) : ROOT_CONTEXT;
|
|
875
|
+
const startMs = Number(mapped.startTimeUnixNano / 1000000n);
|
|
876
|
+
const endMs = Number(mapped.endTimeUnixNano / 1000000n);
|
|
877
|
+
const otelSpan = this.tracer.startSpan(
|
|
878
|
+
mapped.name,
|
|
879
|
+
{
|
|
880
|
+
kind: SpanKind.INTERNAL,
|
|
881
|
+
startTime: startMs,
|
|
882
|
+
attributes: convertAttributes(mapped.attributes),
|
|
883
|
+
links: mapped.links.map((l) => ({
|
|
884
|
+
context: {
|
|
885
|
+
traceId: l.targetTraceId ?? mapped.traceId,
|
|
886
|
+
spanId: l.targetSpanId,
|
|
887
|
+
traceFlags: 1
|
|
888
|
+
},
|
|
889
|
+
attributes: l.attributes
|
|
890
|
+
}))
|
|
891
|
+
},
|
|
892
|
+
parentCtx
|
|
893
|
+
);
|
|
894
|
+
for (const ev of mapped.spanEvents) {
|
|
895
|
+
otelSpan.addEvent(
|
|
896
|
+
ev.name,
|
|
897
|
+
convertAttributes(ev.attributes),
|
|
898
|
+
Number(ev.timeUnixNano / 1000000n)
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
otelSpan.setStatus({ code: SpanStatusCode.OK });
|
|
902
|
+
otelSpan.end(endMs);
|
|
903
|
+
}
|
|
904
|
+
_buildParentContext(traceId, spanId) {
|
|
905
|
+
const spanContext = {
|
|
906
|
+
traceId,
|
|
907
|
+
spanId,
|
|
908
|
+
traceFlags: 1,
|
|
909
|
+
// SAMPLED
|
|
910
|
+
isRemote: false
|
|
911
|
+
};
|
|
912
|
+
return trace.setSpanContext(ROOT_CONTEXT, spanContext);
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
function convertAttributes(attrs) {
|
|
916
|
+
return attrs;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// src/otel/span-mapper.ts
|
|
920
|
+
function mapEventsToSpan(events) {
|
|
921
|
+
if (events.length === 0) return void 0;
|
|
922
|
+
const sorted = [...events].sort(
|
|
923
|
+
(a, b) => a.timeUnixNano < b.timeUnixNano ? -1 : a.timeUnixNano > b.timeUnixNano ? 1 : 0
|
|
924
|
+
);
|
|
925
|
+
const first = sorted[0];
|
|
926
|
+
const spanId = first.spanId;
|
|
927
|
+
const traceId = first.traceId;
|
|
928
|
+
const parentSpanId = first.parentSpanId;
|
|
929
|
+
const name = first.eventType;
|
|
930
|
+
let startTimeUnixNano;
|
|
931
|
+
let endTimeUnixNano;
|
|
932
|
+
const spanEvents = [];
|
|
933
|
+
let mergedAttributes = {};
|
|
934
|
+
const allLinks = [];
|
|
935
|
+
for (const ev of sorted) {
|
|
936
|
+
switch (ev.lifecycle) {
|
|
937
|
+
case "open":
|
|
938
|
+
startTimeUnixNano = ev.timeUnixNano;
|
|
939
|
+
mergedAttributes = { ...mergedAttributes, ...ev.attributes };
|
|
940
|
+
break;
|
|
941
|
+
case "close":
|
|
942
|
+
endTimeUnixNano = ev.timeUnixNano;
|
|
943
|
+
mergedAttributes = { ...mergedAttributes, ...ev.attributes };
|
|
944
|
+
break;
|
|
945
|
+
case "event":
|
|
946
|
+
spanEvents.push({
|
|
947
|
+
name: ev.eventType,
|
|
948
|
+
timeUnixNano: ev.timeUnixNano,
|
|
949
|
+
attributes: ev.attributes
|
|
950
|
+
});
|
|
951
|
+
mergedAttributes = { ...mergedAttributes, ...ev.attributes };
|
|
952
|
+
break;
|
|
953
|
+
case "instant":
|
|
954
|
+
startTimeUnixNano = ev.timeUnixNano;
|
|
955
|
+
endTimeUnixNano = ev.timeUnixNano;
|
|
956
|
+
mergedAttributes = { ...mergedAttributes, ...ev.attributes };
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
allLinks.push(...ev.links);
|
|
960
|
+
}
|
|
961
|
+
if (startTimeUnixNano === void 0) {
|
|
962
|
+
startTimeUnixNano = first.timeUnixNano;
|
|
963
|
+
}
|
|
964
|
+
if (endTimeUnixNano === void 0) {
|
|
965
|
+
endTimeUnixNano = startTimeUnixNano;
|
|
966
|
+
}
|
|
967
|
+
const links = allLinks.map((l) => ({
|
|
968
|
+
targetSpanId: l.targetSpanId,
|
|
969
|
+
targetTraceId: l.targetTraceId,
|
|
970
|
+
attributes: {
|
|
971
|
+
link_type: l.linkType,
|
|
972
|
+
...l.attributes ?? {}
|
|
973
|
+
}
|
|
974
|
+
}));
|
|
975
|
+
return {
|
|
976
|
+
spanId,
|
|
977
|
+
traceId,
|
|
978
|
+
parentSpanId,
|
|
979
|
+
name,
|
|
980
|
+
startTimeUnixNano,
|
|
981
|
+
endTimeUnixNano,
|
|
982
|
+
attributes: mergedAttributes,
|
|
983
|
+
spanEvents,
|
|
984
|
+
links
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
function mapAllEventsToSpans(events) {
|
|
988
|
+
const groups = /* @__PURE__ */ new Map();
|
|
989
|
+
for (const ev of events) {
|
|
990
|
+
const group = groups.get(ev.spanId);
|
|
991
|
+
if (group !== void 0) {
|
|
992
|
+
group.push(ev);
|
|
993
|
+
} else {
|
|
994
|
+
groups.set(ev.spanId, [ev]);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
const spans = [];
|
|
998
|
+
for (const group of groups.values()) {
|
|
999
|
+
const mapped = mapEventsToSpan(group);
|
|
1000
|
+
if (mapped !== void 0) {
|
|
1001
|
+
spans.push(mapped);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return spans;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/query/sqlite-adapter.ts
|
|
1008
|
+
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
1009
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
1010
|
+
import { dirname as dirname2, resolve as resolve2 } from "path";
|
|
1011
|
+
var SqliteQueryAdapter = class {
|
|
1012
|
+
db;
|
|
1013
|
+
ownsDb;
|
|
1014
|
+
/**
|
|
1015
|
+
* @param dbPathOrDb Either a file path string (opens a new connection) or
|
|
1016
|
+
* an existing DatabaseSync instance (shared connection,
|
|
1017
|
+
* e.g. from SqliteSink.getDb() in tests).
|
|
1018
|
+
*/
|
|
1019
|
+
constructor(dbPathOrDb = DEFAULT_DB_PATH) {
|
|
1020
|
+
if (typeof dbPathOrDb === "string") {
|
|
1021
|
+
if (dbPathOrDb !== ":memory:") {
|
|
1022
|
+
const abs = resolve2(dbPathOrDb);
|
|
1023
|
+
mkdirSync2(dirname2(abs), { recursive: true });
|
|
1024
|
+
this.db = new DatabaseSync2(abs);
|
|
1025
|
+
} else {
|
|
1026
|
+
this.db = new DatabaseSync2(":memory:");
|
|
1027
|
+
}
|
|
1028
|
+
this.ownsDb = true;
|
|
1029
|
+
} else {
|
|
1030
|
+
this.db = dbPathOrDb;
|
|
1031
|
+
this.ownsDb = false;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
// ── Span-level queries ─────────────────────────────────────────────────────
|
|
1035
|
+
querySpans(filter) {
|
|
1036
|
+
const { clauses, params } = buildSpanFilter(filter);
|
|
1037
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1038
|
+
const limit = filter.limit !== void 0 ? `LIMIT ${Number(filter.limit)}` : "";
|
|
1039
|
+
const sql = `SELECT * FROM spans ${where} ORDER BY start_time ASC ${limit}`;
|
|
1040
|
+
const stmt = this.db.prepare(sql);
|
|
1041
|
+
stmt.setReadBigInts(true);
|
|
1042
|
+
const rows = stmt.all(...params);
|
|
1043
|
+
return rows.map(parseSpanRow);
|
|
1044
|
+
}
|
|
1045
|
+
getSpan(spanId) {
|
|
1046
|
+
const stmt = this.db.prepare("SELECT * FROM spans WHERE span_id = ?");
|
|
1047
|
+
stmt.setReadBigInts(true);
|
|
1048
|
+
const row = stmt.get(spanId);
|
|
1049
|
+
return row !== void 0 ? parseSpanRow(row) : void 0;
|
|
1050
|
+
}
|
|
1051
|
+
getTrace(traceId) {
|
|
1052
|
+
const stmt = this.db.prepare(
|
|
1053
|
+
"SELECT * FROM spans WHERE trace_id = ? ORDER BY start_time ASC"
|
|
1054
|
+
);
|
|
1055
|
+
stmt.setReadBigInts(true);
|
|
1056
|
+
const rows = stmt.all(traceId);
|
|
1057
|
+
return rows.map(parseSpanRow);
|
|
1058
|
+
}
|
|
1059
|
+
// ── Event-level queries ────────────────────────────────────────────────────
|
|
1060
|
+
queryEvents(filter) {
|
|
1061
|
+
const { clauses, params } = buildEventFilter(filter);
|
|
1062
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1063
|
+
const limit = filter.limit !== void 0 ? `LIMIT ${Number(filter.limit)}` : "";
|
|
1064
|
+
const sql = `SELECT * FROM canonical_events ${where} ORDER BY time_unix_nano ASC ${limit}`;
|
|
1065
|
+
const stmt = this.db.prepare(sql);
|
|
1066
|
+
stmt.setReadBigInts(true);
|
|
1067
|
+
const rows = stmt.all(...params);
|
|
1068
|
+
return rows.map(parseEventRow);
|
|
1069
|
+
}
|
|
1070
|
+
getSpanEvents(spanId) {
|
|
1071
|
+
const stmt = this.db.prepare(
|
|
1072
|
+
"SELECT * FROM canonical_events WHERE span_id = ? ORDER BY time_unix_nano ASC"
|
|
1073
|
+
);
|
|
1074
|
+
stmt.setReadBigInts(true);
|
|
1075
|
+
const rows = stmt.all(spanId);
|
|
1076
|
+
return rows.map(parseEventRow);
|
|
1077
|
+
}
|
|
1078
|
+
// ── Link traversal ─────────────────────────────────────────────────────────
|
|
1079
|
+
getLinks(spanId, direction = "forward") {
|
|
1080
|
+
const rawRows = [];
|
|
1081
|
+
if (direction === "forward" || direction === "both") {
|
|
1082
|
+
const stmt = this.db.prepare(
|
|
1083
|
+
"SELECT * FROM canonical_links WHERE source_span_id = ?"
|
|
1084
|
+
);
|
|
1085
|
+
rawRows.push(...stmt.all(spanId));
|
|
1086
|
+
}
|
|
1087
|
+
if (direction === "reverse" || direction === "both") {
|
|
1088
|
+
const stmt = this.db.prepare(
|
|
1089
|
+
"SELECT * FROM canonical_links WHERE target_span_id = ?"
|
|
1090
|
+
);
|
|
1091
|
+
rawRows.push(...stmt.all(spanId));
|
|
1092
|
+
}
|
|
1093
|
+
if (direction === "both") {
|
|
1094
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1095
|
+
return rawRows.filter((r) => {
|
|
1096
|
+
if (seen.has(r.id)) return false;
|
|
1097
|
+
seen.add(r.id);
|
|
1098
|
+
return true;
|
|
1099
|
+
}).map(parseLinkRow);
|
|
1100
|
+
}
|
|
1101
|
+
return rawRows.map(parseLinkRow);
|
|
1102
|
+
}
|
|
1103
|
+
getLinksByType(linkType) {
|
|
1104
|
+
const stmt = this.db.prepare(
|
|
1105
|
+
"SELECT * FROM canonical_links WHERE link_type = ?"
|
|
1106
|
+
);
|
|
1107
|
+
const rows = stmt.all(linkType);
|
|
1108
|
+
return rows.map(parseLinkRow);
|
|
1109
|
+
}
|
|
1110
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
1111
|
+
close() {
|
|
1112
|
+
if (this.ownsDb) {
|
|
1113
|
+
this.db.close();
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
function parseSpanRow(row) {
|
|
1118
|
+
return {
|
|
1119
|
+
spanId: row.span_id,
|
|
1120
|
+
traceId: row.trace_id,
|
|
1121
|
+
parentSpanId: row.parent_span_id,
|
|
1122
|
+
eventType: row.event_type,
|
|
1123
|
+
startTime: row.start_time,
|
|
1124
|
+
endTime: row.end_time,
|
|
1125
|
+
durationNs: row.duration_ns,
|
|
1126
|
+
status: row.status,
|
|
1127
|
+
runId: row.run_id,
|
|
1128
|
+
sessionId: row.session_id,
|
|
1129
|
+
taskId: row.task_id,
|
|
1130
|
+
attributes: safeParseJson(row.attributes)
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
function parseEventRow(row) {
|
|
1134
|
+
return {
|
|
1135
|
+
id: row.id,
|
|
1136
|
+
timeUnixNano: row.time_unix_nano,
|
|
1137
|
+
source: row.source,
|
|
1138
|
+
eventType: row.event_type,
|
|
1139
|
+
lifecycle: row.lifecycle,
|
|
1140
|
+
traceId: row.trace_id ?? "",
|
|
1141
|
+
spanId: row.span_id,
|
|
1142
|
+
parentSpanId: row.parent_span_id ?? void 0,
|
|
1143
|
+
runId: row.run_id ?? void 0,
|
|
1144
|
+
sessionId: row.session_id ?? void 0,
|
|
1145
|
+
taskId: row.task_id ?? void 0,
|
|
1146
|
+
attributes: safeParseJson(row.attributes),
|
|
1147
|
+
links: []
|
|
1148
|
+
// links are stored in canonical_links, not inline on events
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
function parseLinkRow(row) {
|
|
1152
|
+
return {
|
|
1153
|
+
id: row.id,
|
|
1154
|
+
sourceEventId: row.source_event_id,
|
|
1155
|
+
sourceSpanId: row.source_span_id,
|
|
1156
|
+
targetSpanId: row.target_span_id,
|
|
1157
|
+
targetTraceId: row.target_trace_id,
|
|
1158
|
+
linkType: row.link_type,
|
|
1159
|
+
attributes: safeParseJson(row.attributes)
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
function safeParseJson(json) {
|
|
1163
|
+
try {
|
|
1164
|
+
return JSON.parse(json);
|
|
1165
|
+
} catch {
|
|
1166
|
+
return {};
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
function buildSpanFilter(filter) {
|
|
1170
|
+
const clauses = [];
|
|
1171
|
+
const params = [];
|
|
1172
|
+
if (filter.traceId !== void 0) {
|
|
1173
|
+
clauses.push("trace_id = ?");
|
|
1174
|
+
params.push(filter.traceId);
|
|
1175
|
+
}
|
|
1176
|
+
if (filter.spanId !== void 0) {
|
|
1177
|
+
clauses.push("span_id = ?");
|
|
1178
|
+
params.push(filter.spanId);
|
|
1179
|
+
}
|
|
1180
|
+
if (filter.eventType !== void 0) {
|
|
1181
|
+
clauses.push("event_type = ?");
|
|
1182
|
+
params.push(filter.eventType);
|
|
1183
|
+
}
|
|
1184
|
+
if (filter.taskId !== void 0) {
|
|
1185
|
+
clauses.push("task_id = ?");
|
|
1186
|
+
params.push(filter.taskId);
|
|
1187
|
+
}
|
|
1188
|
+
if (filter.fromTimeUnixNano !== void 0) {
|
|
1189
|
+
clauses.push("start_time >= ?");
|
|
1190
|
+
params.push(filter.fromTimeUnixNano);
|
|
1191
|
+
}
|
|
1192
|
+
if (filter.toTimeUnixNano !== void 0) {
|
|
1193
|
+
clauses.push("start_time <= ?");
|
|
1194
|
+
params.push(filter.toTimeUnixNano);
|
|
1195
|
+
}
|
|
1196
|
+
return { clauses, params };
|
|
1197
|
+
}
|
|
1198
|
+
function buildEventFilter(filter) {
|
|
1199
|
+
const clauses = [];
|
|
1200
|
+
const params = [];
|
|
1201
|
+
if (filter.traceId !== void 0) {
|
|
1202
|
+
clauses.push("trace_id = ?");
|
|
1203
|
+
params.push(filter.traceId);
|
|
1204
|
+
}
|
|
1205
|
+
if (filter.spanId !== void 0) {
|
|
1206
|
+
clauses.push("span_id = ?");
|
|
1207
|
+
params.push(filter.spanId);
|
|
1208
|
+
}
|
|
1209
|
+
if (filter.eventType !== void 0) {
|
|
1210
|
+
clauses.push("event_type = ?");
|
|
1211
|
+
params.push(filter.eventType);
|
|
1212
|
+
}
|
|
1213
|
+
if (filter.taskId !== void 0) {
|
|
1214
|
+
clauses.push("task_id = ?");
|
|
1215
|
+
params.push(filter.taskId);
|
|
1216
|
+
}
|
|
1217
|
+
if (filter.fromTimeUnixNano !== void 0) {
|
|
1218
|
+
clauses.push("time_unix_nano >= ?");
|
|
1219
|
+
params.push(filter.fromTimeUnixNano);
|
|
1220
|
+
}
|
|
1221
|
+
if (filter.toTimeUnixNano !== void 0) {
|
|
1222
|
+
clauses.push("time_unix_nano <= ?");
|
|
1223
|
+
params.push(filter.toTimeUnixNano);
|
|
1224
|
+
}
|
|
1225
|
+
return { clauses, params };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// src/index.ts
|
|
1229
|
+
function createPipeline(options = {}) {
|
|
1230
|
+
const {
|
|
1231
|
+
dbPath = DEFAULT_DB_PATH,
|
|
1232
|
+
afterCorrelate,
|
|
1233
|
+
otel,
|
|
1234
|
+
artifactPatterns = [],
|
|
1235
|
+
enrichRules
|
|
1236
|
+
} = options;
|
|
1237
|
+
const sink = new SqliteSink(dbPath);
|
|
1238
|
+
const correlator = new Correlator();
|
|
1239
|
+
const normalizer = new Normalizer();
|
|
1240
|
+
const otelEmitter = new OtelEmitter(otel ?? {});
|
|
1241
|
+
const rules = enrichRules ?? createDefaultRules(artifactPatterns);
|
|
1242
|
+
const enricher = new Enricher({ correlator, artifactPatterns }, rules);
|
|
1243
|
+
function sinkEvent(event) {
|
|
1244
|
+
sink.write(event);
|
|
1245
|
+
otelEmitter.emit({
|
|
1246
|
+
spanId: event.spanId,
|
|
1247
|
+
traceId: event.traceId,
|
|
1248
|
+
parentSpanId: event.parentSpanId,
|
|
1249
|
+
name: event.eventType,
|
|
1250
|
+
startTimeUnixNano: event.timeUnixNano,
|
|
1251
|
+
endTimeUnixNano: event.timeUnixNano,
|
|
1252
|
+
attributes: event.attributes,
|
|
1253
|
+
spanEvents: [],
|
|
1254
|
+
links: event.links.map((l) => ({
|
|
1255
|
+
targetSpanId: l.targetSpanId,
|
|
1256
|
+
targetTraceId: l.targetTraceId,
|
|
1257
|
+
attributes: { link_type: l.linkType, ...l.attributes ?? {} }
|
|
1258
|
+
}))
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
const collector = new EventCollector((raw) => {
|
|
1262
|
+
const canonical = normalizer.normalize(raw);
|
|
1263
|
+
let correlated = correlator.correlate(canonical);
|
|
1264
|
+
if (afterCorrelate) {
|
|
1265
|
+
correlated = afterCorrelate(correlated);
|
|
1266
|
+
}
|
|
1267
|
+
const { enriched, derived } = enricher.enrich(correlated);
|
|
1268
|
+
sinkEvent(enriched);
|
|
1269
|
+
for (const d of derived) {
|
|
1270
|
+
sinkEvent(d);
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
return { collector, correlator, sink, otelEmitter, enricher };
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
export {
|
|
1277
|
+
generateId,
|
|
1278
|
+
currentTimeUnixNano,
|
|
1279
|
+
isoToUnixNano,
|
|
1280
|
+
EventCollector,
|
|
1281
|
+
DefaultExternalRegistrar,
|
|
1282
|
+
NormalizationError,
|
|
1283
|
+
validateRawEvent,
|
|
1284
|
+
Normalizer,
|
|
1285
|
+
Correlator,
|
|
1286
|
+
keys,
|
|
1287
|
+
globToRegex,
|
|
1288
|
+
lookupArtifact,
|
|
1289
|
+
createArtifactRule,
|
|
1290
|
+
activeTasksKey,
|
|
1291
|
+
allTaskSpansKey,
|
|
1292
|
+
taskFilesKey,
|
|
1293
|
+
createProcessRule,
|
|
1294
|
+
recordTaskArtifact,
|
|
1295
|
+
createCrossAxisRule,
|
|
1296
|
+
createPromotionRule,
|
|
1297
|
+
Enricher,
|
|
1298
|
+
createDefaultRules,
|
|
1299
|
+
DEFAULT_DB_PATH,
|
|
1300
|
+
SqliteSink,
|
|
1301
|
+
OtelEmitter,
|
|
1302
|
+
mapEventsToSpan,
|
|
1303
|
+
mapAllEventsToSpans,
|
|
1304
|
+
SqliteQueryAdapter,
|
|
1305
|
+
createPipeline
|
|
1306
|
+
};
|
|
1307
|
+
//# sourceMappingURL=chunk-FVFGVBXM.js.map
|