@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,302 @@
1
+ import type { AnalysisDefinition, AnalysisResult } from "@obsunified/types";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { runAsk } from "./ask";
4
+
5
+ const def = (
6
+ overrides: Partial<AnalysisDefinition> = {},
7
+ ): AnalysisDefinition => ({
8
+ id: "overall_error_rate",
9
+ title: "Overall error rate",
10
+ group: "Health",
11
+ source: "tier0",
12
+ view: "tile",
13
+ refreshSeconds: 60,
14
+ ...overrides,
15
+ });
16
+
17
+ const result = (overrides: Partial<AnalysisResult> = {}): AnalysisResult => ({
18
+ analysisId: "overall_error_rate",
19
+ projectId: "default",
20
+ generatedAt: "2026-04-28T00:00:00Z",
21
+ paramsHash: null,
22
+ status: "warn",
23
+ primaryValue: 0.05,
24
+ baselineValue: 0.01,
25
+ deltaPct: 400,
26
+ payload: {},
27
+ narrative: null,
28
+ narrativeSignature: "warn|0.1|0.0|",
29
+ durationMs: 5,
30
+ ...overrides,
31
+ });
32
+
33
+ const llm = {
34
+ provider: "anthropic" as const,
35
+ apiKey: "test-key",
36
+ model: "claude-haiku-4-5",
37
+ };
38
+
39
+ const mockFetchSequence = (responses: Array<Record<string, unknown>>) => {
40
+ let i = 0;
41
+ return vi.fn(
42
+ async () =>
43
+ ({
44
+ ok: true,
45
+ json: async () => responses[i++] ?? responses[responses.length - 1],
46
+ text: async () => "",
47
+ }) as unknown as Response,
48
+ );
49
+ };
50
+
51
+ describe("runAsk", () => {
52
+ const originalFetch = globalThis.fetch;
53
+ beforeEach(() => {
54
+ // vi.fn replaces fetch in each test below
55
+ });
56
+ afterEach(() => {
57
+ globalThis.fetch = originalFetch;
58
+ });
59
+
60
+ it("returns the model's text when it answers without tools", async () => {
61
+ globalThis.fetch = mockFetchSequence([
62
+ {
63
+ stop_reason: "end_turn",
64
+ content: [
65
+ {
66
+ type: "text",
67
+ text: "error rate is 5% (overall_error_rate), up 400% vs the prior hour",
68
+ },
69
+ ],
70
+ },
71
+ ]) as typeof fetch;
72
+
73
+ const out = await runAsk("is anything broken?", {
74
+ llm,
75
+ listAnalyses: async () => [],
76
+ getLatestResult: async () => null,
77
+ });
78
+
79
+ expect(out.answer).toContain("error rate is 5%");
80
+ expect(out.evidence).toEqual([]);
81
+ expect(out.queries).toEqual([]);
82
+ expect(out.error).toBeNull();
83
+ });
84
+
85
+ it("invokes list_analyses then run_analysis then synthesizes", async () => {
86
+ globalThis.fetch = mockFetchSequence([
87
+ // turn 1: model asks for the analyses catalog
88
+ {
89
+ stop_reason: "tool_use",
90
+ content: [
91
+ {
92
+ type: "tool_use",
93
+ id: "tu_1",
94
+ name: "list_analyses",
95
+ input: { group: "Health" },
96
+ },
97
+ ],
98
+ },
99
+ // turn 2: model picks one and asks for its result
100
+ {
101
+ stop_reason: "tool_use",
102
+ content: [
103
+ {
104
+ type: "tool_use",
105
+ id: "tu_2",
106
+ name: "run_analysis",
107
+ input: { id: "overall_error_rate" },
108
+ },
109
+ ],
110
+ },
111
+ // turn 3: model writes the final answer
112
+ {
113
+ stop_reason: "end_turn",
114
+ content: [
115
+ {
116
+ type: "text",
117
+ text: "errors at 5% in the last 5 minutes, up from 1% (overall_error_rate)",
118
+ },
119
+ ],
120
+ },
121
+ ]) as typeof fetch;
122
+
123
+ const def0 = def();
124
+ const r0 = result();
125
+ const out = await runAsk("any errors?", {
126
+ llm,
127
+ listAnalyses: async () => [def0],
128
+ getLatestResult: async (id) =>
129
+ id === def0.id ? { definition: def0, result: r0 } : null,
130
+ });
131
+
132
+ expect(out.answer).toContain("errors at 5%");
133
+ expect(out.queries).toHaveLength(2);
134
+ expect(out.queries.map((q) => q.tool)).toEqual([
135
+ "list_analyses",
136
+ "run_analysis",
137
+ ]);
138
+ expect(out.evidence).toHaveLength(1);
139
+ expect(out.evidence[0]?.analysisId).toBe("overall_error_rate");
140
+ expect(out.evidenceReferences).toEqual([
141
+ expect.objectContaining({
142
+ evidenceId: "ask:analysis:overall_error_rate",
143
+ entityKind: "analysis",
144
+ entityId: "overall_error_rate",
145
+ route: "#/investigate/overall_error_rate",
146
+ source: "ask.run_analysis",
147
+ confidence: 1,
148
+ reason: expect.stringContaining("Overall error rate"),
149
+ citations: [
150
+ expect.objectContaining({
151
+ label: "Overall error rate",
152
+ entityKind: "analysis",
153
+ entityId: "overall_error_rate",
154
+ }),
155
+ ],
156
+ suggestedNextPivots: [
157
+ expect.objectContaining({
158
+ label: "Open investigation",
159
+ entityKind: "analysis",
160
+ entityId: "overall_error_rate",
161
+ route: "#/investigate/overall_error_rate",
162
+ }),
163
+ ],
164
+ }),
165
+ ]);
166
+ });
167
+
168
+ it("keeps legacy evidence while adding a fallback evidence reference for analyses without results", async () => {
169
+ globalThis.fetch = mockFetchSequence([
170
+ {
171
+ stop_reason: "tool_use",
172
+ content: [
173
+ {
174
+ type: "tool_use",
175
+ id: "tu_1",
176
+ name: "run_analysis",
177
+ input: { id: "overall_error_rate" },
178
+ },
179
+ ],
180
+ },
181
+ {
182
+ stop_reason: "end_turn",
183
+ content: [
184
+ {
185
+ type: "text",
186
+ text: "overall error rate has no recent result (overall_error_rate)",
187
+ },
188
+ ],
189
+ },
190
+ ]) as typeof fetch;
191
+
192
+ const def0 = def();
193
+ const out = await runAsk("any errors?", {
194
+ llm,
195
+ listAnalyses: async () => [def0],
196
+ getLatestResult: async (id) =>
197
+ id === def0.id ? { definition: def0, result: null } : null,
198
+ });
199
+
200
+ expect(out.evidence).toEqual([
201
+ expect.objectContaining({
202
+ analysisId: "overall_error_rate",
203
+ definition: def0,
204
+ result: null,
205
+ }),
206
+ ]);
207
+ expect(out.evidenceReferences).toEqual([
208
+ expect.objectContaining({
209
+ evidenceId: "ask:analysis:overall_error_rate",
210
+ entityKind: "analysis",
211
+ entityId: "overall_error_rate",
212
+ confidence: 0.55,
213
+ reason: expect.stringContaining("no latest result"),
214
+ }),
215
+ ]);
216
+ });
217
+
218
+ it("surfaces tool errors back to the model rather than throwing", async () => {
219
+ globalThis.fetch = mockFetchSequence([
220
+ {
221
+ stop_reason: "tool_use",
222
+ content: [
223
+ {
224
+ type: "tool_use",
225
+ id: "tu_1",
226
+ name: "run_analysis",
227
+ input: { id: "does_not_exist" },
228
+ },
229
+ ],
230
+ },
231
+ {
232
+ stop_reason: "end_turn",
233
+ content: [
234
+ {
235
+ type: "text",
236
+ text: "the requested analysis isn't registered (does_not_exist)",
237
+ },
238
+ ],
239
+ },
240
+ ]) as typeof fetch;
241
+
242
+ const out = await runAsk("does this work?", {
243
+ llm,
244
+ listAnalyses: async () => [],
245
+ getLatestResult: async () => null,
246
+ });
247
+
248
+ expect(out.answer).toContain("isn't registered");
249
+ expect(out.evidence).toEqual([]);
250
+ expect(out.queries).toHaveLength(1);
251
+ });
252
+
253
+ it("hits the iteration cap and returns an error", async () => {
254
+ // Model never calls end_turn — keeps requesting list_analyses.
255
+ const loopForever = {
256
+ stop_reason: "tool_use",
257
+ content: [
258
+ {
259
+ type: "tool_use",
260
+ id: "tu_x",
261
+ name: "list_analyses",
262
+ input: {},
263
+ },
264
+ ],
265
+ };
266
+ globalThis.fetch = mockFetchSequence([
267
+ loopForever,
268
+ loopForever,
269
+ loopForever,
270
+ loopForever,
271
+ loopForever,
272
+ loopForever,
273
+ loopForever,
274
+ ]) as typeof fetch;
275
+
276
+ const out = await runAsk("loop please", {
277
+ llm,
278
+ listAnalyses: async () => [def()],
279
+ getLatestResult: async () => null,
280
+ });
281
+
282
+ expect(out.answer).toBeNull();
283
+ expect(out.error).toMatch(/iteration cap/);
284
+ // Each iteration's tool call gets logged, even when we cap.
285
+ expect(out.queries.length).toBeGreaterThanOrEqual(5);
286
+ });
287
+
288
+ it("treats an empty stop_reason=end_turn as 'no answer'", async () => {
289
+ globalThis.fetch = mockFetchSequence([
290
+ { stop_reason: "end_turn", content: [] },
291
+ ]) as typeof fetch;
292
+
293
+ const out = await runAsk("hmm", {
294
+ llm,
295
+ listAnalyses: async () => [],
296
+ getLatestResult: async () => null,
297
+ });
298
+
299
+ expect(out.answer).toBeNull();
300
+ expect(out.error).toBe("model returned no text");
301
+ });
302
+ });
package/src/lib/ask.ts ADDED
@@ -0,0 +1,390 @@
1
+ /**
2
+ * RFC 0002 Stage 5 — Ask box tool-use loop.
3
+ *
4
+ * The user asks "is checkout slow?", we hand it to an LLM with two
5
+ * tools that look at the analyses catalog:
6
+ *
7
+ * list_analyses(group?) → AnalysisDefinition[]
8
+ * run_analysis(id) → { definition, result }
9
+ *
10
+ * The loop runs until the model emits a final assistant message or we
11
+ * hit the iteration cap. Why two tools and not one big "ask anything"
12
+ * prompt: the analyses we've already derived (60+ panels on the OTel
13
+ * demo) cover most "is X slow / errors / unhealthy?" questions, and
14
+ * pointing the model at structured data with known shape is far more
15
+ * reliable than asking it to write SQL from scratch. Ad-hoc SQL lands
16
+ * later as a third tool with SELECT-only validation.
17
+ *
18
+ * The model is instructed to cite analysis ids inline so the UI can
19
+ * link the cited evidence; we surface a flat `queries` audit log so
20
+ * users can verify what was actually consulted.
21
+ */
22
+
23
+ import type {
24
+ AnalysisDefinition,
25
+ AnalysisResult,
26
+ AskEvidence,
27
+ AskQuery,
28
+ AskResponse,
29
+ } from "@obsunified/types";
30
+ import { EVIDENCE_REFERENCE_CONTRACT } from "@obsunified/types";
31
+ import { askEvidenceReferences } from "./evidence-references";
32
+ import type { LlmConfig } from "./llm";
33
+
34
+ const DEFAULT_API_URL = "https://api.anthropic.com/v1/messages";
35
+
36
+ const SYSTEM_PROMPT = `You answer operational questions about a self-hosted observability dashboard's telemetry. Use the supplied tools — do not invent numbers.
37
+
38
+ Workflow:
39
+ 1. Use list_analyses to see what's available. Filter by group when it helps.
40
+ 2. Pick the analyses most likely to answer the question. Call run_analysis on each (typically 1–3, max 5).
41
+ 3. Read the results. Synthesize a 1–2 sentence answer.
42
+
43
+ Hard rules — violations are unusable:
44
+ 1. Never start with "I", "Here's", "We", or any chatbot framing. Write declarative statements about the system.
45
+ 2. Cite the analyses you consulted by id, e.g. "(latency_p95_overall)".
46
+ 3. Include a time anchor when describing a number ("in the last 5 minutes", "vs the prior hour", "for the past 24h").
47
+ 4. ≤ 2 sentences, ≤ 280 characters total. Numbers belong in the sentence.
48
+ 5. If the available analyses don't cover the question, say exactly that and name the closest analysis you found.
49
+
50
+ Output: write only the final answer text. No preamble, no JSON.`;
51
+
52
+ const TOOLS = [
53
+ {
54
+ name: "list_analyses",
55
+ description:
56
+ "List registered analyses for the current project. Optionally filter by group (Health, Services, Dependencies, Async, AI, Frontend, Custom). Returns id, title, group, source, view, and scope (when set).",
57
+ input_schema: {
58
+ type: "object" as const,
59
+ properties: {
60
+ group: {
61
+ type: "string",
62
+ description: "Filter by group. Omit to see all.",
63
+ },
64
+ view: {
65
+ type: "string",
66
+ description:
67
+ "Filter by view: 'tile' for fast panels, 'page' for investigations.",
68
+ },
69
+ },
70
+ },
71
+ },
72
+ {
73
+ name: "run_analysis",
74
+ description:
75
+ "Read the latest result for one analysis by id. Returns status, primary value, baseline, delta_pct, and the analysis-specific payload (which often contains the most useful data — top services, span names, etc).",
76
+ input_schema: {
77
+ type: "object" as const,
78
+ properties: {
79
+ id: {
80
+ type: "string",
81
+ description: "Analysis id, e.g. 'overall_error_rate'.",
82
+ },
83
+ },
84
+ required: ["id"],
85
+ },
86
+ },
87
+ ];
88
+
89
+ const MAX_ITERATIONS = 5;
90
+
91
+ interface AnthropicContentBlock {
92
+ type: "text" | "tool_use" | "tool_result";
93
+ text?: string;
94
+ id?: string;
95
+ name?: string;
96
+ input?: Record<string, unknown>;
97
+ tool_use_id?: string;
98
+ content?: string;
99
+ }
100
+
101
+ interface AnthropicResponse {
102
+ content?: AnthropicContentBlock[];
103
+ stop_reason?: string;
104
+ }
105
+
106
+ export interface AskRunDeps {
107
+ listAnalyses: (filters?: {
108
+ group?: string;
109
+ view?: string;
110
+ }) => Promise<AnalysisDefinition[]>;
111
+ getLatestResult: (id: string) => Promise<{
112
+ definition: AnalysisDefinition;
113
+ result: AnalysisResult | null;
114
+ } | null>;
115
+ llm: LlmConfig;
116
+ }
117
+
118
+ const summarizeDefinition = (def: AnalysisDefinition) => ({
119
+ id: def.id,
120
+ title: def.title,
121
+ group: def.group,
122
+ source: def.source,
123
+ view: def.view,
124
+ scope: def.scope ?? null,
125
+ });
126
+
127
+ const summarizeResult = (
128
+ def: AnalysisDefinition,
129
+ result: AnalysisResult | null,
130
+ ) => {
131
+ if (!result) {
132
+ return {
133
+ definition: summarizeDefinition(def),
134
+ result: null,
135
+ note: "No result yet — analysis hasn't run.",
136
+ };
137
+ }
138
+ return {
139
+ definition: summarizeDefinition(def),
140
+ result: {
141
+ generatedAt: result.generatedAt,
142
+ status: result.status,
143
+ primaryValue: result.primaryValue,
144
+ baselineValue: result.baselineValue,
145
+ deltaPct: result.deltaPct,
146
+ payload: result.payload,
147
+ narrative: result.narrative,
148
+ },
149
+ };
150
+ };
151
+
152
+ export async function runAsk(
153
+ question: string,
154
+ deps: AskRunDeps,
155
+ ): Promise<AskResponse> {
156
+ if (deps.llm.provider === "openai") {
157
+ // Lazy import to keep Anthropic-only paths zero-cost.
158
+ const { runAskOpenAI } = await import("./openai");
159
+ return runAskOpenAI(question, deps);
160
+ }
161
+ return runAskAnthropic(question, deps);
162
+ }
163
+
164
+ async function runAskAnthropic(
165
+ question: string,
166
+ deps: AskRunDeps,
167
+ ): Promise<AskResponse> {
168
+ const startedAt = new Date().toISOString();
169
+ const queries: AskQuery[] = [];
170
+ const evidence = new Map<string, AskEvidence>();
171
+
172
+ const messages: Array<{
173
+ role: "user" | "assistant";
174
+ content: string | AnthropicContentBlock[];
175
+ }> = [{ role: "user", content: question }];
176
+
177
+ for (let iteration = 0; iteration < MAX_ITERATIONS; iteration += 1) {
178
+ const response = await callAnthropic(deps.llm, messages, iteration);
179
+
180
+ const toolUses = (response.content ?? []).filter(
181
+ (b) => b.type === "tool_use",
182
+ );
183
+ const finalText = (response.content ?? [])
184
+ .filter((b) => b.type === "text")
185
+ .map((b) => b.text ?? "")
186
+ .join("")
187
+ .trim();
188
+
189
+ // Model finished — return the answer.
190
+ if (response.stop_reason === "max_tokens") {
191
+ return {
192
+ answer: null,
193
+ evidence: [...evidence.values()],
194
+ evidenceReferences: askEvidenceReferences(evidence.values()),
195
+ evidenceContract: EVIDENCE_REFERENCE_CONTRACT,
196
+ queries,
197
+ error: "model response was truncated by max_tokens",
198
+ timestamp: startedAt,
199
+ };
200
+ }
201
+
202
+ if (response.stop_reason === "end_turn" || toolUses.length === 0) {
203
+ return {
204
+ answer: finalText.length > 0 ? finalText : null,
205
+ evidence: [...evidence.values()],
206
+ evidenceReferences: askEvidenceReferences(evidence.values()),
207
+ evidenceContract: EVIDENCE_REFERENCE_CONTRACT,
208
+ queries,
209
+ error: finalText.length > 0 ? null : "model returned no text",
210
+ timestamp: startedAt,
211
+ };
212
+ }
213
+
214
+ // Append the assistant's tool_use message verbatim.
215
+ messages.push({
216
+ role: "assistant",
217
+ content: response.content ?? [],
218
+ });
219
+
220
+ // Execute every tool_use the assistant requested in this turn.
221
+ const toolResultBlocks: AnthropicContentBlock[] = [];
222
+ for (const block of toolUses) {
223
+ const t0 = Date.now();
224
+ let resultPayload: unknown;
225
+ const tracer = deps.llm.tracer;
226
+ const toolName = block.name;
227
+ const args = block.input ?? {};
228
+ const dispatchTool = async (
229
+ span: { setAttribute(k: string, v: unknown): void } | null,
230
+ ): Promise<unknown> => {
231
+ if (toolName === "list_analyses") {
232
+ const all = await deps.listAnalyses({
233
+ group: typeof args.group === "string" ? args.group : undefined,
234
+ view: typeof args.view === "string" ? args.view : undefined,
235
+ });
236
+ const out = all.slice(0, 80).map(summarizeDefinition);
237
+ if (span) span.setAttribute("tool.result_count", out.length);
238
+ queries.push({
239
+ tool: "list_analyses",
240
+ args,
241
+ durationMs: Date.now() - t0,
242
+ });
243
+ return out;
244
+ }
245
+ if (toolName === "run_analysis") {
246
+ const id = typeof args.id === "string" ? args.id : "";
247
+ if (!id) {
248
+ if (span) span.setAttribute("tool.outcome", "missing_id");
249
+ queries.push({
250
+ tool: "run_analysis",
251
+ args,
252
+ durationMs: Date.now() - t0,
253
+ });
254
+ return { error: "id is required" };
255
+ }
256
+ const found = await deps.getLatestResult(id);
257
+ queries.push({
258
+ tool: "run_analysis",
259
+ args,
260
+ durationMs: Date.now() - t0,
261
+ });
262
+ if (!found) {
263
+ if (span) span.setAttribute("tool.outcome", "not_found");
264
+ return { error: `analysis ${id} not found` };
265
+ }
266
+ if (span) {
267
+ span.setAttribute("tool.outcome", "ok");
268
+ span.setAttribute("analysis.id", id);
269
+ if (found.result) {
270
+ span.setAttribute("analysis.status", found.result.status);
271
+ }
272
+ }
273
+ evidence.set(id, {
274
+ analysisId: id,
275
+ definition: found.definition,
276
+ result: found.result,
277
+ });
278
+ return summarizeResult(found.definition, found.result);
279
+ }
280
+ if (span) span.setAttribute("tool.outcome", "unknown_tool");
281
+ return { error: `unknown tool: ${toolName}` };
282
+ };
283
+ try {
284
+ if (tracer) {
285
+ resultPayload = await tracer(
286
+ `tool.${toolName}`,
287
+ async (span) => dispatchTool(span),
288
+ {
289
+ "openinference.span.kind": "TOOL",
290
+ "tool.name": toolName,
291
+ "tool.args": JSON.stringify(args).slice(0, 512),
292
+ },
293
+ );
294
+ } else {
295
+ resultPayload = await dispatchTool(null);
296
+ }
297
+ } catch (err) {
298
+ resultPayload = {
299
+ error: err instanceof Error ? err.message : String(err),
300
+ };
301
+ }
302
+ toolResultBlocks.push({
303
+ type: "tool_result",
304
+ tool_use_id: block.id,
305
+ content: JSON.stringify(resultPayload),
306
+ });
307
+ }
308
+
309
+ messages.push({
310
+ role: "user",
311
+ content: toolResultBlocks,
312
+ });
313
+ }
314
+
315
+ // Hit iteration cap — return whatever evidence we accumulated with an error.
316
+ return {
317
+ answer: null,
318
+ evidence: [...evidence.values()],
319
+ evidenceReferences: askEvidenceReferences(evidence.values()),
320
+ evidenceContract: EVIDENCE_REFERENCE_CONTRACT,
321
+ queries,
322
+ error: `iteration cap (${MAX_ITERATIONS}) reached without final answer`,
323
+ timestamp: startedAt,
324
+ };
325
+ }
326
+
327
+ async function callAnthropic(
328
+ config: LlmConfig,
329
+ messages: Array<{
330
+ role: "user" | "assistant";
331
+ content: string | AnthropicContentBlock[];
332
+ }>,
333
+ turnIndex?: number,
334
+ ): Promise<AnthropicResponse> {
335
+ const url = config.apiUrl ?? DEFAULT_API_URL;
336
+ const exec = async (
337
+ span: { setAttribute(k: string, v: unknown): void } | null,
338
+ ): Promise<AnthropicResponse> => {
339
+ const response = await fetch(url, {
340
+ method: "POST",
341
+ headers: {
342
+ "x-api-key": config.apiKey,
343
+ "anthropic-version": "2023-06-01",
344
+ "content-type": "application/json",
345
+ },
346
+ body: JSON.stringify({
347
+ model: config.model,
348
+ max_tokens: 1024,
349
+ system: SYSTEM_PROMPT,
350
+ tools: TOOLS,
351
+ messages,
352
+ }),
353
+ });
354
+ if (span) span.setAttribute("http.response.status_code", response.status);
355
+ if (!response.ok) {
356
+ const text = await response.text().catch(() => "");
357
+ throw new Error(`anthropic ${response.status}: ${text.slice(0, 200)}`);
358
+ }
359
+ const json = (await response.json()) as AnthropicResponse & {
360
+ usage?: { input_tokens?: number; output_tokens?: number };
361
+ };
362
+ if (span && json.usage) {
363
+ if (typeof json.usage.input_tokens === "number")
364
+ span.setAttribute("gen_ai.usage.input_tokens", json.usage.input_tokens);
365
+ if (typeof json.usage.output_tokens === "number")
366
+ span.setAttribute(
367
+ "gen_ai.usage.output_tokens",
368
+ json.usage.output_tokens,
369
+ );
370
+ }
371
+ if (span && json.stop_reason)
372
+ span.setAttribute("gen_ai.response.finish_reason", json.stop_reason);
373
+ return json;
374
+ };
375
+
376
+ if (!config.tracer) return exec(null);
377
+ const attrs: Record<string, unknown> = {
378
+ "openinference.span.kind": "LLM",
379
+ "gen_ai.system": "anthropic",
380
+ "gen_ai.request.model": config.model,
381
+ "gen_ai.request.max_tokens": 1024,
382
+ "http.url": url,
383
+ };
384
+ if (typeof turnIndex === "number") attrs["llm.turn"] = turnIndex;
385
+ return config.tracer(
386
+ "llm.anthropic.messages",
387
+ async (span) => exec(span),
388
+ attrs,
389
+ );
390
+ }