@obsunified/collector 2.2.1 → 2.3.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 (53) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
  4. package/dist/chunk-2FALTDCE.js.map +1 -0
  5. package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
  6. package/dist/chunk-RASSTGNB.js.map +1 -0
  7. package/dist/index.d.ts +10 -2
  8. package/dist/index.js +2892 -2240
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BmP5-f96.d.ts} +32 -1
  11. package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
  12. package/package.json +4 -3
  13. package/src/__tests__/autosre-closed-loop.test.ts +700 -0
  14. package/src/__tests__/db-parity.integration.test.ts +961 -0
  15. package/src/__tests__/internal-contracts-routes.test.ts +451 -0
  16. package/src/auth/dashboard-auth.test.ts +61 -0
  17. package/src/auth/dashboard-auth.ts +17 -2
  18. package/src/framework/env.ts +105 -0
  19. package/src/index.ts +13 -4
  20. package/src/lib/ai-store/calls.ts +43 -23
  21. package/src/lib/ai-store/evaluations.test.ts +3 -3
  22. package/src/lib/ai-store.ts +12 -1
  23. package/src/lib/analyses-store.test.ts +6 -6
  24. package/src/lib/autosre-findings.ts +169 -14
  25. package/src/lib/autosre-remediation-gate.ts +156 -26
  26. package/src/lib/db-parity.ts +883 -0
  27. package/src/lib/eval-cases-store.test.ts +1 -1
  28. package/src/lib/eval-cases-store.ts +16 -4
  29. package/src/lib/evidence-references.ts +30 -22
  30. package/src/lib/projects-store.test.ts +38 -1
  31. package/src/lib/projects-store.ts +23 -4
  32. package/src/lib/sql-db-postgres.integration.test.ts +75 -2
  33. package/src/lib/stage6.test.ts +1 -1
  34. package/src/lib/usage-store.ts +9 -3
  35. package/src/migrations/052_remediation_callback.sql +10 -0
  36. package/src/migrations/053_remediation_callback_delivery.sql +7 -0
  37. package/src/migrations/parity.test.ts +105 -3
  38. package/src/migrations-postgres/052_remediation_callback.sql +10 -0
  39. package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
  40. package/src/plugins/ai-call-by-id.test.ts +73 -0
  41. package/src/plugins/ai-receiver.ts +15 -0
  42. package/src/plugins/autosre-routes.ts +409 -147
  43. package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
  44. package/src/plugins/connected-routes/manifest.test.ts +224 -0
  45. package/src/plugins/connected-routes/manifest.ts +131 -28
  46. package/src/plugins/connected-routes-links.test.ts +96 -0
  47. package/src/plugins/connected-routes.test.ts +13 -11
  48. package/src/plugins/connected-routes.ts +53 -20
  49. package/src/plugins/eval-cases-routes.ts +33 -10
  50. package/src/plugins/evidence-retrieval-routes.ts +89 -24
  51. package/dist/chunk-KMEZKGSO.js.map +0 -1
  52. package/dist/chunk-ZSF5XP3B.js.map +0 -1
  53. /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
@@ -53,6 +53,48 @@ export async function ingestAICallBatch(
53
53
  await db.batch(batch);
54
54
  }
55
55
 
56
+ function mapAICallRow(row: AICallRow): AICallRecord {
57
+ return {
58
+ projectId: row.project_id ?? "default",
59
+ callId: row.call_id,
60
+ traceId: row.trace_id,
61
+ spanId: row.span_id,
62
+ serviceName: row.service_name,
63
+ modelName: row.model_name,
64
+ provider: row.provider,
65
+ callType: row.call_type,
66
+ requestJson: row.request_json,
67
+ responseJson: row.response_json,
68
+ promptTokens: row.prompt_tokens,
69
+ completionTokens: row.completion_tokens,
70
+ totalCostUsd: row.total_cost_usd,
71
+ latencyMs: row.latency_ms,
72
+ isError: row.is_error === 1 || row.is_error === true,
73
+ errorMessage: row.error_message,
74
+ occurredAt: row.occurred_at,
75
+ receivedAt: row.received_at,
76
+ expiresAt: row.expires_at,
77
+ sessionId: row.session_id ?? null,
78
+ interactionId: row.interaction_id ?? null,
79
+ };
80
+ }
81
+
82
+ /** One call by id, scoped to the project; `null` when absent. */
83
+ export async function getAICallById(
84
+ db: SqlDb,
85
+ projectId: string,
86
+ callId: string,
87
+ ): Promise<AICallRecord | null> {
88
+ if (!projectId) throw new Error("AIStore.getAICall: projectId is required");
89
+ const row = await db
90
+ .prepare(
91
+ `SELECT * FROM ai_calls WHERE project_id = ? AND call_id = ? LIMIT 1`,
92
+ )
93
+ .bind(projectId, callId)
94
+ .first<AICallRow>();
95
+ return row ? mapAICallRow(row) : null;
96
+ }
97
+
56
98
  export async function getAICallsOverview(
57
99
  db: SqlDb,
58
100
  options: AICallsOverviewOptions,
@@ -109,29 +151,7 @@ export async function getAICallsOverview(
109
151
  .bind(...summaryParams)
110
152
  .first<AICallSummaryRow>()) || {};
111
153
 
112
- const calls: AICallRecord[] = (results.results || []).map((row) => ({
113
- projectId: row.project_id ?? "default",
114
- callId: row.call_id,
115
- traceId: row.trace_id,
116
- spanId: row.span_id,
117
- serviceName: row.service_name,
118
- modelName: row.model_name,
119
- provider: row.provider,
120
- callType: row.call_type,
121
- requestJson: row.request_json,
122
- responseJson: row.response_json,
123
- promptTokens: row.prompt_tokens,
124
- completionTokens: row.completion_tokens,
125
- totalCostUsd: row.total_cost_usd,
126
- latencyMs: row.latency_ms,
127
- isError: row.is_error === 1 || row.is_error === true,
128
- errorMessage: row.error_message,
129
- occurredAt: row.occurred_at,
130
- receivedAt: row.received_at,
131
- expiresAt: row.expires_at,
132
- sessionId: row.session_id ?? null,
133
- interactionId: row.interaction_id ?? null,
134
- }));
154
+ const calls: AICallRecord[] = (results.results || []).map(mapAICallRow);
135
155
 
136
156
  return {
137
157
  calls,
@@ -25,20 +25,20 @@ describe("mapEvaluationRows", () => {
25
25
  evidenceId: "ai-evaluation:eval-1",
26
26
  entityKind: "eval",
27
27
  entityId: "eval-1",
28
- route: "#/evals/eval-1",
28
+ route: "#/evaluations/eval-1",
29
29
  source: "ai_span_evaluations.llm_judge",
30
30
  citations: [
31
31
  {
32
32
  label: "span span-1",
33
33
  entityKind: "span",
34
34
  entityId: "trace-1:span-1",
35
- route: "#/traces/trace-1#span=span-1",
35
+ route: "#/traces?trace=trace-1&span=span-1",
36
36
  },
37
37
  {
38
38
  label: "trace trace-1",
39
39
  entityKind: "trace",
40
40
  entityId: "trace-1",
41
- route: "#/traces/trace-1",
41
+ route: "#/traces?trace=trace-1",
42
42
  },
43
43
  ],
44
44
  }),
@@ -10,7 +10,11 @@ import type {
10
10
  AISpansOverviewOptions,
11
11
  AISpansOverviewResponse,
12
12
  } from "@obsunified/types";
13
- import { getAICallsOverview, ingestAICallBatch } from "./ai-store/calls";
13
+ import {
14
+ getAICallById,
15
+ getAICallsOverview,
16
+ ingestAICallBatch,
17
+ } from "./ai-store/calls";
14
18
  import {
15
19
  getAIEvaluation,
16
20
  ingestAIEvaluations,
@@ -37,6 +41,13 @@ export class AIStore {
37
41
  return getAICallsOverview(this.db, options);
38
42
  }
39
43
 
44
+ async getAICall(
45
+ projectId: string,
46
+ callId: string,
47
+ ): Promise<AICallRecord | null> {
48
+ return getAICallById(this.db, projectId, callId);
49
+ }
50
+
40
51
  async purgeExpired(): Promise<number> {
41
52
  return purgeExpiredAIData(this.db);
42
53
  }
@@ -118,7 +118,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
118
118
  "analysis:investigate.latency:tail_offenders:0:span:trace-1:span-1",
119
119
  entityKind: "span",
120
120
  entityId: "trace-1:span-1",
121
- route: "#/traces/trace-1#span=span-1",
121
+ route: "#/traces?trace=trace-1&span=span-1",
122
122
  source: "analysis.payload.evidence.tail_offenders",
123
123
  confidence: 0.95,
124
124
  citations: [
@@ -126,7 +126,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
126
126
  label: "trace trace-1",
127
127
  entityKind: "trace",
128
128
  entityId: "trace-1",
129
- route: "#/traces/trace-1",
129
+ route: "#/traces?trace=trace-1",
130
130
  },
131
131
  ],
132
132
  }),
@@ -134,7 +134,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
134
134
  evidenceId: "analysis:investigate.latency:tail_offenders:1:service:api",
135
135
  entityKind: "service",
136
136
  entityId: "api",
137
- route: "#/traces?service=api",
137
+ route: "#/service-map?service=api",
138
138
  source: "analysis.payload.evidence.tail_offenders",
139
139
  confidence: 0.7,
140
140
  }),
@@ -219,7 +219,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
219
219
  "analysis:trace.instrumentation_gaps:instrumentation-gap:0:span:trace-gap:span-root",
220
220
  entityKind: "span",
221
221
  entityId: "trace-gap:span-root",
222
- route: "#/traces/trace-gap#span=span-root",
222
+ route: "#/traces?trace=trace-gap&span=span-root",
223
223
  source: "analysis.payload.instrumentationGaps",
224
224
  confidence: 0.9,
225
225
  citations: [
@@ -227,7 +227,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
227
227
  label: "trace trace-gap",
228
228
  entityKind: "trace",
229
229
  entityId: "trace-gap",
230
- route: "#/traces/trace-gap",
230
+ route: "#/traces?trace=trace-gap",
231
231
  },
232
232
  ],
233
233
  suggestedNextPivots: expect.arrayContaining([
@@ -235,7 +235,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
235
235
  label: "Open profiler setup docs",
236
236
  entityKind: "docs",
237
237
  entityId: "howto/ebpf",
238
- route: "#/docs/howto/ebpf",
238
+ route: "#/evidence?kind=docs&id=howto%2Febpf",
239
239
  }),
240
240
  ]),
241
241
  });
@@ -15,10 +15,11 @@ import type {
15
15
  import {
16
16
  buildCanonicalNodeId,
17
17
  buildFindingFingerprint,
18
+ OntologyNodeKindSchema,
18
19
  } from "@obsunified/types";
19
20
  import { consoleLogger, type Logger } from "../framework/logger";
20
21
  import { computeChangeBlastRadius } from "./autosre-change-correlation";
21
- import { randomHex } from "./hash";
22
+ import { randomHex, sha256Hex } from "./hash";
22
23
  import { parseJsonArray, parseJsonRecord } from "./json";
23
24
  import type { SqlDb } from "./sql-db";
24
25
 
@@ -221,6 +222,97 @@ function resolveSafeCausalProofTier(
221
222
  return "unverified_locus";
222
223
  }
223
224
 
225
+ export interface FaultLocusSpanRow {
226
+ service_name: string | null;
227
+ span_name: string;
228
+ status_message: string | null;
229
+ error_signature?: string | null;
230
+ }
231
+
232
+ /** Looks up the span a finding's fault locus refers to (newest match wins). */
233
+ export async function loadFaultLocusSpan(
234
+ db: SqlDb,
235
+ projectId: string,
236
+ environment: string,
237
+ spanId: string,
238
+ traceId?: string,
239
+ ): Promise<FaultLocusSpanRow | null> {
240
+ if (!spanId) return null;
241
+ return db
242
+ .prepare(
243
+ `SELECT service_name, span_name, status_message, error_signature
244
+ FROM telemetry_spans
245
+ WHERE project_id = ? AND environment = ? AND span_id = ?
246
+ ${traceId ? "AND trace_id = ?" : ""}
247
+ ORDER BY start_time DESC
248
+ LIMIT 1`,
249
+ )
250
+ .bind(
251
+ ...(traceId
252
+ ? [projectId, environment, spanId, traceId]
253
+ : [projectId, environment, spanId]),
254
+ )
255
+ .first<FaultLocusSpanRow>();
256
+ }
257
+
258
+ /** Fingerprint inputs derivable from a span; empty fields are left undefined. */
259
+ export type SpanFingerprintInput = Pick<
260
+ FindingFingerprintInput,
261
+ "environment" | "originatingNodeId" | "downstreamTargetNodeId"
262
+ > &
263
+ Partial<
264
+ Pick<FindingFingerprintInput, "normalizedOperation" | "errorSignature">
265
+ >;
266
+
267
+ /** True for `kind:env:ns:name...` ids built by buildCanonicalNodeId. */
268
+ function looksLikeCanonicalNodeId(id: string): boolean {
269
+ const parts = id.split(":");
270
+ return (
271
+ parts.length >= 4 &&
272
+ (OntologyNodeKindSchema.options as readonly string[]).includes(parts[0])
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Splits a `<traceId>:<spanId>` span locus. Returns null for anything that
278
+ * is a canonical node id or does not have exactly two non-empty parts.
279
+ */
280
+ function splitTraceSpanLocus(
281
+ id: string,
282
+ ): { traceId: string; spanId: string } | null {
283
+ if (looksLikeCanonicalNodeId(id)) return null;
284
+ const parts = id.split(":");
285
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
286
+ return { traceId: parts[0], spanId: parts[1] };
287
+ }
288
+
289
+ /**
290
+ * Stable recurrence-fingerprint inputs for a fault-locus span: the service node,
291
+ * the span name as the operation and the span's error signature (falling back
292
+ * to its status message). No instance ids participate, so two traces failing
293
+ * the same way on the same service/route share a fingerprint. Used by both
294
+ * finding creation and the evidence bundle's prior-finding lookup.
295
+ */
296
+ export function fingerprintInputFromSpan(
297
+ span: FaultLocusSpanRow,
298
+ environment: string,
299
+ ): SpanFingerprintInput {
300
+ return {
301
+ environment,
302
+ originatingNodeId: buildCanonicalNodeId({
303
+ kind: "service",
304
+ environment,
305
+ serviceName: span.service_name || "unknown-service",
306
+ }).id,
307
+ // Empty strings are "absent": returning undefined lets the caller's
308
+ // `??` chain reach its title/summary fallbacks.
309
+ normalizedOperation: span.span_name?.trim() || undefined,
310
+ errorSignature:
311
+ span.error_signature?.trim() || span.status_message?.trim() || undefined,
312
+ downstreamTargetNodeId: null,
313
+ };
314
+ }
315
+
224
316
  export async function recordInvestigationFinding(
225
317
  db: SqlDb,
226
318
  projectId: string,
@@ -237,23 +329,86 @@ export async function recordInvestigationFinding(
237
329
  .trim()
238
330
  .toLowerCase();
239
331
 
240
- const defaultOriginNode = input.faultLocusEntityId.includes(":")
241
- ? input.faultLocusEntityId
332
+ // Idempotent retries: one key maps to one finding id per project and
333
+ // environment, and a replay returns the stored finding untouched.
334
+ const idempotencyKey = input.idempotencyKey?.trim();
335
+ let idempotencyId: string | undefined;
336
+ if (idempotencyKey) {
337
+ idempotencyId = `fnd_k${(await sha256Hex(`${projectId}\u0000${environment}\u0000${idempotencyKey}`)).slice(0, 20)}`;
338
+ const replay = await getInvestigationFinding(
339
+ db,
340
+ projectId,
341
+ idempotencyId,
342
+ environment,
343
+ );
344
+ if (replay) return replay;
345
+ }
346
+
347
+ // Findings pointing at a span id (no colon) must not mint a per-incident
348
+ // fingerprint. Resolve the span to its service/operation/error signature so
349
+ // every incident on the same service + route + error shares one identity.
350
+ // Caller-supplied fingerprint fields always win; unknown spans fall back to
351
+ // treating the locus id as a service name.
352
+ const supplied: Partial<FindingFingerprintInput> =
353
+ input.fingerprintInput ?? {};
354
+ const locusId = input.faultLocusEntityId.trim();
355
+ let spanDerived: Partial<FindingFingerprintInput> = {};
356
+ const traceSpan =
357
+ input.faultLocusEntityKind === "span" ? splitTraceSpanLocus(locusId) : null;
358
+ if (traceSpan) {
359
+ const spanRow = await loadFaultLocusSpan(
360
+ db,
361
+ projectId,
362
+ environment,
363
+ traceSpan.spanId,
364
+ traceSpan.traceId,
365
+ );
366
+ if (spanRow) spanDerived = fingerprintInputFromSpan(spanRow, environment);
367
+ } else if (
368
+ !locusId.includes(":") &&
369
+ input.faultLocusEntityKind !== "service"
370
+ ) {
371
+ const spanRow = await loadFaultLocusSpan(
372
+ db,
373
+ projectId,
374
+ environment,
375
+ locusId,
376
+ );
377
+ if (spanRow) spanDerived = fingerprintInputFromSpan(spanRow, environment);
378
+ }
379
+ const defaultOriginNode = locusId.includes(":")
380
+ ? traceSpan
381
+ ? buildCanonicalNodeId({
382
+ kind: "service",
383
+ environment,
384
+ serviceName: locusId,
385
+ }).id
386
+ : locusId
242
387
  : buildCanonicalNodeId({
243
388
  kind: "service",
244
389
  environment,
245
- serviceName: input.faultLocusEntityId,
390
+ serviceName: locusId,
246
391
  }).id;
247
392
 
248
- const fpResult = buildFindingFingerprint(
249
- input.fingerprintInput ?? {
250
- environment,
251
- originatingNodeId: defaultOriginNode,
252
- normalizedOperation: input.title,
253
- errorSignature: input.summary.slice(0, 120),
254
- downstreamTargetNodeId: null,
255
- },
256
- );
393
+ const fpResult = buildFindingFingerprint({
394
+ environment: supplied.environment ?? environment,
395
+ originatingNodeId:
396
+ supplied.originatingNodeId ??
397
+ spanDerived.originatingNodeId ??
398
+ defaultOriginNode,
399
+ normalizedOperation:
400
+ supplied.normalizedOperation ??
401
+ spanDerived.normalizedOperation ??
402
+ input.title,
403
+ errorSignature:
404
+ supplied.errorSignature ??
405
+ spanDerived.errorSignature ??
406
+ input.summary.slice(0, 120),
407
+ downstreamTargetNodeId:
408
+ supplied.downstreamTargetNodeId ??
409
+ spanDerived.downstreamTargetNodeId ??
410
+ null,
411
+ });
257
412
 
258
413
  // Check for prior verified findings with the same stable fingerprint
259
414
  const priorRow = await db
@@ -346,7 +501,7 @@ export async function recordInvestigationFinding(
346
501
  finalConfidence: confidence,
347
502
  };
348
503
 
349
- const id = input.id?.trim() || `fnd_${randomHex(10)}`;
504
+ const id = idempotencyId ?? (input.id?.trim() || `fnd_${randomHex(10)}`);
350
505
  const expiresAt = new Date(Date.now() + 90 * 24 * 3600_000).toISOString();
351
506
 
352
507
  await db