@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,1368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statistics handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { dynamodb, querySessionItemsInRange, queryAllItems, TABLE_NAME, unmarshall } from '../lib/dynamodb'
|
|
6
|
+
import { parseDateRange, formatDuration } from '../utils/date'
|
|
7
|
+
import { jsonResponse, errorResponse } from '../utils/response'
|
|
8
|
+
import { countryCodeOf, countryFlagEmoji, getReferrerSourceChannel } from '../utils/geolocation'
|
|
9
|
+
import { parseFilters, matchesFilters, hasFilters } from '../utils/filters'
|
|
10
|
+
import { getQueryParams } from '../../deploy/lambda-adapter'
|
|
11
|
+
import { readDimRollupPrefix, type DayDimRollup, readDayRollups, fullyCoveredDays, isSettledDay } from '../lib/rollups'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* GET /api/sites/{siteId}/stats
|
|
15
|
+
*/
|
|
16
|
+
export async function handleGetStats(request: Request, siteId: string): Promise<Response> {
|
|
17
|
+
try {
|
|
18
|
+
const query = getQueryParams(request)
|
|
19
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
20
|
+
const startDateStr = startDate.toISOString().slice(0, 10)
|
|
21
|
+
const endDateStr = endDate.toISOString().slice(0, 10)
|
|
22
|
+
const filters = parseFilters(query)
|
|
23
|
+
|
|
24
|
+
// Pre-aggregation (#94): without filters, complete past days are served
|
|
25
|
+
// from ROLLUP#DAY# items and raw events are touched only for the live
|
|
26
|
+
// remainder (today / un-rolled days). Filters can't be answered from
|
|
27
|
+
// rollups, so a filtered request takes the raw path.
|
|
28
|
+
const rolled: { day: string, views: number, visitors: number, sessions: number, bounces: number, totalDuration: number, events: number }[] = []
|
|
29
|
+
let rawWindowStart = startDate
|
|
30
|
+
if (!hasFilters(filters)) {
|
|
31
|
+
// Serve only SETTLED days from rollups; the most-recent complete day stays
|
|
32
|
+
// on the raw path so late-arriving events aren't dropped (#162).
|
|
33
|
+
const eligible = fullyCoveredDays(startDate, endDate).filter(d => isSettledDay(d))
|
|
34
|
+
if (eligible.length > 0) {
|
|
35
|
+
const rollups = await readDayRollups(siteId, eligible[0], eligible[eligible.length - 1])
|
|
36
|
+
// Use the contiguous covered prefix so one raw range query handles the
|
|
37
|
+
// rest; a mid-range gap simply ends the prefix and stays raw.
|
|
38
|
+
for (const day of eligible) {
|
|
39
|
+
const r = rollups.get(day)
|
|
40
|
+
if (!r)
|
|
41
|
+
break
|
|
42
|
+
rolled.push(r)
|
|
43
|
+
}
|
|
44
|
+
if (rolled.length > 0) {
|
|
45
|
+
const next = new Date(`${rolled[rolled.length - 1].day}T00:00:00.000Z`)
|
|
46
|
+
next.setUTCDate(next.getUTCDate() + 1)
|
|
47
|
+
rawWindowStart = next
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Query pageviews for the (remaining) raw window
|
|
53
|
+
const pageviewsResult = rawWindowStart <= endDate
|
|
54
|
+
? await queryAllItems({
|
|
55
|
+
TableName: TABLE_NAME,
|
|
56
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
57
|
+
ExpressionAttributeValues: {
|
|
58
|
+
':pk': { S: `SITE#${siteId}` },
|
|
59
|
+
':start': { S: `PAGEVIEW#${rawWindowStart.toISOString()}` },
|
|
60
|
+
':end': { S: `PAGEVIEW#${endDate.toISOString()}` },
|
|
61
|
+
},
|
|
62
|
+
}) as { Items?: any[], Count?: number }
|
|
63
|
+
: { Items: [] }
|
|
64
|
+
|
|
65
|
+
// Query sessions for the raw window via the time-keyed GSI entry (#171).
|
|
66
|
+
const sessionsResult = rawWindowStart <= endDate
|
|
67
|
+
? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[], Count?: number }
|
|
68
|
+
: { Items: [] }
|
|
69
|
+
|
|
70
|
+
// Query realtime visitors (last 2 minutes)
|
|
71
|
+
const realtimeCutoff = new Date(Date.now() - 2 * 60 * 1000)
|
|
72
|
+
const realtimeResult = await queryAllItems({
|
|
73
|
+
TableName: TABLE_NAME,
|
|
74
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
75
|
+
ExpressionAttributeValues: {
|
|
76
|
+
':pk': { S: `SITE#${siteId}` },
|
|
77
|
+
':start': { S: `PAGEVIEW#${realtimeCutoff.toISOString()}` },
|
|
78
|
+
':end': { S: 'PAGEVIEW#~' },
|
|
79
|
+
},
|
|
80
|
+
}) as { Items?: any[] }
|
|
81
|
+
// Realtime honors the active filters like every other number (#129).
|
|
82
|
+
const realtimePageviews = (realtimeResult.Items || []).map(unmarshall).filter((pv: any) => matchesFilters(pv, filters))
|
|
83
|
+
const realtimeVisitors = new Set(realtimePageviews.map(pv => pv.visitorId)).size
|
|
84
|
+
|
|
85
|
+
const pageviews = (pageviewsResult.Items || []).map(unmarshall).filter((pv: any) => matchesFilters(pv, filters))
|
|
86
|
+
const sessions = (sessionsResult.Items || []).map(unmarshall).filter((s) => {
|
|
87
|
+
const sessionStart = new Date(s.startedAt)
|
|
88
|
+
return sessionStart >= rawWindowStart && sessionStart <= endDate && matchesFilters(s, filters)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Raw-window stats. Uniques are counted PER UTC DAY to match the rolled
|
|
92
|
+
// days' semantics (#146): the raw window used to dedupe across its whole
|
|
93
|
+
// span, so a visitor spanning two raw days counted once there but twice
|
|
94
|
+
// after those days rolled up — the same historical query changed answers
|
|
95
|
+
// day to day. Per-day bucketing makes raw + rolled consistent (Fathom's
|
|
96
|
+
// "people = sum of daily uniques" semantics).
|
|
97
|
+
const rawDailyVisitors = new Map<string, Set<string>>()
|
|
98
|
+
for (const pv of pageviews) {
|
|
99
|
+
const day = String(pv.timestamp).slice(0, 10)
|
|
100
|
+
let set = rawDailyVisitors.get(day)
|
|
101
|
+
if (!set) { set = new Set(); rawDailyVisitors.set(day, set) }
|
|
102
|
+
set.add(pv.visitorId)
|
|
103
|
+
}
|
|
104
|
+
const rawVisitors = [...rawDailyVisitors.values()].reduce((sum, set) => sum + set.size, 0)
|
|
105
|
+
const rawViews = pageviews.length
|
|
106
|
+
const rawSessions = sessions.length
|
|
107
|
+
const rawBounces = sessions.filter(s => s.isBounce).length
|
|
108
|
+
// Prefer real engaged time from departure pings (#167); fall back to the
|
|
109
|
+
// last-hit-minus-first-hit duration for sessions without pings.
|
|
110
|
+
const rawDuration = sessions.reduce((sum, s) => sum + (s.activeTime > 0 ? s.activeTime : (s.duration || 0)), 0)
|
|
111
|
+
const rawEvents = sessions.reduce((sum, s) => sum + (s.eventCount || 0), 0)
|
|
112
|
+
|
|
113
|
+
// Combine with rollup days. Note: `people` across rolled days sums daily
|
|
114
|
+
// uniques (a returning visitor counts once per day) — Fathom semantics.
|
|
115
|
+
const totalViews = rawViews + rolled.reduce((sum, r) => sum + r.views, 0)
|
|
116
|
+
const uniqueVisitors = rawVisitors + rolled.reduce((sum, r) => sum + r.visitors, 0)
|
|
117
|
+
const totalSessions = rawSessions + rolled.reduce((sum, r) => sum + r.sessions, 0)
|
|
118
|
+
const bounces = rawBounces + rolled.reduce((sum, r) => sum + r.bounces, 0)
|
|
119
|
+
const totalDuration = rawDuration + rolled.reduce((sum, r) => sum + r.totalDuration, 0)
|
|
120
|
+
const totalEvents = rawEvents + rolled.reduce((sum, r) => sum + r.events, 0)
|
|
121
|
+
const bounceRate = totalSessions > 0 ? Math.round((bounces / totalSessions) * 100) : 0
|
|
122
|
+
const avgDuration = totalSessions > 0 ? Math.round(totalDuration / totalSessions) : 0
|
|
123
|
+
|
|
124
|
+
return jsonResponse({
|
|
125
|
+
realtime: realtimeVisitors,
|
|
126
|
+
people: uniqueVisitors,
|
|
127
|
+
views: totalViews,
|
|
128
|
+
avgTime: formatDuration(avgDuration),
|
|
129
|
+
avgTimeMs: avgDuration,
|
|
130
|
+
bounceRate,
|
|
131
|
+
events: totalEvents,
|
|
132
|
+
sessions: totalSessions,
|
|
133
|
+
dateRange: { start: startDateStr, end: endDateStr },
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
console.error('Stats error:', error)
|
|
138
|
+
return errorResponse('Failed to fetch stats')
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* GET /api/sites/{siteId}/realtime
|
|
144
|
+
*/
|
|
145
|
+
export async function handleGetRealtime(request: Request, siteId: string): Promise<Response> {
|
|
146
|
+
try {
|
|
147
|
+
const query = getQueryParams(request)
|
|
148
|
+
const minutes = Number(query.minutes) || 2
|
|
149
|
+
const cutoff = new Date(Date.now() - minutes * 60 * 1000)
|
|
150
|
+
|
|
151
|
+
// Query recent pageviews
|
|
152
|
+
const result = await queryAllItems({
|
|
153
|
+
TableName: TABLE_NAME,
|
|
154
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
155
|
+
ExpressionAttributeValues: {
|
|
156
|
+
':pk': { S: `SITE#${siteId}` },
|
|
157
|
+
':start': { S: `PAGEVIEW#${cutoff.toISOString()}` },
|
|
158
|
+
':end': { S: 'PAGEVIEW#~' },
|
|
159
|
+
},
|
|
160
|
+
}) as { Items?: any[] }
|
|
161
|
+
|
|
162
|
+
const filters = parseFilters(getQueryParams(request))
|
|
163
|
+
const pageviews = (result.Items || []).map(unmarshall).filter((pv: any) => matchesFilters(pv, filters))
|
|
164
|
+
const uniqueVisitors = new Set(pageviews.map(pv => pv.visitorId)).size
|
|
165
|
+
|
|
166
|
+
// Get active pages
|
|
167
|
+
const pageCounts: Record<string, number> = {}
|
|
168
|
+
for (const pv of pageviews) {
|
|
169
|
+
pageCounts[pv.path] = (pageCounts[pv.path] || 0) + 1
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const topActivePages = Object.entries(pageCounts)
|
|
173
|
+
.map(([path, count]) => ({ name: path, value: count, percentage: 0 }))
|
|
174
|
+
.sort((a, b) => b.value - a.value)
|
|
175
|
+
.slice(0, 10)
|
|
176
|
+
|
|
177
|
+
const total = topActivePages.reduce((sum, p) => sum + p.value, 0)
|
|
178
|
+
topActivePages.forEach(p => {
|
|
179
|
+
p.percentage = total > 0 ? Math.round((p.value / total) * 100) : 0
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
return jsonResponse({
|
|
183
|
+
currentVisitors: uniqueVisitors,
|
|
184
|
+
pageViewsLastHour: pageviews.length,
|
|
185
|
+
topActivePages,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
console.error('Realtime error:', error)
|
|
190
|
+
return errorResponse('Failed to fetch realtime data')
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* GET /api/sites/{siteId}/pages
|
|
196
|
+
*/
|
|
197
|
+
export async function handleGetPages(request: Request, siteId: string): Promise<Response> {
|
|
198
|
+
try {
|
|
199
|
+
const query = getQueryParams(request)
|
|
200
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
201
|
+
const limit = Math.min(Number(query.limit) || 10, 100)
|
|
202
|
+
|
|
203
|
+
// Rolled prefix (#172): settled days come from ROLLUP#DIMS items — they
|
|
204
|
+
// survive the raw TTL, so this report works beyond 30 days ("All Time").
|
|
205
|
+
const { days: rolled, rawWindowStart } = await readDimRollupPrefix(siteId, startDate, endDate)
|
|
206
|
+
|
|
207
|
+
// Query raw pageviews only for the live tail
|
|
208
|
+
const result = rawWindowStart <= endDate
|
|
209
|
+
? await queryAllItems({
|
|
210
|
+
TableName: TABLE_NAME,
|
|
211
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
212
|
+
ExpressionAttributeValues: {
|
|
213
|
+
':pk': { S: `SITE#${siteId}` },
|
|
214
|
+
':start': { S: `PAGEVIEW#${rawWindowStart.toISOString()}` },
|
|
215
|
+
':end': { S: `PAGEVIEW#${endDate.toISOString()}` },
|
|
216
|
+
},
|
|
217
|
+
}) as { Items?: any[] }
|
|
218
|
+
: { Items: [] }
|
|
219
|
+
|
|
220
|
+
const pageviews = (result.Items || []).map(unmarshall)
|
|
221
|
+
|
|
222
|
+
// Hostname for building page links: first raw pageview, else the site's
|
|
223
|
+
// configured domain (a fully rolled-up range has no raw rows to read).
|
|
224
|
+
let siteHostname = pageviews.length > 0 ? pageviews[0].hostname : null
|
|
225
|
+
if (!siteHostname) {
|
|
226
|
+
const siteRes = await dynamodb.getItem({
|
|
227
|
+
TableName: TABLE_NAME,
|
|
228
|
+
Key: { pk: { S: 'SITES' }, sk: { S: `SITE#${siteId}` } },
|
|
229
|
+
}).catch(() => null)
|
|
230
|
+
const site = siteRes?.Item ? unmarshall(siteRes.Item) : null
|
|
231
|
+
siteHostname = Array.isArray(site?.domains) && site.domains.length > 0 ? site.domains[0] : null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Aggregate by path
|
|
235
|
+
const pageStats: Record<string, { views: number; visitors: Set<string>; entries: number }> = {}
|
|
236
|
+
for (const pv of pageviews) {
|
|
237
|
+
if (!pageStats[pv.path]) {
|
|
238
|
+
pageStats[pv.path] = { views: 0, visitors: new Set(), entries: 0 }
|
|
239
|
+
}
|
|
240
|
+
pageStats[pv.path].views++
|
|
241
|
+
pageStats[pv.path].visitors.add(pv.visitorId)
|
|
242
|
+
if (pv.isUnique) pageStats[pv.path].entries++
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Merge the rolled prefix. Visitors sum daily uniques (Fathom semantics,
|
|
246
|
+
// consistent with the scalar rollups, #146).
|
|
247
|
+
const merged: Record<string, { views: number, visitors: number, entries: number }> = {}
|
|
248
|
+
for (const [path, stats] of Object.entries(pageStats))
|
|
249
|
+
merged[path] = { views: stats.views, visitors: stats.visitors.size, entries: stats.entries }
|
|
250
|
+
for (const dayR of rolled) {
|
|
251
|
+
for (const [path, cell] of Object.entries(dayR.pages || {})) {
|
|
252
|
+
const m = (merged[path] ||= { views: 0, visitors: 0, entries: 0 })
|
|
253
|
+
m.views += cell.w
|
|
254
|
+
m.visitors += cell.v
|
|
255
|
+
m.entries += cell.e
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const pages = Object.entries(merged)
|
|
260
|
+
.map(([path, stats]) => ({
|
|
261
|
+
path,
|
|
262
|
+
views: stats.views,
|
|
263
|
+
visitors: stats.visitors,
|
|
264
|
+
entries: stats.entries,
|
|
265
|
+
}))
|
|
266
|
+
.sort((a, b) => b.views - a.views)
|
|
267
|
+
.slice(0, limit)
|
|
268
|
+
|
|
269
|
+
return jsonResponse({ pages, hostname: siteHostname })
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
console.error('Pages error:', error)
|
|
273
|
+
return errorResponse('Failed to fetch pages')
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* GET /api/sites/{siteId}/referrers
|
|
279
|
+
*/
|
|
280
|
+
export async function handleGetReferrers(request: Request, siteId: string): Promise<Response> {
|
|
281
|
+
try {
|
|
282
|
+
const query = getQueryParams(request)
|
|
283
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
284
|
+
const limit = Math.min(Number(query.limit) || 10, 100)
|
|
285
|
+
|
|
286
|
+
const filters = parseFilters(query)
|
|
287
|
+
// Rolled prefix (#172): settled days come from ROLLUP#DIMS items — they
|
|
288
|
+
// survive the raw TTL, so this report works beyond 30 days ("All Time").
|
|
289
|
+
// Filtered queries can't be answered from aggregates and stay raw-only.
|
|
290
|
+
const { days: rolled, rawWindowStart } = hasFilters(filters)
|
|
291
|
+
? { days: [] as DayDimRollup[], rawWindowStart: startDate }
|
|
292
|
+
: await readDimRollupPrefix(siteId, startDate, endDate)
|
|
293
|
+
|
|
294
|
+
// Query sessions for the live tail
|
|
295
|
+
const result = rawWindowStart <= endDate
|
|
296
|
+
? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
|
|
297
|
+
: { Items: [] }
|
|
298
|
+
|
|
299
|
+
const sessions = (result.Items || []).map(unmarshall).filter(s => {
|
|
300
|
+
const sessionStart = new Date(s.startedAt)
|
|
301
|
+
return sessionStart >= startDate && sessionStart <= endDate && matchesFilters(s, filters)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// Aggregate by referrer source (channels derive from the merged sources).
|
|
305
|
+
const referrerStats: Record<string, { visitors: Set<string>; views: number }> = {}
|
|
306
|
+
for (const s of sessions) {
|
|
307
|
+
const source = s.referrerSource || 'Direct'
|
|
308
|
+
if (!referrerStats[source]) {
|
|
309
|
+
referrerStats[source] = { visitors: new Set(), views: 0 }
|
|
310
|
+
}
|
|
311
|
+
referrerStats[source].visitors.add(s.visitorId)
|
|
312
|
+
referrerStats[source].views += s.pageViewCount || 1
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Merge the rolled prefix (daily-unique sums, #146 semantics).
|
|
316
|
+
const merged: Record<string, { visitors: number, views: number }> = {}
|
|
317
|
+
for (const [source, stats] of Object.entries(referrerStats))
|
|
318
|
+
merged[source] = { visitors: stats.visitors.size, views: stats.views }
|
|
319
|
+
for (const dayR of rolled) {
|
|
320
|
+
for (const [source, cell] of Object.entries(dayR.sources || {})) {
|
|
321
|
+
const m = (merged[source] ||= { visitors: 0, views: 0 })
|
|
322
|
+
m.visitors += cell.v
|
|
323
|
+
m.views += cell.w
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const mergedChannels: Record<string, { visitors: number, views: number }> = {}
|
|
327
|
+
for (const [source, stats] of Object.entries(merged)) {
|
|
328
|
+
const channel = getReferrerSourceChannel(source)
|
|
329
|
+
const m = (mergedChannels[channel] ||= { visitors: 0, views: 0 })
|
|
330
|
+
m.visitors += stats.visitors
|
|
331
|
+
m.views += stats.views
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const referrers = Object.entries(merged)
|
|
335
|
+
.map(([source, stats]) => ({
|
|
336
|
+
source,
|
|
337
|
+
channel: getReferrerSourceChannel(source),
|
|
338
|
+
visitors: stats.visitors,
|
|
339
|
+
views: stats.views,
|
|
340
|
+
}))
|
|
341
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
342
|
+
.slice(0, limit)
|
|
343
|
+
|
|
344
|
+
const byChannel = Object.entries(mergedChannels)
|
|
345
|
+
.map(([channel, stats]) => ({
|
|
346
|
+
channel,
|
|
347
|
+
visitors: stats.visitors,
|
|
348
|
+
views: stats.views,
|
|
349
|
+
}))
|
|
350
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
351
|
+
|
|
352
|
+
return jsonResponse({ referrers, byChannel })
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
console.error('Referrers error:', error)
|
|
356
|
+
return errorResponse('Failed to fetch referrers')
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* GET /api/sites/{siteId}/devices
|
|
362
|
+
*/
|
|
363
|
+
export async function handleGetDevices(request: Request, siteId: string): Promise<Response> {
|
|
364
|
+
try {
|
|
365
|
+
const query = getQueryParams(request)
|
|
366
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
367
|
+
|
|
368
|
+
const filters = parseFilters(query)
|
|
369
|
+
// Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
|
|
370
|
+
// Filtered queries stay raw-only.
|
|
371
|
+
const { days: rolled, rawWindowStart } = hasFilters(filters)
|
|
372
|
+
? { days: [] as DayDimRollup[], rawWindowStart: startDate }
|
|
373
|
+
: await readDimRollupPrefix(siteId, startDate, endDate)
|
|
374
|
+
|
|
375
|
+
const result = rawWindowStart <= endDate
|
|
376
|
+
? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
|
|
377
|
+
: { Items: [] }
|
|
378
|
+
|
|
379
|
+
const sessions = (result.Items || []).map(unmarshall).filter(s => {
|
|
380
|
+
const sessionStart = new Date(s.startedAt)
|
|
381
|
+
return sessionStart >= startDate && sessionStart <= endDate && matchesFilters(s, filters)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// Aggregate by device type
|
|
385
|
+
const deviceStats: Record<string, Set<string>> = {}
|
|
386
|
+
const osStats: Record<string, Set<string>> = {}
|
|
387
|
+
|
|
388
|
+
for (const s of sessions) {
|
|
389
|
+
const device = s.deviceType || 'unknown'
|
|
390
|
+
const os = s.os || 'Unknown'
|
|
391
|
+
|
|
392
|
+
if (!deviceStats[device]) deviceStats[device] = new Set()
|
|
393
|
+
deviceStats[device].add(s.visitorId)
|
|
394
|
+
|
|
395
|
+
if (!osStats[os]) osStats[os] = new Set()
|
|
396
|
+
osStats[os].add(s.visitorId)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Merge the rolled prefix (daily-unique sums); percentages come from the
|
|
400
|
+
// merged dimension totals.
|
|
401
|
+
const mergedDevices: Record<string, number> = {}
|
|
402
|
+
for (const [type, visitors] of Object.entries(deviceStats)) mergedDevices[type] = visitors.size
|
|
403
|
+
const mergedOs: Record<string, number> = {}
|
|
404
|
+
for (const [name, visitors] of Object.entries(osStats)) mergedOs[name] = visitors.size
|
|
405
|
+
for (const dayR of rolled) {
|
|
406
|
+
for (const [type, cell] of Object.entries(dayR.devices || {}))
|
|
407
|
+
mergedDevices[type] = (mergedDevices[type] || 0) + cell.v
|
|
408
|
+
for (const [name, cell] of Object.entries(dayR.os || {}))
|
|
409
|
+
mergedOs[name] = (mergedOs[name] || 0) + cell.v
|
|
410
|
+
}
|
|
411
|
+
const totalVisitors = Object.values(mergedDevices).reduce((a, b) => a + b, 0)
|
|
412
|
+
|
|
413
|
+
const deviceTypes = Object.entries(mergedDevices)
|
|
414
|
+
.map(([type, visitors]) => ({
|
|
415
|
+
type: type.charAt(0).toUpperCase() + type.slice(1),
|
|
416
|
+
visitors,
|
|
417
|
+
percentage: totalVisitors > 0 ? Math.round((visitors / totalVisitors) * 100) : 0,
|
|
418
|
+
}))
|
|
419
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
420
|
+
|
|
421
|
+
const osTotal = Object.values(mergedOs).reduce((a, b) => a + b, 0)
|
|
422
|
+
const operatingSystems = Object.entries(mergedOs)
|
|
423
|
+
.map(([name, visitors]) => ({
|
|
424
|
+
name,
|
|
425
|
+
visitors,
|
|
426
|
+
percentage: osTotal > 0 ? Math.round((visitors / osTotal) * 100) : 0,
|
|
427
|
+
}))
|
|
428
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
429
|
+
|
|
430
|
+
// `devices` is the canonical key (the analytics store's sectionMap and the
|
|
431
|
+
// detail page read data.devices — with only deviceTypes/operatingSystems the
|
|
432
|
+
// panel always showed "No device data"). Legacy keys kept for back-compat.
|
|
433
|
+
return jsonResponse({ devices: deviceTypes, deviceTypes, operatingSystems })
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
console.error('Devices error:', error)
|
|
437
|
+
return errorResponse('Failed to fetch devices')
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* GET /api/sites/{siteId}/browsers
|
|
443
|
+
*/
|
|
444
|
+
export async function handleGetBrowsers(request: Request, siteId: string): Promise<Response> {
|
|
445
|
+
try {
|
|
446
|
+
const query = getQueryParams(request)
|
|
447
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
448
|
+
const limit = Math.min(Number(query.limit) || 10, 100)
|
|
449
|
+
|
|
450
|
+
const filters = parseFilters(query)
|
|
451
|
+
// Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
|
|
452
|
+
// Filtered queries stay raw-only.
|
|
453
|
+
const { days: rolled, rawWindowStart } = hasFilters(filters)
|
|
454
|
+
? { days: [] as DayDimRollup[], rawWindowStart: startDate }
|
|
455
|
+
: await readDimRollupPrefix(siteId, startDate, endDate)
|
|
456
|
+
|
|
457
|
+
const result = rawWindowStart <= endDate
|
|
458
|
+
? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
|
|
459
|
+
: { Items: [] }
|
|
460
|
+
|
|
461
|
+
const sessions = (result.Items || []).map(unmarshall).filter(s => {
|
|
462
|
+
const sessionStart = new Date(s.startedAt)
|
|
463
|
+
return sessionStart >= startDate && sessionStart <= endDate && matchesFilters(s, filters)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// Aggregate by browser
|
|
467
|
+
const browserStats: Record<string, Set<string>> = {}
|
|
468
|
+
for (const s of sessions) {
|
|
469
|
+
const browser = s.browser || 'Unknown'
|
|
470
|
+
if (!browserStats[browser]) browserStats[browser] = new Set()
|
|
471
|
+
browserStats[browser].add(s.visitorId)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Merge the rolled prefix (daily-unique sums).
|
|
475
|
+
const merged: Record<string, number> = {}
|
|
476
|
+
for (const [name, visitors] of Object.entries(browserStats)) merged[name] = visitors.size
|
|
477
|
+
for (const dayR of rolled) {
|
|
478
|
+
for (const [name, cell] of Object.entries(dayR.browsers || {}))
|
|
479
|
+
merged[name] = (merged[name] || 0) + cell.v
|
|
480
|
+
}
|
|
481
|
+
const totalVisitors = Object.values(merged).reduce((a, b) => a + b, 0)
|
|
482
|
+
|
|
483
|
+
const browsers = Object.entries(merged)
|
|
484
|
+
.map(([name, visitors]) => ({
|
|
485
|
+
name,
|
|
486
|
+
visitors,
|
|
487
|
+
percentage: totalVisitors > 0 ? Math.round((visitors / totalVisitors) * 100) : 0,
|
|
488
|
+
}))
|
|
489
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
490
|
+
.slice(0, limit)
|
|
491
|
+
|
|
492
|
+
return jsonResponse({ browsers })
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
console.error('Browsers error:', error)
|
|
496
|
+
return errorResponse('Failed to fetch browsers')
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* GET /api/sites/{siteId}/os — operating-system breakdown. Parsed server-side
|
|
502
|
+
* from the User-Agent at collect time (only the coarse OS name is stored).
|
|
503
|
+
*/
|
|
504
|
+
export async function handleGetOS(request: Request, siteId: string): Promise<Response> {
|
|
505
|
+
try {
|
|
506
|
+
const query = getQueryParams(request)
|
|
507
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
508
|
+
const limit = Math.min(Number(query.limit) || 10, 100)
|
|
509
|
+
|
|
510
|
+
const filters = parseFilters(query)
|
|
511
|
+
// Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
|
|
512
|
+
// Filtered queries stay raw-only.
|
|
513
|
+
const { days: rolled, rawWindowStart } = hasFilters(filters)
|
|
514
|
+
? { days: [] as DayDimRollup[], rawWindowStart: startDate }
|
|
515
|
+
: await readDimRollupPrefix(siteId, startDate, endDate)
|
|
516
|
+
|
|
517
|
+
const result = rawWindowStart <= endDate
|
|
518
|
+
? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
|
|
519
|
+
: { Items: [] }
|
|
520
|
+
|
|
521
|
+
const sessions = (result.Items || []).map(unmarshall).filter(s => {
|
|
522
|
+
const sessionStart = new Date(s.startedAt)
|
|
523
|
+
return sessionStart >= startDate && sessionStart <= endDate && matchesFilters(s, filters)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
const osStats: Record<string, Set<string>> = {}
|
|
527
|
+
for (const s of sessions) {
|
|
528
|
+
const os = s.os || 'Unknown'
|
|
529
|
+
if (!osStats[os]) osStats[os] = new Set()
|
|
530
|
+
osStats[os].add(s.visitorId)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Merge the rolled prefix (daily-unique sums).
|
|
534
|
+
const merged: Record<string, number> = {}
|
|
535
|
+
for (const [name, visitors] of Object.entries(osStats)) merged[name] = visitors.size
|
|
536
|
+
for (const dayR of rolled) {
|
|
537
|
+
for (const [name, cell] of Object.entries(dayR.os || {}))
|
|
538
|
+
merged[name] = (merged[name] || 0) + cell.v
|
|
539
|
+
}
|
|
540
|
+
const totalVisitors = Object.values(merged).reduce((a, b) => a + b, 0)
|
|
541
|
+
|
|
542
|
+
const os = Object.entries(merged)
|
|
543
|
+
.map(([name, visitors]) => ({
|
|
544
|
+
name,
|
|
545
|
+
visitors,
|
|
546
|
+
percentage: totalVisitors > 0 ? Math.round((visitors / totalVisitors) * 100) : 0,
|
|
547
|
+
}))
|
|
548
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
549
|
+
.slice(0, limit)
|
|
550
|
+
|
|
551
|
+
return jsonResponse({ os })
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
console.error('OS error:', error)
|
|
555
|
+
return errorResponse('Failed to fetch operating systems')
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* GET /api/sites/{siteId}/countries
|
|
561
|
+
*/
|
|
562
|
+
export async function handleGetCountries(request: Request, siteId: string): Promise<Response> {
|
|
563
|
+
try {
|
|
564
|
+
const query = getQueryParams(request)
|
|
565
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
566
|
+
const limit = Math.min(Number(query.limit) || 10, 100)
|
|
567
|
+
|
|
568
|
+
const filters = parseFilters(query)
|
|
569
|
+
// Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
|
|
570
|
+
// Filtered queries stay raw-only.
|
|
571
|
+
const { days: rolled, rawWindowStart } = hasFilters(filters)
|
|
572
|
+
? { days: [] as DayDimRollup[], rawWindowStart: startDate }
|
|
573
|
+
: await readDimRollupPrefix(siteId, startDate, endDate)
|
|
574
|
+
|
|
575
|
+
const result = rawWindowStart <= endDate
|
|
576
|
+
? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
|
|
577
|
+
: { Items: [] }
|
|
578
|
+
|
|
579
|
+
const sessions = (result.Items || []).map(unmarshall).filter(s => {
|
|
580
|
+
const sessionStart = new Date(s.startedAt)
|
|
581
|
+
return sessionStart >= startDate && sessionStart <= endDate && matchesFilters(s, filters)
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
// Aggregate by country. Sessions without a resolved country (pre-geo data,
|
|
585
|
+
// clients with no timezone/headers) are omitted rather than reported as an
|
|
586
|
+
// "Unknown" bucket.
|
|
587
|
+
const countryStats: Record<string, Set<string>> = {}
|
|
588
|
+
for (const s of sessions) {
|
|
589
|
+
if (!s.country) continue
|
|
590
|
+
if (!countryStats[s.country]) countryStats[s.country] = new Set()
|
|
591
|
+
countryStats[s.country].add(s.visitorId)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Merge the rolled prefix (daily-unique sums).
|
|
595
|
+
const merged: Record<string, number> = {}
|
|
596
|
+
for (const [name, visitors] of Object.entries(countryStats)) merged[name] = visitors.size
|
|
597
|
+
for (const dayR of rolled) {
|
|
598
|
+
for (const [name, cell] of Object.entries(dayR.countries || {}))
|
|
599
|
+
merged[name] = (merged[name] || 0) + cell.v
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const countries = Object.entries(merged)
|
|
603
|
+
.map(([name, visitors]) => {
|
|
604
|
+
// Emoji flag from the ISO code (Fathom/GA-style) — stored values are
|
|
605
|
+
// display names (reverse-mapped) or bare ISO codes.
|
|
606
|
+
const code = countryCodeOf(name) || ''
|
|
607
|
+
return { name, code, flag: code ? countryFlagEmoji(code) : '', visitors }
|
|
608
|
+
})
|
|
609
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
610
|
+
.slice(0, limit)
|
|
611
|
+
|
|
612
|
+
return jsonResponse({ countries })
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
console.error('Countries error:', error)
|
|
616
|
+
return errorResponse('Failed to fetch countries')
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* GET /api/sites/{siteId}/regions
|
|
622
|
+
*/
|
|
623
|
+
export async function handleGetRegions(request: Request, siteId: string): Promise<Response> {
|
|
624
|
+
try {
|
|
625
|
+
const query = getQueryParams(request)
|
|
626
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
627
|
+
const limit = Math.min(Number(query.limit) || 10, 100)
|
|
628
|
+
const filters = parseFilters(query)
|
|
629
|
+
|
|
630
|
+
// Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
|
|
631
|
+
// Filtered queries stay raw-only.
|
|
632
|
+
const { days: rolled, rawWindowStart } = hasFilters(filters)
|
|
633
|
+
? { days: [] as DayDimRollup[], rawWindowStart: startDate }
|
|
634
|
+
: await readDimRollupPrefix(siteId, startDate, endDate)
|
|
635
|
+
|
|
636
|
+
const result = rawWindowStart <= endDate
|
|
637
|
+
? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
|
|
638
|
+
: { Items: [] }
|
|
639
|
+
|
|
640
|
+
const sessions = (result.Items || []).map(unmarshall).filter(s => {
|
|
641
|
+
const sessionStart = new Date(s.startedAt)
|
|
642
|
+
if (sessionStart < startDate || sessionStart > endDate) return false
|
|
643
|
+
return matchesFilters(s, filters)
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
// Aggregate by region
|
|
647
|
+
const regionStats: Record<string, { visitors: Set<string>, country: string }> = {}
|
|
648
|
+
for (const s of sessions) {
|
|
649
|
+
const region = s.region || 'Unknown'
|
|
650
|
+
const country = s.country || 'Unknown'
|
|
651
|
+
const key = `${country}:${region}`
|
|
652
|
+
if (!regionStats[key]) regionStats[key] = { visitors: new Set(), country }
|
|
653
|
+
regionStats[key].visitors.add(s.visitorId)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Merge the rolled prefix (composite `${country}:${region}` keys).
|
|
657
|
+
const merged: Record<string, number> = {}
|
|
658
|
+
for (const [key, data] of Object.entries(regionStats)) merged[key] = data.visitors.size
|
|
659
|
+
for (const dayR of rolled) {
|
|
660
|
+
for (const [key, cell] of Object.entries(dayR.regions || {}))
|
|
661
|
+
merged[key] = (merged[key] || 0) + cell.v
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const regions = Object.entries(merged)
|
|
665
|
+
.map(([key, visitors]) => {
|
|
666
|
+
const [country, region] = key.split(':')
|
|
667
|
+
return { name: region, country, visitors }
|
|
668
|
+
})
|
|
669
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
670
|
+
.slice(0, limit)
|
|
671
|
+
|
|
672
|
+
return jsonResponse({ regions })
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
console.error('Regions error:', error)
|
|
676
|
+
return errorResponse('Failed to fetch regions')
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* GET /api/sites/{siteId}/cities
|
|
682
|
+
*/
|
|
683
|
+
export async function handleGetCities(request: Request, siteId: string): Promise<Response> {
|
|
684
|
+
try {
|
|
685
|
+
const query = getQueryParams(request)
|
|
686
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
687
|
+
const limit = Math.min(Number(query.limit) || 10, 100)
|
|
688
|
+
const filters = parseFilters(query)
|
|
689
|
+
|
|
690
|
+
// Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
|
|
691
|
+
// Filtered queries stay raw-only.
|
|
692
|
+
const { days: rolled, rawWindowStart } = hasFilters(filters)
|
|
693
|
+
? { days: [] as DayDimRollup[], rawWindowStart: startDate }
|
|
694
|
+
: await readDimRollupPrefix(siteId, startDate, endDate)
|
|
695
|
+
|
|
696
|
+
const result = rawWindowStart <= endDate
|
|
697
|
+
? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
|
|
698
|
+
: { Items: [] }
|
|
699
|
+
|
|
700
|
+
const sessions = (result.Items || []).map(unmarshall).filter(s => {
|
|
701
|
+
const sessionStart = new Date(s.startedAt)
|
|
702
|
+
if (sessionStart < startDate || sessionStart > endDate) return false
|
|
703
|
+
return matchesFilters(s, filters)
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
// Aggregate by city
|
|
707
|
+
const cityStats: Record<string, { visitors: Set<string>, country: string, region: string }> = {}
|
|
708
|
+
for (const s of sessions) {
|
|
709
|
+
const city = s.city || 'Unknown'
|
|
710
|
+
const region = s.region || 'Unknown'
|
|
711
|
+
const country = s.country || 'Unknown'
|
|
712
|
+
const key = `${country}:${region}:${city}`
|
|
713
|
+
if (!cityStats[key]) cityStats[key] = { visitors: new Set(), country, region }
|
|
714
|
+
cityStats[key].visitors.add(s.visitorId)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Merge the rolled prefix (composite `${country}:${region}:${city}` keys).
|
|
718
|
+
const merged: Record<string, number> = {}
|
|
719
|
+
for (const [key, data] of Object.entries(cityStats)) merged[key] = data.visitors.size
|
|
720
|
+
for (const dayR of rolled) {
|
|
721
|
+
for (const [key, cell] of Object.entries(dayR.cities || {}))
|
|
722
|
+
merged[key] = (merged[key] || 0) + cell.v
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const cities = Object.entries(merged)
|
|
726
|
+
.map(([key, visitors]) => {
|
|
727
|
+
const [country, region, city] = key.split(':')
|
|
728
|
+
return { name: city, country, region, visitors }
|
|
729
|
+
})
|
|
730
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
731
|
+
.slice(0, limit)
|
|
732
|
+
|
|
733
|
+
return jsonResponse({ cities })
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
console.error('Cities error:', error)
|
|
737
|
+
return errorResponse('Failed to fetch cities')
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* GET /api/sites/{siteId}/screen-sizes — viewport-width breakdown (#139).
|
|
743
|
+
* Raw-window report (screen dimensions aren't rolled up): buckets pageview
|
|
744
|
+
* screenWidth into device-class ranges with unique visitors per bucket.
|
|
745
|
+
*/
|
|
746
|
+
export async function handleGetScreenSizes(request: Request, siteId: string): Promise<Response> {
|
|
747
|
+
try {
|
|
748
|
+
const query = getQueryParams(request)
|
|
749
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
750
|
+
|
|
751
|
+
const result = await queryAllItems({
|
|
752
|
+
TableName: TABLE_NAME,
|
|
753
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
754
|
+
ExpressionAttributeValues: {
|
|
755
|
+
':pk': { S: `SITE#${siteId}` },
|
|
756
|
+
':start': { S: `PAGEVIEW#${startDate.toISOString()}` },
|
|
757
|
+
':end': { S: `PAGEVIEW#${endDate.toISOString()}~` },
|
|
758
|
+
},
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
const BUCKETS: Array<{ name: string, min: number, max: number }> = [
|
|
762
|
+
{ name: 'Mobile (< 480px)', min: 0, max: 479 },
|
|
763
|
+
{ name: 'Mobile L (480–767px)', min: 480, max: 767 },
|
|
764
|
+
{ name: 'Tablet (768–991px)', min: 768, max: 991 },
|
|
765
|
+
{ name: 'Laptop (992–1199px)', min: 992, max: 1199 },
|
|
766
|
+
{ name: 'Desktop (1200–1919px)', min: 1200, max: 1919 },
|
|
767
|
+
{ name: 'Large (≥ 1920px)', min: 1920, max: Infinity },
|
|
768
|
+
]
|
|
769
|
+
const filters = parseFilters(query)
|
|
770
|
+
const visitorsByBucket = new Map<string, Set<string>>()
|
|
771
|
+
for (const raw of (result.Items || [])) {
|
|
772
|
+
const pv = unmarshall(raw)
|
|
773
|
+
if (!matchesFilters(pv, filters))
|
|
774
|
+
continue
|
|
775
|
+
const width = Number(pv.screenWidth)
|
|
776
|
+
if (!Number.isFinite(width) || width <= 0)
|
|
777
|
+
continue
|
|
778
|
+
const bucket = BUCKETS.find(b => width >= b.min && width <= b.max)
|
|
779
|
+
if (!bucket)
|
|
780
|
+
continue
|
|
781
|
+
let set = visitorsByBucket.get(bucket.name)
|
|
782
|
+
if (!set) {
|
|
783
|
+
set = new Set()
|
|
784
|
+
visitorsByBucket.set(bucket.name, set)
|
|
785
|
+
}
|
|
786
|
+
if (pv.visitorId)
|
|
787
|
+
set.add(pv.visitorId)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const total = [...visitorsByBucket.values()].reduce((sum, set) => sum + set.size, 0)
|
|
791
|
+
const screenSizes = BUCKETS
|
|
792
|
+
.map(b => ({
|
|
793
|
+
name: b.name,
|
|
794
|
+
visitors: visitorsByBucket.get(b.name)?.size || 0,
|
|
795
|
+
percentage: total > 0 ? Math.round(((visitorsByBucket.get(b.name)?.size || 0) / total) * 100) : 0,
|
|
796
|
+
}))
|
|
797
|
+
.filter(b => b.visitors > 0)
|
|
798
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
799
|
+
|
|
800
|
+
return jsonResponse({ screenSizes })
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
console.error('Screen sizes error:', error)
|
|
804
|
+
return errorResponse('Failed to fetch screen sizes')
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* GET /api/sites/{siteId}/timeseries
|
|
810
|
+
*/
|
|
811
|
+
export async function handleGetTimeSeries(request: Request, siteId: string): Promise<Response> {
|
|
812
|
+
try {
|
|
813
|
+
const query = getQueryParams(request)
|
|
814
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
815
|
+
const period = query.period || 'day'
|
|
816
|
+
|
|
817
|
+
// Pre-aggregation (#94): for day/month periods, complete past days come
|
|
818
|
+
// from ROLLUP#DAY# items; raw pageviews are queried only from the first
|
|
819
|
+
// un-rolled day onward (usually just today). Hour/minute periods are
|
|
820
|
+
// short ranges and stay raw. Month buckets sum daily uniques.
|
|
821
|
+
const rollupByDay = new Map<string, { views: number, visitors: number }>()
|
|
822
|
+
let rawWindowStart = startDate
|
|
823
|
+
if (period === 'day' || period === 'month') {
|
|
824
|
+
// Serve only SETTLED days from rollups; the most-recent complete day stays
|
|
825
|
+
// on the raw path so late-arriving events aren't dropped (#162).
|
|
826
|
+
const eligible = fullyCoveredDays(startDate, endDate).filter(d => isSettledDay(d))
|
|
827
|
+
if (eligible.length > 0) {
|
|
828
|
+
const rollups = await readDayRollups(siteId, eligible[0], eligible[eligible.length - 1])
|
|
829
|
+
for (const day of eligible) {
|
|
830
|
+
const r = rollups.get(day)
|
|
831
|
+
if (!r)
|
|
832
|
+
break
|
|
833
|
+
rollupByDay.set(day, { views: r.views, visitors: r.visitors })
|
|
834
|
+
}
|
|
835
|
+
if (rollupByDay.size > 0) {
|
|
836
|
+
const lastRolled = [...rollupByDay.keys()].pop()!
|
|
837
|
+
const next = new Date(`${lastRolled}T00:00:00.000Z`)
|
|
838
|
+
next.setUTCDate(next.getUTCDate() + 1)
|
|
839
|
+
rawWindowStart = next
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Query pageviews for the (remaining) raw window
|
|
845
|
+
const result = rawWindowStart <= endDate
|
|
846
|
+
? await queryAllItems({
|
|
847
|
+
TableName: TABLE_NAME,
|
|
848
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
849
|
+
ExpressionAttributeValues: {
|
|
850
|
+
':pk': { S: `SITE#${siteId}` },
|
|
851
|
+
':start': { S: `PAGEVIEW#${rawWindowStart.toISOString()}` },
|
|
852
|
+
':end': { S: `PAGEVIEW#${endDate.toISOString()}` },
|
|
853
|
+
},
|
|
854
|
+
}) as { Items?: any[] }
|
|
855
|
+
: { Items: [] }
|
|
856
|
+
|
|
857
|
+
const pageviews = (result.Items || []).map(unmarshall)
|
|
858
|
+
|
|
859
|
+
// Generate all time buckets in the range
|
|
860
|
+
const allBuckets: string[] = []
|
|
861
|
+
const current = new Date(startDate)
|
|
862
|
+
const end = new Date(endDate)
|
|
863
|
+
|
|
864
|
+
while (current <= end) {
|
|
865
|
+
let key: string
|
|
866
|
+
// Advance in UTC (#136): keys are UTC ISO strings but the cursor used
|
|
867
|
+
// local-time setters — on non-UTC servers, DST transitions and offset
|
|
868
|
+
// drift skipped or duplicated buckets.
|
|
869
|
+
if (period === 'minute') {
|
|
870
|
+
const mins = Math.floor(current.getUTCMinutes() / 5) * 5
|
|
871
|
+
key = `${current.toISOString().slice(0, 14)}${mins.toString().padStart(2, '0')}:00.000Z`
|
|
872
|
+
current.setUTCMinutes(current.getUTCMinutes() + 5)
|
|
873
|
+
}
|
|
874
|
+
else if (period === 'hour') {
|
|
875
|
+
key = `${current.toISOString().slice(0, 13)}:00:00.000Z`
|
|
876
|
+
current.setUTCHours(current.getUTCHours() + 1)
|
|
877
|
+
}
|
|
878
|
+
else if (period === 'month') {
|
|
879
|
+
key = `${current.toISOString().slice(0, 7)}-01T00:00:00.000Z`
|
|
880
|
+
current.setUTCMonth(current.getUTCMonth() + 1)
|
|
881
|
+
}
|
|
882
|
+
else {
|
|
883
|
+
key = `${current.toISOString().slice(0, 10)}T00:00:00.000Z`
|
|
884
|
+
current.setUTCDate(current.getUTCDate() + 1)
|
|
885
|
+
}
|
|
886
|
+
if (!allBuckets.includes(key)) allBuckets.push(key)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Group pageviews by time bucket
|
|
890
|
+
const bucketMap: Record<string, { views: number; visitors: Set<string> }> = {}
|
|
891
|
+
for (const bucket of allBuckets) {
|
|
892
|
+
bucketMap[bucket] = { views: 0, visitors: new Set() }
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
for (const pv of pageviews) {
|
|
896
|
+
const timestamp = new Date(pv.timestamp)
|
|
897
|
+
let key: string
|
|
898
|
+
if (period === 'minute') {
|
|
899
|
+
const mins = Math.floor(timestamp.getUTCMinutes() / 5) * 5
|
|
900
|
+
key = `${timestamp.toISOString().slice(0, 14)}${mins.toString().padStart(2, '0')}:00.000Z`
|
|
901
|
+
}
|
|
902
|
+
else if (period === 'hour') {
|
|
903
|
+
key = `${timestamp.toISOString().slice(0, 13)}:00:00.000Z`
|
|
904
|
+
}
|
|
905
|
+
else if (period === 'month') {
|
|
906
|
+
key = `${timestamp.toISOString().slice(0, 7)}-01T00:00:00.000Z`
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
key = `${timestamp.toISOString().slice(0, 10)}T00:00:00.000Z`
|
|
910
|
+
}
|
|
911
|
+
if (bucketMap[key]) {
|
|
912
|
+
bucketMap[key].views++
|
|
913
|
+
bucketMap[key].visitors.add(pv.visitorId)
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Fold rollup days into their buckets. A day-bucket is either rolled or
|
|
918
|
+
// raw (the raw window starts after the last rolled day); a month-bucket
|
|
919
|
+
// can mix both, so its visitors are summed daily uniques.
|
|
920
|
+
const rollupBuckets: Record<string, { views: number, visitors: number }> = {}
|
|
921
|
+
for (const [day, r] of rollupByDay) {
|
|
922
|
+
const key = period === 'month' ? `${day.slice(0, 7)}-01T00:00:00.000Z` : `${day}T00:00:00.000Z`
|
|
923
|
+
if (!rollupBuckets[key])
|
|
924
|
+
rollupBuckets[key] = { views: 0, visitors: 0 }
|
|
925
|
+
rollupBuckets[key].views += r.views
|
|
926
|
+
rollupBuckets[key].visitors += r.visitors
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const timeSeries = allBuckets.map(bucket => ({
|
|
930
|
+
timestamp: bucket,
|
|
931
|
+
views: (bucketMap[bucket]?.views || 0) + (rollupBuckets[bucket]?.views || 0),
|
|
932
|
+
visitors: (bucketMap[bucket]?.visitors.size || 0) + (rollupBuckets[bucket]?.visitors || 0),
|
|
933
|
+
}))
|
|
934
|
+
|
|
935
|
+
return jsonResponse({ timeSeries })
|
|
936
|
+
}
|
|
937
|
+
catch (error) {
|
|
938
|
+
console.error('TimeSeries error:', error)
|
|
939
|
+
return errorResponse('Failed to fetch time series')
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* GET /api/sites/{siteId}/events
|
|
945
|
+
*/
|
|
946
|
+
export async function handleGetEvents(request: Request, siteId: string): Promise<Response> {
|
|
947
|
+
try {
|
|
948
|
+
const query = getQueryParams(request)
|
|
949
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
950
|
+
const limit = Math.min(Number(query.limit) || 10, 100)
|
|
951
|
+
|
|
952
|
+
// Query custom events
|
|
953
|
+
const result = await queryAllItems({
|
|
954
|
+
TableName: TABLE_NAME,
|
|
955
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
956
|
+
ExpressionAttributeValues: {
|
|
957
|
+
':pk': { S: `SITE#${siteId}` },
|
|
958
|
+
':start': { S: `EVENT#${startDate.toISOString()}` },
|
|
959
|
+
':end': { S: `EVENT#${endDate.toISOString()}` },
|
|
960
|
+
},
|
|
961
|
+
}) as { Items?: any[] }
|
|
962
|
+
|
|
963
|
+
const filters = parseFilters(query)
|
|
964
|
+
const events = (result.Items || []).map(unmarshall).filter(e => matchesFilters(e, filters))
|
|
965
|
+
|
|
966
|
+
// Aggregate by event name
|
|
967
|
+
const eventStats: Record<string, { count: number; visitors: Set<string>; value: number }> = {}
|
|
968
|
+
for (const e of events) {
|
|
969
|
+
const name = e.name || 'unknown'
|
|
970
|
+
if (!eventStats[name]) {
|
|
971
|
+
eventStats[name] = { count: 0, visitors: new Set(), value: 0 }
|
|
972
|
+
}
|
|
973
|
+
eventStats[name].count++
|
|
974
|
+
eventStats[name].visitors.add(e.visitorId)
|
|
975
|
+
// Sum the event value so revenue/valued events report a total (#132).
|
|
976
|
+
if (typeof e.value === 'number')
|
|
977
|
+
eventStats[name].value += e.value
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const eventsList = Object.entries(eventStats)
|
|
981
|
+
.map(([name, stats]) => ({
|
|
982
|
+
name,
|
|
983
|
+
count: stats.count,
|
|
984
|
+
visitors: stats.visitors.size,
|
|
985
|
+
value: stats.value,
|
|
986
|
+
}))
|
|
987
|
+
.sort((a, b) => b.count - a.count)
|
|
988
|
+
.slice(0, limit)
|
|
989
|
+
|
|
990
|
+
return jsonResponse({ events: eventsList })
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
console.error('Events error:', error)
|
|
994
|
+
return errorResponse('Failed to fetch events')
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Aggregate custom events into per-(event, property key, property value) rows
|
|
1000
|
+
* with counts + unique visitors. Pure (no DynamoDB) so it is unit-testable.
|
|
1001
|
+
* The reserved name/value keys are skipped, and non-primitive values ignored.
|
|
1002
|
+
*/
|
|
1003
|
+
export function aggregateEventProperties(
|
|
1004
|
+
events: Array<{ name?: string, visitorId?: string, properties?: Record<string, any> | null }>,
|
|
1005
|
+
opts: { limit?: number } = {},
|
|
1006
|
+
): {
|
|
1007
|
+
eventProperties: Array<{ event: string, key: string, value: string, count: number, visitors: number }>
|
|
1008
|
+
byEvent: Record<string, number>
|
|
1009
|
+
total: number
|
|
1010
|
+
} {
|
|
1011
|
+
const limit = opts.limit ?? 100
|
|
1012
|
+
const groups: Record<string, Record<string, Record<string, { count: number, visitors: Set<string> }>>> = {}
|
|
1013
|
+
const byEvent: Record<string, number> = {}
|
|
1014
|
+
|
|
1015
|
+
for (const e of events) {
|
|
1016
|
+
const event = e.name || 'unknown'
|
|
1017
|
+
byEvent[event] = (byEvent[event] || 0) + 1
|
|
1018
|
+
const props = e.properties
|
|
1019
|
+
if (!props || typeof props !== 'object') continue
|
|
1020
|
+
for (const [key, raw] of Object.entries(props)) {
|
|
1021
|
+
if (key === 'name' || key === 'value') continue
|
|
1022
|
+
if (raw === null || raw === undefined || typeof raw === 'object') continue
|
|
1023
|
+
const value = String(raw)
|
|
1024
|
+
const byKey = (groups[event] ||= {})
|
|
1025
|
+
const byValue = (byKey[key] ||= {})
|
|
1026
|
+
const bucket = (byValue[value] ||= { count: 0, visitors: new Set() })
|
|
1027
|
+
bucket.count++
|
|
1028
|
+
if (e.visitorId) bucket.visitors.add(e.visitorId)
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const eventProperties: Array<{ event: string, key: string, value: string, count: number, visitors: number }> = []
|
|
1033
|
+
for (const event of Object.keys(groups).sort()) {
|
|
1034
|
+
for (const key of Object.keys(groups[event]).sort()) {
|
|
1035
|
+
const rows = Object.entries(groups[event][key])
|
|
1036
|
+
.map(([value, b]) => ({ event, key, value, count: b.count, visitors: b.visitors.size }))
|
|
1037
|
+
.sort((a, b) => b.count - a.count)
|
|
1038
|
+
.slice(0, limit)
|
|
1039
|
+
eventProperties.push(...rows)
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return { eventProperties, byEvent, total: events.length }
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* GET /api/sites/{siteId}/event-properties
|
|
1048
|
+
* Per-event property value distribution (e.g. signup -> plan=pro/free).
|
|
1049
|
+
*/
|
|
1050
|
+
export async function handleGetEventProperties(request: Request, siteId: string): Promise<Response> {
|
|
1051
|
+
try {
|
|
1052
|
+
const query = getQueryParams(request)
|
|
1053
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
1054
|
+
const limit = Math.min(Number(query.limit) || 100, 200)
|
|
1055
|
+
const eventFilter = query.event
|
|
1056
|
+
const filters = parseFilters(query)
|
|
1057
|
+
|
|
1058
|
+
const result = await queryAllItems({
|
|
1059
|
+
TableName: TABLE_NAME,
|
|
1060
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
1061
|
+
ExpressionAttributeValues: {
|
|
1062
|
+
':pk': { S: `SITE#${siteId}` },
|
|
1063
|
+
':start': { S: `EVENT#${startDate.toISOString()}` },
|
|
1064
|
+
':end': { S: `EVENT#${endDate.toISOString()}` },
|
|
1065
|
+
},
|
|
1066
|
+
}) as { Items?: any[] }
|
|
1067
|
+
|
|
1068
|
+
const events = (result.Items || [])
|
|
1069
|
+
.map(unmarshall)
|
|
1070
|
+
.filter((e: any) => (!eventFilter || e.name === eventFilter) && matchesFilters(e, filters))
|
|
1071
|
+
.map((e: any) => {
|
|
1072
|
+
// properties are persisted as a JSON string; parse defensively (mirrors the ORM read path)
|
|
1073
|
+
if (typeof e.properties === 'string') {
|
|
1074
|
+
try { e.properties = JSON.parse(e.properties) }
|
|
1075
|
+
catch { e.properties = {} }
|
|
1076
|
+
}
|
|
1077
|
+
return e
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
return jsonResponse(aggregateEventProperties(events, { limit }))
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
console.error('Event properties error:', error)
|
|
1084
|
+
return errorResponse('Failed to fetch event properties')
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* GET /api/sites/{siteId}/clicks
|
|
1090
|
+
* Aggregated link-click report (outbound, internal, download, mailto, tel).
|
|
1091
|
+
*/
|
|
1092
|
+
export async function handleGetClicks(request: Request, siteId: string): Promise<Response> {
|
|
1093
|
+
try {
|
|
1094
|
+
const query = getQueryParams(request)
|
|
1095
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
1096
|
+
const limit = Math.min(Number(query.limit) || 20, 100)
|
|
1097
|
+
const kindFilter = query.kind
|
|
1098
|
+
|
|
1099
|
+
// Query link clicks
|
|
1100
|
+
const result = await queryAllItems({
|
|
1101
|
+
TableName: TABLE_NAME,
|
|
1102
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
1103
|
+
ExpressionAttributeValues: {
|
|
1104
|
+
':pk': { S: `SITE#${siteId}` },
|
|
1105
|
+
':start': { S: `CLICK#${startDate.toISOString()}` },
|
|
1106
|
+
':end': { S: `CLICK#${endDate.toISOString()}` },
|
|
1107
|
+
},
|
|
1108
|
+
}) as { Items?: any[] }
|
|
1109
|
+
|
|
1110
|
+
const filters = parseFilters(query)
|
|
1111
|
+
const clicks = (result.Items || [])
|
|
1112
|
+
.map(unmarshall)
|
|
1113
|
+
.filter(c => (!kindFilter || c.kind === kindFilter) && matchesFilters(c, filters))
|
|
1114
|
+
|
|
1115
|
+
// Aggregate by destination URL, plus a per-kind summary
|
|
1116
|
+
const urlStats: Record<string, { kind: string; text: string; count: number; visitors: Set<string> }> = {}
|
|
1117
|
+
const byKind: Record<string, number> = { outbound: 0, internal: 0, download: 0, mailto: 0, tel: 0 }
|
|
1118
|
+
for (const c of clicks) {
|
|
1119
|
+
const url = c.url || 'unknown'
|
|
1120
|
+
if (!urlStats[url]) {
|
|
1121
|
+
urlStats[url] = { kind: c.kind || 'outbound', text: c.text || '', count: 0, visitors: new Set() }
|
|
1122
|
+
}
|
|
1123
|
+
urlStats[url].count++
|
|
1124
|
+
urlStats[url].visitors.add(c.visitorId)
|
|
1125
|
+
if (byKind[c.kind] !== undefined) byKind[c.kind]++
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const clicksList = Object.entries(urlStats)
|
|
1129
|
+
.map(([url, s]) => ({
|
|
1130
|
+
url,
|
|
1131
|
+
kind: s.kind,
|
|
1132
|
+
text: s.text,
|
|
1133
|
+
count: s.count,
|
|
1134
|
+
visitors: s.visitors.size,
|
|
1135
|
+
}))
|
|
1136
|
+
.sort((a, b) => b.count - a.count)
|
|
1137
|
+
.slice(0, limit)
|
|
1138
|
+
|
|
1139
|
+
return jsonResponse({ clicks: clicksList, byKind, total: clicks.length })
|
|
1140
|
+
}
|
|
1141
|
+
catch (error) {
|
|
1142
|
+
console.error('Clicks error:', error)
|
|
1143
|
+
return errorResponse('Failed to fetch clicks')
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* GET /api/sites/{siteId}/engagement
|
|
1149
|
+
* Per-page engagement: average scroll depth (%) and active time-on-page (s).
|
|
1150
|
+
*/
|
|
1151
|
+
export async function handleGetEngagement(request: Request, siteId: string): Promise<Response> {
|
|
1152
|
+
try {
|
|
1153
|
+
const query = getQueryParams(request)
|
|
1154
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
1155
|
+
const limit = Math.min(Number(query.limit) || 20, 100)
|
|
1156
|
+
|
|
1157
|
+
// Query engagement samples
|
|
1158
|
+
const result = await queryAllItems({
|
|
1159
|
+
TableName: TABLE_NAME,
|
|
1160
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
1161
|
+
ExpressionAttributeValues: {
|
|
1162
|
+
':pk': { S: `SITE#${siteId}` },
|
|
1163
|
+
':start': { S: `ENGAGEMENT#${startDate.toISOString()}` },
|
|
1164
|
+
':end': { S: `ENGAGEMENT#${endDate.toISOString()}` },
|
|
1165
|
+
},
|
|
1166
|
+
}) as { Items?: any[] }
|
|
1167
|
+
|
|
1168
|
+
const filters = parseFilters(query)
|
|
1169
|
+
const samples = (result.Items || []).map(unmarshall).filter(s => matchesFilters(s, filters))
|
|
1170
|
+
|
|
1171
|
+
// Aggregate by page path
|
|
1172
|
+
const pageStats: Record<string, { count: number; sumScroll: number; sumTime: number; visitors: Set<string> }> = {}
|
|
1173
|
+
let totalScroll = 0
|
|
1174
|
+
let totalTime = 0
|
|
1175
|
+
for (const s of samples) {
|
|
1176
|
+
const path = s.path || '/'
|
|
1177
|
+
if (!pageStats[path]) {
|
|
1178
|
+
pageStats[path] = { count: 0, sumScroll: 0, sumTime: 0, visitors: new Set() }
|
|
1179
|
+
}
|
|
1180
|
+
const stat = pageStats[path]
|
|
1181
|
+
stat.count++
|
|
1182
|
+
stat.sumScroll += s.scrollDepth || 0
|
|
1183
|
+
stat.sumTime += s.timeOnPage || 0
|
|
1184
|
+
stat.visitors.add(s.visitorId)
|
|
1185
|
+
totalScroll += s.scrollDepth || 0
|
|
1186
|
+
totalTime += s.timeOnPage || 0
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const pages = Object.entries(pageStats)
|
|
1190
|
+
.map(([path, stat]) => ({
|
|
1191
|
+
path,
|
|
1192
|
+
samples: stat.count,
|
|
1193
|
+
visitors: stat.visitors.size,
|
|
1194
|
+
avgScrollDepth: Math.round(stat.sumScroll / stat.count),
|
|
1195
|
+
avgSeconds: Math.round(stat.sumTime / stat.count),
|
|
1196
|
+
}))
|
|
1197
|
+
.sort((a, b) => b.samples - a.samples)
|
|
1198
|
+
.slice(0, limit)
|
|
1199
|
+
|
|
1200
|
+
const n = samples.length
|
|
1201
|
+
return jsonResponse({
|
|
1202
|
+
engagement: pages,
|
|
1203
|
+
avgScrollDepth: n > 0 ? Math.round(totalScroll / n) : 0,
|
|
1204
|
+
avgSeconds: n > 0 ? Math.round(totalTime / n) : 0,
|
|
1205
|
+
samples: n,
|
|
1206
|
+
})
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
console.error('Engagement error:', error)
|
|
1210
|
+
return errorResponse('Failed to fetch engagement')
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* GET /api/sites/{siteId}/campaigns
|
|
1216
|
+
*/
|
|
1217
|
+
export async function handleGetCampaigns(request: Request, siteId: string): Promise<Response> {
|
|
1218
|
+
try {
|
|
1219
|
+
const query = getQueryParams(request)
|
|
1220
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
1221
|
+
const limit = Math.min(Number(query.limit) || 10, 100)
|
|
1222
|
+
|
|
1223
|
+
// Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
|
|
1224
|
+
const { days: rolled, rawWindowStart } = await readDimRollupPrefix(siteId, startDate, endDate)
|
|
1225
|
+
|
|
1226
|
+
// Query sessions with UTM data for the live tail
|
|
1227
|
+
const result = rawWindowStart <= endDate
|
|
1228
|
+
? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
|
|
1229
|
+
: { Items: [] }
|
|
1230
|
+
|
|
1231
|
+
const sessions = (result.Items || []).map(unmarshall).filter(s => {
|
|
1232
|
+
const sessionStart = new Date(s.startedAt)
|
|
1233
|
+
return sessionStart >= startDate && sessionStart <= endDate && s.utmCampaign
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
// Aggregate by campaign
|
|
1237
|
+
const campaignStats: Record<string, { visitors: Set<string>; sessions: number; source: string; medium: string }> = {}
|
|
1238
|
+
for (const s of sessions) {
|
|
1239
|
+
const campaign = s.utmCampaign || 'unknown'
|
|
1240
|
+
if (!campaignStats[campaign]) {
|
|
1241
|
+
campaignStats[campaign] = { visitors: new Set(), sessions: 0, source: s.utmSource || '', medium: s.utmMedium || '' }
|
|
1242
|
+
}
|
|
1243
|
+
campaignStats[campaign].visitors.add(s.visitorId)
|
|
1244
|
+
campaignStats[campaign].sessions++
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Merge the rolled prefix (daily-unique sums).
|
|
1248
|
+
const merged: Record<string, { visitors: number, sessions: number, source: string, medium: string }> = {}
|
|
1249
|
+
for (const [name, stats] of Object.entries(campaignStats))
|
|
1250
|
+
merged[name] = { visitors: stats.visitors.size, sessions: stats.sessions, source: stats.source, medium: stats.medium }
|
|
1251
|
+
for (const dayR of rolled) {
|
|
1252
|
+
for (const [name, cell] of Object.entries(dayR.campaigns || {})) {
|
|
1253
|
+
const m = (merged[name] ||= { visitors: 0, sessions: 0, source: cell.src, medium: cell.med })
|
|
1254
|
+
m.visitors += cell.v
|
|
1255
|
+
m.sessions += cell.s
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const campaigns = Object.entries(merged)
|
|
1260
|
+
.map(([name, stats]) => ({
|
|
1261
|
+
name,
|
|
1262
|
+
visitors: stats.visitors,
|
|
1263
|
+
sessions: stats.sessions,
|
|
1264
|
+
source: stats.source,
|
|
1265
|
+
medium: stats.medium,
|
|
1266
|
+
}))
|
|
1267
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
1268
|
+
.slice(0, limit)
|
|
1269
|
+
|
|
1270
|
+
return jsonResponse({ campaigns })
|
|
1271
|
+
}
|
|
1272
|
+
catch (error) {
|
|
1273
|
+
console.error('Campaigns error:', error)
|
|
1274
|
+
return errorResponse('Failed to fetch campaigns')
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* GET /api/sites/{siteId}/comparison
|
|
1280
|
+
*/
|
|
1281
|
+
export async function handleGetComparison(request: Request, siteId: string): Promise<Response> {
|
|
1282
|
+
try {
|
|
1283
|
+
const query = getQueryParams(request)
|
|
1284
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
1285
|
+
|
|
1286
|
+
// Calculate the comparison period (same duration, immediately before)
|
|
1287
|
+
const duration = endDate.getTime() - startDate.getTime()
|
|
1288
|
+
// Previous window is exactly [start - duration, start - 1ms] (#129): the
|
|
1289
|
+
// old derivation shifted the whole window back an extra millisecond.
|
|
1290
|
+
const comparisonEndDate = new Date(startDate.getTime() - 1)
|
|
1291
|
+
const comparisonStartDate = new Date(startDate.getTime() - duration)
|
|
1292
|
+
|
|
1293
|
+
// One filter parse + TWO queries over the combined span (#137) — the
|
|
1294
|
+
// old shape re-parsed filters and re-queried per period (4 queries).
|
|
1295
|
+
const filters = parseFilters(query)
|
|
1296
|
+
const [pageviewsResult, sessionsResult] = await Promise.all([
|
|
1297
|
+
queryAllItems({
|
|
1298
|
+
TableName: TABLE_NAME,
|
|
1299
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
1300
|
+
ExpressionAttributeValues: {
|
|
1301
|
+
':pk': { S: `SITE#${siteId}` },
|
|
1302
|
+
':start': { S: `PAGEVIEW#${comparisonStartDate.toISOString()}` },
|
|
1303
|
+
':end': { S: `PAGEVIEW#${endDate.toISOString()}` },
|
|
1304
|
+
},
|
|
1305
|
+
}) as Promise<{ Items?: any[] }>,
|
|
1306
|
+
querySessionItemsInRange(siteId, comparisonStartDate, endDate) as Promise<{ Items?: any[] }>,
|
|
1307
|
+
])
|
|
1308
|
+
const allPageviews = (pageviewsResult.Items || []).map(unmarshall).filter((pv: any) => matchesFilters(pv, filters))
|
|
1309
|
+
const allSessions = (sessionsResult.Items || []).map(unmarshall).filter(s => matchesFilters(s, filters))
|
|
1310
|
+
|
|
1311
|
+
function getStatsForPeriod(start: Date, end: Date) {
|
|
1312
|
+
const pageviews = allPageviews.filter((pv: any) => {
|
|
1313
|
+
const t = new Date(pv.timestamp)
|
|
1314
|
+
return t >= start && t <= end
|
|
1315
|
+
})
|
|
1316
|
+
const sessions = allSessions.filter((s) => {
|
|
1317
|
+
const t = new Date(s.startedAt)
|
|
1318
|
+
return t >= start && t <= end
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
const uniqueVisitors = new Set(pageviews.map((pv: any) => pv.visitorId)).size
|
|
1322
|
+
const totalViews = pageviews.length
|
|
1323
|
+
const totalSessions = sessions.length
|
|
1324
|
+
const bounces = sessions.filter(s => s.isBounce).length
|
|
1325
|
+
const bounceRate = totalSessions > 0 ? Math.round((bounces / totalSessions) * 100) : 0
|
|
1326
|
+
// Real engaged time when departure pings exist (#167), else wall-clock.
|
|
1327
|
+
const totalDuration = sessions.reduce((sum, s) => sum + (s.activeTime > 0 ? s.activeTime : (s.duration || 0)), 0)
|
|
1328
|
+
const avgDuration = totalSessions > 0 ? Math.round(totalDuration / totalSessions) : 0
|
|
1329
|
+
|
|
1330
|
+
return {
|
|
1331
|
+
visitors: uniqueVisitors,
|
|
1332
|
+
views: totalViews,
|
|
1333
|
+
sessions: totalSessions,
|
|
1334
|
+
bounceRate,
|
|
1335
|
+
avgDuration,
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const currentStats = getStatsForPeriod(startDate, endDate)
|
|
1340
|
+
const previousStats = getStatsForPeriod(comparisonStartDate, comparisonEndDate)
|
|
1341
|
+
|
|
1342
|
+
// Calculate percentage changes
|
|
1343
|
+
const calcChange = (current: number, previous: number) => {
|
|
1344
|
+
if (previous === 0) return current > 0 ? 100 : 0
|
|
1345
|
+
return Math.round(((current - previous) / previous) * 100)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return jsonResponse({
|
|
1349
|
+
current: currentStats,
|
|
1350
|
+
previous: previousStats,
|
|
1351
|
+
changes: {
|
|
1352
|
+
visitors: calcChange(currentStats.visitors, previousStats.visitors),
|
|
1353
|
+
views: calcChange(currentStats.views, previousStats.views),
|
|
1354
|
+
sessions: calcChange(currentStats.sessions, previousStats.sessions),
|
|
1355
|
+
bounceRate: calcChange(currentStats.bounceRate, previousStats.bounceRate),
|
|
1356
|
+
avgDuration: calcChange(currentStats.avgDuration, previousStats.avgDuration),
|
|
1357
|
+
},
|
|
1358
|
+
periods: {
|
|
1359
|
+
current: { start: startDate.toISOString(), end: endDate.toISOString() },
|
|
1360
|
+
previous: { start: comparisonStartDate.toISOString(), end: comparisonEndDate.toISOString() },
|
|
1361
|
+
},
|
|
1362
|
+
})
|
|
1363
|
+
}
|
|
1364
|
+
catch (error) {
|
|
1365
|
+
console.error('Comparison error:', error)
|
|
1366
|
+
return errorResponse('Failed to fetch comparison data')
|
|
1367
|
+
}
|
|
1368
|
+
}
|