@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,39 @@
1
+ import type { JsonValue } from "@obsunified/types";
2
+
3
+ export const parseJsonRecord = (
4
+ value: string | null | undefined,
5
+ ): Record<string, JsonValue> => {
6
+ if (!value) {
7
+ return {};
8
+ }
9
+
10
+ try {
11
+ const parsed = JSON.parse(value) as JsonValue;
12
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
13
+ ? (parsed as Record<string, JsonValue>)
14
+ : {};
15
+ } catch {
16
+ return {};
17
+ }
18
+ };
19
+
20
+ export const parseJsonValue = (value: string): JsonValue => {
21
+ try {
22
+ return JSON.parse(value) as JsonValue;
23
+ } catch {
24
+ return null;
25
+ }
26
+ };
27
+
28
+ export const parseJsonArray = (value: string | null | undefined): unknown[] => {
29
+ if (!value) {
30
+ return [];
31
+ }
32
+
33
+ try {
34
+ const parsed = JSON.parse(value) as unknown;
35
+ return Array.isArray(parsed) ? parsed : [];
36
+ } catch {
37
+ return [];
38
+ }
39
+ };
package/src/lib/llm.ts ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * RFC 0002 Stage 3 — narrative LLM adapter.
3
+ *
4
+ * Single-provider for v1 (Anthropic). The shape is "stuff in a system
5
+ * prompt that bakes the rendering rules, ask for one declarative
6
+ * sentence, return text" — a different provider plugs in here without
7
+ * changing callers.
8
+ *
9
+ * Rendering rules from the RFC's UX section, enforced at the system
10
+ * prompt layer so the model can't accidentally drift:
11
+ *
12
+ * 1. Never start with "I" or "Here's" — declarative, not chatbot.
13
+ * 2. Always cite — if the prompt mentions "the trace", a trace_id
14
+ * must follow inline.
15
+ * 3. Include a time anchor ("starting 8 min ago", "since 14:32").
16
+ * 4. ≤2 sentences. We're labelling tiles, not writing essays.
17
+ */
18
+
19
+ import type { AnalysisDefinition, AnalysisResult } from "@obsunified/types";
20
+ import type { ChildSpanRunner } from "../framework/logger";
21
+
22
+ export interface NarrativeRequest {
23
+ definition: AnalysisDefinition;
24
+ current: AnalysisResult;
25
+ previous: AnalysisResult | null;
26
+ }
27
+
28
+ /**
29
+ * Provider discriminator. The collector picks one based on which API key
30
+ * env var is set (OpenAI takes precedence when both are set, since users
31
+ * who configure both presumably did so deliberately for OpenAI).
32
+ *
33
+ * anthropic → POST https://api.anthropic.com/v1/messages
34
+ * openai → POST https://api.openai.com/v1/chat/completions
35
+ * (apiUrl override lets you point at openrouter, vLLM,
36
+ * azure-openai-compat, ollama, etc.)
37
+ */
38
+ export type LlmProvider = "anthropic" | "openai";
39
+
40
+ export interface LlmConfig {
41
+ provider: LlmProvider;
42
+ apiKey: string;
43
+ /** Model id. Provider-specific. e.g. "claude-haiku-4-5" / "gpt-4o-mini". */
44
+ model: string;
45
+ /** Override base URL — Anthropic-compatible or OpenAI-compatible. */
46
+ apiUrl?: string;
47
+ /** Optional abort signal used to cap provider calls from schedulers. */
48
+ signal?: AbortSignal;
49
+ /**
50
+ * Optional child-span runner. When set, every LLM API call (and tool
51
+ * dispatch in the agent loop) is wrapped in an OpenInference-shaped span
52
+ * with `gen_ai.system`, `gen_ai.request.model`, and post-call usage
53
+ * attributes. Wired by the worker entrypoint via `runtime.withChildSpan`.
54
+ */
55
+ tracer?: ChildSpanRunner;
56
+ }
57
+
58
+ const DEFAULT_API_URL = "https://api.anthropic.com/v1/messages";
59
+
60
+ const SYSTEM_PROMPT = `You write one-sentence operational narratives for a self-hosted observability dashboard.
61
+
62
+ Hard rules — violations are unusable:
63
+ 1. Never start with "I", "Here's", "We", "It looks", or any first-person/chatbot framing. Write declarative statements about the system.
64
+ 2. If you reference a trace, span, log, or service, include the supplied identifier inline (e.g. "trace abc12345").
65
+ 3. Include a time anchor: "starting Nm ago", "for the last Nm", "since HH:MM" — pull from the inputs.
66
+ 4. ≤ 2 sentences total, ≤ 220 characters. Numbers belong in the sentence, not in a separate label.
67
+ 5. If the inputs are insufficient to write a useful sentence, output exactly the string NO_NARRATIVE (no quotes).
68
+
69
+ Tone: like a senior on-call writing a Slack message. Specific, blunt, no hedging, no advice ("you should..."), no questions.`;
70
+
71
+ const renderTemplate = (
72
+ template: string,
73
+ def: AnalysisDefinition,
74
+ r: AnalysisResult,
75
+ ): string => {
76
+ const scope = (def.scope ?? {}) as Record<string, unknown>;
77
+ const traceIds = Array.isArray(r.payload?.trace_ids)
78
+ ? (r.payload.trace_ids as unknown[])
79
+ .filter((x): x is string => typeof x === "string")
80
+ .slice(0, 5)
81
+ .join(", ")
82
+ : "";
83
+ const subs: Record<string, string> = {
84
+ title: def.title,
85
+ status: r.status,
86
+ primary: r.primaryValue === null ? "n/a" : String(r.primaryValue),
87
+ baseline: r.baselineValue === null ? "n/a" : String(r.baselineValue),
88
+ delta_pct: r.deltaPct === null ? "n/a" : `${r.deltaPct.toFixed(1)}%`,
89
+ service: typeof scope.service === "string" ? scope.service : "",
90
+ trace_ids: traceIds,
91
+ };
92
+ return template.replace(/\{\{(\w+)\}\}/g, (_, k) => subs[k] ?? "");
93
+ };
94
+
95
+ const buildUserPrompt = ({
96
+ definition,
97
+ current,
98
+ previous,
99
+ }: NarrativeRequest): string => {
100
+ const lines: string[] = [];
101
+ const tmplRendered = renderTemplate(
102
+ definition.narrate?.prompt ?? "",
103
+ definition,
104
+ current,
105
+ ).trim();
106
+ if (tmplRendered) lines.push(tmplRendered);
107
+
108
+ lines.push("");
109
+ lines.push("Current state:");
110
+ lines.push(`- panel: ${definition.title}`);
111
+ lines.push(`- status: ${current.status}`);
112
+ if (current.primaryValue !== null)
113
+ lines.push(`- primary: ${current.primaryValue}`);
114
+ if (current.baselineValue !== null)
115
+ lines.push(`- baseline (1h): ${current.baselineValue}`);
116
+ if (current.deltaPct !== null)
117
+ lines.push(`- delta vs baseline: ${current.deltaPct.toFixed(1)}%`);
118
+
119
+ const since = previous
120
+ ? Math.max(
121
+ 1,
122
+ Math.round(
123
+ (new Date(current.generatedAt).getTime() -
124
+ new Date(previous.generatedAt).getTime()) /
125
+ 60000,
126
+ ),
127
+ )
128
+ : null;
129
+ if (since !== null) {
130
+ lines.push(
131
+ `- previous run was ${since} minute${since === 1 ? "" : "s"} ago, status=${previous?.status ?? "n/a"}`,
132
+ );
133
+ }
134
+
135
+ const payloadKeys = Object.keys(current.payload ?? {}).filter(
136
+ (k) => k !== "sparkline",
137
+ );
138
+ if (payloadKeys.length > 0) {
139
+ lines.push("");
140
+ lines.push("Payload details:");
141
+ for (const k of payloadKeys) {
142
+ const v = current.payload[k];
143
+ const rendered =
144
+ typeof v === "string" || typeof v === "number" || typeof v === "boolean"
145
+ ? String(v)
146
+ : JSON.stringify(v);
147
+ if (rendered.length <= 200) lines.push(`- ${k}: ${rendered}`);
148
+ }
149
+ }
150
+
151
+ lines.push("");
152
+ lines.push(
153
+ "Output: a single sentence (max two), no preamble, no quotes, no Markdown.",
154
+ );
155
+ return lines.join("\n");
156
+ };
157
+
158
+ export class LlmCallError extends Error {
159
+ readonly status: number | null;
160
+ constructor(message: string, status: number | null = null) {
161
+ super(message);
162
+ this.name = "LlmCallError";
163
+ this.status = status;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Provider dispatcher for narrative generation.
169
+ *
170
+ * Returns `null` when:
171
+ * - the model returns the sentinel NO_NARRATIVE
172
+ * - the response is empty
173
+ * Throws `LlmCallError` on non-2xx HTTP responses so the runner can
174
+ * count the failure against the budget without writing a bogus narrative.
175
+ */
176
+ export async function generateNarrative(
177
+ req: NarrativeRequest,
178
+ config: LlmConfig,
179
+ ): Promise<string | null> {
180
+ if (config.provider === "openai") {
181
+ // Lazy import to keep the Anthropic-only path zero-cost on the worker
182
+ // boot and avoid a circular import between llm.ts and openai.ts.
183
+ const { generateNarrativeOpenAI } = await import("./openai");
184
+ return generateNarrativeOpenAI(req, config);
185
+ }
186
+ return generateNarrativeAnthropic(req, config);
187
+ }
188
+
189
+ async function generateNarrativeAnthropic(
190
+ req: NarrativeRequest,
191
+ config: LlmConfig,
192
+ ): Promise<string | null> {
193
+ const url = config.apiUrl ?? DEFAULT_API_URL;
194
+ const userPrompt = buildUserPrompt(req);
195
+
196
+ const tracer = config.tracer;
197
+ const callLLM = async (
198
+ span: { setAttribute(k: string, v: unknown): void } | null,
199
+ ): Promise<string | null> => {
200
+ const response = await fetch(url, {
201
+ method: "POST",
202
+ headers: {
203
+ "x-api-key": config.apiKey,
204
+ "anthropic-version": "2023-06-01",
205
+ "content-type": "application/json",
206
+ },
207
+ body: JSON.stringify({
208
+ model: config.model,
209
+ max_tokens: 200,
210
+ system: SYSTEM_PROMPT,
211
+ messages: [{ role: "user", content: userPrompt }],
212
+ }),
213
+ signal: config.signal,
214
+ });
215
+ if (span) {
216
+ span.setAttribute("http.response.status_code", response.status);
217
+ }
218
+
219
+ if (!response.ok) {
220
+ const text = await response.text().catch(() => "");
221
+ throw new LlmCallError(
222
+ `anthropic ${response.status}: ${text.slice(0, 200)}`,
223
+ response.status,
224
+ );
225
+ }
226
+
227
+ const body = (await response.json()) as {
228
+ content?: Array<{ type?: string; text?: string }>;
229
+ usage?: { input_tokens?: number; output_tokens?: number };
230
+ stop_reason?: string;
231
+ };
232
+ if (body.stop_reason === "max_tokens") {
233
+ throw new LlmCallError("anthropic response truncated by max_tokens");
234
+ }
235
+ if (span && body.usage) {
236
+ if (typeof body.usage.input_tokens === "number")
237
+ span.setAttribute("gen_ai.usage.input_tokens", body.usage.input_tokens);
238
+ if (typeof body.usage.output_tokens === "number")
239
+ span.setAttribute(
240
+ "gen_ai.usage.output_tokens",
241
+ body.usage.output_tokens,
242
+ );
243
+ }
244
+ const text = (body.content ?? [])
245
+ .filter((block) => block?.type === "text")
246
+ .map((block) => block?.text ?? "")
247
+ .join("")
248
+ .trim();
249
+
250
+ if (!text || text === "NO_NARRATIVE") return null;
251
+ // Strip any accidental wrapping quotes the model might add despite the
252
+ // system prompt.
253
+ return text.replace(/^["']|["']$/g, "").trim();
254
+ };
255
+
256
+ if (!tracer) return callLLM(null);
257
+ return tracer("llm.anthropic.messages", async (span) => callLLM(span), {
258
+ "openinference.span.kind": "LLM",
259
+ "gen_ai.system": "anthropic",
260
+ "gen_ai.request.model": config.model,
261
+ "gen_ai.request.max_tokens": 200,
262
+ });
263
+ }
@@ -0,0 +1,188 @@
1
+ import type {
2
+ LogRecord,
3
+ LogSeverity,
4
+ LogsOverviewOptions,
5
+ LogsOverviewResponse,
6
+ } from "@obsunified/types";
7
+ import { dialectFor, type SqlDb } from "./sql-db";
8
+
9
+ const LOG_SEVERITIES = new Set<LogSeverity>([
10
+ "DEBUG",
11
+ "INFO",
12
+ "WARN",
13
+ "ERROR",
14
+ "FATAL",
15
+ ]);
16
+
17
+ const toLogSeverity = (severity: string): LogSeverity =>
18
+ LOG_SEVERITIES.has(severity as LogSeverity)
19
+ ? (severity as LogSeverity)
20
+ : "INFO";
21
+
22
+ /** Clamp an integer to a safe range */
23
+ const clampInt = (
24
+ value: unknown,
25
+ min: number,
26
+ max: number,
27
+ fallback: number,
28
+ ): number => {
29
+ const n = typeof value === "number" ? value : parseInt(String(value), 10);
30
+ if (!Number.isFinite(n)) return fallback;
31
+ return Math.max(min, Math.min(max, n));
32
+ };
33
+
34
+ interface LogRow {
35
+ project_id?: string;
36
+ log_id: string;
37
+ trace_id: string | null;
38
+ span_id: string | null;
39
+ service_name: string | null;
40
+ severity: string;
41
+ severity_number: number;
42
+ logger_name: string | null;
43
+ message: string;
44
+ attributes_json: string | null;
45
+ flags: number | null;
46
+ dropped_attributes_count: number | null;
47
+ occurred_at: string;
48
+ received_at: string;
49
+ expires_at: string;
50
+ }
51
+
52
+ interface LogsSummaryRow {
53
+ totalLogs?: number;
54
+ errorLogs?: number;
55
+ warnLogs?: number;
56
+ }
57
+
58
+ export class LogsStore {
59
+ constructor(private readonly db: SqlDb) {}
60
+
61
+ async ingestBatch(logs: LogRecord[]): Promise<void> {
62
+ if (logs.length === 0) return;
63
+
64
+ const stmt = this.db.prepare(`
65
+ INSERT OR IGNORE INTO logs (
66
+ project_id, log_id, trace_id, span_id, service_name, severity, severity_number,
67
+ logger_name, message, attributes_json, flags, dropped_attributes_count,
68
+ occurred_at, received_at, expires_at, session_id, interaction_id
69
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
70
+ `);
71
+
72
+ const batch = logs.map((l) => {
73
+ if (!l.projectId)
74
+ throw new Error("LogsStore.ingestBatch: log.projectId is required");
75
+ return stmt.bind(
76
+ l.projectId,
77
+ l.logId,
78
+ l.traceId,
79
+ l.spanId,
80
+ l.serviceName,
81
+ l.severity,
82
+ l.severityNumber,
83
+ l.loggerName,
84
+ l.message,
85
+ l.attributesJson,
86
+ l.flags,
87
+ l.droppedAttributesCount,
88
+ l.occurredAt,
89
+ l.receivedAt,
90
+ l.expiresAt,
91
+ l.sessionId ?? null,
92
+ l.interactionId ?? null,
93
+ );
94
+ });
95
+
96
+ await this.db.batch(batch);
97
+ }
98
+
99
+ async getLogs(options: LogsOverviewOptions): Promise<LogsOverviewResponse> {
100
+ if (!options.projectId)
101
+ throw new Error("LogsStore.getLogs: projectId is required");
102
+ const dialect = dialectFor(this.db);
103
+ const hours = clampInt(options.hours, 1, 720, 24);
104
+ const limit = clampInt(options.limit, 1, 1000, 100);
105
+
106
+ let sql = `SELECT * FROM logs WHERE project_id = ? AND received_at >= ${dialect.sinceHours("?")}`;
107
+ const params: unknown[] = [options.projectId, hours];
108
+
109
+ if (options.service) {
110
+ sql += ` AND service_name = ?`;
111
+ params.push(options.service);
112
+ }
113
+ if (options.severity) {
114
+ sql += ` AND severity = ?`;
115
+ params.push(options.severity);
116
+ }
117
+ if (options.traceId) {
118
+ sql += ` AND trace_id = ?`;
119
+ params.push(options.traceId);
120
+ }
121
+ if (options.search) {
122
+ sql += ` AND message LIKE ?`;
123
+ params.push(`%${options.search}%`);
124
+ }
125
+
126
+ sql += ` ORDER BY received_at DESC LIMIT ?`;
127
+ params.push(limit);
128
+
129
+ const results = await this.db
130
+ .prepare(sql)
131
+ .bind(...params)
132
+ .all<LogRow>();
133
+
134
+ // Simple summary stats
135
+ const summarySql = `
136
+ SELECT
137
+ COUNT(*) as totalLogs,
138
+ SUM(CASE WHEN severity = 'ERROR' OR severity = 'FATAL' THEN 1 ELSE 0 END) as errorLogs,
139
+ SUM(CASE WHEN severity = 'WARN' THEN 1 ELSE 0 END) as warnLogs
140
+ FROM logs WHERE project_id = ? AND received_at >= ${dialect.sinceHours("?")}
141
+ ${options.service ? "AND service_name = ?" : ""}
142
+ `;
143
+ const summaryParams: unknown[] = [options.projectId, hours];
144
+ if (options.service) summaryParams.push(options.service);
145
+ const summaryResult =
146
+ (await this.db
147
+ .prepare(summarySql)
148
+ .bind(...summaryParams)
149
+ .first<LogsSummaryRow>()) || {};
150
+
151
+ const logs: LogRecord[] = (results.results || []).map((r) => ({
152
+ projectId: r.project_id ?? "default",
153
+ logId: r.log_id,
154
+ traceId: r.trace_id,
155
+ spanId: r.span_id,
156
+ serviceName: r.service_name,
157
+ severity: toLogSeverity(r.severity),
158
+ severityNumber: r.severity_number,
159
+ loggerName: r.logger_name,
160
+ message: r.message,
161
+ attributesJson: r.attributes_json,
162
+ flags: r.flags ?? 0,
163
+ droppedAttributesCount: r.dropped_attributes_count ?? 0,
164
+ occurredAt: r.occurred_at,
165
+ receivedAt: r.received_at,
166
+ expiresAt: r.expires_at,
167
+ }));
168
+
169
+ return {
170
+ logs,
171
+ summary: {
172
+ totalLogs: summaryResult.totalLogs || 0,
173
+ errorLogs: summaryResult.errorLogs || 0,
174
+ warnLogs: summaryResult.warnLogs || 0,
175
+ },
176
+ windowHours: hours,
177
+ timestamp: new Date().toISOString(),
178
+ };
179
+ }
180
+
181
+ async purgeExpired(): Promise<number> {
182
+ const dialect = dialectFor(this.db);
183
+ const { meta } = await this.db
184
+ .prepare(`DELETE FROM logs WHERE expires_at < ${dialect.now()}`)
185
+ .run();
186
+ return meta.changes;
187
+ }
188
+ }
@@ -0,0 +1,189 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DecodedMetricPoint } from "../otlp/decode";
3
+ import { MetricsStore } from "./metrics-store";
4
+ import { MemSqlDb } from "./test-utils/mem-sql-db";
5
+
6
+ const point = (
7
+ overrides: Partial<DecodedMetricPoint> = {},
8
+ ): DecodedMetricPoint => ({
9
+ name: "http.server.duration",
10
+ description: null,
11
+ unit: "ms",
12
+ type: "histogram",
13
+ isMonotonic: null,
14
+ temporality: 2,
15
+ scopeName: "@opentelemetry/instrumentation-http",
16
+ scopeVersion: "1.0.0",
17
+ serviceName: "checkout",
18
+ resourceAttrsJson: JSON.stringify({ "service.name": "checkout" }),
19
+ attributesJson: JSON.stringify({ route: "/checkout" }),
20
+ identity: "metric-identity",
21
+ tsNs: "1800000000000000000",
22
+ startTsNs: null,
23
+ value: null,
24
+ count: 1,
25
+ sum: 42,
26
+ min: null,
27
+ max: null,
28
+ boundsJson: JSON.stringify([50]),
29
+ bucketCountsJson: JSON.stringify([1]),
30
+ extraJson: null,
31
+ exemplarsJson: null,
32
+ ...overrides,
33
+ });
34
+
35
+ describe("MetricsStore", () => {
36
+ it("indexes metric exemplars by trace/span during ingest", async () => {
37
+ const db = new MemSqlDb({
38
+ all: (sql, binds) => {
39
+ if (sql.includes("FROM metric_series")) {
40
+ return [{ id: "series-1", identity: binds[1] }];
41
+ }
42
+ return [];
43
+ },
44
+ });
45
+ const store = new MetricsStore(db);
46
+
47
+ await store.ingestBatch({
48
+ projectId: "p1",
49
+ receivedAt: "2026-06-01T00:00:00.000Z",
50
+ expiresAt: "2026-06-02T00:00:00.000Z",
51
+ points: [
52
+ point({
53
+ exemplarsJson: JSON.stringify([
54
+ {
55
+ value: 42,
56
+ traceId: "01010101010101010101010101010101",
57
+ spanId: "0202020202020202",
58
+ tsNs: "1800000000000000001",
59
+ },
60
+ {
61
+ value: 41,
62
+ traceId: null,
63
+ spanId: null,
64
+ tsNs: "1800000000000000002",
65
+ },
66
+ {
67
+ value: 40,
68
+ traceId: "03030303030303030303030303030303",
69
+ spanId: null,
70
+ tsNs: "1800000000000000003",
71
+ },
72
+ ]),
73
+ }),
74
+ ],
75
+ });
76
+
77
+ expect(db.callsMatching("INSERT INTO metric_point")).toHaveLength(1);
78
+ const exemplarInserts = db.callsMatching("INSERT INTO metric_exemplars");
79
+ expect(exemplarInserts).toHaveLength(2);
80
+ expect(exemplarInserts[0].binds.slice(3)).toEqual([
81
+ "p1",
82
+ "http.server.duration",
83
+ "checkout",
84
+ "01010101010101010101010101010101",
85
+ "0202020202020202",
86
+ "1800000000000000001",
87
+ 42,
88
+ "2026-06-01T00:00:00.000Z",
89
+ "2026-06-02T00:00:00.000Z",
90
+ ]);
91
+ expect(exemplarInserts[1].binds[6]).toBe(
92
+ "03030303030303030303030303030303",
93
+ );
94
+ expect(exemplarInserts[1].binds[7]).toBeNull();
95
+ });
96
+
97
+ it("queries exemplars for a trace", async () => {
98
+ const db = new MemSqlDb({
99
+ all: (sql) => {
100
+ if (sql.includes("FROM metric_exemplars")) {
101
+ return [
102
+ {
103
+ id: "ex-1",
104
+ point_id: "point-1",
105
+ series_id: "series-1",
106
+ metric_name: "http.server.duration",
107
+ service_name: "checkout",
108
+ trace_id: "trace-1",
109
+ span_id: "span-1",
110
+ ts_ns: "1800000000000000001",
111
+ value: 42,
112
+ received_at: "2026-06-01T00:00:00.000Z",
113
+ },
114
+ ];
115
+ }
116
+ return [];
117
+ },
118
+ });
119
+ const store = new MetricsStore(db);
120
+
121
+ const rows = await store.exemplarsForTrace("p1", "trace-1", 5);
122
+
123
+ expect(rows).toEqual([
124
+ {
125
+ id: "ex-1",
126
+ pointId: "point-1",
127
+ seriesId: "series-1",
128
+ metricName: "http.server.duration",
129
+ serviceName: "checkout",
130
+ traceId: "trace-1",
131
+ spanId: "span-1",
132
+ tsNs: "1800000000000000001",
133
+ value: 42,
134
+ receivedAt: "2026-06-01T00:00:00.000Z",
135
+ },
136
+ ]);
137
+ expect(db.calls[0].binds).toEqual(["p1", "trace-1", 5]);
138
+ });
139
+
140
+ it("queries recent exemplars for resource metric pivots", async () => {
141
+ const db = new MemSqlDb({
142
+ all: (sql) => {
143
+ if (sql.includes("FROM metric_exemplars")) {
144
+ expect(sql).toContain("service_name = ?");
145
+ expect(sql).toContain("metric_name LIKE ?");
146
+ return [
147
+ {
148
+ id: "ex-2",
149
+ point_id: "point-2",
150
+ series_id: "series-2",
151
+ metric_name: "system.cpu.utilization",
152
+ service_name: "checkout",
153
+ trace_id: "trace-2",
154
+ span_id: "span-2",
155
+ ts_ns: "1800000000000000002",
156
+ value: 0.91,
157
+ received_at: "2026-06-01T00:01:00.000Z",
158
+ },
159
+ ];
160
+ }
161
+ return [];
162
+ },
163
+ });
164
+ const store = new MetricsStore(db);
165
+
166
+ const rows = await store.recentExemplars({
167
+ projectId: "p1",
168
+ serviceName: "checkout",
169
+ metricPrefix: "system.",
170
+ limit: 3,
171
+ });
172
+
173
+ expect(rows).toEqual([
174
+ {
175
+ id: "ex-2",
176
+ pointId: "point-2",
177
+ seriesId: "series-2",
178
+ metricName: "system.cpu.utilization",
179
+ serviceName: "checkout",
180
+ traceId: "trace-2",
181
+ spanId: "span-2",
182
+ tsNs: "1800000000000000002",
183
+ value: 0.91,
184
+ receivedAt: "2026-06-01T00:01:00.000Z",
185
+ },
186
+ ]);
187
+ expect(db.calls[0].binds).toEqual(["p1", "checkout", "system.%", 3]);
188
+ });
189
+ });