@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,468 @@
1
+ /**
2
+ * RFC 0007 — pprof profiling receiver + read endpoints.
3
+ *
4
+ * POST /v1/profiles/pprof — accepts gzipped pprof blob
5
+ * GET /internal/profiles/:id — proxies the blob back
6
+ * GET /internal/profiles/:id?trace_id=… — Phase 4.5 server-side filter
7
+ *
8
+ * Phase 4 minimal scope: ingest-time pprof parsing is deferred. The
9
+ * receiver writes the blob verbatim to R2 / filesystem and reads
10
+ * trace_ids from an `x-obs-trace-ids` header (comma-separated). The
11
+ * @obsunified/telemetry-sdk profile helper will stamp this; eBPF agents that
12
+ * don't emit the header just don't populate `profile_trace_index`,
13
+ * which means the trace waterfall's 🔥 badge won't fire for those
14
+ * profiles. Aggregate views still work.
15
+ *
16
+ * A follow-on commit can add server-side pprof parsing using a Buf-
17
+ * generated schema or pprof-format. The endpoint shape doesn't change
18
+ * when that lands.
19
+ */
20
+
21
+ import type { CollectorPlugin } from "../framework/collector";
22
+ import {
23
+ decodePprofBlob,
24
+ extractTraceIdsFromProfile,
25
+ filterPprofByTraceId,
26
+ summarizeProfileFrames,
27
+ } from "../lib/parse-pprof";
28
+ import { sqlDbFor } from "../lib/sql-db";
29
+ import { getProjectId } from "./_context";
30
+
31
+ const PROFILE_TYPES = new Set([
32
+ "cpu",
33
+ "heap",
34
+ "wall",
35
+ "block",
36
+ "mutex",
37
+ "goroutine",
38
+ "offcpu",
39
+ ]);
40
+
41
+ // Time-sortable random id for profile blob keys. Not a real ULID — no
42
+ // monotonic clock and we use modulo over crypto bytes for the random
43
+ // suffix — but the format (10-char time prefix + 16-char random) gives
44
+ // us lexicographic ordering by ingest time, which is what the R2 path
45
+ // layout assumes. The "ULID" name was misleading.
46
+ const profileId = (): string => {
47
+ const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
48
+ let timeStr = "";
49
+ let n = Date.now();
50
+ for (let i = 0; i < 10; i++) {
51
+ timeStr = ENCODING[n % 32] + timeStr;
52
+ n = Math.floor(n / 32);
53
+ }
54
+ const bytes = new Uint8Array(16);
55
+ crypto.getRandomValues(bytes);
56
+ let randStr = "";
57
+ for (let i = 0; i < 16; i++) randStr += ENCODING[bytes[i] % 32];
58
+ return timeStr + randStr;
59
+ };
60
+
61
+ // Parse a non-negative integer from an untrusted source. Returns the
62
+ // fallback when the input is missing, malformed, NaN, negative, or
63
+ // non-finite. parseInt happily returns NaN for "foo" and that NaN was
64
+ // being written verbatim to D1 as "Invalid Date" / NaN before this
65
+ // landed.
66
+ const parseNonNegInt = (
67
+ value: string | null | undefined,
68
+ fallback: number,
69
+ ): number => {
70
+ if (value == null) return fallback;
71
+ const parsed = Number.parseInt(value, 10);
72
+ if (!Number.isFinite(parsed) || parsed < 0) return fallback;
73
+ return parsed;
74
+ };
75
+
76
+ const TRACE_ID_RE = /^[0-9a-f]{16,32}$/i;
77
+
78
+ const parseTraceIdsHeader = (header: string | null | undefined): string[] => {
79
+ if (!header) return [];
80
+ return header
81
+ .split(",")
82
+ .map((s) => s.trim().toLowerCase())
83
+ .filter((s) => TRACE_ID_RE.test(s));
84
+ };
85
+
86
+ export const profileRoutesPlugin: CollectorPlugin = {
87
+ name: "profile-routes",
88
+ register(app, runtime) {
89
+ // ── POST /v1/profiles/pprof ─────────────────────────────────────
90
+ app.post("/v1/profiles/pprof", async (c) => {
91
+ const projectId = getProjectId(c);
92
+
93
+ const profileType = (
94
+ c.req.header("x-obs-profile-type") || "cpu"
95
+ ).toLowerCase();
96
+ if (!PROFILE_TYPES.has(profileType)) {
97
+ return c.json({ error: `unknown profile type: ${profileType}` }, 400);
98
+ }
99
+ const serviceName = c.req.header("x-obs-service") || null;
100
+ // RFC 0007 acceptance #2 — ingest-time pprof parsing.
101
+ // The x-obs-trace-ids header is honored as a pre-extraction
102
+ // fast path (clients that already iterated the profile
103
+ // avoid us re-parsing on the worker), but we no longer
104
+ // require it: when absent, parse the blob and read sample
105
+ // labels directly. Closes the Phase 4 header-driven
106
+ // shortcut.
107
+ const headerTraceIds = parseTraceIdsHeader(
108
+ c.req.header("x-obs-trace-ids"),
109
+ );
110
+
111
+ const body = await c.req.arrayBuffer();
112
+ if (body.byteLength === 0) {
113
+ return c.json({ error: "empty body" }, 400);
114
+ }
115
+ if (body.byteLength > 4 * 1024 * 1024) {
116
+ return c.json({ error: "blob too large (max 4 MB)" }, 413);
117
+ }
118
+
119
+ let traceIds: string[];
120
+ let parsedSampleCount: number | null = null;
121
+ if (headerTraceIds.length > 0) {
122
+ // Client pre-extracted trace ids — accept verbatim without
123
+ // re-parsing. Decode failures on the blob itself are reported
124
+ // at read time.
125
+ traceIds = headerTraceIds;
126
+ } else {
127
+ try {
128
+ const profile = await decodePprofBlob(body);
129
+ traceIds = extractTraceIdsFromProfile(profile);
130
+ parsedSampleCount = profile.samples.length;
131
+ } catch (err) {
132
+ // No client-side trace_id header and the blob doesn't
133
+ // decode. Return 422 so the agent learns its emit path
134
+ // is broken — silently accepting corrupt blobs into R2
135
+ // makes ingest debugging impossible.
136
+ runtime.logger.warn(
137
+ "[profile-receiver] pprof parse failed (no header fallback)",
138
+ {
139
+ project_id: projectId,
140
+ size: body.byteLength,
141
+ error: err instanceof Error ? err.message : String(err),
142
+ },
143
+ );
144
+ return c.json(
145
+ {
146
+ error: "pprof decode failed",
147
+ hint: "Set x-obs-trace-ids header to bypass server-side parsing.",
148
+ details: err instanceof Error ? err.message : String(err),
149
+ },
150
+ 422,
151
+ );
152
+ }
153
+ }
154
+
155
+ const id = profileId();
156
+ const now = new Date();
157
+ const nowStr = now.toISOString();
158
+ const dateKey = nowStr.slice(0, 10);
159
+ const objectKey = `profiles/${projectId}/${dateKey}/${id}.pprof.gz`;
160
+
161
+ // Storage dispatch — R2 first, no fallback yet (Workers-only).
162
+ if (!c.env.PROFILES_BUCKET) {
163
+ runtime.logger.error("PROFILES_BUCKET binding is missing", {
164
+ project_id: projectId,
165
+ });
166
+ return c.json({ error: "Profile storage not configured" }, 500);
167
+ }
168
+ await c.env.PROFILES_BUCKET.put(objectKey, body, {
169
+ httpMetadata: { contentType: "application/octet-stream" },
170
+ });
171
+
172
+ // 72-hour default, clamped to a sane upper bound so a misconfigured
173
+ // env var can't push expires_at thousands of years out.
174
+ const RETENTION_MAX_HOURS = 24 * 90;
175
+ const retentionHours = Math.min(
176
+ parseNonNegInt(c.env.RETENTION_HOURS, 72),
177
+ RETENTION_MAX_HOURS,
178
+ );
179
+ const expiresAt = new Date(
180
+ now.getTime() + retentionHours * 60 * 60 * 1000,
181
+ ).toISOString();
182
+
183
+ // Duration / window are optional headers; default to "now".
184
+ // Untrusted client input — parseNonNegInt drops NaN / negative.
185
+ const durationMs = parseNonNegInt(c.req.header("x-obs-duration-ms"), 0);
186
+ const startTs = c.req.header("x-obs-start-ts") || nowStr;
187
+
188
+ const db = sqlDbFor(c.env);
189
+ await db
190
+ .prepare(
191
+ `INSERT INTO profile_blobs (
192
+ id, project_id, service_name, profile_type,
193
+ start_ts, end_ts, duration_ms, blob_size_bytes, blob_url,
194
+ sample_count, agent, resource_attrs_json,
195
+ received_at, expires_at
196
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
197
+ )
198
+ .bind(
199
+ id,
200
+ projectId,
201
+ serviceName,
202
+ profileType,
203
+ startTs,
204
+ nowStr,
205
+ durationMs,
206
+ body.byteLength,
207
+ objectKey,
208
+ parsedSampleCount,
209
+ c.req.header("x-obs-agent") || "unknown",
210
+ null,
211
+ nowStr,
212
+ expiresAt,
213
+ )
214
+ .run();
215
+
216
+ if (traceIds.length > 0) {
217
+ const stmt = db.prepare(
218
+ `INSERT OR IGNORE INTO profile_trace_index (profile_id, trace_id, project_id) VALUES (?, ?, ?)`,
219
+ );
220
+ const batch = traceIds.map((tid) => stmt.bind(id, tid, projectId));
221
+ await db.batch(batch);
222
+ }
223
+
224
+ return c.json(
225
+ {
226
+ accepted: true,
227
+ profileId: id,
228
+ traceIdsIndexed: traceIds.length,
229
+ },
230
+ 202,
231
+ );
232
+ });
233
+
234
+ // ── GET /internal/profiles/:id ─────────────────────────────────
235
+ app.get("/internal/profiles/:id", async (c) => {
236
+ const projectId = getProjectId(c);
237
+ const id = c.req.param("id");
238
+ const db = sqlDbFor(c.env);
239
+
240
+ const meta = await db
241
+ .prepare(
242
+ `SELECT id, service_name, profile_type, start_ts, end_ts,
243
+ duration_ms, blob_size_bytes, blob_url, sample_count,
244
+ agent, received_at, expires_at
245
+ FROM profile_blobs
246
+ WHERE project_id = ? AND id = ?`,
247
+ )
248
+ .bind(projectId, id)
249
+ .first<{
250
+ id: string;
251
+ service_name: string | null;
252
+ profile_type: string;
253
+ start_ts: string;
254
+ end_ts: string;
255
+ duration_ms: number;
256
+ blob_size_bytes: number;
257
+ blob_url: string;
258
+ sample_count: number | null;
259
+ agent: string | null;
260
+ received_at: string;
261
+ expires_at: string;
262
+ }>();
263
+
264
+ if (!meta) {
265
+ return c.json({ error: "Not found" }, 404);
266
+ }
267
+
268
+ // Optional: download the blob inline. Default returns metadata
269
+ // only; the dashboard's flame graph viewer will fetch the blob
270
+ // separately via ?blob=true.
271
+ if (c.req.query("blob") === "true") {
272
+ if (!c.env.PROFILES_BUCKET) {
273
+ return c.json({ error: "Profile storage not configured" }, 500);
274
+ }
275
+ const obj = await c.env.PROFILES_BUCKET.get(meta.blob_url);
276
+ if (!obj) {
277
+ return c.json({ error: "Blob not found in storage" }, 404);
278
+ }
279
+ // RFC 0007 acceptance #6 — server-side pre-filter.
280
+ // When `?trace_id=X` is set alongside `?blob=true`, parse
281
+ // the pprof, retain only samples with the matching label,
282
+ // and re-emit a smaller blob. Saves bandwidth on large
283
+ // JVM profiles where 99% of samples don't belong to the
284
+ // trace the user is drilling into.
285
+ const filterTraceId = c.req.query("trace_id");
286
+ if (filterTraceId) {
287
+ const raw = new Uint8Array(await obj.arrayBuffer());
288
+ try {
289
+ const profile = await decodePprofBlob(raw);
290
+ const filtered = await filterPprofByTraceId(profile, filterTraceId);
291
+ return new Response(filtered as unknown as BodyInit, {
292
+ headers: {
293
+ "Content-Type": "application/octet-stream",
294
+ "Content-Encoding": "gzip",
295
+ "Content-Disposition": `attachment; filename="${id}-trace-${filterTraceId.slice(0, 8)}.pprof.gz"`,
296
+ // Surface the size shrink so the UI can show savings.
297
+ "X-Obs-Filter-Original-Bytes": String(raw.byteLength),
298
+ "X-Obs-Filter-Filtered-Bytes": String(filtered.byteLength),
299
+ },
300
+ });
301
+ } catch (err) {
302
+ runtime.logger.warn(
303
+ "[profile-routes] filter failed; returning unfiltered blob",
304
+ {
305
+ profile_id: id,
306
+ trace_id: filterTraceId,
307
+ error: err instanceof Error ? err.message : String(err),
308
+ },
309
+ );
310
+ // Fall through to unfiltered.
311
+ }
312
+ }
313
+ return new Response(obj.body, {
314
+ headers: {
315
+ "Content-Type": "application/octet-stream",
316
+ "Content-Disposition": `attachment; filename="${id}.pprof.gz"`,
317
+ },
318
+ });
319
+ }
320
+
321
+ // Metadata + trace_id list (no blob). The full server-side
322
+ // pprof filter runs above when `?blob=true&trace_id=…`.
323
+ const trace_id = c.req.query("trace_id");
324
+ const traces = await db
325
+ .prepare(
326
+ trace_id
327
+ ? `SELECT trace_id FROM profile_trace_index WHERE profile_id = ? AND project_id = ? AND trace_id = ?`
328
+ : `SELECT trace_id FROM profile_trace_index WHERE profile_id = ? AND project_id = ? LIMIT 1000`,
329
+ )
330
+ .bind(
331
+ ...(trace_id ? [meta.id, projectId, trace_id] : [meta.id, projectId]),
332
+ )
333
+ .all<{ trace_id: string }>();
334
+ const includeFrames = c.req.query("frames") === "true";
335
+ let frames: ReturnType<typeof summarizeProfileFrames> | undefined;
336
+ let framesError: string | null = null;
337
+ if (includeFrames) {
338
+ if (!c.env.PROFILES_BUCKET) {
339
+ framesError = "Profile storage not configured";
340
+ } else {
341
+ const obj = await c.env.PROFILES_BUCKET.get(meta.blob_url);
342
+ if (!obj) {
343
+ framesError = "Blob not found in storage";
344
+ } else {
345
+ try {
346
+ const raw = new Uint8Array(await obj.arrayBuffer());
347
+ const profile = await decodePprofBlob(raw);
348
+ frames = summarizeProfileFrames(profile, {
349
+ limit: parseNonNegInt(c.req.query("frame_limit"), 50),
350
+ traceIdFilter: trace_id ?? null,
351
+ });
352
+ } catch (err) {
353
+ framesError = err instanceof Error ? err.message : String(err);
354
+ runtime.logger.warn("[profile-routes] frame summary failed", {
355
+ profile_id: id,
356
+ trace_id: trace_id ?? null,
357
+ error: framesError,
358
+ });
359
+ }
360
+ }
361
+ }
362
+ }
363
+
364
+ return c.json({
365
+ profile: {
366
+ id: meta.id,
367
+ serviceName: meta.service_name,
368
+ profileType: meta.profile_type,
369
+ startTs: meta.start_ts,
370
+ endTs: meta.end_ts,
371
+ durationMs: meta.duration_ms,
372
+ blobSizeBytes: meta.blob_size_bytes,
373
+ sampleCount: meta.sample_count,
374
+ agent: meta.agent,
375
+ receivedAt: meta.received_at,
376
+ expiresAt: meta.expires_at,
377
+ },
378
+ traceIds: traces.results.map((r) => r.trace_id),
379
+ traceIdRequested: trace_id ?? null,
380
+ frames: frames ?? [],
381
+ framesError,
382
+ });
383
+ });
384
+
385
+ // ── GET /internal/profiles ─────────────────────────────────────
386
+ // Recent profiles, optionally scoped by trace_id (powers the
387
+ // 🔥 badge on the trace waterfall).
388
+ app.get("/internal/profiles", async (c) => {
389
+ const projectId = getProjectId(c);
390
+ const traceId = c.req.query("trace_id");
391
+ const serviceName = c.req.query("service");
392
+ const db = sqlDbFor(c.env);
393
+
394
+ if (traceId) {
395
+ const rows = await db
396
+ .prepare(
397
+ `SELECT b.id, b.service_name, b.profile_type, b.start_ts, b.end_ts,
398
+ b.duration_ms, b.blob_size_bytes, b.agent
399
+ FROM profile_trace_index i
400
+ JOIN profile_blobs b ON b.id = i.profile_id
401
+ WHERE i.project_id = ? AND i.trace_id = ?
402
+ ORDER BY b.end_ts DESC
403
+ LIMIT 50`,
404
+ )
405
+ .bind(projectId, traceId)
406
+ .all<{
407
+ id: string;
408
+ service_name: string | null;
409
+ profile_type: string;
410
+ start_ts: string;
411
+ end_ts: string;
412
+ duration_ms: number;
413
+ blob_size_bytes: number;
414
+ agent: string | null;
415
+ }>();
416
+ return c.json({
417
+ profiles: rows.results.map((r) => ({
418
+ id: r.id,
419
+ serviceName: r.service_name,
420
+ profileType: r.profile_type,
421
+ startTs: r.start_ts,
422
+ endTs: r.end_ts,
423
+ durationMs: r.duration_ms,
424
+ blobSizeBytes: r.blob_size_bytes,
425
+ agent: r.agent,
426
+ })),
427
+ });
428
+ }
429
+
430
+ let sql = `SELECT id, service_name, profile_type, start_ts, end_ts,
431
+ duration_ms, blob_size_bytes, agent
432
+ FROM profile_blobs WHERE project_id = ?`;
433
+ const binds: unknown[] = [projectId];
434
+ if (serviceName) {
435
+ sql += ` AND service_name = ?`;
436
+ binds.push(serviceName);
437
+ }
438
+ sql += ` ORDER BY end_ts DESC LIMIT 50`;
439
+
440
+ const rows = await db
441
+ .prepare(sql)
442
+ .bind(...binds)
443
+ .all<{
444
+ id: string;
445
+ service_name: string | null;
446
+ profile_type: string;
447
+ start_ts: string;
448
+ end_ts: string;
449
+ duration_ms: number;
450
+ blob_size_bytes: number;
451
+ agent: string | null;
452
+ }>();
453
+
454
+ return c.json({
455
+ profiles: rows.results.map((r) => ({
456
+ id: r.id,
457
+ serviceName: r.service_name,
458
+ profileType: r.profile_type,
459
+ startTs: r.start_ts,
460
+ endTs: r.end_ts,
461
+ durationMs: r.duration_ms,
462
+ blobSizeBytes: r.blob_size_bytes,
463
+ agent: r.agent,
464
+ })),
465
+ });
466
+ });
467
+ },
468
+ };
@@ -0,0 +1,92 @@
1
+ import type { CollectorPlugin } from "../framework/collector";
2
+ import { ProjectsStore } from "../lib/projects-store";
3
+ import { sqlDbFor } from "../lib/sql-db";
4
+
5
+ export const projectsRoutesPlugin: CollectorPlugin = {
6
+ name: "projects-routes",
7
+ register(app) {
8
+ app.get("/internal/projects", async (c) => {
9
+ const store = new ProjectsStore(sqlDbFor(c.env));
10
+ await store.ensureDefaultProject();
11
+ const projects = await store.listProjects();
12
+ return c.json({ projects });
13
+ });
14
+
15
+ app.post("/internal/projects", async (c) => {
16
+ const body = await c.req
17
+ .json<{ name?: string; slug?: string }>()
18
+ .catch(() => ({ name: undefined, slug: undefined }));
19
+ if (!body.name || !body.slug) {
20
+ return c.json({ error: "name and slug are required" }, 400);
21
+ }
22
+ const store = new ProjectsStore(sqlDbFor(c.env));
23
+ try {
24
+ const project = await store.createProject({
25
+ name: body.name,
26
+ slug: body.slug,
27
+ });
28
+ return c.json({ project }, 201);
29
+ } catch (err) {
30
+ const message = err instanceof Error ? err.message : String(err);
31
+ // UNIQUE constraint failures from D1 surface as "D1_ERROR" with a cause.
32
+ if (
33
+ message.includes("UNIQUE") ||
34
+ message.toLowerCase().includes("slug")
35
+ ) {
36
+ return c.json({ error: "Slug already in use or invalid" }, 409);
37
+ }
38
+ return c.json({ error: message }, 400);
39
+ }
40
+ });
41
+
42
+ app.patch("/internal/projects/:id", async (c) => {
43
+ const id = c.req.param("id");
44
+ const body = await c.req
45
+ .json<{ name?: string; payloadCaptureEnabled?: boolean }>()
46
+ .catch(() => ({
47
+ name: undefined,
48
+ payloadCaptureEnabled: undefined,
49
+ }));
50
+ const store = new ProjectsStore(sqlDbFor(c.env));
51
+ const project = await store.updateProject(id, {
52
+ name: body.name,
53
+ payloadCaptureEnabled: body.payloadCaptureEnabled,
54
+ });
55
+ if (!project) return c.json({ error: "Project not found" }, 404);
56
+ return c.json({ project });
57
+ });
58
+
59
+ app.get("/internal/projects/:id/keys", async (c) => {
60
+ const id = c.req.param("id");
61
+ const store = new ProjectsStore(sqlDbFor(c.env));
62
+ const project = await store.getProject(id);
63
+ if (!project) return c.json({ error: "Project not found" }, 404);
64
+ const keys = await store.listKeys(id);
65
+ return c.json({ keys });
66
+ });
67
+
68
+ app.post("/internal/projects/:id/keys", async (c) => {
69
+ const id = c.req.param("id");
70
+ const body = await c.req
71
+ .json<{ name?: string }>()
72
+ .catch(() => ({ name: undefined }));
73
+ const store = new ProjectsStore(sqlDbFor(c.env));
74
+ try {
75
+ const result = await store.createKey(id, body.name ?? "unnamed");
76
+ return c.json(result, 201);
77
+ } catch (err) {
78
+ const message = err instanceof Error ? err.message : String(err);
79
+ return c.json({ error: message }, 400);
80
+ }
81
+ });
82
+
83
+ app.delete("/internal/projects/:id/keys/:keyId", async (c) => {
84
+ const keyId = c.req.param("keyId");
85
+ const store = new ProjectsStore(sqlDbFor(c.env));
86
+ const revoked = await store.revokeKey(keyId);
87
+ if (!revoked)
88
+ return c.json({ error: "Key not found or already revoked" }, 404);
89
+ return c.json({ success: true });
90
+ });
91
+ },
92
+ };