@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,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heatmap handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { dynamodb, TABLE_NAME, unmarshall } from '../lib/dynamodb'
|
|
6
|
+
import { parseDateRange } from '../utils/date'
|
|
7
|
+
import { jsonResponse, errorResponse } from '../utils/response'
|
|
8
|
+
import { getQueryParams } from '../../deploy/lambda-adapter'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/sites/{siteId}/heatmap/clicks
|
|
12
|
+
*/
|
|
13
|
+
export async function handleGetHeatmapClicks(request: Request, siteId: string): Promise<Response> {
|
|
14
|
+
try {
|
|
15
|
+
const query = getQueryParams(request)
|
|
16
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
17
|
+
const path = query.path
|
|
18
|
+
const device = query.device
|
|
19
|
+
const limit = Math.min(Number(query.limit) || 1000, 10000)
|
|
20
|
+
|
|
21
|
+
const result = await dynamodb.query({
|
|
22
|
+
TableName: TABLE_NAME,
|
|
23
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
24
|
+
ExpressionAttributeValues: {
|
|
25
|
+
':pk': { S: `SITE#${siteId}` },
|
|
26
|
+
':start': { S: `HMCLICK#${startDate.toISOString()}` },
|
|
27
|
+
':end': { S: `HMCLICK#${endDate.toISOString()}` },
|
|
28
|
+
},
|
|
29
|
+
Limit: limit,
|
|
30
|
+
}) as { Items?: any[] }
|
|
31
|
+
|
|
32
|
+
let clicks = (result.Items || []).map(unmarshall)
|
|
33
|
+
|
|
34
|
+
if (path) {
|
|
35
|
+
clicks = clicks.filter(c => c.path === path || c.path === decodeURIComponent(path))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (device && device !== 'all') {
|
|
39
|
+
clicks = clicks.filter(c => c.deviceType === device)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const gridSize = Number(query.gridSize) || 20
|
|
43
|
+
const clickGrid: Record<string, { count: number; elements: Record<string, number> }> = {}
|
|
44
|
+
|
|
45
|
+
for (const click of clicks) {
|
|
46
|
+
const gridX = Math.floor(click.documentX / gridSize) * gridSize
|
|
47
|
+
const gridY = Math.floor(click.documentY / gridSize) * gridSize
|
|
48
|
+
const key = `${gridX},${gridY}`
|
|
49
|
+
|
|
50
|
+
if (!clickGrid[key]) {
|
|
51
|
+
clickGrid[key] = { count: 0, elements: {} }
|
|
52
|
+
}
|
|
53
|
+
clickGrid[key].count++
|
|
54
|
+
|
|
55
|
+
if (click.selector) {
|
|
56
|
+
clickGrid[key].elements[click.selector] = (clickGrid[key].elements[click.selector] || 0) + 1
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const heatmapData = Object.entries(clickGrid).map(([key, data]) => {
|
|
61
|
+
const [x, y] = key.split(',').map(Number)
|
|
62
|
+
const topElement = Object.entries(data.elements)
|
|
63
|
+
.sort((a, b) => b[1] - a[1])[0]
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
x,
|
|
67
|
+
y,
|
|
68
|
+
count: data.count,
|
|
69
|
+
topElement: topElement ? { selector: topElement[0], count: topElement[1] } : null,
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const elementStats: Record<string, { count: number; tag: string; text?: string }> = {}
|
|
74
|
+
for (const click of clicks) {
|
|
75
|
+
if (click.selector) {
|
|
76
|
+
if (!elementStats[click.selector]) {
|
|
77
|
+
elementStats[click.selector] = { count: 0, tag: click.elementTag || '', text: click.elementText }
|
|
78
|
+
}
|
|
79
|
+
elementStats[click.selector].count++
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const topElements = Object.entries(elementStats)
|
|
84
|
+
.map(([selector, stats]) => ({
|
|
85
|
+
selector,
|
|
86
|
+
tag: stats.tag,
|
|
87
|
+
text: stats.text,
|
|
88
|
+
count: stats.count,
|
|
89
|
+
}))
|
|
90
|
+
.sort((a, b) => b.count - a.count)
|
|
91
|
+
.slice(0, 20)
|
|
92
|
+
|
|
93
|
+
return jsonResponse({
|
|
94
|
+
clicks: heatmapData,
|
|
95
|
+
topElements,
|
|
96
|
+
totalClicks: clicks.length,
|
|
97
|
+
dateRange: {
|
|
98
|
+
start: startDate.toISOString(),
|
|
99
|
+
end: endDate.toISOString(),
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.error('Heatmap clicks error:', error)
|
|
105
|
+
return errorResponse('Failed to fetch heatmap clicks')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* GET /api/sites/{siteId}/heatmap/scroll
|
|
111
|
+
*/
|
|
112
|
+
export async function handleGetHeatmapScroll(request: Request, siteId: string): Promise<Response> {
|
|
113
|
+
try {
|
|
114
|
+
const query = getQueryParams(request)
|
|
115
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
116
|
+
const path = query.path
|
|
117
|
+
const device = query.device
|
|
118
|
+
|
|
119
|
+
const result = await dynamodb.query({
|
|
120
|
+
TableName: TABLE_NAME,
|
|
121
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
|
|
122
|
+
ExpressionAttributeValues: {
|
|
123
|
+
':pk': { S: `SITE#${siteId}` },
|
|
124
|
+
':prefix': { S: 'HMSCROLL#' },
|
|
125
|
+
},
|
|
126
|
+
}) as { Items?: any[] }
|
|
127
|
+
|
|
128
|
+
let scrollData = (result.Items || []).map(unmarshall).filter(s => {
|
|
129
|
+
const timestamp = new Date(s.timestamp)
|
|
130
|
+
return timestamp >= startDate && timestamp <= endDate
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
if (path) {
|
|
134
|
+
scrollData = scrollData.filter(s => s.path === path || s.path === decodeURIComponent(path))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (device && device !== 'all') {
|
|
138
|
+
scrollData = scrollData.filter(s => s.deviceType === device)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const depthBuckets: Record<number, { sessions: number; totalTime: number }> = {}
|
|
142
|
+
let totalSessions = 0
|
|
143
|
+
let totalMaxDepth = 0
|
|
144
|
+
|
|
145
|
+
for (const scroll of scrollData) {
|
|
146
|
+
totalSessions++
|
|
147
|
+
totalMaxDepth += scroll.maxScrollDepth || 0
|
|
148
|
+
|
|
149
|
+
const depths = scroll.scrollDepths || {}
|
|
150
|
+
for (const [depth, time] of Object.entries(depths)) {
|
|
151
|
+
const depthNum = Number(depth)
|
|
152
|
+
if (!depthBuckets[depthNum]) {
|
|
153
|
+
depthBuckets[depthNum] = { sessions: 0, totalTime: 0 }
|
|
154
|
+
}
|
|
155
|
+
depthBuckets[depthNum].sessions++
|
|
156
|
+
depthBuckets[depthNum].totalTime += time as number
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const scrollDepths = Object.entries(depthBuckets)
|
|
161
|
+
.map(([depth, data]) => ({
|
|
162
|
+
depth: Number(depth),
|
|
163
|
+
reachPercentage: totalSessions > 0 ? Math.round((data.sessions / totalSessions) * 100) : 0,
|
|
164
|
+
avgTimeMs: data.sessions > 0 ? Math.round(data.totalTime / data.sessions) : 0,
|
|
165
|
+
sessions: data.sessions,
|
|
166
|
+
}))
|
|
167
|
+
.sort((a, b) => a.depth - b.depth)
|
|
168
|
+
|
|
169
|
+
const avgMaxDepth = totalSessions > 0 ? Math.round(totalMaxDepth / totalSessions) : 0
|
|
170
|
+
|
|
171
|
+
return jsonResponse({
|
|
172
|
+
scrollDepths,
|
|
173
|
+
avgMaxDepth,
|
|
174
|
+
totalSessions,
|
|
175
|
+
dateRange: {
|
|
176
|
+
start: startDate.toISOString(),
|
|
177
|
+
end: endDate.toISOString(),
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
console.error('Heatmap scroll error:', error)
|
|
183
|
+
return errorResponse('Failed to fetch heatmap scroll data')
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* GET /api/sites/{siteId}/heatmap/pages
|
|
189
|
+
*/
|
|
190
|
+
export async function handleGetHeatmapPages(request: Request, siteId: string): Promise<Response> {
|
|
191
|
+
try {
|
|
192
|
+
const query = getQueryParams(request)
|
|
193
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
194
|
+
const limit = Math.min(Number(query.limit) || 20, 100)
|
|
195
|
+
|
|
196
|
+
const clicksResult = await dynamodb.query({
|
|
197
|
+
TableName: TABLE_NAME,
|
|
198
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
199
|
+
ExpressionAttributeValues: {
|
|
200
|
+
':pk': { S: `SITE#${siteId}` },
|
|
201
|
+
':start': { S: `HMCLICK#${startDate.toISOString()}` },
|
|
202
|
+
':end': { S: `HMCLICK#${endDate.toISOString()}` },
|
|
203
|
+
},
|
|
204
|
+
}) as { Items?: any[] }
|
|
205
|
+
|
|
206
|
+
const scrollResult = await dynamodb.query({
|
|
207
|
+
TableName: TABLE_NAME,
|
|
208
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
|
|
209
|
+
ExpressionAttributeValues: {
|
|
210
|
+
':pk': { S: `SITE#${siteId}` },
|
|
211
|
+
':prefix': { S: 'HMSCROLL#' },
|
|
212
|
+
},
|
|
213
|
+
}) as { Items?: any[] }
|
|
214
|
+
|
|
215
|
+
const clicks = (clicksResult.Items || []).map(unmarshall)
|
|
216
|
+
const scrolls = (scrollResult.Items || []).map(unmarshall).filter(s => {
|
|
217
|
+
const timestamp = new Date(s.timestamp)
|
|
218
|
+
return timestamp >= startDate && timestamp <= endDate
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const pageStats: Record<string, {
|
|
222
|
+
clicks: number
|
|
223
|
+
scrollSessions: number
|
|
224
|
+
avgScrollDepth: number
|
|
225
|
+
totalScrollDepth: number
|
|
226
|
+
}> = {}
|
|
227
|
+
|
|
228
|
+
for (const click of clicks) {
|
|
229
|
+
const path = click.path
|
|
230
|
+
if (!pageStats[path]) {
|
|
231
|
+
pageStats[path] = { clicks: 0, scrollSessions: 0, avgScrollDepth: 0, totalScrollDepth: 0 }
|
|
232
|
+
}
|
|
233
|
+
pageStats[path].clicks++
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const scroll of scrolls) {
|
|
237
|
+
const path = scroll.path
|
|
238
|
+
if (!pageStats[path]) {
|
|
239
|
+
pageStats[path] = { clicks: 0, scrollSessions: 0, avgScrollDepth: 0, totalScrollDepth: 0 }
|
|
240
|
+
}
|
|
241
|
+
pageStats[path].scrollSessions++
|
|
242
|
+
pageStats[path].totalScrollDepth += scroll.maxScrollDepth || 0
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const pages = Object.entries(pageStats)
|
|
246
|
+
.map(([path, stats]) => ({
|
|
247
|
+
path,
|
|
248
|
+
clicks: stats.clicks,
|
|
249
|
+
scrollSessions: stats.scrollSessions,
|
|
250
|
+
avgScrollDepth: stats.scrollSessions > 0
|
|
251
|
+
? Math.round(stats.totalScrollDepth / stats.scrollSessions)
|
|
252
|
+
: 0,
|
|
253
|
+
hasClickData: stats.clicks > 0,
|
|
254
|
+
hasScrollData: stats.scrollSessions > 0,
|
|
255
|
+
}))
|
|
256
|
+
.sort((a, b) => (b.clicks + b.scrollSessions) - (a.clicks + a.scrollSessions))
|
|
257
|
+
.slice(0, limit)
|
|
258
|
+
|
|
259
|
+
return jsonResponse({
|
|
260
|
+
pages,
|
|
261
|
+
totalPages: Object.keys(pageStats).length,
|
|
262
|
+
dateRange: {
|
|
263
|
+
start: startDate.toISOString(),
|
|
264
|
+
end: endDate.toISOString(),
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
console.error('Heatmap pages error:', error)
|
|
270
|
+
return errorResponse('Failed to fetch heatmap pages')
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handlers barrel export
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Stats & Analytics
|
|
6
|
+
export * from './stats'
|
|
7
|
+
|
|
8
|
+
// Goals
|
|
9
|
+
export * from './goals'
|
|
10
|
+
|
|
11
|
+
// Sessions
|
|
12
|
+
export * from './sessions'
|
|
13
|
+
|
|
14
|
+
// Heatmaps
|
|
15
|
+
export * from './heatmaps'
|
|
16
|
+
|
|
17
|
+
// Errors
|
|
18
|
+
|
|
19
|
+
// Performance & Vitals
|
|
20
|
+
export * from './performance'
|
|
21
|
+
|
|
22
|
+
// Funnels
|
|
23
|
+
export * from './funnels'
|
|
24
|
+
|
|
25
|
+
// Annotations
|
|
26
|
+
export * from './annotations'
|
|
27
|
+
|
|
28
|
+
// Experiments
|
|
29
|
+
export * from './experiments'
|
|
30
|
+
|
|
31
|
+
// Alerts
|
|
32
|
+
export * from './alerts'
|
|
33
|
+
|
|
34
|
+
// API Keys
|
|
35
|
+
export * from './api-keys'
|
|
36
|
+
|
|
37
|
+
// Uptime
|
|
38
|
+
export * from './uptime'
|
|
39
|
+
|
|
40
|
+
// Webhooks
|
|
41
|
+
export * from './webhooks'
|
|
42
|
+
|
|
43
|
+
// Team
|
|
44
|
+
export * from './team'
|
|
45
|
+
|
|
46
|
+
// Data (export, GDPR, retention, insights)
|
|
47
|
+
export * from './data'
|
|
48
|
+
|
|
49
|
+
// Sharing
|
|
50
|
+
export * from './sharing'
|
|
51
|
+
|
|
52
|
+
// Collection
|
|
53
|
+
export * from './collect'
|
|
54
|
+
|
|
55
|
+
// Misc (health, sites, revenue)
|
|
56
|
+
export * from './misc'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-through cache for expensive dashboard read endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Each cached endpoint currently re-scans raw DynamoDB events and aggregates
|
|
5
|
+
* them in memory on every request. This wraps such a handler so the computed
|
|
6
|
+
* JSON response is cached for a short TTL keyed by (path + query), letting
|
|
7
|
+
* repeated polls (e.g. the summary card every ~15s) reuse the result instead of
|
|
8
|
+
* re-scanning.
|
|
9
|
+
*
|
|
10
|
+
* Safety: on by default with short TTLs (#96) — set ANALYTICS_READ_CACHE=false
|
|
11
|
+
* to opt out. Only caches successful (200) responses, and ALWAYS falls back to
|
|
12
|
+
* the real handler on a cache miss — a missing/expired entry never yields wrong
|
|
13
|
+
* or empty data. The cache is the existing in-memory generic cache (per warm
|
|
14
|
+
* process; resets on cold start). Freshness-critical endpoints (realtime,
|
|
15
|
+
* live view, error triage statuses) are deliberately not wrapped.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getFromCache, setInCache } from '../../utils/cache'
|
|
19
|
+
|
|
20
|
+
interface CachedResponse {
|
|
21
|
+
body: string
|
|
22
|
+
status: number
|
|
23
|
+
headers: Record<string, string>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether the read-through cache is enabled (default on; opt out with ANALYTICS_READ_CACHE=false). */
|
|
27
|
+
export function readCacheEnabled(): boolean {
|
|
28
|
+
return process.env.ANALYTICS_READ_CACHE !== 'false'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wrap a read handler with the read-through cache. When disabled, this is a
|
|
33
|
+
* transparent pass-through to `compute`.
|
|
34
|
+
*/
|
|
35
|
+
export async function withReadCache(
|
|
36
|
+
request: Request,
|
|
37
|
+
name: string,
|
|
38
|
+
ttlMs: number,
|
|
39
|
+
compute: () => Promise<Response>,
|
|
40
|
+
): Promise<Response> {
|
|
41
|
+
if (!readCacheEnabled()) return compute()
|
|
42
|
+
|
|
43
|
+
const url = new URL(request.url)
|
|
44
|
+
const key = `read:${name}:${url.pathname}:${url.search}`
|
|
45
|
+
|
|
46
|
+
const hit = getFromCache<CachedResponse>(key)
|
|
47
|
+
if (hit) {
|
|
48
|
+
return new Response(hit.body, { status: hit.status, headers: hit.headers })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const res = await compute()
|
|
52
|
+
// Only cache successful responses; errors must re-run next time.
|
|
53
|
+
if (res.status === 200) {
|
|
54
|
+
try {
|
|
55
|
+
const body = await res.clone().text()
|
|
56
|
+
const headers = Object.fromEntries(res.headers.entries())
|
|
57
|
+
setInCache<CachedResponse>(key, { body, status: res.status, headers }, ttlMs)
|
|
58
|
+
} catch {
|
|
59
|
+
// Caching is best-effort; never fail the request over a cache write.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return res
|
|
63
|
+
}
|