@obsunified/collector 2.0.1

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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/dist/auth.d.ts +2 -0
  4. package/dist/auth.js +9 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/chunk-UGWDOGGH.js +319 -0
  7. package/dist/chunk-UGWDOGGH.js.map +1 -0
  8. package/dist/chunk-XKQTVIRE.js +671 -0
  9. package/dist/chunk-XKQTVIRE.js.map +1 -0
  10. package/dist/index.d.ts +1197 -0
  11. package/dist/index.js +17707 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/ingest-auth-HFVO1zIp.d.ts +165 -0
  14. package/dist/openai-FJYOQ7WO.js +384 -0
  15. package/dist/openai-FJYOQ7WO.js.map +1 -0
  16. package/package.json +73 -0
  17. package/src/ai-parity/ai-parity.acceptance.test.ts +449 -0
  18. package/src/analyses/derive.ts +642 -0
  19. package/src/analyses/index.ts +50 -0
  20. package/src/analyses/investigations.ts +277 -0
  21. package/src/analyses/tier0.ts +568 -0
  22. package/src/auth/dashboard-auth.ts +269 -0
  23. package/src/auth/ingest-auth.ts +147 -0
  24. package/src/durable-objects/tail-hub.test.ts +32 -0
  25. package/src/durable-objects/tail-hub.ts +158 -0
  26. package/src/framework/collector.ts +467 -0
  27. package/src/framework/env.ts +90 -0
  28. package/src/framework/logger.ts +81 -0
  29. package/src/index.ts +211 -0
  30. package/src/lib/action-aggregates.ts +1031 -0
  31. package/src/lib/action-identity.ts +106 -0
  32. package/src/lib/actions-store.ts +397 -0
  33. package/src/lib/ai-pricing.ts +80 -0
  34. package/src/lib/ai-store/calls.ts +148 -0
  35. package/src/lib/ai-store/evaluations.test.ts +47 -0
  36. package/src/lib/ai-store/evaluations.ts +125 -0
  37. package/src/lib/ai-store/helpers.ts +37 -0
  38. package/src/lib/ai-store/retention.ts +17 -0
  39. package/src/lib/ai-store/sessions.ts +277 -0
  40. package/src/lib/ai-store/spans.ts +109 -0
  41. package/src/lib/ai-store/types.ts +112 -0
  42. package/src/lib/ai-store.ts +76 -0
  43. package/src/lib/alerts-store.ts +458 -0
  44. package/src/lib/analyses-runner.ts +439 -0
  45. package/src/lib/analyses-store.test.ts +243 -0
  46. package/src/lib/analyses-store.ts +454 -0
  47. package/src/lib/ask.test.ts +302 -0
  48. package/src/lib/ask.ts +390 -0
  49. package/src/lib/blob-store-s3.ts +213 -0
  50. package/src/lib/blob-store.ts +366 -0
  51. package/src/lib/eval-cases-store.test.ts +171 -0
  52. package/src/lib/eval-cases-store.ts +1033 -0
  53. package/src/lib/evidence-references.test.ts +10 -0
  54. package/src/lib/evidence-references.ts +684 -0
  55. package/src/lib/hash.ts +26 -0
  56. package/src/lib/identity-index/action-lookups.ts +805 -0
  57. package/src/lib/identity-index/constants.ts +1 -0
  58. package/src/lib/identity-index/key-lookups.ts +199 -0
  59. package/src/lib/identity-index/mappers.ts +349 -0
  60. package/src/lib/identity-index/types.ts +196 -0
  61. package/src/lib/identity-index/user-lookup.ts +137 -0
  62. package/src/lib/identity-index.test.ts +624 -0
  63. package/src/lib/identity-index.ts +85 -0
  64. package/src/lib/json.ts +39 -0
  65. package/src/lib/llm.ts +263 -0
  66. package/src/lib/logs-store.ts +188 -0
  67. package/src/lib/metrics-store.test.ts +189 -0
  68. package/src/lib/metrics-store.ts +312 -0
  69. package/src/lib/narrate-gate.test.ts +260 -0
  70. package/src/lib/narrate-gate.ts +253 -0
  71. package/src/lib/openai.test.ts +300 -0
  72. package/src/lib/openai.ts +512 -0
  73. package/src/lib/otlp.ts +212 -0
  74. package/src/lib/parse-pprof.test.ts +64 -0
  75. package/src/lib/parse-pprof.ts +184 -0
  76. package/src/lib/payload-capture.ts +22 -0
  77. package/src/lib/projects-store.ts +207 -0
  78. package/src/lib/propagation-metric.test.ts +196 -0
  79. package/src/lib/propagation-metric.ts +175 -0
  80. package/src/lib/sql-db-dialect.test.ts +22 -0
  81. package/src/lib/sql-db-postgres.ts +229 -0
  82. package/src/lib/sql-db.ts +166 -0
  83. package/src/lib/stage6.test.ts +216 -0
  84. package/src/lib/store/export.ts +65 -0
  85. package/src/lib/store/helpers.ts +367 -0
  86. package/src/lib/store/ingest.ts +68 -0
  87. package/src/lib/store/issues.ts +158 -0
  88. package/src/lib/store/overview.ts +127 -0
  89. package/src/lib/store/retention.ts +10 -0
  90. package/src/lib/store/service-map.ts +384 -0
  91. package/src/lib/store/trace-detail.bench.ts +76 -0
  92. package/src/lib/store/trace-detail.test.ts +410 -0
  93. package/src/lib/store/trace-detail.ts +427 -0
  94. package/src/lib/store/trace-queries.test.ts +29 -0
  95. package/src/lib/store/trace-queries.ts +89 -0
  96. package/src/lib/store-overview.test.ts +77 -0
  97. package/src/lib/store.ts +96 -0
  98. package/src/lib/tail-publisher.ts +64 -0
  99. package/src/lib/test-utils/mem-sql-db.test.ts +93 -0
  100. package/src/lib/test-utils/mem-sql-db.ts +86 -0
  101. package/src/lib/ua-parser.ts +61 -0
  102. package/src/lib/usage-store.ts +567 -0
  103. package/src/migrations/001_telemetry_spans.sql +28 -0
  104. package/src/migrations/002_usage_events.sql +24 -0
  105. package/src/migrations/003_usage_analytics_columns.sql +13 -0
  106. package/src/migrations/004_logs.sql +19 -0
  107. package/src/migrations/005_ai_calls.sql +25 -0
  108. package/src/migrations/006_user_profiles.sql +11 -0
  109. package/src/migrations/007_session_replay_metadata.sql +10 -0
  110. package/src/migrations/008_session_replay_storage_bytes.sql +1 -0
  111. package/src/migrations/009_projects_and_keys.sql +22 -0
  112. package/src/migrations/010_signal_project_id.sql +15 -0
  113. package/src/migrations/011_default_project_seed.sql +3 -0
  114. package/src/migrations/012_alert_rules.sql +17 -0
  115. package/src/migrations/013_alert_evaluations.sql +13 -0
  116. package/src/migrations/014_alert_state.sql +9 -0
  117. package/src/migrations/015_metrics.sql +58 -0
  118. package/src/migrations/016_metrics_exp_histogram_summary.sql +5 -0
  119. package/src/migrations/017_span_dropped_counts.sql +11 -0
  120. package/src/migrations/018_log_flags.sql +8 -0
  121. package/src/migrations/019_ai_span_payloads.sql +30 -0
  122. package/src/migrations/020_ai_span_evaluations.sql +33 -0
  123. package/src/migrations/021_ai_span_session.sql +13 -0
  124. package/src/migrations/022_span_log_session_id.sql +13 -0
  125. package/src/migrations/023_service_map_indexes.sql +32 -0
  126. package/src/migrations/024_analysis_results.sql +49 -0
  127. package/src/migrations/025_stage6_autopinning_alerts.sql +38 -0
  128. package/src/migrations/026_obs_dashboard_project_seed.sql +18 -0
  129. package/src/migrations/027_identity_propagation.sql +50 -0
  130. package/src/migrations/028_profile_blobs.sql +57 -0
  131. package/src/migrations/029_telemetry_sdk_name.sql +20 -0
  132. package/src/migrations/030_analysis_claim.sql +18 -0
  133. package/src/migrations/031_agent_action_graph.sql +154 -0
  134. package/src/migrations/032_payload_capture_flags.sql +8 -0
  135. package/src/migrations/033_eval_cases.sql +52 -0
  136. package/src/migrations/034_eval_case_results.sql +23 -0
  137. package/src/migrations/035_metric_exemplars.sql +33 -0
  138. package/src/migrations/036_trace_instrumentation_gaps.sql +19 -0
  139. package/src/migrations/037_eval_runs.sql +37 -0
  140. package/src/migrations/038_tool_call_evidence.sql +11 -0
  141. package/src/migrations/039_drop_trace_instrumentation_gaps.sql +11 -0
  142. package/src/migrations/040_evidence_retrieval_refs.sql +50 -0
  143. package/src/migrations-postgres/001_telemetry_spans.sql +33 -0
  144. package/src/migrations-postgres/002_usage_events.sql +24 -0
  145. package/src/migrations-postgres/003_usage_analytics_columns.sql +13 -0
  146. package/src/migrations-postgres/004_logs.sql +19 -0
  147. package/src/migrations-postgres/005_ai_calls.sql +25 -0
  148. package/src/migrations-postgres/006_user_profiles.sql +11 -0
  149. package/src/migrations-postgres/007_session_replay_metadata.sql +10 -0
  150. package/src/migrations-postgres/008_session_replay_storage_bytes.sql +2 -0
  151. package/src/migrations-postgres/009_projects_and_keys.sql +25 -0
  152. package/src/migrations-postgres/010_signal_project_id.sql +18 -0
  153. package/src/migrations-postgres/011_default_project_seed.sql +5 -0
  154. package/src/migrations-postgres/012_alert_rules.sql +17 -0
  155. package/src/migrations-postgres/013_alert_evaluations.sql +13 -0
  156. package/src/migrations-postgres/014_alert_state.sql +9 -0
  157. package/src/migrations-postgres/015_metrics.sql +47 -0
  158. package/src/migrations-postgres/016_metrics_exp_histogram_summary.sql +1 -0
  159. package/src/migrations-postgres/017_span_dropped_counts.sql +4 -0
  160. package/src/migrations-postgres/018_log_flags.sql +2 -0
  161. package/src/migrations-postgres/019_ai_span_payloads.sql +18 -0
  162. package/src/migrations-postgres/020_ai_span_evaluations.sql +21 -0
  163. package/src/migrations-postgres/021_ai_span_session.sql +7 -0
  164. package/src/migrations-postgres/022_span_log_session_id.sql +7 -0
  165. package/src/migrations-postgres/023_service_map_indexes.sql +9 -0
  166. package/src/migrations-postgres/024_analysis_results.sql +41 -0
  167. package/src/migrations-postgres/025_stage6_autopinning_alerts.sql +17 -0
  168. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +14 -0
  169. package/src/migrations-postgres/027_identity_propagation.sql +27 -0
  170. package/src/migrations-postgres/028_profile_blobs.sql +48 -0
  171. package/src/migrations-postgres/029_telemetry_sdk_name.sql +5 -0
  172. package/src/migrations-postgres/030_analysis_claim.sql +5 -0
  173. package/src/migrations-postgres/031_agent_action_graph.sql +190 -0
  174. package/src/migrations-postgres/032_payload_capture_flags.sql +8 -0
  175. package/src/migrations-postgres/033_eval_cases.sql +55 -0
  176. package/src/migrations-postgres/034_eval_case_results.sql +26 -0
  177. package/src/migrations-postgres/035_metric_exemplars.sql +31 -0
  178. package/src/migrations-postgres/036_trace_instrumentation_gaps.sql +19 -0
  179. package/src/migrations-postgres/037_eval_runs.sql +40 -0
  180. package/src/migrations-postgres/038_tool_call_evidence.sql +11 -0
  181. package/src/migrations-postgres/039_drop_trace_instrumentation_gaps.sql +11 -0
  182. package/src/migrations-postgres/040_evidence_retrieval_refs.sql +47 -0
  183. package/src/migrations-postgres/README.md +40 -0
  184. package/src/otlp/acceptance.test.ts +373 -0
  185. package/src/otlp/decode/body.ts +127 -0
  186. package/src/otlp/decode/logs.ts +96 -0
  187. package/src/otlp/decode/metrics.ts +402 -0
  188. package/src/otlp/decode/traces.ts +92 -0
  189. package/src/otlp/decode/values.ts +150 -0
  190. package/src/otlp/decode.test.ts +800 -0
  191. package/src/otlp/decode.ts +11 -0
  192. package/src/otlp/gen/opentelemetry/proto/collector/logs/v1/logs_service_pb.ts +132 -0
  193. package/src/otlp/gen/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.ts +158 -0
  194. package/src/otlp/gen/opentelemetry/proto/collector/trace/v1/trace_service_pb.ts +158 -0
  195. package/src/otlp/gen/opentelemetry/proto/common/v1/common_pb.ts +289 -0
  196. package/src/otlp/gen/opentelemetry/proto/logs/v1/logs_pb.ts +397 -0
  197. package/src/otlp/gen/opentelemetry/proto/metrics/v1/metrics_pb.ts +1329 -0
  198. package/src/otlp/gen/opentelemetry/proto/resource/v1/resource_pb.ts +86 -0
  199. package/src/otlp/gen/opentelemetry/proto/trace/v1/trace_pb.ts +710 -0
  200. package/src/otlp/response.test.ts +158 -0
  201. package/src/otlp/response.ts +129 -0
  202. package/src/plugins/_context.ts +15 -0
  203. package/src/plugins/action-graph-processor/enrichers.ts +45 -0
  204. package/src/plugins/action-graph-processor/redaction.ts +84 -0
  205. package/src/plugins/action-graph-processor.test.ts +1023 -0
  206. package/src/plugins/action-graph-processor.ts +1172 -0
  207. package/src/plugins/action-routes.test.ts +716 -0
  208. package/src/plugins/action-routes.ts +343 -0
  209. package/src/plugins/ai-receiver.ts +254 -0
  210. package/src/plugins/ai-span-payloads-processor.test.ts +90 -0
  211. package/src/plugins/ai-span-payloads-processor.ts +180 -0
  212. package/src/plugins/alerts-evaluator.test.ts +91 -0
  213. package/src/plugins/alerts-evaluator.ts +328 -0
  214. package/src/plugins/alerts-routes.ts +101 -0
  215. package/src/plugins/analyses-routes.ts +138 -0
  216. package/src/plugins/ask-routes.ts +182 -0
  217. package/src/plugins/bot-filter.ts +19 -0
  218. package/src/plugins/connected-routes/manifest.ts +531 -0
  219. package/src/plugins/connected-routes.test.ts +1791 -0
  220. package/src/plugins/connected-routes.ts +1103 -0
  221. package/src/plugins/dashboard-routes.ts +41 -0
  222. package/src/plugins/default-span-enrichment.ts +92 -0
  223. package/src/plugins/eval-cases-routes.test.ts +391 -0
  224. package/src/plugins/eval-cases-routes.ts +529 -0
  225. package/src/plugins/evidence-retrieval-routes.benchmark.test.ts +245 -0
  226. package/src/plugins/evidence-retrieval-routes.test.ts +932 -0
  227. package/src/plugins/evidence-retrieval-routes.ts +2179 -0
  228. package/src/plugins/gen-ai-normalizer.test.ts +680 -0
  229. package/src/plugins/gen-ai-normalizer.ts +302 -0
  230. package/src/plugins/identity-receiver.ts +96 -0
  231. package/src/plugins/issue-enrichment.ts +132 -0
  232. package/src/plugins/issue-insights.ts +81 -0
  233. package/src/plugins/logs-receiver.ts +160 -0
  234. package/src/plugins/metrics-receiver.ts +91 -0
  235. package/src/plugins/onboarding-routes.ts +54 -0
  236. package/src/plugins/otlp-receiver.ts +124 -0
  237. package/src/plugins/platform-routes.ts +165 -0
  238. package/src/plugins/profile-routes.ts +468 -0
  239. package/src/plugins/projects-routes.ts +92 -0
  240. package/src/plugins/query-routes.ts +220 -0
  241. package/src/plugins/redaction-processor.ts +132 -0
  242. package/src/plugins/replay-query-routes.test.ts +38 -0
  243. package/src/plugins/replay-query-routes.ts +199 -0
  244. package/src/plugins/replay-receiver.ts +81 -0
  245. package/src/plugins/tail-routes.test.ts +47 -0
  246. package/src/plugins/tail-routes.ts +42 -0
  247. package/src/plugins/timeline-routes.ts +244 -0
  248. package/src/plugins/ua-enrichment.ts +27 -0
  249. package/src/plugins/usage-normalization.ts +46 -0
  250. package/src/plugins/usage-privacy.ts +55 -0
  251. package/src/plugins/usage-query-routes.ts +102 -0
  252. package/src/plugins/usage-receiver.ts +129 -0
  253. package/src/plugins/users-query-routes.ts +78 -0
  254. package/src/plugins/utm-enrichment.ts +36 -0
@@ -0,0 +1,642 @@
1
+ // RFC 0002 Stage 1 — Tier 1 derivation engine.
2
+ //
3
+ // `deriveAnalysesForProject` queries the project's recent telemetry to infer
4
+ // its `TelemetryShape` (services, cross-service edges, messaging topics,
5
+ // presence of LLM spans) and emits one or more AnalysisDefinitions per
6
+ // detected dimension. The shape query is cached in module-level memory for
7
+ // 5 minutes per project — the derivation cron runs every ~5min so this
8
+ // avoids re-hitting D1 on the dashboard request path.
9
+ //
10
+ // All emitted SQL is executable as-is by the runner; the project id and
11
+ // the relevant scope (service / edge / topic) are baked in at derivation
12
+ // time. Identifiers in analysis ids are slugged (slashes, colons, spaces
13
+ // → underscores) so they're URL-safe.
14
+
15
+ import type { AnalysisDefinition } from "@obsunified/types";
16
+
17
+ interface TelemetryShape {
18
+ services: string[];
19
+ edges: Array<{ source: string; target: string }>;
20
+ messagingTopics: string[];
21
+ hasLlmSpans: boolean;
22
+ }
23
+
24
+ interface CachedShape {
25
+ shape: TelemetryShape;
26
+ expiresAt: number;
27
+ }
28
+
29
+ const SHAPE_CACHE_TTL_MS = 5 * 60 * 1000;
30
+ const SHAPE_CACHE_MAX_ENTRIES = 200;
31
+ const shapeCache: Map<string, CachedShape> = new Map();
32
+
33
+ const slug = (value: string): string =>
34
+ value.replace(/[/:\s]+/g, "_").replace(/[^A-Za-z0-9_.-]/g, "_");
35
+
36
+ const sqlEscape = (value: string): string => value.replace(/'/g, "''");
37
+
38
+ interface D1Result<T = Record<string, unknown>> {
39
+ results: T[];
40
+ }
41
+
42
+ interface D1PreparedStatement {
43
+ bind(...values: unknown[]): D1PreparedStatement;
44
+ all<T = Record<string, unknown>>(): Promise<D1Result<T>>;
45
+ }
46
+
47
+ interface D1Database {
48
+ prepare(query: string): D1PreparedStatement;
49
+ }
50
+
51
+ const fetchShape = async (
52
+ projectId: string,
53
+ db: D1Database,
54
+ ): Promise<TelemetryShape> => {
55
+ // Distinct services in the last hour.
56
+ const servicesRes = await db
57
+ .prepare(
58
+ `SELECT DISTINCT service_name
59
+ FROM telemetry_spans
60
+ WHERE project_id = ?
61
+ AND service_name IS NOT NULL
62
+ AND received_at >= datetime('now', '-1 hour')`,
63
+ )
64
+ .bind(projectId)
65
+ .all<{ service_name: string }>();
66
+
67
+ const services = servicesRes.results
68
+ .map((row) => row.service_name)
69
+ .filter(
70
+ (name): name is string => typeof name === "string" && name.length > 0,
71
+ );
72
+
73
+ // Cross-service edges: parent span's service != child span's service,
74
+ // within the same trace, in the last hour. We only keep distinct
75
+ // (parent_service, child_service) pairs.
76
+ const edgesRes = await db
77
+ .prepare(
78
+ `SELECT DISTINCT parent.service_name AS source_service,
79
+ child.service_name AS target_service
80
+ FROM telemetry_spans child
81
+ JOIN telemetry_spans parent
82
+ ON parent.project_id = child.project_id
83
+ AND parent.trace_id = child.trace_id
84
+ AND parent.span_id = child.parent_span_id
85
+ WHERE child.project_id = ?
86
+ AND child.received_at >= datetime('now', '-1 hour')
87
+ AND parent.service_name IS NOT NULL
88
+ AND child.service_name IS NOT NULL
89
+ AND parent.service_name <> child.service_name`,
90
+ )
91
+ .bind(projectId)
92
+ .all<{ source_service: string; target_service: string }>();
93
+
94
+ const edges = edgesRes.results
95
+ .filter((row) => row.source_service && row.target_service)
96
+ .map((row) => ({ source: row.source_service, target: row.target_service }));
97
+
98
+ // Messaging topics: spans that carry a messaging.destination attribute.
99
+ const topicsRes = await db
100
+ .prepare(
101
+ `SELECT DISTINCT json_extract(attributes_json, '$."messaging.destination"') AS topic
102
+ FROM telemetry_spans
103
+ WHERE project_id = ?
104
+ AND received_at >= datetime('now', '-1 hour')
105
+ AND json_extract(attributes_json, '$."messaging.destination"') IS NOT NULL`,
106
+ )
107
+ .bind(projectId)
108
+ .all<{ topic: string }>();
109
+
110
+ const messagingTopics = topicsRes.results
111
+ .map((row) => row.topic)
112
+ .filter(
113
+ (topic): topic is string => typeof topic === "string" && topic.length > 0,
114
+ );
115
+
116
+ // Are there any LLM-kind spans? OpenInference uses
117
+ // `openinference.span.kind` = 'LLM'. We also accept the more general
118
+ // `gen_ai.system` attribute as a heuristic.
119
+ const llmRes = await db
120
+ .prepare(
121
+ `SELECT 1
122
+ FROM telemetry_spans
123
+ WHERE project_id = ?
124
+ AND received_at >= datetime('now', '-1 hour')
125
+ AND (
126
+ json_extract(attributes_json, '$."openinference.span.kind"') = 'LLM'
127
+ OR json_extract(attributes_json, '$."gen_ai.system"') IS NOT NULL
128
+ )
129
+ LIMIT 1`,
130
+ )
131
+ .bind(projectId)
132
+ .all<{ "1": number }>();
133
+
134
+ return {
135
+ services,
136
+ edges,
137
+ messagingTopics,
138
+ hasLlmSpans: llmRes.results.length > 0,
139
+ };
140
+ };
141
+
142
+ const getShape = async (
143
+ projectId: string,
144
+ db: D1Database,
145
+ ): Promise<TelemetryShape> => {
146
+ const cached = shapeCache.get(projectId);
147
+ const nowMs = Date.now();
148
+ if (cached && cached.expiresAt > nowMs) return cached.shape;
149
+
150
+ const shape = await fetchShape(projectId, db);
151
+ pruneShapeCache(nowMs);
152
+ shapeCache.set(projectId, {
153
+ shape,
154
+ expiresAt: nowMs + SHAPE_CACHE_TTL_MS,
155
+ });
156
+ return shape;
157
+ };
158
+
159
+ function pruneShapeCache(nowMs: number): void {
160
+ for (const [key, entry] of shapeCache) {
161
+ if (entry.expiresAt <= nowMs) shapeCache.delete(key);
162
+ }
163
+ while (shapeCache.size >= SHAPE_CACHE_MAX_ENTRIES) {
164
+ const oldestKey = shapeCache.keys().next().value;
165
+ if (!oldestKey) break;
166
+ shapeCache.delete(oldestKey);
167
+ }
168
+ }
169
+
170
+ // ── Builders ────────────────────────────────────────────────────────────────
171
+
172
+ const makeServiceErrorRate = (
173
+ projectId: string,
174
+ service: string,
175
+ ): AnalysisDefinition => ({
176
+ id: `service_error_rate::${slug(service)}`,
177
+ title: `${service} — error rate`,
178
+ group: "Services",
179
+ source: "tier1",
180
+ view: "tile",
181
+ refreshSeconds: 60,
182
+ scope: { service },
183
+ narrate: {
184
+ prompt: `${service} error rate moved to {{primary}} (was {{baseline}}, {{delta_pct}}). If the payload names the dominant span/route, call it out. Time anchor.`,
185
+ only_when: "status_changed || delta_pct>25",
186
+ },
187
+ // Last 5 minutes vs trailing 1h baseline (excluding the current 5m).
188
+ // Status thresholds match overall_error_rate: critical>5%, warn>1%.
189
+ sql: `
190
+ WITH now_window AS (
191
+ SELECT
192
+ COUNT(*) AS total,
193
+ SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
194
+ FROM telemetry_spans
195
+ WHERE project_id = '${sqlEscape(projectId)}'
196
+ AND service_name = '${sqlEscape(service)}'
197
+ AND received_at >= datetime('now', '-5 minutes')
198
+ ),
199
+ base_window AS (
200
+ SELECT
201
+ COUNT(*) AS total,
202
+ SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
203
+ FROM telemetry_spans
204
+ WHERE project_id = '${sqlEscape(projectId)}'
205
+ AND service_name = '${sqlEscape(service)}'
206
+ AND received_at >= datetime('now', '-1 hour')
207
+ AND received_at < datetime('now', '-5 minutes')
208
+ ),
209
+ spark AS (
210
+ SELECT
211
+ strftime('%Y-%m-%dT%H:%M:00Z', received_at) AS minute,
212
+ COUNT(*) AS total,
213
+ SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
214
+ FROM telemetry_spans
215
+ WHERE project_id = '${sqlEscape(projectId)}'
216
+ AND service_name = '${sqlEscape(service)}'
217
+ AND received_at >= datetime('now', '-30 minutes')
218
+ GROUP BY minute
219
+ ORDER BY minute ASC
220
+ )
221
+ SELECT
222
+ CASE
223
+ WHEN (SELECT total FROM now_window) = 0 THEN 'unknown'
224
+ WHEN (CAST((SELECT errors FROM now_window) AS REAL)
225
+ / (SELECT total FROM now_window)) > 0.05 THEN 'critical'
226
+ WHEN (CAST((SELECT errors FROM now_window) AS REAL)
227
+ / (SELECT total FROM now_window)) > 0.01 THEN 'warn'
228
+ ELSE 'ok'
229
+ END AS status,
230
+ CASE WHEN (SELECT total FROM now_window) = 0 THEN NULL
231
+ ELSE CAST((SELECT errors FROM now_window) AS REAL)
232
+ / (SELECT total FROM now_window) END AS primary_value,
233
+ CASE WHEN (SELECT total FROM base_window) = 0 THEN NULL
234
+ ELSE CAST((SELECT errors FROM base_window) AS REAL)
235
+ / (SELECT total FROM base_window) END AS baseline_value,
236
+ (SELECT json_object(
237
+ 'service', '${sqlEscape(service)}',
238
+ 'window', '5m',
239
+ 'baselineWindow', '1h',
240
+ 'currentErrors', (SELECT errors FROM now_window),
241
+ 'currentTotal', (SELECT total FROM now_window),
242
+ 'baselineErrors',(SELECT errors FROM base_window),
243
+ 'baselineTotal', (SELECT total FROM base_window),
244
+ 'sparkline', (
245
+ SELECT json_group_array(json_object(
246
+ 'minute', minute,
247
+ 'total', total,
248
+ 'errors', errors,
249
+ 'rate', CASE WHEN total = 0 THEN 0
250
+ ELSE CAST(errors AS REAL) / total END
251
+ )) FROM spark
252
+ )
253
+ )) AS payload
254
+ `,
255
+ });
256
+
257
+ const makeServiceLatencyP95 = (
258
+ projectId: string,
259
+ service: string,
260
+ ): AnalysisDefinition => ({
261
+ id: `service_latency_p95::${slug(service)}`,
262
+ title: `${service} — p95 latency`,
263
+ group: "Services",
264
+ source: "tier1",
265
+ view: "tile",
266
+ refreshSeconds: 60,
267
+ scope: { service },
268
+ narrate: {
269
+ prompt: `${service} p95 moved to {{primary}}ms from {{baseline}}ms ({{delta_pct}}). Name the span pattern dominating the tail if visible. Time anchor.`,
270
+ only_when: "status_changed || delta_pct>30",
271
+ },
272
+ // p95 of duration_ms over the last hour vs. same hour yesterday.
273
+ // Approximate via ROW_NUMBER (no native percentile in SQLite).
274
+ // Status by delta_pct: critical>50%, warn>20%, ok else.
275
+ sql: `
276
+ WITH cur AS (
277
+ SELECT duration_ms,
278
+ ROW_NUMBER() OVER (ORDER BY duration_ms ASC) AS rn,
279
+ COUNT(*) OVER () AS n
280
+ FROM telemetry_spans
281
+ WHERE project_id = '${sqlEscape(projectId)}'
282
+ AND service_name = '${sqlEscape(service)}'
283
+ AND received_at >= datetime('now', '-1 hour')
284
+ ),
285
+ base AS (
286
+ SELECT duration_ms,
287
+ ROW_NUMBER() OVER (ORDER BY duration_ms ASC) AS rn,
288
+ COUNT(*) OVER () AS n
289
+ FROM telemetry_spans
290
+ WHERE project_id = '${sqlEscape(projectId)}'
291
+ AND service_name = '${sqlEscape(service)}'
292
+ AND received_at >= datetime('now', '-25 hours')
293
+ AND received_at < datetime('now', '-24 hours')
294
+ ),
295
+ cur_p95 AS (
296
+ SELECT duration_ms FROM cur
297
+ WHERE rn = MAX(1, (95 * n + 99) / 100)
298
+ LIMIT 1
299
+ ),
300
+ base_p95 AS (
301
+ SELECT duration_ms FROM base
302
+ WHERE rn = MAX(1, (95 * n + 99) / 100)
303
+ LIMIT 1
304
+ ),
305
+ cur_val AS (SELECT (SELECT duration_ms FROM cur_p95) AS v),
306
+ base_val AS (SELECT (SELECT duration_ms FROM base_p95) AS v),
307
+ delta AS (
308
+ SELECT CASE
309
+ WHEN (SELECT v FROM cur_val) IS NULL THEN NULL
310
+ WHEN (SELECT v FROM base_val) IS NULL OR (SELECT v FROM base_val) = 0 THEN NULL
311
+ ELSE ((SELECT v FROM cur_val) - (SELECT v FROM base_val))
312
+ * 100.0 / (SELECT v FROM base_val)
313
+ END AS pct
314
+ )
315
+ SELECT
316
+ CASE
317
+ WHEN (SELECT v FROM cur_val) IS NULL THEN 'unknown'
318
+ WHEN (SELECT pct FROM delta) IS NULL THEN 'ok'
319
+ WHEN ABS((SELECT pct FROM delta)) > 50 THEN 'critical'
320
+ WHEN ABS((SELECT pct FROM delta)) > 20 THEN 'warn'
321
+ ELSE 'ok'
322
+ END AS status,
323
+ (SELECT v FROM cur_val) AS primary_value,
324
+ (SELECT v FROM base_val) AS baseline_value,
325
+ (SELECT json_object(
326
+ 'service', '${sqlEscape(service)}',
327
+ 'window', '1h',
328
+ 'baselineWindow', 'same hour yesterday',
329
+ 'unit', 'ms'
330
+ )) AS payload
331
+ `,
332
+ });
333
+
334
+ const makeDependencyHealth = (
335
+ projectId: string,
336
+ source: string,
337
+ target: string,
338
+ ): AnalysisDefinition => ({
339
+ id: `dependency_health::${slug(source)}->${slug(target)}`,
340
+ title: `${source} → ${target}`,
341
+ group: "Dependencies",
342
+ source: "tier1",
343
+ view: "tile",
344
+ // Dependency edges change shape slowly; sampling every 5 min keeps the
345
+ // per-minute analyses tick cheap on installs with many edges.
346
+ refreshSeconds: 300,
347
+ scope: { source, target },
348
+ // Error rate on the child-side spans of the edge over the last 5 minutes
349
+ // vs trailing 1h baseline. We identify edges by joining child↔parent on
350
+ // trace_id+parent_span_id where parent.service_name = source and
351
+ // child.service_name = target.
352
+ sql: `
353
+ WITH edge_now AS (
354
+ SELECT child.status_code AS sc
355
+ FROM telemetry_spans child
356
+ JOIN telemetry_spans parent
357
+ ON parent.project_id = child.project_id
358
+ AND parent.trace_id = child.trace_id
359
+ AND parent.span_id = child.parent_span_id
360
+ WHERE child.project_id = '${sqlEscape(projectId)}'
361
+ AND parent.service_name = '${sqlEscape(source)}'
362
+ AND child.service_name = '${sqlEscape(target)}'
363
+ AND child.received_at >= datetime('now', '-5 minutes')
364
+ ),
365
+ edge_base AS (
366
+ SELECT child.status_code AS sc
367
+ FROM telemetry_spans child
368
+ JOIN telemetry_spans parent
369
+ ON parent.project_id = child.project_id
370
+ AND parent.trace_id = child.trace_id
371
+ AND parent.span_id = child.parent_span_id
372
+ WHERE child.project_id = '${sqlEscape(projectId)}'
373
+ AND parent.service_name = '${sqlEscape(source)}'
374
+ AND child.service_name = '${sqlEscape(target)}'
375
+ AND child.received_at >= datetime('now', '-1 hour')
376
+ AND child.received_at < datetime('now', '-5 minutes')
377
+ ),
378
+ now_agg AS (
379
+ SELECT COUNT(*) AS total,
380
+ SUM(CASE WHEN sc = 2 THEN 1 ELSE 0 END) AS errors
381
+ FROM edge_now
382
+ ),
383
+ base_agg AS (
384
+ SELECT COUNT(*) AS total,
385
+ SUM(CASE WHEN sc = 2 THEN 1 ELSE 0 END) AS errors
386
+ FROM edge_base
387
+ )
388
+ SELECT
389
+ CASE
390
+ WHEN (SELECT total FROM now_agg) = 0 THEN 'unknown'
391
+ WHEN (CAST((SELECT errors FROM now_agg) AS REAL)
392
+ / (SELECT total FROM now_agg)) > 0.05 THEN 'critical'
393
+ WHEN (CAST((SELECT errors FROM now_agg) AS REAL)
394
+ / (SELECT total FROM now_agg)) > 0.01 THEN 'warn'
395
+ ELSE 'ok'
396
+ END AS status,
397
+ CASE WHEN (SELECT total FROM now_agg) = 0 THEN NULL
398
+ ELSE CAST((SELECT errors FROM now_agg) AS REAL)
399
+ / (SELECT total FROM now_agg) END AS primary_value,
400
+ CASE WHEN (SELECT total FROM base_agg) = 0 THEN NULL
401
+ ELSE CAST((SELECT errors FROM base_agg) AS REAL)
402
+ / (SELECT total FROM base_agg) END AS baseline_value,
403
+ (SELECT json_object(
404
+ 'source', '${sqlEscape(source)}',
405
+ 'target', '${sqlEscape(target)}',
406
+ 'window', '5m',
407
+ 'baselineWindow', '1h',
408
+ 'currentTotal', (SELECT total FROM now_agg),
409
+ 'currentErrors', (SELECT errors FROM now_agg),
410
+ 'baselineTotal', (SELECT total FROM base_agg),
411
+ 'baselineErrors',(SELECT errors FROM base_agg)
412
+ )) AS payload
413
+ `,
414
+ });
415
+
416
+ const makeMessagingLag = (
417
+ projectId: string,
418
+ topic: string,
419
+ ): AnalysisDefinition => ({
420
+ id: `messaging_lag::${slug(topic)}`,
421
+ title: `${topic} — messaging lag`,
422
+ group: "Async",
423
+ source: "tier1",
424
+ view: "tile",
425
+ refreshSeconds: 60,
426
+ scope: { topic },
427
+ // Producer span_kind = 4, Consumer span_kind = 5 (OTel SpanKind enum).
428
+ // We approximate lag as the time gap between producer end_time and the
429
+ // matching consumer start_time on the same trace_id. Spans without a
430
+ // matching counterpart are excluded. Status thresholds in seconds:
431
+ // critical>30s, warn>5s.
432
+ sql: `
433
+ WITH producers AS (
434
+ SELECT trace_id,
435
+ end_time,
436
+ json_extract(attributes_json, '$."messaging.destination"') AS topic
437
+ FROM telemetry_spans
438
+ WHERE project_id = '${sqlEscape(projectId)}'
439
+ AND span_kind = 4
440
+ AND json_extract(attributes_json, '$."messaging.destination"') = '${sqlEscape(topic)}'
441
+ AND received_at >= datetime('now', '-15 minutes')
442
+ ),
443
+ consumers AS (
444
+ SELECT trace_id,
445
+ start_time,
446
+ json_extract(attributes_json, '$."messaging.destination"') AS topic
447
+ FROM telemetry_spans
448
+ WHERE project_id = '${sqlEscape(projectId)}'
449
+ AND span_kind = 5
450
+ AND json_extract(attributes_json, '$."messaging.destination"') = '${sqlEscape(topic)}'
451
+ AND received_at >= datetime('now', '-15 minutes')
452
+ ),
453
+ gaps AS (
454
+ SELECT
455
+ (julianday(c.start_time) - julianday(p.end_time)) * 86400.0 AS lag_seconds
456
+ FROM producers p
457
+ JOIN consumers c USING (trace_id)
458
+ WHERE c.start_time >= p.end_time
459
+ ),
460
+ ordered AS (
461
+ SELECT lag_seconds,
462
+ ROW_NUMBER() OVER (ORDER BY lag_seconds ASC) AS rn,
463
+ COUNT(*) OVER () AS n
464
+ FROM gaps
465
+ ),
466
+ p95_row AS (
467
+ SELECT lag_seconds FROM ordered
468
+ WHERE rn = MAX(1, (95 * n + 99) / 100)
469
+ LIMIT 1
470
+ ),
471
+ stats AS (
472
+ SELECT
473
+ (SELECT COUNT(*) FROM gaps) AS pairs,
474
+ (SELECT AVG(lag_seconds) FROM gaps) AS avg_lag,
475
+ (SELECT lag_seconds FROM p95_row) AS p95_lag,
476
+ (SELECT MAX(lag_seconds) FROM gaps) AS max_lag
477
+ )
478
+ SELECT
479
+ CASE
480
+ WHEN (SELECT pairs FROM stats) = 0 THEN 'unknown'
481
+ WHEN COALESCE((SELECT p95_lag FROM stats), 0) > 30 THEN 'critical'
482
+ WHEN COALESCE((SELECT p95_lag FROM stats), 0) > 5 THEN 'warn'
483
+ ELSE 'ok'
484
+ END AS status,
485
+ (SELECT p95_lag FROM stats) AS primary_value,
486
+ (SELECT avg_lag FROM stats) AS baseline_value,
487
+ (SELECT json_object(
488
+ 'topic', '${sqlEscape(topic)}',
489
+ 'unit', 's',
490
+ 'window', '15m',
491
+ 'pairs', (SELECT pairs FROM stats),
492
+ 'avgLag', (SELECT avg_lag FROM stats),
493
+ 'p95Lag', (SELECT p95_lag FROM stats),
494
+ 'maxLag', (SELECT max_lag FROM stats)
495
+ )) AS payload
496
+ `,
497
+ });
498
+
499
+ const makeAiCostBurn = (projectId: string): AnalysisDefinition => ({
500
+ id: "ai_cost_burn",
501
+ title: "AI cost burn",
502
+ group: "AI",
503
+ source: "tier1",
504
+ view: "tile",
505
+ // Cost moves slowly compared to error rates; 5 min cadence keeps the
506
+ // per-minute tick cheap.
507
+ refreshSeconds: 300,
508
+ // USD spent in the last hour vs the prior hour, summed from ai_calls.
509
+ // Status by relative delta: critical>50%, warn>20%. unknown if no cost
510
+ // signal at all.
511
+ sql: `
512
+ WITH cur AS (
513
+ SELECT COALESCE(SUM(total_cost_usd), 0) AS cost,
514
+ COUNT(*) AS n
515
+ FROM ai_calls
516
+ WHERE project_id = '${sqlEscape(projectId)}'
517
+ AND received_at >= datetime('now', '-1 hour')
518
+ ),
519
+ prev AS (
520
+ SELECT COALESCE(SUM(total_cost_usd), 0) AS cost,
521
+ COUNT(*) AS n
522
+ FROM ai_calls
523
+ WHERE project_id = '${sqlEscape(projectId)}'
524
+ AND received_at >= datetime('now', '-2 hours')
525
+ AND received_at < datetime('now', '-1 hour')
526
+ ),
527
+ delta AS (
528
+ SELECT CASE
529
+ WHEN (SELECT cost FROM prev) = 0 THEN NULL
530
+ ELSE ((SELECT cost FROM cur) - (SELECT cost FROM prev))
531
+ * 100.0 / (SELECT cost FROM prev)
532
+ END AS pct
533
+ )
534
+ SELECT
535
+ CASE
536
+ WHEN (SELECT n FROM cur) = 0 AND (SELECT n FROM prev) = 0 THEN 'unknown'
537
+ WHEN (SELECT pct FROM delta) IS NULL THEN 'ok'
538
+ WHEN (SELECT pct FROM delta) > 50 THEN 'critical'
539
+ WHEN (SELECT pct FROM delta) > 20 THEN 'warn'
540
+ ELSE 'ok'
541
+ END AS status,
542
+ (SELECT cost FROM cur) AS primary_value,
543
+ (SELECT cost FROM prev) AS baseline_value,
544
+ (SELECT json_object(
545
+ 'unit', 'usd',
546
+ 'window', '1h',
547
+ 'baselineWindow', 'prior 1h',
548
+ 'currentCalls', (SELECT n FROM cur),
549
+ 'baselineCalls', (SELECT n FROM prev)
550
+ )) AS payload
551
+ `,
552
+ });
553
+
554
+ const makeAiErrorRate = (projectId: string): AnalysisDefinition => ({
555
+ id: "ai_error_rate",
556
+ title: "AI error rate",
557
+ group: "AI",
558
+ source: "tier1",
559
+ view: "tile",
560
+ // AI error rate is per-call; 5 min cadence balances signal vs cost on
561
+ // installs with hundreds of LLM spans.
562
+ refreshSeconds: 300,
563
+ // is_error = 1 fraction in the last 5 minutes vs trailing 1h baseline.
564
+ // Same thresholds as overall_error_rate.
565
+ sql: `
566
+ WITH now_window AS (
567
+ SELECT
568
+ COUNT(*) AS total,
569
+ SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) AS errors
570
+ FROM ai_calls
571
+ WHERE project_id = '${sqlEscape(projectId)}'
572
+ AND received_at >= datetime('now', '-5 minutes')
573
+ ),
574
+ base_window AS (
575
+ SELECT
576
+ COUNT(*) AS total,
577
+ SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) AS errors
578
+ FROM ai_calls
579
+ WHERE project_id = '${sqlEscape(projectId)}'
580
+ AND received_at >= datetime('now', '-1 hour')
581
+ AND received_at < datetime('now', '-5 minutes')
582
+ )
583
+ SELECT
584
+ CASE
585
+ WHEN (SELECT total FROM now_window) = 0 THEN 'unknown'
586
+ WHEN (CAST((SELECT errors FROM now_window) AS REAL)
587
+ / (SELECT total FROM now_window)) > 0.05 THEN 'critical'
588
+ WHEN (CAST((SELECT errors FROM now_window) AS REAL)
589
+ / (SELECT total FROM now_window)) > 0.01 THEN 'warn'
590
+ ELSE 'ok'
591
+ END AS status,
592
+ CASE WHEN (SELECT total FROM now_window) = 0 THEN NULL
593
+ ELSE CAST((SELECT errors FROM now_window) AS REAL)
594
+ / (SELECT total FROM now_window) END AS primary_value,
595
+ CASE WHEN (SELECT total FROM base_window) = 0 THEN NULL
596
+ ELSE CAST((SELECT errors FROM base_window) AS REAL)
597
+ / (SELECT total FROM base_window) END AS baseline_value,
598
+ (SELECT json_object(
599
+ 'window', '5m',
600
+ 'baselineWindow', '1h',
601
+ 'currentErrors', (SELECT errors FROM now_window),
602
+ 'currentTotal', (SELECT total FROM now_window),
603
+ 'baselineErrors',(SELECT errors FROM base_window),
604
+ 'baselineTotal', (SELECT total FROM base_window)
605
+ )) AS payload
606
+ `,
607
+ });
608
+
609
+ // ── Public API ──────────────────────────────────────────────────────────────
610
+
611
+ /** Test hook — clears the per-project shape cache. */
612
+ export const __resetShapeCache = (): void => {
613
+ shapeCache.clear();
614
+ };
615
+
616
+ export const deriveAnalysesForProject = async (
617
+ projectId: string,
618
+ db: D1Database,
619
+ ): Promise<AnalysisDefinition[]> => {
620
+ const shape = await getShape(projectId, db);
621
+ const out: AnalysisDefinition[] = [];
622
+
623
+ for (const service of shape.services) {
624
+ out.push(makeServiceErrorRate(projectId, service));
625
+ out.push(makeServiceLatencyP95(projectId, service));
626
+ }
627
+
628
+ for (const edge of shape.edges) {
629
+ out.push(makeDependencyHealth(projectId, edge.source, edge.target));
630
+ }
631
+
632
+ for (const topic of shape.messagingTopics) {
633
+ out.push(makeMessagingLag(projectId, topic));
634
+ }
635
+
636
+ if (shape.hasLlmSpans) {
637
+ out.push(makeAiCostBurn(projectId));
638
+ out.push(makeAiErrorRate(projectId));
639
+ }
640
+
641
+ return out;
642
+ };
@@ -0,0 +1,50 @@
1
+ // RFC 0002 Stage 1 — Analysis catalog entry point.
2
+ //
3
+ // Combines static Tier 0 (universal) analyses with dynamically derived
4
+ // Tier 1 analyses. Agent 1's runner imports `getAllAnalysesForProject`
5
+ // to pick up the active set for a project on each scheduler tick. Tier 2
6
+ // (user-defined / LLM-suggested) analyses live in `analysis_definitions`
7
+ // directly and are merged in by the runner, not here.
8
+ //
9
+ // Stage 6 augments the merge with auto-pinning: the top analyses cited
10
+ // by the Ask box over the past week get a `pinned: true` flag the
11
+ // dashboard groups separately at the top of the Health tab.
12
+
13
+ import type { AnalysisDefinition } from "@obsunified/types";
14
+
15
+ import { AnalysesStore } from "../lib/analyses-store";
16
+ import type { SqlDb } from "../lib/sql-db";
17
+ import { deriveAnalysesForProject } from "./derive";
18
+ import { INVESTIGATION_ANALYSES } from "./investigations";
19
+ import { TIER0_ANALYSES } from "./tier0";
20
+
21
+ export interface AnalysisContext {
22
+ db: SqlDb;
23
+ }
24
+
25
+ export const getAllAnalysesForProject = async (
26
+ projectId: string,
27
+ ctx: AnalysisContext,
28
+ ): Promise<AnalysisDefinition[]> => {
29
+ const tier1 = await deriveAnalysesForProject(projectId, ctx.db);
30
+ const all = [...TIER0_ANALYSES, ...INVESTIGATION_ANALYSES, ...tier1];
31
+
32
+ // Stage 6 — fold in the auto-pinned set. We tolerate failures on the
33
+ // pin lookup (e.g. fresh install before migration 025) so registry
34
+ // loading never breaks because the optional signal is missing.
35
+ let pinnedIds = new Set<string>();
36
+ try {
37
+ const store = new AnalysesStore(ctx.db);
38
+ const top = await store.getTopAskedAnalyses(projectId);
39
+ pinnedIds = new Set(top.map((t) => t.analysisId));
40
+ } catch {
41
+ // no-op
42
+ }
43
+
44
+ if (pinnedIds.size === 0) return all;
45
+ return all.map((d) => (pinnedIds.has(d.id) ? { ...d, pinned: true } : d));
46
+ };
47
+
48
+ export { deriveAnalysesForProject } from "./derive";
49
+ export { INVESTIGATION_ANALYSES } from "./investigations";
50
+ export { TIER0_ANALYSES } from "./tier0";