@obsunified/collector 2.0.1 → 2.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.
Files changed (126) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Real-SQLite regression for the D1 time-window bug: every timestamp column
3
+ * stores `Date#toISOString()` text (`2026-10-13T05:04:53.123Z`), but the
4
+ * dialect and the shared analysis SQL used `datetime('now', …)`, which
5
+ * renders `2026-10-13 05:04:53`. In a text comparison the space sorts before
6
+ * `T`, so "last 5 minutes" matched the whole current UTC day and
7
+ * `expires_at < now` purges ran a day late. The expressions now render ISO
8
+ * text via `strftime('%Y-%m-%dT%H:%M:%fZ', …)`; these tests execute them.
9
+ */
10
+
11
+ import { readdirSync, readFileSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import type { StoredSpan } from "@obsunified/types";
15
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
16
+ import { deriveAnalysesForProject } from "../analyses/derive";
17
+ import { INVESTIGATION_ANALYSES } from "../analyses/investigations";
18
+ import { TIER0_ANALYSES } from "../analyses/tier0";
19
+ import { purgeExpiredAIData } from "./ai-store/retention";
20
+ import { runSqlAnalysis } from "./analyses-runner";
21
+ import { SQLITE_ISO_FORMAT, sqliteDialect, sqliteIsoNow } from "./sql-db";
22
+ import { ingestTelemetrySpans } from "./store/ingest";
23
+ import {
24
+ createMigratedSqliteDb,
25
+ type NodeSqliteDb,
26
+ } from "./test-utils/node-sqlite-db";
27
+
28
+ const ISO_TEXT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
29
+ const PROJECT = "alpha";
30
+
31
+ const minutesAgo = (minutes: number): string =>
32
+ new Date(Date.now() - minutes * 60_000).toISOString();
33
+
34
+ const span = (over: Partial<StoredSpan>): StoredSpan => ({
35
+ projectId: PROJECT,
36
+ traceId: "trace-1",
37
+ spanId: "span-1",
38
+ parentSpanId: null,
39
+ traceState: null,
40
+ serviceName: "checkout",
41
+ scopeName: null,
42
+ scopeVersion: null,
43
+ spanName: "GET /",
44
+ spanKind: 2,
45
+ statusCode: 1,
46
+ statusMessage: null,
47
+ startTime: minutesAgo(1),
48
+ endTime: minutesAgo(1),
49
+ durationMs: 12,
50
+ attributesJson: "{}",
51
+ droppedAttributesCount: 0,
52
+ resourceAttributesJson: "{}",
53
+ eventsJson: "[]",
54
+ droppedEventsCount: 0,
55
+ linksJson: "[]",
56
+ droppedLinksCount: 0,
57
+ receivedAt: minutesAgo(1),
58
+ expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
59
+ sessionId: null,
60
+ interactionId: null,
61
+ ...over,
62
+ });
63
+
64
+ describe("sqliteDialect time windows against ISO text columns", () => {
65
+ let db: NodeSqliteDb;
66
+
67
+ beforeAll(async () => {
68
+ db = createMigratedSqliteDb();
69
+ await ingestTelemetrySpans(db, [
70
+ // inside the 5-minute window, and an error
71
+ span({
72
+ spanId: "recent-error",
73
+ statusCode: 2,
74
+ receivedAt: minutesAgo(2),
75
+ }),
76
+ // inside the 1-hour baseline only
77
+ span({ spanId: "base-1", receivedAt: minutesAgo(30) }),
78
+ span({ spanId: "base-2", receivedAt: minutesAgo(30) }),
79
+ span({ spanId: "base-3", receivedAt: minutesAgo(30) }),
80
+ // outside both windows, but still "today" in UTC for most of the day
81
+ span({ spanId: "old", receivedAt: minutesAgo(120) }),
82
+ ]);
83
+ });
84
+
85
+ afterAll(() => db.close());
86
+
87
+ it("renders now() and relative windows in the shape Date#toISOString writes", async () => {
88
+ const row = await db
89
+ .prepare(
90
+ `SELECT ${sqliteDialect.now()} AS now_iso,
91
+ ${sqliteDialect.sinceMinutes("?")} AS minutes_iso,
92
+ ${sqliteDialect.sinceHours("?")} AS hours_iso`,
93
+ )
94
+ .bind(5, 1)
95
+ .first<{ now_iso: string; minutes_iso: string; hours_iso: string }>();
96
+ expect(row?.now_iso).toMatch(ISO_TEXT);
97
+ expect(row?.minutes_iso).toMatch(ISO_TEXT);
98
+ expect(row?.hours_iso).toMatch(ISO_TEXT);
99
+ const now = Date.parse(row?.now_iso ?? "");
100
+ expect(Math.abs(now - Date.now())).toBeLessThan(5_000);
101
+ expect(now - Date.parse(row?.minutes_iso ?? "")).toBe(5 * 60_000);
102
+ expect(now - Date.parse(row?.hours_iso ?? "")).toBe(60 * 60_000);
103
+ expect(sqliteIsoNow()).toBe(`strftime('${SQLITE_ISO_FORMAT}', 'now')`);
104
+ });
105
+
106
+ it("sinceMinutes / sinceHours select only rows inside the window", async () => {
107
+ const count = async (where: string, ...binds: unknown[]) => {
108
+ const row = await db
109
+ .prepare(
110
+ `SELECT COUNT(*) AS n FROM telemetry_spans WHERE project_id = ? AND ${where}`,
111
+ )
112
+ .bind(PROJECT, ...binds)
113
+ .first<{ n: number }>();
114
+ return row?.n ?? -1;
115
+ };
116
+ expect(
117
+ await count(`received_at >= ${sqliteDialect.sinceMinutes("?")}`, 5),
118
+ ).toBe(1);
119
+ expect(
120
+ await count(`received_at >= ${sqliteDialect.sinceHours("?")}`, 1),
121
+ ).toBe(4);
122
+ expect(
123
+ await count(
124
+ `received_at >= ${sqliteDialect.sinceHours("?")} AND received_at < ${sqliteDialect.sinceMinutes("?")}`,
125
+ 1,
126
+ 5,
127
+ ),
128
+ ).toBe(3);
129
+ });
130
+
131
+ it("purges rows whose ISO expires_at has passed and keeps the rest", async () => {
132
+ const insert = db.prepare(
133
+ `INSERT INTO ai_span_payloads (project_id, trace_id, span_id, span_kind, received_at, expires_at)
134
+ VALUES (?, ?, ?, 'LLM', ?, ?)`,
135
+ );
136
+ await db.batch([
137
+ insert.bind(PROJECT, "t", "expired", minutesAgo(120), minutesAgo(1)),
138
+ insert.bind(
139
+ PROJECT,
140
+ "t",
141
+ "live",
142
+ minutesAgo(1),
143
+ new Date(Date.now() + 60_000).toISOString(),
144
+ ),
145
+ ]);
146
+ await purgeExpiredAIData(db);
147
+ const rows = await db
148
+ .prepare(`SELECT span_id FROM ai_span_payloads ORDER BY span_id`)
149
+ .all<{ span_id: string }>();
150
+ expect(rows.results.map((r) => r.span_id)).toEqual(["live"]);
151
+ });
152
+
153
+ it("overall_error_rate sees only the last 5 minutes as the primary window", async () => {
154
+ const def = TIER0_ANALYSES.find((d) => d.id === "overall_error_rate");
155
+ if (!def) throw new Error("overall_error_rate analysis missing");
156
+ const result = await runSqlAnalysis(def, {
157
+ db,
158
+ projectId: PROJECT,
159
+ retentionHours: 24,
160
+ });
161
+ // 1 span in the last 5 minutes and it is an error.
162
+ expect(result.primaryValue).toBe(1);
163
+ // 3 ok spans in [-1h, -5m); the 2-hour-old span is outside both.
164
+ expect(result.baselineValue).toBe(0);
165
+ expect(result.status).toBe("critical");
166
+ });
167
+
168
+ it("every built-in and derived analysis executes on SQLite", async () => {
169
+ const derived = await deriveAnalysesForProject(PROJECT, db);
170
+ const defs = [...TIER0_ANALYSES, ...INVESTIGATION_ANALYSES, ...derived];
171
+ expect(defs.length).toBeGreaterThan(TIER0_ANALYSES.length);
172
+ for (const def of defs) {
173
+ if (!def.sql) continue;
174
+ const result = await runSqlAnalysis(def, {
175
+ db,
176
+ projectId: PROJECT,
177
+ retentionHours: 24,
178
+ });
179
+ expect(result.analysisId, def.id).toBe(def.id);
180
+ }
181
+ });
182
+
183
+ it("ships no datetime('now') windows in the analysis sources", () => {
184
+ const here = path.dirname(fileURLToPath(import.meta.url));
185
+ const analysesDir = path.resolve(here, "../analyses");
186
+ const offenders: string[] = [];
187
+ for (const file of readdirSync(analysesDir)) {
188
+ if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue;
189
+ const source = readFileSync(path.join(analysesDir, file), "utf8");
190
+ if (/datetime\(\s*'now'/.test(source)) offenders.push(file);
191
+ }
192
+ expect(offenders).toEqual([]);
193
+ });
194
+ });
package/src/lib/sql-db.ts CHANGED
@@ -96,21 +96,55 @@ export class D1Adapter implements SqlDb {
96
96
  export const sqlDbFor = (env: { DB: D1Database | SqlDb }): SqlDb =>
97
97
  isSqlDb(env.DB) ? env.DB : new D1Adapter(env.DB);
98
98
 
99
+ /**
100
+ * SQLite `strftime` format that reproduces `Date#toISOString()` output
101
+ * (`YYYY-MM-DDTHH:MM:SS.mmmZ`). Every stored timestamp column holds that
102
+ * shape, so time windows must be rendered the same way: `datetime('now')`
103
+ * yields `YYYY-MM-DD HH:MM:SS` and in a text comparison the space sorts
104
+ * before the `T`, which made "last 5 minutes" match the whole current UTC
105
+ * day on D1 and made `expires_at < now` purges run a day late.
106
+ */
107
+ export const SQLITE_ISO_FORMAT = "%Y-%m-%dT%H:%M:%fZ";
108
+
109
+ /** Render SQLite `now` (optionally shifted by a modifier expression) as ISO text. */
110
+ export const sqliteIsoNow = (modifier?: string): string =>
111
+ modifier
112
+ ? `strftime('${SQLITE_ISO_FORMAT}', 'now', ${modifier})`
113
+ : `strftime('${SQLITE_ISO_FORMAT}', 'now')`;
114
+
99
115
  export const sqliteDialect: SqlDialect = {
100
116
  name: "sqlite",
101
- now: () => "datetime('now')",
102
- sinceHours: (param) => `datetime('now', '-' || ${param} || ' hours')`,
103
- sinceMinutes: (param) => `datetime('now', '-' || ${param} || ' minutes')`,
117
+ now: () => sqliteIsoNow(),
118
+ sinceHours: (param) => sqliteIsoNow(`'-' || ${param} || ' hours'`),
119
+ sinceMinutes: (param) => sqliteIsoNow(`'-' || ${param} || ' minutes'`),
104
120
  jsonText: (expr, path) => `json_extract(${expr}, '${path}')`,
105
121
  };
106
122
 
123
+ /**
124
+ * The collector stores every timestamp column (`received_at`, `expires_at`,
125
+ * `occurred_at`, …) as TEXT holding `Date#toISOString()` output on both
126
+ * D1 and Postgres. Postgres will not compare TEXT against a
127
+ * `timestamptz`, so time-window expressions have to be rendered as ISO
128
+ * text in the same `YYYY-MM-DDTHH:MM:SS.mmmZ` shape the collector writes.
129
+ */
130
+ const POSTGRES_ISO_TEXT_FORMAT = `'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'`;
131
+ const POSTGRES_UTC_NOW = "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')";
132
+
133
+ /** Render a Postgres `timestamp` expression as collector-style ISO text. */
134
+ export const postgresIsoText = (expr: string): string =>
135
+ `to_char(${expr}, ${POSTGRES_ISO_TEXT_FORMAT})`;
136
+
107
137
  export const postgresDialect: SqlDialect = {
108
138
  name: "postgres",
109
- now: () => "CURRENT_TIMESTAMP",
139
+ now: () => postgresIsoText(POSTGRES_UTC_NOW),
110
140
  sinceHours: (param) =>
111
- `(CURRENT_TIMESTAMP - (${param}::text || ' hours')::interval)`,
141
+ postgresIsoText(
142
+ `${POSTGRES_UTC_NOW} - (${param}::text || ' hours')::interval`,
143
+ ),
112
144
  sinceMinutes: (param) =>
113
- `(CURRENT_TIMESTAMP - (${param}::text || ' minutes')::interval)`,
145
+ postgresIsoText(
146
+ `${POSTGRES_UTC_NOW} - (${param}::text || ' minutes')::interval`,
147
+ ),
114
148
  jsonText: (expr, path) => {
115
149
  const key = path
116
150
  .replace(/^\$\./, "")
@@ -1,4 +1,8 @@
1
1
  import type { StoredSpan } from "@obsunified/types";
2
+ import {
3
+ extractSpanLineageAndTopology,
4
+ upsertOntologyFromSpans,
5
+ } from "../autosre-ontology";
2
6
  import type { SqlDb } from "../sql-db";
3
7
 
4
8
  export async function ingestTelemetrySpans(
@@ -7,10 +11,29 @@ export async function ingestTelemetrySpans(
7
11
  ): Promise<{ inserted: number; traceCount: number }> {
8
12
  if (spans.length === 0) return { inserted: 0, traceCount: 0 };
9
13
 
10
- const statements = spans.map((span) => {
14
+ const enrichedSpans = spans.map((span) => {
11
15
  if (!span.projectId)
12
16
  throw new Error("TelemetryStore.ingest: span.projectId is required");
13
- return db
17
+ if (
18
+ span.environment !== undefined &&
19
+ span.serviceVersion !== undefined &&
20
+ span.targetNodeId !== undefined
21
+ ) {
22
+ return span;
23
+ }
24
+ const lineage = extractSpanLineageAndTopology(span);
25
+ return {
26
+ ...span,
27
+ environment: span.environment ?? lineage.environment,
28
+ serviceVersion: span.serviceVersion ?? lineage.serviceVersion,
29
+ commitSha: span.commitSha ?? lineage.commitSha,
30
+ targetNodeId: span.targetNodeId ?? lineage.targetNode?.id ?? null,
31
+ errorSignature: span.errorSignature ?? lineage.errorSignature,
32
+ };
33
+ });
34
+
35
+ const statements = enrichedSpans.map((span) =>
36
+ db
14
37
  .prepare(`
15
38
  INSERT OR IGNORE INTO telemetry_spans (
16
39
  project_id, trace_id, span_id, parent_span_id, trace_state,
@@ -19,8 +42,9 @@ export async function ingestTelemetrySpans(
19
42
  attributes_json, dropped_attributes_count,
20
43
  resource_attributes_json, events_json, dropped_events_count,
21
44
  links_json, dropped_links_count, received_at, expires_at,
22
- session_id, interaction_id, telemetry_sdk_name
23
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
45
+ session_id, interaction_id, telemetry_sdk_name,
46
+ environment, service_version, commit_sha, target_node_id, error_signature
47
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
24
48
  `)
25
49
  .bind(
26
50
  span.projectId,
@@ -50,10 +74,74 @@ export async function ingestTelemetrySpans(
50
74
  span.sessionId ?? null,
51
75
  span.interactionId ?? null,
52
76
  span.telemetrySdkName ?? null,
77
+ span.environment ?? "default",
78
+ span.serviceVersion ?? null,
79
+ span.commitSha ?? null,
80
+ span.targetNodeId ?? null,
81
+ span.errorSignature ?? null,
82
+ ),
83
+ );
84
+
85
+ try {
86
+ await db.batch(statements);
87
+ } catch (err) {
88
+ const msg = err instanceof Error ? err.message : String(err);
89
+ if (msg.includes("no such column: environment")) {
90
+ // Fallback for unit tests running against pre-050 partial schemas
91
+ const legacyStatements = enrichedSpans.map((span) =>
92
+ db
93
+ .prepare(`
94
+ INSERT OR IGNORE INTO telemetry_spans (
95
+ project_id, trace_id, span_id, parent_span_id, trace_state,
96
+ service_name, scope_name, scope_version, span_name, span_kind,
97
+ status_code, status_message, start_time, end_time, duration_ms,
98
+ attributes_json, dropped_attributes_count,
99
+ resource_attributes_json, events_json, dropped_events_count,
100
+ links_json, dropped_links_count, received_at, expires_at,
101
+ session_id, interaction_id, telemetry_sdk_name
102
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
103
+ `)
104
+ .bind(
105
+ span.projectId,
106
+ span.traceId,
107
+ span.spanId,
108
+ span.parentSpanId,
109
+ span.traceState,
110
+ span.serviceName,
111
+ span.scopeName,
112
+ span.scopeVersion,
113
+ span.spanName,
114
+ span.spanKind,
115
+ span.statusCode,
116
+ span.statusMessage,
117
+ span.startTime,
118
+ span.endTime,
119
+ span.durationMs,
120
+ span.attributesJson,
121
+ span.droppedAttributesCount,
122
+ span.resourceAttributesJson,
123
+ span.eventsJson,
124
+ span.droppedEventsCount,
125
+ span.linksJson,
126
+ span.droppedLinksCount,
127
+ span.receivedAt,
128
+ span.expiresAt,
129
+ span.sessionId ?? null,
130
+ span.interactionId ?? null,
131
+ span.telemetrySdkName ?? null,
132
+ ),
53
133
  );
54
- });
134
+ await db.batch(legacyStatements);
135
+ } else {
136
+ throw err;
137
+ }
138
+ }
55
139
 
56
- await db.batch(statements);
140
+ try {
141
+ await upsertOntologyFromSpans(db, enrichedSpans);
142
+ } catch {
143
+ // Non-fatal on partial-schema unit test DBs
144
+ }
57
145
 
58
146
  // Trace instrumentation gaps are computed lazily at read time (see
59
147
  // `getTelemetryTraceGaps`), not materialized on ingest. This keeps the
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Builders for RFC 0010 action-graph rows used by the retention tests on
3
+ * both SQLite (`action-retention.test.ts`) and live Postgres
4
+ * (`sql-db-postgres.integration.test.ts`). Everything is written through
5
+ * `ActionStore`, i.e. the same SQL the ingest path uses.
6
+ */
7
+
8
+ import {
9
+ type ActionRecord,
10
+ ActionStore,
11
+ type AgentRunRecord,
12
+ type ArtifactRecord,
13
+ type EvalResultRecord,
14
+ type RetrievalEventRecord,
15
+ type ToolCallRecord,
16
+ } from "../actions-store";
17
+ import type { SqlDb } from "../sql-db";
18
+
19
+ /** Fixed clock for deterministic cutoffs: 2026-10-12T12:00:00Z. */
20
+ export const FIXTURE_NOW_MS = Date.UTC(2026, 9, 12, 12, 0, 0);
21
+ export const FIXTURE_RETENTION_HOURS = 72;
22
+
23
+ /** ISO timestamp `hours` before `FIXTURE_NOW_MS`. */
24
+ export const hoursAgo = (hours: number): string =>
25
+ new Date(FIXTURE_NOW_MS - hours * 3_600_000).toISOString();
26
+
27
+ export const EXPIRED_START = hoursAgo(100);
28
+ export const FRESH_START = hoursAgo(1);
29
+
30
+ export const actionRecord = (
31
+ over: Partial<ActionRecord> = {},
32
+ ): ActionRecord => {
33
+ const id = over.id ?? "act-1";
34
+ return {
35
+ id,
36
+ projectId: "alpha",
37
+ rootActionId: id,
38
+ causedByActionId: null,
39
+ actorType: "agent",
40
+ actorId: "agent-1",
41
+ actionKind: "agent.run",
42
+ name: "run",
43
+ status: "ok",
44
+ startedAt: EXPIRED_START,
45
+ endedAt: EXPIRED_START,
46
+ durationMs: 10,
47
+ traceId: null,
48
+ spanId: null,
49
+ sessionId: null,
50
+ interactionId: null,
51
+ userId: null,
52
+ agentRunId: null,
53
+ stepId: null,
54
+ toolCallId: null,
55
+ promptVersion: null,
56
+ modelName: null,
57
+ provider: null,
58
+ totalCostUsd: null,
59
+ causalConfidence: "explicit",
60
+ attrsJson: null,
61
+ ...over,
62
+ };
63
+ };
64
+
65
+ export const agentRunRecord = (
66
+ over: Partial<AgentRunRecord> = {},
67
+ ): AgentRunRecord => ({
68
+ id: "act-1",
69
+ projectId: "alpha",
70
+ agentId: "agent-1",
71
+ agentName: "Agent",
72
+ agentVersion: "1",
73
+ goal: null,
74
+ outcome: null,
75
+ autonomyLevel: "autonomous_write",
76
+ status: "success",
77
+ errorMessage: null,
78
+ totalCostUsd: 0,
79
+ totalDurationMs: 10,
80
+ metadataJson: null,
81
+ ...over,
82
+ });
83
+
84
+ export const toolCallRecord = (
85
+ over: Partial<ToolCallRecord> = {},
86
+ ): ToolCallRecord => ({
87
+ id: "tc-1",
88
+ actionId: "act-1",
89
+ projectId: "alpha",
90
+ toolName: "sql_query",
91
+ argsHash: "a",
92
+ resultHash: "r",
93
+ errorType: null,
94
+ sideEffect: 0,
95
+ approvalState: null,
96
+ argsRedacted: null,
97
+ resultRedacted: null,
98
+ mcpAuditJson: null,
99
+ mutationBeforeJson: null,
100
+ mutationAfterJson: null,
101
+ mutationDiffJson: null,
102
+ mutationArtifactId: null,
103
+ ...over,
104
+ });
105
+
106
+ export const retrievalEventRecord = (
107
+ over: Partial<RetrievalEventRecord> = {},
108
+ ): RetrievalEventRecord => ({
109
+ id: "re-1",
110
+ actionId: "act-1",
111
+ projectId: "alpha",
112
+ retrieverName: "vector-db",
113
+ queryHash: "q",
114
+ documentsJson: null,
115
+ totalResults: 0,
116
+ maxRelevanceScore: null,
117
+ durationMs: null,
118
+ ...over,
119
+ });
120
+
121
+ export const evalResultRecord = (
122
+ over: Partial<EvalResultRecord> = {},
123
+ ): EvalResultRecord => ({
124
+ id: "er-1",
125
+ actionId: "act-1",
126
+ projectId: "alpha",
127
+ evaluatorName: "groundedness",
128
+ evaluatorVersion: "1",
129
+ score: 1,
130
+ passed: 1,
131
+ reasoning: null,
132
+ rubricJson: null,
133
+ ...over,
134
+ });
135
+
136
+ export const artifactRecord = (
137
+ over: Partial<ArtifactRecord> = {},
138
+ ): ArtifactRecord => ({
139
+ id: "af-1",
140
+ actionId: "act-1",
141
+ projectId: "alpha",
142
+ artifactName: "invoice-pdf",
143
+ artifactType: "file",
144
+ storageRef: "s3://bucket/invoice.pdf",
145
+ sizeBytes: 1,
146
+ sha256Hash: null,
147
+ contentPreview: null,
148
+ ...over,
149
+ });
150
+
151
+ export interface SeedTreeOptions {
152
+ projectId?: string;
153
+ /** Root action id; also the `agent_runs.id`. */
154
+ rootId: string;
155
+ rootStartedAt?: string;
156
+ childStartedAt?: string;
157
+ /** `null` leaves the child in progress. */
158
+ childEndedAt?: string | null;
159
+ agentRunStatus?: string;
160
+ /** Attach one row of every leaf table to the child action. */
161
+ withLeaves?: boolean;
162
+ }
163
+
164
+ /**
165
+ * Seed a two-action tree (root `agent.run` + child `tool.call`) with an
166
+ * `agent_runs` row for the root and, optionally, one row in each leaf table
167
+ * hanging off the child. Returns the ids so tests can assert on them.
168
+ */
169
+ export async function seedTree(db: SqlDb, options: SeedTreeOptions) {
170
+ const store = new ActionStore(db);
171
+ const projectId = options.projectId ?? "alpha";
172
+ const rootId = options.rootId;
173
+ const childId = `${rootId}-child`;
174
+ const rootStartedAt = options.rootStartedAt ?? EXPIRED_START;
175
+ const childStartedAt = options.childStartedAt ?? rootStartedAt;
176
+ const childEndedAt =
177
+ options.childEndedAt === undefined ? childStartedAt : options.childEndedAt;
178
+
179
+ await store.upsertAction(
180
+ actionRecord({
181
+ id: rootId,
182
+ projectId,
183
+ rootActionId: rootId,
184
+ agentRunId: rootId,
185
+ startedAt: rootStartedAt,
186
+ endedAt: rootStartedAt,
187
+ }),
188
+ );
189
+ await store.upsertAgentRun(
190
+ agentRunRecord({
191
+ id: rootId,
192
+ projectId,
193
+ status: options.agentRunStatus ?? "success",
194
+ }),
195
+ );
196
+ await store.upsertAction(
197
+ actionRecord({
198
+ id: childId,
199
+ projectId,
200
+ rootActionId: rootId,
201
+ causedByActionId: rootId,
202
+ agentRunId: rootId,
203
+ actionKind: "tool.call",
204
+ startedAt: childStartedAt,
205
+ endedAt: childEndedAt,
206
+ toolCallId: `${childId}-tc`,
207
+ }),
208
+ );
209
+
210
+ const leaves = {
211
+ toolCallId: `${childId}-tc`,
212
+ retrievalEventId: `${childId}-re`,
213
+ evalResultId: `${childId}-er`,
214
+ artifactId: `${childId}-af`,
215
+ };
216
+ if (options.withLeaves ?? true) {
217
+ await store.upsertArtifact(
218
+ artifactRecord({ id: leaves.artifactId, actionId: childId, projectId }),
219
+ );
220
+ await store.upsertToolCall(
221
+ toolCallRecord({
222
+ id: leaves.toolCallId,
223
+ actionId: childId,
224
+ projectId,
225
+ mutationArtifactId: leaves.artifactId,
226
+ }),
227
+ );
228
+ await store.upsertRetrievalEvent(
229
+ retrievalEventRecord({
230
+ id: leaves.retrievalEventId,
231
+ actionId: childId,
232
+ projectId,
233
+ }),
234
+ );
235
+ await store.upsertEvalResult(
236
+ evalResultRecord({
237
+ id: leaves.evalResultId,
238
+ actionId: childId,
239
+ projectId,
240
+ }),
241
+ );
242
+ }
243
+
244
+ return { projectId, rootId, childId, ...leaves };
245
+ }
246
+
247
+ /** Minimal `eval_cases` row pointing at production entities. */
248
+ export async function seedEvalCase(
249
+ db: SqlDb,
250
+ over: {
251
+ id: string;
252
+ projectId?: string;
253
+ sourceActionId?: string | null;
254
+ sourceAgentRunId?: string | null;
255
+ sourceToolCallId?: string | null;
256
+ },
257
+ ): Promise<void> {
258
+ const now = new Date(FIXTURE_NOW_MS).toISOString();
259
+ await db
260
+ .prepare(
261
+ `INSERT INTO eval_cases (
262
+ id, project_id, source_entity_type, source_entity_id,
263
+ source_agent_run_id, source_action_id, source_tool_call_id,
264
+ name, metadata_json, created_at, updated_at
265
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
266
+ )
267
+ .bind(
268
+ over.id,
269
+ over.projectId ?? "alpha",
270
+ over.sourceActionId
271
+ ? "action"
272
+ : over.sourceToolCallId
273
+ ? "tool_call"
274
+ : "agent_run",
275
+ over.sourceActionId ??
276
+ over.sourceToolCallId ??
277
+ over.sourceAgentRunId ??
278
+ "unknown",
279
+ over.sourceAgentRunId ?? null,
280
+ over.sourceActionId ?? null,
281
+ over.sourceToolCallId ?? null,
282
+ `case ${over.id}`,
283
+ "{}",
284
+ now,
285
+ now,
286
+ )
287
+ .run();
288
+ }
289
+
290
+ /** Row counts across the whole action graph for a project. */
291
+ export async function countActionGraph(db: SqlDb, projectId: string) {
292
+ const count = async (table: string) => {
293
+ const row = await db
294
+ .prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE project_id = ?`)
295
+ .bind(projectId)
296
+ .first<{ n: number | string | bigint }>();
297
+ return Number(row?.n ?? 0);
298
+ };
299
+ return {
300
+ actions: await count("actions"),
301
+ agent_runs: await count("agent_runs"),
302
+ tool_calls: await count("tool_calls"),
303
+ retrieval_events: await count("retrieval_events"),
304
+ eval_results: await count("eval_results"),
305
+ artifacts: await count("artifacts"),
306
+ };
307
+ }