@stacksjs/ts-analytics 0.1.6

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 (237) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +361 -0
  4. package/bin/cli.ts +169 -0
  5. package/dist/Analytics.d.ts +558 -0
  6. package/dist/api.d.ts +109 -0
  7. package/dist/batching.d.ts +93 -0
  8. package/dist/chunk-2mx7fq49.js +4 -0
  9. package/dist/chunk-3z29508k.js +204 -0
  10. package/dist/chunk-deephkz6.js +56 -0
  11. package/dist/chunk-j261vgyp.js +305 -0
  12. package/dist/chunk-xga17tz7.js +207 -0
  13. package/dist/config.d.ts +132 -0
  14. package/dist/dashboard/components/index.d.ts +80 -0
  15. package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
  16. package/dist/dashboard/index.d.ts +110 -0
  17. package/dist/dashboard/types/index.d.ts +144 -0
  18. package/dist/dashboard/utils/index.d.ts +81 -0
  19. package/dist/dynamodb.d.ts +86 -0
  20. package/dist/funnels.d.ts +104 -0
  21. package/dist/geolocation.d.ts +96 -0
  22. package/dist/index.d.ts +371 -0
  23. package/dist/index.js +10211 -0
  24. package/dist/infrastructure/cdk.d.ts +60 -0
  25. package/dist/infrastructure/cloudformation.d.ts +44 -0
  26. package/dist/infrastructure/index.d.ts +45 -0
  27. package/dist/infrastructure/setup.d.ts +133 -0
  28. package/dist/integrations/cloudflare.d.ts +76 -0
  29. package/dist/integrations/hono.d.ts +60 -0
  30. package/dist/integrations/index.d.ts +24 -0
  31. package/dist/integrations/nuxt.d.ts +7 -0
  32. package/dist/integrations/nuxt.js +42 -0
  33. package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
  34. package/dist/integrations/runtime/use-ts-analytics.js +17 -0
  35. package/dist/integrations/stx.d.ts +88 -0
  36. package/dist/integrations/stx.js +18 -0
  37. package/dist/lib/crypto-random.d.ts +4 -0
  38. package/dist/lib/salt.d.ts +16 -0
  39. package/dist/local.d.ts +56 -0
  40. package/dist/model-connector.d.ts +145 -0
  41. package/dist/models/AggregatedStats.d.ts +9 -0
  42. package/dist/models/CampaignStats.d.ts +9 -0
  43. package/dist/models/Conversion.d.ts +11 -0
  44. package/dist/models/CustomEvent.d.ts +11 -0
  45. package/dist/models/DeviceStats.d.ts +9 -0
  46. package/dist/models/EventStats.d.ts +9 -0
  47. package/dist/models/GeoStats.d.ts +9 -0
  48. package/dist/models/Goal.d.ts +9 -0
  49. package/dist/models/GoalStats.d.ts +9 -0
  50. package/dist/models/PageStats.d.ts +11 -0
  51. package/dist/models/PageView.d.ts +13 -0
  52. package/dist/models/RealtimeStats.d.ts +9 -0
  53. package/dist/models/ReferrerStats.d.ts +9 -0
  54. package/dist/models/Session.d.ts +11 -0
  55. package/dist/models/Site.d.ts +11 -0
  56. package/dist/models/index.d.ts +28 -0
  57. package/dist/models/types.d.ts +60 -0
  58. package/dist/sqs-buffering.d.ts +243 -0
  59. package/dist/stacks-integration.d.ts +159 -0
  60. package/dist/tracking-script.d.ts +71 -0
  61. package/dist/tracking.d.ts +17 -0
  62. package/dist/tracking.js +26 -0
  63. package/dist/types.d.ts +595 -0
  64. package/dist/utils/geolocation.d.ts +111 -0
  65. package/dist/utils/user-agent.d.ts +17 -0
  66. package/dist/version.d.ts +7 -0
  67. package/package.json +119 -0
  68. package/src/Analytics.ts +3349 -0
  69. package/src/api.ts +1286 -0
  70. package/src/assets/crosswind.css +2220 -0
  71. package/src/batching.ts +452 -0
  72. package/src/components/dashboard/index.ts +18 -0
  73. package/src/config.ts +456 -0
  74. package/src/dashboard/Dashboard.stx +1517 -0
  75. package/src/dashboard/components/AlertCard.stx +177 -0
  76. package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
  77. package/src/dashboard/components/AnimatedNumber.stx +86 -0
  78. package/src/dashboard/components/BarChart.stx +220 -0
  79. package/src/dashboard/components/BrowserBreakdown.stx +98 -0
  80. package/src/dashboard/components/BrowsersTable.stx +125 -0
  81. package/src/dashboard/components/CampaignBreakdown.stx +163 -0
  82. package/src/dashboard/components/CampaignTable.stx +238 -0
  83. package/src/dashboard/components/CountryList.stx +101 -0
  84. package/src/dashboard/components/DataTable.stx +226 -0
  85. package/src/dashboard/components/DateRangePicker.stx +77 -0
  86. package/src/dashboard/components/DeviceBreakdown.stx +94 -0
  87. package/src/dashboard/components/DevicesTable.stx +163 -0
  88. package/src/dashboard/components/DonutChart.stories.ts +55 -0
  89. package/src/dashboard/components/DonutChart.stx +157 -0
  90. package/src/dashboard/components/EmptyState.stx +90 -0
  91. package/src/dashboard/components/EngagementMetrics.stx +183 -0
  92. package/src/dashboard/components/EventsSection.stx +192 -0
  93. package/src/dashboard/components/FilterBar.stx +104 -0
  94. package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
  95. package/src/dashboard/components/FunnelChart.stx +142 -0
  96. package/src/dashboard/components/GeoTable.stx +337 -0
  97. package/src/dashboard/components/GoalsPanel.stx +109 -0
  98. package/src/dashboard/components/Header.stx +39 -0
  99. package/src/dashboard/components/HeatmapChart.stx +140 -0
  100. package/src/dashboard/components/LiveActivityFeed.stx +172 -0
  101. package/src/dashboard/components/MetricComparison.stx +106 -0
  102. package/src/dashboard/components/MiniStats.stx +96 -0
  103. package/src/dashboard/components/OSBreakdown.stx +124 -0
  104. package/src/dashboard/components/PageDetailCard.stx +102 -0
  105. package/src/dashboard/components/PagesTable.stx +127 -0
  106. package/src/dashboard/components/ProgressRing.stx +89 -0
  107. package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
  108. package/src/dashboard/components/RealtimeCounter.stx +46 -0
  109. package/src/dashboard/components/ReferrersTable.stx +180 -0
  110. package/src/dashboard/components/SparklineChart.stx +160 -0
  111. package/src/dashboard/components/StatCard.stories.ts +58 -0
  112. package/src/dashboard/components/StatCard.stx +90 -0
  113. package/src/dashboard/components/SummaryStats.stx +81 -0
  114. package/src/dashboard/components/TabNav.stx +66 -0
  115. package/src/dashboard/components/ThemeSwitcher.stx +124 -0
  116. package/src/dashboard/components/TimeSeriesChart.stx +106 -0
  117. package/src/dashboard/components/TopList.stories.ts +58 -0
  118. package/src/dashboard/components/TopList.stx +74 -0
  119. package/src/dashboard/components/TrendIndicator.stx +84 -0
  120. package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
  121. package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
  122. package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
  123. package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
  124. package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
  125. package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
  126. package/src/dashboard/components/heatmap/index.ts +12 -0
  127. package/src/dashboard/components/index.ts +86 -0
  128. package/src/dashboard/composables/useAnalytics.ts +465 -0
  129. package/src/dashboard/demo/DemoApp.stx +370 -0
  130. package/src/dashboard/demo/index.ts +8 -0
  131. package/src/dashboard/demo/mockData.ts +234 -0
  132. package/src/dashboard/index.ts +117 -0
  133. package/src/dashboard/stx-shim.d.ts +4 -0
  134. package/src/dashboard/types/index.ts +203 -0
  135. package/src/dashboard/utils/index.ts +426 -0
  136. package/src/dynamodb.ts +344 -0
  137. package/src/funnels.ts +534 -0
  138. package/src/geolocation.ts +515 -0
  139. package/src/handlers/alerts.ts +328 -0
  140. package/src/handlers/annotations.ts +128 -0
  141. package/src/handlers/api-keys.ts +240 -0
  142. package/src/handlers/auth.ts +1020 -0
  143. package/src/handlers/authz.ts +137 -0
  144. package/src/handlers/collect.ts +724 -0
  145. package/src/handlers/data.ts +625 -0
  146. package/src/handlers/experiments.ts +138 -0
  147. package/src/handlers/funnels.ts +216 -0
  148. package/src/handlers/goals.ts +218 -0
  149. package/src/handlers/heatmaps.ts +272 -0
  150. package/src/handlers/index.ts +56 -0
  151. package/src/handlers/lib/read-cache.ts +63 -0
  152. package/src/handlers/misc.ts +486 -0
  153. package/src/handlers/oauth.ts +233 -0
  154. package/src/handlers/performance.ts +388 -0
  155. package/src/handlers/sessions.ts +413 -0
  156. package/src/handlers/sharing.ts +198 -0
  157. package/src/handlers/stats.ts +1368 -0
  158. package/src/handlers/team.ts +161 -0
  159. package/src/handlers/uptime.ts +283 -0
  160. package/src/handlers/views.ts +390 -0
  161. package/src/handlers/webhooks.ts +226 -0
  162. package/src/heatmap/index.ts +32 -0
  163. package/src/heatmap/tracking-script.ts +452 -0
  164. package/src/heatmap/types.ts +79 -0
  165. package/src/index.ts +387 -0
  166. package/src/infrastructure/cdk.ts +496 -0
  167. package/src/infrastructure/cloudformation.ts +595 -0
  168. package/src/infrastructure/index.ts +48 -0
  169. package/src/infrastructure/setup.ts +611 -0
  170. package/src/integrations/cloudflare.ts +732 -0
  171. package/src/integrations/hono.ts +589 -0
  172. package/src/integrations/index.ts +27 -0
  173. package/src/integrations/nuxt.ts +78 -0
  174. package/src/integrations/runtime/use-ts-analytics.ts +32 -0
  175. package/src/integrations/stx.ts +138 -0
  176. package/src/jobs/index.ts +127 -0
  177. package/src/lib/crypto-random.ts +41 -0
  178. package/src/lib/ddb-errors.ts +20 -0
  179. package/src/lib/dynamodb.ts +216 -0
  180. package/src/lib/email.ts +38 -0
  181. package/src/lib/ga-import.ts +471 -0
  182. package/src/lib/ga4-api.ts +244 -0
  183. package/src/lib/goals.ts +205 -0
  184. package/src/lib/index.ts +6 -0
  185. package/src/lib/ingest-counters.ts +123 -0
  186. package/src/lib/log.ts +36 -0
  187. package/src/lib/membership.ts +98 -0
  188. package/src/lib/plans.ts +90 -0
  189. package/src/lib/quota.ts +46 -0
  190. package/src/lib/rate-limit.ts +27 -0
  191. package/src/lib/rollups.ts +472 -0
  192. package/src/lib/salt.ts +97 -0
  193. package/src/lib/scheduler.ts +97 -0
  194. package/src/lib/significance.ts +66 -0
  195. package/src/lib/site-retention.ts +56 -0
  196. package/src/local.ts +360 -0
  197. package/src/model-connector.ts +616 -0
  198. package/src/models/AggregatedStats.ts +162 -0
  199. package/src/models/CampaignStats.ts +141 -0
  200. package/src/models/Conversion.ts +135 -0
  201. package/src/models/CustomEvent.ts +123 -0
  202. package/src/models/DeviceStats.ts +105 -0
  203. package/src/models/EventStats.ts +108 -0
  204. package/src/models/GeoStats.ts +112 -0
  205. package/src/models/Goal.ts +105 -0
  206. package/src/models/GoalStats.ts +106 -0
  207. package/src/models/PageStats.ts +152 -0
  208. package/src/models/PageView.ts +277 -0
  209. package/src/models/RealtimeStats.ts +96 -0
  210. package/src/models/ReferrerStats.ts +115 -0
  211. package/src/models/Session.ts +235 -0
  212. package/src/models/Site.ts +115 -0
  213. package/src/models/index.ts +50 -0
  214. package/src/models/orm/index.ts +2043 -0
  215. package/src/models/types.ts +71 -0
  216. package/src/router.ts +521 -0
  217. package/src/sqs-buffering.ts +806 -0
  218. package/src/stacks-integration.ts +527 -0
  219. package/src/tracking-script.ts +944 -0
  220. package/src/tracking.ts +27 -0
  221. package/src/types/analytics.ts +219 -0
  222. package/src/types/api.ts +75 -0
  223. package/src/types/bun-router.d.ts +15 -0
  224. package/src/types/dashboard.ts +62 -0
  225. package/src/types/index.ts +66 -0
  226. package/src/types/stx.d.ts +27 -0
  227. package/src/types/window.d.ts +101 -0
  228. package/src/types.ts +911 -0
  229. package/src/utils/cache.ts +148 -0
  230. package/src/utils/date.ts +118 -0
  231. package/src/utils/filters.ts +71 -0
  232. package/src/utils/geolocation.ts +323 -0
  233. package/src/utils/index.ts +9 -0
  234. package/src/utils/response.ts +180 -0
  235. package/src/utils/timezone-country.ts +242 -0
  236. package/src/utils/user-agent.ts +110 -0
  237. package/src/version.ts +7 -0
@@ -0,0 +1,3349 @@
1
+ // ============================================================================
2
+ // Analytics - Privacy-Focused Web Analytics for DynamoDB Single-Table Design
3
+ // ============================================================================
4
+ // Inspired by Fathom Analytics - simple, privacy-first analytics
5
+ // Designed for DynamoDB single-table pattern with efficient access patterns
6
+ //
7
+ // Types are defined in ./types.ts and models in ./models/
8
+ //
9
+ // MIXED FILE (#95) -- what is live vs legacy:
10
+ // LIVE generateTrackingScript / generateMinimalTrackingScript -- the served
11
+ // tracker (src/handlers/views.ts handleScript builds /script.js here).
12
+ // LEGACY AnalyticsStore / AnalyticsQueryAPI / AggregationPipeline /
13
+ // AnalyticsAggregator -- the in-memory layer behind src/api.ts; not
14
+ // used by the live server. The real request path is src/router.ts +
15
+ // src/handlers/* (DynamoDB); daily pre-aggregation is src/lib/rollups.ts.
16
+ // ============================================================================
17
+
18
+ // Import types from types.ts for internal use
19
+ // Note: Some types are defined locally in this file and exported directly
20
+ import type { AggregatedStats, AggregationPeriod, AnalyticsStoreOptions, CampaignStats, CustomEvent, DeviceStats, DeviceType, EventStats, GeoStats, Goal, GoalStats, GoalType, PageStats, PageView, RealtimeStats, ReferrerStats, Session, Site, SiteSettings } from './types'
21
+ import { TRACKER_VERSION } from './version'
22
+
23
+ // Re-export types from types.ts
24
+ export type {
25
+ AggregatedStats,
26
+ AggregationPeriod,
27
+ AnalyticsStoreOptions,
28
+ CampaignStats,
29
+ CustomEvent,
30
+ DeviceStats,
31
+ DeviceType,
32
+ EventStats,
33
+ GeoStats,
34
+ Goal,
35
+ GoalStats,
36
+ GoalType,
37
+ PageStats,
38
+ PageView,
39
+ RealtimeStats,
40
+ ReferrerStats,
41
+ Session,
42
+ Site,
43
+ SiteSettings,
44
+ }
45
+
46
+ // ============================================================================
47
+ // Analytics Store - DynamoDB Operations
48
+ // ============================================================================
49
+
50
+ /**
51
+ * DynamoDB key patterns for analytics entities
52
+ */
53
+ export const AnalyticsKeyPatterns = {
54
+ // Sites
55
+ site: {
56
+ pk: (siteId: string): string => `SITE#${siteId}`,
57
+ sk: (siteId: string): string => `SITE#${siteId}`,
58
+ gsi1pk: (ownerId: string): string => `OWNER#${ownerId}`,
59
+ gsi1sk: (siteId: string): string => `SITE#${siteId}`,
60
+ },
61
+
62
+ // Page Views (raw events)
63
+ pageView: {
64
+ pk: (siteId: string): string => `SITE#${siteId}`,
65
+ sk: (timestamp: Date, pageViewId: string): string => `PV#${timestamp.toISOString()}#${pageViewId}`,
66
+ gsi1pk: (siteId: string, date: string): string => `SITE#${siteId}#DATE#${date}`,
67
+ gsi1sk: (path: string, pageViewId: string): string => `PATH#${path}#${pageViewId}`,
68
+ gsi2pk: (siteId: string, visitorId: string): string => `SITE#${siteId}#VISITOR#${visitorId}`,
69
+ gsi2sk: (timestamp: Date): string => `PV#${timestamp.toISOString()}`,
70
+ },
71
+
72
+ // Sessions
73
+ session: {
74
+ pk: (siteId: string): string => `SITE#${siteId}`,
75
+ sk: (sessionId: string): string => `SESSION#${sessionId}`,
76
+ gsi1pk: (siteId: string, date: string): string => `SITE#${siteId}#SESSIONS#${date}`,
77
+ gsi1sk: (sessionId: string): string => `SESSION#${sessionId}`,
78
+ },
79
+
80
+ // Custom Events
81
+ customEvent: {
82
+ pk: (siteId: string): string => `SITE#${siteId}`,
83
+ sk: (timestamp: Date, eventId: string): string => `EVENT#${timestamp.toISOString()}#${eventId}`,
84
+ gsi1pk: (siteId: string, eventName: string): string => `SITE#${siteId}#EVENTNAME#${eventName}`,
85
+ gsi1sk: (timestamp: Date): string => `EVENT#${timestamp.toISOString()}`,
86
+ },
87
+
88
+ // Aggregated Stats (daily/hourly/monthly)
89
+ aggregatedStats: {
90
+ pk: (siteId: string): string => `SITE#${siteId}`,
91
+ sk: (period: AggregationPeriod, periodStart: string): string => `STATS#${period.toUpperCase()}#${periodStart}`,
92
+ },
93
+
94
+ // Page Stats
95
+ pageStats: {
96
+ pk: (siteId: string): string => `SITE#${siteId}`,
97
+ sk: (period: AggregationPeriod, periodStart: string, path: string): string =>
98
+ `PAGESTATS#${period.toUpperCase()}#${periodStart}#${encodeURIComponent(path)}`,
99
+ gsi1pk: (siteId: string, period: AggregationPeriod, periodStart: string): string =>
100
+ `SITE#${siteId}#PAGESTATS#${period.toUpperCase()}#${periodStart}`,
101
+ gsi1sk: (pageViews: number, path: string): string =>
102
+ `PV#${String(pageViews).padStart(10, '0')}#${encodeURIComponent(path)}`,
103
+ },
104
+
105
+ // Referrer Stats
106
+ referrerStats: {
107
+ pk: (siteId: string): string => `SITE#${siteId}`,
108
+ sk: (period: AggregationPeriod, periodStart: string, source: string): string =>
109
+ `REFSTATS#${period.toUpperCase()}#${periodStart}#${source}`,
110
+ },
111
+
112
+ // Geo Stats
113
+ geoStats: {
114
+ pk: (siteId: string): string => `SITE#${siteId}`,
115
+ sk: (period: AggregationPeriod, periodStart: string, country: string, region?: string): string =>
116
+ `GEOSTATS#${period.toUpperCase()}#${periodStart}#${country}${region ? `#${region}` : ''}`,
117
+ },
118
+
119
+ // Device Stats
120
+ deviceStats: {
121
+ pk: (siteId: string): string => `SITE#${siteId}`,
122
+ sk: (period: AggregationPeriod, periodStart: string, dimension: string, value: string): string =>
123
+ `DEVICESTATS#${period.toUpperCase()}#${periodStart}#${dimension}#${value}`,
124
+ },
125
+
126
+ // Campaign Stats
127
+ campaignStats: {
128
+ pk: (siteId: string): string => `SITE#${siteId}`,
129
+ sk: (period: AggregationPeriod, periodStart: string, utmSource: string, utmCampaign?: string): string =>
130
+ `CAMPSTATS#${period.toUpperCase()}#${periodStart}#${utmSource}${utmCampaign ? `#${utmCampaign}` : ''}`,
131
+ },
132
+
133
+ // Event Stats
134
+ eventStats: {
135
+ pk: (siteId: string): string => `SITE#${siteId}`,
136
+ sk: (period: AggregationPeriod, periodStart: string, eventName: string): string =>
137
+ `EVENTSTATS#${period.toUpperCase()}#${periodStart}#${eventName}`,
138
+ },
139
+
140
+ // Goals
141
+ goal: {
142
+ pk: (siteId: string): string => `SITE#${siteId}`,
143
+ sk: (goalId: string): string => `GOAL#${goalId}`,
144
+ },
145
+
146
+ // Goal Stats
147
+ goalStats: {
148
+ pk: (siteId: string): string => `SITE#${siteId}`,
149
+ sk: (goalId: string, period: AggregationPeriod, periodStart: string): string =>
150
+ `GOALSTATS#${goalId}#${period.toUpperCase()}#${periodStart}`,
151
+ },
152
+
153
+ // Realtime Stats
154
+ realtimeStats: {
155
+ pk: (siteId: string): string => `SITE#${siteId}`,
156
+ sk: (minute: string): string => `REALTIME#${minute}`,
157
+ },
158
+ } as const
159
+
160
+ /**
161
+ * Analytics Store for DynamoDB
162
+ */
163
+ export class AnalyticsStore {
164
+ private options: Required<AnalyticsStoreOptions>
165
+
166
+ constructor(options: AnalyticsStoreOptions) {
167
+ this.options = {
168
+ useTtl: true,
169
+ rawEventTtl: 30 * 24 * 60 * 60, // 30 days
170
+ hourlyAggregateTtl: 90 * 24 * 60 * 60, // 90 days
171
+ dailyAggregateTtl: 2 * 365 * 24 * 60 * 60, // 2 years
172
+ ...options,
173
+ }
174
+ }
175
+
176
+ // ==========================================================================
177
+ // Site Operations
178
+ // ==========================================================================
179
+
180
+ /**
181
+ * Generate command to create a site
182
+ */
183
+ createSiteCommand(site: Site): {
184
+ command: 'PutItem'
185
+ input: {
186
+ TableName: string
187
+ Item: Record<string, unknown>
188
+ ConditionExpression: string
189
+ }
190
+ } {
191
+ const keys = AnalyticsKeyPatterns.site
192
+ return {
193
+ command: 'PutItem',
194
+ input: {
195
+ TableName: this.options.tableName,
196
+ Item: {
197
+ pk: { S: keys.pk(site.id) },
198
+ sk: { S: keys.sk(site.id) },
199
+ gsi1pk: { S: keys.gsi1pk(site.ownerId) },
200
+ gsi1sk: { S: keys.gsi1sk(site.id) },
201
+ id: { S: site.id },
202
+ name: { S: site.name },
203
+ domains: { L: site.domains.map(d => ({ S: d })) },
204
+ timezone: { S: site.timezone },
205
+ isActive: { BOOL: site.isActive },
206
+ ownerId: { S: site.ownerId },
207
+ settings: { S: JSON.stringify(site.settings) },
208
+ createdAt: { S: site.createdAt.toISOString() },
209
+ updatedAt: { S: site.updatedAt.toISOString() },
210
+ _et: { S: 'Site' },
211
+ },
212
+ ConditionExpression: 'attribute_not_exists(pk)',
213
+ },
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Generate command to get a site by ID
219
+ */
220
+ getSiteCommand(siteId: string): {
221
+ command: 'GetItem'
222
+ input: {
223
+ TableName: string
224
+ Key: Record<string, unknown>
225
+ }
226
+ } {
227
+ const keys = AnalyticsKeyPatterns.site
228
+ return {
229
+ command: 'GetItem',
230
+ input: {
231
+ TableName: this.options.tableName,
232
+ Key: {
233
+ pk: { S: keys.pk(siteId) },
234
+ sk: { S: keys.sk(siteId) },
235
+ },
236
+ },
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Generate command to list sites by owner
242
+ */
243
+ listSitesByOwnerCommand(ownerId: string): {
244
+ command: 'Query'
245
+ input: {
246
+ TableName: string
247
+ IndexName: string
248
+ KeyConditionExpression: string
249
+ ExpressionAttributeValues: Record<string, unknown>
250
+ }
251
+ } {
252
+ const keys = AnalyticsKeyPatterns.site
253
+ return {
254
+ command: 'Query',
255
+ input: {
256
+ TableName: this.options.tableName,
257
+ IndexName: 'GSI1',
258
+ KeyConditionExpression: 'gsi1pk = :pk',
259
+ ExpressionAttributeValues: {
260
+ ':pk': { S: keys.gsi1pk(ownerId) },
261
+ },
262
+ },
263
+ }
264
+ }
265
+
266
+ // ==========================================================================
267
+ // Page View Operations
268
+ // ==========================================================================
269
+
270
+ /**
271
+ * Generate command to record a page view
272
+ */
273
+ recordPageViewCommand(pageView: PageView): {
274
+ command: 'PutItem'
275
+ input: {
276
+ TableName: string
277
+ Item: Record<string, unknown>
278
+ }
279
+ } {
280
+ const keys = AnalyticsKeyPatterns.pageView
281
+ const date = pageView.timestamp.toISOString().split('T')[0]
282
+ const ttl = this.options.useTtl
283
+ ? Math.floor(Date.now() / 1000) + this.options.rawEventTtl
284
+ : undefined
285
+
286
+ const item: Record<string, unknown> = {
287
+ pk: { S: keys.pk(pageView.siteId) },
288
+ sk: { S: keys.sk(pageView.timestamp, pageView.id) },
289
+ gsi1pk: { S: keys.gsi1pk(pageView.siteId, date) },
290
+ gsi1sk: { S: keys.gsi1sk(pageView.path, pageView.id) },
291
+ gsi2pk: { S: keys.gsi2pk(pageView.siteId, pageView.visitorId) },
292
+ gsi2sk: { S: keys.gsi2sk(pageView.timestamp) },
293
+ id: { S: pageView.id },
294
+ siteId: { S: pageView.siteId },
295
+ visitorId: { S: pageView.visitorId },
296
+ sessionId: { S: pageView.sessionId },
297
+ path: { S: pageView.path },
298
+ hostname: { S: pageView.hostname },
299
+ isUnique: { BOOL: pageView.isUnique },
300
+ isBounce: { BOOL: pageView.isBounce },
301
+ timestamp: { S: pageView.timestamp.toISOString() },
302
+ _et: { S: 'PageView' },
303
+ }
304
+
305
+ // Add optional fields
306
+ if (pageView.title)
307
+ item.title = { S: pageView.title }
308
+ if (pageView.referrer)
309
+ item.referrer = { S: pageView.referrer }
310
+ if (pageView.referrerSource)
311
+ item.referrerSource = { S: pageView.referrerSource }
312
+ if (pageView.utmSource)
313
+ item.utmSource = { S: pageView.utmSource }
314
+ if (pageView.utmMedium)
315
+ item.utmMedium = { S: pageView.utmMedium }
316
+ if (pageView.utmCampaign)
317
+ item.utmCampaign = { S: pageView.utmCampaign }
318
+ if (pageView.utmContent)
319
+ item.utmContent = { S: pageView.utmContent }
320
+ if (pageView.utmTerm)
321
+ item.utmTerm = { S: pageView.utmTerm }
322
+ if (pageView.country)
323
+ item.country = { S: pageView.country }
324
+ if (pageView.region)
325
+ item.region = { S: pageView.region }
326
+ if (pageView.city)
327
+ item.city = { S: pageView.city }
328
+ if (pageView.deviceType)
329
+ item.deviceType = { S: pageView.deviceType }
330
+ if (pageView.browser)
331
+ item.browser = { S: pageView.browser }
332
+ if (pageView.browserVersion)
333
+ item.browserVersion = { S: pageView.browserVersion }
334
+ if (pageView.os)
335
+ item.os = { S: pageView.os }
336
+ if (pageView.osVersion)
337
+ item.osVersion = { S: pageView.osVersion }
338
+ if (pageView.screenWidth)
339
+ item.screenWidth = { N: String(pageView.screenWidth) }
340
+ if (pageView.screenHeight)
341
+ item.screenHeight = { N: String(pageView.screenHeight) }
342
+ if (pageView.timeOnPage !== undefined)
343
+ item.timeOnPage = { N: String(pageView.timeOnPage) }
344
+ if (ttl)
345
+ item.ttl = { N: String(ttl) }
346
+
347
+ return {
348
+ command: 'PutItem',
349
+ input: {
350
+ TableName: this.options.tableName,
351
+ Item: item,
352
+ },
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Generate command to query page views for a date range
358
+ */
359
+ queryPageViewsCommand(
360
+ siteId: string,
361
+ startDate: Date,
362
+ endDate: Date,
363
+ options?: { path?: string, limit?: number },
364
+ ): {
365
+ command: 'Query'
366
+ input: {
367
+ TableName: string
368
+ KeyConditionExpression: string
369
+ ExpressionAttributeNames: Record<string, string>
370
+ ExpressionAttributeValues: Record<string, unknown>
371
+ FilterExpression?: string
372
+ Limit?: number
373
+ ScanIndexForward: boolean
374
+ }
375
+ } {
376
+ const keys = AnalyticsKeyPatterns.pageView
377
+ const startSk = keys.sk(startDate, '')
378
+ const endSk = keys.sk(endDate, 'zzz')
379
+
380
+ const input: {
381
+ TableName: string
382
+ KeyConditionExpression: string
383
+ ExpressionAttributeNames: Record<string, string>
384
+ ExpressionAttributeValues: Record<string, unknown>
385
+ FilterExpression?: string
386
+ Limit?: number
387
+ ScanIndexForward: boolean
388
+ } = {
389
+ TableName: this.options.tableName,
390
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
391
+ ExpressionAttributeNames: {},
392
+ ExpressionAttributeValues: {
393
+ ':pk': { S: keys.pk(siteId) },
394
+ ':start': { S: startSk },
395
+ ':end': { S: endSk },
396
+ },
397
+ ScanIndexForward: false,
398
+ }
399
+
400
+ if (options?.path) {
401
+ input.FilterExpression = '#path = :path'
402
+ input.ExpressionAttributeNames['#path'] = 'path'
403
+ input.ExpressionAttributeValues[':path'] = { S: options.path }
404
+ }
405
+
406
+ if (options?.limit) {
407
+ input.Limit = options.limit
408
+ }
409
+
410
+ return {
411
+ command: 'Query',
412
+ input,
413
+ }
414
+ }
415
+
416
+ // ==========================================================================
417
+ // Session Operations
418
+ // ==========================================================================
419
+
420
+ /**
421
+ * Generate command to create/update a session
422
+ */
423
+ upsertSessionCommand(session: Session): {
424
+ command: 'PutItem'
425
+ input: {
426
+ TableName: string
427
+ Item: Record<string, unknown>
428
+ }
429
+ } {
430
+ const keys = AnalyticsKeyPatterns.session
431
+ const date = session.startedAt.toISOString().split('T')[0]
432
+ const ttl = this.options.useTtl
433
+ ? Math.floor(Date.now() / 1000) + this.options.rawEventTtl
434
+ : undefined
435
+
436
+ const item: Record<string, unknown> = {
437
+ pk: { S: keys.pk(session.siteId) },
438
+ sk: { S: keys.sk(session.id) },
439
+ gsi1pk: { S: keys.gsi1pk(session.siteId, date) },
440
+ gsi1sk: { S: keys.gsi1sk(session.id) },
441
+ id: { S: session.id },
442
+ siteId: { S: session.siteId },
443
+ visitorId: { S: session.visitorId },
444
+ entryPath: { S: session.entryPath },
445
+ exitPath: { S: session.exitPath },
446
+ pageViewCount: { N: String(session.pageViewCount) },
447
+ eventCount: { N: String(session.eventCount) },
448
+ isBounce: { BOOL: session.isBounce },
449
+ duration: { N: String(session.duration) },
450
+ startedAt: { S: session.startedAt.toISOString() },
451
+ endedAt: { S: session.endedAt.toISOString() },
452
+ _et: { S: 'Session' },
453
+ }
454
+
455
+ // Add optional fields
456
+ if (session.referrer)
457
+ item.referrer = { S: session.referrer }
458
+ if (session.referrerSource)
459
+ item.referrerSource = { S: session.referrerSource }
460
+ if (session.utmSource)
461
+ item.utmSource = { S: session.utmSource }
462
+ if (session.utmMedium)
463
+ item.utmMedium = { S: session.utmMedium }
464
+ if (session.utmCampaign)
465
+ item.utmCampaign = { S: session.utmCampaign }
466
+ if (session.country)
467
+ item.country = { S: session.country }
468
+ if (session.deviceType)
469
+ item.deviceType = { S: session.deviceType }
470
+ if (session.browser)
471
+ item.browser = { S: session.browser }
472
+ if (session.os)
473
+ item.os = { S: session.os }
474
+ if (ttl)
475
+ item.ttl = { N: String(ttl) }
476
+
477
+ return {
478
+ command: 'PutItem',
479
+ input: {
480
+ TableName: this.options.tableName,
481
+ Item: item,
482
+ },
483
+ }
484
+ }
485
+
486
+ // ==========================================================================
487
+ // Custom Event Operations
488
+ // ==========================================================================
489
+
490
+ /**
491
+ * Generate command to record a custom event
492
+ */
493
+ recordCustomEventCommand(event: CustomEvent): {
494
+ command: 'PutItem'
495
+ input: {
496
+ TableName: string
497
+ Item: Record<string, unknown>
498
+ }
499
+ } {
500
+ const keys = AnalyticsKeyPatterns.customEvent
501
+ const ttl = this.options.useTtl
502
+ ? Math.floor(Date.now() / 1000) + this.options.rawEventTtl
503
+ : undefined
504
+
505
+ const item: Record<string, unknown> = {
506
+ pk: { S: keys.pk(event.siteId) },
507
+ sk: { S: keys.sk(event.timestamp, event.id) },
508
+ gsi1pk: { S: keys.gsi1pk(event.siteId, event.name) },
509
+ gsi1sk: { S: keys.gsi1sk(event.timestamp) },
510
+ id: { S: event.id },
511
+ siteId: { S: event.siteId },
512
+ visitorId: { S: event.visitorId },
513
+ sessionId: { S: event.sessionId },
514
+ name: { S: event.name },
515
+ path: { S: event.path },
516
+ timestamp: { S: event.timestamp.toISOString() },
517
+ _et: { S: 'CustomEvent' },
518
+ }
519
+
520
+ if (event.value !== undefined)
521
+ item.value = { N: String(event.value) }
522
+ if (event.properties)
523
+ item.properties = { S: JSON.stringify(event.properties) }
524
+ if (ttl)
525
+ item.ttl = { N: String(ttl) }
526
+
527
+ return {
528
+ command: 'PutItem',
529
+ input: {
530
+ TableName: this.options.tableName,
531
+ Item: item,
532
+ },
533
+ }
534
+ }
535
+
536
+ // ==========================================================================
537
+ // Aggregated Stats Operations
538
+ // ==========================================================================
539
+
540
+ /**
541
+ * Generate command to upsert aggregated stats
542
+ */
543
+ upsertAggregatedStatsCommand(stats: AggregatedStats): {
544
+ command: 'UpdateItem'
545
+ input: {
546
+ TableName: string
547
+ Key: Record<string, unknown>
548
+ UpdateExpression: string
549
+ ExpressionAttributeNames: Record<string, string>
550
+ ExpressionAttributeValues: Record<string, unknown>
551
+ }
552
+ } {
553
+ const keys = AnalyticsKeyPatterns.aggregatedStats
554
+ const ttl = this.getTtlForPeriod(stats.period)
555
+
556
+ return {
557
+ command: 'UpdateItem',
558
+ input: {
559
+ TableName: this.options.tableName,
560
+ Key: {
561
+ pk: { S: keys.pk(stats.siteId) },
562
+ sk: { S: keys.sk(stats.period, stats.periodStart) },
563
+ },
564
+ UpdateExpression: `
565
+ SET #pv = if_not_exists(#pv, :zero) + :pv,
566
+ #uv = if_not_exists(#uv, :zero) + :uv,
567
+ #sessions = if_not_exists(#sessions, :zero) + :sessions,
568
+ #bounces = if_not_exists(#bounces, :zero) + :bounces,
569
+ #totalTime = if_not_exists(#totalTime, :zero) + :totalTime,
570
+ #newVisitors = if_not_exists(#newVisitors, :zero) + :newVisitors,
571
+ #retVisitors = if_not_exists(#retVisitors, :zero) + :retVisitors,
572
+ #updatedAt = :now,
573
+ #et = :et
574
+ ${ttl ? ', #ttl = :ttl' : ''}
575
+ `.trim(),
576
+ ExpressionAttributeNames: {
577
+ '#pv': 'pageViews',
578
+ '#uv': 'uniqueVisitors',
579
+ '#sessions': 'sessions',
580
+ '#bounces': 'bounces',
581
+ '#totalTime': 'totalTimeOnSite',
582
+ '#newVisitors': 'newVisitors',
583
+ '#retVisitors': 'returningVisitors',
584
+ '#updatedAt': 'updatedAt',
585
+ '#et': '_et',
586
+ ...(ttl ? { '#ttl': 'ttl' } : {}),
587
+ },
588
+ ExpressionAttributeValues: {
589
+ ':pv': { N: String(stats.pageViews) },
590
+ ':uv': { N: String(stats.uniqueVisitors) },
591
+ ':sessions': { N: String(stats.sessions) },
592
+ ':bounces': { N: String(stats.bounces) },
593
+ ':totalTime': { N: String(stats.totalTimeOnSite) },
594
+ ':newVisitors': { N: String(stats.newVisitors) },
595
+ ':retVisitors': { N: String(stats.returningVisitors) },
596
+ ':zero': { N: '0' },
597
+ ':now': { S: new Date().toISOString() },
598
+ ':et': { S: 'AggregatedStats' },
599
+ ...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
600
+ },
601
+ },
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Generate command to get aggregated stats for a date range
607
+ */
608
+ getAggregatedStatsCommand(
609
+ siteId: string,
610
+ period: AggregationPeriod,
611
+ startPeriod: string,
612
+ endPeriod: string,
613
+ ): {
614
+ command: 'Query'
615
+ input: {
616
+ TableName: string
617
+ KeyConditionExpression: string
618
+ ExpressionAttributeValues: Record<string, unknown>
619
+ ScanIndexForward: boolean
620
+ }
621
+ } {
622
+ const keys = AnalyticsKeyPatterns.aggregatedStats
623
+ return {
624
+ command: 'Query',
625
+ input: {
626
+ TableName: this.options.tableName,
627
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
628
+ ExpressionAttributeValues: {
629
+ ':pk': { S: keys.pk(siteId) },
630
+ ':start': { S: keys.sk(period, startPeriod) },
631
+ ':end': { S: keys.sk(period, endPeriod) },
632
+ },
633
+ ScanIndexForward: true,
634
+ },
635
+ }
636
+ }
637
+
638
+ // ==========================================================================
639
+ // Page Stats Operations
640
+ // ==========================================================================
641
+
642
+ /**
643
+ * Generate command to upsert page stats
644
+ */
645
+ upsertPageStatsCommand(stats: PageStats): {
646
+ command: 'UpdateItem'
647
+ input: {
648
+ TableName: string
649
+ Key: Record<string, unknown>
650
+ UpdateExpression: string
651
+ ExpressionAttributeNames: Record<string, string>
652
+ ExpressionAttributeValues: Record<string, unknown>
653
+ }
654
+ } {
655
+ const keys = AnalyticsKeyPatterns.pageStats
656
+ const ttl = this.getTtlForPeriod(stats.period)
657
+
658
+ return {
659
+ command: 'UpdateItem',
660
+ input: {
661
+ TableName: this.options.tableName,
662
+ Key: {
663
+ pk: { S: keys.pk(stats.siteId) },
664
+ sk: { S: keys.sk(stats.period, stats.periodStart, stats.path) },
665
+ },
666
+ UpdateExpression: `
667
+ SET #pv = if_not_exists(#pv, :zero) + :pv,
668
+ #uv = if_not_exists(#uv, :zero) + :uv,
669
+ #entries = if_not_exists(#entries, :zero) + :entries,
670
+ #exits = if_not_exists(#exits, :zero) + :exits,
671
+ #bounces = if_not_exists(#bounces, :zero) + :bounces,
672
+ gsi1pk = :gsi1pk,
673
+ gsi1sk = :gsi1sk,
674
+ #path = :path,
675
+ #et = :et
676
+ ${stats.title ? ', #title = :title' : ''}
677
+ ${ttl ? ', #ttl = :ttl' : ''}
678
+ `.trim(),
679
+ ExpressionAttributeNames: {
680
+ '#pv': 'pageViews',
681
+ '#uv': 'uniqueVisitors',
682
+ '#entries': 'entries',
683
+ '#exits': 'exits',
684
+ '#bounces': 'bounces',
685
+ '#path': 'path',
686
+ '#et': '_et',
687
+ ...(stats.title ? { '#title': 'title' } : {}),
688
+ ...(ttl ? { '#ttl': 'ttl' } : {}),
689
+ },
690
+ ExpressionAttributeValues: {
691
+ ':pv': { N: String(stats.pageViews) },
692
+ ':uv': { N: String(stats.uniqueVisitors) },
693
+ ':entries': { N: String(stats.entries) },
694
+ ':exits': { N: String(stats.exits) },
695
+ ':bounces': { N: String(stats.bounces) },
696
+ ':gsi1pk': { S: keys.gsi1pk(stats.siteId, stats.period, stats.periodStart) },
697
+ ':gsi1sk': { S: keys.gsi1sk(stats.pageViews, stats.path) },
698
+ ':path': { S: stats.path },
699
+ ':zero': { N: '0' },
700
+ ':et': { S: 'PageStats' },
701
+ ...(stats.title ? { ':title': { S: stats.title } } : {}),
702
+ ...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
703
+ },
704
+ },
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Generate command to get top pages for a period
710
+ */
711
+ getTopPagesCommand(
712
+ siteId: string,
713
+ period: AggregationPeriod,
714
+ periodStart: string,
715
+ limit: number = 10,
716
+ ): {
717
+ command: 'Query'
718
+ input: {
719
+ TableName: string
720
+ IndexName: string
721
+ KeyConditionExpression: string
722
+ ExpressionAttributeValues: Record<string, unknown>
723
+ Limit: number
724
+ ScanIndexForward: boolean
725
+ }
726
+ } {
727
+ const keys = AnalyticsKeyPatterns.pageStats
728
+ return {
729
+ command: 'Query',
730
+ input: {
731
+ TableName: this.options.tableName,
732
+ IndexName: 'GSI1',
733
+ KeyConditionExpression: 'gsi1pk = :pk',
734
+ ExpressionAttributeValues: {
735
+ ':pk': { S: keys.gsi1pk(siteId, period, periodStart) },
736
+ },
737
+ Limit: limit,
738
+ ScanIndexForward: false, // Descending order by page views
739
+ },
740
+ }
741
+ }
742
+
743
+ // ==========================================================================
744
+ // Referrer Stats Operations
745
+ // ==========================================================================
746
+
747
+ /**
748
+ * Generate command to upsert referrer stats
749
+ */
750
+ upsertReferrerStatsCommand(stats: ReferrerStats): {
751
+ command: 'UpdateItem'
752
+ input: {
753
+ TableName: string
754
+ Key: Record<string, unknown>
755
+ UpdateExpression: string
756
+ ExpressionAttributeNames: Record<string, string>
757
+ ExpressionAttributeValues: Record<string, unknown>
758
+ }
759
+ } {
760
+ const keys = AnalyticsKeyPatterns.referrerStats
761
+ const ttl = this.getTtlForPeriod(stats.period)
762
+
763
+ return {
764
+ command: 'UpdateItem',
765
+ input: {
766
+ TableName: this.options.tableName,
767
+ Key: {
768
+ pk: { S: keys.pk(stats.siteId) },
769
+ sk: { S: keys.sk(stats.period, stats.periodStart, stats.source) },
770
+ },
771
+ UpdateExpression: `
772
+ SET #visitors = if_not_exists(#visitors, :zero) + :visitors,
773
+ #pv = if_not_exists(#pv, :zero) + :pv,
774
+ #source = :source,
775
+ #et = :et
776
+ ${stats.referrer ? ', #referrer = :referrer' : ''}
777
+ ${ttl ? ', #ttl = :ttl' : ''}
778
+ `.trim(),
779
+ ExpressionAttributeNames: {
780
+ '#visitors': 'visitors',
781
+ '#pv': 'pageViews',
782
+ '#source': 'source',
783
+ '#et': '_et',
784
+ ...(stats.referrer ? { '#referrer': 'referrer' } : {}),
785
+ ...(ttl ? { '#ttl': 'ttl' } : {}),
786
+ },
787
+ ExpressionAttributeValues: {
788
+ ':visitors': { N: String(stats.visitors) },
789
+ ':pv': { N: String(stats.pageViews) },
790
+ ':source': { S: stats.source },
791
+ ':zero': { N: '0' },
792
+ ':et': { S: 'ReferrerStats' },
793
+ ...(stats.referrer ? { ':referrer': { S: stats.referrer } } : {}),
794
+ ...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
795
+ },
796
+ },
797
+ }
798
+ }
799
+
800
+ // ==========================================================================
801
+ // Goal Operations
802
+ // ==========================================================================
803
+
804
+ /**
805
+ * Generate command to create a goal
806
+ */
807
+ createGoalCommand(goal: Goal): {
808
+ command: 'PutItem'
809
+ input: {
810
+ TableName: string
811
+ Item: Record<string, unknown>
812
+ ConditionExpression: string
813
+ }
814
+ } {
815
+ const keys = AnalyticsKeyPatterns.goal
816
+ return {
817
+ command: 'PutItem',
818
+ input: {
819
+ TableName: this.options.tableName,
820
+ Item: {
821
+ pk: { S: keys.pk(goal.siteId) },
822
+ sk: { S: keys.sk(goal.id) },
823
+ id: { S: goal.id },
824
+ siteId: { S: goal.siteId },
825
+ name: { S: goal.name },
826
+ type: { S: goal.type },
827
+ pattern: { S: goal.pattern },
828
+ matchType: { S: goal.matchType },
829
+ isActive: { BOOL: goal.isActive },
830
+ createdAt: { S: goal.createdAt.toISOString() },
831
+ updatedAt: { S: goal.updatedAt.toISOString() },
832
+ _et: { S: 'Goal' },
833
+ ...(goal.value !== undefined ? { value: { N: String(goal.value) } } : {}),
834
+ },
835
+ ConditionExpression: 'attribute_not_exists(pk)',
836
+ },
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Generate command to list goals for a site
842
+ */
843
+ listGoalsCommand(siteId: string): {
844
+ command: 'Query'
845
+ input: {
846
+ TableName: string
847
+ KeyConditionExpression: string
848
+ ExpressionAttributeValues: Record<string, unknown>
849
+ }
850
+ } {
851
+ const keys = AnalyticsKeyPatterns.goal
852
+ return {
853
+ command: 'Query',
854
+ input: {
855
+ TableName: this.options.tableName,
856
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
857
+ ExpressionAttributeValues: {
858
+ ':pk': { S: keys.pk(siteId) },
859
+ ':skPrefix': { S: 'GOAL#' },
860
+ },
861
+ },
862
+ }
863
+ }
864
+
865
+ // ==========================================================================
866
+ // Realtime Stats Operations
867
+ // ==========================================================================
868
+
869
+ /**
870
+ * Generate command to update realtime stats
871
+ * Uses a DynamoDB String Set to track unique visitor IDs per minute
872
+ */
873
+ updateRealtimeStatsCommand(stats: RealtimeStats & { visitorId?: string }): {
874
+ command: 'UpdateItem'
875
+ input: {
876
+ TableName: string
877
+ Key: Record<string, unknown>
878
+ UpdateExpression: string
879
+ ExpressionAttributeNames: Record<string, string>
880
+ ExpressionAttributeValues: Record<string, unknown>
881
+ }
882
+ } {
883
+ const keys = AnalyticsKeyPatterns.realtimeStats
884
+
885
+ // Build expression based on whether we have a visitor ID to track
886
+ const hasVisitorId = stats.visitorId && stats.visitorId.length > 0
887
+
888
+ return {
889
+ command: 'UpdateItem',
890
+ input: {
891
+ TableName: this.options.tableName,
892
+ Key: {
893
+ pk: { S: keys.pk(stats.siteId) },
894
+ sk: { S: keys.sk(stats.minute) },
895
+ },
896
+ UpdateExpression: hasVisitorId
897
+ ? `
898
+ SET #pv = if_not_exists(#pv, :zero) + :pvInc,
899
+ #activePages = :activePages,
900
+ #ttl = :ttl,
901
+ #et = :et
902
+ ADD #visitorIds :visitorId
903
+ `.trim()
904
+ : `
905
+ SET #pv = if_not_exists(#pv, :zero) + :pvInc,
906
+ #activePages = :activePages,
907
+ #ttl = :ttl,
908
+ #et = :et
909
+ `.trim(),
910
+ ExpressionAttributeNames: {
911
+ '#pv': 'pageViews',
912
+ '#activePages': 'activePages',
913
+ '#ttl': 'ttl',
914
+ '#et': '_et',
915
+ ...(hasVisitorId ? { '#visitorIds': 'visitorIds' } : {}),
916
+ },
917
+ ExpressionAttributeValues: {
918
+ ':pvInc': { N: '1' },
919
+ ':activePages': { S: JSON.stringify(stats.activePages) },
920
+ ':ttl': { N: String(stats.ttl) },
921
+ ':zero': { N: '0' },
922
+ ':et': { S: 'RealtimeStats' },
923
+ ...(hasVisitorId ? { ':visitorId': { SS: [stats.visitorId!] } } : {}),
924
+ },
925
+ },
926
+ }
927
+ }
928
+
929
+ /**
930
+ * Generate command to get realtime stats (last N minutes)
931
+ */
932
+ getRealtimeStatsCommand(siteId: string, minutes: number = 5): {
933
+ command: 'Query'
934
+ input: {
935
+ TableName: string
936
+ KeyConditionExpression: string
937
+ ExpressionAttributeValues: Record<string, unknown>
938
+ ScanIndexForward: boolean
939
+ Limit: number
940
+ }
941
+ } {
942
+ const keys = AnalyticsKeyPatterns.realtimeStats
943
+ const now = new Date()
944
+ const startMinute = new Date(now.getTime() - minutes * 60 * 1000)
945
+ .toISOString()
946
+ .slice(0, 16) // Truncate to minute
947
+
948
+ return {
949
+ command: 'Query',
950
+ input: {
951
+ TableName: this.options.tableName,
952
+ KeyConditionExpression: 'pk = :pk AND sk >= :start',
953
+ ExpressionAttributeValues: {
954
+ ':pk': { S: keys.pk(siteId) },
955
+ ':start': { S: keys.sk(startMinute) },
956
+ },
957
+ ScanIndexForward: false,
958
+ Limit: minutes,
959
+ },
960
+ }
961
+ }
962
+
963
+ // ==========================================================================
964
+ // Geo Stats Operations
965
+ // ==========================================================================
966
+
967
+ /**
968
+ * Generate command to upsert geo stats
969
+ */
970
+ upsertGeoStatsCommand(stats: GeoStats): {
971
+ command: 'UpdateItem'
972
+ input: {
973
+ TableName: string
974
+ Key: Record<string, unknown>
975
+ UpdateExpression: string
976
+ ExpressionAttributeNames: Record<string, string>
977
+ ExpressionAttributeValues: Record<string, unknown>
978
+ }
979
+ } {
980
+ const keys = AnalyticsKeyPatterns.geoStats
981
+ const ttl = this.getTtlForPeriod(stats.period)
982
+
983
+ return {
984
+ command: 'UpdateItem',
985
+ input: {
986
+ TableName: this.options.tableName,
987
+ Key: {
988
+ pk: { S: keys.pk(stats.siteId) },
989
+ sk: { S: keys.sk(stats.period, stats.periodStart, stats.country, stats.region) },
990
+ },
991
+ UpdateExpression: `
992
+ SET #visitors = if_not_exists(#visitors, :zero) + :visitors,
993
+ #pv = if_not_exists(#pv, :zero) + :pv,
994
+ #country = :country,
995
+ #et = :et
996
+ ${stats.region ? ', #region = :region' : ''}
997
+ ${stats.city ? ', #city = :city' : ''}
998
+ ${ttl ? ', #ttl = :ttl' : ''}
999
+ `.trim(),
1000
+ ExpressionAttributeNames: {
1001
+ '#visitors': 'visitors',
1002
+ '#pv': 'pageViews',
1003
+ '#country': 'country',
1004
+ '#et': '_et',
1005
+ ...(stats.region ? { '#region': 'region' } : {}),
1006
+ ...(stats.city ? { '#city': 'city' } : {}),
1007
+ ...(ttl ? { '#ttl': 'ttl' } : {}),
1008
+ },
1009
+ ExpressionAttributeValues: {
1010
+ ':visitors': { N: String(stats.visitors) },
1011
+ ':pv': { N: String(stats.pageViews) },
1012
+ ':country': { S: stats.country },
1013
+ ':zero': { N: '0' },
1014
+ ':et': { S: 'GeoStats' },
1015
+ ...(stats.region ? { ':region': { S: stats.region } } : {}),
1016
+ ...(stats.city ? { ':city': { S: stats.city } } : {}),
1017
+ ...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
1018
+ },
1019
+ },
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * Generate command to get geo stats for a period
1025
+ */
1026
+ getGeoStatsCommand(
1027
+ siteId: string,
1028
+ period: AggregationPeriod,
1029
+ periodStart: string,
1030
+ limit: number = 10,
1031
+ ): {
1032
+ command: 'Query'
1033
+ input: {
1034
+ TableName: string
1035
+ KeyConditionExpression: string
1036
+ ExpressionAttributeValues: Record<string, unknown>
1037
+ Limit: number
1038
+ }
1039
+ } {
1040
+ const keys = AnalyticsKeyPatterns.geoStats
1041
+ return {
1042
+ command: 'Query',
1043
+ input: {
1044
+ TableName: this.options.tableName,
1045
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
1046
+ ExpressionAttributeValues: {
1047
+ ':pk': { S: keys.pk(siteId) },
1048
+ ':skPrefix': { S: `GEOSTATS#${period.toUpperCase()}#${periodStart}` },
1049
+ },
1050
+ Limit: limit,
1051
+ },
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * Generate command to get regions for a country
1057
+ */
1058
+ getRegionsCommand(
1059
+ siteId: string,
1060
+ period: AggregationPeriod,
1061
+ periodStart: string,
1062
+ country?: string,
1063
+ limit: number = 10,
1064
+ ): {
1065
+ command: 'Query'
1066
+ input: {
1067
+ TableName: string
1068
+ KeyConditionExpression: string
1069
+ ExpressionAttributeValues: Record<string, unknown>
1070
+ FilterExpression?: string
1071
+ ExpressionAttributeNames?: Record<string, string>
1072
+ Limit: number
1073
+ }
1074
+ } {
1075
+ const keys = AnalyticsKeyPatterns.geoStats
1076
+ const skPrefix = country
1077
+ ? `GEOSTATS#${period.toUpperCase()}#${periodStart}#${country}#`
1078
+ : `GEOSTATS#${period.toUpperCase()}#${periodStart}`
1079
+
1080
+ const input: ReturnType<typeof this.getRegionsCommand>['input'] = {
1081
+ TableName: this.options.tableName,
1082
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
1083
+ ExpressionAttributeValues: {
1084
+ ':pk': { S: keys.pk(siteId) },
1085
+ ':skPrefix': { S: skPrefix },
1086
+ },
1087
+ Limit: limit,
1088
+ }
1089
+
1090
+ // Filter to only include records with region but without city (region-level aggregates)
1091
+ input.FilterExpression = 'attribute_exists(#region) AND attribute_not_exists(#city)'
1092
+ input.ExpressionAttributeNames = {
1093
+ '#region': 'region',
1094
+ '#city': 'city',
1095
+ }
1096
+
1097
+ return { command: 'Query', input }
1098
+ }
1099
+
1100
+ /**
1101
+ * Generate command to get cities, optionally filtered by country and/or region
1102
+ */
1103
+ getCitiesCommand(
1104
+ siteId: string,
1105
+ period: AggregationPeriod,
1106
+ periodStart: string,
1107
+ options?: { country?: string, region?: string },
1108
+ limit: number = 10,
1109
+ ): {
1110
+ command: 'Query'
1111
+ input: {
1112
+ TableName: string
1113
+ KeyConditionExpression: string
1114
+ ExpressionAttributeValues: Record<string, unknown>
1115
+ FilterExpression?: string
1116
+ ExpressionAttributeNames?: Record<string, string>
1117
+ Limit: number
1118
+ }
1119
+ } {
1120
+ const keys = AnalyticsKeyPatterns.geoStats
1121
+ let skPrefix = `GEOSTATS#${period.toUpperCase()}#${periodStart}`
1122
+
1123
+ if (options?.country) {
1124
+ skPrefix += `#${options.country}`
1125
+ if (options?.region) {
1126
+ skPrefix += `#${options.region}`
1127
+ }
1128
+ }
1129
+
1130
+ const input: { TableName: string; KeyConditionExpression: string; ExpressionAttributeValues: Record<string, unknown>; FilterExpression?: string; ExpressionAttributeNames?: Record<string, string>; Limit: number } = {
1131
+ TableName: this.options.tableName,
1132
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
1133
+ ExpressionAttributeValues: {
1134
+ ':pk': { S: keys.pk(siteId) },
1135
+ ':skPrefix': { S: skPrefix },
1136
+ },
1137
+ Limit: limit,
1138
+ }
1139
+
1140
+ // Filter to only include records with city (city-level aggregates)
1141
+ input.FilterExpression = 'attribute_exists(#city)'
1142
+ input.ExpressionAttributeNames = { '#city': 'city' }
1143
+
1144
+ // Add region filter if country is specified but region is not (filter by region attribute)
1145
+ if (options?.country && options?.region) {
1146
+ input.FilterExpression += ' AND #region = :region'
1147
+ input.ExpressionAttributeNames['#region'] = 'region'
1148
+ input.ExpressionAttributeValues[':region'] = { S: options.region }
1149
+ }
1150
+
1151
+ return { command: 'Query', input }
1152
+ }
1153
+
1154
+ // ==========================================================================
1155
+ // Device Stats Operations
1156
+ // ==========================================================================
1157
+
1158
+ /**
1159
+ * Generate command to upsert device stats
1160
+ */
1161
+ upsertDeviceStatsCommand(stats: DeviceStats): {
1162
+ command: 'UpdateItem'
1163
+ input: {
1164
+ TableName: string
1165
+ Key: Record<string, unknown>
1166
+ UpdateExpression: string
1167
+ ExpressionAttributeNames: Record<string, string>
1168
+ ExpressionAttributeValues: Record<string, unknown>
1169
+ }
1170
+ } {
1171
+ const keys = AnalyticsKeyPatterns.deviceStats
1172
+ const ttl = this.getTtlForPeriod(stats.period)
1173
+
1174
+ return {
1175
+ command: 'UpdateItem',
1176
+ input: {
1177
+ TableName: this.options.tableName,
1178
+ Key: {
1179
+ pk: { S: keys.pk(stats.siteId) },
1180
+ sk: { S: keys.sk(stats.period, stats.periodStart, stats.dimension, stats.value) },
1181
+ },
1182
+ UpdateExpression: `
1183
+ SET #visitors = if_not_exists(#visitors, :zero) + :visitors,
1184
+ #pv = if_not_exists(#pv, :zero) + :pv,
1185
+ #dimension = :dimension,
1186
+ #value = :value,
1187
+ #et = :et
1188
+ ${ttl ? ', #ttl = :ttl' : ''}
1189
+ `.trim(),
1190
+ ExpressionAttributeNames: {
1191
+ '#visitors': 'visitors',
1192
+ '#pv': 'pageViews',
1193
+ '#dimension': 'dimension',
1194
+ '#value': 'value',
1195
+ '#et': '_et',
1196
+ ...(ttl ? { '#ttl': 'ttl' } : {}),
1197
+ },
1198
+ ExpressionAttributeValues: {
1199
+ ':visitors': { N: String(stats.visitors) },
1200
+ ':pv': { N: String(stats.pageViews) },
1201
+ ':dimension': { S: stats.dimension },
1202
+ ':value': { S: stats.value },
1203
+ ':zero': { N: '0' },
1204
+ ':et': { S: 'DeviceStats' },
1205
+ ...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
1206
+ },
1207
+ },
1208
+ }
1209
+ }
1210
+
1211
+ /**
1212
+ * Generate command to get device stats for a period
1213
+ */
1214
+ getDeviceStatsCommand(
1215
+ siteId: string,
1216
+ period: AggregationPeriod,
1217
+ periodStart: string,
1218
+ dimension?: 'device' | 'browser' | 'os' | 'screen',
1219
+ ): {
1220
+ command: 'Query'
1221
+ input: {
1222
+ TableName: string
1223
+ KeyConditionExpression: string
1224
+ ExpressionAttributeValues: Record<string, unknown>
1225
+ FilterExpression?: string
1226
+ ExpressionAttributeNames?: Record<string, string>
1227
+ }
1228
+ } {
1229
+ const keys = AnalyticsKeyPatterns.deviceStats
1230
+ const input: ReturnType<typeof this.getDeviceStatsCommand>['input'] = {
1231
+ TableName: this.options.tableName,
1232
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
1233
+ ExpressionAttributeValues: {
1234
+ ':pk': { S: keys.pk(siteId) },
1235
+ ':skPrefix': { S: `DEVICESTATS#${period.toUpperCase()}#${periodStart}` },
1236
+ },
1237
+ }
1238
+
1239
+ if (dimension) {
1240
+ input.FilterExpression = '#dim = :dim'
1241
+ input.ExpressionAttributeNames = { '#dim': 'dimension' }
1242
+ input.ExpressionAttributeValues[':dim'] = { S: dimension }
1243
+ }
1244
+
1245
+ return { command: 'Query', input }
1246
+ }
1247
+
1248
+ // ==========================================================================
1249
+ // Event Stats Operations
1250
+ // ==========================================================================
1251
+
1252
+ /**
1253
+ * Generate command to upsert event stats
1254
+ */
1255
+ upsertEventStatsCommand(stats: EventStats): {
1256
+ command: 'UpdateItem'
1257
+ input: {
1258
+ TableName: string
1259
+ Key: Record<string, unknown>
1260
+ UpdateExpression: string
1261
+ ExpressionAttributeNames: Record<string, string>
1262
+ ExpressionAttributeValues: Record<string, unknown>
1263
+ }
1264
+ } {
1265
+ const keys = AnalyticsKeyPatterns.eventStats
1266
+ const ttl = this.getTtlForPeriod(stats.period)
1267
+
1268
+ return {
1269
+ command: 'UpdateItem',
1270
+ input: {
1271
+ TableName: this.options.tableName,
1272
+ Key: {
1273
+ pk: { S: keys.pk(stats.siteId) },
1274
+ sk: { S: keys.sk(stats.period, stats.periodStart, stats.eventName) },
1275
+ },
1276
+ UpdateExpression: `
1277
+ SET #count = if_not_exists(#count, :zero) + :count,
1278
+ #uv = if_not_exists(#uv, :zero) + :uv,
1279
+ #totalValue = if_not_exists(#totalValue, :zero) + :totalValue,
1280
+ #eventName = :eventName,
1281
+ #et = :et
1282
+ ${ttl ? ', #ttl = :ttl' : ''}
1283
+ `.trim(),
1284
+ ExpressionAttributeNames: {
1285
+ '#count': 'count',
1286
+ '#uv': 'uniqueVisitors',
1287
+ '#totalValue': 'totalValue',
1288
+ '#eventName': 'eventName',
1289
+ '#et': '_et',
1290
+ ...(ttl ? { '#ttl': 'ttl' } : {}),
1291
+ },
1292
+ ExpressionAttributeValues: {
1293
+ ':count': { N: String(stats.count) },
1294
+ ':uv': { N: String(stats.uniqueVisitors) },
1295
+ ':totalValue': { N: String(stats.totalValue) },
1296
+ ':eventName': { S: stats.eventName },
1297
+ ':zero': { N: '0' },
1298
+ ':et': { S: 'EventStats' },
1299
+ ...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
1300
+ },
1301
+ },
1302
+ }
1303
+ }
1304
+
1305
+ /**
1306
+ * Generate command to get event stats for a period
1307
+ */
1308
+ getEventStatsCommand(
1309
+ siteId: string,
1310
+ period: AggregationPeriod,
1311
+ periodStart: string,
1312
+ ): {
1313
+ command: 'Query'
1314
+ input: {
1315
+ TableName: string
1316
+ KeyConditionExpression: string
1317
+ ExpressionAttributeValues: Record<string, unknown>
1318
+ }
1319
+ } {
1320
+ const keys = AnalyticsKeyPatterns.eventStats
1321
+ return {
1322
+ command: 'Query',
1323
+ input: {
1324
+ TableName: this.options.tableName,
1325
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
1326
+ ExpressionAttributeValues: {
1327
+ ':pk': { S: keys.pk(siteId) },
1328
+ ':skPrefix': { S: `EVENTSTATS#${period.toUpperCase()}#${periodStart}` },
1329
+ },
1330
+ },
1331
+ }
1332
+ }
1333
+
1334
+ // ==========================================================================
1335
+ // Goal Stats Operations
1336
+ // ==========================================================================
1337
+
1338
+ /**
1339
+ * Generate command to upsert goal stats
1340
+ */
1341
+ upsertGoalStatsCommand(stats: GoalStats): {
1342
+ command: 'UpdateItem'
1343
+ input: {
1344
+ TableName: string
1345
+ Key: Record<string, unknown>
1346
+ UpdateExpression: string
1347
+ ExpressionAttributeNames: Record<string, string>
1348
+ ExpressionAttributeValues: Record<string, unknown>
1349
+ }
1350
+ } {
1351
+ const keys = AnalyticsKeyPatterns.goalStats
1352
+ const ttl = this.getTtlForPeriod(stats.period)
1353
+
1354
+ return {
1355
+ command: 'UpdateItem',
1356
+ input: {
1357
+ TableName: this.options.tableName,
1358
+ Key: {
1359
+ pk: { S: keys.pk(stats.siteId) },
1360
+ sk: { S: keys.sk(stats.goalId, stats.period, stats.periodStart) },
1361
+ },
1362
+ UpdateExpression: `
1363
+ SET #conversions = if_not_exists(#conversions, :zero) + :conversions,
1364
+ #uniqueConv = if_not_exists(#uniqueConv, :zero) + :uniqueConv,
1365
+ #revenue = if_not_exists(#revenue, :zero) + :revenue,
1366
+ #goalId = :goalId,
1367
+ #et = :et
1368
+ ${ttl ? ', #ttl = :ttl' : ''}
1369
+ `.trim(),
1370
+ ExpressionAttributeNames: {
1371
+ '#conversions': 'conversions',
1372
+ '#uniqueConv': 'uniqueConversions',
1373
+ '#revenue': 'revenue',
1374
+ '#goalId': 'goalId',
1375
+ '#et': '_et',
1376
+ ...(ttl ? { '#ttl': 'ttl' } : {}),
1377
+ },
1378
+ ExpressionAttributeValues: {
1379
+ ':conversions': { N: String(stats.conversions) },
1380
+ ':uniqueConv': { N: String(stats.uniqueConversions) },
1381
+ ':revenue': { N: String(stats.revenue) },
1382
+ ':goalId': { S: stats.goalId },
1383
+ ':zero': { N: '0' },
1384
+ ':et': { S: 'GoalStats' },
1385
+ ...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
1386
+ },
1387
+ },
1388
+ }
1389
+ }
1390
+
1391
+ /**
1392
+ * Generate command to get goal stats for a period
1393
+ */
1394
+ getGoalStatsCommand(
1395
+ siteId: string,
1396
+ goalId: string,
1397
+ period: AggregationPeriod,
1398
+ startPeriod: string,
1399
+ endPeriod: string,
1400
+ ): {
1401
+ command: 'Query'
1402
+ input: {
1403
+ TableName: string
1404
+ KeyConditionExpression: string
1405
+ ExpressionAttributeValues: Record<string, unknown>
1406
+ ScanIndexForward: boolean
1407
+ }
1408
+ } {
1409
+ const keys = AnalyticsKeyPatterns.goalStats
1410
+ return {
1411
+ command: 'Query',
1412
+ input: {
1413
+ TableName: this.options.tableName,
1414
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
1415
+ ExpressionAttributeValues: {
1416
+ ':pk': { S: keys.pk(siteId) },
1417
+ ':start': { S: keys.sk(goalId, period, startPeriod) },
1418
+ ':end': { S: keys.sk(goalId, period, endPeriod) },
1419
+ },
1420
+ ScanIndexForward: true,
1421
+ },
1422
+ }
1423
+ }
1424
+
1425
+ // ==========================================================================
1426
+ // Campaign Stats Operations
1427
+ // ==========================================================================
1428
+
1429
+ /**
1430
+ * Generate command to upsert campaign stats
1431
+ */
1432
+ upsertCampaignStatsCommand(stats: CampaignStats): {
1433
+ command: 'UpdateItem'
1434
+ input: {
1435
+ TableName: string
1436
+ Key: Record<string, unknown>
1437
+ UpdateExpression: string
1438
+ ExpressionAttributeNames: Record<string, string>
1439
+ ExpressionAttributeValues: Record<string, unknown>
1440
+ }
1441
+ } {
1442
+ const keys = AnalyticsKeyPatterns.campaignStats
1443
+ const ttl = this.getTtlForPeriod(stats.period)
1444
+
1445
+ return {
1446
+ command: 'UpdateItem',
1447
+ input: {
1448
+ TableName: this.options.tableName,
1449
+ Key: {
1450
+ pk: { S: keys.pk(stats.siteId) },
1451
+ sk: { S: keys.sk(stats.period, stats.periodStart, stats.utmSource, stats.utmCampaign) },
1452
+ },
1453
+ UpdateExpression: `
1454
+ SET #visitors = if_not_exists(#visitors, :zero) + :visitors,
1455
+ #pv = if_not_exists(#pv, :zero) + :pv,
1456
+ #conversions = if_not_exists(#conversions, :zero) + :conversions,
1457
+ #revenue = if_not_exists(#revenue, :zero) + :revenue,
1458
+ #utmSource = :utmSource,
1459
+ #et = :et
1460
+ ${stats.utmMedium ? ', #utmMedium = :utmMedium' : ''}
1461
+ ${stats.utmCampaign ? ', #utmCampaign = :utmCampaign' : ''}
1462
+ ${ttl ? ', #ttl = :ttl' : ''}
1463
+ `.trim(),
1464
+ ExpressionAttributeNames: {
1465
+ '#visitors': 'visitors',
1466
+ '#pv': 'pageViews',
1467
+ '#conversions': 'conversions',
1468
+ '#revenue': 'revenue',
1469
+ '#utmSource': 'utmSource',
1470
+ '#et': '_et',
1471
+ ...(stats.utmMedium ? { '#utmMedium': 'utmMedium' } : {}),
1472
+ ...(stats.utmCampaign ? { '#utmCampaign': 'utmCampaign' } : {}),
1473
+ ...(ttl ? { '#ttl': 'ttl' } : {}),
1474
+ },
1475
+ ExpressionAttributeValues: {
1476
+ ':visitors': { N: String(stats.visitors) },
1477
+ ':pv': { N: String(stats.pageViews) },
1478
+ ':conversions': { N: String(stats.conversions) },
1479
+ ':revenue': { N: String(stats.revenue) },
1480
+ ':utmSource': { S: stats.utmSource },
1481
+ ':zero': { N: '0' },
1482
+ ':et': { S: 'CampaignStats' },
1483
+ ...(stats.utmMedium ? { ':utmMedium': { S: stats.utmMedium } } : {}),
1484
+ ...(stats.utmCampaign ? { ':utmCampaign': { S: stats.utmCampaign } } : {}),
1485
+ ...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
1486
+ },
1487
+ },
1488
+ }
1489
+ }
1490
+
1491
+ /**
1492
+ * Generate command to get campaign stats for a period
1493
+ */
1494
+ getCampaignStatsCommand(
1495
+ siteId: string,
1496
+ period: AggregationPeriod,
1497
+ periodStart: string,
1498
+ ): {
1499
+ command: 'Query'
1500
+ input: {
1501
+ TableName: string
1502
+ KeyConditionExpression: string
1503
+ ExpressionAttributeValues: Record<string, unknown>
1504
+ }
1505
+ } {
1506
+ const keys = AnalyticsKeyPatterns.campaignStats
1507
+ return {
1508
+ command: 'Query',
1509
+ input: {
1510
+ TableName: this.options.tableName,
1511
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
1512
+ ExpressionAttributeValues: {
1513
+ ':pk': { S: keys.pk(siteId) },
1514
+ ':skPrefix': { S: `CAMPSTATS#${period.toUpperCase()}#${periodStart}` },
1515
+ },
1516
+ },
1517
+ }
1518
+ }
1519
+
1520
+ // ==========================================================================
1521
+ // Helper Methods
1522
+ // ==========================================================================
1523
+
1524
+ /**
1525
+ * Get TTL timestamp for a period type
1526
+ */
1527
+ private getTtlForPeriod(period: AggregationPeriod): number | undefined {
1528
+ if (!this.options.useTtl)
1529
+ return undefined
1530
+
1531
+ const now = Math.floor(Date.now() / 1000)
1532
+ switch (period) {
1533
+ case 'hour':
1534
+ return now + this.options.hourlyAggregateTtl
1535
+ case 'day':
1536
+ return now + this.options.dailyAggregateTtl
1537
+ case 'month':
1538
+ return undefined // Monthly aggregates kept forever
1539
+ default:
1540
+ return undefined
1541
+ }
1542
+ }
1543
+
1544
+ /**
1545
+ * Get period start string for a date
1546
+ */
1547
+ static getPeriodStart(date: Date, period: AggregationPeriod): string {
1548
+ const iso = date.toISOString()
1549
+ switch (period) {
1550
+ case 'hour':
1551
+ return `${iso.slice(0, 13)}:00:00.000Z` // 2024-01-15T14:00:00.000Z
1552
+ case 'day':
1553
+ return iso.slice(0, 10) // 2024-01-15
1554
+ case 'month':
1555
+ return iso.slice(0, 7) // 2024-01
1556
+ default:
1557
+ return iso.slice(0, 10)
1558
+ }
1559
+ }
1560
+
1561
+ /**
1562
+ * Generate a unique ID
1563
+ */
1564
+ static generateId(): string {
1565
+ return crypto.randomUUID()
1566
+ }
1567
+
1568
+ /**
1569
+ * Hash a visitor identifier (for privacy)
1570
+ */
1571
+ static async hashVisitorId(
1572
+ ip: string,
1573
+ userAgent: string,
1574
+ siteId: string,
1575
+ salt: string,
1576
+ ): Promise<string> {
1577
+ const data = `${ip}|${userAgent}|${siteId}|${salt}`
1578
+ const encoder = new TextEncoder()
1579
+ const dataBuffer = encoder.encode(data)
1580
+ const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer)
1581
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
1582
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
1583
+ }
1584
+
1585
+ /**
1586
+ * Parse referrer to get source
1587
+ */
1588
+ static parseReferrerSource(referrer: string | undefined): string {
1589
+ if (!referrer)
1590
+ return 'direct'
1591
+
1592
+ try {
1593
+ const url = new URL(referrer)
1594
+ const hostname = url.hostname.toLowerCase()
1595
+
1596
+ // Search engines
1597
+ if (hostname.includes('google'))
1598
+ return 'google'
1599
+ if (hostname.includes('bing'))
1600
+ return 'bing'
1601
+ if (hostname.includes('yahoo'))
1602
+ return 'yahoo'
1603
+ if (hostname.includes('duckduckgo'))
1604
+ return 'duckduckgo'
1605
+ if (hostname.includes('baidu'))
1606
+ return 'baidu'
1607
+ if (hostname.includes('yandex'))
1608
+ return 'yandex'
1609
+
1610
+ // Social media - order matters for substring matches
1611
+ if (hostname.includes('facebook') || hostname.includes('fb.'))
1612
+ return 'facebook'
1613
+ if (hostname.includes('reddit'))
1614
+ return 'reddit' // Check before twitter since reddit contains 't.co'
1615
+ if (hostname.includes('twitter') || hostname === 't.co' || hostname.endsWith('.t.co'))
1616
+ return 'twitter'
1617
+ if (hostname.includes('linkedin'))
1618
+ return 'linkedin'
1619
+ if (hostname.includes('instagram'))
1620
+ return 'instagram'
1621
+ if (hostname.includes('pinterest'))
1622
+ return 'pinterest'
1623
+ if (hostname.includes('youtube'))
1624
+ return 'youtube'
1625
+ if (hostname.includes('tiktok'))
1626
+ return 'tiktok'
1627
+
1628
+ // Return the domain as source
1629
+ return hostname.replace('www.', '')
1630
+ }
1631
+ catch {
1632
+ return 'unknown'
1633
+ }
1634
+ }
1635
+
1636
+ /**
1637
+ * Parse user agent for device info
1638
+ */
1639
+ static parseUserAgent(userAgent: string): {
1640
+ deviceType: DeviceType
1641
+ browser: string
1642
+ browserVersion: string
1643
+ os: string
1644
+ osVersion: string
1645
+ } {
1646
+ // Simple UA parsing - in production, use a proper UA parser library
1647
+ const ua = userAgent.toLowerCase()
1648
+
1649
+ // Device type - check tablet first since iPad contains "mobile" in some UAs
1650
+ let deviceType: DeviceType = 'desktop'
1651
+ if (/ipad|tablet|playbook|silk/i.test(ua)) {
1652
+ deviceType = 'tablet'
1653
+ }
1654
+ else if (/mobile|android|iphone|ipod|blackberry|windows phone/i.test(ua)) {
1655
+ deviceType = 'mobile'
1656
+ }
1657
+
1658
+ // Browser detection
1659
+ let browser = 'Unknown'
1660
+ let browserVersion = ''
1661
+ if (ua.includes('firefox')) {
1662
+ browser = 'Firefox'
1663
+ browserVersion = ua.match(/firefox\/([\d.]+)/)?.[1] ?? ''
1664
+ }
1665
+ else if (ua.includes('edg')) {
1666
+ browser = 'Edge'
1667
+ browserVersion = ua.match(/edg\/([\d.]+)/)?.[1] ?? ''
1668
+ }
1669
+ else if (ua.includes('chrome')) {
1670
+ browser = 'Chrome'
1671
+ browserVersion = ua.match(/chrome\/([\d.]+)/)?.[1] ?? ''
1672
+ }
1673
+ else if (ua.includes('safari')) {
1674
+ browser = 'Safari'
1675
+ browserVersion = ua.match(/version\/([\d.]+)/)?.[1] ?? ''
1676
+ }
1677
+
1678
+ // OS detection - check mobile OS first since they may contain "mac os" pattern
1679
+ let os = 'Unknown'
1680
+ let osVersion = ''
1681
+ if (ua.includes('iphone') || ua.includes('ipad')) {
1682
+ os = 'iOS'
1683
+ osVersion = ua.match(/os ([\d_]+)/)?.[1]?.replace(/_/g, '.') ?? ''
1684
+ }
1685
+ else if (ua.includes('android')) {
1686
+ os = 'Android'
1687
+ osVersion = ua.match(/android ([\d.]+)/)?.[1] ?? ''
1688
+ }
1689
+ else if (ua.includes('windows')) {
1690
+ os = 'Windows'
1691
+ if (ua.includes('windows nt 10'))
1692
+ osVersion = '10'
1693
+ else if (ua.includes('windows nt 11'))
1694
+ osVersion = '11'
1695
+ }
1696
+ else if (ua.includes('mac os')) {
1697
+ os = 'macOS'
1698
+ osVersion = ua.match(/mac os x ([\d_]+)/)?.[1]?.replace(/_/g, '.') ?? ''
1699
+ }
1700
+ else if (ua.includes('linux')) {
1701
+ os = 'Linux'
1702
+ }
1703
+
1704
+ return { deviceType, browser, browserVersion, os, osVersion }
1705
+ }
1706
+ }
1707
+
1708
+ // ============================================================================
1709
+ // Analytics Aggregator - Background Job for Rolling Up Stats
1710
+ // ============================================================================
1711
+
1712
+ /**
1713
+ * Aggregator options
1714
+ */
1715
+ export interface AggregatorOptions {
1716
+ /** Analytics store instance */
1717
+ store: AnalyticsStore
1718
+ /** Batch size for processing events */
1719
+ batchSize?: number
1720
+ }
1721
+
1722
+ /**
1723
+ * Analytics Aggregator for rolling up raw events into aggregated stats
1724
+ */
1725
+ export class AnalyticsAggregator {
1726
+ private store: AnalyticsStore
1727
+ private batchSize: number
1728
+
1729
+ constructor(options: AggregatorOptions) {
1730
+ this.store = options.store
1731
+ this.batchSize = options.batchSize ?? 100
1732
+ }
1733
+
1734
+ /**
1735
+ * Generate hourly aggregation stats from page views
1736
+ * This would typically be run as a scheduled job
1737
+ */
1738
+ aggregateHourlyStats(
1739
+ siteId: string,
1740
+ hourStart: Date,
1741
+ pageViews: PageView[],
1742
+ sessions: Session[],
1743
+ ): AggregatedStats {
1744
+ const periodStart = AnalyticsStore.getPeriodStart(hourStart, 'hour')
1745
+
1746
+ // Calculate unique visitors using Set
1747
+ const uniqueVisitors = new Set(pageViews.map(pv => pv.visitorId))
1748
+
1749
+ // Calculate bounce rate
1750
+ const bounces = sessions.filter(s => s.isBounce).length
1751
+ const bounceRate = sessions.length > 0 ? bounces / sessions.length : 0
1752
+
1753
+ // Calculate average session duration
1754
+ const totalDuration = sessions.reduce((sum, s) => sum + s.duration, 0)
1755
+ const avgSessionDuration = sessions.length > 0 ? totalDuration / sessions.length : 0
1756
+
1757
+ // Calculate pages per session
1758
+ const totalPages = sessions.reduce((sum, s) => sum + s.pageViewCount, 0)
1759
+ const avgPagesPerSession = sessions.length > 0 ? totalPages / sessions.length : 0
1760
+
1761
+ // Identify new vs returning visitors (simplified - in production, check against visitor history)
1762
+ const newVisitors = uniqueVisitors.size // Simplified
1763
+ const returningVisitors = 0 // Would need historical data
1764
+
1765
+ return {
1766
+ siteId,
1767
+ period: 'hour',
1768
+ periodStart,
1769
+ pageViews: pageViews.length,
1770
+ uniqueVisitors: uniqueVisitors.size,
1771
+ sessions: sessions.length,
1772
+ bounces,
1773
+ bounceRate,
1774
+ avgSessionDuration,
1775
+ avgPagesPerSession,
1776
+ totalTimeOnSite: totalDuration,
1777
+ newVisitors,
1778
+ returningVisitors,
1779
+ createdAt: new Date(),
1780
+ updatedAt: new Date(),
1781
+ }
1782
+ }
1783
+
1784
+ /**
1785
+ * Generate page-level stats from page views
1786
+ */
1787
+ aggregatePageStats(
1788
+ siteId: string,
1789
+ period: AggregationPeriod,
1790
+ periodStart: Date,
1791
+ pageViews: PageView[],
1792
+ ): PageStats[] {
1793
+ const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
1794
+
1795
+ // Group by path
1796
+ const pathGroups = new Map<string, PageView[]>()
1797
+ for (const pv of pageViews) {
1798
+ const existing = pathGroups.get(pv.path) || []
1799
+ existing.push(pv)
1800
+ pathGroups.set(pv.path, existing)
1801
+ }
1802
+
1803
+ const results: PageStats[] = []
1804
+ for (const [path, views] of pathGroups) {
1805
+ const uniqueVisitors = new Set(views.map(v => v.visitorId))
1806
+ const entries = views.filter(v => v.isUnique).length
1807
+ const bounces = views.filter(v => v.isBounce).length
1808
+ const exits = views.length // Simplified - would need session data
1809
+
1810
+ // Calculate average time on page
1811
+ const timesOnPage = views.filter(v => v.timeOnPage !== undefined).map(v => v.timeOnPage!)
1812
+ const avgTimeOnPage = timesOnPage.length > 0
1813
+ ? timesOnPage.reduce((a, b) => a + b, 0) / timesOnPage.length
1814
+ : 0
1815
+
1816
+ results.push({
1817
+ siteId,
1818
+ period,
1819
+ periodStart: periodStartStr,
1820
+ path,
1821
+ title: views[views.length - 1]?.title,
1822
+ pageViews: views.length,
1823
+ uniqueVisitors: uniqueVisitors.size,
1824
+ entries,
1825
+ exits,
1826
+ bounces,
1827
+ avgTimeOnPage,
1828
+ exitRate: views.length > 0 ? exits / views.length : 0,
1829
+ })
1830
+ }
1831
+
1832
+ return results
1833
+ }
1834
+
1835
+ /**
1836
+ * Generate referrer stats from sessions
1837
+ */
1838
+ aggregateReferrerStats(
1839
+ siteId: string,
1840
+ period: AggregationPeriod,
1841
+ periodStart: Date,
1842
+ sessions: Session[],
1843
+ ): ReferrerStats[] {
1844
+ const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
1845
+
1846
+ // Group by referrer source
1847
+ const sourceGroups = new Map<string, Session[]>()
1848
+ for (const session of sessions) {
1849
+ const source = session.referrerSource || 'direct'
1850
+ const existing = sourceGroups.get(source) || []
1851
+ existing.push(session)
1852
+ sourceGroups.set(source, existing)
1853
+ }
1854
+
1855
+ const results: ReferrerStats[] = []
1856
+ for (const [source, groupSessions] of sourceGroups) {
1857
+ const visitors = new Set(groupSessions.map(s => s.visitorId))
1858
+ const pageViews = groupSessions.reduce((sum, s) => sum + s.pageViewCount, 0)
1859
+ const bounces = groupSessions.filter(s => s.isBounce).length
1860
+ const totalDuration = groupSessions.reduce((sum, s) => sum + s.duration, 0)
1861
+
1862
+ results.push({
1863
+ siteId,
1864
+ period,
1865
+ periodStart: periodStartStr,
1866
+ source,
1867
+ visitors: visitors.size,
1868
+ pageViews,
1869
+ bounceRate: groupSessions.length > 0 ? bounces / groupSessions.length : 0,
1870
+ avgSessionDuration: groupSessions.length > 0 ? totalDuration / groupSessions.length : 0,
1871
+ })
1872
+ }
1873
+
1874
+ return results
1875
+ }
1876
+
1877
+ /**
1878
+ * Generate geographic stats from sessions
1879
+ * Creates hierarchical aggregations at country, region, and city levels
1880
+ */
1881
+ aggregateGeoStats(
1882
+ siteId: string,
1883
+ period: AggregationPeriod,
1884
+ periodStart: Date,
1885
+ sessions: Session[],
1886
+ ): GeoStats[] {
1887
+ const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
1888
+ const results: GeoStats[] = []
1889
+
1890
+ // Group by country
1891
+ const countryGroups = new Map<string, Session[]>()
1892
+ for (const session of sessions) {
1893
+ const country = session.country || 'Unknown'
1894
+ const existing = countryGroups.get(country) || []
1895
+ existing.push(session)
1896
+ countryGroups.set(country, existing)
1897
+ }
1898
+
1899
+ // Country-level stats
1900
+ for (const [country, countrySessions] of countryGroups) {
1901
+ const visitors = new Set(countrySessions.map(s => s.visitorId))
1902
+ const pageViews = countrySessions.reduce((sum, s) => sum + s.pageViewCount, 0)
1903
+ const bounces = countrySessions.filter(s => s.isBounce).length
1904
+
1905
+ results.push({
1906
+ siteId,
1907
+ period,
1908
+ periodStart: periodStartStr,
1909
+ country,
1910
+ visitors: visitors.size,
1911
+ pageViews,
1912
+ bounceRate: countrySessions.length > 0 ? bounces / countrySessions.length : 0,
1913
+ })
1914
+
1915
+ // Region-level stats (group by country+region)
1916
+ const regionGroups = new Map<string, Session[]>()
1917
+ for (const session of countrySessions) {
1918
+ const region = session.region || 'Unknown'
1919
+ const existing = regionGroups.get(region) || []
1920
+ existing.push(session)
1921
+ regionGroups.set(region, existing)
1922
+ }
1923
+
1924
+ for (const [region, regionSessions] of regionGroups) {
1925
+ const regionVisitors = new Set(regionSessions.map(s => s.visitorId))
1926
+ const regionPageViews = regionSessions.reduce((sum, s) => sum + s.pageViewCount, 0)
1927
+ const regionBounces = regionSessions.filter(s => s.isBounce).length
1928
+
1929
+ results.push({
1930
+ siteId,
1931
+ period,
1932
+ periodStart: periodStartStr,
1933
+ country,
1934
+ region,
1935
+ visitors: regionVisitors.size,
1936
+ pageViews: regionPageViews,
1937
+ bounceRate: regionSessions.length > 0 ? regionBounces / regionSessions.length : 0,
1938
+ })
1939
+
1940
+ // City-level stats (group by country+region+city)
1941
+ const cityGroups = new Map<string, Session[]>()
1942
+ for (const session of regionSessions) {
1943
+ const city = session.city || 'Unknown'
1944
+ const existing = cityGroups.get(city) || []
1945
+ existing.push(session)
1946
+ cityGroups.set(city, existing)
1947
+ }
1948
+
1949
+ for (const [city, citySessions] of cityGroups) {
1950
+ const cityVisitors = new Set(citySessions.map(s => s.visitorId))
1951
+ const cityPageViews = citySessions.reduce((sum, s) => sum + s.pageViewCount, 0)
1952
+ const cityBounces = citySessions.filter(s => s.isBounce).length
1953
+
1954
+ results.push({
1955
+ siteId,
1956
+ period,
1957
+ periodStart: periodStartStr,
1958
+ country,
1959
+ region,
1960
+ city,
1961
+ visitors: cityVisitors.size,
1962
+ pageViews: cityPageViews,
1963
+ bounceRate: citySessions.length > 0 ? cityBounces / citySessions.length : 0,
1964
+ })
1965
+ }
1966
+ }
1967
+ }
1968
+
1969
+ return results
1970
+ }
1971
+
1972
+ /**
1973
+ * Generate device stats from sessions
1974
+ */
1975
+ aggregateDeviceStats(
1976
+ siteId: string,
1977
+ period: AggregationPeriod,
1978
+ periodStart: Date,
1979
+ sessions: Session[],
1980
+ ): DeviceStats[] {
1981
+ const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
1982
+ const results: DeviceStats[] = []
1983
+
1984
+ // Group by device type
1985
+ const deviceGroups = this.groupByDimension(sessions, 'deviceType')
1986
+ for (const [value, groupSessions] of deviceGroups) {
1987
+ results.push(this.createDeviceStats(siteId, period, periodStartStr, 'device', value, groupSessions))
1988
+ }
1989
+
1990
+ // Group by browser
1991
+ const browserGroups = this.groupByDimension(sessions, 'browser')
1992
+ for (const [value, groupSessions] of browserGroups) {
1993
+ results.push(this.createDeviceStats(siteId, period, periodStartStr, 'browser', value, groupSessions))
1994
+ }
1995
+
1996
+ // Group by OS
1997
+ const osGroups = this.groupByDimension(sessions, 'os')
1998
+ for (const [value, groupSessions] of osGroups) {
1999
+ results.push(this.createDeviceStats(siteId, period, periodStartStr, 'os', value, groupSessions))
2000
+ }
2001
+
2002
+ return results
2003
+ }
2004
+
2005
+ private groupByDimension(
2006
+ sessions: Session[],
2007
+ dimension: keyof Session,
2008
+ ): Map<string, Session[]> {
2009
+ const groups = new Map<string, Session[]>()
2010
+ for (const session of sessions) {
2011
+ const value = String(session[dimension] || 'Unknown')
2012
+ const existing = groups.get(value) || []
2013
+ existing.push(session)
2014
+ groups.set(value, existing)
2015
+ }
2016
+ return groups
2017
+ }
2018
+
2019
+ private createDeviceStats(
2020
+ siteId: string,
2021
+ period: AggregationPeriod,
2022
+ periodStart: string,
2023
+ dimension: DeviceStats['dimension'],
2024
+ value: string,
2025
+ sessions: Session[],
2026
+ ): DeviceStats {
2027
+ const visitors = new Set(sessions.map(s => s.visitorId))
2028
+ const pageViews = sessions.reduce((sum, s) => sum + s.pageViewCount, 0)
2029
+ const bounces = sessions.filter(s => s.isBounce).length
2030
+
2031
+ return {
2032
+ siteId,
2033
+ period,
2034
+ periodStart,
2035
+ dimension,
2036
+ value,
2037
+ visitors: visitors.size,
2038
+ pageViews,
2039
+ bounceRate: sessions.length > 0 ? bounces / sessions.length : 0,
2040
+ }
2041
+ }
2042
+ }
2043
+
2044
+ // ============================================================================
2045
+ // Goal Matcher - Match PageViews/Events to Goal Definitions
2046
+ // ============================================================================
2047
+
2048
+ /**
2049
+ * Result of a goal match check
2050
+ */
2051
+ export interface GoalMatchResult {
2052
+ /** Whether the goal was matched */
2053
+ matched: boolean
2054
+ /** The goal that was matched */
2055
+ goal: Goal
2056
+ /** The value attributed to this conversion */
2057
+ value: number
2058
+ /** The item that triggered the match (pageview or event) */
2059
+ trigger: PageView | CustomEvent
2060
+ /** Timestamp of the conversion */
2061
+ timestamp: Date
2062
+ }
2063
+
2064
+ /**
2065
+ * Conversion record for storage
2066
+ */
2067
+ export interface Conversion {
2068
+ /** Unique conversion ID */
2069
+ id: string
2070
+ /** Site ID */
2071
+ siteId: string
2072
+ /** Goal ID */
2073
+ goalId: string
2074
+ /** Visitor ID */
2075
+ visitorId: string
2076
+ /** Session ID */
2077
+ sessionId: string
2078
+ /** Conversion value */
2079
+ value: number
2080
+ /** Path where conversion occurred */
2081
+ path: string
2082
+ /** Timestamp */
2083
+ timestamp: Date
2084
+ /** TTL for auto-deletion */
2085
+ ttl?: number
2086
+ }
2087
+
2088
+ /**
2089
+ * Goal Matcher for checking if pageviews/events match goal definitions
2090
+ */
2091
+ export class GoalMatcher {
2092
+ private goals: Goal[]
2093
+ private compiledPatterns: Map<string, RegExp>
2094
+
2095
+ constructor(goals: Goal[]) {
2096
+ this.goals = goals.filter(g => g.isActive)
2097
+ this.compiledPatterns = new Map()
2098
+
2099
+ // Pre-compile regex patterns for performance
2100
+ for (const goal of this.goals) {
2101
+ if (goal.matchType === 'regex') {
2102
+ try {
2103
+ this.compiledPatterns.set(goal.id, new RegExp(goal.pattern))
2104
+ }
2105
+ catch {
2106
+ // Invalid regex - skip this goal
2107
+ console.warn(`Invalid regex pattern for goal ${goal.id}: ${goal.pattern}`)
2108
+ }
2109
+ }
2110
+ }
2111
+ }
2112
+
2113
+ /**
2114
+ * Check if a pageview matches any pageview-type goals
2115
+ */
2116
+ matchPageView(pageView: PageView): GoalMatchResult[] {
2117
+ const results: GoalMatchResult[] = []
2118
+
2119
+ for (const goal of this.goals) {
2120
+ if (goal.type !== 'pageview')
2121
+ continue
2122
+
2123
+ if (this.matchesPattern(pageView.path, goal)) {
2124
+ results.push({
2125
+ matched: true,
2126
+ goal,
2127
+ value: goal.value ?? 0,
2128
+ trigger: pageView,
2129
+ timestamp: pageView.timestamp,
2130
+ })
2131
+ }
2132
+ }
2133
+
2134
+ return results
2135
+ }
2136
+
2137
+ /**
2138
+ * Check if a custom event matches any event-type goals
2139
+ */
2140
+ matchEvent(event: CustomEvent): GoalMatchResult[] {
2141
+ const results: GoalMatchResult[] = []
2142
+
2143
+ for (const goal of this.goals) {
2144
+ if (goal.type !== 'event')
2145
+ continue
2146
+
2147
+ if (this.matchesPattern(event.name, goal)) {
2148
+ results.push({
2149
+ matched: true,
2150
+ goal,
2151
+ value: event.value ?? goal.value ?? 0,
2152
+ trigger: event,
2153
+ timestamp: event.timestamp,
2154
+ })
2155
+ }
2156
+ }
2157
+
2158
+ return results
2159
+ }
2160
+
2161
+ /**
2162
+ * Check if a value matches a goal's pattern
2163
+ */
2164
+ private matchesPattern(value: string, goal: Goal): boolean {
2165
+ switch (goal.matchType) {
2166
+ case 'exact':
2167
+ return value === goal.pattern
2168
+
2169
+ case 'contains':
2170
+ return value.includes(goal.pattern)
2171
+
2172
+ case 'regex': {
2173
+ const regex = this.compiledPatterns.get(goal.id)
2174
+ return regex ? regex.test(value) : false
2175
+ }
2176
+
2177
+ default:
2178
+ return false
2179
+ }
2180
+ }
2181
+
2182
+ /**
2183
+ * Get all active goals
2184
+ */
2185
+ getActiveGoals(): Goal[] {
2186
+ return this.goals
2187
+ }
2188
+
2189
+ /**
2190
+ * Create a conversion record from a goal match
2191
+ */
2192
+ static createConversion(
2193
+ siteId: string,
2194
+ match: GoalMatchResult,
2195
+ ): Conversion {
2196
+ const trigger = match.trigger
2197
+ return {
2198
+ id: AnalyticsStore.generateId(),
2199
+ siteId,
2200
+ goalId: match.goal.id,
2201
+ visitorId: trigger.visitorId,
2202
+ sessionId: trigger.sessionId,
2203
+ value: match.value,
2204
+ path: trigger.path,
2205
+ timestamp: match.timestamp,
2206
+ }
2207
+ }
2208
+ }
2209
+
2210
+ // ============================================================================
2211
+ // Analytics Query API - High-Level Dashboard Queries
2212
+ // ============================================================================
2213
+
2214
+ /**
2215
+ * Date range for queries
2216
+ */
2217
+ export interface DateRange {
2218
+ start: Date
2219
+ end: Date
2220
+ }
2221
+
2222
+ /**
2223
+ * Dashboard summary data
2224
+ */
2225
+ export interface DashboardSummary {
2226
+ /** Total page views in period */
2227
+ pageViews: number
2228
+ /** Unique visitors in period */
2229
+ uniqueVisitors: number
2230
+ /** Total sessions in period */
2231
+ sessions: number
2232
+ /** Bounce rate (0-1) */
2233
+ bounceRate: number
2234
+ /** Average session duration (ms) */
2235
+ avgSessionDuration: number
2236
+ /** Average pages per session */
2237
+ avgPagesPerSession: number
2238
+ /** Comparison with previous period */
2239
+ comparison?: {
2240
+ pageViewsChange: number
2241
+ visitorsChange: number
2242
+ bounceRateChange: number
2243
+ }
2244
+ }
2245
+
2246
+ /**
2247
+ * Time series data point
2248
+ */
2249
+ export interface TimeSeriesPoint {
2250
+ timestamp: string
2251
+ pageViews: number
2252
+ uniqueVisitors: number
2253
+ sessions: number
2254
+ bounceRate: number
2255
+ }
2256
+
2257
+ /**
2258
+ * Top item (page, referrer, country, etc.)
2259
+ */
2260
+ export interface TopItem {
2261
+ name: string
2262
+ value: number
2263
+ percentage: number
2264
+ change?: number
2265
+ }
2266
+
2267
+ /**
2268
+ * Dashboard data response
2269
+ */
2270
+ export interface DashboardData {
2271
+ summary: DashboardSummary
2272
+ timeSeries: TimeSeriesPoint[]
2273
+ topPages: TopItem[]
2274
+ topReferrers: TopItem[]
2275
+ topCountries: TopItem[]
2276
+ topDevices: TopItem[]
2277
+ topBrowsers: TopItem[]
2278
+ goals?: GoalPerformance[]
2279
+ realtime?: RealtimeData
2280
+ }
2281
+
2282
+ /**
2283
+ * Goal performance data
2284
+ */
2285
+ export interface GoalPerformance {
2286
+ goalId: string
2287
+ goalName: string
2288
+ conversions: number
2289
+ conversionRate: number
2290
+ revenue: number
2291
+ }
2292
+
2293
+ /**
2294
+ * Realtime dashboard data
2295
+ */
2296
+ export interface RealtimeData {
2297
+ currentVisitors: number
2298
+ pageViewsLastHour: number
2299
+ topActivePages: TopItem[]
2300
+ }
2301
+
2302
+ /**
2303
+ * Query options
2304
+ */
2305
+ export interface QueryOptions {
2306
+ /** Site ID */
2307
+ siteId: string
2308
+ /** Date range */
2309
+ dateRange: DateRange
2310
+ /** Whether to include comparison with previous period */
2311
+ includeComparison?: boolean
2312
+ /** Whether to include realtime data */
2313
+ includeRealtime?: boolean
2314
+ /** Whether to include goal performance */
2315
+ includeGoals?: boolean
2316
+ /** Limit for top items */
2317
+ topLimit?: number
2318
+ /** Timezone for date calculations */
2319
+ timezone?: string
2320
+ }
2321
+
2322
+ /**
2323
+ * Analytics Query API for dashboard data
2324
+ */
2325
+ export class AnalyticsQueryAPI {
2326
+ private store: AnalyticsStore
2327
+
2328
+ constructor(store: AnalyticsStore) {
2329
+ this.store = store
2330
+ }
2331
+
2332
+ /**
2333
+ * Determine the best aggregation period for a date range
2334
+ */
2335
+ static determinePeriod(dateRange: DateRange): AggregationPeriod {
2336
+ const diffMs = dateRange.end.getTime() - dateRange.start.getTime()
2337
+ const diffDays = diffMs / (1000 * 60 * 60 * 24)
2338
+
2339
+ if (diffDays <= 2) {
2340
+ return 'hour'
2341
+ }
2342
+ else if (diffDays <= 90) {
2343
+ return 'day'
2344
+ }
2345
+ else {
2346
+ return 'month'
2347
+ }
2348
+ }
2349
+
2350
+ /**
2351
+ * Get the previous period for comparison
2352
+ */
2353
+ static getPreviousPeriod(dateRange: DateRange): DateRange {
2354
+ const diffMs = dateRange.end.getTime() - dateRange.start.getTime()
2355
+ return {
2356
+ start: new Date(dateRange.start.getTime() - diffMs),
2357
+ end: new Date(dateRange.start.getTime() - 1),
2358
+ }
2359
+ }
2360
+
2361
+ /**
2362
+ * Generate query commands for fetching dashboard data
2363
+ * Returns an object with all the DynamoDB commands needed
2364
+ */
2365
+ generateDashboardQueries(options: QueryOptions): {
2366
+ aggregatedStats: ReturnType<AnalyticsStore['getAggregatedStatsCommand']>
2367
+ topPages: ReturnType<AnalyticsStore['getTopPagesCommand']>
2368
+ realtimeStats?: ReturnType<AnalyticsStore['getRealtimeStatsCommand']>
2369
+ goals?: ReturnType<AnalyticsStore['listGoalsCommand']>
2370
+ previousPeriodStats?: ReturnType<AnalyticsStore['getAggregatedStatsCommand']>
2371
+ } {
2372
+ const period = AnalyticsQueryAPI.determinePeriod(options.dateRange)
2373
+ const startPeriod = AnalyticsStore.getPeriodStart(options.dateRange.start, period)
2374
+ const endPeriod = AnalyticsStore.getPeriodStart(options.dateRange.end, period)
2375
+ const limit = options.topLimit ?? 10
2376
+
2377
+ const queries: ReturnType<AnalyticsQueryAPI['generateDashboardQueries']> = {
2378
+ aggregatedStats: this.store.getAggregatedStatsCommand(
2379
+ options.siteId,
2380
+ period,
2381
+ startPeriod,
2382
+ endPeriod,
2383
+ ),
2384
+ topPages: this.store.getTopPagesCommand(
2385
+ options.siteId,
2386
+ period,
2387
+ endPeriod, // Use end date for most recent stats
2388
+ limit,
2389
+ ),
2390
+ }
2391
+
2392
+ if (options.includeRealtime) {
2393
+ queries.realtimeStats = this.store.getRealtimeStatsCommand(options.siteId, 5)
2394
+ }
2395
+
2396
+ if (options.includeGoals) {
2397
+ queries.goals = this.store.listGoalsCommand(options.siteId)
2398
+ }
2399
+
2400
+ if (options.includeComparison) {
2401
+ const prevPeriod = AnalyticsQueryAPI.getPreviousPeriod(options.dateRange)
2402
+ const prevStartPeriod = AnalyticsStore.getPeriodStart(prevPeriod.start, period)
2403
+ const prevEndPeriod = AnalyticsStore.getPeriodStart(prevPeriod.end, period)
2404
+
2405
+ queries.previousPeriodStats = this.store.getAggregatedStatsCommand(
2406
+ options.siteId,
2407
+ period,
2408
+ prevStartPeriod,
2409
+ prevEndPeriod,
2410
+ )
2411
+ }
2412
+
2413
+ return queries
2414
+ }
2415
+
2416
+ /**
2417
+ * Generate query command for fetching top regions
2418
+ * @param siteId - Site ID
2419
+ * @param options - Query options including dateRange, country filter, and limit
2420
+ */
2421
+ getRegions(
2422
+ siteId: string,
2423
+ options: {
2424
+ dateRange: DateRange
2425
+ country?: string
2426
+ limit?: number
2427
+ },
2428
+ ): ReturnType<AnalyticsStore['getRegionsCommand']> {
2429
+ const period = AnalyticsQueryAPI.determinePeriod(options.dateRange)
2430
+ const periodStart = AnalyticsStore.getPeriodStart(options.dateRange.end, period)
2431
+ const limit = options.limit ?? 10
2432
+
2433
+ return this.store.getRegionsCommand(
2434
+ siteId,
2435
+ period,
2436
+ periodStart,
2437
+ options.country,
2438
+ limit,
2439
+ )
2440
+ }
2441
+
2442
+ /**
2443
+ * Generate query command for fetching top cities
2444
+ * @param siteId - Site ID
2445
+ * @param options - Query options including dateRange, country/region filters, and limit
2446
+ */
2447
+ getCities(
2448
+ siteId: string,
2449
+ options: {
2450
+ dateRange: DateRange
2451
+ country?: string
2452
+ region?: string
2453
+ limit?: number
2454
+ },
2455
+ ): ReturnType<AnalyticsStore['getCitiesCommand']> {
2456
+ const period = AnalyticsQueryAPI.determinePeriod(options.dateRange)
2457
+ const periodStart = AnalyticsStore.getPeriodStart(options.dateRange.end, period)
2458
+ const limit = options.limit ?? 10
2459
+
2460
+ return this.store.getCitiesCommand(
2461
+ siteId,
2462
+ period,
2463
+ periodStart,
2464
+ { country: options.country, region: options.region },
2465
+ limit,
2466
+ )
2467
+ }
2468
+
2469
+ /**
2470
+ * Process aggregated stats results into dashboard summary
2471
+ */
2472
+ static processSummary(
2473
+ stats: AggregatedStats[],
2474
+ previousStats?: AggregatedStats[],
2475
+ ): DashboardSummary {
2476
+ // Sum up all stats in the period
2477
+ const totals = stats.reduce(
2478
+ (acc, s) => ({
2479
+ pageViews: acc.pageViews + s.pageViews,
2480
+ uniqueVisitors: acc.uniqueVisitors + s.uniqueVisitors,
2481
+ sessions: acc.sessions + s.sessions,
2482
+ bounces: acc.bounces + s.bounces,
2483
+ totalTime: acc.totalTime + s.totalTimeOnSite,
2484
+ totalPages: acc.totalPages + (s.sessions * s.avgPagesPerSession),
2485
+ }),
2486
+ { pageViews: 0, uniqueVisitors: 0, sessions: 0, bounces: 0, totalTime: 0, totalPages: 0 },
2487
+ )
2488
+
2489
+ const summary: DashboardSummary = {
2490
+ pageViews: totals.pageViews,
2491
+ uniqueVisitors: totals.uniqueVisitors,
2492
+ sessions: totals.sessions,
2493
+ bounceRate: totals.sessions > 0 ? totals.bounces / totals.sessions : 0,
2494
+ avgSessionDuration: totals.sessions > 0 ? totals.totalTime / totals.sessions : 0,
2495
+ avgPagesPerSession: totals.sessions > 0 ? totals.totalPages / totals.sessions : 0,
2496
+ }
2497
+
2498
+ if (previousStats && previousStats.length > 0) {
2499
+ const prevTotals = previousStats.reduce(
2500
+ (acc, s) => ({
2501
+ pageViews: acc.pageViews + s.pageViews,
2502
+ uniqueVisitors: acc.uniqueVisitors + s.uniqueVisitors,
2503
+ bounces: acc.bounces + s.bounces,
2504
+ sessions: acc.sessions + s.sessions,
2505
+ }),
2506
+ { pageViews: 0, uniqueVisitors: 0, bounces: 0, sessions: 0 },
2507
+ )
2508
+
2509
+ const prevBounceRate = prevTotals.sessions > 0 ? prevTotals.bounces / prevTotals.sessions : 0
2510
+
2511
+ summary.comparison = {
2512
+ pageViewsChange: prevTotals.pageViews > 0
2513
+ ? ((totals.pageViews - prevTotals.pageViews) / prevTotals.pageViews) * 100
2514
+ : 0,
2515
+ visitorsChange: prevTotals.uniqueVisitors > 0
2516
+ ? ((totals.uniqueVisitors - prevTotals.uniqueVisitors) / prevTotals.uniqueVisitors) * 100
2517
+ : 0,
2518
+ bounceRateChange: summary.bounceRate - prevBounceRate,
2519
+ }
2520
+ }
2521
+
2522
+ return summary
2523
+ }
2524
+
2525
+ /**
2526
+ * Convert aggregated stats to time series data
2527
+ */
2528
+ static processTimeSeries(stats: AggregatedStats[]): TimeSeriesPoint[] {
2529
+ return stats
2530
+ .sort((a, b) => a.periodStart.localeCompare(b.periodStart))
2531
+ .map(s => ({
2532
+ timestamp: s.periodStart,
2533
+ pageViews: s.pageViews,
2534
+ uniqueVisitors: s.uniqueVisitors,
2535
+ sessions: s.sessions,
2536
+ bounceRate: s.bounceRate,
2537
+ }))
2538
+ }
2539
+
2540
+ /**
2541
+ * Process page stats into top items
2542
+ */
2543
+ static processTopPages(pageStats: PageStats[], totalPageViews: number): TopItem[] {
2544
+ return pageStats
2545
+ .sort((a, b) => b.pageViews - a.pageViews)
2546
+ .map(p => ({
2547
+ name: p.path,
2548
+ value: p.pageViews,
2549
+ percentage: totalPageViews > 0 ? (p.pageViews / totalPageViews) * 100 : 0,
2550
+ }))
2551
+ }
2552
+
2553
+ /**
2554
+ * Process referrer stats into top items
2555
+ */
2556
+ static processTopReferrers(referrerStats: ReferrerStats[], totalVisitors: number): TopItem[] {
2557
+ return referrerStats
2558
+ .sort((a, b) => b.visitors - a.visitors)
2559
+ .map(r => ({
2560
+ name: r.source,
2561
+ value: r.visitors,
2562
+ percentage: totalVisitors > 0 ? (r.visitors / totalVisitors) * 100 : 0,
2563
+ }))
2564
+ }
2565
+
2566
+ /**
2567
+ * Process geo stats into top items
2568
+ */
2569
+ static processTopCountries(geoStats: GeoStats[], totalVisitors: number): TopItem[] {
2570
+ return geoStats
2571
+ .filter(g => !g.region) // Only country-level stats
2572
+ .sort((a, b) => b.visitors - a.visitors)
2573
+ .map(g => ({
2574
+ name: g.country,
2575
+ value: g.visitors,
2576
+ percentage: totalVisitors > 0 ? (g.visitors / totalVisitors) * 100 : 0,
2577
+ }))
2578
+ }
2579
+
2580
+ /**
2581
+ * Process geo stats into top regions
2582
+ */
2583
+ static processTopRegions(geoStats: GeoStats[], totalVisitors: number): TopItem[] {
2584
+ return geoStats
2585
+ .filter(g => g.region && !g.city) // Only region-level stats (has region, no city)
2586
+ .sort((a, b) => b.visitors - a.visitors)
2587
+ .map(g => ({
2588
+ name: g.region!,
2589
+ value: g.visitors,
2590
+ percentage: totalVisitors > 0 ? (g.visitors / totalVisitors) * 100 : 0,
2591
+ metadata: { country: g.country },
2592
+ }))
2593
+ }
2594
+
2595
+ /**
2596
+ * Process geo stats into top cities
2597
+ */
2598
+ static processTopCities(geoStats: GeoStats[], totalVisitors: number): TopItem[] {
2599
+ return geoStats
2600
+ .filter(g => g.city) // Only city-level stats
2601
+ .sort((a, b) => b.visitors - a.visitors)
2602
+ .map(g => ({
2603
+ name: g.city!,
2604
+ value: g.visitors,
2605
+ percentage: totalVisitors > 0 ? (g.visitors / totalVisitors) * 100 : 0,
2606
+ metadata: { country: g.country, region: g.region },
2607
+ }))
2608
+ }
2609
+
2610
+ /**
2611
+ * Process device stats into top items
2612
+ */
2613
+ static processTopDevices(
2614
+ deviceStats: DeviceStats[],
2615
+ dimension: 'device' | 'browser' | 'os',
2616
+ totalVisitors: number,
2617
+ ): TopItem[] {
2618
+ return deviceStats
2619
+ .filter(d => d.dimension === dimension)
2620
+ .sort((a, b) => b.visitors - a.visitors)
2621
+ .map(d => ({
2622
+ name: d.value,
2623
+ value: d.visitors,
2624
+ percentage: totalVisitors > 0 ? (d.visitors / totalVisitors) * 100 : 0,
2625
+ }))
2626
+ }
2627
+
2628
+ /**
2629
+ * Process realtime stats
2630
+ */
2631
+ static processRealtimeData(
2632
+ realtimeStats: RealtimeStats[],
2633
+ ): RealtimeData {
2634
+ // Count unique visitors across all minutes by collecting visitor IDs
2635
+ const uniqueVisitorIds = new Set<string>()
2636
+ for (const stat of realtimeStats) {
2637
+ // visitorIds is stored as a DynamoDB String Set (SS) and comes as an array
2638
+ const visitorIds = stat.visitorIds as string[] | undefined
2639
+ if (visitorIds && Array.isArray(visitorIds)) {
2640
+ for (const id of visitorIds) {
2641
+ uniqueVisitorIds.add(id)
2642
+ }
2643
+ }
2644
+ }
2645
+ const currentVisitors = uniqueVisitorIds.size
2646
+
2647
+ const pageViewsLastHour = realtimeStats.reduce((sum, s) => sum + s.pageViews, 0)
2648
+
2649
+ // Aggregate active pages across all minutes
2650
+ const activePageCounts = new Map<string, number>()
2651
+ for (const stat of realtimeStats) {
2652
+ for (const [page, count] of Object.entries(stat.activePages)) {
2653
+ activePageCounts.set(page, (activePageCounts.get(page) ?? 0) + count)
2654
+ }
2655
+ }
2656
+
2657
+ const totalActive = Array.from(activePageCounts.values()).reduce((a, b) => a + b, 0)
2658
+ const topActivePages: TopItem[] = Array.from(activePageCounts.entries())
2659
+ .sort((a, b) => b[1] - a[1])
2660
+ .slice(0, 10)
2661
+ .map(([name, value]) => ({
2662
+ name,
2663
+ value,
2664
+ percentage: totalActive > 0 ? (value / totalActive) * 100 : 0,
2665
+ }))
2666
+
2667
+ return {
2668
+ currentVisitors,
2669
+ pageViewsLastHour,
2670
+ topActivePages,
2671
+ }
2672
+ }
2673
+
2674
+ /**
2675
+ * Process goal stats into performance data
2676
+ */
2677
+ static processGoalPerformance(
2678
+ goals: Goal[],
2679
+ goalStats: GoalStats[],
2680
+ totalVisitors: number,
2681
+ ): GoalPerformance[] {
2682
+ return goals.map((goal) => {
2683
+ const stats = goalStats.filter(gs => gs.goalId === goal.id)
2684
+ const totalConversions = stats.reduce((sum, s) => sum + s.conversions, 0)
2685
+ const totalRevenue = stats.reduce((sum, s) => sum + s.revenue, 0)
2686
+
2687
+ return {
2688
+ goalId: goal.id,
2689
+ goalName: goal.name,
2690
+ conversions: totalConversions,
2691
+ conversionRate: totalVisitors > 0 ? totalConversions / totalVisitors : 0,
2692
+ revenue: totalRevenue,
2693
+ }
2694
+ })
2695
+ }
2696
+ }
2697
+
2698
+ // ============================================================================
2699
+ // Aggregation Pipeline - Scheduled Job Utilities
2700
+ // ============================================================================
2701
+
2702
+ /**
2703
+ * Pipeline job configuration
2704
+ */
2705
+ export interface PipelineJobConfig {
2706
+ /** Site ID to process */
2707
+ siteId: string
2708
+ /** Period to aggregate */
2709
+ period: AggregationPeriod
2710
+ /** Start time for aggregation window */
2711
+ windowStart: Date
2712
+ /** End time for aggregation window */
2713
+ windowEnd: Date
2714
+ /** Whether to delete raw events after aggregation */
2715
+ deleteRawEvents?: boolean
2716
+ }
2717
+
2718
+ /**
2719
+ * Pipeline job result
2720
+ */
2721
+ export interface PipelineJobResult {
2722
+ /** Job configuration */
2723
+ config: PipelineJobConfig
2724
+ /** Whether the job succeeded */
2725
+ success: boolean
2726
+ /** Error message if failed */
2727
+ error?: string
2728
+ /** Number of page views processed */
2729
+ pageViewsProcessed: number
2730
+ /** Number of sessions processed */
2731
+ sessionsProcessed: number
2732
+ /** Number of events processed */
2733
+ eventsProcessed: number
2734
+ /** Number of conversions tracked */
2735
+ conversionsTracked: number
2736
+ /** Commands generated for DynamoDB writes */
2737
+ commands: Array<{
2738
+ command: string
2739
+ input: Record<string, unknown>
2740
+ }>
2741
+ /** Processing duration (ms) */
2742
+ durationMs: number
2743
+ }
2744
+
2745
+ /**
2746
+ * Aggregation job status
2747
+ */
2748
+ export interface AggregationJobStatus {
2749
+ /** Job ID */
2750
+ jobId: string
2751
+ /** Site ID */
2752
+ siteId: string
2753
+ /** Period being aggregated */
2754
+ period: AggregationPeriod
2755
+ /** Job status */
2756
+ status: 'pending' | 'running' | 'completed' | 'failed'
2757
+ /** Start time */
2758
+ startedAt?: Date
2759
+ /** Completion time */
2760
+ completedAt?: Date
2761
+ /** Error if failed */
2762
+ error?: string
2763
+ /** Progress percentage (0-100) */
2764
+ progress: number
2765
+ }
2766
+
2767
+ /**
2768
+ * Aggregation Pipeline for scheduled processing of analytics data
2769
+ */
2770
+ export class AggregationPipeline {
2771
+ private store: AnalyticsStore
2772
+ private aggregator: AnalyticsAggregator
2773
+
2774
+ constructor(store: AnalyticsStore, aggregator?: AnalyticsAggregator) {
2775
+ this.store = store
2776
+ this.aggregator = aggregator ?? new AnalyticsAggregator({ store })
2777
+ }
2778
+
2779
+ /**
2780
+ * Generate commands to run an aggregation job
2781
+ * This processes raw events and generates aggregated stats
2782
+ */
2783
+ runAggregationJob(
2784
+ config: PipelineJobConfig,
2785
+ pageViews: PageView[],
2786
+ sessions: Session[],
2787
+ events: CustomEvent[],
2788
+ goals: Goal[],
2789
+ ): PipelineJobResult {
2790
+ const startTime = Date.now()
2791
+ const commands: PipelineJobResult['commands'] = []
2792
+ let conversionsTracked = 0
2793
+
2794
+ try {
2795
+ // 1. Generate aggregated stats
2796
+ const aggregatedStats = this.aggregator.aggregateHourlyStats(
2797
+ config.siteId,
2798
+ config.windowStart,
2799
+ pageViews,
2800
+ sessions,
2801
+ )
2802
+ // Adjust period if not hourly
2803
+ aggregatedStats.period = config.period
2804
+ aggregatedStats.periodStart = AnalyticsStore.getPeriodStart(config.windowStart, config.period)
2805
+
2806
+ const statsCmd = this.store.upsertAggregatedStatsCommand(aggregatedStats)
2807
+ commands.push({ command: statsCmd.command, input: statsCmd.input })
2808
+
2809
+ // 2. Generate page stats
2810
+ const pageStats = this.aggregator.aggregatePageStats(
2811
+ config.siteId,
2812
+ config.period,
2813
+ config.windowStart,
2814
+ pageViews,
2815
+ )
2816
+ for (const ps of pageStats) {
2817
+ const cmd = this.store.upsertPageStatsCommand(ps)
2818
+ commands.push({ command: cmd.command, input: cmd.input })
2819
+ }
2820
+
2821
+ // 3. Generate referrer stats
2822
+ const referrerStats = this.aggregator.aggregateReferrerStats(
2823
+ config.siteId,
2824
+ config.period,
2825
+ config.windowStart,
2826
+ sessions,
2827
+ )
2828
+ for (const rs of referrerStats) {
2829
+ const cmd = this.store.upsertReferrerStatsCommand(rs)
2830
+ commands.push({ command: cmd.command, input: cmd.input })
2831
+ }
2832
+
2833
+ // 4. Generate geo stats
2834
+ const geoStats = this.aggregator.aggregateGeoStats(
2835
+ config.siteId,
2836
+ config.period,
2837
+ config.windowStart,
2838
+ sessions,
2839
+ )
2840
+ for (const gs of geoStats) {
2841
+ const cmd = this.store.upsertGeoStatsCommand(gs)
2842
+ commands.push({ command: cmd.command, input: cmd.input })
2843
+ }
2844
+
2845
+ // 5. Generate device stats
2846
+ const deviceStats = this.aggregator.aggregateDeviceStats(
2847
+ config.siteId,
2848
+ config.period,
2849
+ config.windowStart,
2850
+ sessions,
2851
+ )
2852
+ for (const ds of deviceStats) {
2853
+ const cmd = this.store.upsertDeviceStatsCommand(ds)
2854
+ commands.push({ command: cmd.command, input: cmd.input })
2855
+ }
2856
+
2857
+ // 6. Process goal conversions
2858
+ if (goals.length > 0) {
2859
+ const goalMatcher = new GoalMatcher(goals)
2860
+ const conversions: Conversion[] = []
2861
+
2862
+ // Match pageviews to goals
2863
+ for (const pv of pageViews) {
2864
+ const matches = goalMatcher.matchPageView(pv)
2865
+ for (const match of matches) {
2866
+ conversions.push(GoalMatcher.createConversion(config.siteId, match))
2867
+ }
2868
+ }
2869
+
2870
+ // Match events to goals
2871
+ for (const event of events) {
2872
+ const matches = goalMatcher.matchEvent(event)
2873
+ for (const match of matches) {
2874
+ conversions.push(GoalMatcher.createConversion(config.siteId, match))
2875
+ }
2876
+ }
2877
+
2878
+ conversionsTracked = conversions.length
2879
+
2880
+ // Generate goal stats grouped by goal
2881
+ const goalGroups = new Map<string, Conversion[]>()
2882
+ for (const conv of conversions) {
2883
+ const existing = goalGroups.get(conv.goalId) ?? []
2884
+ existing.push(conv)
2885
+ goalGroups.set(conv.goalId, existing)
2886
+ }
2887
+
2888
+ for (const [goalId, convs] of goalGroups) {
2889
+ const goalStats = this.createGoalStats(
2890
+ config.siteId,
2891
+ goalId,
2892
+ config.period,
2893
+ config.windowStart,
2894
+ convs,
2895
+ aggregatedStats.uniqueVisitors,
2896
+ )
2897
+ const cmd = this.store.upsertGoalStatsCommand(goalStats)
2898
+ commands.push({ command: cmd.command, input: cmd.input })
2899
+ }
2900
+ }
2901
+
2902
+ // 7. Generate event stats
2903
+ const eventStats = this.aggregateEventStats(
2904
+ config.siteId,
2905
+ config.period,
2906
+ config.windowStart,
2907
+ events,
2908
+ )
2909
+ for (const es of eventStats) {
2910
+ const cmd = this.store.upsertEventStatsCommand(es)
2911
+ commands.push({ command: cmd.command, input: cmd.input })
2912
+ }
2913
+
2914
+ return {
2915
+ config,
2916
+ success: true,
2917
+ pageViewsProcessed: pageViews.length,
2918
+ sessionsProcessed: sessions.length,
2919
+ eventsProcessed: events.length,
2920
+ conversionsTracked,
2921
+ commands,
2922
+ durationMs: Date.now() - startTime,
2923
+ }
2924
+ }
2925
+ catch (error) {
2926
+ return {
2927
+ config,
2928
+ success: false,
2929
+ error: error instanceof Error ? error.message : 'Unknown error',
2930
+ pageViewsProcessed: 0,
2931
+ sessionsProcessed: 0,
2932
+ eventsProcessed: 0,
2933
+ conversionsTracked: 0,
2934
+ commands,
2935
+ durationMs: Date.now() - startTime,
2936
+ }
2937
+ }
2938
+ }
2939
+
2940
+ /**
2941
+ * Get the next scheduled job times for a site
2942
+ */
2943
+ static getScheduledJobTimes(now: Date = new Date()): {
2944
+ hourly: Date
2945
+ daily: Date
2946
+ monthly: Date
2947
+ } {
2948
+ // Hourly: next hour boundary
2949
+ const hourly = new Date(now)
2950
+ hourly.setMinutes(0, 0, 0)
2951
+ hourly.setHours(hourly.getHours() + 1)
2952
+
2953
+ // Daily: next midnight UTC
2954
+ const daily = new Date(now)
2955
+ daily.setUTCHours(0, 0, 0, 0)
2956
+ daily.setUTCDate(daily.getUTCDate() + 1)
2957
+
2958
+ // Monthly: first of next month UTC
2959
+ const monthly = new Date(now)
2960
+ monthly.setUTCHours(0, 0, 0, 0)
2961
+ monthly.setUTCDate(1)
2962
+ monthly.setUTCMonth(monthly.getUTCMonth() + 1)
2963
+
2964
+ return { hourly, daily, monthly }
2965
+ }
2966
+
2967
+ /**
2968
+ * Generate a cron expression for aggregation jobs
2969
+ */
2970
+ static getCronExpression(period: AggregationPeriod): string {
2971
+ switch (period) {
2972
+ case 'hour':
2973
+ return '0 * * * *' // Every hour at minute 0
2974
+ case 'day':
2975
+ return '0 0 * * *' // Every day at midnight
2976
+ case 'month':
2977
+ return '0 0 1 * *' // First day of every month
2978
+ default:
2979
+ return '0 * * * *'
2980
+ }
2981
+ }
2982
+
2983
+ /**
2984
+ * Get the aggregation window for a period
2985
+ */
2986
+ static getAggregationWindow(period: AggregationPeriod, referenceTime: Date = new Date()): {
2987
+ start: Date
2988
+ end: Date
2989
+ } {
2990
+ const end = new Date(referenceTime)
2991
+
2992
+ switch (period) {
2993
+ case 'hour': {
2994
+ // Previous hour
2995
+ end.setMinutes(0, 0, 0)
2996
+ const start = new Date(end)
2997
+ start.setHours(start.getHours() - 1)
2998
+ return { start, end }
2999
+ }
3000
+ case 'day': {
3001
+ // Previous day
3002
+ end.setUTCHours(0, 0, 0, 0)
3003
+ const start = new Date(end)
3004
+ start.setUTCDate(start.getUTCDate() - 1)
3005
+ return { start, end }
3006
+ }
3007
+ case 'month': {
3008
+ // Previous month
3009
+ end.setUTCDate(1)
3010
+ end.setUTCHours(0, 0, 0, 0)
3011
+ const start = new Date(end)
3012
+ start.setUTCMonth(start.getUTCMonth() - 1)
3013
+ return { start, end }
3014
+ }
3015
+ default:
3016
+ return { start: end, end }
3017
+ }
3018
+ }
3019
+
3020
+ /**
3021
+ * Create a job configuration for a site and period
3022
+ */
3023
+ static createJobConfig(
3024
+ siteId: string,
3025
+ period: AggregationPeriod,
3026
+ referenceTime?: Date,
3027
+ ): PipelineJobConfig {
3028
+ const window = AggregationPipeline.getAggregationWindow(period, referenceTime)
3029
+ return {
3030
+ siteId,
3031
+ period,
3032
+ windowStart: window.start,
3033
+ windowEnd: window.end,
3034
+ deleteRawEvents: period === 'hour', // Only delete after hourly aggregation
3035
+ }
3036
+ }
3037
+
3038
+ /**
3039
+ * Helper to aggregate event stats
3040
+ */
3041
+ private aggregateEventStats(
3042
+ siteId: string,
3043
+ period: AggregationPeriod,
3044
+ periodStart: Date,
3045
+ events: CustomEvent[],
3046
+ ): EventStats[] {
3047
+ const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
3048
+
3049
+ // Group by event name
3050
+ const eventGroups = new Map<string, CustomEvent[]>()
3051
+ for (const event of events) {
3052
+ const existing = eventGroups.get(event.name) ?? []
3053
+ existing.push(event)
3054
+ eventGroups.set(event.name, existing)
3055
+ }
3056
+
3057
+ const results: EventStats[] = []
3058
+ for (const [eventName, groupEvents] of eventGroups) {
3059
+ const uniqueVisitors = new Set(groupEvents.map(e => e.visitorId))
3060
+ const values = groupEvents.filter(e => e.value !== undefined).map(e => e.value!)
3061
+ const totalValue = values.reduce((a, b) => a + b, 0)
3062
+
3063
+ results.push({
3064
+ siteId,
3065
+ period,
3066
+ periodStart: periodStartStr,
3067
+ eventName,
3068
+ count: groupEvents.length,
3069
+ uniqueVisitors: uniqueVisitors.size,
3070
+ totalValue,
3071
+ avgValue: values.length > 0 ? totalValue / values.length : 0,
3072
+ })
3073
+ }
3074
+
3075
+ return results
3076
+ }
3077
+
3078
+ /**
3079
+ * Helper to create goal stats
3080
+ */
3081
+ private createGoalStats(
3082
+ siteId: string,
3083
+ goalId: string,
3084
+ period: AggregationPeriod,
3085
+ periodStart: Date,
3086
+ conversions: Conversion[],
3087
+ totalVisitors: number,
3088
+ ): GoalStats {
3089
+ const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
3090
+ const uniqueConversions = new Set(conversions.map(c => c.visitorId))
3091
+ const revenue = conversions.reduce((sum, c) => sum + c.value, 0)
3092
+
3093
+ return {
3094
+ siteId,
3095
+ goalId,
3096
+ period,
3097
+ periodStart: periodStartStr,
3098
+ conversions: conversions.length,
3099
+ uniqueConversions: uniqueConversions.size,
3100
+ conversionRate: totalVisitors > 0 ? uniqueConversions.size / totalVisitors : 0,
3101
+ revenue,
3102
+ }
3103
+ }
3104
+ }
3105
+
3106
+ // ============================================================================
3107
+ // Tracking Script Generator
3108
+ // ============================================================================
3109
+
3110
+ /**
3111
+ * Options for generating tracking script
3112
+ */
3113
+ export interface TrackingScriptOptions {
3114
+ /** Site ID */
3115
+ siteId: string
3116
+ /** API endpoint URL */
3117
+ apiEndpoint: string
3118
+ /** Whether to respect Do Not Track */
3119
+ honorDnt?: boolean
3120
+ /** Whether to track hash changes as page views */
3121
+ trackHashChanges?: boolean
3122
+ /** Whether to track outbound links (legacy; superseded by trackLinkClicks) */
3123
+ trackOutboundLinks?: boolean
3124
+ /** Whether to track link clicks (outbound, internal, download, mailto, tel) */
3125
+ trackLinkClicks?: boolean
3126
+ /** Whether to track SPA route changes (history pushState/replaceState/popstate) as page views */
3127
+ trackSpaRoutes?: boolean
3128
+ /** Whether to track engagement (max scroll depth + active time on page), flushed on page-leave */
3129
+ trackEngagement?: boolean
3130
+ /** Use stealth mode (shorter endpoint paths, less identifiable) */
3131
+ stealthMode?: boolean
3132
+ /** Whether to track Core Web Vitals (LCP, FID, CLS, TTFB, INP) */
3133
+ trackWebVitals?: boolean
3134
+ /** Ingest-only error key (Sentry-style DSN key). When set, captured errors
3135
+ */
3136
+ }
3137
+
3138
+ /**
3139
+ * Generate minimal tracking script
3140
+ */
3141
+ /* eslint-disable prefer-const, pickier/no-unused-vars, general/prefer-template */
3142
+ export function generateTrackingScript(options: TrackingScriptOptions): string {
3143
+ const endpoint = options.stealthMode ? '/t' : '/collect'
3144
+ const webVitalsCode = options.trackWebVitals !== false ? `
3145
+ // Core Web Vitals tracking
3146
+ var vitalsSent={};
3147
+ function sendVital(name,value,rating){
3148
+ if(vitalsSent[name])return;
3149
+ vitalsSent[name]=true;
3150
+ t('vitals',{metric:name,value:Math.round(value),rating:rating},true);
3151
+ }
3152
+ function getRating(name,value){
3153
+ var thresholds={LCP:[2500,4000],FID:[100,300],CLS:[0.1,0.25],FCP:[1800,3000],TTFB:[800,1800],INP:[200,500]};
3154
+ var t=thresholds[name]||[0,0];
3155
+ return value<=t[0]?'good':value<=t[1]?'needs-improvement':'poor';
3156
+ }
3157
+ // LCP - Largest Contentful Paint
3158
+ if(w.PerformanceObserver){
3159
+ try{
3160
+ new PerformanceObserver(function(l){
3161
+ var entries=l.getEntries();
3162
+ var last=entries[entries.length-1];
3163
+ if(last)sendVital('LCP',last.startTime,getRating('LCP',last.startTime));
3164
+ }).observe({type:'largest-contentful-paint',buffered:true});
3165
+ }
3166
+ catch (e){}
3167
+ // FCP - First Contentful Paint
3168
+ try{
3169
+ new PerformanceObserver(function(l){
3170
+ for(var e of l.getEntries()){if(e.name==='first-contentful-paint')sendVital('FCP',e.startTime,getRating('FCP',e.startTime));}
3171
+ }).observe({type:'paint',buffered:true});
3172
+ }
3173
+ catch (e){}
3174
+ // FID - First Input Delay
3175
+ try{
3176
+ new PerformanceObserver(function(l){
3177
+ var entry=l.getEntries()[0];
3178
+ if(entry)sendVital('FID',entry.processingStart-entry.startTime,getRating('FID',entry.processingStart-entry.startTime));
3179
+ }).observe({type:'first-input',buffered:true});
3180
+ }
3181
+ catch (e){}
3182
+ // CLS - Cumulative Layout Shift
3183
+ var clsValue=0;
3184
+ try{
3185
+ new PerformanceObserver(function(l){
3186
+ for(var e of l.getEntries()){if(!e.hadRecentInput)clsValue+=e.value;}
3187
+ }).observe({type:'layout-shift',buffered:true});
3188
+ w.addEventListener('visibilitychange',function(){
3189
+ if(d.visibilityState==='hidden')sendVital('CLS',clsValue*1000,getRating('CLS',clsValue));
3190
+ });
3191
+ }
3192
+ catch (e){}
3193
+ // INP - Interaction to Next Paint
3194
+ var inpValue=0;
3195
+ try{
3196
+ new PerformanceObserver(function(l){
3197
+ for(var e of l.getEntries()){
3198
+ if(e.interactionId&&e.duration>inpValue)inpValue=e.duration;
3199
+ }
3200
+ }).observe({type:'event',buffered:true,durationThreshold:16});
3201
+ w.addEventListener('visibilitychange',function(){
3202
+ if(d.visibilityState==='hidden'&&inpValue>0)sendVital('INP',inpValue,getRating('INP',inpValue));
3203
+ });
3204
+ }
3205
+ catch (e){}
3206
+ }
3207
+ // TTFB - Time to First Byte
3208
+ w.addEventListener('load',function(){
3209
+ var nav=performance.getEntriesByType('navigation')[0];
3210
+ if(nav)sendVital('TTFB',nav.responseStart,getRating('TTFB',nav.responseStart));
3211
+ });` : ''
3212
+
3213
+
3214
+ return `
3215
+ <!-- Analytics -->
3216
+ <script data-site="${options.siteId}" data-api="${options.apiEndpoint}" defer>
3217
+ (function(){
3218
+ 'use strict';
3219
+ var d=document,w=window,n=navigator,s=d.currentScript;
3220
+ // Double-include guard (#138) + version identity (#179): the guard value IS
3221
+ // the tracker version, so support can read it off any live page.
3222
+ if(w.__tsa)return;w.__tsa="${TRACKER_VERSION}";
3223
+ var site=s.dataset.site,api=s.dataset.api;
3224
+ if((!site||!api)&&s.src){try{var su=new URL(s.src);if(!api)api=su.origin;var sm=su.pathname.match(/\\/sites\\/([^/]+)\\/script/);if(!site&&sm)site=sm[1];}catch(_e){}}
3225
+ ${options.honorDnt ? 'if(n.doNotTrack==="1"||n.globalPrivacyControl===true)return;' : ''}
3226
+ var q=[],sk='_tsa_sid',sid;try{sid=sessionStorage.getItem(sk)}
3227
+ catch (e){}if(!sid){sid=Math.random().toString(36).slice(2);try{sessionStorage.setItem(sk,sid)}
3228
+ catch (e){}}
3229
+ // Client-side browser detection
3230
+ var br=s.dataset.browser||'';
3231
+ if(!br)try{
3232
+ var ua=n.userAgent||'';
3233
+ var uad=n.userAgentData;
3234
+ // Check userAgentData.brands for non-generic browser brands
3235
+ if(uad&&uad.brands){
3236
+ var b=uad.brands.find(function(x){return x.brand&&!/chromium|not.*brand|google chrome/i.test(x.brand)});
3237
+ if(b)br=b.brand;
3238
+ }
3239
+ // Fallback to UA string patterns
3240
+ if(!br||br==='Google Chrome'){
3241
+ if(/Arc\//i.test(ua))br='Arc';
3242
+ else if(/Edg\//i.test(ua))br='Edge';
3243
+ else if(/OPR\/|Opera/i.test(ua))br='Opera';
3244
+ else if(/Brave/i.test(ua))br='Brave';
3245
+ else if(/Vivaldi/i.test(ua))br='Vivaldi';
3246
+ else if(/Firefox/i.test(ua))br='Firefox';
3247
+ else if(/Safari/i.test(ua)&&!/Chrome/i.test(ua))br='Safari';
3248
+ else if(/Chrome/i.test(ua))br='Chrome';
3249
+ }
3250
+ }
3251
+ catch (e){}
3252
+ // Coarse timezone for privacy-first country stats: the server maps it to a
3253
+ // country and discards it — no IP geolocation, no third parties.
3254
+ var tzn='';try{tzn=Intl.DateTimeFormat().resolvedOptions().timeZone||''}
3255
+ catch (e){}
3256
+ function t(e,p,b){
3257
+ var body=JSON.stringify({
3258
+ s:site,sid:sid,e:e,p:p||{},
3259
+ u:location.href,r:d.referrer,t:d.title,
3260
+ sw:screen.width,sh:screen.height,
3261
+ br:br,tz:tzn,v:"${TRACKER_VERSION}",
3262
+ eid:Math.random().toString(36).slice(2)+Date.now().toString(36)
3263
+ });
3264
+ if(b&&n.sendBeacon){try{if(n.sendBeacon(api+'${endpoint}',body))return;}catch(_e){}}
3265
+ var x=new XMLHttpRequest();
3266
+ x.open('POST',api+'${endpoint}',true);
3267
+ x.setRequestHeader('Content-Type','application/json');
3268
+ x.send(body);
3269
+ }
3270
+ function pv(){t('pageview',undefined,true);}
3271
+ ${options.trackHashChanges ? 'w.addEventListener(\'hashchange\',pv);' : ''}
3272
+ ${options.trackSpaRoutes
3273
+ ? `
3274
+ var lastPath=location.pathname+location.search;
3275
+ function spaPv(){
3276
+ var p=location.pathname+location.search;
3277
+ if(p===lastPath)return;
3278
+ lastPath=p;
3279
+ if(typeof engFlush!=='undefined'){engMax=0;engActive=0;engSent=false;engStart=Date.now();}
3280
+ pv();
3281
+ }
3282
+ var _ps=history.pushState;
3283
+ history.pushState=function(){if(typeof engFlush!=='undefined')engFlush();_ps.apply(this,arguments);spaPv();};
3284
+ var _rs=history.replaceState;
3285
+ history.replaceState=function(){if(typeof engFlush!=='undefined')engFlush();_rs.apply(this,arguments);spaPv();};
3286
+ w.addEventListener('popstate',spaPv);`
3287
+ : ''}
3288
+ ${options.trackOutboundLinks
3289
+ ? `
3290
+ d.addEventListener('click',function(e){
3291
+ var a=e.target.closest('a');
3292
+ if(a&&a.hostname!==location.hostname){
3293
+ t('outbound',{url:a.href});
3294
+ }
3295
+ });`
3296
+ : ''}
3297
+ ${options.trackLinkClicks
3298
+ ? `
3299
+ d.addEventListener('click',function(e){
3300
+ var a=e.target&&e.target.closest&&e.target.closest('a');
3301
+ if(!a||!a.href)return;
3302
+ var proto=a.protocol,kind;
3303
+ if(proto==='mailto:')kind='mailto';
3304
+ else if(proto==='tel:')kind='tel';
3305
+ else if(proto==='http:'||proto==='https:'){
3306
+ if(a.hasAttribute('download')||/\\.(pdf|zip|docx?|xlsx?|pptx?|dmg|pkg|exe|csv|tsv|mp3|mp4|mov|wav|gz|tgz|rar|7z|apk|iso|bin|deb|rpm|msi)$/i.test(a.pathname))kind='download';
3307
+ else if(a.hostname!==location.hostname)kind='outbound';
3308
+ else{var h=a.getAttribute('href')||'';if(h.charAt(0)==='#')return;kind='internal';}
3309
+ }else return;
3310
+ t('click',{url:a.href,kind:kind,text:(a.innerText||a.textContent||'').replace(/\\s+/g,' ').trim().slice(0,200)},true);
3311
+ },true);`
3312
+ : ''}
3313
+ ${options.trackEngagement
3314
+ ? `
3315
+ var engStart=Date.now(),engActive=0,engMax=0,engSent=false;
3316
+ function engScroll(){
3317
+ var st=w.pageYOffset||d.documentElement.scrollTop;
3318
+ var dh=Math.max(d.body.scrollHeight,d.documentElement.scrollHeight)-w.innerHeight;
3319
+ if(dh<=0)return;
3320
+ var p=Math.round((st/dh)*100);
3321
+ if(p>engMax)engMax=p>100?100:p;
3322
+ }
3323
+ var engThr=null;
3324
+ w.addEventListener('scroll',function(){
3325
+ if(engThr)return;
3326
+ engThr=setTimeout(function(){engThr=null;engScroll();},250);
3327
+ },{passive:true});
3328
+ function engAccum(){engActive+=Date.now()-engStart;engStart=Date.now();}
3329
+ function engFlush(){
3330
+ if(d.visibilityState!=='hidden')engAccum();
3331
+ var secs=Math.round(engActive/1000);
3332
+ if(secs<1&&engSent)return;
3333
+ engSent=true;engActive=0;
3334
+ t('engagement',{scrollDepth:engMax,timeOnPage:secs},true);
3335
+ }
3336
+ d.addEventListener('visibilitychange',function(){
3337
+ if(d.visibilityState==='hidden'){engAccum();engFlush();}
3338
+ else{engStart=Date.now();}
3339
+ });
3340
+ w.addEventListener('pagehide',engFlush);`
3341
+ : ''}
3342
+ if(d.readyState==='complete')pv();
3343
+ else w.addEventListener('load',pv);
3344
+ w.fathom={track:function(n,v,p){var o={};if(p)for(var k in p)o[k]=p[k];o.name=n;o.value=v;t('event',o);}};\n${webVitalsCode}
3345
+ })();
3346
+ </script>
3347
+ `.trim()
3348
+ }
3349
+ /* eslint-enable prefer-const, pickier/no-unused-vars, general/prefer-template */