@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,724 @@
1
+ /**
2
+ * Event collection handler - main ingestion endpoint
3
+ */
4
+
5
+ import {
6
+ generateId,
7
+ generateSessionId,
8
+ hashVisitorId,
9
+ getSharedDailySalt,
10
+ getConfig,
11
+ } from '../index'
12
+ import type { Session as SessionType } from '../../src/types'
13
+ import { randomToken } from '../lib/crypto-random'
14
+ import { rateLimitAllow } from '../lib/rate-limit'
15
+ import {
16
+ PageView as PageViewModel,
17
+ Session as SessionModel,
18
+ CustomEvent as CustomEventModel,
19
+ HeatmapClick,
20
+ HeatmapMovement,
21
+ HeatmapScroll,
22
+ } from '../../src/models/orm'
23
+ import { dynamodb, TABLE_NAME, unmarshall, marshall, isConditionalCheckFailed } from '../lib/dynamodb'
24
+ import { accountEventWithinQuota } from '../lib/plans'
25
+ import { checkAndRecordConversions } from '../lib/goals'
26
+ import { getSession, setSession } from '../utils/cache'
27
+ import { parseUserAgent, isBot } from '../utils/user-agent'
28
+ import { getCountryFromHeaders, getCountryFromIP, getRegionFromHeaders, getCityFromHeaders, parseReferrerSource, isSpamReferrer, anonymizeIp } from '../utils/geolocation'
29
+ import { getCountryFromTimezone } from '../utils/timezone-country'
30
+ import { jsonResponse, errorResponse, noContentResponse } from '../utils/response'
31
+ import { getClientIP, getUserAgent, getHeaders } from '../../deploy/lambda-adapter'
32
+ import { ensureSiteExists } from './misc'
33
+ import { recordIngest } from '../lib/ingest-counters'
34
+ import { ensureSiteRetentionLoaded, rawTtlForSite } from '../lib/site-retention'
35
+
36
+ /** Max ingest requests per minute per IP on /collect + /t (429 beyond this). */
37
+ const COLLECT_RATE_LIMIT_PER_MIN = 1200
38
+
39
+ /**
40
+ * A client-supplied session id is only trusted if it looks like one our tracker
41
+ * emits — otherwise a missing id collapses every hit to SESSION#undefined and a
42
+ * crafted id can corrupt session counts / keys (#147).
43
+ */
44
+ function isValidSessionId(value: unknown): value is string {
45
+ return typeof value === 'string' && /^[\w-]{6,64}$/.test(value)
46
+ }
47
+
48
+ /**
49
+ * Whether an event should be dropped per the configured exclusions: IPs by
50
+ * exact match, paths by regex (falling back to exact match if the pattern is
51
+ * invalid). Lets deployments exclude internal traffic / admin paths (#159).
52
+ */
53
+ function isExcluded(ip: string, path: string, ips?: string[], paths?: string[]): boolean {
54
+ if (ips && ips.length > 0 && ips.includes(ip))
55
+ return true
56
+ if (paths) {
57
+ for (const pattern of paths) {
58
+ try {
59
+ if (new RegExp(pattern).test(path))
60
+ return true
61
+ }
62
+ catch {
63
+ if (pattern === path)
64
+ return true
65
+ }
66
+ }
67
+ }
68
+ return false
69
+ }
70
+
71
+ /**
72
+ * Keep only valid scroll buckets (integer depth 0-100 → finite ms), so a crafted
73
+ * payload can't bloat the item — and, since keys are bounded to 0-100, the
74
+ * cross-request merge in HeatmapScroll.upsert stays bounded too (#150).
75
+ */
76
+ function clampScrollDepths(depths: unknown): Record<number, number> {
77
+ const out: Record<number, number> = {}
78
+ if (!depths || typeof depths !== 'object')
79
+ return out
80
+ for (const [k, v] of Object.entries(depths as Record<string, unknown>)) {
81
+ const d = Number(k)
82
+ if (Number.isInteger(d) && d >= 0 && d <= 100 && typeof v === 'number' && Number.isFinite(v))
83
+ out[d] = Math.max(0, Math.min(v, 24 * 60 * 60 * 1000))
84
+ }
85
+ return out
86
+ }
87
+
88
+ /**
89
+ * Extract UTM parameters and ad click IDs from a request URL.
90
+ */
91
+ function extractUtm(url: URL): {
92
+ utmSource?: string
93
+ utmMedium?: string
94
+ utmCampaign?: string
95
+ utmContent?: string
96
+ utmTerm?: string
97
+ gclid?: string
98
+ fbclid?: string
99
+ } {
100
+ const g = (k: string): string | undefined => url.searchParams.get(k) || undefined
101
+ return {
102
+ utmSource: g('utm_source'),
103
+ utmMedium: g('utm_medium'),
104
+ utmCampaign: g('utm_campaign'),
105
+ utmContent: g('utm_content'),
106
+ utmTerm: g('utm_term'),
107
+ gclid: g('gclid'),
108
+ fbclid: g('fbclid'),
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Filterable visitor dimensions copied from the session onto click/engagement
114
+ * records, so those reports can be sliced by the dashboard filters. Only defined
115
+ * values are included (marshall stores undefined as NULL otherwise).
116
+ */
117
+ function filterDims(session: SessionType | null | undefined): Record<string, string> {
118
+ const out: Record<string, string> = {}
119
+ if (!session) return out
120
+ const keys = ['deviceType', 'browser', 'os', 'country', 'region', 'city', 'referrerSource', 'utmSource', 'utmMedium', 'utmCampaign'] as const
121
+ for (const k of keys) {
122
+ const v = (session as Record<string, any>)[k]
123
+ if (v) out[k] = v
124
+ }
125
+ return out
126
+ }
127
+
128
+ /**
129
+ * POST /collect or /t
130
+ */
131
+ export async function handleCollect(request: Request): Promise<Response> {
132
+ try {
133
+ // Reject oversized bodies before parsing (#173): a multi-MB payload both
134
+ // memory-amplifies and 500s against DynamoDB's 400KB item limit (losing
135
+ // the event). Legit beacons are <2KB.
136
+ const MAX_BODY_BYTES = 16 * 1024
137
+ const rawBody = await request.text()
138
+ if (rawBody.length > MAX_BODY_BYTES) {
139
+ return jsonResponse({ error: 'Payload too large' }, 413)
140
+ }
141
+ let payload: Record<string, any>
142
+ try {
143
+ payload = JSON.parse(rawBody) as Record<string, any>
144
+ }
145
+ catch {
146
+ return jsonResponse({ error: 'Invalid JSON' }, 400)
147
+ }
148
+
149
+ if (!payload?.s || !payload?.e || !payload?.u) {
150
+ if (typeof payload?.s === 'string')
151
+ recordIngest(payload.s, 'invalid')
152
+ return jsonResponse({ error: 'Missing required fields: s, e, u' }, 400)
153
+ }
154
+
155
+ // Parse URL early to get hostname for site creation
156
+ let parsedUrlForSite: URL
157
+ try {
158
+ parsedUrlForSite = new URL(payload.u)
159
+ }
160
+ catch {
161
+ return jsonResponse({ error: 'Invalid URL' }, 400)
162
+ }
163
+
164
+ const userAgent = getUserAgent(request)
165
+
166
+ // Drop bot/crawler traffic and known referral-spam before any writes
167
+ // (no site auto-create, no records).
168
+ if (isBot(userAgent) || isSpamReferrer(payload.r)) {
169
+ recordIngest(payload.s, 'bot')
170
+ return noContentResponse(request)
171
+ }
172
+
173
+ // Ensure site exists (auto-create if first event; gated in production, #170)
174
+ const site = await ensureSiteExists(payload.s, parsedUrlForSite.hostname)
175
+ if (!site) {
176
+ // Unknown site and auto-provisioning disabled — drop silently (a 4xx
177
+ // would only help someone probing for valid ids).
178
+ recordIngest(payload.s, 'firewall')
179
+ return noContentResponse(request)
180
+ }
181
+
182
+ // Domain firewall (#170): when the site has domains configured, only
183
+ // accept events whose page hostname matches (exact, subdomain, or '*').
184
+ // An empty domains list keeps the open behavior for unconfigured sites.
185
+ const allowedDomains: string[] = Array.isArray(site.domains) ? site.domains.filter(Boolean) : []
186
+ if (allowedDomains.length > 0) {
187
+ const host = parsedUrlForSite.hostname.toLowerCase()
188
+ const allowed = allowedDomains.some((d: string) => {
189
+ const dom = String(d).toLowerCase().replace(/^www\./, '')
190
+ if (dom === '*') return true
191
+ const h = host.replace(/^www\./, '')
192
+ return h === dom || h.endsWith(`.${dom}`)
193
+ })
194
+ if (!allowed) {
195
+ recordIngest(payload.s, 'firewall')
196
+ return noContentResponse(request)
197
+ }
198
+ }
199
+
200
+ // Account plan quota (#62): events across all of an owner's projects count
201
+ // against the owner's monthly plan limit. Unowned (auto-created) sites and
202
+ // quota errors fail open — a billing glitch never drops events.
203
+ if (site?.ownerId && !(await accountEventWithinQuota(site.ownerId))) {
204
+ recordIngest(payload.s, 'quota')
205
+ return jsonResponse({ error: 'Monthly event quota exceeded for this account' }, 429)
206
+ }
207
+
208
+ const ip = getClientIP(request)
209
+
210
+ // Per-IP rate limit on the public ingest endpoint to cap event-spam / DoS
211
+ // (#158). Deliberately generous (20/s) so heavy SPA usage and shared NAT
212
+ // aren't throttled while single-source floods are. In-memory per process.
213
+ if (!rateLimitAllow(`collect:${ip}`, COLLECT_RATE_LIMIT_PER_MIN, 60_000)) {
214
+ recordIngest(payload.s, 'rate_limited')
215
+ return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
216
+ status: 429,
217
+ headers: { 'Content-Type': 'application/json', 'Retry-After': '60', 'Access-Control-Allow-Origin': '*' },
218
+ })
219
+ }
220
+
221
+ // Honor configured IP/path exclusions (internal traffic, admin paths) (#159).
222
+ const tracking = getConfig().tracking
223
+ if (isExcluded(ip, parsedUrlForSite.pathname, tracking.excludedIps, tracking.excludedPaths)) {
224
+ recordIngest(payload.s, 'excluded')
225
+ return noContentResponse(request)
226
+ }
227
+
228
+ // Idempotency (#169): the tracker mints a per-event id (eid); a replayed
229
+ // delivery (Lambda retry, network retransmit, SQS at-least-once) carries
230
+ // the same eid, so a conditional put on an EIDLOCK item lets exactly one
231
+ // delivery through. Fails OPEN on non-conditional errors — a DynamoDB
232
+ // hiccup must never drop events (degrades to today's at-least-once).
233
+ const eid = typeof payload.eid === 'string' && /^[\w-]{8,64}$/.test(payload.eid) ? payload.eid : null
234
+ if (eid) {
235
+ try {
236
+ await dynamodb.putItem({
237
+ TableName: TABLE_NAME,
238
+ Item: {
239
+ pk: { S: `SITE#${payload.s}` },
240
+ sk: { S: `EIDLOCK#${eid}` },
241
+ ttl: { N: String(Math.floor(Date.now() / 1000) + 24 * 60 * 60) },
242
+ },
243
+ ConditionExpression: 'attribute_not_exists(pk)',
244
+ })
245
+ }
246
+ catch (e) {
247
+ if (isConditionalCheckFailed(e)) {
248
+ recordIngest(payload.s, 'dedup')
249
+ return noContentResponse(request)
250
+ }
251
+ }
252
+ }
253
+
254
+ const headers = getHeaders(request)
255
+
256
+ // Accepted for processing — count it (with the tracker version when the
257
+ // beacon carries one, #179) so ingest drops are measurable (#175).
258
+ recordIngest(payload.s, 'collected', typeof payload.v === 'string' ? payload.v : undefined)
259
+
260
+ // Per-site retention (#178): cached 5-min read so every raw write below
261
+ // stamps the site's configured TTL instead of the global default.
262
+ await ensureSiteRetentionLoaded(payload.s)
263
+
264
+ // Ingest is cleanly direct (#97). The old SQS "fast path" was removed: it
265
+ // intercepted every event type but coerced clicks/engagement/vitals into
266
+ // EVENT# items and skipped session creation entirely, silently breaking
267
+ // bounce rates and session-derived breakdowns whenever it was enabled.
268
+ // The sqs-buffering library + deploy/sqs-consumer-handler.ts remain as
269
+ // standalone opt-in infrastructure for spike-buffered pipelines.
270
+ // Raw IP+UA are PII — never stream them to production logs (the privacy
271
+ // docs promise the hash inputs are discarded). Opt in per run for local
272
+ // debugging only.
273
+ if (process.env.ANALYTICS_DEBUG === 'true')
274
+ console.log(`[Collect] IP: ${ip}, UA: ${userAgent?.substring(0, 50)}...`)
275
+ const salt = await getSharedDailySalt()
276
+ // When neither IP nor UA carries any signal, hashVisitorId would collapse
277
+ // every such hit to one identical hash — under-counting uniques and letting a
278
+ // client hide by omitting both. Use a per-hit anonymous id instead (#148).
279
+ const hasVisitorSignal = (!!ip && ip !== 'unknown') || (!!userAgent && userAgent !== 'unknown')
280
+ const visitorId = hasVisitorSignal
281
+ ? await hashVisitorId(ip, userAgent, payload.s, salt)
282
+ : `anon_${randomToken(24)}`
283
+
284
+ let parsedUrl: URL
285
+ try {
286
+ parsedUrl = new URL(payload.u)
287
+ }
288
+ catch {
289
+ return jsonResponse({ error: 'Invalid URL' }, 400)
290
+ }
291
+
292
+ const timestamp = new Date()
293
+ // Reject a missing/malformed client session id (would collapse to
294
+ // SESSION#undefined / allow crafted keys); mint a server-side one (#147).
295
+ let sessionId = isValidSessionId(payload.sid) ? payload.sid : generateSessionId()
296
+
297
+ let sessionKey = `${payload.s}:${sessionId}`
298
+ let session = getSession(sessionKey)
299
+
300
+ // Load session from DynamoDB if not in cache
301
+ if (!session) {
302
+ try {
303
+ const sessionResult = await dynamodb.getItem({
304
+ TableName: TABLE_NAME,
305
+ Key: {
306
+ pk: { S: `SITE#${payload.s}` },
307
+ sk: { S: `SESSION#${sessionId}` },
308
+ },
309
+ })
310
+ if (sessionResult.Item) {
311
+ session = unmarshall(sessionResult.Item) as SessionType
312
+ if (typeof session.startedAt === 'string') {
313
+ session.startedAt = new Date(session.startedAt)
314
+ }
315
+ setSession(sessionKey, session)
316
+ }
317
+ }
318
+ catch (e) {
319
+ console.log('[Collect] Failed to load session from DB:', e)
320
+ }
321
+ }
322
+
323
+ // Session timeout (#135): the client keeps its sessionStorage sid for the
324
+ // tab's whole life, so without a server-side idle cutoff a tab left open
325
+ // across days stays ONE session (skewing duration/bounce) and stale sids
326
+ // are replayable forever. Match Fathom's ~30-minute semantics: when the
327
+ // loaded session has been idle past the cutoff, mint a fresh server-side
328
+ // session id and treat this hit as a new session.
329
+ const SESSION_IDLE_MS = 30 * 60 * 1000
330
+ if (session) {
331
+ const lastActivity = new Date((session.endedAt || session.startedAt) as Date | string).getTime()
332
+ if (Number.isFinite(lastActivity) && Date.now() - lastActivity > SESSION_IDLE_MS) {
333
+ session = null
334
+ sessionId = generateSessionId()
335
+ sessionKey = `${payload.s}:${sessionId}`
336
+ }
337
+ }
338
+
339
+
340
+ if (payload.e === 'pageview') {
341
+ const deviceInfo = parseUserAgent(userAgent)
342
+ const browser = payload.br || deviceInfo.browser
343
+ // Self-referrals (SPA restores, same-site openers) are Direct traffic —
344
+ // don't let a site show up as its own top referrer.
345
+ let externalReferrer = typeof payload.r === 'string' ? payload.r.slice(0, 1024) : undefined
346
+ try {
347
+ if (externalReferrer && new URL(externalReferrer).hostname === parsedUrl.hostname)
348
+ externalReferrer = undefined
349
+ }
350
+ catch {}
351
+ const referrerSource = parseReferrerSource(externalReferrer)
352
+
353
+ let country = getCountryFromHeaders(headers)
354
+ // Fathom-privacy fallback: derive the country from the browser timezone
355
+ // the tracker sends (`tz`). No IP is used and nothing goes to a third
356
+ // party; the raw timezone is discarded — only the country is stored.
357
+ if (!country)
358
+ country = getCountryFromTimezone(payload.tz)
359
+ // Only fall back to a third-party geo lookup when geolocation is enabled
360
+ // (off by default) and send an anonymized IP per config (#144).
361
+ if (!country && getConfig().privacy.collectGeolocation) {
362
+ const geoIp = anonymizeIp(ip, getConfig().privacy.ipAnonymization)
363
+ if (geoIp)
364
+ country = await getCountryFromIP(geoIp)
365
+ }
366
+ const region = getRegionFromHeaders(headers)
367
+ const city = getCityFromHeaders(headers)
368
+
369
+ // Settle the session FIRST and derive the pageview's isUnique/isBounce
370
+ // from the actual outcome (#145): flags were previously persisted from
371
+ // the pre-write session read, so two concurrent first hits both stored
372
+ // isUnique=true — inflating entry-page uniques and bounce entries.
373
+ let wasNewSession = false
374
+
375
+ if (session) {
376
+ session.pageViewCount += 1
377
+ session.exitPath = parsedUrl.pathname
378
+ session.endedAt = timestamp
379
+ session.isBounce = false
380
+ const startedAt = session.startedAt instanceof Date ? session.startedAt : new Date(session.startedAt)
381
+ session.duration = timestamp.getTime() - startedAt.getTime()
382
+ // Atomic increment so concurrent pageviews don't lose the count (#161).
383
+ await SessionModel.incrementMetrics(payload.s, sessionId, {
384
+ incPageViews: 1,
385
+ exitPath: parsedUrl.pathname,
386
+ endedAt: timestamp,
387
+ duration: session.duration,
388
+ isBounce: false,
389
+ countryIfAbsent: country,
390
+ })
391
+ }
392
+ else {
393
+ session = {
394
+ id: sessionId,
395
+ siteId: payload.s,
396
+ visitorId,
397
+ entryPath: parsedUrl.pathname,
398
+ exitPath: parsedUrl.pathname,
399
+ referrer: externalReferrer,
400
+ referrerSource,
401
+ ...extractUtm(parsedUrl),
402
+ deviceType: deviceInfo.deviceType as 'desktop' | 'mobile' | 'tablet' | 'unknown',
403
+ browser,
404
+ os: deviceInfo.os,
405
+ country,
406
+ region,
407
+ city,
408
+ pageViewCount: 1,
409
+ eventCount: 0,
410
+ isBounce: true,
411
+ duration: 0,
412
+ startedAt: timestamp,
413
+ endedAt: timestamp,
414
+ }
415
+ // Conditional create; if a concurrent request already created this
416
+ // session, count this pageview atomically instead of clobbering it (#161).
417
+ const created = await SessionModel.createIfAbsent(session)
418
+ wasNewSession = created
419
+ if (!created) {
420
+ await SessionModel.incrementMetrics(payload.s, sessionId, {
421
+ incPageViews: 1,
422
+ exitPath: parsedUrl.pathname,
423
+ endedAt: timestamp,
424
+ duration: 0,
425
+ isBounce: false,
426
+ countryIfAbsent: country,
427
+ })
428
+ }
429
+ }
430
+
431
+ setSession(sessionKey, session)
432
+
433
+ await PageViewModel.record({
434
+ id: generateId(),
435
+ siteId: payload.s,
436
+ visitorId,
437
+ sessionId,
438
+ path: parsedUrl.pathname,
439
+ hostname: parsedUrl.hostname,
440
+ title: typeof payload.t === 'string' ? payload.t.slice(0, 512) : payload.t,
441
+ referrer: externalReferrer,
442
+ referrerSource,
443
+ ...extractUtm(parsedUrl),
444
+ deviceType: deviceInfo.deviceType as 'desktop' | 'mobile' | 'tablet' | 'unknown',
445
+ browser,
446
+ os: deviceInfo.os,
447
+ country,
448
+ region,
449
+ city,
450
+ screenWidth: payload.sw,
451
+ screenHeight: payload.sh,
452
+ isUnique: wasNewSession,
453
+ isBounce: wasNewSession,
454
+ timestamp,
455
+ })
456
+
457
+ await checkAndRecordConversions(
458
+ payload.s,
459
+ visitorId,
460
+ sessionId,
461
+ { path: parsedUrl.pathname },
462
+ {
463
+ referrerSource,
464
+ utmSource: parsedUrl.searchParams.get('utm_source') || undefined,
465
+ utmMedium: parsedUrl.searchParams.get('utm_medium') || undefined,
466
+ utmCampaign: parsedUrl.searchParams.get('utm_campaign') || undefined,
467
+ }
468
+ )
469
+ }
470
+ else if (payload.e === 'event') {
471
+ const props = payload.p || {}
472
+ const eventName = props.name || 'unnamed'
473
+ const eventValue = typeof props.value === 'number' ? props.value : undefined
474
+
475
+ // Keep only primitive custom props; exclude the reserved name/value keys.
476
+ // Cap the count and string length so a crafted payload can't bloat the
477
+ // item toward the DynamoDB 400KB limit (#134).
478
+ const MAX_PROPS = 50
479
+ const MAX_PROP_LEN = 1024
480
+ const customProps: Record<string, string | number | boolean> = {}
481
+ for (const [k, v] of Object.entries(props)) {
482
+ if (k === 'name' || k === 'value') continue
483
+ if (Object.keys(customProps).length >= MAX_PROPS) break
484
+ if (typeof v === 'string') customProps[k.slice(0, 256)] = v.slice(0, MAX_PROP_LEN)
485
+ else if (typeof v === 'number' || typeof v === 'boolean') customProps[k.slice(0, 256)] = v
486
+ }
487
+
488
+ await CustomEventModel.record({
489
+ id: generateId(),
490
+ siteId: payload.s,
491
+ visitorId,
492
+ sessionId,
493
+ ...filterDims(session),
494
+ name: eventName,
495
+ value: eventValue,
496
+ properties: Object.keys(customProps).length > 0 ? customProps : undefined,
497
+ path: parsedUrl.pathname,
498
+ timestamp,
499
+ })
500
+
501
+ if (session) {
502
+ session.eventCount += 1
503
+ session.endedAt = timestamp
504
+ const startedAt = session.startedAt instanceof Date ? session.startedAt : new Date(session.startedAt)
505
+ session.duration = timestamp.getTime() - startedAt.getTime()
506
+
507
+ // Atomic increment so concurrent events don't lose the count (#161).
508
+ await SessionModel.incrementMetrics(payload.s, sessionId, {
509
+ incEvents: 1,
510
+ endedAt: timestamp,
511
+ duration: session.duration,
512
+ })
513
+ setSession(sessionKey, session)
514
+ }
515
+
516
+ await checkAndRecordConversions(
517
+ payload.s,
518
+ visitorId,
519
+ sessionId,
520
+ { path: parsedUrl.pathname, eventName, eventValue },
521
+ {
522
+ referrerSource: session?.referrerSource,
523
+ utmSource: session?.utmSource,
524
+ utmMedium: session?.utmMedium,
525
+ utmCampaign: session?.utmCampaign,
526
+ }
527
+ )
528
+ }
529
+ else if (payload.e === 'outbound') {
530
+ const props = payload.p || {}
531
+
532
+ await CustomEventModel.record({
533
+ id: generateId(),
534
+ siteId: payload.s,
535
+ visitorId,
536
+ sessionId,
537
+ name: 'outbound',
538
+ properties: { url: props.url || '' },
539
+ path: parsedUrl.pathname,
540
+ timestamp,
541
+ })
542
+
543
+ if (session) {
544
+ session.eventCount += 1
545
+ session.endedAt = timestamp
546
+ const startedAt = session.startedAt instanceof Date ? session.startedAt : new Date(session.startedAt)
547
+ session.duration = timestamp.getTime() - startedAt.getTime()
548
+
549
+ // Atomic increment so concurrent events don't lose the count (#161).
550
+ await SessionModel.incrementMetrics(payload.s, sessionId, {
551
+ incEvents: 1,
552
+ endedAt: timestamp,
553
+ duration: session.duration,
554
+ })
555
+ setSession(sessionKey, session)
556
+ }
557
+ }
558
+ else if (payload.e === 'click') {
559
+ const props = payload.p || {}
560
+ const kind = ['outbound', 'internal', 'download', 'mailto', 'tel'].includes(props.kind)
561
+ ? props.kind
562
+ : 'outbound'
563
+
564
+ await dynamodb.putItem({
565
+ TableName: TABLE_NAME,
566
+ Item: marshall({
567
+ pk: `SITE#${payload.s}`,
568
+ sk: `CLICK#${timestamp.toISOString()}#${generateId()}`,
569
+ siteId: payload.s,
570
+ sessionId,
571
+ visitorId,
572
+ ...filterDims(session),
573
+ path: parsedUrl.pathname,
574
+ url: String(props.url || '').slice(0, 1000),
575
+ kind,
576
+ text: String(props.text || '').slice(0, 200),
577
+ timestamp: timestamp.toISOString(),
578
+ _et: 'LinkClick',
579
+ ttl: rawTtlForSite(payload.s),
580
+ }),
581
+ })
582
+
583
+ if (session) {
584
+ session.eventCount += 1
585
+ session.endedAt = timestamp
586
+ const startedAt = session.startedAt instanceof Date ? session.startedAt : new Date(session.startedAt)
587
+ session.duration = timestamp.getTime() - startedAt.getTime()
588
+
589
+ // Atomic increment so concurrent events don't lose the count (#161).
590
+ await SessionModel.incrementMetrics(payload.s, sessionId, {
591
+ incEvents: 1,
592
+ endedAt: timestamp,
593
+ duration: session.duration,
594
+ })
595
+ setSession(sessionKey, session)
596
+ }
597
+ }
598
+ else if (payload.e === 'engagement') {
599
+ const props = payload.p || {}
600
+ const scrollDepth = Math.max(0, Math.min(100, Math.round(Number(props.scrollDepth) || 0)))
601
+ const timeOnPage = Math.max(0, Math.round(Number(props.timeOnPage) || 0))
602
+
603
+ await dynamodb.putItem({
604
+ TableName: TABLE_NAME,
605
+ Item: marshall({
606
+ pk: `SITE#${payload.s}`,
607
+ sk: `ENGAGEMENT#${timestamp.toISOString()}#${generateId()}`,
608
+ siteId: payload.s,
609
+ sessionId,
610
+ visitorId,
611
+ ...filterDims(session),
612
+ path: parsedUrl.pathname,
613
+ scrollDepth,
614
+ timeOnPage,
615
+ timestamp: timestamp.toISOString(),
616
+ _et: 'Engagement',
617
+ ttl: rawTtlForSite(payload.s),
618
+ }),
619
+ })
620
+
621
+ // Fold real engaged time into the session (#167): avg time on site was
622
+ // last-hit-minus-first-hit, so every bounce counted as 0s. Departure
623
+ // pings carry the truth — accumulate it atomically on the session.
624
+ if (timeOnPage > 0 && session) {
625
+ await SessionModel.incrementMetrics(payload.s, sessionId, {
626
+ addActiveMs: Math.min(timeOnPage, 6 * 60 * 60) * 1000,
627
+ endedAt: timestamp,
628
+ })
629
+ }
630
+ }
631
+ else if (payload.e === 'hm_click') {
632
+ const props = payload.p || {}
633
+ const deviceInfo = parseUserAgent(userAgent)
634
+
635
+ await HeatmapClick.record({
636
+ id: generateId(),
637
+ siteId: payload.s,
638
+ sessionId,
639
+ visitorId,
640
+ path: payload.u,
641
+ viewportX: props.vx || 0,
642
+ viewportY: props.vy || 0,
643
+ documentX: props.dx || 0,
644
+ documentY: props.dy || 0,
645
+ viewportWidth: props.vw || 0,
646
+ viewportHeight: props.vh || 0,
647
+ selector: props.selector || '',
648
+ elementTag: props.tag || '',
649
+ elementText: props.text,
650
+ deviceType: deviceInfo.deviceType as 'desktop' | 'mobile' | 'tablet' | 'unknown',
651
+ timestamp,
652
+ })
653
+ }
654
+ else if (payload.e === 'hm_move') {
655
+ const props = payload.p || {}
656
+ const deviceInfo = parseUserAgent(userAgent)
657
+
658
+ if (props.points && Array.isArray(props.points) && props.points.length > 0) {
659
+ // Cap points so one request can't exceed the DynamoDB 400KB item limit
660
+ // (write failures / amplification DoS) (#149).
661
+ const MAX_POINTS = 500
662
+ await HeatmapMovement.record({
663
+ id: generateId(),
664
+ siteId: payload.s,
665
+ sessionId,
666
+ visitorId,
667
+ path: payload.u,
668
+ points: props.points.slice(0, MAX_POINTS),
669
+ viewportWidth: props.vw || 0,
670
+ viewportHeight: props.vh || 0,
671
+ deviceType: deviceInfo.deviceType as 'desktop' | 'mobile' | 'tablet' | 'unknown',
672
+ timestamp,
673
+ })
674
+ }
675
+ }
676
+ else if (payload.e === 'hm_scroll') {
677
+ const props = payload.p || {}
678
+ const deviceInfo = parseUserAgent(userAgent)
679
+
680
+ await HeatmapScroll.upsert({
681
+ id: `${sessionId}-${encodeURIComponent(payload.u)}`,
682
+ siteId: payload.s,
683
+ sessionId,
684
+ visitorId,
685
+ path: payload.u,
686
+ maxScrollDepth: props.maxDepth || 0,
687
+ scrollDepths: clampScrollDepths(props.depths),
688
+ documentHeight: props.docHeight || 0,
689
+ viewportHeight: props.vh || 0,
690
+ deviceType: deviceInfo.deviceType as 'desktop' | 'mobile' | 'tablet' | 'unknown',
691
+ timestamp,
692
+ })
693
+ }
694
+ else if (payload.e === 'vitals') {
695
+ const props = payload.p || {}
696
+ const deviceInfo = parseUserAgent(userAgent)
697
+ const browser = payload.br || deviceInfo.browser
698
+
699
+ await dynamodb.putItem({
700
+ TableName: TABLE_NAME,
701
+ Item: marshall({
702
+ pk: `SITE#${payload.s}`,
703
+ sk: `VITAL#${timestamp.toISOString()}#${generateId()}`,
704
+ siteId: payload.s,
705
+ sessionId,
706
+ visitorId,
707
+ path: parsedUrl.pathname,
708
+ metric: props.metric || 'unknown',
709
+ value: props.value || 0,
710
+ rating: props.rating || 'unknown',
711
+ deviceType: deviceInfo.deviceType,
712
+ browser,
713
+ timestamp: timestamp.toISOString(),
714
+ ttl: rawTtlForSite(payload.s),
715
+ }),
716
+ })
717
+ }
718
+ return noContentResponse(request)
719
+ }
720
+ catch (error) {
721
+ console.error('Collect error:', error)
722
+ return errorResponse('Internal server error')
723
+ }
724
+ }