@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,567 @@
1
+ /**
2
+ * UsageStore — union of all repos.
3
+ * - A's in-memory aggregation approach (more flexible, handles legacy load time keys)
4
+ * - A's broader error detection (event_type='frontend_error' OR severity='error')
5
+ * - A's multi-key load time parsing (loadTimeMs, load_time_ms, durationMs)
6
+ * - A's purgeExpired()
7
+ * - A's summarizeMap helper
8
+ */
9
+
10
+ import type {
11
+ UsageBrowserSummary,
12
+ UsageCountrySummary,
13
+ UsageDeviceSummary,
14
+ UsageErrorSummary,
15
+ UsageEventRecord,
16
+ UsageEventRow,
17
+ UsageEventSummary,
18
+ UsageOSSummary,
19
+ UsageOverviewOptions,
20
+ UsageOverviewResponse,
21
+ UsagePageSummary,
22
+ UsageSessionDetailResponse,
23
+ UsageSessionSummary,
24
+ UsageUtmCampaignSummary,
25
+ UsageUtmMediumSummary,
26
+ UsageUtmSourceSummary,
27
+ } from "@obsunified/types";
28
+
29
+ import { parseJsonRecord } from "./json";
30
+ import { dialectFor, type SqlDb } from "./sql-db";
31
+
32
+ const cutoffIso = (hours: number): string =>
33
+ new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
34
+
35
+ const summarizeMap = <T>(
36
+ map: Map<string, number>,
37
+ build: (value: string, count: number) => T,
38
+ ): T[] =>
39
+ Array.from(map.entries())
40
+ .map(([value, count]) => build(value, count))
41
+ .sort((left, right) => {
42
+ const leftCount = (left as { count: number }).count;
43
+ const rightCount = (right as { count: number }).count;
44
+ return rightCount - leftCount;
45
+ })
46
+ .slice(0, 10);
47
+
48
+ const filterRow = (
49
+ row: UsageEventRow,
50
+ options: UsageOverviewOptions,
51
+ cutoff: string,
52
+ ): boolean => {
53
+ if (row.occurred_at < cutoff) return false;
54
+ if (options.path && row.page_path !== options.path) return false;
55
+ if (!options.includeAdmin) {
56
+ const context = parseJsonRecord(row.context_json);
57
+ if (context["collector.is_admin_path"] === true) return false;
58
+ }
59
+ return true;
60
+ };
61
+
62
+ const toSessionSummary = (rows: UsageEventRow[]): UsageSessionSummary => {
63
+ const sorted = [...rows].sort((left, right) =>
64
+ left.occurred_at.localeCompare(right.occurred_at),
65
+ );
66
+ const first = sorted[0];
67
+ const last = sorted[sorted.length - 1];
68
+
69
+ return {
70
+ sessionId: first.session_id,
71
+ visitorId: first.visitor_id,
72
+ firstSeen: first.occurred_at,
73
+ lastSeen: last.occurred_at,
74
+ eventCount: sorted.length,
75
+ pageViewCount: sorted.filter((row) => row.event_type === "page_view")
76
+ .length,
77
+ errorCount: sorted.filter(
78
+ (row) => row.event_type === "frontend_error" || row.severity === "error",
79
+ ).length,
80
+ lastPath: last.page_path,
81
+ referrer: first.referrer,
82
+ };
83
+ };
84
+
85
+ export class UsageStore {
86
+ constructor(private readonly db: SqlDb) {}
87
+
88
+ async ingest(
89
+ events: UsageEventRecord[],
90
+ ): Promise<{ inserted: number; sessionCount: number }> {
91
+ if (events.length === 0) return { inserted: 0, sessionCount: 0 };
92
+
93
+ const statements = events.map((event) => {
94
+ if (!event.projectId)
95
+ throw new Error("UsageStore.ingest: event.projectId is required");
96
+ return this.db
97
+ .prepare(`
98
+ INSERT OR IGNORE INTO usage_events (
99
+ project_id, event_id, session_id, visitor_id, event_type, event_name,
100
+ page_path, page_title, referrer, severity, source,
101
+ context_json, properties_json, user_agent, occurred_at,
102
+ received_at, expires_at, country, browser, os,
103
+ device_type, is_bot, utm_source, utm_medium, utm_campaign,
104
+ interaction_id
105
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
106
+ `)
107
+ .bind(
108
+ event.projectId,
109
+ event.eventId,
110
+ event.sessionId,
111
+ event.visitorId,
112
+ event.eventType,
113
+ event.eventName,
114
+ event.pagePath,
115
+ event.pageTitle,
116
+ event.referrer,
117
+ event.severity,
118
+ event.source,
119
+ event.contextJson,
120
+ event.propertiesJson,
121
+ event.userAgent,
122
+ event.occurredAt,
123
+ event.receivedAt,
124
+ event.expiresAt,
125
+ event.country,
126
+ event.browser,
127
+ event.os,
128
+ event.deviceType,
129
+ event.isBot ? 1 : 0,
130
+ event.utmSource,
131
+ event.utmMedium,
132
+ event.utmCampaign,
133
+ event.interactionId ?? null,
134
+ );
135
+ });
136
+
137
+ await this.db.batch(statements);
138
+ return {
139
+ inserted: events.length,
140
+ sessionCount: new Set(events.map((event) => event.sessionId)).size,
141
+ };
142
+ }
143
+
144
+ async getOverview(
145
+ options: UsageOverviewOptions,
146
+ ): Promise<UsageOverviewResponse> {
147
+ if (!options.projectId)
148
+ throw new Error("UsageStore.getOverview: projectId is required");
149
+ const cutoff = cutoffIso(options.hours);
150
+ const result = await this.db
151
+ .prepare(`
152
+ SELECT project_id, event_id, session_id, visitor_id, event_type, event_name,
153
+ page_path, page_title, referrer, severity, source,
154
+ context_json, properties_json, user_agent, occurred_at,
155
+ received_at, country, browser, os, device_type, is_bot,
156
+ utm_source, utm_medium, utm_campaign
157
+ FROM usage_events
158
+ WHERE project_id = ? AND occurred_at >= ?
159
+ ORDER BY occurred_at DESC
160
+ LIMIT 10000
161
+ `)
162
+ .bind(options.projectId, cutoff)
163
+ .all<UsageEventRow>();
164
+
165
+ const rows = (result.results ?? []).filter((row) =>
166
+ filterRow(row, options, cutoff),
167
+ );
168
+ const pageMap = new Map<
169
+ string,
170
+ {
171
+ path: string;
172
+ title: string | null;
173
+ views: number;
174
+ sessions: Set<string>;
175
+ loadTimes: number[];
176
+ errorCount: number;
177
+ }
178
+ >();
179
+ const eventMap = new Map<
180
+ string,
181
+ {
182
+ eventName: string;
183
+ eventType: UsageEventSummary["eventType"];
184
+ totalEvents: number;
185
+ sessions: Set<string>;
186
+ }
187
+ >();
188
+ const sessions = new Map<string, UsageEventRow[]>();
189
+ const browsers = new Map<string, number>();
190
+ const operatingSystems = new Map<string, number>();
191
+ const devices = new Map<string, number>();
192
+ const countries = new Map<string, number>();
193
+ const utmSources = new Map<string, number>();
194
+ const utmMediums = new Map<string, number>();
195
+ const utmCampaigns = new Map<string, number>();
196
+ const hourlyPageViews = new Map<string, number>();
197
+ const frontendErrors: UsageErrorSummary[] = [];
198
+
199
+ let pageViews = 0;
200
+ let interactions = 0;
201
+ let frontendErrorCount = 0;
202
+ let botsFiltered = 0;
203
+
204
+ for (const row of rows) {
205
+ if (row.is_bot) botsFiltered += 1;
206
+
207
+ const sessionRows = sessions.get(row.session_id) ?? [];
208
+ sessionRows.push(row);
209
+ sessions.set(row.session_id, sessionRows);
210
+
211
+ const eventKey = `${row.event_type}:${row.event_name}`;
212
+ const eventEntry = eventMap.get(eventKey) ?? {
213
+ eventName: row.event_name,
214
+ eventType: row.event_type,
215
+ totalEvents: 0,
216
+ sessions: new Set<string>(),
217
+ };
218
+ eventEntry.totalEvents += 1;
219
+ eventEntry.sessions.add(row.session_id);
220
+ eventMap.set(eventKey, eventEntry);
221
+
222
+ if (row.browser)
223
+ browsers.set(row.browser, (browsers.get(row.browser) ?? 0) + 1);
224
+ if (row.os)
225
+ operatingSystems.set(row.os, (operatingSystems.get(row.os) ?? 0) + 1);
226
+ if (row.device_type)
227
+ devices.set(row.device_type, (devices.get(row.device_type) ?? 0) + 1);
228
+ if (row.country)
229
+ countries.set(row.country, (countries.get(row.country) ?? 0) + 1);
230
+ if (row.utm_source)
231
+ utmSources.set(
232
+ row.utm_source,
233
+ (utmSources.get(row.utm_source) ?? 0) + 1,
234
+ );
235
+ if (row.utm_medium)
236
+ utmMediums.set(
237
+ row.utm_medium,
238
+ (utmMediums.get(row.utm_medium) ?? 0) + 1,
239
+ );
240
+ if (row.utm_campaign)
241
+ utmCampaigns.set(
242
+ row.utm_campaign,
243
+ (utmCampaigns.get(row.utm_campaign) ?? 0) + 1,
244
+ );
245
+
246
+ if (row.event_type === "page_view") {
247
+ pageViews += 1;
248
+ const hour = `${row.occurred_at.slice(0, 13)}:00:00.000Z`;
249
+ hourlyPageViews.set(hour, (hourlyPageViews.get(hour) ?? 0) + 1);
250
+
251
+ if (row.page_path) {
252
+ const pageEntry = pageMap.get(row.page_path) ?? {
253
+ path: row.page_path,
254
+ title: row.page_title,
255
+ views: 0,
256
+ sessions: new Set<string>(),
257
+ loadTimes: [],
258
+ errorCount: 0,
259
+ };
260
+ pageEntry.views += 1;
261
+ pageEntry.sessions.add(row.session_id);
262
+ if (!pageEntry.title && row.page_title)
263
+ pageEntry.title = row.page_title;
264
+
265
+ // Multi-key load time parsing (from A)
266
+ const properties = parseJsonRecord(row.properties_json);
267
+ const loadTimeValue =
268
+ properties.loadTimeMs ??
269
+ properties.load_time_ms ??
270
+ properties.durationMs;
271
+ const loadTime =
272
+ typeof loadTimeValue === "number"
273
+ ? loadTimeValue
274
+ : typeof loadTimeValue === "string"
275
+ ? Number(loadTimeValue)
276
+ : NaN;
277
+ if (Number.isFinite(loadTime)) pageEntry.loadTimes.push(loadTime);
278
+
279
+ pageMap.set(row.page_path, pageEntry);
280
+ }
281
+ } else if (row.event_type === "interaction") {
282
+ interactions += 1;
283
+ } else if (row.event_type === "frontend_error") {
284
+ frontendErrorCount += 1;
285
+ const properties = parseJsonRecord(row.properties_json);
286
+ frontendErrors.push({
287
+ eventId: row.event_id,
288
+ sessionId: row.session_id,
289
+ pagePath: row.page_path,
290
+ errorName:
291
+ typeof properties.errorName === "string"
292
+ ? properties.errorName
293
+ : null,
294
+ errorMessage:
295
+ typeof properties.errorMessage === "string"
296
+ ? properties.errorMessage
297
+ : null,
298
+ component:
299
+ typeof properties.component === "string"
300
+ ? properties.component
301
+ : null,
302
+ occurredAt: row.occurred_at,
303
+ });
304
+ }
305
+
306
+ // Broader error detection (from A)
307
+ if (
308
+ row.page_path &&
309
+ (row.event_type === "frontend_error" || row.severity === "error")
310
+ ) {
311
+ const pageEntry = pageMap.get(row.page_path) ?? {
312
+ path: row.page_path,
313
+ title: row.page_title,
314
+ views: 0,
315
+ sessions: new Set<string>(),
316
+ loadTimes: [],
317
+ errorCount: 0,
318
+ };
319
+ pageEntry.errorCount += 1;
320
+ pageMap.set(row.page_path, pageEntry);
321
+ }
322
+ }
323
+
324
+ const pages: UsagePageSummary[] = Array.from(pageMap.values())
325
+ .map((entry) => ({
326
+ path: entry.path,
327
+ title: entry.title,
328
+ views: entry.views,
329
+ uniqueSessions: entry.sessions.size,
330
+ averageLoadTimeMs:
331
+ entry.loadTimes.length > 0
332
+ ? Math.round(
333
+ entry.loadTimes.reduce((sum, v) => sum + v, 0) /
334
+ entry.loadTimes.length,
335
+ )
336
+ : 0,
337
+ errorCount: entry.errorCount,
338
+ }))
339
+ .sort(
340
+ (left, right) =>
341
+ right.views - left.views || left.path.localeCompare(right.path),
342
+ )
343
+ .slice(0, options.limit ?? 20);
344
+
345
+ const recentSessions = Array.from(sessions.values())
346
+ .map((sessionRows) => toSessionSummary(sessionRows))
347
+ .sort((left, right) => right.lastSeen.localeCompare(left.lastSeen))
348
+ .slice(0, 20);
349
+
350
+ return {
351
+ summary: {
352
+ totalEvents: rows.length,
353
+ uniqueSessions: sessions.size,
354
+ uniqueVisitors: new Set(rows.map((row) => row.visitor_id)).size,
355
+ pageViews,
356
+ frontendErrors: frontendErrorCount,
357
+ interactions,
358
+ },
359
+ pages,
360
+ events: Array.from(eventMap.values())
361
+ .map((entry) => ({
362
+ eventName: entry.eventName,
363
+ eventType: entry.eventType,
364
+ totalEvents: entry.totalEvents,
365
+ uniqueSessions: entry.sessions.size,
366
+ }))
367
+ .sort(
368
+ (left, right) =>
369
+ right.totalEvents - left.totalEvents ||
370
+ left.eventName.localeCompare(right.eventName),
371
+ )
372
+ .slice(0, 20),
373
+ recentSessions,
374
+ frontendErrors: frontendErrors
375
+ .sort((left, right) => right.occurredAt.localeCompare(left.occurredAt))
376
+ .slice(0, 20),
377
+ browsers: summarizeMap<UsageBrowserSummary>(
378
+ browsers,
379
+ (browser, count) => ({ browser, count }),
380
+ ),
381
+ operatingSystems: summarizeMap<UsageOSSummary>(
382
+ operatingSystems,
383
+ (os, count) => ({ os, count }),
384
+ ),
385
+ devices: summarizeMap<UsageDeviceSummary>(devices, (device, count) => ({
386
+ device,
387
+ count,
388
+ })),
389
+ countries: summarizeMap<UsageCountrySummary>(
390
+ countries,
391
+ (country, count) => ({ country, count }),
392
+ ),
393
+ utmSources: summarizeMap<UsageUtmSourceSummary>(
394
+ utmSources,
395
+ (source, count) => ({ source, count }),
396
+ ),
397
+ utmMediums: summarizeMap<UsageUtmMediumSummary>(
398
+ utmMediums,
399
+ (medium, count) => ({ medium, count }),
400
+ ),
401
+ utmCampaigns: summarizeMap<UsageUtmCampaignSummary>(
402
+ utmCampaigns,
403
+ (campaign, count) => ({ campaign, count }),
404
+ ),
405
+ hourlyPageViews: Array.from(hourlyPageViews.entries())
406
+ .map(([hour, count]) => ({ hour, count }))
407
+ .sort((left, right) => left.hour.localeCompare(right.hour)),
408
+ botsFiltered,
409
+ filters: {
410
+ path: options.path ?? "all",
411
+ includeAdmin: options.includeAdmin ?? false,
412
+ },
413
+ windowHours: options.hours,
414
+ timestamp: new Date().toISOString(),
415
+ };
416
+ }
417
+
418
+ async getSessionDetail(
419
+ sessionId: string,
420
+ projectId: string,
421
+ ): Promise<UsageSessionDetailResponse | null> {
422
+ if (!projectId)
423
+ throw new Error("UsageStore.getSessionDetail: projectId is required");
424
+ const result = await this.db
425
+ .prepare(`
426
+ SELECT project_id, event_id, session_id, visitor_id, event_type, event_name,
427
+ page_path, page_title, referrer, severity, source,
428
+ context_json, properties_json, user_agent, occurred_at,
429
+ received_at, country, browser, os, device_type, is_bot,
430
+ utm_source, utm_medium, utm_campaign, interaction_id
431
+ FROM usage_events
432
+ WHERE project_id = ? AND session_id = ?
433
+ ORDER BY occurred_at ASC
434
+ `)
435
+ .bind(projectId, sessionId)
436
+ .all<UsageEventRow>();
437
+
438
+ const rows = result.results ?? [];
439
+ if (rows.length === 0) return null;
440
+
441
+ return {
442
+ session: toSessionSummary(rows),
443
+ events: rows.map((row) => ({
444
+ eventId: row.event_id,
445
+ eventType: row.event_type,
446
+ eventName: row.event_name,
447
+ pagePath: row.page_path,
448
+ pageTitle: row.page_title,
449
+ severity: row.severity ?? "info",
450
+ occurredAt: row.occurred_at,
451
+ properties: parseJsonRecord(row.properties_json),
452
+ context: parseJsonRecord(row.context_json),
453
+ interactionId: row.interaction_id ?? null,
454
+ })),
455
+ timestamp: new Date().toISOString(),
456
+ };
457
+ }
458
+
459
+ /**
460
+ * List sessions with an optional qualitative filter.
461
+ *
462
+ * Aggregates per session_id in a single query; supports three filters
463
+ * derived from session-level rollups: sessions that errored, sessions
464
+ * with no interactions (engagement drop-off), and sessions with slow
465
+ * page loads.
466
+ */
467
+ async listSessions(options: {
468
+ projectId: string;
469
+ hours: number;
470
+ filter: "all" | "ended_in_error" | "dropoff" | "slow";
471
+ slowMs?: number;
472
+ limit?: number;
473
+ }): Promise<{
474
+ sessions: Array<
475
+ UsageSessionSummary & {
476
+ interactionCount: number;
477
+ maxLoadTimeMs: number | null;
478
+ }
479
+ >;
480
+ }> {
481
+ if (!options.projectId)
482
+ throw new Error("UsageStore.listSessions: projectId is required");
483
+ const cutoff = cutoffIso(options.hours);
484
+ const dialect = dialectFor(this.db);
485
+ const slowMs = options.slowMs ?? 3000;
486
+ const limit = Math.max(1, Math.min(1000, options.limit ?? 200));
487
+ const loadMsExpr = dialect.jsonText("properties_json", "$.loadTimeMs");
488
+ const loadTimeMsExpr = dialect.jsonText(
489
+ "properties_json",
490
+ "$.load_time_ms",
491
+ );
492
+ const durationMsExpr = dialect.jsonText("properties_json", "$.durationMs");
493
+
494
+ const result = await this.db
495
+ .prepare(
496
+ `SELECT
497
+ session_id,
498
+ MIN(visitor_id) AS visitor_id,
499
+ MIN(occurred_at) AS first_seen,
500
+ MAX(occurred_at) AS last_seen,
501
+ COUNT(*) AS event_count,
502
+ SUM(CASE WHEN event_type = 'page_view' THEN 1 ELSE 0 END) AS page_view_count,
503
+ SUM(CASE WHEN event_type = 'interaction' THEN 1 ELSE 0 END) AS interaction_count,
504
+ SUM(CASE WHEN event_type = 'frontend_error' OR severity = 'error' THEN 1 ELSE 0 END) AS error_count,
505
+ MAX(CASE WHEN event_type = 'page_view' THEN CAST(COALESCE(${loadMsExpr}, ${loadTimeMsExpr}, ${durationMsExpr}) AS REAL) ELSE NULL END) AS max_load_ms,
506
+ (SELECT referrer FROM usage_events ref WHERE ref.project_id = ue.project_id AND ref.session_id = ue.session_id ORDER BY occurred_at ASC LIMIT 1) AS referrer,
507
+ (SELECT page_path FROM usage_events lp WHERE lp.project_id = ue.project_id AND lp.session_id = ue.session_id ORDER BY occurred_at DESC LIMIT 1) AS last_path
508
+ FROM usage_events ue
509
+ WHERE project_id = ? AND received_at >= ?
510
+ GROUP BY session_id
511
+ ORDER BY last_seen DESC
512
+ LIMIT ?`,
513
+ )
514
+ .bind(options.projectId, cutoff, limit * 3)
515
+ .all<{
516
+ session_id: string;
517
+ visitor_id: string;
518
+ first_seen: string;
519
+ last_seen: string;
520
+ event_count: number;
521
+ page_view_count: number;
522
+ interaction_count: number;
523
+ error_count: number;
524
+ max_load_ms: number | null;
525
+ referrer: string | null;
526
+ last_path: string | null;
527
+ }>();
528
+
529
+ const raw = (result.results ?? []).map((r) => ({
530
+ sessionId: r.session_id,
531
+ visitorId: r.visitor_id,
532
+ firstSeen: r.first_seen,
533
+ lastSeen: r.last_seen,
534
+ eventCount: r.event_count,
535
+ pageViewCount: r.page_view_count,
536
+ errorCount: r.error_count,
537
+ lastPath: r.last_path,
538
+ referrer: r.referrer,
539
+ interactionCount: r.interaction_count,
540
+ maxLoadTimeMs: r.max_load_ms,
541
+ }));
542
+
543
+ const filtered = raw.filter((s) => {
544
+ switch (options.filter) {
545
+ case "ended_in_error":
546
+ return s.errorCount > 0;
547
+ case "dropoff":
548
+ return s.pageViewCount >= 1 && s.interactionCount === 0;
549
+ case "slow":
550
+ return s.maxLoadTimeMs !== null && s.maxLoadTimeMs >= slowMs;
551
+ default:
552
+ return true;
553
+ }
554
+ });
555
+
556
+ return { sessions: filtered.slice(0, limit) };
557
+ }
558
+
559
+ async purgeExpired(): Promise<number> {
560
+ const now = new Date().toISOString();
561
+ const result = await this.db
562
+ .prepare("DELETE FROM usage_events WHERE expires_at <= ?")
563
+ .bind(now)
564
+ .run();
565
+ return result.meta?.changes ?? 0;
566
+ }
567
+ }
@@ -0,0 +1,28 @@
1
+ CREATE TABLE IF NOT EXISTS telemetry_spans (
2
+ trace_id TEXT NOT NULL,
3
+ span_id TEXT NOT NULL,
4
+ parent_span_id TEXT,
5
+ service_name TEXT,
6
+ scope_name TEXT,
7
+ scope_version TEXT,
8
+ span_name TEXT NOT NULL,
9
+ span_kind INTEGER,
10
+ status_code INTEGER,
11
+ status_message TEXT,
12
+ start_time TEXT NOT NULL,
13
+ end_time TEXT NOT NULL,
14
+ duration_ms REAL NOT NULL,
15
+ attributes_json TEXT,
16
+ resource_attributes_json TEXT,
17
+ events_json TEXT,
18
+ links_json TEXT,
19
+ received_at TEXT NOT NULL,
20
+ expires_at TEXT NOT NULL,
21
+ PRIMARY KEY (trace_id, span_id)
22
+ );
23
+
24
+ CREATE INDEX IF NOT EXISTS idx_spans_received_at ON telemetry_spans (received_at DESC);
25
+ CREATE INDEX IF NOT EXISTS idx_spans_trace_id ON telemetry_spans (trace_id);
26
+ CREATE INDEX IF NOT EXISTS idx_spans_service ON telemetry_spans (service_name);
27
+ CREATE INDEX IF NOT EXISTS idx_spans_status ON telemetry_spans (status_code);
28
+ CREATE INDEX IF NOT EXISTS idx_spans_root ON telemetry_spans (parent_span_id, received_at DESC);
@@ -0,0 +1,24 @@
1
+ CREATE TABLE IF NOT EXISTS usage_events (
2
+ event_id TEXT PRIMARY KEY,
3
+ session_id TEXT NOT NULL,
4
+ visitor_id TEXT NOT NULL,
5
+ event_type TEXT NOT NULL,
6
+ event_name TEXT NOT NULL,
7
+ page_path TEXT,
8
+ page_title TEXT,
9
+ referrer TEXT,
10
+ severity TEXT,
11
+ source TEXT,
12
+ context_json TEXT NOT NULL,
13
+ properties_json TEXT NOT NULL,
14
+ user_agent TEXT,
15
+ occurred_at TEXT NOT NULL,
16
+ received_at TEXT NOT NULL,
17
+ expires_at TEXT NOT NULL
18
+ );
19
+
20
+ CREATE INDEX IF NOT EXISTS idx_usage_received ON usage_events (received_at DESC);
21
+ CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_events (session_id);
22
+ CREATE INDEX IF NOT EXISTS idx_usage_visitor ON usage_events (visitor_id);
23
+ CREATE INDEX IF NOT EXISTS idx_usage_path ON usage_events (page_path);
24
+ CREATE INDEX IF NOT EXISTS idx_usage_type ON usage_events (event_type);
@@ -0,0 +1,13 @@
1
+ ALTER TABLE usage_events ADD COLUMN country TEXT;
2
+ ALTER TABLE usage_events ADD COLUMN browser TEXT;
3
+ ALTER TABLE usage_events ADD COLUMN os TEXT;
4
+ ALTER TABLE usage_events ADD COLUMN device_type TEXT;
5
+ ALTER TABLE usage_events ADD COLUMN is_bot INTEGER DEFAULT 0;
6
+ ALTER TABLE usage_events ADD COLUMN utm_source TEXT;
7
+ ALTER TABLE usage_events ADD COLUMN utm_medium TEXT;
8
+ ALTER TABLE usage_events ADD COLUMN utm_campaign TEXT;
9
+
10
+ CREATE INDEX IF NOT EXISTS idx_usage_country ON usage_events (country);
11
+ CREATE INDEX IF NOT EXISTS idx_usage_bot ON usage_events (is_bot);
12
+ CREATE INDEX IF NOT EXISTS idx_usage_browser ON usage_events (browser);
13
+ CREATE INDEX IF NOT EXISTS idx_usage_utm ON usage_events (utm_source);
@@ -0,0 +1,19 @@
1
+ CREATE TABLE IF NOT EXISTS logs (
2
+ log_id TEXT PRIMARY KEY,
3
+ trace_id TEXT,
4
+ span_id TEXT,
5
+ service_name TEXT,
6
+ severity TEXT NOT NULL,
7
+ severity_number INTEGER NOT NULL,
8
+ logger_name TEXT,
9
+ message TEXT NOT NULL,
10
+ attributes_json TEXT,
11
+ occurred_at TEXT NOT NULL,
12
+ received_at TEXT NOT NULL,
13
+ expires_at TEXT NOT NULL
14
+ );
15
+
16
+ CREATE INDEX IF NOT EXISTS idx_logs_received ON logs (received_at DESC);
17
+ CREATE INDEX IF NOT EXISTS idx_logs_trace ON logs (trace_id);
18
+ CREATE INDEX IF NOT EXISTS idx_logs_service ON logs (service_name);
19
+ CREATE INDEX IF NOT EXISTS idx_logs_severity ON logs (severity_number);
@@ -0,0 +1,25 @@
1
+ CREATE TABLE IF NOT EXISTS ai_calls (
2
+ call_id TEXT PRIMARY KEY,
3
+ trace_id TEXT,
4
+ span_id TEXT,
5
+ service_name TEXT,
6
+ model_name TEXT NOT NULL,
7
+ provider TEXT NOT NULL,
8
+ call_type TEXT NOT NULL, -- 'text_completion', 'chat', 'prompt_to_image', 'embedding'
9
+ request_json TEXT, -- The raw prompt/messages or image configuration
10
+ response_json TEXT, -- The raw completion text or image URLs
11
+ prompt_tokens INTEGER,
12
+ completion_tokens INTEGER,
13
+ total_cost_usd REAL,
14
+ latency_ms REAL,
15
+ is_error INTEGER DEFAULT 0,
16
+ error_message TEXT,
17
+ occurred_at TEXT NOT NULL,
18
+ received_at TEXT NOT NULL,
19
+ expires_at TEXT NOT NULL
20
+ );
21
+
22
+ CREATE INDEX IF NOT EXISTS idx_aicalls_received ON ai_calls (received_at DESC);
23
+ CREATE INDEX IF NOT EXISTS idx_aicalls_trace ON ai_calls (trace_id);
24
+ CREATE INDEX IF NOT EXISTS idx_aicalls_service ON ai_calls (service_name);
25
+ CREATE INDEX IF NOT EXISTS idx_aicalls_model ON ai_calls (model_name);
@@ -0,0 +1,11 @@
1
+ CREATE TABLE IF NOT EXISTS user_profiles (
2
+ user_id TEXT PRIMARY KEY,
3
+ visitor_id TEXT NOT NULL,
4
+ email TEXT,
5
+ name TEXT,
6
+ properties_json TEXT, -- custom traits (plan_tier, role)
7
+ first_seen_at TEXT NOT NULL,
8
+ last_seen_at TEXT NOT NULL
9
+ );
10
+
11
+ CREATE INDEX IF NOT EXISTS idx_user_visitor ON user_profiles(visitor_id);
@@ -0,0 +1,10 @@
1
+ CREATE TABLE IF NOT EXISTS session_replay_metadata (
2
+ session_id TEXT PRIMARY KEY,
3
+ visitor_id TEXT NOT NULL,
4
+ first_chunk_at TEXT NOT NULL,
5
+ last_chunk_at TEXT NOT NULL,
6
+ chunk_count INTEGER DEFAULT 0,
7
+ events_count INTEGER DEFAULT 0
8
+ );
9
+
10
+ CREATE INDEX IF NOT EXISTS idx_replay_visitor ON session_replay_metadata(visitor_id);
@@ -0,0 +1 @@
1
+ ALTER TABLE session_replay_metadata ADD COLUMN storage_bytes INTEGER NOT NULL DEFAULT 0;