@obsunified/collector 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -1,3 +1,7 @@
1
+ import type {
2
+ CausalProofTier,
3
+ RelationshipCertainty,
4
+ } from "@obsunified/types";
1
5
  import type {
2
6
  ActionRef,
3
7
  AICallRef,
@@ -26,7 +30,10 @@ export type ConnectedEntityKind =
26
30
  | "user"
27
31
  | "action"
28
32
  | "agent_run"
29
- | "tool_call";
33
+ | "tool_call"
34
+ | "change"
35
+ | "finding"
36
+ | "incident";
30
37
 
31
38
  export const KNOWN_KINDS: ReadonlySet<string> = new Set<ConnectedEntityKind>([
32
39
  "span",
@@ -41,6 +48,9 @@ export const KNOWN_KINDS: ReadonlySet<string> = new Set<ConnectedEntityKind>([
41
48
  "action",
42
49
  "agent_run",
43
50
  "tool_call",
51
+ "change",
52
+ "finding",
53
+ "incident",
44
54
  ]);
45
55
 
46
56
  export interface ConnectedLink {
@@ -54,6 +64,8 @@ export interface ConnectedLink {
54
64
  confidence?: number;
55
65
  reason?: string;
56
66
  causalConfidence?: CausalConfidence;
67
+ relationshipCertainty?: RelationshipCertainty;
68
+ causalProofTier?: CausalProofTier;
57
69
  }
58
70
 
59
71
  export interface ConnectedSection {
@@ -1789,3 +1789,109 @@ describe("ConnectedRail manifest — agent action graph entities (RFC 0010)", ()
1789
1789
  expect(otherTools?.links[0].label).toContain("other_tool");
1790
1790
  });
1791
1791
  });
1792
+
1793
+ describe("ConnectedRail manifest — span parent session link", () => {
1794
+ it("surfaces parent session link under Up when interaction usage event has session_id", async () => {
1795
+ const db = new MemSqlDb({
1796
+ all: (sql) => {
1797
+ if (sql.includes("FROM telemetry_spans")) {
1798
+ return [
1799
+ {
1800
+ trace_id: "tr-payment",
1801
+ span_id: "sp-payment",
1802
+ parent_span_id: null,
1803
+ service_name: "checkout-api",
1804
+ span_name: "payment.charge",
1805
+ status_code: 1,
1806
+ status_message: null,
1807
+ start_time: "2026-05-04T10:00:00Z",
1808
+ duration_ms: 700,
1809
+ interaction_id: "int-checkout-click",
1810
+ },
1811
+ ];
1812
+ }
1813
+ if (sql.includes("FROM usage_events")) {
1814
+ return [
1815
+ {
1816
+ event_id: "evt-click-1",
1817
+ event_type: "interaction",
1818
+ event_name: "click_checkout",
1819
+ page_path: "/api/checkout",
1820
+ severity: null,
1821
+ occurred_at: "2026-05-04T10:00:00Z",
1822
+ interaction_id: "int-checkout-click",
1823
+ session_id: "session-abc-123456",
1824
+ },
1825
+ ];
1826
+ }
1827
+ return [];
1828
+ },
1829
+ first: () => null,
1830
+ });
1831
+ const fetch = setup(db);
1832
+ const m = await fetch("/internal/connected/span/tr-payment:sp-payment");
1833
+
1834
+ // Preserves parent trace link
1835
+ const traceSection = m.up.find((s) => s.label === "Trace");
1836
+ expect(traceSection).toBeDefined();
1837
+ expect(traceSection?.links[0]).toMatchObject({
1838
+ entityKind: "trace",
1839
+ entityId: "tr-payment",
1840
+ });
1841
+
1842
+ // Surfaces parent session link via linkToSession
1843
+ const sessionSection = m.up.find((s) =>
1844
+ s.label.toLowerCase().includes("session"),
1845
+ );
1846
+ expect(sessionSection).toBeDefined();
1847
+ expect(sessionSection?.links[0]).toMatchObject({
1848
+ entityKind: "replay",
1849
+ sample: "session-abc-123456",
1850
+ href: "#/replay?session=session-abc-123456",
1851
+ });
1852
+
1853
+ // Preserves originating click link in Related
1854
+ const clickSection = m.related.find((s) =>
1855
+ s.label.toLowerCase().includes("click"),
1856
+ );
1857
+ expect(clickSection).toBeDefined();
1858
+ expect(clickSection?.links.length).toBeGreaterThan(0);
1859
+ });
1860
+
1861
+ it("preserves informative absence when span has no interaction or session", async () => {
1862
+ const db = new MemSqlDb({
1863
+ all: (sql) => {
1864
+ if (sql.includes("FROM telemetry_spans")) {
1865
+ return [
1866
+ {
1867
+ trace_id: "tr-cron",
1868
+ span_id: "sp-cron",
1869
+ parent_span_id: null,
1870
+ service_name: "worker",
1871
+ span_name: "cron.tick",
1872
+ status_code: 1,
1873
+ status_message: null,
1874
+ start_time: "2026-05-04T10:00:00Z",
1875
+ duration_ms: 100,
1876
+ interaction_id: null,
1877
+ },
1878
+ ];
1879
+ }
1880
+ return [];
1881
+ },
1882
+ first: () => null,
1883
+ });
1884
+ const fetch = setup(db);
1885
+ const m = await fetch("/internal/connected/span/tr-cron:sp-cron");
1886
+
1887
+ expect(m.up).toHaveLength(1);
1888
+ expect(m.up[0].label).toBe("Trace");
1889
+
1890
+ const clickSection = m.related.find((s) =>
1891
+ s.label.toLowerCase().includes("click"),
1892
+ );
1893
+ expect(clickSection).toBeDefined();
1894
+ expect(clickSection?.links).toEqual([]);
1895
+ expect(clickSection?.emptyReason).toContain("Server-originated work");
1896
+ });
1897
+ });
@@ -289,12 +289,25 @@ export const connectedRoutesPlugin: CollectorPlugin = {
289
289
  const otherSpans = traceManifest.spans.filter(
290
290
  (s) => s.spanId !== span.spanId,
291
291
  );
292
+ const interactionManifest = span.interactionId
293
+ ? await index.byInteraction(projectId, span.interactionId)
294
+ : null;
295
+ const parentSessionId = interactionManifest?.usageEvents.find((e) =>
296
+ Boolean(e.sessionId),
297
+ )?.sessionId;
298
+
292
299
  manifest.up = [
293
300
  {
294
301
  label: "Trace",
295
302
  links: [linkToTrace(span.traceId, "Parent trace")],
296
303
  },
297
304
  ];
305
+ if (parentSessionId) {
306
+ manifest.up.push({
307
+ label: "User session",
308
+ links: [linkToSession(parentSessionId)],
309
+ });
310
+ }
298
311
  manifest.across = [
299
312
  linksFromSpans(otherSpans, `Other spans in this trace`),
300
313
  linksFromLogs(traceManifest.logs, "Logs in this trace"),
@@ -339,11 +352,7 @@ export const connectedRoutesPlugin: CollectorPlugin = {
339
352
  "Metric exemplars in this trace",
340
353
  ),
341
354
  ];
342
- if (span.interactionId) {
343
- const interactionManifest = await index.byInteraction(
344
- projectId,
345
- span.interactionId,
346
- );
355
+ if (interactionManifest) {
347
356
  manifest.related = [
348
357
  linksFromUsage(
349
358
  interactionManifest.usageEvents,
@@ -1040,6 +1049,277 @@ export const connectedRoutesPlugin: CollectorPlugin = {
1040
1049
  manifest.rawManifest = actionManifest;
1041
1050
  }
1042
1051
  }
1052
+ } else if (kind === "change") {
1053
+ const db = sqlDbFor(c.env);
1054
+ const changeRow = await db
1055
+ .prepare(
1056
+ `SELECT * FROM change_events WHERE project_id = ? AND id = ? LIMIT 1`,
1057
+ )
1058
+ .bind(projectId, id)
1059
+ .first<{
1060
+ id: string;
1061
+ environment: string;
1062
+ kind: string;
1063
+ target_node_id: string;
1064
+ service_name: string | null;
1065
+ version: string | null;
1066
+ previous_version: string | null;
1067
+ commit_sha: string | null;
1068
+ summary: string;
1069
+ }>();
1070
+ if (changeRow) {
1071
+ manifest.up = [
1072
+ {
1073
+ label: "Target Ontology Node",
1074
+ links: [
1075
+ {
1076
+ label: `${changeRow.target_node_id} (${changeRow.environment})`,
1077
+ href: `#/health?node=${encodeURIComponent(changeRow.target_node_id)}`,
1078
+ relationshipCertainty: "exact",
1079
+ reason: "Target node mutated by this change event.",
1080
+ },
1081
+ ],
1082
+ },
1083
+ ];
1084
+
1085
+ const findings = await db
1086
+ .prepare(
1087
+ `SELECT id, title, causal_proof_tier, confidence, incident_id
1088
+ FROM investigation_findings
1089
+ WHERE project_id = ? AND environment = ? AND contributing_change_id = ?
1090
+ ORDER BY created_at DESC LIMIT 10`,
1091
+ )
1092
+ .bind(projectId, changeRow.environment, changeRow.id)
1093
+ .all<{
1094
+ id: string;
1095
+ title: string;
1096
+ causal_proof_tier: string;
1097
+ confidence: number;
1098
+ incident_id: string | null;
1099
+ }>();
1100
+
1101
+ manifest.down = [
1102
+ {
1103
+ label: "Attributed Findings",
1104
+ links: findings.results.map((f) => ({
1105
+ label: f.title,
1106
+ href: `#/findings/${encodeURIComponent(f.id)}`,
1107
+ entityKind: "finding",
1108
+ entityId: f.id,
1109
+ relationshipCertainty: "exact",
1110
+ causalProofTier:
1111
+ f.causal_proof_tier as import("@obsunified/types").CausalProofTier,
1112
+ confidence: f.confidence,
1113
+ reason: "Finding attributes this change event.",
1114
+ })),
1115
+ emptyReason:
1116
+ findings.results.length === 0
1117
+ ? "No investigation findings cite this change event."
1118
+ : undefined,
1119
+ },
1120
+ ];
1121
+
1122
+ const spans = changeRow.version
1123
+ ? await db
1124
+ .prepare(
1125
+ `SELECT DISTINCT trace_id, span_id, service_name, span_name
1126
+ FROM telemetry_spans
1127
+ WHERE project_id = ? AND environment = ? AND service_version = ? AND status_code = 2
1128
+ ORDER BY start_time DESC LIMIT 5`,
1129
+ )
1130
+ .bind(projectId, changeRow.environment, changeRow.version)
1131
+ .all<{
1132
+ trace_id: string;
1133
+ span_id: string;
1134
+ service_name: string;
1135
+ span_name: string;
1136
+ }>()
1137
+ : { results: [] };
1138
+
1139
+ manifest.across = [
1140
+ {
1141
+ label: "Failing Spans on Cohort Version",
1142
+ links: spans.results.map((s) => ({
1143
+ label: `${s.service_name} · ${s.span_name}`,
1144
+ href: `#/traces/${s.trace_id}#span=${s.span_id}`,
1145
+ entityKind: "span",
1146
+ entityId: `${s.trace_id}:${s.span_id}`,
1147
+ relationshipCertainty: "exact",
1148
+ reason: `Span executed on version ${changeRow.version}.`,
1149
+ })),
1150
+ emptyReason:
1151
+ spans.results.length === 0
1152
+ ? "No failing spans recorded on this change version."
1153
+ : undefined,
1154
+ },
1155
+ ];
1156
+ }
1157
+ } else if (kind === "finding") {
1158
+ const db = sqlDbFor(c.env);
1159
+ const findingRow = await db
1160
+ .prepare(
1161
+ `SELECT * FROM investigation_findings WHERE project_id = ? AND id = ? LIMIT 1`,
1162
+ )
1163
+ .bind(projectId, id)
1164
+ .first<{
1165
+ id: string;
1166
+ environment: string;
1167
+ incident_id: string | null;
1168
+ title: string;
1169
+ causal_proof_tier: string;
1170
+ confidence: number;
1171
+ symptom_entity_kind: string;
1172
+ symptom_entity_id: string;
1173
+ fault_locus_entity_kind: string;
1174
+ fault_locus_entity_id: string;
1175
+ contributing_change_id: string | null;
1176
+ contending_trace_id: string | null;
1177
+ agent_run_id: string | null;
1178
+ }>();
1179
+ if (findingRow) {
1180
+ const proofTier =
1181
+ findingRow.causal_proof_tier as import("@obsunified/types").CausalProofTier;
1182
+ manifest.up = [];
1183
+ if (findingRow.incident_id) {
1184
+ manifest.up.push({
1185
+ label: "Incident Investigation",
1186
+ links: [
1187
+ {
1188
+ label: `incident ${findingRow.incident_id}`,
1189
+ href: `#/incidents/${encodeURIComponent(findingRow.incident_id)}`,
1190
+ entityKind: "incident",
1191
+ entityId: findingRow.incident_id,
1192
+ relationshipCertainty: "exact",
1193
+ causalProofTier: proofTier,
1194
+ },
1195
+ ],
1196
+ });
1197
+ }
1198
+ if (findingRow.contributing_change_id) {
1199
+ manifest.up.push({
1200
+ label: "Contributing Change",
1201
+ links: [
1202
+ {
1203
+ label: `change ${findingRow.contributing_change_id}`,
1204
+ href: `#/changes/${encodeURIComponent(findingRow.contributing_change_id)}`,
1205
+ entityKind: "change",
1206
+ entityId: findingRow.contributing_change_id,
1207
+ relationshipCertainty: "exact",
1208
+ causalProofTier: proofTier,
1209
+ confidence: findingRow.confidence,
1210
+ },
1211
+ ],
1212
+ });
1213
+ }
1214
+ manifest.across = [
1215
+ {
1216
+ label: "Fault Locus & Symptom",
1217
+ links: [
1218
+ {
1219
+ label: `fault locus: ${findingRow.fault_locus_entity_id}`,
1220
+ href: `#/traces?q=${encodeURIComponent(findingRow.fault_locus_entity_id)}`,
1221
+ relationshipCertainty: "exact",
1222
+ causalProofTier: proofTier,
1223
+ },
1224
+ ...(findingRow.contending_trace_id
1225
+ ? [
1226
+ {
1227
+ label: `contending trace ${findingRow.contending_trace_id}`,
1228
+ href: `#/traces/${encodeURIComponent(findingRow.contending_trace_id)}`,
1229
+ entityKind: "trace" as const,
1230
+ entityId: findingRow.contending_trace_id,
1231
+ relationshipCertainty: "inferred" as const,
1232
+ causalProofTier: proofTier,
1233
+ },
1234
+ ]
1235
+ : []),
1236
+ ],
1237
+ },
1238
+ ];
1239
+ manifest.down = [
1240
+ {
1241
+ label: "Agent Lineage",
1242
+ links: [
1243
+ ...(findingRow.agent_run_id
1244
+ ? [
1245
+ {
1246
+ ...linkToAgentRun(findingRow.agent_run_id),
1247
+ relationshipCertainty: "exact" as const,
1248
+ causalProofTier: proofTier,
1249
+ },
1250
+ ]
1251
+ : []),
1252
+ ],
1253
+ emptyReason: !findingRow.agent_run_id
1254
+ ? "No agent run linked to this finding."
1255
+ : undefined,
1256
+ },
1257
+ ];
1258
+ }
1259
+ } else if (kind === "incident") {
1260
+ const db = sqlDbFor(c.env);
1261
+ const incRow = await db
1262
+ .prepare(
1263
+ `SELECT * FROM incident_investigations WHERE project_id = ? AND id = ? LIMIT 1`,
1264
+ )
1265
+ .bind(projectId, id)
1266
+ .first<{
1267
+ id: string;
1268
+ environment: string;
1269
+ target_node_id: string;
1270
+ symptom_anchor_kind: string;
1271
+ symptom_anchor_id: string;
1272
+ status: string;
1273
+ }>();
1274
+ if (incRow) {
1275
+ manifest.up = [
1276
+ {
1277
+ label: "Trigger Signal",
1278
+ links: [
1279
+ {
1280
+ label: `${incRow.symptom_anchor_kind}: ${incRow.symptom_anchor_id}`,
1281
+ href: `#/alerts?id=${encodeURIComponent(incRow.symptom_anchor_id)}`,
1282
+ entityKind:
1283
+ incRow.symptom_anchor_kind === "analysis"
1284
+ ? "analysis"
1285
+ : "alert",
1286
+ entityId: incRow.symptom_anchor_id,
1287
+ relationshipCertainty: "exact",
1288
+ },
1289
+ ],
1290
+ },
1291
+ ];
1292
+ const incFindings = await db
1293
+ .prepare(
1294
+ `SELECT id, title, causal_proof_tier FROM investigation_findings
1295
+ WHERE project_id = ? AND incident_id = ?
1296
+ ORDER BY created_at DESC LIMIT 5`,
1297
+ )
1298
+ .bind(projectId, incRow.id)
1299
+ .all<{
1300
+ id: string;
1301
+ title: string;
1302
+ causal_proof_tier: string;
1303
+ }>();
1304
+ manifest.across = [
1305
+ {
1306
+ label: "Findings",
1307
+ links: incFindings.results.map((f) => ({
1308
+ label: f.title,
1309
+ href: `#/findings/${encodeURIComponent(f.id)}`,
1310
+ entityKind: "finding" as const,
1311
+ entityId: f.id,
1312
+ relationshipCertainty: "exact" as const,
1313
+ causalProofTier:
1314
+ f.causal_proof_tier as import("@obsunified/types").CausalProofTier,
1315
+ })),
1316
+ emptyReason:
1317
+ incFindings.results.length > 0
1318
+ ? undefined
1319
+ : "No finding recorded for this incident yet.",
1320
+ },
1321
+ ];
1322
+ }
1043
1323
  } else if (kind === "alert" || kind === "analysis") {
1044
1324
  // Alerts and Analyses are topic-related, not identity-related.
1045
1325
  // Their connections come from the alert_rules.analysis_id and
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Dashboard auth used to guard `/dashboard/login` itself, and the SPA
3
+ * fallback redirected `/dashboard/*` to `/dashboard/` — which it also
4
+ * matched. A browser hitting `/` on a collector without an assets binding
5
+ * bounced forever. These tests follow the redirect chain by hand.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { createDashboardAuth } from "../auth/dashboard-auth";
10
+ import {
11
+ createTelemetryCollectorApp,
12
+ isDashboardLoginPath,
13
+ } from "../framework/collector";
14
+ import type { CollectorEnv } from "../framework/env";
15
+ import { MemSqlDb } from "../lib/test-utils/mem-sql-db";
16
+ import { dashboardRoutesPlugin } from "./dashboard-routes";
17
+
18
+ const HTML = { Accept: "text/html,application/xhtml+xml" };
19
+
20
+ const makeApp = (auth: Parameters<typeof createDashboardAuth>[0]) =>
21
+ createTelemetryCollectorApp({
22
+ plugins: [dashboardRoutesPlugin],
23
+ dashboardAuth: createDashboardAuth(auth),
24
+ });
25
+
26
+ const env = (extra: Record<string, unknown> = {}) =>
27
+ ({ DB: new MemSqlDb(), ...extra }) as unknown as CollectorEnv;
28
+
29
+ /** Follow same-origin redirects; returns every path visited plus the final response. */
30
+ const follow = async (
31
+ app: ReturnType<typeof makeApp>,
32
+ path: string,
33
+ headers: Record<string, string>,
34
+ maxHops = 6,
35
+ ) => {
36
+ const visited: string[] = [];
37
+ let current = path;
38
+ for (let hop = 0; hop < maxHops; hop += 1) {
39
+ visited.push(current);
40
+ const res = await app.request(current, { headers }, env());
41
+ const location = res.headers.get("Location");
42
+ if (res.status < 300 || res.status >= 400 || !location) {
43
+ return { visited, res };
44
+ }
45
+ current = location;
46
+ }
47
+ throw new Error(`redirect loop: ${visited.join(" -> ")}`);
48
+ };
49
+
50
+ describe("dashboard routes + auth", () => {
51
+ it("isDashboardLoginPath matches the login page and its sub-routes only", () => {
52
+ expect(isDashboardLoginPath("/dashboard/login")).toBe(true);
53
+ expect(isDashboardLoginPath("/dashboard/login/")).toBe(true);
54
+ expect(isDashboardLoginPath("/dashboard/login/forgot")).toBe(true);
55
+ expect(isDashboardLoginPath("/dashboard/loginx")).toBe(false);
56
+ expect(isDashboardLoginPath("/dashboard/")).toBe(false);
57
+ expect(isDashboardLoginPath("/internal/login")).toBe(false);
58
+ });
59
+
60
+ it("an unauthenticated browser at / reaches the login page without looping", async () => {
61
+ const app = makeApp({ password: "pw" });
62
+ const { visited, res } = await follow(app, "/", HTML);
63
+ expect(visited).toEqual(["/", "/dashboard/", "/dashboard/login"]);
64
+ // No assets binding: the login page cannot be served here, but the
65
+ // chain must end instead of bouncing back to /dashboard/.
66
+ expect(res.status).toBe(404);
67
+ expect(res.headers.get("Location")).toBeNull();
68
+ await expect(res.json()).resolves.toMatchObject({
69
+ error: expect.stringContaining("Dashboard assets are not configured"),
70
+ });
71
+ });
72
+
73
+ it("still redirects unauthenticated HTML requests for other dashboard pages to login", async () => {
74
+ const app = makeApp({ password: "pw" });
75
+ const res = await app.request(
76
+ "/dashboard/traces",
77
+ { headers: HTML },
78
+ env(),
79
+ );
80
+ expect(res.status).toBe(302);
81
+ expect(res.headers.get("Location")).toBe("/dashboard/login");
82
+ });
83
+
84
+ it("does not redirect /dashboard/login to itself", async () => {
85
+ const app = makeApp({ password: "pw" });
86
+ const res = await app.request("/dashboard/login", { headers: HTML }, env());
87
+ expect(res.status).toBe(404);
88
+ expect(res.headers.get("Location")).toBeNull();
89
+ });
90
+
91
+ it("an authenticated request without assets does not loop on /dashboard/", async () => {
92
+ const app = makeApp({ token: "tok" });
93
+ const headers = { ...HTML, Authorization: "Bearer tok" };
94
+ const { visited, res } = await follow(app, "/", headers);
95
+ expect(visited).toEqual(["/", "/dashboard/"]);
96
+ expect(res.status).toBe(404);
97
+ });
98
+
99
+ it("serves the SPA entrypoint from the assets binding for deep links", async () => {
100
+ const app = makeApp({ token: "tok" });
101
+ const fetched: string[] = [];
102
+ const assets = {
103
+ fetch: async (req: Request) => {
104
+ fetched.push(new URL(req.url).pathname);
105
+ return new Response("<html>spa</html>", {
106
+ status: 200,
107
+ headers: { "content-type": "text/html" },
108
+ });
109
+ },
110
+ };
111
+ const res = await app.request(
112
+ "/dashboard/traces/abc",
113
+ { headers: { ...HTML, Authorization: "Bearer tok" } },
114
+ env({ ASSETS: assets }),
115
+ );
116
+ expect(res.status).toBe(200);
117
+ expect(await res.text()).toBe("<html>spa</html>");
118
+ expect(fetched).toEqual(["/dashboard/"]);
119
+ });
120
+ });
@@ -29,8 +29,17 @@ export const dashboardRoutesPlugin: CollectorPlugin = {
29
29
  url.pathname = "/dashboard/";
30
30
  return assets.fetch(new Request(url.toString(), c.req.raw));
31
31
  }
32
- // Fallback: redirects to SPA root
33
- return c.redirect("/dashboard/");
32
+ // No assets binding: there is nothing to serve. Redirecting to
33
+ // `/dashboard/` here would match this same route and loop forever
34
+ // (and bounce unauthenticated browsers between `/dashboard/` and
35
+ // `/dashboard/login` endlessly).
36
+ return c.json(
37
+ {
38
+ error:
39
+ "Dashboard assets are not configured on this collector. Serve @obsunified/dashboard separately or bind [assets] in wrangler.toml.",
40
+ },
41
+ 404,
42
+ );
34
43
  });
35
44
 
36
45
  // Root redirect: visiting the collector root goes to dashboard
@@ -4,6 +4,7 @@ import {
4
4
  SESSION_ID_KEY,
5
5
  } from "@obsunified/types/constants";
6
6
  import type { CollectorPlugin } from "../framework/collector";
7
+ import { extractSpanLineageAndTopology } from "../lib/autosre-ontology";
7
8
  import { parseJsonRecord } from "../lib/json";
8
9
 
9
10
  export const defaultSpanEnrichmentPlugin: CollectorPlugin = {
@@ -77,7 +78,7 @@ export const defaultSpanEnrichmentPlugin: CollectorPlugin = {
77
78
  ? (resourceAttributes["telemetry.sdk.name"] as string)
78
79
  : null;
79
80
 
80
- return {
81
+ const candidate: StoredSpan = {
81
82
  ...span,
82
83
  serviceName,
83
84
  attributesJson: JSON.stringify(normalizedAttributes),
@@ -85,6 +86,17 @@ export const defaultSpanEnrichmentPlugin: CollectorPlugin = {
85
86
  interactionId,
86
87
  telemetrySdkName,
87
88
  };
89
+
90
+ const lineage = extractSpanLineageAndTopology(candidate);
91
+
92
+ return {
93
+ ...candidate,
94
+ environment: lineage.environment,
95
+ serviceVersion: lineage.serviceVersion,
96
+ commitSha: lineage.commitSha,
97
+ targetNodeId: lineage.targetNode?.id ?? null,
98
+ errorSignature: lineage.errorSignature,
99
+ };
88
100
  });
89
101
  },
90
102
  });