@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,220 @@
1
+ /** Union: A's env-based retention + D's search param + D's export endpoint */
2
+ import {
3
+ DEFAULT_WINDOW_HOURS,
4
+ getConfiguredRetentionHours,
5
+ } from "@obsunified/types/constants";
6
+ import type { CollectorPlugin } from "../framework/collector";
7
+ import { getProjectId } from "./_context";
8
+
9
+ export const queryRoutesPlugin: CollectorPlugin = {
10
+ name: "query-routes",
11
+ register(app, runtime) {
12
+ app.get("/internal/telemetry/overview", async (c) => {
13
+ const projectId = getProjectId(c);
14
+ const maxHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
15
+ const hours = Math.min(
16
+ maxHours,
17
+ Math.max(
18
+ 1,
19
+ Number.parseInt(
20
+ c.req.query("hours") || String(DEFAULT_WINDOW_HOURS),
21
+ 10,
22
+ ) || DEFAULT_WINDOW_HOURS,
23
+ ),
24
+ );
25
+ const service = c.req.query("service") || undefined;
26
+ const statusParam = c.req.query("status");
27
+ const status =
28
+ statusParam === "ok" || statusParam === "error" ? statusParam : "all";
29
+ const limit = Number.parseInt(c.req.query("limit") || "30", 10) || 30;
30
+ const search = c.req.query("q")?.trim() || undefined;
31
+
32
+ const store = runtime.createStore(c.env);
33
+ const overview = await store.getOverview({
34
+ projectId,
35
+ hours,
36
+ service,
37
+ status,
38
+ limit,
39
+ search,
40
+ });
41
+ return c.json({
42
+ ...overview,
43
+ plugins: runtime.getRegisteredPluginNames(),
44
+ });
45
+ });
46
+
47
+ app.get("/internal/telemetry/service-map", async (c) => {
48
+ const projectId = getProjectId(c);
49
+ const maxHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
50
+ const hours = Math.min(
51
+ maxHours,
52
+ Math.max(
53
+ 1,
54
+ Number.parseInt(
55
+ c.req.query("hours") || String(DEFAULT_WINDOW_HOURS),
56
+ 10,
57
+ ) || DEFAULT_WINDOW_HOURS,
58
+ ),
59
+ );
60
+ const store = runtime.createStore(c.env);
61
+ // RFC 0009 — `?source=sdk|ebpf|all` toggles which spans
62
+ // contribute. Default is `all` so existing dashboards see
63
+ // every edge. The dashboard's service-map view exposes the
64
+ // filter as a UI toggle.
65
+ const sourceParam = c.req.query("source");
66
+ const source: "all" | "sdk" | "ebpf" =
67
+ sourceParam === "sdk" || sourceParam === "ebpf" ? sourceParam : "all";
68
+ const map = await store.getServiceMap({ projectId, hours, source });
69
+ return c.json({
70
+ ...map,
71
+ windowHours: hours,
72
+ source,
73
+ timestamp: new Date().toISOString(),
74
+ });
75
+ });
76
+
77
+ app.get("/internal/telemetry/services/:service/operations", async (c) => {
78
+ const projectId = getProjectId(c);
79
+ const maxHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
80
+ const hours = Math.min(
81
+ maxHours,
82
+ Math.max(
83
+ 1,
84
+ Number.parseInt(
85
+ c.req.query("hours") || String(DEFAULT_WINDOW_HOURS),
86
+ 10,
87
+ ) || DEFAULT_WINDOW_HOURS,
88
+ ),
89
+ );
90
+ const service = c.req.param("service");
91
+ if (!service) {
92
+ return c.json(
93
+ { error: "Bad Request", message: "service is required" },
94
+ 400,
95
+ );
96
+ }
97
+ const store = runtime.createStore(c.env);
98
+ const result = await store.getServiceOperations({
99
+ projectId,
100
+ service,
101
+ hours,
102
+ });
103
+ return c.json({
104
+ ...result,
105
+ windowHours: hours,
106
+ timestamp: new Date().toISOString(),
107
+ });
108
+ });
109
+
110
+ app.get("/internal/telemetry/traces/:traceId", async (c) => {
111
+ const projectId = getProjectId(c);
112
+ const traceId = c.req.param("traceId");
113
+ const store = runtime.createStore(c.env);
114
+ // A `query.trace_detail` span = a trace was viewed. Counting these
115
+ // against `traces.ingest`'s trace_count gives the read/write ratio
116
+ // (Q3); span duration captures end-to-end read latency (Q1).
117
+ const detail = await runtime.withChildSpan(
118
+ "query.trace_detail",
119
+ async (span) => {
120
+ span.setAttribute("project.id", projectId);
121
+ span.setAttribute("trace.id", traceId);
122
+ const result = await store.getTraceDetail(traceId, projectId);
123
+ span.setAttribute("query.found", result !== null);
124
+ span.setAttribute("query.span_count", result?.spans.length ?? 0);
125
+ return result;
126
+ },
127
+ );
128
+ if (!detail)
129
+ return c.json({ error: "Not Found", message: "Trace not found" }, 404);
130
+ return c.json({ ...detail, plugins: runtime.getRegisteredPluginNames() });
131
+ });
132
+
133
+ app.get("/internal/telemetry/traces/:traceId/gaps", async (c) => {
134
+ const projectId = getProjectId(c);
135
+ const traceId = c.req.param("traceId");
136
+ const store = runtime.createStore(c.env);
137
+ // `query.trace_gaps` span duration is the read-time gap computation
138
+ // cost end-to-end (project-scoped span SELECT + compute) — the
139
+ // DB-inclusive half of Q1 that the pure-compute benchmark omits.
140
+ const gaps = await runtime.withChildSpan(
141
+ "query.trace_gaps",
142
+ async (span) => {
143
+ span.setAttribute("project.id", projectId);
144
+ span.setAttribute("trace.id", traceId);
145
+ const result = await store.getTraceGaps(traceId, projectId);
146
+ span.setAttribute("query.found", result !== null);
147
+ span.setAttribute(
148
+ "query.blindspot_count",
149
+ result?.blindspots.length ?? 0,
150
+ );
151
+ return result;
152
+ },
153
+ );
154
+ if (!gaps)
155
+ return c.json(
156
+ { error: "Not Found", message: "Trace gaps not found" },
157
+ 404,
158
+ );
159
+ return c.json(gaps);
160
+ });
161
+
162
+ app.get(
163
+ "/internal/telemetry/instrumentation-gaps/calibration",
164
+ async (c) => {
165
+ const projectId = getProjectId(c);
166
+ const maxHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
167
+ const hours = Math.min(
168
+ maxHours,
169
+ Math.max(1, Number.parseInt(c.req.query("hours") || "72", 10) || 72),
170
+ );
171
+ const limit =
172
+ Number.parseInt(c.req.query("limit") || "5000", 10) || 5000;
173
+ const store = runtime.createStore(c.env);
174
+ const calibration = await store.calibrateTraceGaps({
175
+ projectId,
176
+ hours,
177
+ limit,
178
+ });
179
+ return c.json(calibration);
180
+ },
181
+ );
182
+
183
+ // NDJSON export (from D)
184
+ app.get("/internal/telemetry/export", async (c) => {
185
+ const projectId = getProjectId(c);
186
+ const maxHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
187
+ const hours = Math.min(
188
+ maxHours,
189
+ Math.max(
190
+ 1,
191
+ Number.parseInt(
192
+ c.req.query("hours") || String(DEFAULT_WINDOW_HOURS),
193
+ 10,
194
+ ) || DEFAULT_WINDOW_HOURS,
195
+ ),
196
+ );
197
+ const service = c.req.query("service") || undefined;
198
+ const statusParam = c.req.query("status");
199
+ const status =
200
+ statusParam === "ok" || statusParam === "error" ? statusParam : "all";
201
+ const search = c.req.query("q")?.trim() || undefined;
202
+
203
+ const store = runtime.createStore(c.env);
204
+ const ndjson = await store.getExportRows({
205
+ projectId,
206
+ hours,
207
+ service,
208
+ status,
209
+ search,
210
+ });
211
+
212
+ return new Response(ndjson, {
213
+ headers: {
214
+ "content-type": "application/x-ndjson; charset=utf-8",
215
+ "content-disposition": `attachment; filename="telemetry-export-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.jsonl"`,
216
+ },
217
+ });
218
+ });
219
+ },
220
+ };
@@ -0,0 +1,132 @@
1
+ import type { JsonValue, StoredSpan } from "@obsunified/types";
2
+ import type { CollectorPlugin } from "../framework/collector";
3
+ import { parseJsonValue } from "../lib/json";
4
+
5
+ const DEFAULT_REDACT_FIELDS = [
6
+ "authorization",
7
+ "cookie",
8
+ "set-cookie",
9
+ "password",
10
+ "passwd",
11
+ "secret",
12
+ "token",
13
+ "api-key",
14
+ "x-api-key",
15
+ "email",
16
+ "enduser.id",
17
+ ];
18
+
19
+ const REDACTED_VALUE = "[REDACTED]";
20
+
21
+ const toRedactionFieldSet = (extraFields?: string): Set<string> => {
22
+ const customFields = extraFields
23
+ ? extraFields
24
+ .split(",")
25
+ .map((field) => field.trim().toLowerCase())
26
+ .filter(Boolean)
27
+ : [];
28
+ return new Set([...DEFAULT_REDACT_FIELDS, ...customFields]);
29
+ };
30
+
31
+ const shouldRedactKey = (key: string, fields: Set<string>): boolean => {
32
+ const normalizedKey = key.toLowerCase();
33
+ if (fields.has(normalizedKey)) return true;
34
+ for (const field of fields) {
35
+ if (normalizedKey.endsWith(field)) return true;
36
+ }
37
+ return false;
38
+ };
39
+
40
+ const redactJsonValue = (
41
+ value: JsonValue,
42
+ fields: Set<string>,
43
+ ): { nextValue: JsonValue; redactionCount: number } => {
44
+ if (Array.isArray(value)) {
45
+ let redactionCount = 0;
46
+ const nextValue = value.map((item) => {
47
+ const result = redactJsonValue(item, fields);
48
+ redactionCount += result.redactionCount;
49
+ return result.nextValue;
50
+ });
51
+ return { nextValue, redactionCount };
52
+ }
53
+
54
+ if (value && typeof value === "object") {
55
+ let redactionCount = 0;
56
+ const nextEntries = Object.entries(value).map(([key, nestedValue]) => {
57
+ if (shouldRedactKey(key, fields)) {
58
+ redactionCount += 1;
59
+ return [key, REDACTED_VALUE] as const;
60
+ }
61
+ const result = redactJsonValue(nestedValue, fields);
62
+ redactionCount += result.redactionCount;
63
+ return [key, result.nextValue] as const;
64
+ });
65
+ return { nextValue: Object.fromEntries(nextEntries), redactionCount };
66
+ }
67
+
68
+ return { nextValue: value, redactionCount: 0 };
69
+ };
70
+
71
+ const redactJsonString = (
72
+ value: string,
73
+ fields: Set<string>,
74
+ ): { serialized: string; redactionCount: number } => {
75
+ const parsed = parseJsonValue(value);
76
+ const redacted = redactJsonValue(parsed, fields);
77
+ return {
78
+ serialized: JSON.stringify(redacted.nextValue),
79
+ redactionCount: redacted.redactionCount,
80
+ };
81
+ };
82
+
83
+ export const redactionProcessorPlugin: CollectorPlugin = {
84
+ name: "redaction-processor",
85
+ register(_app, runtime) {
86
+ runtime.addSpanProcessor({
87
+ name: "redaction-processor",
88
+ process(spans, context) {
89
+ const redactFields = toRedactionFieldSet(
90
+ context.env.TELEMETRY_REDACT_FIELDS,
91
+ );
92
+ return spans.map((span): StoredSpan => {
93
+ const attributes = redactJsonString(
94
+ span.attributesJson,
95
+ redactFields,
96
+ );
97
+ const resourceAttributes = redactJsonString(
98
+ span.resourceAttributesJson,
99
+ redactFields,
100
+ );
101
+ const events = redactJsonString(span.eventsJson, redactFields);
102
+ const links = redactJsonString(span.linksJson, redactFields);
103
+
104
+ const totalRedactions =
105
+ attributes.redactionCount +
106
+ resourceAttributes.redactionCount +
107
+ events.redactionCount +
108
+ links.redactionCount;
109
+
110
+ const normalizedAttributes = parseJsonValue(attributes.serialized);
111
+ const nextAttributes =
112
+ normalizedAttributes &&
113
+ typeof normalizedAttributes === "object" &&
114
+ !Array.isArray(normalizedAttributes)
115
+ ? {
116
+ ...normalizedAttributes,
117
+ "collector.redaction.count": totalRedactions,
118
+ }
119
+ : normalizedAttributes;
120
+
121
+ return {
122
+ ...span,
123
+ attributesJson: JSON.stringify(nextAttributes),
124
+ resourceAttributesJson: resourceAttributes.serialized,
125
+ eventsJson: events.serialized,
126
+ linksJson: links.serialized,
127
+ };
128
+ });
129
+ },
130
+ });
131
+ },
132
+ };
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fetchReplayChunks } from "./replay-query-routes";
3
+
4
+ describe("fetchReplayChunks", () => {
5
+ it("fetches replay chunk objects with bounded concurrency and preserves order", async () => {
6
+ let active = 0;
7
+ let maxActive = 0;
8
+ const bucket = {
9
+ async get(key: string) {
10
+ active += 1;
11
+ maxActive = Math.max(maxActive, active);
12
+ await new Promise((resolve) => setTimeout(resolve, 5));
13
+ active -= 1;
14
+ return {
15
+ async json<T>() {
16
+ return [{ key }] as T;
17
+ },
18
+ };
19
+ },
20
+ };
21
+
22
+ const events = await fetchReplayChunks(
23
+ bucket,
24
+ Array.from({ length: 6 }, (_, i) => ({ key: `chunk-${i}` })),
25
+ 2,
26
+ );
27
+
28
+ expect(maxActive).toBeLessThanOrEqual(2);
29
+ expect(events.map((event) => event.key)).toEqual([
30
+ "chunk-0",
31
+ "chunk-1",
32
+ "chunk-2",
33
+ "chunk-3",
34
+ "chunk-4",
35
+ "chunk-5",
36
+ ]);
37
+ });
38
+ });
@@ -0,0 +1,199 @@
1
+ import type { SessionReplayMetadataRow } from "@obsunified/types";
2
+ import type { CollectorPlugin } from "../framework/collector";
3
+ import { sqlDbFor } from "../lib/sql-db";
4
+ import { getProjectId } from "./_context";
5
+
6
+ const DEFAULT_CHUNK_LIMIT = 100;
7
+ const MAX_CHUNK_LIMIT = 500;
8
+ const REPLAY_CHUNK_FETCH_CONCURRENCY = 8;
9
+
10
+ interface ReplayChunkObject {
11
+ key: string;
12
+ }
13
+
14
+ interface ReplayChunkBody {
15
+ json<T>(): Promise<T>;
16
+ }
17
+
18
+ interface ReplayChunkBucket {
19
+ get(key: string): Promise<ReplayChunkBody | null>;
20
+ }
21
+
22
+ async function mapWithConcurrency<T, R>(
23
+ items: T[],
24
+ concurrency: number,
25
+ mapper: (item: T) => Promise<R>,
26
+ ): Promise<R[]> {
27
+ const results: R[] = new Array(items.length);
28
+ let nextIndex = 0;
29
+ const workerCount = Math.min(Math.max(1, concurrency), items.length);
30
+ await Promise.all(
31
+ Array.from({ length: workerCount }, async () => {
32
+ while (nextIndex < items.length) {
33
+ const current = nextIndex++;
34
+ results[current] = await mapper(items[current]);
35
+ }
36
+ }),
37
+ );
38
+ return results;
39
+ }
40
+
41
+ export async function fetchReplayChunks(
42
+ bucket: ReplayChunkBucket,
43
+ objects: ReplayChunkObject[],
44
+ concurrency = REPLAY_CHUNK_FETCH_CONCURRENCY,
45
+ ): Promise<Record<string, unknown>[]> {
46
+ const chunks = await mapWithConcurrency(objects, concurrency, async (obj) => {
47
+ const objectData = await bucket.get(obj.key);
48
+ if (!objectData) return [];
49
+ return objectData.json<Record<string, unknown>[]>();
50
+ });
51
+ return chunks.flat();
52
+ }
53
+
54
+ export const replayQueryRoutesPlugin: CollectorPlugin = {
55
+ name: "replay-query-routes",
56
+ register(app) {
57
+ app.get("/internal/replays", async (c) => {
58
+ const projectId = getProjectId(c);
59
+ const limit = Math.max(
60
+ 1,
61
+ Math.min(500, parseInt(c.req.query("limit") ?? "50", 10) || 50),
62
+ );
63
+ const { results } = await sqlDbFor(c.env)
64
+ .prepare(
65
+ `SELECT
66
+ r.*,
67
+ (SELECT page_path FROM usage_events e WHERE e.project_id = r.project_id AND e.session_id = r.session_id AND e.event_type = 'page_view' ORDER BY e.occurred_at ASC LIMIT 1) as starting_link
68
+ FROM session_replay_metadata r
69
+ WHERE r.project_id = ?
70
+ ORDER BY r.last_chunk_at DESC
71
+ LIMIT ?`,
72
+ )
73
+ .bind(projectId, limit)
74
+ .all();
75
+
76
+ return c.json({ replays: results });
77
+ });
78
+
79
+ app.get("/internal/replays/:sessionId", async (c) => {
80
+ const projectId = getProjectId(c);
81
+ const sessionId = c.req.param("sessionId");
82
+
83
+ if (!c.env.REPLAYS_BUCKET) {
84
+ return c.json({ error: "Replay storage not configured" }, 500);
85
+ }
86
+
87
+ const metadata = await sqlDbFor(c.env)
88
+ .prepare(
89
+ `SELECT * FROM session_replay_metadata WHERE project_id = ? AND session_id = ?`,
90
+ )
91
+ .bind(projectId, sessionId)
92
+ .first<SessionReplayMetadataRow>();
93
+
94
+ if (!metadata) {
95
+ return c.json({ error: "Session replay not found" }, 404);
96
+ }
97
+
98
+ const prefix = `replays/${projectId}/${sessionId}/`;
99
+ const objects: Array<{ key: string }> = [];
100
+ let cursor: string | undefined;
101
+ do {
102
+ const page = await c.env.REPLAYS_BUCKET.list({ prefix, cursor });
103
+ objects.push(...page.objects);
104
+ cursor = page.truncated ? page.cursor : undefined;
105
+ } while (cursor);
106
+
107
+ if (objects.length === 0) {
108
+ return c.json({ error: "Replay chunks missing in storage" }, 404);
109
+ }
110
+
111
+ objects.sort((a, b) => a.key.localeCompare(b.key));
112
+
113
+ const chunkOffset = Math.max(
114
+ 0,
115
+ parseInt(c.req.query("chunkOffset") ?? "0", 10) || 0,
116
+ );
117
+ const chunkLimit = Math.max(
118
+ 1,
119
+ Math.min(
120
+ MAX_CHUNK_LIMIT,
121
+ parseInt(
122
+ c.req.query("chunkLimit") ?? String(DEFAULT_CHUNK_LIMIT),
123
+ 10,
124
+ ) || DEFAULT_CHUNK_LIMIT,
125
+ ),
126
+ );
127
+ const selectedObjects = objects.slice(
128
+ chunkOffset,
129
+ chunkOffset + chunkLimit,
130
+ );
131
+ const events = await fetchReplayChunks(
132
+ c.env.REPLAYS_BUCKET,
133
+ selectedObjects,
134
+ );
135
+ const nextChunkOffset =
136
+ chunkOffset + chunkLimit < objects.length
137
+ ? chunkOffset + chunkLimit
138
+ : null;
139
+
140
+ return c.json({
141
+ metadata,
142
+ events,
143
+ chunks: {
144
+ offset: chunkOffset,
145
+ limit: chunkLimit,
146
+ returned: selectedObjects.length,
147
+ total: objects.length,
148
+ nextChunkOffset,
149
+ },
150
+ });
151
+ });
152
+
153
+ app.delete("/internal/replays/:sessionId", async (c) => {
154
+ const projectId = getProjectId(c);
155
+ const sessionId = c.req.param("sessionId");
156
+
157
+ if (!c.env.REPLAYS_BUCKET) {
158
+ return c.json({ error: "Replay storage not configured" }, 500);
159
+ }
160
+
161
+ // 1. Check if it exists
162
+ const metadata = await sqlDbFor(c.env)
163
+ .prepare(
164
+ `SELECT * FROM session_replay_metadata WHERE project_id = ? AND session_id = ?`,
165
+ )
166
+ .bind(projectId, sessionId)
167
+ .first<SessionReplayMetadataRow>();
168
+
169
+ if (!metadata) {
170
+ return c.json({ error: "Session replay not found" }, 404);
171
+ }
172
+
173
+ // 2. Delete all chunks from bucket
174
+ const bucket = c.env.REPLAYS_BUCKET;
175
+ if (!bucket) {
176
+ return c.json({ error: "Replay storage not configured" }, 500);
177
+ }
178
+ const prefix = `replays/${projectId}/${sessionId}/`;
179
+ let cursor: string | undefined;
180
+ do {
181
+ const page = await bucket.list({ prefix, cursor });
182
+ if (page.objects.length > 0) {
183
+ await Promise.all(page.objects.map((obj) => bucket.delete(obj.key)));
184
+ }
185
+ cursor = page.truncated ? page.cursor : undefined;
186
+ } while (cursor);
187
+
188
+ // 3. Delete metadata from DB
189
+ await sqlDbFor(c.env)
190
+ .prepare(
191
+ `DELETE FROM session_replay_metadata WHERE project_id = ? AND session_id = ?`,
192
+ )
193
+ .bind(projectId, sessionId)
194
+ .run();
195
+
196
+ return c.json({ success: true });
197
+ });
198
+ },
199
+ };
@@ -0,0 +1,81 @@
1
+ import type { ReplayChunkInput } from "@obsunified/types";
2
+ import type { CollectorPlugin } from "../framework/collector";
3
+ import { sqlDbFor } from "../lib/sql-db";
4
+ import { getProjectId } from "./_context";
5
+
6
+ export const replayReceiverPlugin: CollectorPlugin = {
7
+ name: "replay-receiver",
8
+ register(app, runtime) {
9
+ app.post("/v1/replays", async (c) => {
10
+ const projectId = getProjectId(c);
11
+ let payload: ReplayChunkInput;
12
+ try {
13
+ payload = await c.req.json<ReplayChunkInput>();
14
+ } catch {
15
+ return c.json({ error: "Invalid JSON body" }, 400);
16
+ }
17
+ if (
18
+ !isSafeId(payload.sessionId) ||
19
+ (payload.visitorId !== undefined && !isSafeId(payload.visitorId)) ||
20
+ !Number.isInteger(payload.sequenceNumber) ||
21
+ payload.sequenceNumber < 0 ||
22
+ !Array.isArray(payload.events)
23
+ ) {
24
+ return c.json({ error: "Invalid replay payload" }, 400);
25
+ }
26
+ const now = new Date().toISOString();
27
+
28
+ const timestamp = Date.now();
29
+ const objectKey = `replays/${projectId}/${payload.sessionId}/${timestamp}-${payload.sequenceNumber}.json`;
30
+
31
+ if (!c.env.REPLAYS_BUCKET) {
32
+ runtime.logger.error("REPLAYS_BUCKET binding is missing", {
33
+ project_id: projectId,
34
+ });
35
+ return c.json({ error: "Replay storage not configured" }, 500);
36
+ }
37
+
38
+ const payloadString = JSON.stringify(payload.events);
39
+ const chunkBytes = new TextEncoder().encode(payloadString).length;
40
+
41
+ await c.env.REPLAYS_BUCKET.put(objectKey, payloadString, {
42
+ httpMetadata: { contentType: "application/json" },
43
+ });
44
+
45
+ await sqlDbFor(c.env)
46
+ .prepare(
47
+ `INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
48
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
49
+ ON CONFLICT(session_id) DO UPDATE SET
50
+ last_chunk_at = excluded.last_chunk_at,
51
+ chunk_count = session_replay_metadata.chunk_count + 1,
52
+ events_count = session_replay_metadata.events_count + excluded.events_count,
53
+ storage_bytes = session_replay_metadata.storage_bytes + excluded.storage_bytes
54
+ `,
55
+ )
56
+ .bind(
57
+ projectId,
58
+ payload.sessionId,
59
+ payload.visitorId,
60
+ now,
61
+ now,
62
+ 1,
63
+ payload.events.length,
64
+ chunkBytes,
65
+ )
66
+ .run();
67
+
68
+ return c.json({ success: true, chunkKey: objectKey });
69
+ });
70
+ },
71
+ };
72
+
73
+ function isSafeId(value: unknown): value is string {
74
+ return (
75
+ typeof value === "string" &&
76
+ value.length > 0 &&
77
+ value.length <= 160 &&
78
+ /^[A-Za-z0-9._:-]+$/.test(value) &&
79
+ !value.includes("..")
80
+ );
81
+ }