@obsunified/collector 2.2.0 → 2.2.3

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 (45) hide show
  1. package/dist/auth.js +1 -1
  2. package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
  3. package/dist/chunk-2FALTDCE.js.map +1 -0
  4. package/dist/index.d.ts +61 -3
  5. package/dist/index.js +10933 -9769
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -3
  8. package/src/__tests__/autosre-closed-loop.test.ts +1554 -17
  9. package/src/auth/dashboard-auth.test.ts +61 -0
  10. package/src/auth/dashboard-auth.ts +17 -2
  11. package/src/lib/ai-store/calls.ts +43 -23
  12. package/src/lib/ai-store.ts +12 -1
  13. package/src/lib/analyses-runner.ts +44 -7
  14. package/src/lib/autosre-change-correlation.ts +39 -12
  15. package/src/lib/autosre-findings.ts +237 -44
  16. package/src/lib/autosre-ontology-cache.test.ts +78 -0
  17. package/src/lib/autosre-ontology.ts +52 -28
  18. package/src/lib/autosre-remediation-gate.ts +942 -348
  19. package/src/lib/housekeeping.test.ts +70 -0
  20. package/src/lib/housekeeping.ts +91 -83
  21. package/src/lib/projects-store.test.ts +38 -1
  22. package/src/lib/projects-store.ts +23 -4
  23. package/src/lib/sql-db-postgres.integration.test.ts +75 -2
  24. package/src/lib/store/ingest.ts +8 -56
  25. package/src/lib/usage-store.ts +9 -3
  26. package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
  27. package/src/migrations/052_remediation_callback.sql +10 -0
  28. package/src/migrations/053_remediation_callback_delivery.sql +7 -0
  29. package/src/migrations/alert-state-notified.test.ts +3 -3
  30. package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
  31. package/src/migrations-postgres/052_remediation_callback.sql +10 -0
  32. package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
  33. package/src/plugins/ai-call-by-id.test.ts +73 -0
  34. package/src/plugins/ai-receiver.ts +10 -0
  35. package/src/plugins/alerts-evaluator.ts +15 -3
  36. package/src/plugins/autosre-routes.ts +374 -108
  37. package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
  38. package/src/plugins/connected-routes/manifest.test.ts +142 -0
  39. package/src/plugins/connected-routes/manifest.ts +123 -6
  40. package/src/plugins/connected-routes-links.test.ts +96 -0
  41. package/src/plugins/connected-routes.test.ts +3 -3
  42. package/src/plugins/connected-routes.ts +56 -83
  43. package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
  44. package/src/plugins/evidence-retrieval-routes.ts +397 -25
  45. package/dist/chunk-KMEZKGSO.js.map +0 -1
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hrefForEntity, linksFromAi, linksFromEvalResults } from "./manifest";
3
+
4
+ describe("connected-routes manifest link shapes", () => {
5
+ it("routes eval, service and ai_call entities to deep-linkable hashes", () => {
6
+ expect(hrefForEntity("eval", "case 1")).toBe("#/evaluations/case%201");
7
+ expect(hrefForEntity("service", "checkout")).toBe(
8
+ "#/service-map?service=checkout",
9
+ );
10
+ expect(hrefForEntity("ai_call", "call-1")).toBe("#/ai/calls/call-1");
11
+ });
12
+
13
+ it("carries the eval id and the ai call id in section links", () => {
14
+ const evals = linksFromEvalResults(
15
+ [
16
+ {
17
+ id: "eval-1",
18
+ actionId: "act-1",
19
+ projectId: "default",
20
+ evaluatorName: "groundedness",
21
+ evaluatorVersion: "1",
22
+ score: 1,
23
+ passed: 1,
24
+ reasoning: null,
25
+ rubricJson: null,
26
+ },
27
+ ],
28
+ "Evals",
29
+ );
30
+ expect(evals.links[0].href).toBe("#/evaluations/eval-1");
31
+ const ai = linksFromAi(
32
+ [
33
+ {
34
+ callId: "call/1",
35
+ provider: "openai",
36
+ modelName: "gpt",
37
+ } as unknown as Parameters<typeof linksFromAi>[0][number],
38
+ ],
39
+ "AI",
40
+ );
41
+ expect(ai.links[0].href).toBe("#/ai/calls/call%2F1");
42
+ expect(ai.links[0].entityId).toBe("call/1");
43
+ });
44
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ hrefForEntity,
4
+ linksFromArtifacts,
5
+ linksFromEvalResults,
6
+ linksFromRetrievalEvents,
7
+ linksFromToolCalls,
8
+ MAX_LINKS_INLINE,
9
+ resolveFaultLocusTarget,
10
+ } from "./manifest";
11
+
12
+ // Tabs served by apps/web/src/app/router.ts (KNOWN_TABS). Generated hrefs
13
+ // must start with one of these or the app falls back to Health.
14
+ const ROUTER_TABS = new Set([
15
+ "health",
16
+ "investigate",
17
+ "traces",
18
+ "service-map",
19
+ "issues",
20
+ "logs",
21
+ "ai",
22
+ "evidence",
23
+ "usage",
24
+ "replay",
25
+ "alerts",
26
+ "users",
27
+ "agent-runs",
28
+ "actions",
29
+ "tool-calls",
30
+ "profiles",
31
+ "tool-reliability",
32
+ "evaluations",
33
+ "changes",
34
+ "findings",
35
+ "incidents",
36
+ "remediations",
37
+ ]);
38
+ const tabOf = (href: string) =>
39
+ href.replace(/^#\//, "").split(/[/?#]/)[0] ?? "";
40
+
41
+ describe("connected manifest hrefs target real router tabs", () => {
42
+ it("tool call overflow, retrievals, evals and artifacts avoid unknown tabs", () => {
43
+ const many = Array.from({ length: MAX_LINKS_INLINE + 1 }, (_, i) => ({
44
+ id: `t${i}`,
45
+ actionId: "a1",
46
+ toolName: "search",
47
+ }));
48
+ const hrefs = [
49
+ linksFromToolCalls(many as never, "Tools").links[0]?.href,
50
+ linksFromRetrievalEvents(
51
+ [
52
+ {
53
+ id: "r1",
54
+ actionId: "a1",
55
+ retrieverName: "kb",
56
+ totalResults: 3,
57
+ },
58
+ ] as never,
59
+ "R",
60
+ ).links[0]?.href,
61
+ linksFromEvalResults(
62
+ [{ id: "e1", actionId: "a1", evaluatorName: "g", passed: 1 }] as never,
63
+ "E",
64
+ ).links[0]?.href,
65
+ linksFromArtifacts(
66
+ [
67
+ {
68
+ id: "x1",
69
+ actionId: "a1",
70
+ artifactName: "plan",
71
+ artifactType: "json",
72
+ },
73
+ ] as never,
74
+ "A",
75
+ ).links[0]?.href,
76
+ ];
77
+ expect(hrefs).toEqual([
78
+ "#/tool-calls",
79
+ "#/actions/a1",
80
+ "#/evaluations/e1",
81
+ "#/actions/a1",
82
+ ]);
83
+ for (const href of hrefs)
84
+ expect(ROUTER_TABS.has(tabOf(href ?? ""))).toBe(true);
85
+ });
86
+
87
+ it("hrefForEntity only emits router tabs for every evidence kind", () => {
88
+ for (const kind of [
89
+ "trace",
90
+ "span",
91
+ "alert",
92
+ "analysis",
93
+ "service",
94
+ "agent_run",
95
+ "action",
96
+ "tool_call",
97
+ "ai_call",
98
+ "profile",
99
+ "change",
100
+ "finding",
101
+ "incident",
102
+ "eval",
103
+ "log",
104
+ "replay",
105
+ "docs",
106
+ ]) {
107
+ expect(ROUTER_TABS.has(tabOf(hrefForEntity(kind, "id-1"))), kind).toBe(
108
+ true,
109
+ );
110
+ }
111
+ expect(hrefForEntity("trace", "abc")).toBe("#/traces?trace=abc");
112
+ expect(hrefForEntity("alert", "al 1")).toBe("#/alerts?id=al%201");
113
+ expect(hrefForEntity("ai_call", "c1")).toBe("#/ai/calls/c1");
114
+ });
115
+ });
116
+
117
+ describe("resolveFaultLocusTarget", () => {
118
+ const trace = "a".repeat(32);
119
+ const span = "b".repeat(16);
120
+ it("classifies service nodes, bare services and spans", () => {
121
+ expect(
122
+ resolveFaultLocusTarget("service:prod:default:checkout-api"),
123
+ ).toEqual({
124
+ entityKind: "service",
125
+ entityId: "service:prod:default:checkout-api",
126
+ href: "#/service-map?service=checkout-api",
127
+ });
128
+ expect(resolveFaultLocusTarget("checkout-api")).toMatchObject({
129
+ entityKind: "service",
130
+ href: "#/service-map?service=checkout-api",
131
+ });
132
+ expect(resolveFaultLocusTarget(`${trace}:${span}`)).toEqual({
133
+ entityKind: "span",
134
+ entityId: `${trace}:${span}`,
135
+ href: `#/traces?trace=${trace}&span=${span}`,
136
+ });
137
+ expect(resolveFaultLocusTarget(span)).toMatchObject({
138
+ entityKind: "span",
139
+ href: `#/evidence?kind=span&id=${span}`,
140
+ });
141
+ });
142
+ });
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  CausalProofTier,
3
+ EvidenceEntityKind,
3
4
  RelationshipCertainty,
4
5
  } from "@obsunified/types";
5
6
  import type {
@@ -58,7 +59,7 @@ export interface ConnectedLink {
58
59
  href: string;
59
60
  count?: number;
60
61
  sample?: string;
61
- entityKind?: ConnectedEntityKind | "trace" | "eval";
62
+ entityKind?: ConnectedEntityKind | EvidenceEntityKind;
62
63
  entityId?: string;
63
64
  source?: string;
64
65
  confidence?: number;
@@ -347,7 +348,9 @@ export const linksFromAi = (
347
348
  label: prefix,
348
349
  links: calls.map((c) => ({
349
350
  label: `${c.provider} · ${c.modelName}`,
350
- href: `#/ai?id=${c.callId}`,
351
+ href: `#/ai/calls/${encodeURIComponent(c.callId)}`,
352
+ entityKind: "ai_call",
353
+ entityId: c.callId,
351
354
  })),
352
355
  };
353
356
  };
@@ -454,7 +457,7 @@ export const linksFromToolCalls = (
454
457
  links: [
455
458
  {
456
459
  label: `${calls.length} tool calls`,
457
- href: `#/tools`,
460
+ href: `#/tool-calls`,
458
461
  count: calls.length,
459
462
  sample: truncate(calls[0].toolName),
460
463
  entityKind: "tool_call",
@@ -491,7 +494,8 @@ export const linksFromRetrievalEvents = (
491
494
  label: prefix,
492
495
  links: events.map((r) => ({
493
496
  label: `retrieve: ${r.retrieverName} (${r.totalResults} docs)`,
494
- href: `#/retrieval-events/${r.id}`,
497
+ // No dedicated retrieval tab: the owning action's detail page lists it.
498
+ href: `#/actions/${encodeURIComponent(r.actionId)}`,
495
499
  })),
496
500
  };
497
501
  };
@@ -514,7 +518,9 @@ export const linksFromEvalResults = (
514
518
  label: prefix,
515
519
  links: results.map((e) => ({
516
520
  label: `eval: ${e.evaluatorName} (${e.passed ? "passed" : "failed"})`,
517
- href: `#/evals/${e.id}`,
521
+ // The Evaluations tab resolves this id to an eval case, falling back
522
+ // to the case saved from the same action.
523
+ href: `#/evaluations/${encodeURIComponent(e.id)}`,
518
524
  entityKind: "eval",
519
525
  entityId: e.id,
520
526
  ...metadataForAction?.(e.actionId),
@@ -537,7 +543,118 @@ export const linksFromArtifacts = (
537
543
  label: prefix,
538
544
  links: arts.map((a) => ({
539
545
  label: `artifact: ${a.artifactName} (${a.artifactType})`,
540
- href: `#/artifacts/${a.id}`,
546
+ // No dedicated artifact tab: the owning action's detail page lists it.
547
+ href: `#/actions/${encodeURIComponent(a.actionId)}`,
541
548
  })),
542
549
  };
543
550
  };
551
+
552
+ /**
553
+ * Dashboard destination for an evidence entity, using only routes the web
554
+ * router (`apps/web/src/app/router.ts`) actually serves. Unknown kinds fall
555
+ * back to the evidence explorer so a link never lands on the Health tab.
556
+ */
557
+ export const hrefForEntity = (kind: string, id: string): string => {
558
+ const enc = encodeURIComponent;
559
+ switch (kind) {
560
+ case "trace":
561
+ return `#/traces?trace=${enc(id)}`;
562
+ case "span": {
563
+ const [traceId, spanId] = id.split(":");
564
+ return spanId && traceId
565
+ ? `#/traces?trace=${enc(traceId)}&span=${enc(spanId)}`
566
+ : `#/evidence?kind=span&id=${enc(id)}`;
567
+ }
568
+ case "alert":
569
+ return `#/alerts?id=${enc(id)}`;
570
+ case "analysis":
571
+ return "#/health";
572
+ case "service":
573
+ return `#/service-map?service=${enc(id)}`;
574
+ case "agent_run":
575
+ return `#/agent-runs/${enc(id)}`;
576
+ case "action":
577
+ return `#/actions/${enc(id)}`;
578
+ case "tool_call":
579
+ return `#/tool-calls/${enc(id)}`;
580
+ case "ai_call":
581
+ return `#/ai/calls/${enc(id)}`;
582
+ case "profile":
583
+ return `#/profiles/${enc(id)}`;
584
+ case "change":
585
+ return `#/changes/${enc(id)}`;
586
+ case "finding":
587
+ return `#/findings/${enc(id)}`;
588
+ case "incident":
589
+ return `#/incidents/${enc(id)}`;
590
+ case "eval":
591
+ return `#/evaluations/${enc(id)}`;
592
+ case "log":
593
+ return `#/logs?id=${enc(id)}`;
594
+ case "replay":
595
+ return `#/replay?session=${enc(id)}`;
596
+ default:
597
+ return `#/evidence?kind=${enc(kind)}&id=${enc(id)}`;
598
+ }
599
+ };
600
+
601
+ export interface FaultLocusTarget {
602
+ entityKind: "service" | "span";
603
+ entityId: string;
604
+ href: string;
605
+ }
606
+
607
+ const HEX_SPAN_PAIR = /^[0-9a-f]{32}:[0-9a-f]{16}$/i;
608
+ const HEX_SPAN_ID = /^[0-9a-f]{16}$/i;
609
+ const NODE_KINDS = new Set([
610
+ "service",
611
+ "endpoint",
612
+ "database",
613
+ "queue",
614
+ "messaging_topic",
615
+ "topic",
616
+ "cache",
617
+ "external",
618
+ ]);
619
+
620
+ /**
621
+ * A finding's `faultLocusEntityId` is a canonical ontology node id
622
+ * (`service:<env>:<ns>:<name>`, `endpoint:<env>:<ns>:<svc>:...`), a
623
+ * `traceId:spanId` pair, a bare span id, or a bare service name. Resolve it to
624
+ * the entity kind it really is and the surface that shows that kind.
625
+ */
626
+ export const resolveFaultLocusTarget = (
627
+ faultLocusEntityId: string,
628
+ ): FaultLocusTarget => {
629
+ const id = faultLocusEntityId;
630
+ if (HEX_SPAN_PAIR.test(id)) {
631
+ return {
632
+ entityKind: "span",
633
+ entityId: id,
634
+ href: hrefForEntity("span", id),
635
+ };
636
+ }
637
+ if (HEX_SPAN_ID.test(id)) {
638
+ return {
639
+ entityKind: "span",
640
+ entityId: id,
641
+ href: hrefForEntity("span", id),
642
+ };
643
+ }
644
+ const segments = id.split(":");
645
+ if (segments.length > 1 && NODE_KINDS.has(segments[0] ?? "")) {
646
+ // service:<env>:<ns>:<name> and endpoint:<env>:<ns>:<svc>:... both carry
647
+ // the owning service name at index 3.
648
+ const name = segments[3] || segments[segments.length - 1] || id;
649
+ return {
650
+ entityKind: "service",
651
+ entityId: id,
652
+ href: hrefForEntity("service", name),
653
+ };
654
+ }
655
+ return {
656
+ entityKind: "service",
657
+ entityId: id,
658
+ href: hrefForEntity("service", id),
659
+ };
660
+ };
@@ -0,0 +1,96 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it } from "vitest";
3
+ import { CollectorRuntime } from "../framework/collector";
4
+ import type { CollectorEnv } from "../framework/env";
5
+ import { recordInvestigationFinding } from "../lib/autosre-findings";
6
+ import { openOrDeduplicateIncident } from "../lib/autosre-remediation-gate";
7
+ import { createMigratedSqliteDb } from "../lib/test-utils/node-sqlite-db";
8
+ import { connectedRoutesPlugin } from "./connected-routes";
9
+
10
+ type Link = { href: string; entityKind?: string; entityId?: string };
11
+ type Manifest = { up: { links: Link[] }[]; across: { links: Link[] }[] };
12
+
13
+ const SERVICE_NODE = "service:prod:default:checkout-api";
14
+
15
+ const setup = () => {
16
+ const db = createMigratedSqliteDb();
17
+ const app = new Hono<{ Bindings: CollectorEnv }>();
18
+ connectedRoutesPlugin.register(app, new CollectorRuntime());
19
+ const env = { DB: db as unknown as D1Database } as CollectorEnv;
20
+ const get = async (path: string) =>
21
+ (await (await app.request(path, {}, env)).json()) as Manifest;
22
+ return { db, get };
23
+ };
24
+
25
+ const openIncident = async (
26
+ db: ReturnType<typeof setup>["db"],
27
+ kind: "trace" | "alert",
28
+ anchorId: string,
29
+ ) =>
30
+ (
31
+ await openOrDeduplicateIncident(db, "default", {
32
+ environment: "prod",
33
+ targetNodeId: SERVICE_NODE,
34
+ symptomAnchorKind: kind,
35
+ symptomAnchorId: anchorId,
36
+ symptomFingerprint: `fp_${kind}`,
37
+ symptomCategory: `fp_${kind}`,
38
+ })
39
+ ).incident;
40
+
41
+ describe("connected links carry the real entity kind and a matching href", () => {
42
+ it("incident trigger signal for a trace anchor is a trace link", async () => {
43
+ const { db, get } = setup();
44
+ const incident = await openIncident(db, "trace", "trace-123");
45
+ const m = await get(
46
+ `/internal/connected/incident/${incident.id}?environment=prod`,
47
+ );
48
+ expect(m.up[0]?.links[0]).toMatchObject({
49
+ entityKind: "trace",
50
+ entityId: "trace-123",
51
+ href: "#/traces?trace=trace-123",
52
+ });
53
+ db.close();
54
+ });
55
+
56
+ it("incident trigger signal for an alert anchor stays an alert link", async () => {
57
+ const { db, get } = setup();
58
+ const incident = await openIncident(db, "alert", "rule-9");
59
+ const m = await get(
60
+ `/internal/connected/incident/${incident.id}?environment=prod`,
61
+ );
62
+ expect(m.up[0]?.links[0]).toMatchObject({
63
+ entityKind: "alert",
64
+ entityId: "rule-9",
65
+ href: "#/alerts?id=rule-9",
66
+ });
67
+ db.close();
68
+ });
69
+
70
+ it.each([
71
+ [SERVICE_NODE, "service", "#/service-map?service=checkout-api"],
72
+ ["checkout-api", "service", "#/service-map?service=checkout-api"],
73
+ ])("finding fault locus %s links as %s", async (locus, kind, href) => {
74
+ const { db, get } = setup();
75
+ const incident = await openIncident(db, "trace", "t1");
76
+ const finding = await recordInvestigationFinding(db, "default", {
77
+ environment: "prod",
78
+ incidentId: incident.id,
79
+ title: "Regression",
80
+ summary: "Errors after deploy",
81
+ causalProofTier: "corroborated_candidate",
82
+ symptomEntityKind: "trace",
83
+ symptomEntityId: "t1",
84
+ faultLocusEntityKind: "service",
85
+ faultLocusEntityId: locus,
86
+ causalChain: [],
87
+ citedEvidenceIds: [],
88
+ });
89
+ const m = await get(
90
+ `/internal/connected/finding/${finding.id}?environment=prod`,
91
+ );
92
+ const link = m.across[0]?.links[0];
93
+ expect(link).toMatchObject({ entityKind: kind, entityId: locus, href });
94
+ db.close();
95
+ });
96
+ });
@@ -470,7 +470,7 @@ describe("ConnectedRail manifest — raw signal action back-links", () => {
470
470
  m.down.find((s) => s.label === "Evaluations for this span")?.links[0],
471
471
  ).toMatchObject({
472
472
  label: "eval: groundedness (passed)",
473
- href: "#/evals/eval-explicit",
473
+ href: "#/evaluations/eval-explicit",
474
474
  });
475
475
  expect(
476
476
  m.related.find((s) => s.label === "Agent runs for this span")?.links[0],
@@ -749,7 +749,7 @@ describe("ConnectedRail manifest — raw signal action back-links", () => {
749
749
  ).toMatchObject({ href: "#/tool-calls/tool-explicit" });
750
750
  expect(
751
751
  m.down.find((s) => s.label === "Evaluations for this AI call")?.links[0],
752
- ).toMatchObject({ href: "#/evals/eval-explicit" });
752
+ ).toMatchObject({ href: "#/evaluations/eval-explicit" });
753
753
  expect(
754
754
  m.related.find((s) => s.label === "Agent runs for this AI call")
755
755
  ?.links[0],
@@ -1280,7 +1280,7 @@ describe("ConnectedRail scenario acceptance contracts", () => {
1280
1280
  const aiSection = userManifest.across.find((s) =>
1281
1281
  s.label.toLowerCase().includes("ai call"),
1282
1282
  );
1283
- expect(aiSection?.links[0].href).toBe("#/ai?id=ai-heavy-1");
1283
+ expect(aiSection?.links[0].href).toBe("#/ai/calls/ai-heavy-1");
1284
1284
 
1285
1285
  const sessionManifest = await fetch(
1286
1286
  "/internal/connected/usage/sess-heavy?session_id=sess-heavy",