@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.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
- package/dist/chunk-ZSF5XP3B.js.map +1 -0
- package/dist/index.d.ts +630 -27
- package/dist/index.js +5819 -853
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
- package/package.json +5 -5
- package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +59 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +69 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/autosre-change-correlation.ts +780 -0
- package/src/lib/autosre-findings.ts +725 -0
- package/src/lib/autosre-ontology.ts +914 -0
- package/src/lib/autosre-remediation-gate.ts +1663 -0
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/eval-cases-store.ts +63 -1
- package/src/lib/evidence-references.ts +6 -0
- package/src/lib/housekeeping.test.ts +153 -0
- package/src/lib/housekeeping.ts +193 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/store/ingest.ts +94 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +68 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +181 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/autosre-routes.ts +1019 -0
- package/src/plugins/connected-routes/manifest.ts +13 -1
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +285 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/default-span-enrichment.ts +13 -1
- package/src/plugins/eval-cases-routes.ts +65 -19
- package/src/plugins/evidence-retrieval-routes.ts +300 -4
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- package/dist/chunk-UGWDOGGH.js.map +0 -1
- package/dist/chunk-XKQTVIRE.js.map +0 -1
- /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-Postgres integration checks for the standalone Node collector path
|
|
3
|
+
* (RFC 0008 `PostgresAdapter`). Skipped unless `OBS_PG_TEST_URL` is set:
|
|
4
|
+
*
|
|
5
|
+
* OBS_PG_TEST_URL=postgres://obs:obs@127.0.0.1:5432/obs_unified \
|
|
6
|
+
* pnpm --filter @obsunified/collector exec vitest run \
|
|
7
|
+
* src/lib/sql-db-postgres.integration.test.ts
|
|
8
|
+
*
|
|
9
|
+
* Point it at an EMPTY scratch database: the run applies
|
|
10
|
+
* `migrations-postgres/` into the connection's default schema, refuses to
|
|
11
|
+
* start if the schema already contains any table, and drops every table it
|
|
12
|
+
* created afterwards. (Session state such as `search_path` is deliberately not
|
|
13
|
+
* relied upon so the suite also runs against single-session servers like
|
|
14
|
+
* PGlite's socket bridge.)
|
|
15
|
+
*
|
|
16
|
+
* Each test pins a Postgres-only failure that D1 never surfaced: BYTEA vs
|
|
17
|
+
* hex `key_hash`, BOOLEAN flags, TEXT timestamps compared to
|
|
18
|
+
* `timestamptz` (including the `metric_exemplars` TIMESTAMPTZ columns that
|
|
19
|
+
* 043 converts), and SQLite built-ins in the shared analysis SQL.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import type {
|
|
26
|
+
AICallRecord,
|
|
27
|
+
AlertRule,
|
|
28
|
+
StoredSpan,
|
|
29
|
+
UsageEventRecord,
|
|
30
|
+
} from "@obsunified/types";
|
|
31
|
+
import type { MiddlewareHandler } from "hono";
|
|
32
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
33
|
+
import { deriveAnalysesForProject } from "../analyses/derive";
|
|
34
|
+
import { INVESTIGATION_ANALYSES } from "../analyses/investigations";
|
|
35
|
+
import { TIER0_ANALYSES } from "../analyses/tier0";
|
|
36
|
+
import {
|
|
37
|
+
type CollectorRuntime,
|
|
38
|
+
createTelemetryCollectorApp,
|
|
39
|
+
type SpanProcessorPlugin,
|
|
40
|
+
} from "../framework/collector";
|
|
41
|
+
import type { CollectorEnv, CollectorRouteContext } from "../framework/env";
|
|
42
|
+
import type { DecodedMetricPoint } from "../otlp/decode";
|
|
43
|
+
import { aiSpanPayloadsProcessorPlugin } from "../plugins/ai-span-payloads-processor";
|
|
44
|
+
import { identityReceiverPlugin } from "../plugins/identity-receiver";
|
|
45
|
+
import { purgeExpiredActionGraph } from "./action-retention";
|
|
46
|
+
import { ActionStore } from "./actions-store";
|
|
47
|
+
import { getAICallsOverview, ingestAICallBatch } from "./ai-store/calls";
|
|
48
|
+
import { purgeExpiredAIData } from "./ai-store/retention";
|
|
49
|
+
import { getAISpansOverview } from "./ai-store/spans";
|
|
50
|
+
import { AlertsStore } from "./alerts-store";
|
|
51
|
+
import { runSqlAnalysis } from "./analyses-runner";
|
|
52
|
+
import { sha256Hex } from "./hash";
|
|
53
|
+
import { MetricsStore } from "./metrics-store";
|
|
54
|
+
import { ProjectsStore } from "./projects-store";
|
|
55
|
+
import {
|
|
56
|
+
configurePostgresTypeParsers,
|
|
57
|
+
PostgresAdapter,
|
|
58
|
+
} from "./sql-db-postgres";
|
|
59
|
+
import { ingestTelemetrySpans } from "./store/ingest";
|
|
60
|
+
import {
|
|
61
|
+
actionRecord,
|
|
62
|
+
agentRunRecord,
|
|
63
|
+
countActionGraph,
|
|
64
|
+
FIXTURE_NOW_MS,
|
|
65
|
+
FIXTURE_RETENTION_HOURS,
|
|
66
|
+
FRESH_START,
|
|
67
|
+
seedEvalCase,
|
|
68
|
+
seedTree,
|
|
69
|
+
} from "./test-utils/action-graph-fixtures";
|
|
70
|
+
import { UsageStore } from "./usage-store";
|
|
71
|
+
|
|
72
|
+
const databaseUrl = process.env.OBS_PG_TEST_URL;
|
|
73
|
+
|
|
74
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
75
|
+
const migrationsDir = path.resolve(here, "../migrations-postgres");
|
|
76
|
+
|
|
77
|
+
/** Plaintext lives in apps/web/.env.development.local; this is its SHA-256. */
|
|
78
|
+
const DASHBOARD_SEED_HASH =
|
|
79
|
+
"6aa18dc1261b312e98b236d5a7dae698d1a90ecbe33d8024744063a066a35b9b";
|
|
80
|
+
|
|
81
|
+
const VALID_STATUSES = new Set(["ok", "warn", "critical", "unknown"]);
|
|
82
|
+
|
|
83
|
+
type PgModule = typeof import("pg");
|
|
84
|
+
type PgPool = import("pg").Pool;
|
|
85
|
+
type PgClient = import("pg").Client;
|
|
86
|
+
|
|
87
|
+
const isoNow = () => new Date().toISOString();
|
|
88
|
+
const isoIn = (ms: number) => new Date(Date.now() + ms).toISOString();
|
|
89
|
+
|
|
90
|
+
const aiCall = (over: Partial<AICallRecord>): AICallRecord => ({
|
|
91
|
+
projectId: "default",
|
|
92
|
+
callId: `call-${Math.random().toString(36).slice(2)}`,
|
|
93
|
+
traceId: null,
|
|
94
|
+
spanId: null,
|
|
95
|
+
serviceName: "checkout",
|
|
96
|
+
modelName: "gpt-test",
|
|
97
|
+
provider: "openai",
|
|
98
|
+
callType: "chat",
|
|
99
|
+
requestJson: JSON.stringify({ messages: [] }),
|
|
100
|
+
responseJson: null,
|
|
101
|
+
promptTokens: 10,
|
|
102
|
+
completionTokens: 5,
|
|
103
|
+
totalCostUsd: 0.01,
|
|
104
|
+
latencyMs: 120,
|
|
105
|
+
isError: false,
|
|
106
|
+
errorMessage: null,
|
|
107
|
+
occurredAt: isoNow(),
|
|
108
|
+
receivedAt: isoNow(),
|
|
109
|
+
expiresAt: isoIn(60 * 60 * 1000),
|
|
110
|
+
...over,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const usageEvent = (over: Partial<UsageEventRecord>): UsageEventRecord => ({
|
|
114
|
+
projectId: "default",
|
|
115
|
+
eventId: `evt-${Math.random().toString(36).slice(2)}`,
|
|
116
|
+
sessionId: "sess-1",
|
|
117
|
+
visitorId: "visitor-1",
|
|
118
|
+
eventType: "page_view",
|
|
119
|
+
eventName: "page_view",
|
|
120
|
+
pagePath: "/",
|
|
121
|
+
pageTitle: "Home",
|
|
122
|
+
referrer: null,
|
|
123
|
+
severity: "info",
|
|
124
|
+
source: "web",
|
|
125
|
+
contextJson: JSON.stringify({ viewport: "1280x720" }),
|
|
126
|
+
propertiesJson: JSON.stringify({ loadTimeMs: 250 }),
|
|
127
|
+
userAgent: "Mozilla/5.0",
|
|
128
|
+
occurredAt: isoNow(),
|
|
129
|
+
receivedAt: isoNow(),
|
|
130
|
+
expiresAt: isoIn(60 * 60 * 1000),
|
|
131
|
+
country: "US",
|
|
132
|
+
browser: "Chrome",
|
|
133
|
+
os: "macOS",
|
|
134
|
+
deviceType: "desktop",
|
|
135
|
+
isBot: false,
|
|
136
|
+
utmSource: null,
|
|
137
|
+
utmMedium: null,
|
|
138
|
+
utmCampaign: null,
|
|
139
|
+
...over,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const metricPoint = (
|
|
143
|
+
over: Partial<DecodedMetricPoint>,
|
|
144
|
+
): DecodedMetricPoint => ({
|
|
145
|
+
name: "http.server.duration",
|
|
146
|
+
description: null,
|
|
147
|
+
unit: "ms",
|
|
148
|
+
type: "histogram",
|
|
149
|
+
isMonotonic: null,
|
|
150
|
+
temporality: 2,
|
|
151
|
+
scopeName: "@opentelemetry/instrumentation-http",
|
|
152
|
+
scopeVersion: "1.0.0",
|
|
153
|
+
serviceName: "checkout",
|
|
154
|
+
resourceAttrsJson: JSON.stringify({ "service.name": "checkout" }),
|
|
155
|
+
attributesJson: JSON.stringify({ route: "/checkout" }),
|
|
156
|
+
identity: `metric-${Math.random().toString(36).slice(2)}`,
|
|
157
|
+
tsNs: "1800000000000000000",
|
|
158
|
+
startTsNs: null,
|
|
159
|
+
value: null,
|
|
160
|
+
count: 1,
|
|
161
|
+
sum: 42,
|
|
162
|
+
min: null,
|
|
163
|
+
max: null,
|
|
164
|
+
boundsJson: JSON.stringify([50]),
|
|
165
|
+
bucketCountsJson: JSON.stringify([1]),
|
|
166
|
+
extraJson: null,
|
|
167
|
+
exemplarsJson: null,
|
|
168
|
+
...over,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const exemplarJson = (traceId: string, tsNs: string) =>
|
|
172
|
+
JSON.stringify([{ value: 1, traceId, spanId: null, tsNs }]);
|
|
173
|
+
|
|
174
|
+
/** Same `to_char` shape `postgresDialect.now()` and 043 render. */
|
|
175
|
+
const ISO_TEXT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
176
|
+
|
|
177
|
+
describe.skipIf(!databaseUrl)("PostgresAdapter against a live Postgres", () => {
|
|
178
|
+
const legacySchema = `obs_pg_test_legacy_${Date.now().toString(36)}`;
|
|
179
|
+
let pg: PgModule;
|
|
180
|
+
let pool: PgPool;
|
|
181
|
+
let db: PostgresAdapter;
|
|
182
|
+
let migrationFiles: string[] = [];
|
|
183
|
+
let preexistingTables = new Set<string>();
|
|
184
|
+
|
|
185
|
+
const adminClient = async (): Promise<PgClient> => {
|
|
186
|
+
const client = new pg.Client({ connectionString: databaseUrl });
|
|
187
|
+
await client.connect();
|
|
188
|
+
return client;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const listTables = async (client: PgClient): Promise<Set<string>> => {
|
|
192
|
+
const res = await client.query<{ table_name: string }>(
|
|
193
|
+
`SELECT table_name FROM information_schema.tables
|
|
194
|
+
WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'`,
|
|
195
|
+
);
|
|
196
|
+
return new Set(res.rows.map((r) => r.table_name));
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
beforeAll(async () => {
|
|
200
|
+
pg = (await import("pg")).default as unknown as PgModule;
|
|
201
|
+
configurePostgresTypeParsers(pg.types);
|
|
202
|
+
|
|
203
|
+
const admin = await adminClient();
|
|
204
|
+
try {
|
|
205
|
+
preexistingTables = await listTables(admin);
|
|
206
|
+
if (preexistingTables.size > 0) {
|
|
207
|
+
// Refuse any non-empty schema, not just one with collector tables,
|
|
208
|
+
// so the teardown below can never touch an unrelated database.
|
|
209
|
+
throw new Error(
|
|
210
|
+
`OBS_PG_TEST_URL must point at an empty scratch database (found ${preexistingTables.size} table(s): ${[...preexistingTables].sort().join(", ")})`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
await admin.query(`CREATE SCHEMA ${legacySchema}`);
|
|
214
|
+
|
|
215
|
+
// Apply every Postgres migration the same way
|
|
216
|
+
// apps/collector-node/scripts/migrate-pg.mjs does (one transaction
|
|
217
|
+
// per file).
|
|
218
|
+
migrationFiles = (await readdir(migrationsDir))
|
|
219
|
+
.filter((f) => f.endsWith(".sql"))
|
|
220
|
+
.sort();
|
|
221
|
+
for (const file of migrationFiles) {
|
|
222
|
+
const sql = await readFile(path.join(migrationsDir, file), "utf8");
|
|
223
|
+
await admin.query("BEGIN");
|
|
224
|
+
try {
|
|
225
|
+
await admin.query(sql);
|
|
226
|
+
await admin.query("COMMIT");
|
|
227
|
+
} catch (err) {
|
|
228
|
+
await admin.query("ROLLBACK");
|
|
229
|
+
throw new Error(`${file} failed: ${(err as Error).message}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
await admin.end();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// max: 1 keeps the run deterministic on single-session servers
|
|
237
|
+
// (PGlite's socket bridge) and costs nothing against real Postgres.
|
|
238
|
+
pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
|
|
239
|
+
db = new PostgresAdapter(pool);
|
|
240
|
+
}, 120_000);
|
|
241
|
+
|
|
242
|
+
afterAll(async () => {
|
|
243
|
+
await pool?.end();
|
|
244
|
+
if (!pg) return;
|
|
245
|
+
const admin = await adminClient();
|
|
246
|
+
try {
|
|
247
|
+
await admin.query(`DROP SCHEMA IF EXISTS ${legacySchema} CASCADE`);
|
|
248
|
+
const created = [...(await listTables(admin))].filter(
|
|
249
|
+
(t) => !preexistingTables.has(t),
|
|
250
|
+
);
|
|
251
|
+
for (const table of created) {
|
|
252
|
+
await admin.query(`DROP TABLE IF EXISTS "${table}" CASCADE`);
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
await admin.end();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("applies every migration and leaves ingest_keys.key_hash as TEXT", async () => {
|
|
260
|
+
expect(migrationFiles).toContain("041_ingest_key_hash_text.sql");
|
|
261
|
+
const row = await db
|
|
262
|
+
.prepare(
|
|
263
|
+
`SELECT data_type FROM information_schema.columns
|
|
264
|
+
WHERE table_schema = current_schema() AND table_name = 'ingest_keys' AND column_name = 'key_hash'`,
|
|
265
|
+
)
|
|
266
|
+
.first<{ data_type: string }>();
|
|
267
|
+
expect(row?.data_type).toBe("text");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("resolves the seeded obs-dashboard key by its hex digest", async () => {
|
|
271
|
+
const store = new ProjectsStore(db);
|
|
272
|
+
await expect(store.findByKeyHash(DASHBOARD_SEED_HASH)).resolves.toEqual({
|
|
273
|
+
projectId: "obs-dashboard",
|
|
274
|
+
keyId: "obs-dashboard-bootstrap",
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("bootstraps INGEST_KEY, mints and revokes keys through the hex digest", async () => {
|
|
279
|
+
const store = new ProjectsStore(db);
|
|
280
|
+
await store.bootstrapEnvKey("dev-ingest-key");
|
|
281
|
+
await store.bootstrapEnvKey("dev-ingest-key"); // idempotent
|
|
282
|
+
const bootstrapped = await store.findByKeyHash(
|
|
283
|
+
await sha256Hex("dev-ingest-key"),
|
|
284
|
+
);
|
|
285
|
+
expect(bootstrapped?.projectId).toBe("default");
|
|
286
|
+
|
|
287
|
+
const minted = await store.createKey("default", "ci");
|
|
288
|
+
const found = await store.findByKeyHash(await sha256Hex(minted.key));
|
|
289
|
+
expect(found).toEqual({ projectId: "default", keyId: minted.id });
|
|
290
|
+
|
|
291
|
+
expect(await store.revokeKey(minted.id, "other-project")).toBe(false);
|
|
292
|
+
expect(await store.revokeKey(minted.id, "default")).toBe(true);
|
|
293
|
+
expect(await store.findByKeyHash(await sha256Hex(minted.key))).toBeNull();
|
|
294
|
+
|
|
295
|
+
const listed = await store.listKeys("default");
|
|
296
|
+
expect(listed.map((k) => k.name).sort()).toEqual(["bootstrap", "ci"]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("migration 041 converts a legacy BYTEA key_hash column in place", async () => {
|
|
300
|
+
const legacy = await adminClient();
|
|
301
|
+
try {
|
|
302
|
+
await legacy.query(`SET search_path TO ${legacySchema}`);
|
|
303
|
+
// Shape of migrations-postgres/009 before the fix.
|
|
304
|
+
await legacy.query(`
|
|
305
|
+
CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL);
|
|
306
|
+
CREATE TABLE ingest_keys (
|
|
307
|
+
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, key_hash BYTEA NOT NULL UNIQUE,
|
|
308
|
+
key_prefix TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, revoked_at TEXT,
|
|
309
|
+
FOREIGN KEY (project_id) REFERENCES projects (id));
|
|
310
|
+
CREATE INDEX idx_ingest_keys_hash ON ingest_keys (key_hash);
|
|
311
|
+
INSERT INTO projects VALUES ('default', 'Default', 'default', 'x'), ('obs-dashboard', 'Obs Dashboard', 'obs-dashboard', 'x');
|
|
312
|
+
`);
|
|
313
|
+
// 026 seeded the raw 32 digest bytes …
|
|
314
|
+
await legacy.query(
|
|
315
|
+
`INSERT INTO ingest_keys VALUES ('seed', 'obs-dashboard', decode($1, 'hex'), 'obs_dashboard_65', 'bootstrap', 'x', NULL)`,
|
|
316
|
+
[DASHBOARD_SEED_HASH],
|
|
317
|
+
);
|
|
318
|
+
// … while the collector bound the hex text, which BYTEA kept as 64 ASCII bytes.
|
|
319
|
+
const bootstrapHash = await sha256Hex("dev-ingest-key");
|
|
320
|
+
await legacy.query(
|
|
321
|
+
`INSERT INTO ingest_keys VALUES ('boot', 'default', $1, 'dev-ingest-key', 'bootstrap', 'x', NULL)`,
|
|
322
|
+
[bootstrapHash],
|
|
323
|
+
);
|
|
324
|
+
const before = await legacy.query(
|
|
325
|
+
`SELECT id, octet_length(key_hash) AS len FROM ingest_keys ORDER BY id`,
|
|
326
|
+
);
|
|
327
|
+
expect(before.rows).toEqual([
|
|
328
|
+
{ id: "boot", len: 64 },
|
|
329
|
+
{ id: "seed", len: 32 },
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
const sql = await readFile(
|
|
333
|
+
path.join(migrationsDir, "041_ingest_key_hash_text.sql"),
|
|
334
|
+
"utf8",
|
|
335
|
+
);
|
|
336
|
+
await legacy.query(sql);
|
|
337
|
+
await legacy.query(sql); // re-running is a no-op
|
|
338
|
+
|
|
339
|
+
const type = await legacy.query(
|
|
340
|
+
`SELECT data_type FROM information_schema.columns
|
|
341
|
+
WHERE table_schema = $1 AND table_name = 'ingest_keys' AND column_name = 'key_hash'`,
|
|
342
|
+
[legacySchema],
|
|
343
|
+
);
|
|
344
|
+
expect(type.rows[0]?.data_type).toBe("text");
|
|
345
|
+
const after = await legacy.query(
|
|
346
|
+
`SELECT id, key_hash FROM ingest_keys ORDER BY id`,
|
|
347
|
+
);
|
|
348
|
+
expect(after.rows).toEqual([
|
|
349
|
+
{ id: "boot", key_hash: bootstrapHash },
|
|
350
|
+
{ id: "seed", key_hash: DASHBOARD_SEED_HASH },
|
|
351
|
+
]);
|
|
352
|
+
const lookup = await legacy.query(
|
|
353
|
+
`SELECT id FROM ingest_keys WHERE key_hash = $1`,
|
|
354
|
+
[DASHBOARD_SEED_HASH],
|
|
355
|
+
);
|
|
356
|
+
expect(lookup.rows).toEqual([{ id: "seed" }]);
|
|
357
|
+
} finally {
|
|
358
|
+
// Single-session servers (PGlite) share GUCs across sockets, so put
|
|
359
|
+
// search_path back before the pooled adapter runs again.
|
|
360
|
+
await legacy.query("RESET search_path").catch(() => {});
|
|
361
|
+
await legacy.end();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("creates metric_exemplars timestamps as TEXT and purges expired exemplars", async () => {
|
|
366
|
+
expect(migrationFiles).toContain(
|
|
367
|
+
"043_metric_exemplars_text_timestamps.sql",
|
|
368
|
+
);
|
|
369
|
+
const cols = await db
|
|
370
|
+
.prepare(
|
|
371
|
+
`SELECT column_name, data_type FROM information_schema.columns
|
|
372
|
+
WHERE table_schema = current_schema() AND table_name = 'metric_exemplars'
|
|
373
|
+
AND column_name IN ('received_at', 'expires_at')
|
|
374
|
+
ORDER BY column_name`,
|
|
375
|
+
)
|
|
376
|
+
.all<{ column_name: string; data_type: string }>();
|
|
377
|
+
expect(cols.results).toEqual([
|
|
378
|
+
{ column_name: "expires_at", data_type: "text" },
|
|
379
|
+
{ column_name: "received_at", data_type: "text" },
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
const metrics = new MetricsStore(db);
|
|
383
|
+
await metrics.ingestBatch({
|
|
384
|
+
projectId: "default",
|
|
385
|
+
receivedAt: isoNow(),
|
|
386
|
+
expiresAt: isoIn(-60_000),
|
|
387
|
+
points: [
|
|
388
|
+
metricPoint({ exemplarsJson: exemplarJson("trace-ex-expired", "1") }),
|
|
389
|
+
],
|
|
390
|
+
});
|
|
391
|
+
await metrics.ingestBatch({
|
|
392
|
+
projectId: "default",
|
|
393
|
+
receivedAt: isoNow(),
|
|
394
|
+
expiresAt: isoIn(3_600_000),
|
|
395
|
+
points: [
|
|
396
|
+
metricPoint({ exemplarsJson: exemplarJson("trace-ex-live", "2") }),
|
|
397
|
+
],
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// One expired exemplar + its expired point.
|
|
401
|
+
expect(await metrics.purgeExpired()).toBe(2);
|
|
402
|
+
const exemplars = await db
|
|
403
|
+
.prepare(`SELECT trace_id FROM metric_exemplars ORDER BY trace_id`)
|
|
404
|
+
.all<{ trace_id: string }>();
|
|
405
|
+
expect(exemplars.results).toEqual([{ trace_id: "trace-ex-live" }]);
|
|
406
|
+
const points = await db
|
|
407
|
+
.prepare(`SELECT COUNT(*) AS n FROM metric_point`)
|
|
408
|
+
.first<{ n: number }>();
|
|
409
|
+
expect(points?.n).toBe(1);
|
|
410
|
+
const live = await metrics.exemplarsForTrace("default", "trace-ex-live");
|
|
411
|
+
expect(live.map((e) => e.traceId)).toEqual(["trace-ex-live"]);
|
|
412
|
+
expect(live[0]?.receivedAt).toMatch(ISO_TEXT);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("migration 043 converts legacy TIMESTAMPTZ metric_exemplars columns in place", async () => {
|
|
416
|
+
// max: 1 pins SET search_path / timezone to the one client the
|
|
417
|
+
// adapter will reuse, on PGlite and on real Postgres alike.
|
|
418
|
+
const legacyPool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
|
|
419
|
+
try {
|
|
420
|
+
await legacyPool.query(`SET search_path TO ${legacySchema}`);
|
|
421
|
+
// A non-UTC session zone must not leak into the converted text.
|
|
422
|
+
await legacyPool.query(`SET timezone TO 'America/New_York'`);
|
|
423
|
+
// Shape of migrations-postgres/035 before the fix (metric_series /
|
|
424
|
+
// metric_point trimmed to the columns the FK and purge touch).
|
|
425
|
+
await legacyPool.query(`
|
|
426
|
+
CREATE TABLE metric_series (id TEXT PRIMARY KEY);
|
|
427
|
+
CREATE TABLE metric_point (id TEXT PRIMARY KEY, expires_at TEXT NOT NULL);
|
|
428
|
+
CREATE TABLE metric_exemplars (
|
|
429
|
+
id TEXT PRIMARY KEY,
|
|
430
|
+
point_id TEXT NOT NULL REFERENCES metric_point(id),
|
|
431
|
+
series_id TEXT NOT NULL REFERENCES metric_series(id),
|
|
432
|
+
project_id TEXT NOT NULL,
|
|
433
|
+
metric_name TEXT NOT NULL,
|
|
434
|
+
service_name TEXT,
|
|
435
|
+
trace_id TEXT,
|
|
436
|
+
span_id TEXT,
|
|
437
|
+
ts_ns TEXT NOT NULL,
|
|
438
|
+
value DOUBLE PRECISION NOT NULL,
|
|
439
|
+
received_at TIMESTAMPTZ NOT NULL,
|
|
440
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
441
|
+
);
|
|
442
|
+
CREATE INDEX idx_metric_exemplars_expires ON metric_exemplars (expires_at);
|
|
443
|
+
INSERT INTO metric_series VALUES ('s1');
|
|
444
|
+
INSERT INTO metric_point VALUES
|
|
445
|
+
('p-expired', '2000-01-01T00:00:00.000Z'),
|
|
446
|
+
('p-live', '2999-01-01T00:00:00.000Z');
|
|
447
|
+
`);
|
|
448
|
+
// The collector bound Date#toISOString() text, which Postgres
|
|
449
|
+
// parsed into timestamptz. Include a non-UTC offset to prove the
|
|
450
|
+
// conversion normalises to UTC rather than echoing input.
|
|
451
|
+
const insert = `INSERT INTO metric_exemplars
|
|
452
|
+
(id, point_id, series_id, project_id, metric_name, service_name, trace_id, span_id, ts_ns, value, received_at, expires_at)
|
|
453
|
+
VALUES ($1, $2, 's1', 'default', 'http.server.duration', 'checkout', $3, NULL, '1', 1, $4::timestamptz, $5::timestamptz)`;
|
|
454
|
+
await legacyPool.query(insert, [
|
|
455
|
+
"ex-expired",
|
|
456
|
+
"p-expired",
|
|
457
|
+
"trace-legacy-expired",
|
|
458
|
+
"2000-01-01T00:00:00.000Z",
|
|
459
|
+
"2000-01-02T00:00:00.000Z",
|
|
460
|
+
]);
|
|
461
|
+
await legacyPool.query(insert, [
|
|
462
|
+
"ex-live",
|
|
463
|
+
"p-live",
|
|
464
|
+
"trace-legacy-live",
|
|
465
|
+
"2026-06-01T14:34:56.789+02:00",
|
|
466
|
+
"2999-01-01T00:00:00.000Z",
|
|
467
|
+
]);
|
|
468
|
+
const before = await legacyPool.query(
|
|
469
|
+
`SELECT data_type FROM information_schema.columns
|
|
470
|
+
WHERE table_schema = $1 AND table_name = 'metric_exemplars' AND column_name = 'expires_at'`,
|
|
471
|
+
[legacySchema],
|
|
472
|
+
);
|
|
473
|
+
expect(before.rows[0]?.data_type).toBe("timestamp with time zone");
|
|
474
|
+
|
|
475
|
+
const legacyDb = new PostgresAdapter(legacyPool);
|
|
476
|
+
const metrics = new MetricsStore(legacyDb);
|
|
477
|
+
// The launch blocker: TEXT now() against TIMESTAMPTZ columns.
|
|
478
|
+
await expect(metrics.purgeExpired()).rejects.toThrow(
|
|
479
|
+
/operator does not exist: timestamp with time zone < text/,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
const sql = await readFile(
|
|
483
|
+
path.join(migrationsDir, "043_metric_exemplars_text_timestamps.sql"),
|
|
484
|
+
"utf8",
|
|
485
|
+
);
|
|
486
|
+
await legacyPool.query(sql);
|
|
487
|
+
await legacyPool.query(sql); // re-running is a no-op
|
|
488
|
+
|
|
489
|
+
const types = await legacyPool.query<{
|
|
490
|
+
column_name: string;
|
|
491
|
+
data_type: string;
|
|
492
|
+
}>(
|
|
493
|
+
`SELECT column_name, data_type FROM information_schema.columns
|
|
494
|
+
WHERE table_schema = $1 AND table_name = 'metric_exemplars'
|
|
495
|
+
AND column_name IN ('received_at', 'expires_at')
|
|
496
|
+
ORDER BY column_name`,
|
|
497
|
+
[legacySchema],
|
|
498
|
+
);
|
|
499
|
+
expect(types.rows).toEqual([
|
|
500
|
+
{ column_name: "expires_at", data_type: "text" },
|
|
501
|
+
{ column_name: "received_at", data_type: "text" },
|
|
502
|
+
]);
|
|
503
|
+
const converted = await legacyPool.query(
|
|
504
|
+
`SELECT id, received_at, expires_at FROM metric_exemplars ORDER BY id`,
|
|
505
|
+
);
|
|
506
|
+
expect(converted.rows).toEqual([
|
|
507
|
+
{
|
|
508
|
+
id: "ex-expired",
|
|
509
|
+
received_at: "2000-01-01T00:00:00.000Z",
|
|
510
|
+
expires_at: "2000-01-02T00:00:00.000Z",
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
id: "ex-live",
|
|
514
|
+
received_at: "2026-06-01T12:34:56.789Z",
|
|
515
|
+
expires_at: "2999-01-01T00:00:00.000Z",
|
|
516
|
+
},
|
|
517
|
+
]);
|
|
518
|
+
const indexes = await legacyPool.query<{ indexname: string }>(
|
|
519
|
+
`SELECT indexname FROM pg_indexes WHERE schemaname = $1 AND tablename = 'metric_exemplars'`,
|
|
520
|
+
[legacySchema],
|
|
521
|
+
);
|
|
522
|
+
expect(indexes.rows.map((r) => r.indexname)).toContain(
|
|
523
|
+
"idx_metric_exemplars_expires",
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
// Retention now runs through the real store: 1 exemplar + 1 point.
|
|
527
|
+
expect(await metrics.purgeExpired()).toBe(2);
|
|
528
|
+
const remaining = await legacyPool.query(
|
|
529
|
+
`SELECT id FROM metric_exemplars ORDER BY id`,
|
|
530
|
+
);
|
|
531
|
+
expect(remaining.rows).toEqual([{ id: "ex-live" }]);
|
|
532
|
+
const points = await legacyPool.query(
|
|
533
|
+
`SELECT id FROM metric_point ORDER BY id`,
|
|
534
|
+
);
|
|
535
|
+
expect(points.rows).toEqual([{ id: "p-live" }]);
|
|
536
|
+
const recent = await metrics.recentExemplars({ projectId: "default" });
|
|
537
|
+
expect(recent.map((e) => [e.traceId, e.receivedAt])).toEqual([
|
|
538
|
+
["trace-legacy-live", "2026-06-01T12:34:56.789Z"],
|
|
539
|
+
]);
|
|
540
|
+
} finally {
|
|
541
|
+
// Single-session servers (PGlite) share GUCs across sockets, so put
|
|
542
|
+
// search_path / timezone back before the pooled adapter runs again.
|
|
543
|
+
await legacyPool.query("RESET timezone").catch(() => {});
|
|
544
|
+
await legacyPool.query("RESET search_path").catch(() => {});
|
|
545
|
+
await legacyPool.end();
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("migration 044 creates idx_actions_started_at and is a no-op when re-applied", async () => {
|
|
550
|
+
expect(migrationFiles).toContain("044_actions_started_at_index.sql");
|
|
551
|
+
const indexed = () =>
|
|
552
|
+
db
|
|
553
|
+
.prepare(
|
|
554
|
+
`SELECT indexname FROM pg_indexes
|
|
555
|
+
WHERE schemaname = current_schema() AND tablename = 'actions' AND indexname = 'idx_actions_started_at'`,
|
|
556
|
+
)
|
|
557
|
+
.first<{ indexname: string }>();
|
|
558
|
+
expect((await indexed())?.indexname).toBe("idx_actions_started_at");
|
|
559
|
+
|
|
560
|
+
// Upgrade path: an already-indexed database re-running 044 must not fail.
|
|
561
|
+
const admin = await adminClient();
|
|
562
|
+
try {
|
|
563
|
+
await admin.query(
|
|
564
|
+
await readFile(
|
|
565
|
+
path.join(migrationsDir, "044_actions_started_at_index.sql"),
|
|
566
|
+
"utf8",
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
} finally {
|
|
570
|
+
await admin.end();
|
|
571
|
+
}
|
|
572
|
+
expect((await indexed())?.indexname).toBe("idx_actions_started_at");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("migrations 045–048 key profiles, action graph, payloads and spans by project and re-apply cleanly", async () => {
|
|
576
|
+
const expected: Record<string, string> = {
|
|
577
|
+
user_profiles: "PRIMARY KEY (project_id, user_id)",
|
|
578
|
+
actions: "PRIMARY KEY (project_id, id)",
|
|
579
|
+
agent_runs: "PRIMARY KEY (project_id, id)",
|
|
580
|
+
tool_calls: "PRIMARY KEY (project_id, id)",
|
|
581
|
+
retrieval_events: "PRIMARY KEY (project_id, id)",
|
|
582
|
+
eval_results: "PRIMARY KEY (project_id, id)",
|
|
583
|
+
artifacts: "PRIMARY KEY (project_id, id)",
|
|
584
|
+
ai_span_payloads: "PRIMARY KEY (project_id, trace_id, span_id)",
|
|
585
|
+
telemetry_spans: "PRIMARY KEY (project_id, trace_id, span_id)",
|
|
586
|
+
};
|
|
587
|
+
const primaryKeys = async () => {
|
|
588
|
+
const rows = await db
|
|
589
|
+
.prepare(
|
|
590
|
+
`SELECT c.conrelid::regclass::text AS table_name,
|
|
591
|
+
pg_get_constraintdef(c.oid) AS pk
|
|
592
|
+
FROM pg_constraint c
|
|
593
|
+
JOIN pg_namespace n ON n.oid = c.connamespace
|
|
594
|
+
WHERE c.contype = 'p' AND n.nspname = current_schema()`,
|
|
595
|
+
)
|
|
596
|
+
.all<{ table_name: string; pk: string }>();
|
|
597
|
+
return Object.fromEntries(
|
|
598
|
+
rows.results
|
|
599
|
+
.filter((r) => r.table_name in expected)
|
|
600
|
+
.map((r) => [r.table_name, r.pk]),
|
|
601
|
+
);
|
|
602
|
+
};
|
|
603
|
+
expect(await primaryKeys()).toEqual(expected);
|
|
604
|
+
|
|
605
|
+
// Upgrade path: re-running the four files on a converted database is
|
|
606
|
+
// a no-op (each looks the current key up by definition first).
|
|
607
|
+
const admin = await adminClient();
|
|
608
|
+
try {
|
|
609
|
+
for (const file of migrationFiles.filter(
|
|
610
|
+
(f) => f >= "045_" && f < "049_",
|
|
611
|
+
)) {
|
|
612
|
+
await admin.query(
|
|
613
|
+
await readFile(path.join(migrationsDir, file), "utf8"),
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
} finally {
|
|
617
|
+
await admin.end();
|
|
618
|
+
}
|
|
619
|
+
expect(await primaryKeys()).toEqual(expected);
|
|
620
|
+
const spanIndexes = await db
|
|
621
|
+
.prepare(
|
|
622
|
+
`SELECT indexname FROM pg_indexes
|
|
623
|
+
WHERE schemaname = current_schema() AND tablename = 'telemetry_spans'
|
|
624
|
+
ORDER BY indexname`,
|
|
625
|
+
)
|
|
626
|
+
.all<{ indexname: string }>();
|
|
627
|
+
expect(spanIndexes.results.map((r) => r.indexname)).toContain(
|
|
628
|
+
"idx_spans_project_received",
|
|
629
|
+
);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("keeps colliding ids separate per project through the real write paths", async () => {
|
|
633
|
+
const cleanup = async () => {
|
|
634
|
+
for (const table of [
|
|
635
|
+
"actions",
|
|
636
|
+
"telemetry_spans",
|
|
637
|
+
"ai_span_payloads",
|
|
638
|
+
"user_profiles",
|
|
639
|
+
]) {
|
|
640
|
+
await db
|
|
641
|
+
.prepare(`DELETE FROM ${table} WHERE project_id IN (?, ?)`)
|
|
642
|
+
.bind("tenant-a", "tenant-b")
|
|
643
|
+
.run();
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
try {
|
|
647
|
+
const store = new ActionStore(db);
|
|
648
|
+
await store.upsertAction(
|
|
649
|
+
actionRecord({ id: "01SHARED", projectId: "tenant-a", name: "a run" }),
|
|
650
|
+
);
|
|
651
|
+
await store.upsertAction(
|
|
652
|
+
actionRecord({ id: "01SHARED", projectId: "tenant-b", name: "b run" }),
|
|
653
|
+
);
|
|
654
|
+
await store.upsertAction(
|
|
655
|
+
actionRecord({
|
|
656
|
+
id: "01SHARED",
|
|
657
|
+
projectId: "tenant-b",
|
|
658
|
+
name: "b run v2",
|
|
659
|
+
}),
|
|
660
|
+
);
|
|
661
|
+
expect((await store.getAction("tenant-a", "01SHARED"))?.name).toBe(
|
|
662
|
+
"a run",
|
|
663
|
+
);
|
|
664
|
+
expect((await store.getAction("tenant-b", "01SHARED"))?.name).toBe(
|
|
665
|
+
"b run v2",
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
const span = (projectId: string, spanName: string): StoredSpan => ({
|
|
669
|
+
projectId,
|
|
670
|
+
traceId: "trace-shared",
|
|
671
|
+
spanId: "span-shared",
|
|
672
|
+
parentSpanId: null,
|
|
673
|
+
traceState: null,
|
|
674
|
+
serviceName: "agent",
|
|
675
|
+
scopeName: null,
|
|
676
|
+
scopeVersion: null,
|
|
677
|
+
spanName,
|
|
678
|
+
spanKind: 1,
|
|
679
|
+
statusCode: 1,
|
|
680
|
+
statusMessage: null,
|
|
681
|
+
startTime: isoNow(),
|
|
682
|
+
endTime: isoNow(),
|
|
683
|
+
durationMs: 5,
|
|
684
|
+
attributesJson: JSON.stringify({
|
|
685
|
+
"openinference.span.kind": "LLM",
|
|
686
|
+
// An object: string payloads are stored verbatim, which JSONB rejects.
|
|
687
|
+
"ai.payload.input": { prompt: `${projectId} prompt` },
|
|
688
|
+
}),
|
|
689
|
+
droppedAttributesCount: 0,
|
|
690
|
+
resourceAttributesJson: "{}",
|
|
691
|
+
eventsJson: "[]",
|
|
692
|
+
droppedEventsCount: 0,
|
|
693
|
+
linksJson: "[]",
|
|
694
|
+
droppedLinksCount: 0,
|
|
695
|
+
receivedAt: isoNow(),
|
|
696
|
+
expiresAt: isoIn(60 * 60 * 1000),
|
|
697
|
+
sessionId: null,
|
|
698
|
+
interactionId: null,
|
|
699
|
+
});
|
|
700
|
+
// INSERT OR IGNORE → ON CONFLICT DO NOTHING used to drop tenant-b's span.
|
|
701
|
+
await ingestTelemetrySpans(db, [span("tenant-a", "a-llm")]);
|
|
702
|
+
await ingestTelemetrySpans(db, [span("tenant-b", "b-llm")]);
|
|
703
|
+
const names = await db
|
|
704
|
+
.prepare(
|
|
705
|
+
`SELECT project_id, span_name FROM telemetry_spans
|
|
706
|
+
WHERE trace_id = ? AND span_id = ? ORDER BY project_id`,
|
|
707
|
+
)
|
|
708
|
+
.bind("trace-shared", "span-shared")
|
|
709
|
+
.all<{ project_id: string; span_name: string }>();
|
|
710
|
+
expect(names.results).toEqual([
|
|
711
|
+
{ project_id: "tenant-a", span_name: "a-llm" },
|
|
712
|
+
{ project_id: "tenant-b", span_name: "b-llm" },
|
|
713
|
+
]);
|
|
714
|
+
|
|
715
|
+
// Payload side table: the processor's upsert must stay per project.
|
|
716
|
+
const processors: SpanProcessorPlugin[] = [];
|
|
717
|
+
aiSpanPayloadsProcessorPlugin.register(
|
|
718
|
+
{} as Parameters<typeof aiSpanPayloadsProcessorPlugin.register>[0],
|
|
719
|
+
{
|
|
720
|
+
addSpanProcessor(p: SpanProcessorPlugin) {
|
|
721
|
+
processors.push(p);
|
|
722
|
+
},
|
|
723
|
+
} as unknown as CollectorRuntime,
|
|
724
|
+
);
|
|
725
|
+
const process = processors[0]?.process;
|
|
726
|
+
if (!process) throw new Error("span processor was not registered");
|
|
727
|
+
const ctx = {
|
|
728
|
+
env: { DB: db, OBS_PAYLOAD_CAPTURE_DEFAULT: "true" },
|
|
729
|
+
now: new Date(),
|
|
730
|
+
logger: console,
|
|
731
|
+
} as unknown as CollectorRouteContext;
|
|
732
|
+
await process([span("tenant-a", "a-llm")], ctx);
|
|
733
|
+
await process([span("tenant-b", "b-llm")], ctx);
|
|
734
|
+
const payloads = await db
|
|
735
|
+
.prepare(
|
|
736
|
+
`SELECT project_id, input_json FROM ai_span_payloads
|
|
737
|
+
WHERE trace_id = ? AND span_id = ? ORDER BY project_id`,
|
|
738
|
+
)
|
|
739
|
+
.bind("trace-shared", "span-shared")
|
|
740
|
+
.all<{ project_id: string; input_json: string }>();
|
|
741
|
+
expect(
|
|
742
|
+
payloads.results.map((r) => [r.project_id, JSON.parse(r.input_json)]),
|
|
743
|
+
).toEqual([
|
|
744
|
+
["tenant-a", { prompt: "tenant-a prompt" }],
|
|
745
|
+
["tenant-b", { prompt: "tenant-b prompt" }],
|
|
746
|
+
]);
|
|
747
|
+
const aOverview = await getAISpansOverview(db, {
|
|
748
|
+
projectId: "tenant-a",
|
|
749
|
+
hours: 24,
|
|
750
|
+
});
|
|
751
|
+
expect(aOverview.spans.map((s) => s.spanName)).toEqual(["a-llm"]);
|
|
752
|
+
|
|
753
|
+
// /v1/identify: same userId in two projects → two profiles.
|
|
754
|
+
const setProject: MiddlewareHandler<{
|
|
755
|
+
Bindings: CollectorEnv;
|
|
756
|
+
Variables: { projectId: string };
|
|
757
|
+
}> = async (c, next) => {
|
|
758
|
+
const header = c.req.header("x-project-id");
|
|
759
|
+
if (header) c.set("projectId", header);
|
|
760
|
+
await next();
|
|
761
|
+
};
|
|
762
|
+
const app = createTelemetryCollectorApp({
|
|
763
|
+
plugins: [identityReceiverPlugin],
|
|
764
|
+
auth: { middleware: setProject },
|
|
765
|
+
dashboardAuth: { middleware: setProject, registerRoutes: () => {} },
|
|
766
|
+
});
|
|
767
|
+
const identify = (projectId: string, body: Record<string, unknown>) =>
|
|
768
|
+
app.request(
|
|
769
|
+
"/v1/identify",
|
|
770
|
+
{
|
|
771
|
+
method: "POST",
|
|
772
|
+
headers: {
|
|
773
|
+
"content-type": "application/json",
|
|
774
|
+
"x-project-id": projectId,
|
|
775
|
+
},
|
|
776
|
+
body: JSON.stringify(body),
|
|
777
|
+
},
|
|
778
|
+
{ DB: db as unknown as D1Database } as CollectorEnv,
|
|
779
|
+
);
|
|
780
|
+
const userId = "ada@example.com";
|
|
781
|
+
expect(
|
|
782
|
+
(
|
|
783
|
+
await identify("tenant-a", {
|
|
784
|
+
userId,
|
|
785
|
+
visitorId: "vis-a",
|
|
786
|
+
name: "Ada (a)",
|
|
787
|
+
properties: { plan: "a" },
|
|
788
|
+
})
|
|
789
|
+
).status,
|
|
790
|
+
).toBe(200);
|
|
791
|
+
expect(
|
|
792
|
+
(
|
|
793
|
+
await identify("tenant-b", {
|
|
794
|
+
userId,
|
|
795
|
+
visitorId: "vis-b",
|
|
796
|
+
name: "Ada (b)",
|
|
797
|
+
})
|
|
798
|
+
).status,
|
|
799
|
+
).toBe(200);
|
|
800
|
+
const profiles = await db
|
|
801
|
+
.prepare(
|
|
802
|
+
`SELECT project_id, visitor_id, name FROM user_profiles
|
|
803
|
+
WHERE user_id = ? ORDER BY project_id`,
|
|
804
|
+
)
|
|
805
|
+
.bind(userId)
|
|
806
|
+
.all<{ project_id: string; visitor_id: string; name: string }>();
|
|
807
|
+
expect(profiles.results).toEqual([
|
|
808
|
+
{ project_id: "tenant-a", visitor_id: "vis-a", name: "Ada (a)" },
|
|
809
|
+
{ project_id: "tenant-b", visitor_id: "vis-b", name: "Ada (b)" },
|
|
810
|
+
]);
|
|
811
|
+
} finally {
|
|
812
|
+
// Leave no tenant rows behind for the purge / analysis tests below.
|
|
813
|
+
await cleanup();
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("purges expired action trees by TEXT started_at and keeps fresh, in-progress and eval-pinned trees", async () => {
|
|
818
|
+
const purge = (
|
|
819
|
+
over: {
|
|
820
|
+
maxActionsPerBatch?: number;
|
|
821
|
+
maxTreesPerBatch?: number;
|
|
822
|
+
maxBatches?: number;
|
|
823
|
+
} = {},
|
|
824
|
+
) =>
|
|
825
|
+
purgeExpiredActionGraph(db, {
|
|
826
|
+
retentionHours: FIXTURE_RETENTION_HOURS,
|
|
827
|
+
now: () => FIXTURE_NOW_MS,
|
|
828
|
+
...over,
|
|
829
|
+
});
|
|
830
|
+
const count = (table: string, column: string, value: string) =>
|
|
831
|
+
db
|
|
832
|
+
.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} = ?`)
|
|
833
|
+
.bind(value)
|
|
834
|
+
.first<{ n: number | string }>()
|
|
835
|
+
.then((r) => Number(r?.n ?? 0));
|
|
836
|
+
|
|
837
|
+
const expired = await seedTree(db, {
|
|
838
|
+
projectId: "pg-ret",
|
|
839
|
+
rootId: "pg-old",
|
|
840
|
+
});
|
|
841
|
+
const fresh = await seedTree(db, {
|
|
842
|
+
projectId: "pg-ret",
|
|
843
|
+
rootId: "pg-new",
|
|
844
|
+
rootStartedAt: FRESH_START,
|
|
845
|
+
});
|
|
846
|
+
await seedTree(db, {
|
|
847
|
+
projectId: "pg-ret",
|
|
848
|
+
rootId: "pg-hung",
|
|
849
|
+
childEndedAt: null,
|
|
850
|
+
});
|
|
851
|
+
await seedTree(db, {
|
|
852
|
+
projectId: "pg-ret",
|
|
853
|
+
rootId: "pg-running",
|
|
854
|
+
agentRunStatus: "running",
|
|
855
|
+
});
|
|
856
|
+
const pinned = await seedTree(db, {
|
|
857
|
+
projectId: "pg-ret",
|
|
858
|
+
rootId: "pg-pinned",
|
|
859
|
+
});
|
|
860
|
+
await seedEvalCase(db, {
|
|
861
|
+
id: "pg-case",
|
|
862
|
+
projectId: "pg-ret",
|
|
863
|
+
sourceActionId: pinned.childId,
|
|
864
|
+
});
|
|
865
|
+
await seedTree(db, { projectId: "pg-ret-other", rootId: "pg-other-old" });
|
|
866
|
+
// Three more expired two-action trees to exercise packing and batching.
|
|
867
|
+
for (let i = 0; i < 3; i += 1) {
|
|
868
|
+
await seedTree(db, {
|
|
869
|
+
projectId: "pg-ret",
|
|
870
|
+
rootId: `pg-bulk-${i}`,
|
|
871
|
+
withLeaves: false,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
// An expired tree larger than the per-batch cap used below (5 actions).
|
|
875
|
+
const bigStore = new ActionStore(db);
|
|
876
|
+
for (let i = 0; i < 5; i += 1) {
|
|
877
|
+
await bigStore.upsertAction(
|
|
878
|
+
actionRecord({
|
|
879
|
+
id: i === 0 ? "pg-big" : `pg-big-${i}`,
|
|
880
|
+
projectId: "pg-ret",
|
|
881
|
+
rootActionId: "pg-big",
|
|
882
|
+
causedByActionId: i === 0 ? null : "pg-big",
|
|
883
|
+
agentRunId: "pg-big",
|
|
884
|
+
}),
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
await bigStore.upsertAgentRun(
|
|
888
|
+
agentRunRecord({ id: "pg-big", projectId: "pg-ret" }),
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
// Cap of 4 actions per batch, one batch: two whole 2-action trees go,
|
|
892
|
+
// nothing is split, and the 5-action tree is reported, not touched.
|
|
893
|
+
const first = await purge({ maxActionsPerBatch: 4, maxBatches: 1 });
|
|
894
|
+
expect(first).toMatchObject({
|
|
895
|
+
actions: 4,
|
|
896
|
+
trees: 2,
|
|
897
|
+
batches: 1,
|
|
898
|
+
truncated: true,
|
|
899
|
+
oversized_trees: 1,
|
|
900
|
+
oversized: [
|
|
901
|
+
{ project_id: "pg-ret", root_action_id: "pg-big", actions: 5 },
|
|
902
|
+
],
|
|
903
|
+
});
|
|
904
|
+
expect(await count("actions", "root_action_id", "pg-big")).toBe(5);
|
|
905
|
+
for (const root of [
|
|
906
|
+
"pg-old",
|
|
907
|
+
"pg-other-old",
|
|
908
|
+
"pg-bulk-0",
|
|
909
|
+
"pg-bulk-1",
|
|
910
|
+
"pg-bulk-2",
|
|
911
|
+
]) {
|
|
912
|
+
expect([0, 2]).toContain(await count("actions", "root_action_id", root));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const rest = await purge({ maxActionsPerBatch: 4 });
|
|
916
|
+
expect(rest).toMatchObject({ truncated: false, oversized_trees: 1 });
|
|
917
|
+
// 2 (pg-old) + 2 (pg-other-old) + 6 (bulk) spine rows in total.
|
|
918
|
+
expect(first.actions + rest.actions).toBe(10);
|
|
919
|
+
expect(first.trees + rest.trees).toBe(5);
|
|
920
|
+
expect(first.agent_runs + rest.agent_runs).toBe(5);
|
|
921
|
+
expect(first.tool_calls + rest.tool_calls).toBe(2);
|
|
922
|
+
expect(first.artifacts + rest.artifacts).toBe(2);
|
|
923
|
+
|
|
924
|
+
expect(await count("actions", "root_action_id", expired.rootId)).toBe(0);
|
|
925
|
+
expect(await count("tool_calls", "id", expired.toolCallId)).toBe(0);
|
|
926
|
+
expect(
|
|
927
|
+
await count("retrieval_events", "id", expired.retrievalEventId),
|
|
928
|
+
).toBe(0);
|
|
929
|
+
expect(await count("eval_results", "id", expired.evalResultId)).toBe(0);
|
|
930
|
+
expect(await count("artifacts", "id", expired.artifactId)).toBe(0);
|
|
931
|
+
expect(await count("agent_runs", "id", expired.rootId)).toBe(0);
|
|
932
|
+
expect(await count("actions", "root_action_id", "pg-other-old")).toBe(0);
|
|
933
|
+
|
|
934
|
+
expect(await count("actions", "root_action_id", fresh.rootId)).toBe(2);
|
|
935
|
+
expect(await count("tool_calls", "id", fresh.toolCallId)).toBe(1);
|
|
936
|
+
expect(await count("actions", "root_action_id", "pg-hung")).toBe(2);
|
|
937
|
+
expect(await count("actions", "root_action_id", "pg-running")).toBe(2);
|
|
938
|
+
expect(await count("agent_runs", "id", "pg-running")).toBe(1);
|
|
939
|
+
expect(await count("actions", "root_action_id", pinned.rootId)).toBe(2);
|
|
940
|
+
expect(await count("eval_cases", "id", "pg-case")).toBe(1);
|
|
941
|
+
expect(await count("actions", "root_action_id", "pg-big")).toBe(5);
|
|
942
|
+
expect(await count("agent_runs", "id", "pg-big")).toBe(1);
|
|
943
|
+
|
|
944
|
+
// With a cap that fits it, the oversized tree goes whole.
|
|
945
|
+
const big = await purge({ maxActionsPerBatch: 5 });
|
|
946
|
+
expect(big).toMatchObject({
|
|
947
|
+
actions: 5,
|
|
948
|
+
trees: 1,
|
|
949
|
+
agent_runs: 1,
|
|
950
|
+
oversized_trees: 0,
|
|
951
|
+
});
|
|
952
|
+
expect(await count("actions", "root_action_id", "pg-big")).toBe(0);
|
|
953
|
+
expect(await countActionGraph(db, "pg-ret")).toEqual({
|
|
954
|
+
actions: 8,
|
|
955
|
+
agent_runs: 4,
|
|
956
|
+
tool_calls: 4,
|
|
957
|
+
retrieval_events: 4,
|
|
958
|
+
eval_results: 4,
|
|
959
|
+
artifacts: 4,
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
// Idempotent: nothing left to purge.
|
|
963
|
+
expect(await purge()).toMatchObject({
|
|
964
|
+
actions: 0,
|
|
965
|
+
batches: 0,
|
|
966
|
+
truncated: false,
|
|
967
|
+
});
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it("ingests AI calls and summarises BOOLEAN is_error inside a TEXT time window", async () => {
|
|
971
|
+
await ingestAICallBatch(db, [
|
|
972
|
+
aiCall({ callId: "ai-ok" }),
|
|
973
|
+
aiCall({ callId: "ai-err", isError: true, errorMessage: "boom" }),
|
|
974
|
+
aiCall({
|
|
975
|
+
callId: "ai-old",
|
|
976
|
+
receivedAt: new Date(Date.now() - 48 * 3600 * 1000).toISOString(),
|
|
977
|
+
}),
|
|
978
|
+
]);
|
|
979
|
+
|
|
980
|
+
const overview = await getAICallsOverview(db, {
|
|
981
|
+
projectId: "default",
|
|
982
|
+
hours: 24,
|
|
983
|
+
});
|
|
984
|
+
expect(overview.summary.totalCalls).toBe(2);
|
|
985
|
+
expect(overview.summary.errorCalls).toBe(1);
|
|
986
|
+
expect(overview.summary.totalPromptTokens).toBe(20);
|
|
987
|
+
expect(typeof overview.summary.totalCostUsd).toBe("number");
|
|
988
|
+
expect(overview.calls.map((c) => [c.callId, c.isError]).sort()).toEqual([
|
|
989
|
+
["ai-err", true],
|
|
990
|
+
["ai-ok", false],
|
|
991
|
+
]);
|
|
992
|
+
// JSONB columns must still come back as JSON text (JSONB normalises
|
|
993
|
+
// whitespace, so compare the parsed value).
|
|
994
|
+
expect(typeof overview.calls[0]?.requestJson).toBe("string");
|
|
995
|
+
expect(JSON.parse(overview.calls[0]?.requestJson ?? "null")).toEqual({
|
|
996
|
+
messages: [],
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
const errorsOnly = await getAICallsOverview(db, {
|
|
1000
|
+
projectId: "default",
|
|
1001
|
+
hours: 24,
|
|
1002
|
+
isError: true,
|
|
1003
|
+
});
|
|
1004
|
+
expect(errorsOnly.calls.map((c) => c.callId)).toEqual(["ai-err"]);
|
|
1005
|
+
|
|
1006
|
+
const alerts = new AlertsStore(db);
|
|
1007
|
+
const rule = {
|
|
1008
|
+
id: "rule-ai-errors",
|
|
1009
|
+
projectId: "default",
|
|
1010
|
+
name: "AI errors",
|
|
1011
|
+
signal: "ai",
|
|
1012
|
+
query: { isError: true },
|
|
1013
|
+
threshold: 0,
|
|
1014
|
+
windowMins: 60,
|
|
1015
|
+
comparison: "gt",
|
|
1016
|
+
channels: [],
|
|
1017
|
+
enabled: true,
|
|
1018
|
+
createdAt: isoNow(),
|
|
1019
|
+
updatedAt: isoNow(),
|
|
1020
|
+
} as unknown as AlertRule;
|
|
1021
|
+
expect(await alerts.evaluateRule(rule)).toBe(1);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it("migration 049 adds alert_state.notified with a delivered backfill, and the store drives it", async () => {
|
|
1025
|
+
// Legacy shape: alert_state as created by 014, before 049.
|
|
1026
|
+
const legacyPool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
|
|
1027
|
+
try {
|
|
1028
|
+
await legacyPool.query(`SET search_path TO ${legacySchema}`);
|
|
1029
|
+
await legacyPool.query(`
|
|
1030
|
+
CREATE TABLE alert_state (
|
|
1031
|
+
rule_id TEXT PRIMARY KEY,
|
|
1032
|
+
project_id TEXT NOT NULL,
|
|
1033
|
+
current_state TEXT NOT NULL,
|
|
1034
|
+
last_state_change TEXT NOT NULL
|
|
1035
|
+
);
|
|
1036
|
+
INSERT INTO alert_state VALUES
|
|
1037
|
+
('rule-legacy', 'default', 'firing', '2026-05-31T00:00:00.000Z');
|
|
1038
|
+
`);
|
|
1039
|
+
const sql = await readFile(
|
|
1040
|
+
path.join(migrationsDir, "049_alert_state_notified.sql"),
|
|
1041
|
+
"utf8",
|
|
1042
|
+
);
|
|
1043
|
+
await legacyPool.query(sql);
|
|
1044
|
+
// Re-running is a no-op. Postgres answers the IF NOT EXISTS skip
|
|
1045
|
+
// with a NOTICE, which PGlite's socket bridge does not relay
|
|
1046
|
+
// cleanly; silence it on this session (real Postgres is unaffected).
|
|
1047
|
+
await legacyPool.query(`SET client_min_messages TO WARNING`);
|
|
1048
|
+
await legacyPool.query(sql);
|
|
1049
|
+
const column = await legacyPool.query<{
|
|
1050
|
+
data_type: string;
|
|
1051
|
+
is_nullable: string;
|
|
1052
|
+
column_default: string;
|
|
1053
|
+
}>(
|
|
1054
|
+
`SELECT data_type, is_nullable, column_default FROM information_schema.columns
|
|
1055
|
+
WHERE table_schema = $1 AND table_name = 'alert_state' AND column_name = 'notified'`,
|
|
1056
|
+
[legacySchema],
|
|
1057
|
+
);
|
|
1058
|
+
expect(column.rows).toEqual([
|
|
1059
|
+
{ data_type: "integer", is_nullable: "NO", column_default: "1" },
|
|
1060
|
+
]);
|
|
1061
|
+
const backfilled = await legacyPool.query<{ notified: number }>(
|
|
1062
|
+
`SELECT notified FROM alert_state WHERE rule_id = 'rule-legacy'`,
|
|
1063
|
+
);
|
|
1064
|
+
expect(Number(backfilled.rows[0]?.notified)).toBe(1);
|
|
1065
|
+
} finally {
|
|
1066
|
+
// PGlite is one session for every socket: put search_path and the
|
|
1067
|
+
// message level back before the pooled adapter runs again.
|
|
1068
|
+
await legacyPool.query("RESET client_min_messages").catch(() => {});
|
|
1069
|
+
await legacyPool.query("RESET search_path").catch(() => {});
|
|
1070
|
+
await legacyPool.end();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Live schema: transitions start undelivered; only the exact
|
|
1074
|
+
// transition that was delivered is acknowledged.
|
|
1075
|
+
const projects = new ProjectsStore(db);
|
|
1076
|
+
const project = await projects.createProject({
|
|
1077
|
+
name: "Alert state",
|
|
1078
|
+
slug: `alert-state-${Date.now().toString(36)}`,
|
|
1079
|
+
});
|
|
1080
|
+
const alerts = new AlertsStore(db);
|
|
1081
|
+
const rule = await alerts.createRule(project.id, {
|
|
1082
|
+
name: "notified flag",
|
|
1083
|
+
signal: "spans",
|
|
1084
|
+
query: {},
|
|
1085
|
+
threshold: 1,
|
|
1086
|
+
windowMins: 5,
|
|
1087
|
+
comparison: ">",
|
|
1088
|
+
channels: [{ type: "webhook", url: "https://hooks.example.com/pg" }],
|
|
1089
|
+
enabled: true,
|
|
1090
|
+
});
|
|
1091
|
+
const t1 = "2026-05-31T00:00:00.000Z";
|
|
1092
|
+
await alerts.transitionState(rule.id, project.id, "firing", t1);
|
|
1093
|
+
expect(await alerts.getState(rule.id, project.id)).toMatchObject({
|
|
1094
|
+
current_state: "firing",
|
|
1095
|
+
last_state_change: t1,
|
|
1096
|
+
notified: 0,
|
|
1097
|
+
});
|
|
1098
|
+
expect(
|
|
1099
|
+
await alerts.markNotified(rule.id, "other-project", "firing", t1),
|
|
1100
|
+
).toBe(false);
|
|
1101
|
+
expect(await alerts.markNotified(rule.id, project.id, "ok", t1)).toBe(
|
|
1102
|
+
false,
|
|
1103
|
+
);
|
|
1104
|
+
expect(await alerts.markNotified(rule.id, project.id, "firing", t1)).toBe(
|
|
1105
|
+
true,
|
|
1106
|
+
);
|
|
1107
|
+
expect((await alerts.getState(rule.id, project.id))?.notified).toBe(1);
|
|
1108
|
+
expect(await alerts.markNotified(rule.id, project.id, "firing", t1)).toBe(
|
|
1109
|
+
false,
|
|
1110
|
+
);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
it("purges expired AI data using the dialect's text-rendered now()", async () => {
|
|
1114
|
+
await ingestAICallBatch(db, [
|
|
1115
|
+
aiCall({ callId: "ai-expired", expiresAt: isoIn(-60_000) }),
|
|
1116
|
+
]);
|
|
1117
|
+
expect(await purgeExpiredAIData(db)).toBe(1);
|
|
1118
|
+
const remaining = await db
|
|
1119
|
+
.prepare(`SELECT call_id FROM ai_calls WHERE call_id = ?`)
|
|
1120
|
+
.bind("ai-expired")
|
|
1121
|
+
.first();
|
|
1122
|
+
expect(remaining).toBeNull();
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it("ingests usage events with BOOLEAN is_bot and reads them back as D1 flags", async () => {
|
|
1126
|
+
const usage = new UsageStore(db);
|
|
1127
|
+
await usage.ingest([
|
|
1128
|
+
usageEvent({ eventId: "u-human" }),
|
|
1129
|
+
usageEvent({ eventId: "u-bot", sessionId: "sess-bot", isBot: true }),
|
|
1130
|
+
]);
|
|
1131
|
+
const flags = await db
|
|
1132
|
+
.prepare(`SELECT event_id, is_bot FROM usage_events ORDER BY event_id`)
|
|
1133
|
+
.all<{ event_id: string; is_bot: unknown }>();
|
|
1134
|
+
expect(flags.results).toEqual([
|
|
1135
|
+
{ event_id: "u-bot", is_bot: 1 },
|
|
1136
|
+
{ event_id: "u-human", is_bot: 0 },
|
|
1137
|
+
]);
|
|
1138
|
+
|
|
1139
|
+
const overview = await usage.getOverview({
|
|
1140
|
+
projectId: "default",
|
|
1141
|
+
hours: 24,
|
|
1142
|
+
});
|
|
1143
|
+
// Same semantics as D1: bots are counted (not dropped) by getOverview.
|
|
1144
|
+
expect(overview.botsFiltered).toBe(1);
|
|
1145
|
+
expect(overview.summary.totalEvents).toBe(2);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it("runs every built-in and derived analysis definition", async () => {
|
|
1149
|
+
// Seed two linked spans so Tier 1 derivation emits the service,
|
|
1150
|
+
// edge, messaging-topic and AI analyses (julianday, json_extract …).
|
|
1151
|
+
const now = isoNow();
|
|
1152
|
+
const span = db.prepare(`
|
|
1153
|
+
INSERT INTO telemetry_spans (project_id, trace_id, span_id, parent_span_id, service_name,
|
|
1154
|
+
span_name, span_kind, status_code, start_time, end_time, duration_ms, attributes_json,
|
|
1155
|
+
received_at, expires_at)
|
|
1156
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
1157
|
+
await db.batch([
|
|
1158
|
+
span.bind(
|
|
1159
|
+
"default",
|
|
1160
|
+
"trace-1",
|
|
1161
|
+
"span-a",
|
|
1162
|
+
null,
|
|
1163
|
+
"svc-a",
|
|
1164
|
+
"publish orders",
|
|
1165
|
+
4,
|
|
1166
|
+
0,
|
|
1167
|
+
now,
|
|
1168
|
+
now,
|
|
1169
|
+
12.5,
|
|
1170
|
+
JSON.stringify({ "messaging.destination": "orders" }),
|
|
1171
|
+
now,
|
|
1172
|
+
isoIn(3_600_000),
|
|
1173
|
+
),
|
|
1174
|
+
db
|
|
1175
|
+
.prepare(`
|
|
1176
|
+
INSERT INTO telemetry_spans (project_id, trace_id, span_id, parent_span_id, service_name,
|
|
1177
|
+
span_name, span_kind, status_code, start_time, end_time, duration_ms, attributes_json,
|
|
1178
|
+
received_at, expires_at)
|
|
1179
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1180
|
+
.bind(
|
|
1181
|
+
"default",
|
|
1182
|
+
"trace-1",
|
|
1183
|
+
"span-b",
|
|
1184
|
+
"span-a",
|
|
1185
|
+
"svc-b",
|
|
1186
|
+
"consume orders",
|
|
1187
|
+
5,
|
|
1188
|
+
2,
|
|
1189
|
+
now,
|
|
1190
|
+
now,
|
|
1191
|
+
30,
|
|
1192
|
+
JSON.stringify({
|
|
1193
|
+
"messaging.destination": "orders",
|
|
1194
|
+
"gen_ai.system": "openai",
|
|
1195
|
+
}),
|
|
1196
|
+
now,
|
|
1197
|
+
isoIn(3_600_000),
|
|
1198
|
+
),
|
|
1199
|
+
]);
|
|
1200
|
+
|
|
1201
|
+
const derived = await deriveAnalysesForProject("default", db);
|
|
1202
|
+
expect(derived.map((d) => d.id)).toEqual(
|
|
1203
|
+
expect.arrayContaining(["ai_error_rate", "messaging_lag::orders"]),
|
|
1204
|
+
);
|
|
1205
|
+
|
|
1206
|
+
const defs = [...TIER0_ANALYSES, ...INVESTIGATION_ANALYSES, ...derived];
|
|
1207
|
+
for (const def of defs) {
|
|
1208
|
+
const result = await runSqlAnalysis(def, {
|
|
1209
|
+
db,
|
|
1210
|
+
projectId: "default",
|
|
1211
|
+
retentionHours: 24,
|
|
1212
|
+
}).catch((err: Error) => {
|
|
1213
|
+
throw new Error(`${def.id}: ${err.message}`);
|
|
1214
|
+
});
|
|
1215
|
+
expect(VALID_STATUSES.has(result.status), def.id).toBe(true);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const errorRate = await runSqlAnalysis(
|
|
1219
|
+
TIER0_ANALYSES.find((d) => d.id === "overall_error_rate") as never,
|
|
1220
|
+
{ db, projectId: "default", retentionHours: 24 },
|
|
1221
|
+
);
|
|
1222
|
+
expect(errorRate.primaryValue).toBe(0.5);
|
|
1223
|
+
expect(Array.isArray(errorRate.payload.sparkline)).toBe(true);
|
|
1224
|
+
});
|
|
1225
|
+
});
|