@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,47 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it } from "vitest";
3
+ import { CollectorRuntime } from "../framework/collector";
4
+ import type { CollectorEnv } from "../framework/env";
5
+ import { tailRoutesPlugin } from "./tail-routes";
6
+
7
+ describe("tailRoutesPlugin", () => {
8
+ it("forwards the authenticated project id instead of trusting query params", async () => {
9
+ let forwardedUrl: string | null = null;
10
+ const app = new Hono<{
11
+ Bindings: CollectorEnv;
12
+ Variables: { projectId: string };
13
+ }>();
14
+ app.use("/internal/*", async (c, next) => {
15
+ c.set("projectId", "authorized-project");
16
+ await next();
17
+ });
18
+ tailRoutesPlugin.register(
19
+ app as unknown as Hono<{ Bindings: CollectorEnv }>,
20
+ new CollectorRuntime(),
21
+ );
22
+
23
+ const env = {
24
+ TAIL_HUB: {
25
+ idFromName: (name: string) => name,
26
+ get: () => ({
27
+ fetch: async (url: string) => {
28
+ forwardedUrl = url;
29
+ return new Response("ok", { status: 200 });
30
+ },
31
+ }),
32
+ },
33
+ } as unknown as CollectorEnv;
34
+
35
+ const res = await app.request(
36
+ "/internal/telemetry/tail?projectId=attacker-project&kinds=span",
37
+ { method: "GET" },
38
+ env,
39
+ );
40
+
41
+ expect(res.status).toBe(200);
42
+ expect(forwardedUrl).not.toBeNull();
43
+ const url = new URL(forwardedUrl ?? "");
44
+ expect(url.searchParams.get("projectId")).toBe("authorized-project");
45
+ expect(url.searchParams.get("kinds")).toBe("span");
46
+ });
47
+ });
@@ -0,0 +1,42 @@
1
+ import type { CollectorPlugin } from "../framework/collector";
2
+ import { getProjectId } from "./_context";
3
+
4
+ /**
5
+ * Live tail SSE endpoint. The dashboard opens an EventSource here; the
6
+ * collector proxies to the TailHub Durable Object which holds the open
7
+ * stream and fans out broadcasts from ingest.
8
+ */
9
+ export const tailRoutesPlugin: CollectorPlugin = {
10
+ name: "tail-routes",
11
+ register(app) {
12
+ app.get("/internal/telemetry/tail", (c) => {
13
+ const hub = c.env.TAIL_HUB;
14
+ if (!hub) {
15
+ return c.json(
16
+ { error: "Live tail not configured (TAIL_HUB binding missing)" },
17
+ 503,
18
+ );
19
+ }
20
+ // Project scope must come from auth middleware, never from the client
21
+ // URL. The dashboard live-tail client uses a fetch stream so it can
22
+ // send the same X-Project-Id header as the rest of the API.
23
+ const projectId = getProjectId(c);
24
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(projectId)) {
25
+ return c.json({ error: "invalid projectId" }, 400);
26
+ }
27
+ const kinds = c.req.query("kinds") || "span,log";
28
+
29
+ const id = hub.idFromName("singleton");
30
+ const stub = hub.get(id);
31
+
32
+ const subscribeUrl = new URL("https://hub/subscribe");
33
+ subscribeUrl.searchParams.set("projectId", projectId);
34
+ subscribeUrl.searchParams.set("kinds", kinds);
35
+
36
+ return stub.fetch(subscribeUrl.toString(), {
37
+ method: "GET",
38
+ signal: c.req.raw.signal,
39
+ });
40
+ });
41
+ },
42
+ };
@@ -0,0 +1,244 @@
1
+ import type { CollectorPlugin } from "../framework/collector";
2
+ import { IdentityIndex } from "../lib/identity-index";
3
+ import { sqlDbFor } from "../lib/sql-db";
4
+ import { getProjectId } from "./_context";
5
+
6
+ export interface TimelineEvent {
7
+ t: string;
8
+ kind: "span" | "log" | "usage";
9
+ id: string;
10
+ title: string;
11
+ subtitle?: string;
12
+ severity?: "info" | "warn" | "error";
13
+ durationMs?: number;
14
+ /**
15
+ * RFC 0004 — click-scoped correlation key. Present on every event that
16
+ * was emitted while an interaction was active. The {@link TimelineResponse.groups}
17
+ * field groups events by this id so the dashboard can render
18
+ * "click → traces it caused" without a second round-trip.
19
+ */
20
+ interactionId?: string;
21
+ payload: Record<string, unknown>;
22
+ }
23
+
24
+ export interface TimelineReplayMetadata {
25
+ firstChunkAt: string;
26
+ lastChunkAt: string;
27
+ chunkCount: number;
28
+ eventsCount: number;
29
+ }
30
+
31
+ /**
32
+ * RFC 0004 — a click and the traces it caused, bundled. The dashboard's
33
+ * session timeline collapses an interaction's events under its
34
+ * originating click; this is the precomputed shape the dashboard reads.
35
+ */
36
+ export interface TimelineGroup {
37
+ interactionId: string;
38
+ /** The originating user-originated event (usage event, kind="interaction" or "page_view"). */
39
+ clickEvent: TimelineEvent | null;
40
+ /** Root spans (parent_span_id IS NULL) carrying this interaction_id. */
41
+ causedTraces: Array<{
42
+ traceId: string;
43
+ rootSpanId: string;
44
+ rootSpanName: string;
45
+ serviceName: string | null;
46
+ durationMs: number;
47
+ status: "ok" | "error";
48
+ }>;
49
+ /** Logs + non-click usage events sharing this interaction_id. */
50
+ relatedEvents: TimelineEvent[];
51
+ }
52
+
53
+ export interface TimelineResponse {
54
+ sessionId: string;
55
+ firstSeen: string | null;
56
+ lastSeen: string | null;
57
+ counts: {
58
+ spans: number;
59
+ logs: number;
60
+ usage: number;
61
+ };
62
+ replay: TimelineReplayMetadata | null;
63
+ events: TimelineEvent[];
64
+ /**
65
+ * RFC 0004 — derived view keyed by interaction_id. Empty object when
66
+ * no events in the session carry one (sessions ingested before
67
+ * @obsunified/analytics-sdk's autoCorrelate landed). Events without an
68
+ * interaction_id appear in `events` but never under `groups`.
69
+ */
70
+ groups: Record<string, TimelineGroup>;
71
+ timestamp: string;
72
+ }
73
+
74
+ const severityFromStatus = (code: number): "info" | "warn" | "error" =>
75
+ code === 2 ? "error" : "info";
76
+
77
+ const severityFromLog = (
78
+ severity: string | null,
79
+ ): "info" | "warn" | "error" => {
80
+ if (severity === "ERROR" || severity === "FATAL") return "error";
81
+ if (severity === "WARN") return "warn";
82
+ return "info";
83
+ };
84
+
85
+ const severityFromUsage = (
86
+ type: string,
87
+ severity: string | null,
88
+ ): "info" | "warn" | "error" => {
89
+ if (type === "frontend_error" || severity === "error") return "error";
90
+ if (severity === "warning") return "warn";
91
+ return "info";
92
+ };
93
+
94
+ export const timelineRoutesPlugin: CollectorPlugin = {
95
+ name: "timeline-routes",
96
+ register(app) {
97
+ app.get("/internal/timeline/:sessionId", async (c) => {
98
+ const projectId = getProjectId(c);
99
+ const sessionId = c.req.param("sessionId");
100
+ if (!sessionId) {
101
+ return c.json({ error: "sessionId required" }, 400);
102
+ }
103
+
104
+ // RFC 0008 — go through the IdentityIndex helper rather than
105
+ // duplicating per-signal SQL. Same indexed lookup, single
106
+ // ownership of the cross-signal join shape.
107
+ const index = new IdentityIndex(sqlDbFor(c.env));
108
+ const manifest = await index.bySession(projectId, sessionId);
109
+
110
+ const events: TimelineEvent[] = [];
111
+ // Stash root-span shapes for grouping. Indexed by trace_id since a
112
+ // single click can fan out into multiple service-level traces (rare
113
+ // for a normal browser → single backend, but defensible).
114
+ const rootSpansByInteraction = new Map<
115
+ string,
116
+ TimelineGroup["causedTraces"]
117
+ >();
118
+
119
+ for (const s of manifest.spans) {
120
+ const interactionId = s.interactionId ?? undefined;
121
+ events.push({
122
+ t: s.startTime,
123
+ kind: "span",
124
+ id: `${s.traceId}:${s.spanId}`,
125
+ title: `${s.serviceName ?? "unknown"} · ${s.spanName}`,
126
+ subtitle: s.statusMessage ?? undefined,
127
+ severity: severityFromStatus(s.statusCode),
128
+ durationMs: s.durationMs,
129
+ interactionId,
130
+ payload: {
131
+ traceId: s.traceId,
132
+ spanId: s.spanId,
133
+ parentSpanId: s.parentSpanId,
134
+ statusCode: s.statusCode,
135
+ },
136
+ });
137
+ // Only root spans count as "the trace this click caused" — child
138
+ // spans are part of the same trace tree, so collecting them
139
+ // would double-count.
140
+ if (interactionId && s.parentSpanId === null) {
141
+ const list = rootSpansByInteraction.get(interactionId) ?? [];
142
+ list.push({
143
+ traceId: s.traceId,
144
+ rootSpanId: s.spanId,
145
+ rootSpanName: s.spanName,
146
+ serviceName: s.serviceName,
147
+ durationMs: s.durationMs,
148
+ status: s.statusCode === 2 ? "error" : "ok",
149
+ });
150
+ rootSpansByInteraction.set(interactionId, list);
151
+ }
152
+ }
153
+
154
+ for (const l of manifest.logs) {
155
+ events.push({
156
+ t: l.occurredAt,
157
+ kind: "log",
158
+ id: l.logId,
159
+ title: l.message.slice(0, 160),
160
+ subtitle:
161
+ `${l.serviceName ?? ""}${l.loggerName ? ` · ${l.loggerName}` : ""}`.trim(),
162
+ severity: severityFromLog(l.severity),
163
+ interactionId: l.interactionId ?? undefined,
164
+ payload: {
165
+ severity: l.severity,
166
+ traceId: l.traceId,
167
+ spanId: l.spanId,
168
+ },
169
+ });
170
+ }
171
+
172
+ for (const u of manifest.usageEvents) {
173
+ events.push({
174
+ t: u.occurredAt,
175
+ kind: "usage",
176
+ id: u.eventId,
177
+ title: `${u.eventType}${u.eventName && u.eventName !== u.eventType ? ` · ${u.eventName}` : ""}`,
178
+ subtitle: u.pagePath ?? undefined,
179
+ severity: severityFromUsage(u.eventType, u.severity),
180
+ interactionId: u.interactionId ?? undefined,
181
+ payload: {
182
+ eventType: u.eventType,
183
+ eventName: u.eventName,
184
+ pagePath: u.pagePath,
185
+ },
186
+ });
187
+ }
188
+
189
+ events.sort((a, b) => a.t.localeCompare(b.t));
190
+
191
+ // RFC 0004 — derive groups keyed by interaction_id.
192
+ const groups: Record<string, TimelineGroup> = {};
193
+ for (const event of events) {
194
+ if (!event.interactionId) continue;
195
+ let group = groups[event.interactionId];
196
+ if (!group) {
197
+ group = {
198
+ interactionId: event.interactionId,
199
+ clickEvent: null,
200
+ causedTraces: rootSpansByInteraction.get(event.interactionId) ?? [],
201
+ relatedEvents: [],
202
+ };
203
+ groups[event.interactionId] = group;
204
+ }
205
+ // Treat the first usage event with this id as the click that
206
+ // originated it. Usage events are minted at addEventListener
207
+ // fire time so they're timestamp-prior to any fetch the
208
+ // handler kicks off.
209
+ if (event.kind === "usage" && group.clickEvent === null) {
210
+ group.clickEvent = event;
211
+ } else {
212
+ group.relatedEvents.push(event);
213
+ }
214
+ }
215
+
216
+ const firstSeen = events[0]?.t ?? null;
217
+ const lastSeen = events[events.length - 1]?.t ?? null;
218
+
219
+ const response: TimelineResponse = {
220
+ sessionId,
221
+ firstSeen,
222
+ lastSeen,
223
+ counts: {
224
+ spans: manifest.spans.length,
225
+ logs: manifest.logs.length,
226
+ usage: manifest.usageEvents.length,
227
+ },
228
+ replay: manifest.replay
229
+ ? {
230
+ firstChunkAt: manifest.replay.firstChunkAt,
231
+ lastChunkAt: manifest.replay.lastChunkAt,
232
+ chunkCount: manifest.replay.chunkCount,
233
+ eventsCount: manifest.replay.eventsCount,
234
+ }
235
+ : null,
236
+ events,
237
+ groups,
238
+ timestamp: new Date().toISOString(),
239
+ };
240
+
241
+ return c.json(response);
242
+ });
243
+ },
244
+ };
@@ -0,0 +1,27 @@
1
+ import type {
2
+ CollectorPlugin,
3
+ UsageEventProcessorPlugin,
4
+ } from "../framework/collector";
5
+ import { parseUserAgent } from "../lib/ua-parser";
6
+
7
+ const processor: UsageEventProcessorPlugin = {
8
+ name: "ua-enrichment",
9
+ process(events) {
10
+ return events.map((event) => {
11
+ const parsed = parseUserAgent(event.userAgent);
12
+ return {
13
+ ...event,
14
+ browser: parsed.browser,
15
+ os: parsed.os,
16
+ deviceType: parsed.deviceType,
17
+ };
18
+ });
19
+ },
20
+ };
21
+
22
+ export const uaEnrichmentPlugin: CollectorPlugin = {
23
+ name: "ua-enrichment",
24
+ register(_app, runtime) {
25
+ runtime.addUsageEventProcessor(processor);
26
+ },
27
+ };
@@ -0,0 +1,46 @@
1
+ import type { JsonValue, UsageEventRecord } from "@obsunified/types";
2
+ import type { CollectorPlugin } from "../framework/collector";
3
+ import { parseJsonRecord } from "../lib/json";
4
+
5
+ const normalizePathSegment = (segment: string): string => {
6
+ if (!segment) return segment;
7
+ if (/^\d+$/.test(segment)) return ":id";
8
+ if (/^[0-9a-f]{16,}$/i.test(segment)) return ":id";
9
+ return segment;
10
+ };
11
+
12
+ const normalizePath = (path: string | null): string | null => {
13
+ if (!path) return null;
14
+ const [pathname] = path.split("?");
15
+ const segments = pathname
16
+ .split("/")
17
+ .map(normalizePathSegment)
18
+ .filter((segment, index) => !(index === 0 && segment === ""));
19
+ return `/${segments.join("/")}`.replace(/\/{2,}/g, "/");
20
+ };
21
+
22
+ export const usageNormalizationPlugin: CollectorPlugin = {
23
+ name: "usage-normalization",
24
+ register(_app, runtime) {
25
+ runtime.addUsageEventProcessor({
26
+ name: "usage-normalization",
27
+ process(events) {
28
+ return events.map((event): UsageEventRecord => {
29
+ const context = parseJsonRecord(event.contextJson);
30
+ const normalizedPath = normalizePath(event.pagePath);
31
+ const nextContext: Record<string, JsonValue> = {
32
+ ...context,
33
+ "collector.is_admin_path": Boolean(
34
+ normalizedPath?.startsWith("/admin"),
35
+ ),
36
+ };
37
+ return {
38
+ ...event,
39
+ pagePath: normalizedPath,
40
+ contextJson: JSON.stringify(nextContext),
41
+ };
42
+ });
43
+ },
44
+ });
45
+ },
46
+ };
@@ -0,0 +1,55 @@
1
+ import type { JsonValue, UsageEventRecord } from "@obsunified/types";
2
+ import type { CollectorPlugin } from "../framework/collector";
3
+ import { parseJsonValue } from "../lib/json";
4
+
5
+ const REDACTED_VALUE = "[REDACTED]";
6
+ const DEFAULT_REDACT_FIELDS = [
7
+ "email",
8
+ "token",
9
+ "password",
10
+ "authorization",
11
+ "cookie",
12
+ ];
13
+
14
+ const shouldRedactKey = (key: string): boolean => {
15
+ const normalized = key.toLowerCase();
16
+ return DEFAULT_REDACT_FIELDS.some(
17
+ (field) => normalized === field || normalized.endsWith(field),
18
+ );
19
+ };
20
+
21
+ const redactJson = (value: JsonValue): JsonValue => {
22
+ if (Array.isArray(value)) return value.map(redactJson);
23
+ if (value && typeof value === "object") {
24
+ return Object.fromEntries(
25
+ Object.entries(value).map(([key, nestedValue]) =>
26
+ shouldRedactKey(key)
27
+ ? [key, REDACTED_VALUE]
28
+ : [key, redactJson(nestedValue)],
29
+ ),
30
+ );
31
+ }
32
+ return value;
33
+ };
34
+
35
+ export const usagePrivacyPlugin: CollectorPlugin = {
36
+ name: "usage-privacy",
37
+ register(_app, runtime) {
38
+ runtime.addUsageEventProcessor({
39
+ name: "usage-privacy",
40
+ process(events) {
41
+ return events.map(
42
+ (event): UsageEventRecord => ({
43
+ ...event,
44
+ contextJson: JSON.stringify(
45
+ redactJson(parseJsonValue(event.contextJson)),
46
+ ),
47
+ propertiesJson: JSON.stringify(
48
+ redactJson(parseJsonValue(event.propertiesJson)),
49
+ ),
50
+ }),
51
+ );
52
+ },
53
+ });
54
+ },
55
+ };
@@ -0,0 +1,102 @@
1
+ import {
2
+ DEFAULT_WINDOW_HOURS,
3
+ getConfiguredRetentionHours,
4
+ } from "@obsunified/types/constants";
5
+ import type { CollectorPlugin } from "../framework/collector";
6
+ import { getProjectId } from "./_context";
7
+
8
+ export const usageQueryRoutesPlugin: CollectorPlugin = {
9
+ name: "usage-query-routes",
10
+ register(app, runtime) {
11
+ app.get("/internal/usage/overview", async (c) => {
12
+ const projectId = getProjectId(c);
13
+ const maxHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
14
+ const hours = Math.min(
15
+ maxHours,
16
+ Math.max(
17
+ 1,
18
+ Number.parseInt(
19
+ c.req.query("hours") || String(DEFAULT_WINDOW_HOURS),
20
+ 10,
21
+ ) || DEFAULT_WINDOW_HOURS,
22
+ ),
23
+ );
24
+
25
+ const store = runtime.createUsageStore(c.env);
26
+ const overview = await store.getOverview({
27
+ projectId,
28
+ hours,
29
+ path: c.req.query("path") || undefined,
30
+ includeAdmin: c.req.query("includeAdmin") === "true",
31
+ });
32
+ return c.json({
33
+ ...overview,
34
+ plugins: runtime.getRegisteredPluginNames(),
35
+ });
36
+ });
37
+
38
+ app.get("/internal/usage/sessions", async (c) => {
39
+ const projectId = getProjectId(c);
40
+ const maxHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
41
+ const hours = Math.min(
42
+ maxHours,
43
+ Math.max(
44
+ 1,
45
+ Number.parseInt(
46
+ c.req.query("hours") || String(DEFAULT_WINDOW_HOURS),
47
+ 10,
48
+ ) || DEFAULT_WINDOW_HOURS,
49
+ ),
50
+ );
51
+ const filterParam = c.req.query("filter");
52
+ const filter: "all" | "ended_in_error" | "dropoff" | "slow" =
53
+ filterParam === "ended_in_error" ||
54
+ filterParam === "dropoff" ||
55
+ filterParam === "slow"
56
+ ? filterParam
57
+ : "all";
58
+ const slowMs = Math.max(
59
+ 100,
60
+ Number.parseInt(c.req.query("slowMs") || "3000", 10) || 3000,
61
+ );
62
+ const limit = Math.max(
63
+ 1,
64
+ Math.min(
65
+ 500,
66
+ Number.parseInt(c.req.query("limit") || "100", 10) || 100,
67
+ ),
68
+ );
69
+
70
+ const store = runtime.createUsageStore(c.env);
71
+ const result = await store.listSessions({
72
+ projectId,
73
+ hours,
74
+ filter,
75
+ slowMs,
76
+ limit,
77
+ });
78
+ return c.json({
79
+ ...result,
80
+ filter,
81
+ hours,
82
+ slowMs,
83
+ timestamp: new Date().toISOString(),
84
+ });
85
+ });
86
+
87
+ app.get("/internal/usage/sessions/:sessionId", async (c) => {
88
+ const projectId = getProjectId(c);
89
+ const store = runtime.createUsageStore(c.env);
90
+ const detail = await store.getSessionDetail(
91
+ c.req.param("sessionId"),
92
+ projectId,
93
+ );
94
+ if (!detail)
95
+ return c.json(
96
+ { error: "Not Found", message: "Session not found" },
97
+ 404,
98
+ );
99
+ return c.json({ ...detail, plugins: runtime.getRegisteredPluginNames() });
100
+ });
101
+ },
102
+ };
@@ -0,0 +1,129 @@
1
+ /** Union: A's JSON error handling, event limit, env-based retention. P/D's dynamic CORS. */
2
+
3
+ import type {
4
+ UsageEventInput,
5
+ UsageEventPayload,
6
+ UsageEventRecord,
7
+ } from "@obsunified/types";
8
+ import {
9
+ DEFAULT_WINDOW_HOURS,
10
+ getConfiguredRetentionHours,
11
+ } from "@obsunified/types/constants";
12
+ import type { CollectorPlugin } from "../framework/collector";
13
+ import { retentionExpiry } from "../lib/otlp";
14
+ import { getProjectId } from "./_context";
15
+
16
+ const sanitizeIsoTimestamp = (value?: string): string => {
17
+ if (!value) return new Date().toISOString();
18
+ const parsed = new Date(value);
19
+ return Number.isNaN(parsed.getTime())
20
+ ? new Date().toISOString()
21
+ : parsed.toISOString();
22
+ };
23
+
24
+ const toUsageRecord = (
25
+ input: UsageEventInput,
26
+ userAgent: string | null,
27
+ country: string | null,
28
+ receivedAt: Date,
29
+ projectId: string,
30
+ retentionHours?: number,
31
+ ): UsageEventRecord => ({
32
+ projectId,
33
+ eventId: crypto.randomUUID(),
34
+ sessionId: input.sessionId,
35
+ visitorId: input.visitorId,
36
+ eventType: input.type,
37
+ eventName: input.name,
38
+ pagePath: input.path || null,
39
+ pageTitle: input.title || null,
40
+ referrer: input.referrer || null,
41
+ severity: input.severity || "info",
42
+ source: "web",
43
+ contextJson: JSON.stringify(input.context || {}),
44
+ propertiesJson: JSON.stringify(input.properties || {}),
45
+ userAgent,
46
+ occurredAt: sanitizeIsoTimestamp(input.occurredAt),
47
+ receivedAt: receivedAt.toISOString(),
48
+ expiresAt: retentionExpiry(receivedAt, retentionHours),
49
+ country,
50
+ browser: null,
51
+ os: null,
52
+ deviceType: null,
53
+ isBot: false,
54
+ utmSource: null,
55
+ utmMedium: null,
56
+ utmCampaign: null,
57
+ // RFC 0004 — opaque correlation id from the analytics SDK; null when
58
+ // the event was emitted outside any user interaction.
59
+ interactionId: input.interactionId || null,
60
+ });
61
+
62
+ export const usageReceiverPlugin: CollectorPlugin = {
63
+ name: "usage-receiver",
64
+ register(app, runtime) {
65
+ // CORS is handled by the framework-level /v1/* middleware.
66
+ // No per-route CORS needed here.
67
+
68
+ app.post("/v1/usage", async (c) => {
69
+ const projectId = getProjectId(c);
70
+ let payload: UsageEventPayload;
71
+ try {
72
+ payload = await c.req.json<UsageEventPayload>();
73
+ } catch {
74
+ return c.json({ error: "Invalid JSON body" }, 400);
75
+ }
76
+ const rawEvents = payload.events || [];
77
+ if (rawEvents.length > 200) {
78
+ return c.json(
79
+ { error: `Too many events: ${rawEvents.length} (max 200)` },
80
+ 413,
81
+ );
82
+ }
83
+ const validInputs = rawEvents.filter((event): event is UsageEventInput =>
84
+ Boolean(
85
+ event?.type && event?.name && event?.sessionId && event?.visitorId,
86
+ ),
87
+ );
88
+
89
+ const receivedAt = new Date();
90
+ const retentionHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
91
+ const country = c.req.header("X-Client-Country") || null;
92
+ const records = validInputs.map((event) =>
93
+ toUsageRecord(
94
+ event,
95
+ c.req.header("User-Agent") || null,
96
+ country,
97
+ receivedAt,
98
+ projectId,
99
+ retentionHours,
100
+ ),
101
+ );
102
+
103
+ const processed = await runtime.runUsageEventProcessors(
104
+ records,
105
+ runtime.createRouteContext(c.env, c),
106
+ );
107
+ const store = runtime.createUsageStore(c.env);
108
+ const result = await runtime.withChildSpan(
109
+ "usage.ingest",
110
+ async (span) => {
111
+ const r = await store.ingest(processed);
112
+ span.setAttribute("usage.events_received", rawEvents.length);
113
+ span.setAttribute("usage.events_valid", validInputs.length);
114
+ span.setAttribute("usage.events_inserted", r.inserted);
115
+ span.setAttribute("usage.session_count", r.sessionCount);
116
+ span.setAttribute("project.id", projectId);
117
+ return r;
118
+ },
119
+ );
120
+
121
+ return c.json({
122
+ success: true,
123
+ inserted: result.inserted,
124
+ sessionCount: result.sessionCount,
125
+ acceptedWindowHours: DEFAULT_WINDOW_HOURS,
126
+ });
127
+ });
128
+ },
129
+ };