@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
package/src/api.ts ADDED
@@ -0,0 +1,1286 @@
1
+ // ============================================================================
2
+ // ⚠️ LEGACY — NOT the live request path (#95)
3
+ // ============================================================================
4
+ // This layer is built on the IN-MEMORY AnalyticsStore and is kept only for
5
+ // backwards compatibility of the published library API. Nothing here serves
6
+ // the bundled server or dashboard.
7
+ //
8
+ // The source of truth for the real API is:
9
+ // src/router.ts + src/handlers/* (DynamoDB-backed, served by server/)
10
+ //
11
+ // Add new endpoints THERE. Do not extend this file.
12
+ // ============================================================================
13
+ // HTTP handlers for analytics endpoints, designed to work with any server framework
14
+ // (Bun, Express, Hono, AWS Lambda, Cloudflare Workers, etc.)
15
+
16
+ import type { AggregationPeriod, CustomEvent, Goal, PageView, Session, Site, SiteSettings } from './Analytics'
17
+ import {
18
+
19
+ AggregationPipeline,
20
+ AnalyticsQueryAPI,
21
+ AnalyticsStore,
22
+
23
+ generateTrackingScript,
24
+
25
+ GoalMatcher,
26
+
27
+ } from './Analytics'
28
+ import {
29
+ GeolocationService,
30
+ type IPGeoResult,
31
+ lookupFromHeaders,
32
+ } from './geolocation'
33
+ import { getDailySalt } from './lib/salt'
34
+
35
+ // ============================================================================
36
+ // Types
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Generic HTTP request interface (framework-agnostic)
41
+ */
42
+ export interface AnalyticsRequest {
43
+ method: string
44
+ path: string
45
+ params: Record<string, string>
46
+ query: Record<string, string>
47
+ body: unknown
48
+ headers: Record<string, string>
49
+ ip?: string
50
+ userAgent?: string
51
+ }
52
+
53
+ /**
54
+ * Generic HTTP response interface
55
+ */
56
+ export interface AnalyticsResponse {
57
+ status: number
58
+ headers: Record<string, string>
59
+ body: unknown
60
+ }
61
+
62
+ /**
63
+ * API handler context
64
+ */
65
+ export interface HandlerContext {
66
+ store: AnalyticsStore
67
+ queryApi: AnalyticsQueryAPI
68
+ pipeline: AggregationPipeline
69
+ /** Daily rotating salt for visitor ID hashing */
70
+ visitorSalt: string
71
+ /** Function to execute DynamoDB commands */
72
+ executeCommand: (command: { command: string, input: Record<string, unknown> }) => Promise<unknown>
73
+ /** Optional: Function to get goals for a site */
74
+ getGoals?: (siteId: string) => Promise<Goal[]>
75
+ /** Optional: Function to get site by ID */
76
+ getSite?: (siteId: string) => Promise<Site | null>
77
+ /** Optional: Session store for managing visitor sessions */
78
+ sessionStore?: SessionStore
79
+ }
80
+
81
+ /**
82
+ * Simple in-memory session store interface
83
+ */
84
+ export interface SessionStore {
85
+ get: (key: string) => Promise<Session | null>
86
+ set: (key: string, session: Session, ttlSeconds?: number) => Promise<void>
87
+ delete: (key: string) => Promise<void>
88
+ }
89
+
90
+ /**
91
+ * Collect endpoint payload (from tracking script)
92
+ */
93
+ export interface CollectPayload {
94
+ /** Site ID */
95
+ s: string
96
+ /** Session ID */
97
+ sid: string
98
+ /** Event type */
99
+ e: 'pageview' | 'event' | 'outbound' | 'error'
100
+ /** Event properties */
101
+ p?: Record<string, unknown>
102
+ /** Current URL */
103
+ u: string
104
+ /** Referrer */
105
+ r?: string
106
+ /** Page title */
107
+ t?: string
108
+ /** Screen width */
109
+ sw?: number
110
+ /** Screen height */
111
+ sh?: number
112
+ }
113
+
114
+ /**
115
+ * API configuration
116
+ */
117
+ export interface AnalyticsAPIConfig {
118
+ /** DynamoDB table name */
119
+ tableName: string
120
+ /** CORS allowed origins */
121
+ corsOrigins?: string[]
122
+ /** Whether to use TTL for raw events */
123
+ useTtl?: boolean
124
+ /** Raw event TTL in seconds */
125
+ rawEventTtl?: number
126
+ /** Base path for API routes */
127
+ basePath?: string
128
+ }
129
+
130
+ // ============================================================================
131
+ // Analytics API Handler Class
132
+ // ============================================================================
133
+
134
+ /**
135
+ * Analytics API - handles all analytics HTTP endpoints
136
+ */
137
+ export class AnalyticsAPI {
138
+ private config: Required<AnalyticsAPIConfig>
139
+ private store: AnalyticsStore
140
+ private queryApi: AnalyticsQueryAPI
141
+ private pipeline: AggregationPipeline
142
+
143
+ constructor(config: AnalyticsAPIConfig) {
144
+ this.config = {
145
+ corsOrigins: ['*'],
146
+ useTtl: true,
147
+ rawEventTtl: 30 * 24 * 60 * 60,
148
+ basePath: '/api/analytics',
149
+ ...config,
150
+ }
151
+
152
+ this.store = new AnalyticsStore({
153
+ tableName: this.config.tableName,
154
+ useTtl: this.config.useTtl,
155
+ rawEventTtl: this.config.rawEventTtl,
156
+ })
157
+
158
+ this.queryApi = new AnalyticsQueryAPI(this.store)
159
+ this.pipeline = new AggregationPipeline(this.store)
160
+ }
161
+
162
+ /**
163
+ * Create handler context for request processing
164
+ */
165
+ createContext(
166
+ executeCommand: HandlerContext['executeCommand'],
167
+ options?: Partial<Pick<HandlerContext, 'getGoals' | 'getSite' | 'sessionStore'>>,
168
+ ): HandlerContext {
169
+ return {
170
+ store: this.store,
171
+ queryApi: this.queryApi,
172
+ pipeline: this.pipeline,
173
+ visitorSalt: this.getDailySalt(),
174
+ executeCommand,
175
+ ...options,
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Get daily rotating salt for visitor ID hashing (secret-seeded, #88)
181
+ */
182
+ private getDailySalt(): string {
183
+ return getDailySalt()
184
+ }
185
+
186
+ /**
187
+ * Get CORS headers
188
+ */
189
+ getCorsHeaders(origin?: string): Record<string, string> {
190
+ const allowedOrigin = this.config.corsOrigins.includes('*')
191
+ ? '*'
192
+ : (origin && this.config.corsOrigins.includes(origin) ? origin : this.config.corsOrigins[0])
193
+
194
+ return {
195
+ 'Access-Control-Allow-Origin': allowedOrigin || '*',
196
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
197
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
198
+ 'Access-Control-Max-Age': '86400',
199
+ }
200
+ }
201
+
202
+ // ==========================================================================
203
+ // Route Handlers
204
+ // ==========================================================================
205
+
206
+ /**
207
+ * Handle OPTIONS preflight request
208
+ */
209
+ handleOptions(req: AnalyticsRequest): AnalyticsResponse {
210
+ return {
211
+ status: 204,
212
+ headers: this.getCorsHeaders(req.headers.origin),
213
+ body: null,
214
+ }
215
+ }
216
+
217
+ /**
218
+ * POST /collect - Receive tracking events from JavaScript snippet
219
+ */
220
+ async handleCollect(
221
+ req: AnalyticsRequest,
222
+ ctx: HandlerContext,
223
+ ): Promise<AnalyticsResponse> {
224
+ try {
225
+ const payload = req.body as CollectPayload
226
+
227
+ if (!payload?.s || !payload?.e || !payload?.u) {
228
+ return this.errorResponse(400, 'Missing required fields: s, e, u')
229
+ }
230
+
231
+ // Hash visitor ID for privacy
232
+ const visitorId = await AnalyticsStore.hashVisitorId(
233
+ req.ip || 'unknown',
234
+ req.userAgent || req.headers['user-agent'] || 'unknown',
235
+ payload.s,
236
+ ctx.visitorSalt,
237
+ )
238
+
239
+ // Parse URL
240
+ let parsedUrl: URL
241
+ try {
242
+ parsedUrl = new URL(payload.u)
243
+ }
244
+ catch {
245
+ return this.errorResponse(400, 'Invalid URL')
246
+ }
247
+
248
+ const timestamp = new Date()
249
+ const sessionId = payload.sid
250
+
251
+ // Get or create session
252
+ let session = ctx.sessionStore ? await ctx.sessionStore.get(`${payload.s}:${sessionId}`) : null
253
+ const isNewSession = !session
254
+ const isUnique = isNewSession
255
+
256
+ if (payload.e === 'pageview') {
257
+ // Parse device info from user agent
258
+ const deviceInfo = AnalyticsStore.parseUserAgent(req.userAgent || req.headers['user-agent'] || '')
259
+ const referrerSource = AnalyticsStore.parseReferrerSource(payload.r)
260
+
261
+ // Parse UTM parameters from URL
262
+ const utmSource = parsedUrl.searchParams.get('utm_source') || undefined
263
+ const utmMedium = parsedUrl.searchParams.get('utm_medium') || undefined
264
+ const utmCampaign = parsedUrl.searchParams.get('utm_campaign') || undefined
265
+ const utmContent = parsedUrl.searchParams.get('utm_content') || undefined
266
+ const utmTerm = parsedUrl.searchParams.get('utm_term') || undefined
267
+
268
+ // Get geolocation from headers (Cloudflare, Vercel, etc.) or IP lookup
269
+ let geoResult: IPGeoResult | null = null
270
+ try {
271
+ geoResult = await lookupFromHeaders(req.headers as any)
272
+ // Note: For IP-based lookup (slower), you could use:
273
+ // if (!geoResult && req.ip) {
274
+ // geoResult = await lookupIP(req.ip)
275
+ // }
276
+ }
277
+ catch {
278
+ // Geolocation lookup failed, continue without it
279
+ }
280
+
281
+ // Create page view record
282
+ const pageView: PageView = {
283
+ id: AnalyticsStore.generateId(),
284
+ siteId: payload.s,
285
+ visitorId,
286
+ sessionId,
287
+ path: parsedUrl.pathname,
288
+ hostname: parsedUrl.hostname,
289
+ title: payload.t,
290
+ referrer: payload.r,
291
+ referrerSource,
292
+ utmSource,
293
+ utmMedium,
294
+ utmCampaign,
295
+ utmContent,
296
+ utmTerm,
297
+ deviceType: deviceInfo.deviceType,
298
+ browser: deviceInfo.browser,
299
+ browserVersion: deviceInfo.browserVersion,
300
+ os: deviceInfo.os,
301
+ osVersion: deviceInfo.osVersion,
302
+ screenWidth: payload.sw,
303
+ screenHeight: payload.sh,
304
+ // Geolocation data
305
+ country: geoResult?.countryCode,
306
+ region: geoResult?.regionCode || geoResult?.region,
307
+ city: geoResult?.city,
308
+ isUnique,
309
+ isBounce: isNewSession, // Will be updated if more pages viewed
310
+ timestamp,
311
+ }
312
+
313
+ // Store page view
314
+ const pvCommand = this.store.recordPageViewCommand(pageView)
315
+ await ctx.executeCommand(pvCommand)
316
+
317
+ // Update or create session
318
+ if (session) {
319
+ session.pageViewCount += 1
320
+ session.exitPath = parsedUrl.pathname
321
+ session.endedAt = timestamp
322
+ session.isBounce = false
323
+ session.duration = timestamp.getTime() - session.startedAt.getTime()
324
+ }
325
+ else {
326
+ session = {
327
+ id: sessionId,
328
+ siteId: payload.s,
329
+ visitorId,
330
+ entryPath: parsedUrl.pathname,
331
+ exitPath: parsedUrl.pathname,
332
+ referrer: payload.r,
333
+ referrerSource,
334
+ utmSource,
335
+ utmMedium,
336
+ utmCampaign,
337
+ // Geolocation data
338
+ country: geoResult?.countryCode,
339
+ region: geoResult?.regionCode || geoResult?.region,
340
+ city: geoResult?.city,
341
+ deviceType: deviceInfo.deviceType,
342
+ browser: deviceInfo.browser,
343
+ os: deviceInfo.os,
344
+ pageViewCount: 1,
345
+ eventCount: 0,
346
+ isBounce: true,
347
+ duration: 0,
348
+ startedAt: timestamp,
349
+ endedAt: timestamp,
350
+ }
351
+ }
352
+
353
+ // Store session
354
+ const sessionCommand = this.store.upsertSessionCommand(session)
355
+ await ctx.executeCommand(sessionCommand)
356
+
357
+ if (ctx.sessionStore) {
358
+ await ctx.sessionStore.set(`${payload.s}:${sessionId}`, session, 1800) // 30 min TTL
359
+ }
360
+
361
+ // Update realtime stats with visitor ID for unique visitor tracking
362
+ const minute = timestamp.toISOString().slice(0, 16)
363
+ const realtimeCommand = this.store.updateRealtimeStatsCommand({
364
+ siteId: payload.s,
365
+ minute,
366
+ currentVisitors: 0, // Computed from visitorIds set on retrieval
367
+ pageViews: 1,
368
+ activePages: { [parsedUrl.pathname]: 1 },
369
+ ttl: Math.floor(Date.now() / 1000) + 600, // 10 min TTL
370
+ visitorId, // Track unique visitor ID in DynamoDB String Set
371
+ })
372
+ await ctx.executeCommand(realtimeCommand)
373
+
374
+ // Check for goal conversions
375
+ if (ctx.getGoals) {
376
+ const goals = await ctx.getGoals(payload.s)
377
+ if (goals.length > 0) {
378
+ const matcher = new GoalMatcher(goals)
379
+ const _matches = matcher.matchPageView(pageView)
380
+ // Note: Conversions are typically tracked during aggregation, not real-time
381
+ // This could be extended to store real-time conversion events
382
+ }
383
+ }
384
+ }
385
+ else if (payload.e === 'event') {
386
+ // Custom event
387
+ const eventProps = payload.p || {}
388
+ const customEvent: CustomEvent = {
389
+ id: AnalyticsStore.generateId(),
390
+ siteId: payload.s,
391
+ visitorId,
392
+ sessionId,
393
+ name: String(eventProps.name || 'unknown'),
394
+ value: typeof eventProps.value === 'number' ? eventProps.value : undefined,
395
+ properties: eventProps as Record<string, string | number | boolean>,
396
+ path: parsedUrl.pathname,
397
+ timestamp,
398
+ }
399
+
400
+ const eventCommand = this.store.recordCustomEventCommand(customEvent)
401
+ await ctx.executeCommand(eventCommand)
402
+
403
+ // Update session event count
404
+ if (session) {
405
+ session.eventCount += 1
406
+ session.endedAt = timestamp
407
+ const sessionCommand = this.store.upsertSessionCommand(session)
408
+ await ctx.executeCommand(sessionCommand)
409
+
410
+ if (ctx.sessionStore) {
411
+ await ctx.sessionStore.set(`${payload.s}:${sessionId}`, session, 1800)
412
+ }
413
+ }
414
+ }
415
+ else if (payload.e === 'outbound') {
416
+ // Outbound link click - tracked as custom event
417
+ const customEvent: CustomEvent = {
418
+ id: AnalyticsStore.generateId(),
419
+ siteId: payload.s,
420
+ visitorId,
421
+ sessionId,
422
+ name: 'outbound_click',
423
+ properties: { url: String(payload.p?.url || '') },
424
+ path: parsedUrl.pathname,
425
+ timestamp,
426
+ }
427
+
428
+ const eventCommand = this.store.recordCustomEventCommand(customEvent)
429
+ await ctx.executeCommand(eventCommand)
430
+ }
431
+ else if (payload.e === 'error') {
432
+ // JavaScript error - tracked as custom event with error details
433
+ const errorProps = payload.p || {}
434
+ const customEvent: CustomEvent = {
435
+ id: AnalyticsStore.generateId(),
436
+ siteId: payload.s,
437
+ visitorId,
438
+ sessionId,
439
+ name: 'error',
440
+ properties: {
441
+ message: String(errorProps.message || 'Unknown error'),
442
+ source: String(errorProps.source || ''),
443
+ line: typeof errorProps.line === 'number' ? errorProps.line : 0,
444
+ col: typeof errorProps.col === 'number' ? errorProps.col : 0,
445
+ stack: String(errorProps.stack || ''),
446
+ },
447
+ path: parsedUrl.pathname,
448
+ timestamp,
449
+ }
450
+
451
+ const eventCommand = this.store.recordCustomEventCommand(customEvent)
452
+ await ctx.executeCommand(eventCommand)
453
+
454
+ // Update session event count
455
+ if (session) {
456
+ session.eventCount += 1
457
+ session.endedAt = timestamp
458
+ const sessionCommand = this.store.upsertSessionCommand(session)
459
+ await ctx.executeCommand(sessionCommand)
460
+
461
+ if (ctx.sessionStore) {
462
+ await ctx.sessionStore.set(`${payload.s}:${sessionId}`, session, 1800)
463
+ }
464
+ }
465
+ }
466
+
467
+ // Return 1x1 transparent GIF or empty response
468
+ return {
469
+ status: 204,
470
+ headers: {
471
+ ...this.getCorsHeaders(req.headers.origin),
472
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
473
+ },
474
+ body: null,
475
+ }
476
+ }
477
+ catch (error) {
478
+ console.error('Collect error:', error)
479
+ return this.errorResponse(500, 'Internal server error')
480
+ }
481
+ }
482
+
483
+ /**
484
+ * GET /sites - List sites for current user
485
+ */
486
+ async handleListSites(
487
+ req: AnalyticsRequest,
488
+ ctx: HandlerContext,
489
+ ownerId: string,
490
+ ): Promise<AnalyticsResponse> {
491
+ try {
492
+ const command = this.store.listSitesByOwnerCommand(ownerId)
493
+ const result = await ctx.executeCommand(command) as { Items?: unknown[] }
494
+
495
+ return {
496
+ status: 200,
497
+ headers: {
498
+ ...this.getCorsHeaders(req.headers.origin),
499
+ 'Content-Type': 'application/json',
500
+ },
501
+ body: {
502
+ sites: result.Items || [],
503
+ },
504
+ }
505
+ }
506
+ catch (error) {
507
+ console.error('List sites error:', error)
508
+ return this.errorResponse(500, 'Failed to list sites')
509
+ }
510
+ }
511
+
512
+ /**
513
+ * POST /sites - Create a new site
514
+ */
515
+ async handleCreateSite(
516
+ req: AnalyticsRequest,
517
+ ctx: HandlerContext,
518
+ ownerId: string,
519
+ ): Promise<AnalyticsResponse> {
520
+ try {
521
+ const body = req.body as Partial<Site>
522
+
523
+ if (!body.name || !body.domains?.length) {
524
+ return this.errorResponse(400, 'Missing required fields: name, domains')
525
+ }
526
+
527
+ const now = new Date()
528
+ const site: Site = {
529
+ id: AnalyticsStore.generateId(),
530
+ name: body.name,
531
+ domains: body.domains,
532
+ timezone: body.timezone || 'UTC',
533
+ isActive: true,
534
+ ownerId,
535
+ settings: body.settings || this.getDefaultSiteSettings(),
536
+ createdAt: now,
537
+ updatedAt: now,
538
+ }
539
+
540
+ const command = this.store.createSiteCommand(site)
541
+ await ctx.executeCommand(command)
542
+
543
+ return {
544
+ status: 201,
545
+ headers: {
546
+ ...this.getCorsHeaders(req.headers.origin),
547
+ 'Content-Type': 'application/json',
548
+ },
549
+ body: { site },
550
+ }
551
+ }
552
+ catch (error) {
553
+ console.error('Create site error:', error)
554
+ return this.errorResponse(500, 'Failed to create site')
555
+ }
556
+ }
557
+
558
+ /**
559
+ * GET /sites/:siteId - Get site details
560
+ */
561
+ async handleGetSite(
562
+ req: AnalyticsRequest,
563
+ ctx: HandlerContext,
564
+ ): Promise<AnalyticsResponse> {
565
+ try {
566
+ const { siteId } = req.params
567
+
568
+ if (!siteId) {
569
+ return this.errorResponse(400, 'Missing siteId')
570
+ }
571
+
572
+ const command = this.store.getSiteCommand(siteId)
573
+ const result = await ctx.executeCommand(command) as { Item?: unknown }
574
+
575
+ if (!result.Item) {
576
+ return this.errorResponse(404, 'Site not found')
577
+ }
578
+
579
+ return {
580
+ status: 200,
581
+ headers: {
582
+ ...this.getCorsHeaders(req.headers.origin),
583
+ 'Content-Type': 'application/json',
584
+ },
585
+ body: { site: result.Item },
586
+ }
587
+ }
588
+ catch (error) {
589
+ console.error('Get site error:', error)
590
+ return this.errorResponse(500, 'Failed to get site')
591
+ }
592
+ }
593
+
594
+ /**
595
+ * GET /sites/:siteId/stats - Get dashboard stats
596
+ */
597
+ async handleGetStats(
598
+ req: AnalyticsRequest,
599
+ ctx: HandlerContext,
600
+ ): Promise<AnalyticsResponse> {
601
+ try {
602
+ const { siteId } = req.params
603
+ const { start, end, period, compare } = req.query
604
+
605
+ if (!siteId) {
606
+ return this.errorResponse(400, 'Missing siteId')
607
+ }
608
+
609
+ // Parse date range
610
+ const startDate = start ? new Date(start) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // Default: last 7 days
611
+ const endDate = end ? new Date(end) : new Date()
612
+
613
+ if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
614
+ return this.errorResponse(400, 'Invalid date format')
615
+ }
616
+
617
+ const dateRange = { start: startDate, end: endDate }
618
+ const queries = ctx.queryApi.generateDashboardQueries({
619
+ siteId,
620
+ dateRange,
621
+ includeComparison: compare === 'true',
622
+ includeRealtime: true,
623
+ includeGoals: true,
624
+ topLimit: 10,
625
+ })
626
+
627
+ // Execute queries in parallel
628
+ const [aggregatedResult, topPagesResult, realtimeResult, goalsResult, previousResult] = await Promise.all([
629
+ ctx.executeCommand(queries.aggregatedStats),
630
+ ctx.executeCommand(queries.topPages),
631
+ queries.realtimeStats ? ctx.executeCommand(queries.realtimeStats) : null,
632
+ queries.goals ? ctx.executeCommand(queries.goals) : null,
633
+ queries.previousPeriodStats ? ctx.executeCommand(queries.previousPeriodStats) : null,
634
+ ])
635
+
636
+ // Process results
637
+ const stats = (aggregatedResult as { Items?: unknown[] })?.Items || []
638
+ const previousStats = previousResult ? (previousResult as { Items?: unknown[] })?.Items || [] : undefined
639
+
640
+ const summary = AnalyticsQueryAPI.processSummary(
641
+ stats as Parameters<typeof AnalyticsQueryAPI.processSummary>[0],
642
+ previousStats as Parameters<typeof AnalyticsQueryAPI.processSummary>[1],
643
+ )
644
+ const timeSeries = AnalyticsQueryAPI.processTimeSeries(
645
+ stats as Parameters<typeof AnalyticsQueryAPI.processTimeSeries>[0],
646
+ )
647
+
648
+ return {
649
+ status: 200,
650
+ headers: {
651
+ ...this.getCorsHeaders(req.headers.origin),
652
+ 'Content-Type': 'application/json',
653
+ },
654
+ body: {
655
+ summary,
656
+ timeSeries,
657
+ topPages: (topPagesResult as { Items?: unknown[] })?.Items || [],
658
+ realtime: realtimeResult ? (realtimeResult as { Items?: unknown[] })?.Items || [] : null,
659
+ goals: goalsResult ? (goalsResult as { Items?: unknown[] })?.Items || [] : null,
660
+ dateRange: {
661
+ start: startDate.toISOString(),
662
+ end: endDate.toISOString(),
663
+ period: period || AnalyticsQueryAPI.determinePeriod(dateRange),
664
+ },
665
+ },
666
+ }
667
+ }
668
+ catch (error) {
669
+ console.error('Get stats error:', error)
670
+ return this.errorResponse(500, 'Failed to get stats')
671
+ }
672
+ }
673
+
674
+ /**
675
+ * GET /sites/:siteId/realtime - Get realtime stats
676
+ */
677
+ async handleGetRealtime(
678
+ req: AnalyticsRequest,
679
+ ctx: HandlerContext,
680
+ ): Promise<AnalyticsResponse> {
681
+ try {
682
+ const { siteId } = req.params
683
+ const minutes = Number.parseInt(req.query.minutes || '5', 10)
684
+
685
+ if (!siteId) {
686
+ return this.errorResponse(400, 'Missing siteId')
687
+ }
688
+
689
+ const command = this.store.getRealtimeStatsCommand(siteId, minutes)
690
+ const result = await ctx.executeCommand(command) as { Items?: unknown[] }
691
+
692
+ const realtimeData = AnalyticsQueryAPI.processRealtimeData(
693
+ (result.Items || []) as Parameters<typeof AnalyticsQueryAPI.processRealtimeData>[0],
694
+ )
695
+
696
+ return {
697
+ status: 200,
698
+ headers: {
699
+ ...this.getCorsHeaders(req.headers.origin),
700
+ 'Content-Type': 'application/json',
701
+ },
702
+ body: realtimeData,
703
+ }
704
+ }
705
+ catch (error) {
706
+ console.error('Get realtime error:', error)
707
+ return this.errorResponse(500, 'Failed to get realtime stats')
708
+ }
709
+ }
710
+
711
+ /**
712
+ * GET /sites/:siteId/script - Get tracking script
713
+ */
714
+ handleGetScript(req: AnalyticsRequest): AnalyticsResponse {
715
+ const { siteId } = req.params
716
+ const apiEndpoint = req.query.api || `${req.headers.host}${this.config.basePath}`
717
+
718
+ if (!siteId) {
719
+ return this.errorResponse(400, 'Missing siteId')
720
+ }
721
+
722
+ const script = generateTrackingScript({
723
+ siteId,
724
+ apiEndpoint: apiEndpoint.startsWith('http') ? apiEndpoint : `https://${apiEndpoint}`,
725
+ honorDnt: req.query.dnt !== 'false',
726
+ trackHashChanges: req.query.hash === 'true',
727
+ trackOutboundLinks: req.query.outbound !== 'false',
728
+ })
729
+
730
+ return {
731
+ status: 200,
732
+ headers: {
733
+ ...this.getCorsHeaders(req.headers.origin),
734
+ 'Content-Type': 'text/html',
735
+ 'Cache-Control': 'public, max-age=3600',
736
+ },
737
+ body: script,
738
+ }
739
+ }
740
+
741
+ /**
742
+ * GET /sites/:siteId/goals - List goals
743
+ */
744
+ async handleListGoals(
745
+ req: AnalyticsRequest,
746
+ ctx: HandlerContext,
747
+ ): Promise<AnalyticsResponse> {
748
+ try {
749
+ const { siteId } = req.params
750
+
751
+ if (!siteId) {
752
+ return this.errorResponse(400, 'Missing siteId')
753
+ }
754
+
755
+ const command = this.store.listGoalsCommand(siteId)
756
+ const result = await ctx.executeCommand(command) as { Items?: unknown[] }
757
+
758
+ return {
759
+ status: 200,
760
+ headers: {
761
+ ...this.getCorsHeaders(req.headers.origin),
762
+ 'Content-Type': 'application/json',
763
+ },
764
+ body: { goals: result.Items || [] },
765
+ }
766
+ }
767
+ catch (error) {
768
+ console.error('List goals error:', error)
769
+ return this.errorResponse(500, 'Failed to list goals')
770
+ }
771
+ }
772
+
773
+ /**
774
+ * POST /sites/:siteId/goals - Create a goal
775
+ */
776
+ async handleCreateGoal(
777
+ req: AnalyticsRequest,
778
+ ctx: HandlerContext,
779
+ ): Promise<AnalyticsResponse> {
780
+ try {
781
+ const { siteId } = req.params
782
+ const body = req.body as Partial<Goal>
783
+
784
+ if (!siteId) {
785
+ return this.errorResponse(400, 'Missing siteId')
786
+ }
787
+
788
+ if (!body.name || !body.type || !body.pattern || !body.matchType) {
789
+ return this.errorResponse(400, 'Missing required fields: name, type, pattern, matchType')
790
+ }
791
+
792
+ const now = new Date()
793
+ const goal: Goal = {
794
+ id: AnalyticsStore.generateId(),
795
+ siteId,
796
+ name: body.name,
797
+ type: body.type,
798
+ pattern: body.pattern,
799
+ matchType: body.matchType,
800
+ value: body.value,
801
+ isActive: body.isActive ?? true,
802
+ createdAt: now,
803
+ updatedAt: now,
804
+ }
805
+
806
+ const command = this.store.createGoalCommand(goal)
807
+ await ctx.executeCommand(command)
808
+
809
+ return {
810
+ status: 201,
811
+ headers: {
812
+ ...this.getCorsHeaders(req.headers.origin),
813
+ 'Content-Type': 'application/json',
814
+ },
815
+ body: { goal },
816
+ }
817
+ }
818
+ catch (error) {
819
+ console.error('Create goal error:', error)
820
+ return this.errorResponse(500, 'Failed to create goal')
821
+ }
822
+ }
823
+
824
+ /**
825
+ * GET /sites/:siteId/pages - Get top pages
826
+ */
827
+ async handleGetTopPages(
828
+ req: AnalyticsRequest,
829
+ ctx: HandlerContext,
830
+ ): Promise<AnalyticsResponse> {
831
+ try {
832
+ const { siteId } = req.params
833
+ const { start, end, limit } = req.query
834
+
835
+ if (!siteId) {
836
+ return this.errorResponse(400, 'Missing siteId')
837
+ }
838
+
839
+ const startDate = start ? new Date(start) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
840
+ const endDate = end ? new Date(end) : new Date()
841
+ const dateRange = { start: startDate, end: endDate }
842
+ const period = AnalyticsQueryAPI.determinePeriod(dateRange)
843
+ const periodStart = AnalyticsStore.getPeriodStart(endDate, period)
844
+
845
+ const command = this.store.getTopPagesCommand(
846
+ siteId,
847
+ period,
848
+ periodStart,
849
+ Number.parseInt(limit || '10', 10),
850
+ )
851
+ const result = await ctx.executeCommand(command) as { Items?: unknown[] }
852
+
853
+ return {
854
+ status: 200,
855
+ headers: {
856
+ ...this.getCorsHeaders(req.headers.origin),
857
+ 'Content-Type': 'application/json',
858
+ },
859
+ body: { pages: result.Items || [] },
860
+ }
861
+ }
862
+ catch (error) {
863
+ console.error('Get top pages error:', error)
864
+ return this.errorResponse(500, 'Failed to get top pages')
865
+ }
866
+ }
867
+
868
+ /**
869
+ * GET /sites/:siteId/regions - Get top regions
870
+ * Query params:
871
+ * - start/startDate: Start date (ISO string)
872
+ * - end/endDate: End date (ISO string)
873
+ * - country: Filter by country code (e.g., 'US')
874
+ * - limit: Number of results (default 10)
875
+ */
876
+ async handleGetRegions(
877
+ req: AnalyticsRequest,
878
+ ctx: HandlerContext,
879
+ ): Promise<AnalyticsResponse> {
880
+ try {
881
+ const { siteId } = req.params
882
+ const { start, end, startDate: startDateParam, endDate: endDateParam, country, limit } = req.query
883
+
884
+ if (!siteId) {
885
+ return this.errorResponse(400, 'Missing siteId')
886
+ }
887
+
888
+ const startDate = (start || startDateParam) ? new Date(start || startDateParam) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
889
+ const endDate = (end || endDateParam) ? new Date(end || endDateParam) : new Date()
890
+ const dateRange = { start: startDate, end: endDate }
891
+
892
+ const command = ctx.queryApi.getRegions(siteId, {
893
+ dateRange,
894
+ country: country || undefined,
895
+ limit: limit ? Number.parseInt(limit, 10) : 10,
896
+ })
897
+ const result = await ctx.executeCommand(command) as { Items?: unknown[] }
898
+
899
+ // Calculate total visitors for percentage calculation
900
+ const items = (result.Items || []) as Array<{ visitors?: { N?: string } }>
901
+ const totalVisitors = items.reduce((sum, item) => {
902
+ return sum + (item.visitors?.N ? Number.parseInt(item.visitors.N, 10) : 0)
903
+ }, 0)
904
+
905
+ const regions = AnalyticsQueryAPI.processTopRegions(
906
+ items as Parameters<typeof AnalyticsQueryAPI.processTopRegions>[0],
907
+ totalVisitors,
908
+ )
909
+
910
+ return {
911
+ status: 200,
912
+ headers: {
913
+ ...this.getCorsHeaders(req.headers.origin),
914
+ 'Content-Type': 'application/json',
915
+ },
916
+ body: {
917
+ regions,
918
+ dateRange: {
919
+ start: startDate.toISOString(),
920
+ end: endDate.toISOString(),
921
+ },
922
+ filters: {
923
+ country: country || null,
924
+ },
925
+ },
926
+ }
927
+ }
928
+ catch (error) {
929
+ console.error('Get regions error:', error)
930
+ return this.errorResponse(500, 'Failed to get regions')
931
+ }
932
+ }
933
+
934
+ /**
935
+ * GET /sites/:siteId/cities - Get top cities
936
+ * Query params:
937
+ * - start/startDate: Start date (ISO string)
938
+ * - end/endDate: End date (ISO string)
939
+ * - country: Filter by country code (e.g., 'US')
940
+ * - region: Filter by region code (e.g., 'CA' for California)
941
+ * - limit: Number of results (default 10)
942
+ */
943
+ async handleGetCities(
944
+ req: AnalyticsRequest,
945
+ ctx: HandlerContext,
946
+ ): Promise<AnalyticsResponse> {
947
+ try {
948
+ const { siteId } = req.params
949
+ const { start, end, startDate: startDateParam, endDate: endDateParam, country, region, limit } = req.query
950
+
951
+ if (!siteId) {
952
+ return this.errorResponse(400, 'Missing siteId')
953
+ }
954
+
955
+ const startDate = (start || startDateParam) ? new Date(start || startDateParam) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
956
+ const endDate = (end || endDateParam) ? new Date(end || endDateParam) : new Date()
957
+ const dateRange = { start: startDate, end: endDate }
958
+
959
+ const command = ctx.queryApi.getCities(siteId, {
960
+ dateRange,
961
+ country: country || undefined,
962
+ region: region || undefined,
963
+ limit: limit ? Number.parseInt(limit, 10) : 10,
964
+ })
965
+ const result = await ctx.executeCommand(command) as { Items?: unknown[] }
966
+
967
+ // Calculate total visitors for percentage calculation
968
+ const items = (result.Items || []) as Array<{ visitors?: { N?: string } }>
969
+ const totalVisitors = items.reduce((sum, item) => {
970
+ return sum + (item.visitors?.N ? Number.parseInt(item.visitors.N, 10) : 0)
971
+ }, 0)
972
+
973
+ const cities = AnalyticsQueryAPI.processTopCities(
974
+ items as Parameters<typeof AnalyticsQueryAPI.processTopCities>[0],
975
+ totalVisitors,
976
+ )
977
+
978
+ return {
979
+ status: 200,
980
+ headers: {
981
+ ...this.getCorsHeaders(req.headers.origin),
982
+ 'Content-Type': 'application/json',
983
+ },
984
+ body: {
985
+ cities,
986
+ dateRange: {
987
+ start: startDate.toISOString(),
988
+ end: endDate.toISOString(),
989
+ },
990
+ filters: {
991
+ country: country || null,
992
+ region: region || null,
993
+ },
994
+ },
995
+ }
996
+ }
997
+ catch (error) {
998
+ console.error('Get cities error:', error)
999
+ return this.errorResponse(500, 'Failed to get cities')
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * POST /aggregate - Trigger aggregation (for scheduled jobs)
1005
+ */
1006
+ async handleAggregate(
1007
+ req: AnalyticsRequest,
1008
+ ctx: HandlerContext,
1009
+ ): Promise<AnalyticsResponse> {
1010
+ try {
1011
+ const body = req.body as {
1012
+ siteId: string
1013
+ period: AggregationPeriod
1014
+ windowStart?: string
1015
+ windowEnd?: string
1016
+ }
1017
+
1018
+ if (!body.siteId || !body.period) {
1019
+ return this.errorResponse(400, 'Missing required fields: siteId, period')
1020
+ }
1021
+
1022
+ // Create job config
1023
+ const config = body.windowStart && body.windowEnd
1024
+ ? {
1025
+ siteId: body.siteId,
1026
+ period: body.period,
1027
+ windowStart: new Date(body.windowStart),
1028
+ windowEnd: new Date(body.windowEnd),
1029
+ deleteRawEvents: body.period === 'hour',
1030
+ }
1031
+ : AggregationPipeline.createJobConfig(body.siteId, body.period)
1032
+
1033
+ // Fetch raw data for the window
1034
+ // Note: In production, this would query DynamoDB for the raw events
1035
+ // For now, return the job config and let the caller handle data fetching
1036
+ return {
1037
+ status: 200,
1038
+ headers: {
1039
+ ...this.getCorsHeaders(req.headers.origin),
1040
+ 'Content-Type': 'application/json',
1041
+ },
1042
+ body: {
1043
+ message: 'Aggregation job created',
1044
+ config,
1045
+ nextScheduledTimes: AggregationPipeline.getScheduledJobTimes(),
1046
+ },
1047
+ }
1048
+ }
1049
+ catch (error) {
1050
+ console.error('Aggregate error:', error)
1051
+ return this.errorResponse(500, 'Failed to create aggregation job')
1052
+ }
1053
+ }
1054
+
1055
+ // ==========================================================================
1056
+ // Helper Methods
1057
+ // ==========================================================================
1058
+
1059
+ /**
1060
+ * Create error response
1061
+ */
1062
+ private errorResponse(status: number, message: string): AnalyticsResponse {
1063
+ return {
1064
+ status,
1065
+ headers: {
1066
+ 'Content-Type': 'application/json',
1067
+ },
1068
+ body: { error: message },
1069
+ }
1070
+ }
1071
+
1072
+ /**
1073
+ * Get default site settings
1074
+ */
1075
+ private getDefaultSiteSettings(): SiteSettings {
1076
+ return {
1077
+ collectGeolocation: false,
1078
+ trackReferrers: true,
1079
+ trackUtmParams: true,
1080
+ trackDeviceType: true,
1081
+ publicDashboard: false,
1082
+ excludedPaths: [],
1083
+ excludedIps: [],
1084
+ dataRetentionDays: 365,
1085
+ }
1086
+ }
1087
+
1088
+ /**
1089
+ * Get store instance
1090
+ */
1091
+ getStore(): AnalyticsStore {
1092
+ return this.store
1093
+ }
1094
+
1095
+ /**
1096
+ * Get query API instance
1097
+ */
1098
+ getQueryAPI(): AnalyticsQueryAPI {
1099
+ return this.queryApi
1100
+ }
1101
+
1102
+ /**
1103
+ * Get pipeline instance
1104
+ */
1105
+ getPipeline(): AggregationPipeline {
1106
+ return this.pipeline
1107
+ }
1108
+ }
1109
+
1110
+ // ============================================================================
1111
+ // Framework Adapters
1112
+ // ============================================================================
1113
+
1114
+ /**
1115
+ * Create a Bun/Hono compatible router handler
1116
+ */
1117
+ export function createBunRouter(api: AnalyticsAPI, executeCommand: HandlerContext['executeCommand']) {
1118
+ const ctx = api.createContext(executeCommand)
1119
+
1120
+ return {
1121
+ async fetch(request: Request): Promise<Response> {
1122
+ const url = new URL(request.url)
1123
+ const path = url.pathname
1124
+ const method = request.method
1125
+
1126
+ // Parse request into AnalyticsRequest format
1127
+ const req: AnalyticsRequest = {
1128
+ method,
1129
+ path,
1130
+ params: {},
1131
+ query: Object.fromEntries(url.searchParams),
1132
+ body: method !== 'GET' ? await request.json().catch(() => ({})) : {},
1133
+ headers: Object.fromEntries(request.headers as unknown as Iterable<[string, string]>),
1134
+ ip: request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || undefined,
1135
+ userAgent: request.headers.get('user-agent') || undefined,
1136
+ }
1137
+
1138
+ // Route matching (simplified - use a proper router in production)
1139
+ let response: AnalyticsResponse
1140
+
1141
+ if (method === 'OPTIONS') {
1142
+ response = api.handleOptions(req)
1143
+ }
1144
+ else if (method === 'POST' && path.endsWith('/collect')) {
1145
+ response = await api.handleCollect(req, ctx)
1146
+ }
1147
+ else if (method === 'GET' && path.match(/\/sites\/[^/]+\/stats$/)) {
1148
+ req.params.siteId = path.split('/').slice(-2)[0]
1149
+ response = await api.handleGetStats(req, ctx)
1150
+ }
1151
+ else if (method === 'GET' && path.match(/\/sites\/[^/]+\/realtime$/)) {
1152
+ req.params.siteId = path.split('/').slice(-2)[0]
1153
+ response = await api.handleGetRealtime(req, ctx)
1154
+ }
1155
+ else if (method === 'GET' && path.match(/\/sites\/[^/]+\/script$/)) {
1156
+ req.params.siteId = path.split('/').slice(-2)[0]
1157
+ response = api.handleGetScript(req)
1158
+ }
1159
+ else if (method === 'GET' && path.match(/\/sites\/[^/]+\/goals$/)) {
1160
+ req.params.siteId = path.split('/').slice(-2)[0]
1161
+ response = await api.handleListGoals(req, ctx)
1162
+ }
1163
+ else if (method === 'POST' && path.match(/\/sites\/[^/]+\/goals$/)) {
1164
+ req.params.siteId = path.split('/').slice(-2)[0]
1165
+ response = await api.handleCreateGoal(req, ctx)
1166
+ }
1167
+ else if (method === 'GET' && path.match(/\/sites\/[^/]+\/pages$/)) {
1168
+ req.params.siteId = path.split('/').slice(-2)[0]
1169
+ response = await api.handleGetTopPages(req, ctx)
1170
+ }
1171
+ else if (method === 'GET' && path.match(/\/sites\/[^/]+\/regions$/)) {
1172
+ req.params.siteId = path.split('/').slice(-2)[0]
1173
+ response = await api.handleGetRegions(req, ctx)
1174
+ }
1175
+ else if (method === 'GET' && path.match(/\/sites\/[^/]+\/cities$/)) {
1176
+ req.params.siteId = path.split('/').slice(-2)[0]
1177
+ response = await api.handleGetCities(req, ctx)
1178
+ }
1179
+ else if (method === 'GET' && path.match(/\/sites\/[^/]+$/)) {
1180
+ req.params.siteId = path.split('/').pop()!
1181
+ response = await api.handleGetSite(req, ctx)
1182
+ }
1183
+ else {
1184
+ response = {
1185
+ status: 404,
1186
+ headers: { 'Content-Type': 'application/json' },
1187
+ body: { error: 'Not found' },
1188
+ }
1189
+ }
1190
+
1191
+ return new Response(
1192
+ response.body ? JSON.stringify(response.body) : null,
1193
+ {
1194
+ status: response.status,
1195
+ headers: response.headers,
1196
+ },
1197
+ )
1198
+ },
1199
+ }
1200
+ }
1201
+
1202
+ /**
1203
+ * Create AWS Lambda handler
1204
+ */
1205
+ export function createLambdaHandler(api: AnalyticsAPI, executeCommand: HandlerContext['executeCommand']): (event: {
1206
+ httpMethod: string
1207
+ path: string
1208
+ pathParameters?: Record<string, string>
1209
+ queryStringParameters?: Record<string, string>
1210
+ body?: string
1211
+ headers: Record<string, string>
1212
+ requestContext?: { identity?: { sourceIp?: string } }
1213
+ }) => Promise<{ statusCode: number, headers: Record<string, string>, body: string }> {
1214
+ const ctx = api.createContext(executeCommand)
1215
+
1216
+ return async (event: {
1217
+ httpMethod: string
1218
+ path: string
1219
+ pathParameters?: Record<string, string>
1220
+ queryStringParameters?: Record<string, string>
1221
+ body?: string
1222
+ headers: Record<string, string>
1223
+ requestContext?: { identity?: { sourceIp?: string } }
1224
+ }): Promise<{ statusCode: number, headers: Record<string, string>, body: string }> => {
1225
+ const req: AnalyticsRequest = {
1226
+ method: event.httpMethod,
1227
+ path: event.path,
1228
+ params: event.pathParameters || {},
1229
+ query: event.queryStringParameters || {},
1230
+ body: event.body ? JSON.parse(event.body) : {},
1231
+ headers: event.headers,
1232
+ ip: event.requestContext?.identity?.sourceIp,
1233
+ userAgent: event.headers['User-Agent'] || event.headers['user-agent'],
1234
+ }
1235
+
1236
+ let response: AnalyticsResponse
1237
+
1238
+ // Route based on path and method
1239
+ if (req.method === 'OPTIONS') {
1240
+ response = api.handleOptions(req)
1241
+ }
1242
+ else if (req.method === 'POST' && req.path.endsWith('/collect')) {
1243
+ response = await api.handleCollect(req, ctx)
1244
+ }
1245
+ else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/stats')) {
1246
+ response = await api.handleGetStats(req, ctx)
1247
+ }
1248
+ else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/realtime')) {
1249
+ response = await api.handleGetRealtime(req, ctx)
1250
+ }
1251
+ else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/script')) {
1252
+ response = api.handleGetScript(req)
1253
+ }
1254
+ else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/goals')) {
1255
+ response = await api.handleListGoals(req, ctx)
1256
+ }
1257
+ else if (req.method === 'POST' && req.params.siteId && req.path.endsWith('/goals')) {
1258
+ response = await api.handleCreateGoal(req, ctx)
1259
+ }
1260
+ else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/regions')) {
1261
+ response = await api.handleGetRegions(req, ctx)
1262
+ }
1263
+ else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/cities')) {
1264
+ response = await api.handleGetCities(req, ctx)
1265
+ }
1266
+ else if (req.method === 'GET' && req.params.siteId && !req.path.includes('/')) {
1267
+ response = await api.handleGetSite(req, ctx)
1268
+ }
1269
+ else if (req.method === 'POST' && req.path.endsWith('/aggregate')) {
1270
+ response = await api.handleAggregate(req, ctx)
1271
+ }
1272
+ else {
1273
+ response = {
1274
+ status: 404,
1275
+ headers: { 'Content-Type': 'application/json' },
1276
+ body: { error: 'Not found' },
1277
+ }
1278
+ }
1279
+
1280
+ return {
1281
+ statusCode: response.status,
1282
+ headers: response.headers,
1283
+ body: response.body ? JSON.stringify(response.body) : '',
1284
+ }
1285
+ }
1286
+ }