@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
|
@@ -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 (
|
|
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
|
-
//
|
|
33
|
-
|
|
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
|
-
|
|
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
|
});
|