@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.
- package/CHANGELOG.md +68 -0
- package/LICENSE.md +21 -0
- package/README.md +361 -0
- package/bin/cli.ts +169 -0
- package/dist/Analytics.d.ts +558 -0
- package/dist/api.d.ts +109 -0
- package/dist/batching.d.ts +93 -0
- package/dist/chunk-2mx7fq49.js +4 -0
- package/dist/chunk-3z29508k.js +204 -0
- package/dist/chunk-deephkz6.js +56 -0
- package/dist/chunk-j261vgyp.js +305 -0
- package/dist/chunk-xga17tz7.js +207 -0
- package/dist/config.d.ts +132 -0
- package/dist/dashboard/components/index.d.ts +80 -0
- package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
- package/dist/dashboard/index.d.ts +110 -0
- package/dist/dashboard/types/index.d.ts +144 -0
- package/dist/dashboard/utils/index.d.ts +81 -0
- package/dist/dynamodb.d.ts +86 -0
- package/dist/funnels.d.ts +104 -0
- package/dist/geolocation.d.ts +96 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +10211 -0
- package/dist/infrastructure/cdk.d.ts +60 -0
- package/dist/infrastructure/cloudformation.d.ts +44 -0
- package/dist/infrastructure/index.d.ts +45 -0
- package/dist/infrastructure/setup.d.ts +133 -0
- package/dist/integrations/cloudflare.d.ts +76 -0
- package/dist/integrations/hono.d.ts +60 -0
- package/dist/integrations/index.d.ts +24 -0
- package/dist/integrations/nuxt.d.ts +7 -0
- package/dist/integrations/nuxt.js +42 -0
- package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
- package/dist/integrations/runtime/use-ts-analytics.js +17 -0
- package/dist/integrations/stx.d.ts +88 -0
- package/dist/integrations/stx.js +18 -0
- package/dist/lib/crypto-random.d.ts +4 -0
- package/dist/lib/salt.d.ts +16 -0
- package/dist/local.d.ts +56 -0
- package/dist/model-connector.d.ts +145 -0
- package/dist/models/AggregatedStats.d.ts +9 -0
- package/dist/models/CampaignStats.d.ts +9 -0
- package/dist/models/Conversion.d.ts +11 -0
- package/dist/models/CustomEvent.d.ts +11 -0
- package/dist/models/DeviceStats.d.ts +9 -0
- package/dist/models/EventStats.d.ts +9 -0
- package/dist/models/GeoStats.d.ts +9 -0
- package/dist/models/Goal.d.ts +9 -0
- package/dist/models/GoalStats.d.ts +9 -0
- package/dist/models/PageStats.d.ts +11 -0
- package/dist/models/PageView.d.ts +13 -0
- package/dist/models/RealtimeStats.d.ts +9 -0
- package/dist/models/ReferrerStats.d.ts +9 -0
- package/dist/models/Session.d.ts +11 -0
- package/dist/models/Site.d.ts +11 -0
- package/dist/models/index.d.ts +28 -0
- package/dist/models/types.d.ts +60 -0
- package/dist/sqs-buffering.d.ts +243 -0
- package/dist/stacks-integration.d.ts +159 -0
- package/dist/tracking-script.d.ts +71 -0
- package/dist/tracking.d.ts +17 -0
- package/dist/tracking.js +26 -0
- package/dist/types.d.ts +595 -0
- package/dist/utils/geolocation.d.ts +111 -0
- package/dist/utils/user-agent.d.ts +17 -0
- package/dist/version.d.ts +7 -0
- package/package.json +119 -0
- package/src/Analytics.ts +3349 -0
- package/src/api.ts +1286 -0
- package/src/assets/crosswind.css +2220 -0
- package/src/batching.ts +452 -0
- package/src/components/dashboard/index.ts +18 -0
- package/src/config.ts +456 -0
- package/src/dashboard/Dashboard.stx +1517 -0
- package/src/dashboard/components/AlertCard.stx +177 -0
- package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
- package/src/dashboard/components/AnimatedNumber.stx +86 -0
- package/src/dashboard/components/BarChart.stx +220 -0
- package/src/dashboard/components/BrowserBreakdown.stx +98 -0
- package/src/dashboard/components/BrowsersTable.stx +125 -0
- package/src/dashboard/components/CampaignBreakdown.stx +163 -0
- package/src/dashboard/components/CampaignTable.stx +238 -0
- package/src/dashboard/components/CountryList.stx +101 -0
- package/src/dashboard/components/DataTable.stx +226 -0
- package/src/dashboard/components/DateRangePicker.stx +77 -0
- package/src/dashboard/components/DeviceBreakdown.stx +94 -0
- package/src/dashboard/components/DevicesTable.stx +163 -0
- package/src/dashboard/components/DonutChart.stories.ts +55 -0
- package/src/dashboard/components/DonutChart.stx +157 -0
- package/src/dashboard/components/EmptyState.stx +90 -0
- package/src/dashboard/components/EngagementMetrics.stx +183 -0
- package/src/dashboard/components/EventsSection.stx +192 -0
- package/src/dashboard/components/FilterBar.stx +104 -0
- package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
- package/src/dashboard/components/FunnelChart.stx +142 -0
- package/src/dashboard/components/GeoTable.stx +337 -0
- package/src/dashboard/components/GoalsPanel.stx +109 -0
- package/src/dashboard/components/Header.stx +39 -0
- package/src/dashboard/components/HeatmapChart.stx +140 -0
- package/src/dashboard/components/LiveActivityFeed.stx +172 -0
- package/src/dashboard/components/MetricComparison.stx +106 -0
- package/src/dashboard/components/MiniStats.stx +96 -0
- package/src/dashboard/components/OSBreakdown.stx +124 -0
- package/src/dashboard/components/PageDetailCard.stx +102 -0
- package/src/dashboard/components/PagesTable.stx +127 -0
- package/src/dashboard/components/ProgressRing.stx +89 -0
- package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
- package/src/dashboard/components/RealtimeCounter.stx +46 -0
- package/src/dashboard/components/ReferrersTable.stx +180 -0
- package/src/dashboard/components/SparklineChart.stx +160 -0
- package/src/dashboard/components/StatCard.stories.ts +58 -0
- package/src/dashboard/components/StatCard.stx +90 -0
- package/src/dashboard/components/SummaryStats.stx +81 -0
- package/src/dashboard/components/TabNav.stx +66 -0
- package/src/dashboard/components/ThemeSwitcher.stx +124 -0
- package/src/dashboard/components/TimeSeriesChart.stx +106 -0
- package/src/dashboard/components/TopList.stories.ts +58 -0
- package/src/dashboard/components/TopList.stx +74 -0
- package/src/dashboard/components/TrendIndicator.stx +84 -0
- package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
- package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
- package/src/dashboard/components/heatmap/index.ts +12 -0
- package/src/dashboard/components/index.ts +86 -0
- package/src/dashboard/composables/useAnalytics.ts +465 -0
- package/src/dashboard/demo/DemoApp.stx +370 -0
- package/src/dashboard/demo/index.ts +8 -0
- package/src/dashboard/demo/mockData.ts +234 -0
- package/src/dashboard/index.ts +117 -0
- package/src/dashboard/stx-shim.d.ts +4 -0
- package/src/dashboard/types/index.ts +203 -0
- package/src/dashboard/utils/index.ts +426 -0
- package/src/dynamodb.ts +344 -0
- package/src/funnels.ts +534 -0
- package/src/geolocation.ts +515 -0
- package/src/handlers/alerts.ts +328 -0
- package/src/handlers/annotations.ts +128 -0
- package/src/handlers/api-keys.ts +240 -0
- package/src/handlers/auth.ts +1020 -0
- package/src/handlers/authz.ts +137 -0
- package/src/handlers/collect.ts +724 -0
- package/src/handlers/data.ts +625 -0
- package/src/handlers/experiments.ts +138 -0
- package/src/handlers/funnels.ts +216 -0
- package/src/handlers/goals.ts +218 -0
- package/src/handlers/heatmaps.ts +272 -0
- package/src/handlers/index.ts +56 -0
- package/src/handlers/lib/read-cache.ts +63 -0
- package/src/handlers/misc.ts +486 -0
- package/src/handlers/oauth.ts +233 -0
- package/src/handlers/performance.ts +388 -0
- package/src/handlers/sessions.ts +413 -0
- package/src/handlers/sharing.ts +198 -0
- package/src/handlers/stats.ts +1368 -0
- package/src/handlers/team.ts +161 -0
- package/src/handlers/uptime.ts +283 -0
- package/src/handlers/views.ts +390 -0
- package/src/handlers/webhooks.ts +226 -0
- package/src/heatmap/index.ts +32 -0
- package/src/heatmap/tracking-script.ts +452 -0
- package/src/heatmap/types.ts +79 -0
- package/src/index.ts +387 -0
- package/src/infrastructure/cdk.ts +496 -0
- package/src/infrastructure/cloudformation.ts +595 -0
- package/src/infrastructure/index.ts +48 -0
- package/src/infrastructure/setup.ts +611 -0
- package/src/integrations/cloudflare.ts +732 -0
- package/src/integrations/hono.ts +589 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/nuxt.ts +78 -0
- package/src/integrations/runtime/use-ts-analytics.ts +32 -0
- package/src/integrations/stx.ts +138 -0
- package/src/jobs/index.ts +127 -0
- package/src/lib/crypto-random.ts +41 -0
- package/src/lib/ddb-errors.ts +20 -0
- package/src/lib/dynamodb.ts +216 -0
- package/src/lib/email.ts +38 -0
- package/src/lib/ga-import.ts +471 -0
- package/src/lib/ga4-api.ts +244 -0
- package/src/lib/goals.ts +205 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/ingest-counters.ts +123 -0
- package/src/lib/log.ts +36 -0
- package/src/lib/membership.ts +98 -0
- package/src/lib/plans.ts +90 -0
- package/src/lib/quota.ts +46 -0
- package/src/lib/rate-limit.ts +27 -0
- package/src/lib/rollups.ts +472 -0
- package/src/lib/salt.ts +97 -0
- package/src/lib/scheduler.ts +97 -0
- package/src/lib/significance.ts +66 -0
- package/src/lib/site-retention.ts +56 -0
- package/src/local.ts +360 -0
- package/src/model-connector.ts +616 -0
- package/src/models/AggregatedStats.ts +162 -0
- package/src/models/CampaignStats.ts +141 -0
- package/src/models/Conversion.ts +135 -0
- package/src/models/CustomEvent.ts +123 -0
- package/src/models/DeviceStats.ts +105 -0
- package/src/models/EventStats.ts +108 -0
- package/src/models/GeoStats.ts +112 -0
- package/src/models/Goal.ts +105 -0
- package/src/models/GoalStats.ts +106 -0
- package/src/models/PageStats.ts +152 -0
- package/src/models/PageView.ts +277 -0
- package/src/models/RealtimeStats.ts +96 -0
- package/src/models/ReferrerStats.ts +115 -0
- package/src/models/Session.ts +235 -0
- package/src/models/Site.ts +115 -0
- package/src/models/index.ts +50 -0
- package/src/models/orm/index.ts +2043 -0
- package/src/models/types.ts +71 -0
- package/src/router.ts +521 -0
- package/src/sqs-buffering.ts +806 -0
- package/src/stacks-integration.ts +527 -0
- package/src/tracking-script.ts +944 -0
- package/src/tracking.ts +27 -0
- package/src/types/analytics.ts +219 -0
- package/src/types/api.ts +75 -0
- package/src/types/bun-router.d.ts +15 -0
- package/src/types/dashboard.ts +62 -0
- package/src/types/index.ts +66 -0
- package/src/types/stx.d.ts +27 -0
- package/src/types/window.d.ts +101 -0
- package/src/types.ts +911 -0
- package/src/utils/cache.ts +148 -0
- package/src/utils/date.ts +118 -0
- package/src/utils/filters.ts +71 -0
- package/src/utils/geolocation.ts +323 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/response.ts +180 -0
- package/src/utils/timezone-country.ts +242 -0
- package/src/utils/user-agent.ts +110 -0
- 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
|
+
}
|