@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,806 @@
1
+ /**
2
+ * SQS-Based Write Buffering
3
+ *
4
+ * High-throughput event ingestion using SQS as a buffer between
5
+ * the /collect endpoint and DynamoDB writes. This architecture:
6
+ *
7
+ * 1. Handles traffic spikes by queuing events
8
+ * 2. Enables batch writes to DynamoDB (25 items per batch)
9
+ * 3. Provides automatic retry with exponential backoff
10
+ * 4. Reduces Lambda execution time for /collect (faster responses)
11
+ * 5. Decouples ingestion from storage
12
+ *
13
+ * Architecture:
14
+ * [Browser] -> [Lambda /collect] -> [SQS Queue] -> [Lambda Consumer] -> [DynamoDB]
15
+ *
16
+ * Based on learnings from Fathom Analytics about scaling DynamoDB:
17
+ * - Avoid per-request connection overhead
18
+ * - Use batching for efficient writes
19
+ * - Buffer writes during traffic spikes
20
+ *
21
+ * Extends ts-cloud for infrastructure and AWS clients.
22
+ * @see cloud.config.ts for infrastructure definitions
23
+ */
24
+
25
+ import type { CustomEvent, PageView, Session } from './types'
26
+
27
+ // Re-export ts-cloud compatible types for convenience
28
+ export interface SQSRawMessage {
29
+ MessageId: string
30
+ ReceiptHandle: string
31
+ Body: string
32
+ Attributes?: Record<string, string>
33
+ MessageAttributes?: Record<string, unknown>
34
+ }
35
+
36
+ export interface QueueAttributes {
37
+ QueueUrl: string
38
+ QueueArn?: string
39
+ ApproximateNumberOfMessages?: string
40
+ ApproximateNumberOfMessagesNotVisible?: string
41
+ VisibilityTimeout?: string
42
+ MessageRetentionPeriod?: string
43
+ }
44
+
45
+ // ============================================================================
46
+ // Types
47
+ // ============================================================================
48
+
49
+ export interface SQSConfig {
50
+ /** SQS Queue URL for event buffering */
51
+ queueUrl?: string
52
+ /** Enable SQS buffering (default: false for backward compatibility) */
53
+ enabled: boolean
54
+ /** Batch size for SQS messages (max 10) */
55
+ batchSize: number
56
+ /** Maximum messages per Lambda invocation */
57
+ maxMessagesPerInvocation: number
58
+ /** Visibility timeout in seconds */
59
+ visibilityTimeout: number
60
+ /** Message retention in seconds (max 14 days) */
61
+ messageRetention: number
62
+ /** Dead letter queue URL for failed messages */
63
+ deadLetterQueueUrl?: string
64
+ /** Max receive count before sending to DLQ */
65
+ maxReceiveCount: number
66
+ }
67
+
68
+ export interface AnalyticsEvent {
69
+ type: 'pageview' | 'session' | 'event' | 'realtime'
70
+ siteId: string
71
+ timestamp: string
72
+ data: PageView | Session | CustomEvent | RealtimeUpdate
73
+ }
74
+
75
+ export interface RealtimeUpdate {
76
+ siteId: string
77
+ minute: string
78
+ path: string
79
+ visitorId: string
80
+ }
81
+
82
+ export interface SQSMessage {
83
+ events: AnalyticsEvent[]
84
+ batchId: string
85
+ timestamp: string
86
+ retryCount?: number
87
+ }
88
+
89
+ export interface SQSSendResult {
90
+ successful: number
91
+ failed: number
92
+ messageId?: string
93
+ }
94
+
95
+ export interface SQSClient {
96
+ sendMessage: (input: SQSSendMessageInput) => Promise<SQSSendMessageOutput>
97
+ sendMessageBatch: (input: SQSSendMessageBatchInput) => Promise<SQSSendMessageBatchOutput>
98
+ receiveMessage: (input: SQSReceiveMessageInput) => Promise<SQSReceiveMessageOutput>
99
+ deleteMessage: (input: SQSDeleteMessageInput) => Promise<void>
100
+ deleteMessageBatch: (input: SQSDeleteMessageBatchInput) => Promise<SQSDeleteMessageBatchOutput>
101
+ }
102
+
103
+ interface SQSSendMessageInput {
104
+ QueueUrl: string
105
+ MessageBody: string
106
+ MessageGroupId?: string
107
+ MessageDeduplicationId?: string
108
+ }
109
+
110
+ interface SQSSendMessageOutput {
111
+ MessageId?: string
112
+ }
113
+
114
+ interface SQSSendMessageBatchInput {
115
+ QueueUrl: string
116
+ Entries: Array<{
117
+ Id: string
118
+ MessageBody: string
119
+ MessageGroupId?: string
120
+ MessageDeduplicationId?: string
121
+ }>
122
+ }
123
+
124
+ interface SQSSendMessageBatchOutput {
125
+ Successful?: Array<{ Id: string, MessageId: string }>
126
+ Failed?: Array<{ Id: string, Code: string, Message: string }>
127
+ }
128
+
129
+ interface SQSReceiveMessageInput {
130
+ QueueUrl: string
131
+ MaxNumberOfMessages?: number
132
+ VisibilityTimeout?: number
133
+ WaitTimeSeconds?: number
134
+ }
135
+
136
+ interface SQSReceiveMessageOutput {
137
+ Messages?: Array<{
138
+ MessageId: string
139
+ ReceiptHandle: string
140
+ Body: string
141
+ }>
142
+ }
143
+
144
+ interface SQSDeleteMessageInput {
145
+ QueueUrl: string
146
+ ReceiptHandle: string
147
+ }
148
+
149
+ interface SQSDeleteMessageBatchInput {
150
+ QueueUrl: string
151
+ Entries: Array<{
152
+ Id: string
153
+ ReceiptHandle: string
154
+ }>
155
+ }
156
+
157
+ interface SQSDeleteMessageBatchOutput {
158
+ Successful?: Array<{ Id: string }>
159
+ Failed?: Array<{ Id: string, Code: string }>
160
+ }
161
+
162
+ // ============================================================================
163
+ // Default Configuration
164
+ // ============================================================================
165
+
166
+ export const defaultSQSConfig: SQSConfig = {
167
+ enabled: false,
168
+ batchSize: 10, // SQS max batch size
169
+ maxMessagesPerInvocation: 100,
170
+ visibilityTimeout: 30,
171
+ messageRetention: 86400, // 1 day
172
+ maxReceiveCount: 3,
173
+ }
174
+
175
+ // ============================================================================
176
+ // Event Buffer (in-memory for Lambda warm starts)
177
+ // ============================================================================
178
+
179
+ /**
180
+ * In-memory event buffer for batching before SQS send
181
+ */
182
+ export class EventBuffer {
183
+ private buffer: AnalyticsEvent[] = []
184
+ private flushTimer: ReturnType<typeof setTimeout> | null = null
185
+ private readonly maxSize: number
186
+ private readonly flushIntervalMs: number
187
+ private readonly onFlush: (events: AnalyticsEvent[]) => Promise<void>
188
+
189
+ constructor(options: {
190
+ maxSize?: number
191
+ flushIntervalMs?: number
192
+ onFlush: (events: AnalyticsEvent[]) => Promise<void>
193
+ }) {
194
+ this.maxSize = options.maxSize ?? 100
195
+ this.flushIntervalMs = options.flushIntervalMs ?? 1000
196
+ this.onFlush = options.onFlush
197
+ }
198
+
199
+ add(event: AnalyticsEvent): void {
200
+ this.buffer.push(event)
201
+
202
+ if (this.buffer.length >= this.maxSize) {
203
+ this.flush()
204
+ }
205
+ else if (!this.flushTimer) {
206
+ this.flushTimer = setTimeout(() => this.flush(), this.flushIntervalMs)
207
+ }
208
+ }
209
+
210
+ async flush(): Promise<void> {
211
+ if (this.flushTimer) {
212
+ clearTimeout(this.flushTimer)
213
+ this.flushTimer = null
214
+ }
215
+
216
+ if (this.buffer.length === 0) return
217
+
218
+ const events = this.buffer.splice(0, this.buffer.length)
219
+ await this.onFlush(events)
220
+ }
221
+
222
+ get size(): number {
223
+ return this.buffer.length
224
+ }
225
+ }
226
+
227
+ // ============================================================================
228
+ // SQS Event Producer
229
+ // ============================================================================
230
+
231
+ /**
232
+ * Sends analytics events to SQS queue
233
+ */
234
+ export class SQSEventProducer {
235
+ private readonly client: SQSClient
236
+ private readonly queueUrl: string
237
+ private readonly batchSize: number
238
+
239
+ constructor(client: SQSClient, queueUrl: string, batchSize: number = 10) {
240
+ this.client = client
241
+ this.queueUrl = queueUrl
242
+ this.batchSize = Math.min(batchSize, 10) // SQS limit
243
+ }
244
+
245
+ /**
246
+ * Send a single event to SQS
247
+ */
248
+ async sendEvent(event: AnalyticsEvent): Promise<SQSSendResult> {
249
+ return this.sendEvents([event])
250
+ }
251
+
252
+ /**
253
+ * Send multiple events to SQS (batched)
254
+ */
255
+ async sendEvents(events: AnalyticsEvent[]): Promise<SQSSendResult> {
256
+ if (events.length === 0) {
257
+ return { successful: 0, failed: 0 }
258
+ }
259
+
260
+ const message: SQSMessage = {
261
+ events,
262
+ batchId: generateBatchId(),
263
+ timestamp: new Date().toISOString(),
264
+ }
265
+
266
+ try {
267
+ const result = await this.client.sendMessage({
268
+ QueueUrl: this.queueUrl,
269
+ MessageBody: JSON.stringify(message),
270
+ // Use FIFO queue with site-based grouping for ordering
271
+ MessageGroupId: events[0]?.siteId || 'default',
272
+ MessageDeduplicationId: message.batchId,
273
+ })
274
+
275
+ return {
276
+ successful: events.length,
277
+ failed: 0,
278
+ messageId: result.MessageId,
279
+ }
280
+ }
281
+ catch (error) {
282
+ console.error('[SQS] Failed to send events:', error)
283
+ return {
284
+ successful: 0,
285
+ failed: events.length,
286
+ }
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Send events in batches using SQS batch API
292
+ */
293
+ async sendEventsBatch(events: AnalyticsEvent[]): Promise<SQSSendResult> {
294
+ let successful = 0
295
+ let failed = 0
296
+
297
+ // Group events by site for better batching
298
+ const bySite = groupEventsBySite(events)
299
+
300
+ for (const [siteId, siteEvents] of Object.entries(bySite)) {
301
+ // Chunk into SQS batch size
302
+ for (let i = 0; i < siteEvents.length; i += this.batchSize) {
303
+ const chunk = siteEvents.slice(i, i + this.batchSize)
304
+ const entries = chunk.map((event, idx) => ({
305
+ Id: `${i + idx}`,
306
+ MessageBody: JSON.stringify({
307
+ events: [event],
308
+ batchId: generateBatchId(),
309
+ timestamp: new Date().toISOString(),
310
+ } as SQSMessage),
311
+ MessageGroupId: siteId,
312
+ MessageDeduplicationId: generateBatchId(),
313
+ }))
314
+
315
+ try {
316
+ const result = await this.client.sendMessageBatch({
317
+ QueueUrl: this.queueUrl,
318
+ Entries: entries,
319
+ })
320
+
321
+ successful += result.Successful?.length ?? 0
322
+ failed += result.Failed?.length ?? 0
323
+
324
+ if (result.Failed && result.Failed.length > 0) {
325
+ console.error('[SQS] Batch send failures:', result.Failed)
326
+ }
327
+ }
328
+ catch (error) {
329
+ console.error('[SQS] Batch send error:', error)
330
+ failed += chunk.length
331
+ }
332
+ }
333
+ }
334
+
335
+ return { successful, failed }
336
+ }
337
+ }
338
+
339
+ // ============================================================================
340
+ // SQS Event Consumer
341
+ // ============================================================================
342
+
343
+ /**
344
+ * Processes analytics events from SQS queue
345
+ */
346
+ export class SQSEventConsumer {
347
+ private readonly client: SQSClient
348
+ private readonly queueUrl: string
349
+ private readonly processor: EventProcessor
350
+
351
+ constructor(
352
+ client: SQSClient,
353
+ queueUrl: string,
354
+ processor: EventProcessor,
355
+ ) {
356
+ this.client = client
357
+ this.queueUrl = queueUrl
358
+ this.processor = processor
359
+ }
360
+
361
+ /**
362
+ * Process a batch of SQS messages (called by Lambda trigger)
363
+ */
364
+ async processBatch(messages: SQSLambdaMessage[]): Promise<ProcessResult> {
365
+ const results: ProcessResult = {
366
+ successful: 0,
367
+ failed: 0,
368
+ errors: [],
369
+ }
370
+
371
+ for (const message of messages) {
372
+ try {
373
+ const sqsMessage: SQSMessage = JSON.parse(message.body)
374
+ await this.processor.processEvents(sqsMessage.events)
375
+ results.successful++
376
+ }
377
+ catch (error) {
378
+ console.error('[SQS Consumer] Failed to process message:', error)
379
+ results.failed++
380
+ results.errors.push({
381
+ messageId: message.messageId,
382
+ error: error instanceof Error ? error.message : String(error),
383
+ })
384
+ }
385
+ }
386
+
387
+ return results
388
+ }
389
+
390
+ /**
391
+ * Poll for messages (for non-Lambda environments)
392
+ */
393
+ async poll(maxMessages: number = 10): Promise<ProcessResult> {
394
+ const results: ProcessResult = {
395
+ successful: 0,
396
+ failed: 0,
397
+ errors: [],
398
+ }
399
+
400
+ try {
401
+ const response = await this.client.receiveMessage({
402
+ QueueUrl: this.queueUrl,
403
+ MaxNumberOfMessages: Math.min(maxMessages, 10),
404
+ VisibilityTimeout: 30,
405
+ WaitTimeSeconds: 20, // Long polling
406
+ })
407
+
408
+ if (!response.Messages || response.Messages.length === 0) {
409
+ return results
410
+ }
411
+
412
+ const deleteEntries: Array<{ Id: string, ReceiptHandle: string }> = []
413
+
414
+ for (const message of response.Messages) {
415
+ try {
416
+ const sqsMessage: SQSMessage = JSON.parse(message.Body)
417
+ await this.processor.processEvents(sqsMessage.events)
418
+ results.successful++
419
+ deleteEntries.push({
420
+ Id: message.MessageId,
421
+ ReceiptHandle: message.ReceiptHandle,
422
+ })
423
+ }
424
+ catch (error) {
425
+ console.error('[SQS Consumer] Processing error:', error)
426
+ results.failed++
427
+ results.errors.push({
428
+ messageId: message.MessageId,
429
+ error: error instanceof Error ? error.message : String(error),
430
+ })
431
+ }
432
+ }
433
+
434
+ // Delete successfully processed messages
435
+ if (deleteEntries.length > 0) {
436
+ await this.client.deleteMessageBatch({
437
+ QueueUrl: this.queueUrl,
438
+ Entries: deleteEntries,
439
+ })
440
+ }
441
+ }
442
+ catch (error) {
443
+ console.error('[SQS Consumer] Poll error:', error)
444
+ }
445
+
446
+ return results
447
+ }
448
+ }
449
+
450
+ // ============================================================================
451
+ // Event Processor Interface
452
+ // ============================================================================
453
+
454
+ export interface EventProcessor {
455
+ processEvents(events: AnalyticsEvent[]): Promise<void>
456
+ }
457
+
458
+ export interface SQSLambdaMessage {
459
+ messageId: string
460
+ receiptHandle: string
461
+ body: string
462
+ }
463
+
464
+ export interface ProcessResult {
465
+ successful: number
466
+ failed: number
467
+ errors: Array<{ messageId: string, error: string }>
468
+ }
469
+
470
+ // ============================================================================
471
+ // Partition Key Strategy for Scale
472
+ // ============================================================================
473
+
474
+ /**
475
+ * Generate a partition key with time-based sharding to avoid hot partitions
476
+ *
477
+ * For high-volume sites, we add a shard suffix based on the current minute
478
+ * to distribute writes across multiple partitions.
479
+ *
480
+ * Pattern: SITE#{siteId}#SHARD#{shardId}
481
+ *
482
+ * This helps with:
483
+ * - Distributing write throughput across partitions
484
+ * - Avoiding hot partition throttling
485
+ * - Supporting burst traffic patterns
486
+ */
487
+ export function getShardedPartitionKey(
488
+ siteId: string,
489
+ shardCount: number = 10,
490
+ ): string {
491
+ // Use current minute for deterministic sharding
492
+ const minute = new Date().getMinutes()
493
+ const shardId = minute % shardCount
494
+ return `SITE#${siteId}#SHARD#${shardId}`
495
+ }
496
+
497
+ /**
498
+ * Generate partition key with random sharding for even distribution
499
+ */
500
+ export function getRandomShardedPartitionKey(
501
+ siteId: string,
502
+ shardCount: number = 10,
503
+ ): string {
504
+ const shardId = Math.floor(Math.random() * shardCount)
505
+ return `SITE#${siteId}#SHARD#${shardId}`
506
+ }
507
+
508
+ /**
509
+ * Get all shard keys for a site (used for queries)
510
+ */
511
+ export function getAllShardKeys(
512
+ siteId: string,
513
+ shardCount: number = 10,
514
+ ): string[] {
515
+ return Array.from({ length: shardCount }, (_, i) => `SITE#${siteId}#SHARD#${i}`)
516
+ }
517
+
518
+ // ============================================================================
519
+ // Write Coalescing
520
+ // ============================================================================
521
+
522
+ /**
523
+ * Coalesce multiple writes to the same key within a time window
524
+ * This reduces write costs and avoids unnecessary overwrites
525
+ */
526
+ export class WriteCoalescer<T> {
527
+ private pending: Map<string, { data: T, timer: ReturnType<typeof setTimeout> }> = new Map()
528
+ private readonly windowMs: number
529
+ private readonly onWrite: (key: string, data: T) => Promise<void>
530
+
531
+ constructor(options: {
532
+ windowMs?: number
533
+ onWrite: (key: string, data: T) => Promise<void>
534
+ }) {
535
+ this.windowMs = options.windowMs ?? 100
536
+ this.onWrite = options.onWrite
537
+ }
538
+
539
+ /**
540
+ * Queue a write - if a write for the same key is pending, it will be replaced
541
+ */
542
+ write(key: string, data: T): void {
543
+ const existing = this.pending.get(key)
544
+ if (existing) {
545
+ clearTimeout(existing.timer)
546
+ }
547
+
548
+ const timer = setTimeout(async () => {
549
+ this.pending.delete(key)
550
+ try {
551
+ await this.onWrite(key, data)
552
+ }
553
+ catch (error) {
554
+ console.error('[WriteCoalescer] Write failed:', key, error)
555
+ }
556
+ }, this.windowMs)
557
+
558
+ this.pending.set(key, { data, timer })
559
+ }
560
+
561
+ /**
562
+ * Flush all pending writes immediately
563
+ */
564
+ async flush(): Promise<void> {
565
+ const entries = Array.from(this.pending.entries())
566
+ this.pending.clear()
567
+
568
+ for (const [key, { data, timer }] of entries) {
569
+ clearTimeout(timer)
570
+ try {
571
+ await this.onWrite(key, data)
572
+ }
573
+ catch (error) {
574
+ console.error('[WriteCoalescer] Flush write failed:', key, error)
575
+ }
576
+ }
577
+ }
578
+
579
+ get size(): number {
580
+ return this.pending.size
581
+ }
582
+ }
583
+
584
+ // ============================================================================
585
+ // Utilities
586
+ // ============================================================================
587
+
588
+ function generateBatchId(): string {
589
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
590
+ }
591
+
592
+ function groupEventsBySite(events: AnalyticsEvent[]): Record<string, AnalyticsEvent[]> {
593
+ const grouped: Record<string, AnalyticsEvent[]> = {}
594
+ for (const event of events) {
595
+ const siteId = event.siteId || 'unknown'
596
+ if (!grouped[siteId]) {
597
+ grouped[siteId] = []
598
+ }
599
+ grouped[siteId].push(event)
600
+ }
601
+ return grouped
602
+ }
603
+
604
+ // ============================================================================
605
+ // CloudFormation/SAM Template Helper
606
+ // ============================================================================
607
+
608
+ /**
609
+ * Generate CloudFormation resources for SQS-based buffering
610
+ */
611
+ export function generateSQSResources(config: {
612
+ queueName: string
613
+ deadLetterQueueName?: string
614
+ visibilityTimeout?: number
615
+ messageRetention?: number
616
+ maxReceiveCount?: number
617
+ fifo?: boolean
618
+ }): Record<string, unknown> {
619
+ const resources: Record<string, unknown> = {}
620
+ const fifoSuffix = config.fifo ? '.fifo' : ''
621
+
622
+ // Dead Letter Queue
623
+ if (config.deadLetterQueueName) {
624
+ resources.AnalyticsDeadLetterQueue = {
625
+ Type: 'AWS::SQS::Queue',
626
+ Properties: {
627
+ QueueName: `${config.deadLetterQueueName}${fifoSuffix}`,
628
+ MessageRetentionPeriod: 1209600, // 14 days
629
+ ...(config.fifo && {
630
+ FifoQueue: true,
631
+ ContentBasedDeduplication: true,
632
+ }),
633
+ },
634
+ }
635
+ }
636
+
637
+ // Main Queue
638
+ resources.AnalyticsEventQueue = {
639
+ Type: 'AWS::SQS::Queue',
640
+ Properties: {
641
+ QueueName: `${config.queueName}${fifoSuffix}`,
642
+ VisibilityTimeout: config.visibilityTimeout ?? 30,
643
+ MessageRetentionPeriod: config.messageRetention ?? 86400,
644
+ ...(config.fifo && {
645
+ FifoQueue: true,
646
+ ContentBasedDeduplication: true,
647
+ }),
648
+ ...(config.deadLetterQueueName && {
649
+ RedrivePolicy: {
650
+ deadLetterTargetArn: { 'Fn::GetAtt': ['AnalyticsDeadLetterQueue', 'Arn'] },
651
+ maxReceiveCount: config.maxReceiveCount ?? 3,
652
+ },
653
+ }),
654
+ },
655
+ }
656
+
657
+ return resources
658
+ }
659
+
660
+ // ============================================================================
661
+ // ts-cloud Integration
662
+ // ============================================================================
663
+
664
+ /**
665
+ * Create an SQS event producer using ts-cloud configuration
666
+ *
667
+ * @example
668
+ * ```ts
669
+ * import { createAnalyticsProducer } from 'ts-analytics'
670
+ *
671
+ * // Uses queue URL from environment or cloud.config.ts
672
+ * const producer = await createAnalyticsProducer()
673
+ *
674
+ * // Or specify queue URL directly
675
+ * const producer = await createAnalyticsProducer({
676
+ * queueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789/analytics-events',
677
+ * region: 'us-east-1',
678
+ * })
679
+ *
680
+ * // Send events
681
+ * await producer.sendEvent({
682
+ * type: 'pageview',
683
+ * siteId: 'my-site',
684
+ * timestamp: new Date().toISOString(),
685
+ * data: pageViewData,
686
+ * })
687
+ * ```
688
+ */
689
+ export async function createAnalyticsProducer(options?: {
690
+ queueUrl?: string
691
+ region?: string
692
+ }): Promise<SQSEventProducer> {
693
+ // Try to load from environment or cloud.config.ts
694
+ const queueUrl = options?.queueUrl
695
+ || process.env.SQS_QUEUE_URL
696
+ || process.env.ANALYTICS_SQS_QUEUE_URL
697
+
698
+ if (!queueUrl) {
699
+ throw new Error(
700
+ 'SQS queue URL not configured. Set SQS_QUEUE_URL environment variable or pass queueUrl option.',
701
+ )
702
+ }
703
+
704
+ const region = options?.region || process.env.AWS_REGION || 'us-east-1'
705
+
706
+ // Dynamic import to avoid bundling ts-cloud in browser builds
707
+ // Falls back to a minimal implementation if ts-cloud is not available
708
+ let clientAdapter: SQSClient
709
+
710
+ try {
711
+ const tsCloud = await (import('ts-cloud' as string) as Promise<any>)
712
+ const sqsClient = new tsCloud.SQSClient(region)
713
+
714
+ // Create adapter to match our interface using ts-cloud client
715
+ clientAdapter = {
716
+ sendMessage: async (input) => {
717
+ const result = await sqsClient.sendMessage({
718
+ queueUrl: input.QueueUrl,
719
+ messageBody: input.MessageBody,
720
+ messageGroupId: input.MessageGroupId,
721
+ messageDeduplicationId: input.MessageDeduplicationId,
722
+ })
723
+ return { MessageId: result.MessageId }
724
+ },
725
+ sendMessageBatch: async (input) => {
726
+ // ts-cloud may not have sendMessageBatch, fall back to individual sends
727
+ const results = await Promise.allSettled(
728
+ input.Entries.map(e =>
729
+ sqsClient.sendMessage({
730
+ queueUrl: input.QueueUrl,
731
+ messageBody: e.MessageBody,
732
+ messageGroupId: e.MessageGroupId,
733
+ messageDeduplicationId: e.MessageDeduplicationId,
734
+ }) .then((r: any) => ({ Id: e.Id, MessageId: r.MessageId })),
735
+ ),
736
+ )
737
+ return {
738
+ Successful: results
739
+ .filter((r): r is PromiseFulfilledResult<{ Id: string, MessageId: string }> => r.status === 'fulfilled')
740
+ .map(r => r.value),
741
+ Failed: results
742
+ .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
743
+ .map((r, i) => ({ Id: input.Entries[i].Id, Code: 'SendFailed', Message: String(r.reason) })),
744
+ }
745
+ },
746
+ receiveMessage: async (input) => {
747
+ const result = await sqsClient.receiveMessages({
748
+ queueUrl: input.QueueUrl,
749
+ maxMessages: input.MaxNumberOfMessages,
750
+ visibilityTimeout: input.VisibilityTimeout,
751
+ waitTimeSeconds: input.WaitTimeSeconds,
752
+ })
753
+ return {
754
+ Messages: result.Messages?.map((m: any) => ({
755
+ MessageId: m.MessageId || '',
756
+ ReceiptHandle: m.ReceiptHandle || '',
757
+ Body: m.Body || '',
758
+ })),
759
+ }
760
+ },
761
+ deleteMessage: async (input) => {
762
+ await sqsClient.deleteMessage(input.QueueUrl, input.ReceiptHandle)
763
+ },
764
+ deleteMessageBatch: async (input) => {
765
+ // ts-cloud may not have deleteMessageBatch, fall back to individual deletes
766
+ const results = await Promise.allSettled(
767
+ input.Entries.map(e =>
768
+ sqsClient.deleteMessage(input.QueueUrl, e.ReceiptHandle).then(() => ({ Id: e.Id })),
769
+ ),
770
+ )
771
+ return {
772
+ Successful: results
773
+ .filter((r): r is PromiseFulfilledResult<{ Id: string }> => r.status === 'fulfilled')
774
+ .map(r => r.value),
775
+ Failed: results
776
+ .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
777
+ .map((_r, i) => ({ Id: input.Entries[i].Id, Code: 'DeleteFailed' })),
778
+ }
779
+ },
780
+ }
781
+ }
782
+ catch {
783
+ throw new Error(
784
+ 'ts-cloud package is required for SQS integration. Install with: bun add ts-cloud',
785
+ )
786
+ }
787
+
788
+ return new SQSEventProducer(clientAdapter, queueUrl)
789
+ }
790
+
791
+ /**
792
+ * Check if SQS buffering is enabled based on configuration
793
+ */
794
+ export function isSQSBufferingEnabled(): boolean {
795
+ return (
796
+ process.env.SQS_BUFFERING_ENABLED === 'true'
797
+ || process.env.ANALYTICS_SQS_ENABLED === 'true'
798
+ || Boolean(process.env.SQS_QUEUE_URL)
799
+ )
800
+ }
801
+
802
+ // ============================================================================
803
+ // Export Default Config
804
+ // ============================================================================
805
+
806
+ export { defaultSQSConfig as sqsConfig }