@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
@@ -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,224 @@
1
+ import { KNOWN_TABS, parseDashboardRouteHash } from "@obsunified/types";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ hrefForEntity,
5
+ linksFromActions,
6
+ linksFromAi,
7
+ linksFromArtifacts,
8
+ linksFromEvalResults,
9
+ linksFromLogs,
10
+ linksFromMetricExemplars,
11
+ linksFromRetrievalEvents,
12
+ linksFromSpans,
13
+ linksFromToolCalls,
14
+ linksFromUsage,
15
+ linkToAction,
16
+ linkToAgentRun,
17
+ linkToSession,
18
+ linkToTrace,
19
+ MAX_LINKS_INLINE,
20
+ resolveFaultLocusTarget,
21
+ } from "./manifest";
22
+
23
+ const tabOf = (href: string) =>
24
+ href.replace(/^#\//, "").split(/[/?#]/)[0] ?? "";
25
+
26
+ describe("connected manifest hrefs target real router tabs", () => {
27
+ it("tool call overflow, retrievals, evals and artifacts avoid unknown tabs", () => {
28
+ const many = Array.from({ length: MAX_LINKS_INLINE + 1 }, (_, i) => ({
29
+ id: `t${i}`,
30
+ actionId: "a1",
31
+ toolName: "search",
32
+ }));
33
+ const hrefs = [
34
+ linksFromToolCalls(many as never, "Tools").links[0]?.href,
35
+ linksFromRetrievalEvents(
36
+ [
37
+ {
38
+ id: "r1",
39
+ actionId: "a1",
40
+ retrieverName: "kb",
41
+ totalResults: 3,
42
+ },
43
+ ] as never,
44
+ "R",
45
+ ).links[0]?.href,
46
+ linksFromEvalResults(
47
+ [{ id: "e1", actionId: "a1", evaluatorName: "g", passed: 1 }] as never,
48
+ "E",
49
+ ).links[0]?.href,
50
+ linksFromArtifacts(
51
+ [
52
+ {
53
+ id: "x1",
54
+ actionId: "a1",
55
+ artifactName: "plan",
56
+ artifactType: "json",
57
+ },
58
+ ] as never,
59
+ "A",
60
+ ).links[0]?.href,
61
+ ];
62
+ expect(hrefs).toEqual([
63
+ "#/tool-calls",
64
+ "#/actions/a1",
65
+ "#/evaluations/e1",
66
+ "#/actions/a1",
67
+ ]);
68
+ for (const href of hrefs) {
69
+ expect(KNOWN_TABS.has(tabOf(href ?? ""))).toBe(true);
70
+ expect(parseDashboardRouteHash(href ?? "").ok).toBe(true);
71
+ }
72
+ });
73
+
74
+ it("emits canonical routes from all link builders and round-trips through parseDashboardRouteHash", () => {
75
+ expect(linkToTrace("trace-1").href).toBe("#/traces?trace=trace-1");
76
+ expect(linkToSession("sess/1").href).toBe("#/replay?session=sess%2F1");
77
+ expect(linkToAction("act/1").href).toBe("#/actions/act%2F1");
78
+ expect(linkToAgentRun("run/1").href).toBe("#/agent-runs/run%2F1");
79
+
80
+ const inlineSpans = linksFromSpans(
81
+ [
82
+ {
83
+ traceId: "tr-1",
84
+ spanId: "sp-1",
85
+ serviceName: "api",
86
+ spanName: "GET /",
87
+ } as never,
88
+ ],
89
+ "Spans",
90
+ );
91
+ expect(inlineSpans.links[0]?.href).toBe("#/traces?trace=tr-1&span=sp-1");
92
+ expect(parseDashboardRouteHash(inlineSpans.links[0]?.href ?? "")).toEqual({
93
+ ok: true,
94
+ route: { tab: "traces", traceId: "tr-1", spanId: "sp-1" },
95
+ });
96
+
97
+ const manySpans = linksFromSpans(
98
+ Array.from({ length: MAX_LINKS_INLINE + 1 }, (_, i) => ({
99
+ traceId: "tr-many",
100
+ spanId: `sp-${i}`,
101
+ serviceName: "api",
102
+ spanName: "op",
103
+ })) as never,
104
+ "Spans",
105
+ );
106
+ expect(manySpans.links[0]?.href).toBe("#/traces?q=tr-many");
107
+
108
+ const inlineLogs = linksFromLogs(
109
+ [
110
+ {
111
+ logId: "log-1",
112
+ traceId: "tr-1",
113
+ severity: "ERROR",
114
+ message: "boom",
115
+ } as never,
116
+ ],
117
+ "Logs",
118
+ );
119
+ expect(inlineLogs.links[0]?.href).toBe("#/logs?id=log-1");
120
+
121
+ const inlineUsage = linksFromUsage(
122
+ [
123
+ {
124
+ eventId: "ev-1",
125
+ eventType: "click",
126
+ eventName: "buy",
127
+ } as never,
128
+ ],
129
+ "Usage",
130
+ );
131
+ expect(inlineUsage.links[0]?.href).toBe("#/usage?id=ev-1");
132
+
133
+ const inlineExemplars = linksFromMetricExemplars(
134
+ [
135
+ {
136
+ traceId: "tr-1",
137
+ spanId: "sp-2",
138
+ serviceName: "api",
139
+ metricName: "latency",
140
+ value: 42,
141
+ tsNs: "100",
142
+ } as never,
143
+ ],
144
+ "Exemplars",
145
+ );
146
+ expect(inlineExemplars.links[0]?.href).toBe(
147
+ "#/traces?trace=tr-1&span=sp-2",
148
+ );
149
+
150
+ const inlineAi = linksFromAi(
151
+ [
152
+ {
153
+ callId: "call/1",
154
+ provider: "openai",
155
+ modelName: "gpt-4o",
156
+ } as never,
157
+ ],
158
+ "AI",
159
+ );
160
+ expect(inlineAi.links[0]?.href).toBe("#/ai/calls/call%2F1");
161
+
162
+ const inlineActions = linksFromActions(
163
+ [{ id: "act-1", actionKind: "step", name: "plan" } as never],
164
+ "Actions",
165
+ );
166
+ expect(inlineActions.links[0]?.href).toBe("#/actions/act-1");
167
+ });
168
+
169
+ it("hrefForEntity only emits router tabs for every evidence kind", () => {
170
+ for (const kind of [
171
+ "trace",
172
+ "span",
173
+ "alert",
174
+ "analysis",
175
+ "service",
176
+ "agent_run",
177
+ "action",
178
+ "tool_call",
179
+ "ai_call",
180
+ "profile",
181
+ "change",
182
+ "finding",
183
+ "incident",
184
+ "eval",
185
+ "log",
186
+ "replay",
187
+ "docs",
188
+ ]) {
189
+ const href = hrefForEntity(kind, "id-1");
190
+ expect(KNOWN_TABS.has(tabOf(href)), kind).toBe(true);
191
+ expect(parseDashboardRouteHash(href).ok, kind).toBe(true);
192
+ }
193
+ expect(hrefForEntity("trace", "abc")).toBe("#/traces?trace=abc");
194
+ expect(hrefForEntity("alert", "al 1")).toBe("#/alerts?id=al+1");
195
+ expect(hrefForEntity("ai_call", "c1")).toBe("#/ai/calls/c1");
196
+ });
197
+ });
198
+
199
+ describe("resolveFaultLocusTarget", () => {
200
+ const trace = "a".repeat(32);
201
+ const span = "b".repeat(16);
202
+ it("classifies service nodes, bare services and spans", () => {
203
+ expect(
204
+ resolveFaultLocusTarget("service:prod:default:checkout-api"),
205
+ ).toEqual({
206
+ entityKind: "service",
207
+ entityId: "service:prod:default:checkout-api",
208
+ href: "#/service-map?service=checkout-api",
209
+ });
210
+ expect(resolveFaultLocusTarget("checkout-api")).toMatchObject({
211
+ entityKind: "service",
212
+ href: "#/service-map?service=checkout-api",
213
+ });
214
+ expect(resolveFaultLocusTarget(`${trace}:${span}`)).toEqual({
215
+ entityKind: "span",
216
+ entityId: `${trace}:${span}`,
217
+ href: `#/traces?trace=${trace}&span=${span}`,
218
+ });
219
+ expect(resolveFaultLocusTarget(span)).toMatchObject({
220
+ entityKind: "span",
221
+ href: `#/evidence?kind=span&id=${span}`,
222
+ });
223
+ });
224
+ });
@@ -1,4 +1,11 @@
1
- import type { CausalProofTier, RelationshipCertainty } from "@obsunified/types";
1
+ import {
2
+ type CausalProofTier,
3
+ dashboardRouteToHref,
4
+ type EvidenceEntityKind,
5
+ type RelationshipCertainty,
6
+ routeForEntity,
7
+ hrefForEntity as sharedHrefForEntity,
8
+ } from "@obsunified/types";
2
9
  import type {
3
10
  ActionRef,
4
11
  AICallRef,
@@ -15,6 +22,8 @@ import type {
15
22
  import type { CausalConfidence } from "../../lib/identity-index/types";
16
23
  import type { SqlDb } from "../../lib/sql-db";
17
24
 
25
+ export { routeForEntity };
26
+
18
27
  export type ConnectedEntityKind =
19
28
  | "span"
20
29
  | "profile"
@@ -55,7 +64,7 @@ export interface ConnectedLink {
55
64
  href: string;
56
65
  count?: number;
57
66
  sample?: string;
58
- entityKind?: ConnectedEntityKind | "trace" | "eval";
67
+ entityKind?: ConnectedEntityKind | EvidenceEntityKind;
59
68
  entityId?: string;
60
69
  source?: string;
61
70
  confidence?: number;
@@ -90,7 +99,7 @@ export const linkToTrace = (
90
99
  label?: string,
91
100
  ): ConnectedLink => ({
92
101
  label: label ?? `trace ${traceId.slice(0, 12)}`,
93
- href: `#/traces/${traceId}`,
102
+ href: dashboardRouteToHref({ tab: "traces", traceId }),
94
103
  entityKind: "trace",
95
104
  entityId: traceId,
96
105
  });
@@ -104,7 +113,7 @@ export const linkToSession = (sessionId: string): ConnectedLink => ({
104
113
  sessionId.length > 12
105
114
  ? `session …${sessionId.slice(-12)}`
106
115
  : `session ${sessionId}`,
107
- href: `#/replay?session=${encodeURIComponent(sessionId)}`,
116
+ href: dashboardRouteToHref({ tab: "replay", sessionId }),
108
117
  sample: sessionId,
109
118
  entityKind: "replay",
110
119
  entityId: sessionId,
@@ -127,7 +136,10 @@ export const linksFromSpans = (
127
136
  links: [
128
137
  {
129
138
  label: `${spans.length} spans`,
130
- href: `#/traces?q=${encodeURIComponent(spans[0].traceId)}`,
139
+ href: dashboardRouteToHref({
140
+ tab: "traces",
141
+ query: spans[0].traceId,
142
+ }),
131
143
  count: spans.length,
132
144
  sample: truncate(
133
145
  `${spans[0].serviceName ?? "?"} · ${spans[0].spanName}`,
@@ -140,7 +152,11 @@ export const linksFromSpans = (
140
152
  label: prefix,
141
153
  links: spans.map((s) => ({
142
154
  label: `${s.serviceName ?? "?"} · ${truncate(s.spanName, 40)}`,
143
- href: `#/traces/${s.traceId}#span=${s.spanId}`,
155
+ href: dashboardRouteToHref({
156
+ tab: "traces",
157
+ traceId: s.traceId,
158
+ spanId: s.spanId,
159
+ }),
144
160
  })),
145
161
  };
146
162
  };
@@ -163,7 +179,10 @@ export const linksFromLogs = (
163
179
  links: [
164
180
  {
165
181
  label: `${logs.length} logs`,
166
- href: `#/logs?q=${encodeURIComponent(sample.traceId ?? "")}`,
182
+ href: dashboardRouteToHref({
183
+ tab: "logs",
184
+ ...(sample.traceId ? { query: sample.traceId } : {}),
185
+ }),
167
186
  count: logs.length,
168
187
  sample: truncate(sample.message),
169
188
  },
@@ -174,7 +193,7 @@ export const linksFromLogs = (
174
193
  label: prefix,
175
194
  links: logs.map((l) => ({
176
195
  label: `[${l.severity}] ${truncate(l.message, 60)}`,
177
- href: `#/logs?id=${l.logId}`,
196
+ href: dashboardRouteToHref({ tab: "logs", logId: l.logId }),
178
197
  })),
179
198
  };
180
199
  };
@@ -196,7 +215,7 @@ export const linksFromUsage = (
196
215
  links: [
197
216
  {
198
217
  label: `${events.length} usage events`,
199
- href: `#/usage`,
218
+ href: dashboardRouteToHref({ tab: "usage" }),
200
219
  count: events.length,
201
220
  sample: truncate(`${events[0].eventType} · ${events[0].eventName}`),
202
221
  },
@@ -207,7 +226,7 @@ export const linksFromUsage = (
207
226
  label: prefix,
208
227
  links: events.map((e) => ({
209
228
  label: `${e.eventType} · ${truncate(e.eventName, 50)}`,
210
- href: `#/usage?id=${e.eventId}`,
229
+ href: dashboardRouteToHref({ tab: "usage", usageId: e.eventId }),
211
230
  })),
212
231
  };
213
232
  };
@@ -231,8 +250,8 @@ export const linksFromMetricExemplars = (
231
250
  {
232
251
  label: `${exemplars.length} metric exemplars`,
233
252
  href: sample.traceId
234
- ? `#/traces/${sample.traceId}`
235
- : `#/traces?q=${encodeURIComponent(sample.metricName)}`,
253
+ ? dashboardRouteToHref({ tab: "traces", traceId: sample.traceId })
254
+ : dashboardRouteToHref({ tab: "traces", query: sample.metricName }),
236
255
  count: exemplars.length,
237
256
  sample: truncate(
238
257
  `${sample.serviceName ?? "?"} · ${sample.metricName}=${sample.value}`,
@@ -246,10 +265,12 @@ export const linksFromMetricExemplars = (
246
265
  links: exemplars.map((e) => ({
247
266
  label: `${e.serviceName ?? "?"} · ${truncate(e.metricName, 40)}=${e.value}`,
248
267
  href: e.traceId
249
- ? e.spanId
250
- ? `#/traces/${e.traceId}#span=${e.spanId}`
251
- : `#/traces/${e.traceId}`
252
- : `#/traces?q=${encodeURIComponent(e.metricName)}`,
268
+ ? dashboardRouteToHref({
269
+ tab: "traces",
270
+ traceId: e.traceId,
271
+ ...(e.spanId ? { spanId: e.spanId } : {}),
272
+ })
273
+ : dashboardRouteToHref({ tab: "traces", query: e.metricName }),
253
274
  sample: e.tsNs,
254
275
  })),
255
276
  };
@@ -309,7 +330,11 @@ export const profileLinksForTrace = async (
309
330
  label: `${icon} ${type === "offcpu" ? "Off-CPU profiles" : `${type[0].toUpperCase()}${type.slice(1)} profiles`}`,
310
331
  links: list.map((r) => ({
311
332
  label: `${r.service_name ?? "?"} · ${r.duration_ms}ms`,
312
- href: `#/profiles/${r.id}?trace_id=${encodeURIComponent(traceId)}`,
333
+ href: dashboardRouteToHref({
334
+ tab: "profiles",
335
+ profileId: r.id,
336
+ traceId,
337
+ }),
313
338
  })),
314
339
  });
315
340
  }
@@ -333,7 +358,7 @@ export const linksFromAi = (
333
358
  links: [
334
359
  {
335
360
  label: `${calls.length} AI calls`,
336
- href: `#/ai`,
361
+ href: dashboardRouteToHref({ tab: "ai" }),
337
362
  count: calls.length,
338
363
  sample: truncate(`${calls[0].provider} · ${calls[0].modelName}`),
339
364
  },
@@ -344,7 +369,9 @@ export const linksFromAi = (
344
369
  label: prefix,
345
370
  links: calls.map((c) => ({
346
371
  label: `${c.provider} · ${c.modelName}`,
347
- href: `#/ai?id=${c.callId}`,
372
+ href: dashboardRouteToHref({ tab: "ai", aiCallId: c.callId }),
373
+ entityKind: "ai_call",
374
+ entityId: c.callId,
348
375
  })),
349
376
  };
350
377
  };
@@ -354,7 +381,7 @@ export const linkToAction = (
354
381
  label?: string,
355
382
  ): ConnectedLink => ({
356
383
  label: label ?? `action ${actionId.slice(0, 12)}`,
357
- href: `#/actions/${actionId}`,
384
+ href: dashboardRouteToHref({ tab: "actions", actionId }),
358
385
  entityKind: "action",
359
386
  entityId: actionId,
360
387
  });
@@ -364,7 +391,7 @@ export const linkToAgentRun = (
364
391
  label?: string,
365
392
  ): ConnectedLink => ({
366
393
  label: label ?? `agent run ${runId.slice(0, 12)}`,
367
- href: `#/agent-runs/${runId}`,
394
+ href: dashboardRouteToHref({ tab: "agent-runs", agentRunId: runId }),
368
395
  entityKind: "agent_run",
369
396
  entityId: runId,
370
397
  });
@@ -407,7 +434,7 @@ export const linksFromActions = (
407
434
  links: [
408
435
  {
409
436
  label: `${actions.length} actions`,
410
- href: `#/actions`,
437
+ href: dashboardRouteToHref({ tab: "actions" }),
411
438
  count: actions.length,
412
439
  sample: truncate(
413
440
  `${actions[0].actionKind} · ${actions[0].name ?? "unnamed"}`,
@@ -423,7 +450,7 @@ export const linksFromActions = (
423
450
  label: prefix,
424
451
  links: actions.map((a) => ({
425
452
  label: `[${a.actionKind}] ${truncate(a.name ?? "unnamed", 40)}`,
426
- href: `#/actions/${a.id}`,
453
+ href: dashboardRouteToHref({ tab: "actions", actionId: a.id }),
427
454
  entityKind: "action",
428
455
  entityId: a.id,
429
456
  ...actionContextMetadata(a, metadata),
@@ -451,7 +478,7 @@ export const linksFromToolCalls = (
451
478
  links: [
452
479
  {
453
480
  label: `${calls.length} tool calls`,
454
- href: `#/tools`,
481
+ href: dashboardRouteToHref({ tab: "tool-calls" }),
455
482
  count: calls.length,
456
483
  sample: truncate(calls[0].toolName),
457
484
  entityKind: "tool_call",
@@ -465,7 +492,7 @@ export const linksFromToolCalls = (
465
492
  label: prefix,
466
493
  links: calls.map((t) => ({
467
494
  label: `tool: ${t.toolName}`,
468
- href: `#/tool-calls/${t.id}`,
495
+ href: dashboardRouteToHref({ tab: "tool-calls", toolCallId: t.id }),
469
496
  entityKind: "tool_call",
470
497
  entityId: t.id,
471
498
  ...metadataForAction?.(t.actionId),
@@ -488,7 +515,8 @@ export const linksFromRetrievalEvents = (
488
515
  label: prefix,
489
516
  links: events.map((r) => ({
490
517
  label: `retrieve: ${r.retrieverName} (${r.totalResults} docs)`,
491
- href: `#/retrieval-events/${r.id}`,
518
+ // No dedicated retrieval tab: the owning action's detail page lists it.
519
+ href: dashboardRouteToHref({ tab: "actions", actionId: r.actionId }),
492
520
  })),
493
521
  };
494
522
  };
@@ -511,7 +539,9 @@ export const linksFromEvalResults = (
511
539
  label: prefix,
512
540
  links: results.map((e) => ({
513
541
  label: `eval: ${e.evaluatorName} (${e.passed ? "passed" : "failed"})`,
514
- href: `#/evals/${e.id}`,
542
+ // The Evaluations tab resolves this id to an eval case, falling back
543
+ // to the case saved from the same action.
544
+ href: dashboardRouteToHref({ tab: "evaluations", evaluationId: e.id }),
515
545
  entityKind: "eval",
516
546
  entityId: e.id,
517
547
  ...metadataForAction?.(e.actionId),
@@ -534,7 +564,80 @@ export const linksFromArtifacts = (
534
564
  label: prefix,
535
565
  links: arts.map((a) => ({
536
566
  label: `artifact: ${a.artifactName} (${a.artifactType})`,
537
- href: `#/artifacts/${a.id}`,
567
+ // No dedicated artifact tab: the owning action's detail page lists it.
568
+ href: dashboardRouteToHref({ tab: "actions", actionId: a.actionId }),
538
569
  })),
539
570
  };
540
571
  };
572
+
573
+ /**
574
+ * Dashboard destination for an evidence entity, delegating to the shared
575
+ * `@obsunified/types` route contract. Unknown kinds fall back to the
576
+ * evidence explorer so a link never lands on the Health tab.
577
+ */
578
+ export const hrefForEntity = (
579
+ kind: string,
580
+ id: string,
581
+ options?: { traceId?: string; spanId?: string },
582
+ ): string => sharedHrefForEntity(kind, id, options);
583
+
584
+ export interface FaultLocusTarget {
585
+ entityKind: "service" | "span";
586
+ entityId: string;
587
+ href: string;
588
+ }
589
+
590
+ const HEX_SPAN_PAIR = /^[0-9a-f]{32}:[0-9a-f]{16}$/i;
591
+ const HEX_SPAN_ID = /^[0-9a-f]{16}$/i;
592
+ const NODE_KINDS = new Set([
593
+ "service",
594
+ "endpoint",
595
+ "database",
596
+ "queue",
597
+ "messaging_topic",
598
+ "topic",
599
+ "cache",
600
+ "external",
601
+ ]);
602
+
603
+ /**
604
+ * A finding's `faultLocusEntityId` is a canonical ontology node id
605
+ * (`service:<env>:<ns>:<name>`, `endpoint:<env>:<ns>:<svc>:...`), a
606
+ * `traceId:spanId` pair, a bare span id, or a bare service name. Resolve it to
607
+ * the entity kind it really is and the surface that shows that kind.
608
+ */
609
+ export const resolveFaultLocusTarget = (
610
+ faultLocusEntityId: string,
611
+ ): FaultLocusTarget => {
612
+ const id = faultLocusEntityId;
613
+ if (HEX_SPAN_PAIR.test(id)) {
614
+ return {
615
+ entityKind: "span",
616
+ entityId: id,
617
+ href: hrefForEntity("span", id),
618
+ };
619
+ }
620
+ if (HEX_SPAN_ID.test(id)) {
621
+ return {
622
+ entityKind: "span",
623
+ entityId: id,
624
+ href: hrefForEntity("span", id),
625
+ };
626
+ }
627
+ const segments = id.split(":");
628
+ if (segments.length > 1 && NODE_KINDS.has(segments[0] ?? "")) {
629
+ // service:<env>:<ns>:<name> and endpoint:<env>:<ns>:<svc>:... both carry
630
+ // the owning service name at index 3.
631
+ const name = segments[3] || segments[segments.length - 1] || id;
632
+ return {
633
+ entityKind: "service",
634
+ entityId: id,
635
+ href: hrefForEntity("service", name),
636
+ };
637
+ }
638
+ return {
639
+ entityKind: "service",
640
+ entityId: id,
641
+ href: hrefForEntity("service", id),
642
+ };
643
+ };
@@ -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
+ });