@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,269 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { CollectorEnv } from "../framework/env";
3
+ import { ProjectsStore } from "../lib/projects-store";
4
+ import { type SqlDb, sqlDbFor } from "../lib/sql-db";
5
+
6
+ const SESSION_COOKIE = "obs_session";
7
+ const SESSION_MAX_AGE = 7 * 24 * 60 * 60; // 7 days in seconds
8
+ const PROJECT_CACHE_TTL_MS = 30_000;
9
+
10
+ interface ProjectCacheEntry {
11
+ ids: Set<string>;
12
+ expiresAt: number;
13
+ }
14
+
15
+ let projectCache = new WeakMap<object, ProjectCacheEntry>();
16
+
17
+ async function knownProjectIds(
18
+ dbKey: object,
19
+ db: SqlDb,
20
+ now: number,
21
+ ): Promise<Set<string>> {
22
+ const cached = projectCache.get(dbKey);
23
+ if (cached && cached.expiresAt > now) {
24
+ return cached.ids;
25
+ }
26
+ const store = new ProjectsStore(db);
27
+ const projects = await store.listProjects();
28
+ const ids = new Set(projects.map((p) => p.id));
29
+ projectCache.set(dbKey, { ids, expiresAt: now + PROJECT_CACHE_TTL_MS });
30
+ return ids;
31
+ }
32
+
33
+ /** Test helper — clears the project list cache. */
34
+ export function resetDashboardProjectCache(): void {
35
+ projectCache = new WeakMap();
36
+ }
37
+
38
+ /**
39
+ * Sign a payload using HMAC-SHA256.
40
+ * Works across all runtimes that support Web Crypto API (CF Workers, Node 20+, Deno, Bun).
41
+ */
42
+ async function sign(payload: string, secret: string): Promise<string> {
43
+ const encoder = new TextEncoder();
44
+ const key = await crypto.subtle.importKey(
45
+ "raw",
46
+ encoder.encode(secret),
47
+ { name: "HMAC", hash: "SHA-256" },
48
+ false,
49
+ ["sign"],
50
+ );
51
+ const signature = await crypto.subtle.sign(
52
+ "HMAC",
53
+ key,
54
+ encoder.encode(payload),
55
+ );
56
+ return btoa(String.fromCharCode(...new Uint8Array(signature)));
57
+ }
58
+
59
+ async function verify(
60
+ payload: string,
61
+ signature: string,
62
+ secret: string,
63
+ ): Promise<boolean> {
64
+ const expected = await sign(payload, secret);
65
+ return timingSafeEqualStr(expected, signature);
66
+ }
67
+
68
+ // Constant-time comparison. Both inputs are SHA-256-digested first so the
69
+ // XOR loop runs over fixed-length (32-byte) buffers — this removes the
70
+ // early-exit timing oracle of `===` and any length-based side channel.
71
+ async function timingSafeEqualStr(a: string, b: string): Promise<boolean> {
72
+ const enc = new TextEncoder();
73
+ const [da, db] = await Promise.all([
74
+ crypto.subtle.digest("SHA-256", enc.encode(a)),
75
+ crypto.subtle.digest("SHA-256", enc.encode(b)),
76
+ ]);
77
+ const ha = new Uint8Array(da);
78
+ const hb = new Uint8Array(db);
79
+ let diff = 0;
80
+ for (let i = 0; i < ha.length; i++) diff |= ha[i] ^ hb[i];
81
+ return diff === 0;
82
+ }
83
+
84
+ function createSessionToken(expiresAt: number): string {
85
+ return JSON.stringify({ exp: expiresAt });
86
+ }
87
+
88
+ function parseSessionToken(token: string): { exp: number } | null {
89
+ try {
90
+ const parsed = JSON.parse(token);
91
+ if (typeof parsed.exp === "number") return parsed;
92
+ return null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function getCookie(
99
+ cookieHeader: string | undefined,
100
+ name: string,
101
+ ): string | undefined {
102
+ if (!cookieHeader) return undefined;
103
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
104
+ return match ? decodeURIComponent(match[1]) : undefined;
105
+ }
106
+
107
+ /**
108
+ * Creates auth routes and middleware for dashboard password login.
109
+ *
110
+ * Routes added:
111
+ * POST /auth/login — accepts { password }, sets session cookie
112
+ * GET /auth/check — returns { authenticated: true/false }
113
+ * POST /auth/logout — clears session cookie
114
+ *
115
+ * The returned middleware validates the session cookie on protected routes.
116
+ */
117
+ export function createDashboardAuth(config: { password: string }): {
118
+ middleware: MiddlewareHandler<{
119
+ Bindings: CollectorEnv;
120
+ Variables: { projectId: string };
121
+ }>;
122
+ registerRoutes: (
123
+ app: import("hono").Hono<{ Bindings: CollectorEnv }>,
124
+ ) => void;
125
+ } {
126
+ const middleware: MiddlewareHandler<{
127
+ Bindings: CollectorEnv;
128
+ Variables: { projectId: string };
129
+ }> = async (c, next) => {
130
+ if (!config.password) {
131
+ return c.json(
132
+ {
133
+ error:
134
+ "DASHBOARD_PASSWORD is not configured. Set the DASHBOARD_PASSWORD environment variable.",
135
+ },
136
+ 500,
137
+ );
138
+ }
139
+
140
+ const cookieHeader = c.req.header("Cookie");
141
+ const sessionValue = getCookie(cookieHeader, SESSION_COOKIE);
142
+
143
+ if (!sessionValue) {
144
+ // For API requests, return 401. For page requests, redirect to login.
145
+ const accept = c.req.header("Accept") || "";
146
+ if (accept.includes("text/html")) {
147
+ return c.redirect("/dashboard/login");
148
+ }
149
+ return c.json({ error: "Unauthorized" }, 401);
150
+ }
151
+
152
+ // Session cookie format: payload.signature
153
+ const dotIndex = sessionValue.lastIndexOf(".");
154
+ if (dotIndex === -1) {
155
+ return c.json({ error: "Invalid session" }, 401);
156
+ }
157
+
158
+ const payload = sessionValue.substring(0, dotIndex);
159
+ const sig = sessionValue.substring(dotIndex + 1);
160
+
161
+ const valid = await verify(payload, sig, config.password);
162
+ if (!valid) {
163
+ return c.json({ error: "Invalid session" }, 401);
164
+ }
165
+
166
+ const token = parseSessionToken(payload);
167
+ if (!token || token.exp < Math.floor(Date.now() / 1000)) {
168
+ return c.json({ error: "Session expired" }, 401);
169
+ }
170
+
171
+ // Resolve project for data-scoped routes. /internal/projects/* is exempt
172
+ // because that's where projects are managed (and where the first
173
+ // project is created before the user has selected anything).
174
+ const path = c.req.path;
175
+ const needsProject =
176
+ path.startsWith("/internal/") && !path.startsWith("/internal/projects");
177
+
178
+ if (needsProject) {
179
+ const headerProject = c.req.header("X-Project-Id");
180
+ const projectId = headerProject?.trim() || "default";
181
+ const known = await knownProjectIds(
182
+ c.env.DB,
183
+ sqlDbFor(c.env),
184
+ Date.now(),
185
+ );
186
+ // Always allow 'default' so fresh installs don't 400 before any
187
+ // project is seeded (ensure_default_project runs via migration).
188
+ if (projectId !== "default" && !known.has(projectId)) {
189
+ return c.json({ error: `Unknown project: ${projectId}` }, 400);
190
+ }
191
+ c.set("projectId", projectId);
192
+ }
193
+
194
+ await next();
195
+ };
196
+
197
+ const registerRoutes = (
198
+ app: import("hono").Hono<{ Bindings: CollectorEnv }>,
199
+ ) => {
200
+ app.post("/auth/login", async (c) => {
201
+ if (!config.password) {
202
+ return c.json({ error: "Dashboard password not configured" }, 500);
203
+ }
204
+
205
+ const body = await c.req
206
+ .json<{ password?: string }>()
207
+ .catch(() => ({ password: undefined }));
208
+ if (
209
+ !body.password ||
210
+ !(await timingSafeEqualStr(body.password, config.password))
211
+ ) {
212
+ return c.json({ error: "Invalid password" }, 401);
213
+ }
214
+
215
+ const expiresAt = Math.floor(Date.now() / 1000) + SESSION_MAX_AGE;
216
+ const payload = createSessionToken(expiresAt);
217
+ const sig = await sign(payload, config.password);
218
+ const cookieValue = `${payload}.${sig}`;
219
+
220
+ const isSecure = c.req.url.startsWith("https:");
221
+ c.header(
222
+ "Set-Cookie",
223
+ `${SESSION_COOKIE}=${encodeURIComponent(cookieValue)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE}${isSecure ? "; Secure" : ""}`,
224
+ );
225
+
226
+ return c.json({ success: true });
227
+ });
228
+
229
+ app.get("/auth/check", async (c) => {
230
+ const cookieHeader = c.req.header("Cookie");
231
+ const sessionValue = getCookie(cookieHeader, SESSION_COOKIE);
232
+
233
+ if (!sessionValue) {
234
+ return c.json({ authenticated: false });
235
+ }
236
+
237
+ const dotIndex = sessionValue.lastIndexOf(".");
238
+ if (dotIndex === -1) {
239
+ return c.json({ authenticated: false });
240
+ }
241
+
242
+ const payload = sessionValue.substring(0, dotIndex);
243
+ const sig = sessionValue.substring(dotIndex + 1);
244
+
245
+ const valid = await verify(payload, sig, config.password);
246
+ if (!valid) {
247
+ return c.json({ authenticated: false });
248
+ }
249
+
250
+ const token = parseSessionToken(payload);
251
+ if (!token || token.exp < Math.floor(Date.now() / 1000)) {
252
+ return c.json({ authenticated: false });
253
+ }
254
+
255
+ return c.json({ authenticated: true });
256
+ });
257
+
258
+ app.post("/auth/logout", (c) => {
259
+ const isSecure = c.req.url.startsWith("https:");
260
+ c.header(
261
+ "Set-Cookie",
262
+ `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0${isSecure ? "; Secure" : ""}`,
263
+ );
264
+ return c.json({ success: true });
265
+ });
266
+ };
267
+
268
+ return { middleware, registerRoutes };
269
+ }
@@ -0,0 +1,147 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { CollectorEnv } from "../framework/env";
3
+ import { sha256Hex } from "../lib/hash";
4
+ import { ProjectsStore } from "../lib/projects-store";
5
+ import { sqlDbFor } from "../lib/sql-db";
6
+
7
+ const CACHE_TTL_MS = 60_000; // 60 seconds
8
+ const CACHE_MAX_ENTRIES = 500;
9
+
10
+ interface CacheEntry {
11
+ projectId: string;
12
+ expiresAt: number;
13
+ }
14
+
15
+ let keyCaches = new WeakMap<object, Map<string, CacheEntry>>();
16
+ let bootstrapDone = false;
17
+
18
+ function cacheFor(dbKey: object): Map<string, CacheEntry> {
19
+ let cache = keyCaches.get(dbKey);
20
+ if (!cache) {
21
+ cache = new Map();
22
+ keyCaches.set(dbKey, cache);
23
+ }
24
+ return cache;
25
+ }
26
+
27
+ function getCached(
28
+ cache: Map<string, CacheEntry>,
29
+ hash: string,
30
+ now: number,
31
+ ): string | null {
32
+ pruneKeyCache(cache, now);
33
+ const entry = cache.get(hash);
34
+ if (!entry) return null;
35
+ if (entry.expiresAt <= now) {
36
+ cache.delete(hash);
37
+ return null;
38
+ }
39
+ return entry.projectId;
40
+ }
41
+
42
+ function setCached(
43
+ cache: Map<string, CacheEntry>,
44
+ hash: string,
45
+ projectId: string,
46
+ now: number,
47
+ ): void {
48
+ pruneKeyCache(cache, now);
49
+ while (cache.size >= CACHE_MAX_ENTRIES) {
50
+ const oldestKey = cache.keys().next().value;
51
+ if (!oldestKey) break;
52
+ cache.delete(oldestKey);
53
+ }
54
+ cache.set(hash, { projectId, expiresAt: now + CACHE_TTL_MS });
55
+ }
56
+
57
+ function pruneKeyCache(cache: Map<string, CacheEntry>, now: number): void {
58
+ for (const [hash, entry] of cache) {
59
+ if (entry.expiresAt <= now) cache.delete(hash);
60
+ }
61
+ }
62
+
63
+ /** Test helper — clears the in-memory cache and bootstrap flag. */
64
+ export function resetIngestAuthCache(): void {
65
+ keyCaches = new WeakMap();
66
+ bootstrapDone = false;
67
+ }
68
+
69
+ /**
70
+ * Creates middleware that validates ingest API keys against the ingest_keys table.
71
+ *
72
+ * Accepts the key from either:
73
+ * - Authorization: Bearer <key>
74
+ * - X-API-Key: <key>
75
+ *
76
+ * On success, sets `projectId` on the Hono context via `c.set('projectId', ...)`.
77
+ *
78
+ * On the first request per isolate, if `env.INGEST_KEY` is set, the middleware
79
+ * registers it as a `bootstrap` key on the default project — so existing
80
+ * single-tenant deployments continue to work without manual migration.
81
+ */
82
+ export function createIngestAuth(config?: {
83
+ allowUnauthenticated?: boolean;
84
+ }): MiddlewareHandler<{
85
+ Bindings: CollectorEnv;
86
+ Variables: { projectId: string };
87
+ }> {
88
+ return async (c, next) => {
89
+ // Lazy bootstrap of legacy env key (idempotent).
90
+ if (!bootstrapDone) {
91
+ const legacy = c.env.INGEST_KEY || c.env.TELEMETRY_INGEST_TOKEN;
92
+ if (legacy) {
93
+ const store = new ProjectsStore(sqlDbFor(c.env));
94
+ try {
95
+ await store.bootstrapEnvKey(legacy);
96
+ // Latch only after success: a transient DB error on the first
97
+ // request must not permanently disable legacy-key bootstrap for
98
+ // the isolate. bootstrapEnvKey is idempotent, so concurrent
99
+ // first-requests racing here are harmless.
100
+ bootstrapDone = true;
101
+ } catch (err) {
102
+ console.error("[ingest-auth] bootstrap failed:", err);
103
+ }
104
+ } else {
105
+ // Nothing to bootstrap — don't keep re-checking on every request.
106
+ bootstrapDone = true;
107
+ }
108
+ }
109
+
110
+ const bearer = c.req.header("Authorization")?.replace(/^Bearer\s+/i, "");
111
+ const apiKey = c.req.header("X-API-Key");
112
+ const token = bearer || apiKey;
113
+
114
+ if (!token) {
115
+ if (
116
+ config?.allowUnauthenticated ||
117
+ c.env.ALLOW_UNAUTHENTICATED === "true"
118
+ ) {
119
+ c.set("projectId", "default");
120
+ return next();
121
+ }
122
+ return c.json({ error: "Unauthorized" }, 401);
123
+ }
124
+
125
+ const now = Date.now();
126
+ const hash = await sha256Hex(token);
127
+ const rawDb =
128
+ (c.env.DB as unknown as Record<string, unknown>)?.__unwrapped_db ||
129
+ c.env.DB;
130
+ const cache = cacheFor(rawDb);
131
+ const cached = getCached(cache, hash, now);
132
+ if (cached) {
133
+ c.set("projectId", cached);
134
+ return next();
135
+ }
136
+
137
+ const store = new ProjectsStore(sqlDbFor(c.env));
138
+ const match = await store.findByKeyHash(hash);
139
+ if (!match) {
140
+ return c.json({ error: "Unauthorized" }, 401);
141
+ }
142
+
143
+ setCached(cache, hash, match.projectId, now);
144
+ c.set("projectId", match.projectId);
145
+ return next();
146
+ };
147
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { TailHub } from "./tail-hub";
3
+
4
+ describe("TailHub", () => {
5
+ it("rejects publish payloads with invalid project ids", async () => {
6
+ const hub = new TailHub();
7
+ const res = await hub.fetch(
8
+ new Request("https://hub/publish", {
9
+ method: "POST",
10
+ body: JSON.stringify([
11
+ {
12
+ kind: "span",
13
+ projectId: "../other-project",
14
+ row: { spanId: "s1" },
15
+ t: "2026-05-31T00:00:00.000Z",
16
+ },
17
+ ]),
18
+ }),
19
+ );
20
+
21
+ expect(res.status).toBe(400);
22
+ });
23
+
24
+ it("rejects subscriptions with invalid project ids", async () => {
25
+ const hub = new TailHub();
26
+ const res = await hub.fetch(
27
+ new Request("https://hub/subscribe?projectId=../other-project"),
28
+ );
29
+
30
+ expect(res.status).toBe(400);
31
+ });
32
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * In-memory SSE pub/sub hub for live-tailing spans and logs.
3
+ *
4
+ * Ingest plugins call `/publish` after writing to D1; the dashboard opens an
5
+ * EventSource on `/internal/telemetry/tail` which proxies here via `/subscribe`.
6
+ *
7
+ * One singleton DO instance is used (idFromName("singleton")); it fans out to
8
+ * every connected subscriber, filtering by projectId and kind server-side so
9
+ * clients don't see other tenants' data.
10
+ *
11
+ * Heartbeat frames keep the connection alive through proxies with idle
12
+ * timeouts (Vite dev proxy, Cloudflare edge).
13
+ */
14
+
15
+ export type TailKind = "span" | "log";
16
+
17
+ export interface TailEvent {
18
+ kind: TailKind;
19
+ projectId: string;
20
+ row: Record<string, unknown>;
21
+ t: string;
22
+ }
23
+
24
+ interface Subscriber {
25
+ id: string;
26
+ projectId: string;
27
+ kinds: Set<TailKind>;
28
+ writer: WritableStreamDefaultWriter<Uint8Array>;
29
+ heartbeat: ReturnType<typeof setInterval>;
30
+ }
31
+
32
+ const HEARTBEAT_MS = 20_000;
33
+ const MAX_SUBSCRIBERS = 200;
34
+ const PROJECT_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
35
+
36
+ function isRecord(value: unknown): value is Record<string, unknown> {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
39
+
40
+ function isTailEvent(value: unknown): value is TailEvent {
41
+ if (!isRecord(value)) return false;
42
+ const { kind, projectId, row, t } = value;
43
+ return (
44
+ (kind === "span" || kind === "log") &&
45
+ typeof projectId === "string" &&
46
+ PROJECT_ID_RE.test(projectId) &&
47
+ isRecord(row) &&
48
+ typeof t === "string" &&
49
+ t.length > 0
50
+ );
51
+ }
52
+
53
+ export class TailHub {
54
+ private readonly subscribers = new Map<string, Subscriber>();
55
+ private readonly encoder = new TextEncoder();
56
+
57
+ async fetch(req: Request): Promise<Response> {
58
+ const url = new URL(req.url);
59
+ if (req.method === "POST" && url.pathname === "/publish") {
60
+ let events: TailEvent[];
61
+ try {
62
+ events = (await req.json()) as TailEvent[];
63
+ } catch {
64
+ return new Response(null, { status: 400 });
65
+ }
66
+ if (!Array.isArray(events)) {
67
+ return new Response(null, { status: 400 });
68
+ }
69
+ if (!events.every(isTailEvent)) {
70
+ return new Response(null, { status: 400 });
71
+ }
72
+ await this.broadcast(events);
73
+ return new Response(null, { status: 204 });
74
+ }
75
+ if (req.method === "GET" && url.pathname === "/subscribe") {
76
+ const projectId = url.searchParams.get("projectId") ?? "default";
77
+ if (!PROJECT_ID_RE.test(projectId)) {
78
+ return new Response("bad projectId", { status: 400 });
79
+ }
80
+ const kindsParam = url.searchParams.get("kinds") ?? "span,log";
81
+ const kinds = new Set(
82
+ kindsParam
83
+ .split(",")
84
+ .map((s) => s.trim())
85
+ .filter(Boolean),
86
+ ) as Set<TailKind>;
87
+ if (![...kinds].every((kind) => kind === "span" || kind === "log")) {
88
+ return new Response("bad kinds", { status: 400 });
89
+ }
90
+ return this.subscribe(projectId, kinds, req);
91
+ }
92
+ return new Response("not found", { status: 404 });
93
+ }
94
+
95
+ private subscribe(
96
+ projectId: string,
97
+ kinds: Set<TailKind>,
98
+ req: Request,
99
+ ): Response {
100
+ if (req.signal.aborted) {
101
+ return new Response(null, { status: 499 });
102
+ }
103
+ if (this.subscribers.size >= MAX_SUBSCRIBERS) {
104
+ return new Response("too many subscribers", { status: 429 });
105
+ }
106
+ const { readable, writable } = new TransformStream<
107
+ Uint8Array,
108
+ Uint8Array
109
+ >();
110
+ const writer = writable.getWriter();
111
+ const id = crypto.randomUUID();
112
+
113
+ writer.write(this.encoder.encode(`: connected\n\n`)).catch(() => {});
114
+
115
+ const heartbeat = setInterval(() => {
116
+ writer
117
+ .write(this.encoder.encode(`: heartbeat\n\n`))
118
+ .catch(() => this.drop(id));
119
+ }, HEARTBEAT_MS);
120
+
121
+ req.signal.addEventListener("abort", () => this.drop(id));
122
+ this.subscribers.set(id, { id, projectId, kinds, writer, heartbeat });
123
+
124
+ return new Response(readable, {
125
+ headers: {
126
+ "content-type": "text/event-stream",
127
+ "cache-control": "no-cache",
128
+ "x-accel-buffering": "no",
129
+ },
130
+ });
131
+ }
132
+
133
+ private drop(id: string): void {
134
+ const sub = this.subscribers.get(id);
135
+ if (!sub) return;
136
+ clearInterval(sub.heartbeat);
137
+ sub.writer.close().catch(() => {});
138
+ this.subscribers.delete(id);
139
+ }
140
+
141
+ private async broadcast(events: TailEvent[]): Promise<void> {
142
+ if (events.length === 0 || this.subscribers.size === 0) return;
143
+ for (const sub of this.subscribers.values()) {
144
+ const payload = events.filter(
145
+ (e) => e.projectId === sub.projectId && sub.kinds.has(e.kind),
146
+ );
147
+ if (payload.length === 0) continue;
148
+ const frame = this.encoder.encode(
149
+ `event: tail\ndata: ${JSON.stringify(payload)}\n\n`,
150
+ );
151
+ try {
152
+ await sub.writer.write(frame);
153
+ } catch {
154
+ this.drop(sub.id);
155
+ }
156
+ }
157
+ }
158
+ }