@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
package/src/Analytics.ts
ADDED
|
@@ -0,0 +1,3349 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Analytics - Privacy-Focused Web Analytics for DynamoDB Single-Table Design
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Inspired by Fathom Analytics - simple, privacy-first analytics
|
|
5
|
+
// Designed for DynamoDB single-table pattern with efficient access patterns
|
|
6
|
+
//
|
|
7
|
+
// Types are defined in ./types.ts and models in ./models/
|
|
8
|
+
//
|
|
9
|
+
// MIXED FILE (#95) -- what is live vs legacy:
|
|
10
|
+
// LIVE generateTrackingScript / generateMinimalTrackingScript -- the served
|
|
11
|
+
// tracker (src/handlers/views.ts handleScript builds /script.js here).
|
|
12
|
+
// LEGACY AnalyticsStore / AnalyticsQueryAPI / AggregationPipeline /
|
|
13
|
+
// AnalyticsAggregator -- the in-memory layer behind src/api.ts; not
|
|
14
|
+
// used by the live server. The real request path is src/router.ts +
|
|
15
|
+
// src/handlers/* (DynamoDB); daily pre-aggregation is src/lib/rollups.ts.
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
// Import types from types.ts for internal use
|
|
19
|
+
// Note: Some types are defined locally in this file and exported directly
|
|
20
|
+
import type { AggregatedStats, AggregationPeriod, AnalyticsStoreOptions, CampaignStats, CustomEvent, DeviceStats, DeviceType, EventStats, GeoStats, Goal, GoalStats, GoalType, PageStats, PageView, RealtimeStats, ReferrerStats, Session, Site, SiteSettings } from './types'
|
|
21
|
+
import { TRACKER_VERSION } from './version'
|
|
22
|
+
|
|
23
|
+
// Re-export types from types.ts
|
|
24
|
+
export type {
|
|
25
|
+
AggregatedStats,
|
|
26
|
+
AggregationPeriod,
|
|
27
|
+
AnalyticsStoreOptions,
|
|
28
|
+
CampaignStats,
|
|
29
|
+
CustomEvent,
|
|
30
|
+
DeviceStats,
|
|
31
|
+
DeviceType,
|
|
32
|
+
EventStats,
|
|
33
|
+
GeoStats,
|
|
34
|
+
Goal,
|
|
35
|
+
GoalStats,
|
|
36
|
+
GoalType,
|
|
37
|
+
PageStats,
|
|
38
|
+
PageView,
|
|
39
|
+
RealtimeStats,
|
|
40
|
+
ReferrerStats,
|
|
41
|
+
Session,
|
|
42
|
+
Site,
|
|
43
|
+
SiteSettings,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// Analytics Store - DynamoDB Operations
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* DynamoDB key patterns for analytics entities
|
|
52
|
+
*/
|
|
53
|
+
export const AnalyticsKeyPatterns = {
|
|
54
|
+
// Sites
|
|
55
|
+
site: {
|
|
56
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
57
|
+
sk: (siteId: string): string => `SITE#${siteId}`,
|
|
58
|
+
gsi1pk: (ownerId: string): string => `OWNER#${ownerId}`,
|
|
59
|
+
gsi1sk: (siteId: string): string => `SITE#${siteId}`,
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
// Page Views (raw events)
|
|
63
|
+
pageView: {
|
|
64
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
65
|
+
sk: (timestamp: Date, pageViewId: string): string => `PV#${timestamp.toISOString()}#${pageViewId}`,
|
|
66
|
+
gsi1pk: (siteId: string, date: string): string => `SITE#${siteId}#DATE#${date}`,
|
|
67
|
+
gsi1sk: (path: string, pageViewId: string): string => `PATH#${path}#${pageViewId}`,
|
|
68
|
+
gsi2pk: (siteId: string, visitorId: string): string => `SITE#${siteId}#VISITOR#${visitorId}`,
|
|
69
|
+
gsi2sk: (timestamp: Date): string => `PV#${timestamp.toISOString()}`,
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// Sessions
|
|
73
|
+
session: {
|
|
74
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
75
|
+
sk: (sessionId: string): string => `SESSION#${sessionId}`,
|
|
76
|
+
gsi1pk: (siteId: string, date: string): string => `SITE#${siteId}#SESSIONS#${date}`,
|
|
77
|
+
gsi1sk: (sessionId: string): string => `SESSION#${sessionId}`,
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// Custom Events
|
|
81
|
+
customEvent: {
|
|
82
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
83
|
+
sk: (timestamp: Date, eventId: string): string => `EVENT#${timestamp.toISOString()}#${eventId}`,
|
|
84
|
+
gsi1pk: (siteId: string, eventName: string): string => `SITE#${siteId}#EVENTNAME#${eventName}`,
|
|
85
|
+
gsi1sk: (timestamp: Date): string => `EVENT#${timestamp.toISOString()}`,
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// Aggregated Stats (daily/hourly/monthly)
|
|
89
|
+
aggregatedStats: {
|
|
90
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
91
|
+
sk: (period: AggregationPeriod, periodStart: string): string => `STATS#${period.toUpperCase()}#${periodStart}`,
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// Page Stats
|
|
95
|
+
pageStats: {
|
|
96
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
97
|
+
sk: (period: AggregationPeriod, periodStart: string, path: string): string =>
|
|
98
|
+
`PAGESTATS#${period.toUpperCase()}#${periodStart}#${encodeURIComponent(path)}`,
|
|
99
|
+
gsi1pk: (siteId: string, period: AggregationPeriod, periodStart: string): string =>
|
|
100
|
+
`SITE#${siteId}#PAGESTATS#${period.toUpperCase()}#${periodStart}`,
|
|
101
|
+
gsi1sk: (pageViews: number, path: string): string =>
|
|
102
|
+
`PV#${String(pageViews).padStart(10, '0')}#${encodeURIComponent(path)}`,
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// Referrer Stats
|
|
106
|
+
referrerStats: {
|
|
107
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
108
|
+
sk: (period: AggregationPeriod, periodStart: string, source: string): string =>
|
|
109
|
+
`REFSTATS#${period.toUpperCase()}#${periodStart}#${source}`,
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// Geo Stats
|
|
113
|
+
geoStats: {
|
|
114
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
115
|
+
sk: (period: AggregationPeriod, periodStart: string, country: string, region?: string): string =>
|
|
116
|
+
`GEOSTATS#${period.toUpperCase()}#${periodStart}#${country}${region ? `#${region}` : ''}`,
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
// Device Stats
|
|
120
|
+
deviceStats: {
|
|
121
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
122
|
+
sk: (period: AggregationPeriod, periodStart: string, dimension: string, value: string): string =>
|
|
123
|
+
`DEVICESTATS#${period.toUpperCase()}#${periodStart}#${dimension}#${value}`,
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// Campaign Stats
|
|
127
|
+
campaignStats: {
|
|
128
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
129
|
+
sk: (period: AggregationPeriod, periodStart: string, utmSource: string, utmCampaign?: string): string =>
|
|
130
|
+
`CAMPSTATS#${period.toUpperCase()}#${periodStart}#${utmSource}${utmCampaign ? `#${utmCampaign}` : ''}`,
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
// Event Stats
|
|
134
|
+
eventStats: {
|
|
135
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
136
|
+
sk: (period: AggregationPeriod, periodStart: string, eventName: string): string =>
|
|
137
|
+
`EVENTSTATS#${period.toUpperCase()}#${periodStart}#${eventName}`,
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// Goals
|
|
141
|
+
goal: {
|
|
142
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
143
|
+
sk: (goalId: string): string => `GOAL#${goalId}`,
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// Goal Stats
|
|
147
|
+
goalStats: {
|
|
148
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
149
|
+
sk: (goalId: string, period: AggregationPeriod, periodStart: string): string =>
|
|
150
|
+
`GOALSTATS#${goalId}#${period.toUpperCase()}#${periodStart}`,
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
// Realtime Stats
|
|
154
|
+
realtimeStats: {
|
|
155
|
+
pk: (siteId: string): string => `SITE#${siteId}`,
|
|
156
|
+
sk: (minute: string): string => `REALTIME#${minute}`,
|
|
157
|
+
},
|
|
158
|
+
} as const
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Analytics Store for DynamoDB
|
|
162
|
+
*/
|
|
163
|
+
export class AnalyticsStore {
|
|
164
|
+
private options: Required<AnalyticsStoreOptions>
|
|
165
|
+
|
|
166
|
+
constructor(options: AnalyticsStoreOptions) {
|
|
167
|
+
this.options = {
|
|
168
|
+
useTtl: true,
|
|
169
|
+
rawEventTtl: 30 * 24 * 60 * 60, // 30 days
|
|
170
|
+
hourlyAggregateTtl: 90 * 24 * 60 * 60, // 90 days
|
|
171
|
+
dailyAggregateTtl: 2 * 365 * 24 * 60 * 60, // 2 years
|
|
172
|
+
...options,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ==========================================================================
|
|
177
|
+
// Site Operations
|
|
178
|
+
// ==========================================================================
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Generate command to create a site
|
|
182
|
+
*/
|
|
183
|
+
createSiteCommand(site: Site): {
|
|
184
|
+
command: 'PutItem'
|
|
185
|
+
input: {
|
|
186
|
+
TableName: string
|
|
187
|
+
Item: Record<string, unknown>
|
|
188
|
+
ConditionExpression: string
|
|
189
|
+
}
|
|
190
|
+
} {
|
|
191
|
+
const keys = AnalyticsKeyPatterns.site
|
|
192
|
+
return {
|
|
193
|
+
command: 'PutItem',
|
|
194
|
+
input: {
|
|
195
|
+
TableName: this.options.tableName,
|
|
196
|
+
Item: {
|
|
197
|
+
pk: { S: keys.pk(site.id) },
|
|
198
|
+
sk: { S: keys.sk(site.id) },
|
|
199
|
+
gsi1pk: { S: keys.gsi1pk(site.ownerId) },
|
|
200
|
+
gsi1sk: { S: keys.gsi1sk(site.id) },
|
|
201
|
+
id: { S: site.id },
|
|
202
|
+
name: { S: site.name },
|
|
203
|
+
domains: { L: site.domains.map(d => ({ S: d })) },
|
|
204
|
+
timezone: { S: site.timezone },
|
|
205
|
+
isActive: { BOOL: site.isActive },
|
|
206
|
+
ownerId: { S: site.ownerId },
|
|
207
|
+
settings: { S: JSON.stringify(site.settings) },
|
|
208
|
+
createdAt: { S: site.createdAt.toISOString() },
|
|
209
|
+
updatedAt: { S: site.updatedAt.toISOString() },
|
|
210
|
+
_et: { S: 'Site' },
|
|
211
|
+
},
|
|
212
|
+
ConditionExpression: 'attribute_not_exists(pk)',
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Generate command to get a site by ID
|
|
219
|
+
*/
|
|
220
|
+
getSiteCommand(siteId: string): {
|
|
221
|
+
command: 'GetItem'
|
|
222
|
+
input: {
|
|
223
|
+
TableName: string
|
|
224
|
+
Key: Record<string, unknown>
|
|
225
|
+
}
|
|
226
|
+
} {
|
|
227
|
+
const keys = AnalyticsKeyPatterns.site
|
|
228
|
+
return {
|
|
229
|
+
command: 'GetItem',
|
|
230
|
+
input: {
|
|
231
|
+
TableName: this.options.tableName,
|
|
232
|
+
Key: {
|
|
233
|
+
pk: { S: keys.pk(siteId) },
|
|
234
|
+
sk: { S: keys.sk(siteId) },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generate command to list sites by owner
|
|
242
|
+
*/
|
|
243
|
+
listSitesByOwnerCommand(ownerId: string): {
|
|
244
|
+
command: 'Query'
|
|
245
|
+
input: {
|
|
246
|
+
TableName: string
|
|
247
|
+
IndexName: string
|
|
248
|
+
KeyConditionExpression: string
|
|
249
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
250
|
+
}
|
|
251
|
+
} {
|
|
252
|
+
const keys = AnalyticsKeyPatterns.site
|
|
253
|
+
return {
|
|
254
|
+
command: 'Query',
|
|
255
|
+
input: {
|
|
256
|
+
TableName: this.options.tableName,
|
|
257
|
+
IndexName: 'GSI1',
|
|
258
|
+
KeyConditionExpression: 'gsi1pk = :pk',
|
|
259
|
+
ExpressionAttributeValues: {
|
|
260
|
+
':pk': { S: keys.gsi1pk(ownerId) },
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ==========================================================================
|
|
267
|
+
// Page View Operations
|
|
268
|
+
// ==========================================================================
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Generate command to record a page view
|
|
272
|
+
*/
|
|
273
|
+
recordPageViewCommand(pageView: PageView): {
|
|
274
|
+
command: 'PutItem'
|
|
275
|
+
input: {
|
|
276
|
+
TableName: string
|
|
277
|
+
Item: Record<string, unknown>
|
|
278
|
+
}
|
|
279
|
+
} {
|
|
280
|
+
const keys = AnalyticsKeyPatterns.pageView
|
|
281
|
+
const date = pageView.timestamp.toISOString().split('T')[0]
|
|
282
|
+
const ttl = this.options.useTtl
|
|
283
|
+
? Math.floor(Date.now() / 1000) + this.options.rawEventTtl
|
|
284
|
+
: undefined
|
|
285
|
+
|
|
286
|
+
const item: Record<string, unknown> = {
|
|
287
|
+
pk: { S: keys.pk(pageView.siteId) },
|
|
288
|
+
sk: { S: keys.sk(pageView.timestamp, pageView.id) },
|
|
289
|
+
gsi1pk: { S: keys.gsi1pk(pageView.siteId, date) },
|
|
290
|
+
gsi1sk: { S: keys.gsi1sk(pageView.path, pageView.id) },
|
|
291
|
+
gsi2pk: { S: keys.gsi2pk(pageView.siteId, pageView.visitorId) },
|
|
292
|
+
gsi2sk: { S: keys.gsi2sk(pageView.timestamp) },
|
|
293
|
+
id: { S: pageView.id },
|
|
294
|
+
siteId: { S: pageView.siteId },
|
|
295
|
+
visitorId: { S: pageView.visitorId },
|
|
296
|
+
sessionId: { S: pageView.sessionId },
|
|
297
|
+
path: { S: pageView.path },
|
|
298
|
+
hostname: { S: pageView.hostname },
|
|
299
|
+
isUnique: { BOOL: pageView.isUnique },
|
|
300
|
+
isBounce: { BOOL: pageView.isBounce },
|
|
301
|
+
timestamp: { S: pageView.timestamp.toISOString() },
|
|
302
|
+
_et: { S: 'PageView' },
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Add optional fields
|
|
306
|
+
if (pageView.title)
|
|
307
|
+
item.title = { S: pageView.title }
|
|
308
|
+
if (pageView.referrer)
|
|
309
|
+
item.referrer = { S: pageView.referrer }
|
|
310
|
+
if (pageView.referrerSource)
|
|
311
|
+
item.referrerSource = { S: pageView.referrerSource }
|
|
312
|
+
if (pageView.utmSource)
|
|
313
|
+
item.utmSource = { S: pageView.utmSource }
|
|
314
|
+
if (pageView.utmMedium)
|
|
315
|
+
item.utmMedium = { S: pageView.utmMedium }
|
|
316
|
+
if (pageView.utmCampaign)
|
|
317
|
+
item.utmCampaign = { S: pageView.utmCampaign }
|
|
318
|
+
if (pageView.utmContent)
|
|
319
|
+
item.utmContent = { S: pageView.utmContent }
|
|
320
|
+
if (pageView.utmTerm)
|
|
321
|
+
item.utmTerm = { S: pageView.utmTerm }
|
|
322
|
+
if (pageView.country)
|
|
323
|
+
item.country = { S: pageView.country }
|
|
324
|
+
if (pageView.region)
|
|
325
|
+
item.region = { S: pageView.region }
|
|
326
|
+
if (pageView.city)
|
|
327
|
+
item.city = { S: pageView.city }
|
|
328
|
+
if (pageView.deviceType)
|
|
329
|
+
item.deviceType = { S: pageView.deviceType }
|
|
330
|
+
if (pageView.browser)
|
|
331
|
+
item.browser = { S: pageView.browser }
|
|
332
|
+
if (pageView.browserVersion)
|
|
333
|
+
item.browserVersion = { S: pageView.browserVersion }
|
|
334
|
+
if (pageView.os)
|
|
335
|
+
item.os = { S: pageView.os }
|
|
336
|
+
if (pageView.osVersion)
|
|
337
|
+
item.osVersion = { S: pageView.osVersion }
|
|
338
|
+
if (pageView.screenWidth)
|
|
339
|
+
item.screenWidth = { N: String(pageView.screenWidth) }
|
|
340
|
+
if (pageView.screenHeight)
|
|
341
|
+
item.screenHeight = { N: String(pageView.screenHeight) }
|
|
342
|
+
if (pageView.timeOnPage !== undefined)
|
|
343
|
+
item.timeOnPage = { N: String(pageView.timeOnPage) }
|
|
344
|
+
if (ttl)
|
|
345
|
+
item.ttl = { N: String(ttl) }
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
command: 'PutItem',
|
|
349
|
+
input: {
|
|
350
|
+
TableName: this.options.tableName,
|
|
351
|
+
Item: item,
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Generate command to query page views for a date range
|
|
358
|
+
*/
|
|
359
|
+
queryPageViewsCommand(
|
|
360
|
+
siteId: string,
|
|
361
|
+
startDate: Date,
|
|
362
|
+
endDate: Date,
|
|
363
|
+
options?: { path?: string, limit?: number },
|
|
364
|
+
): {
|
|
365
|
+
command: 'Query'
|
|
366
|
+
input: {
|
|
367
|
+
TableName: string
|
|
368
|
+
KeyConditionExpression: string
|
|
369
|
+
ExpressionAttributeNames: Record<string, string>
|
|
370
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
371
|
+
FilterExpression?: string
|
|
372
|
+
Limit?: number
|
|
373
|
+
ScanIndexForward: boolean
|
|
374
|
+
}
|
|
375
|
+
} {
|
|
376
|
+
const keys = AnalyticsKeyPatterns.pageView
|
|
377
|
+
const startSk = keys.sk(startDate, '')
|
|
378
|
+
const endSk = keys.sk(endDate, 'zzz')
|
|
379
|
+
|
|
380
|
+
const input: {
|
|
381
|
+
TableName: string
|
|
382
|
+
KeyConditionExpression: string
|
|
383
|
+
ExpressionAttributeNames: Record<string, string>
|
|
384
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
385
|
+
FilterExpression?: string
|
|
386
|
+
Limit?: number
|
|
387
|
+
ScanIndexForward: boolean
|
|
388
|
+
} = {
|
|
389
|
+
TableName: this.options.tableName,
|
|
390
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
391
|
+
ExpressionAttributeNames: {},
|
|
392
|
+
ExpressionAttributeValues: {
|
|
393
|
+
':pk': { S: keys.pk(siteId) },
|
|
394
|
+
':start': { S: startSk },
|
|
395
|
+
':end': { S: endSk },
|
|
396
|
+
},
|
|
397
|
+
ScanIndexForward: false,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (options?.path) {
|
|
401
|
+
input.FilterExpression = '#path = :path'
|
|
402
|
+
input.ExpressionAttributeNames['#path'] = 'path'
|
|
403
|
+
input.ExpressionAttributeValues[':path'] = { S: options.path }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (options?.limit) {
|
|
407
|
+
input.Limit = options.limit
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
command: 'Query',
|
|
412
|
+
input,
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ==========================================================================
|
|
417
|
+
// Session Operations
|
|
418
|
+
// ==========================================================================
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Generate command to create/update a session
|
|
422
|
+
*/
|
|
423
|
+
upsertSessionCommand(session: Session): {
|
|
424
|
+
command: 'PutItem'
|
|
425
|
+
input: {
|
|
426
|
+
TableName: string
|
|
427
|
+
Item: Record<string, unknown>
|
|
428
|
+
}
|
|
429
|
+
} {
|
|
430
|
+
const keys = AnalyticsKeyPatterns.session
|
|
431
|
+
const date = session.startedAt.toISOString().split('T')[0]
|
|
432
|
+
const ttl = this.options.useTtl
|
|
433
|
+
? Math.floor(Date.now() / 1000) + this.options.rawEventTtl
|
|
434
|
+
: undefined
|
|
435
|
+
|
|
436
|
+
const item: Record<string, unknown> = {
|
|
437
|
+
pk: { S: keys.pk(session.siteId) },
|
|
438
|
+
sk: { S: keys.sk(session.id) },
|
|
439
|
+
gsi1pk: { S: keys.gsi1pk(session.siteId, date) },
|
|
440
|
+
gsi1sk: { S: keys.gsi1sk(session.id) },
|
|
441
|
+
id: { S: session.id },
|
|
442
|
+
siteId: { S: session.siteId },
|
|
443
|
+
visitorId: { S: session.visitorId },
|
|
444
|
+
entryPath: { S: session.entryPath },
|
|
445
|
+
exitPath: { S: session.exitPath },
|
|
446
|
+
pageViewCount: { N: String(session.pageViewCount) },
|
|
447
|
+
eventCount: { N: String(session.eventCount) },
|
|
448
|
+
isBounce: { BOOL: session.isBounce },
|
|
449
|
+
duration: { N: String(session.duration) },
|
|
450
|
+
startedAt: { S: session.startedAt.toISOString() },
|
|
451
|
+
endedAt: { S: session.endedAt.toISOString() },
|
|
452
|
+
_et: { S: 'Session' },
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Add optional fields
|
|
456
|
+
if (session.referrer)
|
|
457
|
+
item.referrer = { S: session.referrer }
|
|
458
|
+
if (session.referrerSource)
|
|
459
|
+
item.referrerSource = { S: session.referrerSource }
|
|
460
|
+
if (session.utmSource)
|
|
461
|
+
item.utmSource = { S: session.utmSource }
|
|
462
|
+
if (session.utmMedium)
|
|
463
|
+
item.utmMedium = { S: session.utmMedium }
|
|
464
|
+
if (session.utmCampaign)
|
|
465
|
+
item.utmCampaign = { S: session.utmCampaign }
|
|
466
|
+
if (session.country)
|
|
467
|
+
item.country = { S: session.country }
|
|
468
|
+
if (session.deviceType)
|
|
469
|
+
item.deviceType = { S: session.deviceType }
|
|
470
|
+
if (session.browser)
|
|
471
|
+
item.browser = { S: session.browser }
|
|
472
|
+
if (session.os)
|
|
473
|
+
item.os = { S: session.os }
|
|
474
|
+
if (ttl)
|
|
475
|
+
item.ttl = { N: String(ttl) }
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
command: 'PutItem',
|
|
479
|
+
input: {
|
|
480
|
+
TableName: this.options.tableName,
|
|
481
|
+
Item: item,
|
|
482
|
+
},
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ==========================================================================
|
|
487
|
+
// Custom Event Operations
|
|
488
|
+
// ==========================================================================
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Generate command to record a custom event
|
|
492
|
+
*/
|
|
493
|
+
recordCustomEventCommand(event: CustomEvent): {
|
|
494
|
+
command: 'PutItem'
|
|
495
|
+
input: {
|
|
496
|
+
TableName: string
|
|
497
|
+
Item: Record<string, unknown>
|
|
498
|
+
}
|
|
499
|
+
} {
|
|
500
|
+
const keys = AnalyticsKeyPatterns.customEvent
|
|
501
|
+
const ttl = this.options.useTtl
|
|
502
|
+
? Math.floor(Date.now() / 1000) + this.options.rawEventTtl
|
|
503
|
+
: undefined
|
|
504
|
+
|
|
505
|
+
const item: Record<string, unknown> = {
|
|
506
|
+
pk: { S: keys.pk(event.siteId) },
|
|
507
|
+
sk: { S: keys.sk(event.timestamp, event.id) },
|
|
508
|
+
gsi1pk: { S: keys.gsi1pk(event.siteId, event.name) },
|
|
509
|
+
gsi1sk: { S: keys.gsi1sk(event.timestamp) },
|
|
510
|
+
id: { S: event.id },
|
|
511
|
+
siteId: { S: event.siteId },
|
|
512
|
+
visitorId: { S: event.visitorId },
|
|
513
|
+
sessionId: { S: event.sessionId },
|
|
514
|
+
name: { S: event.name },
|
|
515
|
+
path: { S: event.path },
|
|
516
|
+
timestamp: { S: event.timestamp.toISOString() },
|
|
517
|
+
_et: { S: 'CustomEvent' },
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (event.value !== undefined)
|
|
521
|
+
item.value = { N: String(event.value) }
|
|
522
|
+
if (event.properties)
|
|
523
|
+
item.properties = { S: JSON.stringify(event.properties) }
|
|
524
|
+
if (ttl)
|
|
525
|
+
item.ttl = { N: String(ttl) }
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
command: 'PutItem',
|
|
529
|
+
input: {
|
|
530
|
+
TableName: this.options.tableName,
|
|
531
|
+
Item: item,
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ==========================================================================
|
|
537
|
+
// Aggregated Stats Operations
|
|
538
|
+
// ==========================================================================
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Generate command to upsert aggregated stats
|
|
542
|
+
*/
|
|
543
|
+
upsertAggregatedStatsCommand(stats: AggregatedStats): {
|
|
544
|
+
command: 'UpdateItem'
|
|
545
|
+
input: {
|
|
546
|
+
TableName: string
|
|
547
|
+
Key: Record<string, unknown>
|
|
548
|
+
UpdateExpression: string
|
|
549
|
+
ExpressionAttributeNames: Record<string, string>
|
|
550
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
551
|
+
}
|
|
552
|
+
} {
|
|
553
|
+
const keys = AnalyticsKeyPatterns.aggregatedStats
|
|
554
|
+
const ttl = this.getTtlForPeriod(stats.period)
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
command: 'UpdateItem',
|
|
558
|
+
input: {
|
|
559
|
+
TableName: this.options.tableName,
|
|
560
|
+
Key: {
|
|
561
|
+
pk: { S: keys.pk(stats.siteId) },
|
|
562
|
+
sk: { S: keys.sk(stats.period, stats.periodStart) },
|
|
563
|
+
},
|
|
564
|
+
UpdateExpression: `
|
|
565
|
+
SET #pv = if_not_exists(#pv, :zero) + :pv,
|
|
566
|
+
#uv = if_not_exists(#uv, :zero) + :uv,
|
|
567
|
+
#sessions = if_not_exists(#sessions, :zero) + :sessions,
|
|
568
|
+
#bounces = if_not_exists(#bounces, :zero) + :bounces,
|
|
569
|
+
#totalTime = if_not_exists(#totalTime, :zero) + :totalTime,
|
|
570
|
+
#newVisitors = if_not_exists(#newVisitors, :zero) + :newVisitors,
|
|
571
|
+
#retVisitors = if_not_exists(#retVisitors, :zero) + :retVisitors,
|
|
572
|
+
#updatedAt = :now,
|
|
573
|
+
#et = :et
|
|
574
|
+
${ttl ? ', #ttl = :ttl' : ''}
|
|
575
|
+
`.trim(),
|
|
576
|
+
ExpressionAttributeNames: {
|
|
577
|
+
'#pv': 'pageViews',
|
|
578
|
+
'#uv': 'uniqueVisitors',
|
|
579
|
+
'#sessions': 'sessions',
|
|
580
|
+
'#bounces': 'bounces',
|
|
581
|
+
'#totalTime': 'totalTimeOnSite',
|
|
582
|
+
'#newVisitors': 'newVisitors',
|
|
583
|
+
'#retVisitors': 'returningVisitors',
|
|
584
|
+
'#updatedAt': 'updatedAt',
|
|
585
|
+
'#et': '_et',
|
|
586
|
+
...(ttl ? { '#ttl': 'ttl' } : {}),
|
|
587
|
+
},
|
|
588
|
+
ExpressionAttributeValues: {
|
|
589
|
+
':pv': { N: String(stats.pageViews) },
|
|
590
|
+
':uv': { N: String(stats.uniqueVisitors) },
|
|
591
|
+
':sessions': { N: String(stats.sessions) },
|
|
592
|
+
':bounces': { N: String(stats.bounces) },
|
|
593
|
+
':totalTime': { N: String(stats.totalTimeOnSite) },
|
|
594
|
+
':newVisitors': { N: String(stats.newVisitors) },
|
|
595
|
+
':retVisitors': { N: String(stats.returningVisitors) },
|
|
596
|
+
':zero': { N: '0' },
|
|
597
|
+
':now': { S: new Date().toISOString() },
|
|
598
|
+
':et': { S: 'AggregatedStats' },
|
|
599
|
+
...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Generate command to get aggregated stats for a date range
|
|
607
|
+
*/
|
|
608
|
+
getAggregatedStatsCommand(
|
|
609
|
+
siteId: string,
|
|
610
|
+
period: AggregationPeriod,
|
|
611
|
+
startPeriod: string,
|
|
612
|
+
endPeriod: string,
|
|
613
|
+
): {
|
|
614
|
+
command: 'Query'
|
|
615
|
+
input: {
|
|
616
|
+
TableName: string
|
|
617
|
+
KeyConditionExpression: string
|
|
618
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
619
|
+
ScanIndexForward: boolean
|
|
620
|
+
}
|
|
621
|
+
} {
|
|
622
|
+
const keys = AnalyticsKeyPatterns.aggregatedStats
|
|
623
|
+
return {
|
|
624
|
+
command: 'Query',
|
|
625
|
+
input: {
|
|
626
|
+
TableName: this.options.tableName,
|
|
627
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
628
|
+
ExpressionAttributeValues: {
|
|
629
|
+
':pk': { S: keys.pk(siteId) },
|
|
630
|
+
':start': { S: keys.sk(period, startPeriod) },
|
|
631
|
+
':end': { S: keys.sk(period, endPeriod) },
|
|
632
|
+
},
|
|
633
|
+
ScanIndexForward: true,
|
|
634
|
+
},
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ==========================================================================
|
|
639
|
+
// Page Stats Operations
|
|
640
|
+
// ==========================================================================
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Generate command to upsert page stats
|
|
644
|
+
*/
|
|
645
|
+
upsertPageStatsCommand(stats: PageStats): {
|
|
646
|
+
command: 'UpdateItem'
|
|
647
|
+
input: {
|
|
648
|
+
TableName: string
|
|
649
|
+
Key: Record<string, unknown>
|
|
650
|
+
UpdateExpression: string
|
|
651
|
+
ExpressionAttributeNames: Record<string, string>
|
|
652
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
653
|
+
}
|
|
654
|
+
} {
|
|
655
|
+
const keys = AnalyticsKeyPatterns.pageStats
|
|
656
|
+
const ttl = this.getTtlForPeriod(stats.period)
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
command: 'UpdateItem',
|
|
660
|
+
input: {
|
|
661
|
+
TableName: this.options.tableName,
|
|
662
|
+
Key: {
|
|
663
|
+
pk: { S: keys.pk(stats.siteId) },
|
|
664
|
+
sk: { S: keys.sk(stats.period, stats.periodStart, stats.path) },
|
|
665
|
+
},
|
|
666
|
+
UpdateExpression: `
|
|
667
|
+
SET #pv = if_not_exists(#pv, :zero) + :pv,
|
|
668
|
+
#uv = if_not_exists(#uv, :zero) + :uv,
|
|
669
|
+
#entries = if_not_exists(#entries, :zero) + :entries,
|
|
670
|
+
#exits = if_not_exists(#exits, :zero) + :exits,
|
|
671
|
+
#bounces = if_not_exists(#bounces, :zero) + :bounces,
|
|
672
|
+
gsi1pk = :gsi1pk,
|
|
673
|
+
gsi1sk = :gsi1sk,
|
|
674
|
+
#path = :path,
|
|
675
|
+
#et = :et
|
|
676
|
+
${stats.title ? ', #title = :title' : ''}
|
|
677
|
+
${ttl ? ', #ttl = :ttl' : ''}
|
|
678
|
+
`.trim(),
|
|
679
|
+
ExpressionAttributeNames: {
|
|
680
|
+
'#pv': 'pageViews',
|
|
681
|
+
'#uv': 'uniqueVisitors',
|
|
682
|
+
'#entries': 'entries',
|
|
683
|
+
'#exits': 'exits',
|
|
684
|
+
'#bounces': 'bounces',
|
|
685
|
+
'#path': 'path',
|
|
686
|
+
'#et': '_et',
|
|
687
|
+
...(stats.title ? { '#title': 'title' } : {}),
|
|
688
|
+
...(ttl ? { '#ttl': 'ttl' } : {}),
|
|
689
|
+
},
|
|
690
|
+
ExpressionAttributeValues: {
|
|
691
|
+
':pv': { N: String(stats.pageViews) },
|
|
692
|
+
':uv': { N: String(stats.uniqueVisitors) },
|
|
693
|
+
':entries': { N: String(stats.entries) },
|
|
694
|
+
':exits': { N: String(stats.exits) },
|
|
695
|
+
':bounces': { N: String(stats.bounces) },
|
|
696
|
+
':gsi1pk': { S: keys.gsi1pk(stats.siteId, stats.period, stats.periodStart) },
|
|
697
|
+
':gsi1sk': { S: keys.gsi1sk(stats.pageViews, stats.path) },
|
|
698
|
+
':path': { S: stats.path },
|
|
699
|
+
':zero': { N: '0' },
|
|
700
|
+
':et': { S: 'PageStats' },
|
|
701
|
+
...(stats.title ? { ':title': { S: stats.title } } : {}),
|
|
702
|
+
...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Generate command to get top pages for a period
|
|
710
|
+
*/
|
|
711
|
+
getTopPagesCommand(
|
|
712
|
+
siteId: string,
|
|
713
|
+
period: AggregationPeriod,
|
|
714
|
+
periodStart: string,
|
|
715
|
+
limit: number = 10,
|
|
716
|
+
): {
|
|
717
|
+
command: 'Query'
|
|
718
|
+
input: {
|
|
719
|
+
TableName: string
|
|
720
|
+
IndexName: string
|
|
721
|
+
KeyConditionExpression: string
|
|
722
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
723
|
+
Limit: number
|
|
724
|
+
ScanIndexForward: boolean
|
|
725
|
+
}
|
|
726
|
+
} {
|
|
727
|
+
const keys = AnalyticsKeyPatterns.pageStats
|
|
728
|
+
return {
|
|
729
|
+
command: 'Query',
|
|
730
|
+
input: {
|
|
731
|
+
TableName: this.options.tableName,
|
|
732
|
+
IndexName: 'GSI1',
|
|
733
|
+
KeyConditionExpression: 'gsi1pk = :pk',
|
|
734
|
+
ExpressionAttributeValues: {
|
|
735
|
+
':pk': { S: keys.gsi1pk(siteId, period, periodStart) },
|
|
736
|
+
},
|
|
737
|
+
Limit: limit,
|
|
738
|
+
ScanIndexForward: false, // Descending order by page views
|
|
739
|
+
},
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ==========================================================================
|
|
744
|
+
// Referrer Stats Operations
|
|
745
|
+
// ==========================================================================
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Generate command to upsert referrer stats
|
|
749
|
+
*/
|
|
750
|
+
upsertReferrerStatsCommand(stats: ReferrerStats): {
|
|
751
|
+
command: 'UpdateItem'
|
|
752
|
+
input: {
|
|
753
|
+
TableName: string
|
|
754
|
+
Key: Record<string, unknown>
|
|
755
|
+
UpdateExpression: string
|
|
756
|
+
ExpressionAttributeNames: Record<string, string>
|
|
757
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
758
|
+
}
|
|
759
|
+
} {
|
|
760
|
+
const keys = AnalyticsKeyPatterns.referrerStats
|
|
761
|
+
const ttl = this.getTtlForPeriod(stats.period)
|
|
762
|
+
|
|
763
|
+
return {
|
|
764
|
+
command: 'UpdateItem',
|
|
765
|
+
input: {
|
|
766
|
+
TableName: this.options.tableName,
|
|
767
|
+
Key: {
|
|
768
|
+
pk: { S: keys.pk(stats.siteId) },
|
|
769
|
+
sk: { S: keys.sk(stats.period, stats.periodStart, stats.source) },
|
|
770
|
+
},
|
|
771
|
+
UpdateExpression: `
|
|
772
|
+
SET #visitors = if_not_exists(#visitors, :zero) + :visitors,
|
|
773
|
+
#pv = if_not_exists(#pv, :zero) + :pv,
|
|
774
|
+
#source = :source,
|
|
775
|
+
#et = :et
|
|
776
|
+
${stats.referrer ? ', #referrer = :referrer' : ''}
|
|
777
|
+
${ttl ? ', #ttl = :ttl' : ''}
|
|
778
|
+
`.trim(),
|
|
779
|
+
ExpressionAttributeNames: {
|
|
780
|
+
'#visitors': 'visitors',
|
|
781
|
+
'#pv': 'pageViews',
|
|
782
|
+
'#source': 'source',
|
|
783
|
+
'#et': '_et',
|
|
784
|
+
...(stats.referrer ? { '#referrer': 'referrer' } : {}),
|
|
785
|
+
...(ttl ? { '#ttl': 'ttl' } : {}),
|
|
786
|
+
},
|
|
787
|
+
ExpressionAttributeValues: {
|
|
788
|
+
':visitors': { N: String(stats.visitors) },
|
|
789
|
+
':pv': { N: String(stats.pageViews) },
|
|
790
|
+
':source': { S: stats.source },
|
|
791
|
+
':zero': { N: '0' },
|
|
792
|
+
':et': { S: 'ReferrerStats' },
|
|
793
|
+
...(stats.referrer ? { ':referrer': { S: stats.referrer } } : {}),
|
|
794
|
+
...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ==========================================================================
|
|
801
|
+
// Goal Operations
|
|
802
|
+
// ==========================================================================
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Generate command to create a goal
|
|
806
|
+
*/
|
|
807
|
+
createGoalCommand(goal: Goal): {
|
|
808
|
+
command: 'PutItem'
|
|
809
|
+
input: {
|
|
810
|
+
TableName: string
|
|
811
|
+
Item: Record<string, unknown>
|
|
812
|
+
ConditionExpression: string
|
|
813
|
+
}
|
|
814
|
+
} {
|
|
815
|
+
const keys = AnalyticsKeyPatterns.goal
|
|
816
|
+
return {
|
|
817
|
+
command: 'PutItem',
|
|
818
|
+
input: {
|
|
819
|
+
TableName: this.options.tableName,
|
|
820
|
+
Item: {
|
|
821
|
+
pk: { S: keys.pk(goal.siteId) },
|
|
822
|
+
sk: { S: keys.sk(goal.id) },
|
|
823
|
+
id: { S: goal.id },
|
|
824
|
+
siteId: { S: goal.siteId },
|
|
825
|
+
name: { S: goal.name },
|
|
826
|
+
type: { S: goal.type },
|
|
827
|
+
pattern: { S: goal.pattern },
|
|
828
|
+
matchType: { S: goal.matchType },
|
|
829
|
+
isActive: { BOOL: goal.isActive },
|
|
830
|
+
createdAt: { S: goal.createdAt.toISOString() },
|
|
831
|
+
updatedAt: { S: goal.updatedAt.toISOString() },
|
|
832
|
+
_et: { S: 'Goal' },
|
|
833
|
+
...(goal.value !== undefined ? { value: { N: String(goal.value) } } : {}),
|
|
834
|
+
},
|
|
835
|
+
ConditionExpression: 'attribute_not_exists(pk)',
|
|
836
|
+
},
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Generate command to list goals for a site
|
|
842
|
+
*/
|
|
843
|
+
listGoalsCommand(siteId: string): {
|
|
844
|
+
command: 'Query'
|
|
845
|
+
input: {
|
|
846
|
+
TableName: string
|
|
847
|
+
KeyConditionExpression: string
|
|
848
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
849
|
+
}
|
|
850
|
+
} {
|
|
851
|
+
const keys = AnalyticsKeyPatterns.goal
|
|
852
|
+
return {
|
|
853
|
+
command: 'Query',
|
|
854
|
+
input: {
|
|
855
|
+
TableName: this.options.tableName,
|
|
856
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
|
|
857
|
+
ExpressionAttributeValues: {
|
|
858
|
+
':pk': { S: keys.pk(siteId) },
|
|
859
|
+
':skPrefix': { S: 'GOAL#' },
|
|
860
|
+
},
|
|
861
|
+
},
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ==========================================================================
|
|
866
|
+
// Realtime Stats Operations
|
|
867
|
+
// ==========================================================================
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Generate command to update realtime stats
|
|
871
|
+
* Uses a DynamoDB String Set to track unique visitor IDs per minute
|
|
872
|
+
*/
|
|
873
|
+
updateRealtimeStatsCommand(stats: RealtimeStats & { visitorId?: string }): {
|
|
874
|
+
command: 'UpdateItem'
|
|
875
|
+
input: {
|
|
876
|
+
TableName: string
|
|
877
|
+
Key: Record<string, unknown>
|
|
878
|
+
UpdateExpression: string
|
|
879
|
+
ExpressionAttributeNames: Record<string, string>
|
|
880
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
881
|
+
}
|
|
882
|
+
} {
|
|
883
|
+
const keys = AnalyticsKeyPatterns.realtimeStats
|
|
884
|
+
|
|
885
|
+
// Build expression based on whether we have a visitor ID to track
|
|
886
|
+
const hasVisitorId = stats.visitorId && stats.visitorId.length > 0
|
|
887
|
+
|
|
888
|
+
return {
|
|
889
|
+
command: 'UpdateItem',
|
|
890
|
+
input: {
|
|
891
|
+
TableName: this.options.tableName,
|
|
892
|
+
Key: {
|
|
893
|
+
pk: { S: keys.pk(stats.siteId) },
|
|
894
|
+
sk: { S: keys.sk(stats.minute) },
|
|
895
|
+
},
|
|
896
|
+
UpdateExpression: hasVisitorId
|
|
897
|
+
? `
|
|
898
|
+
SET #pv = if_not_exists(#pv, :zero) + :pvInc,
|
|
899
|
+
#activePages = :activePages,
|
|
900
|
+
#ttl = :ttl,
|
|
901
|
+
#et = :et
|
|
902
|
+
ADD #visitorIds :visitorId
|
|
903
|
+
`.trim()
|
|
904
|
+
: `
|
|
905
|
+
SET #pv = if_not_exists(#pv, :zero) + :pvInc,
|
|
906
|
+
#activePages = :activePages,
|
|
907
|
+
#ttl = :ttl,
|
|
908
|
+
#et = :et
|
|
909
|
+
`.trim(),
|
|
910
|
+
ExpressionAttributeNames: {
|
|
911
|
+
'#pv': 'pageViews',
|
|
912
|
+
'#activePages': 'activePages',
|
|
913
|
+
'#ttl': 'ttl',
|
|
914
|
+
'#et': '_et',
|
|
915
|
+
...(hasVisitorId ? { '#visitorIds': 'visitorIds' } : {}),
|
|
916
|
+
},
|
|
917
|
+
ExpressionAttributeValues: {
|
|
918
|
+
':pvInc': { N: '1' },
|
|
919
|
+
':activePages': { S: JSON.stringify(stats.activePages) },
|
|
920
|
+
':ttl': { N: String(stats.ttl) },
|
|
921
|
+
':zero': { N: '0' },
|
|
922
|
+
':et': { S: 'RealtimeStats' },
|
|
923
|
+
...(hasVisitorId ? { ':visitorId': { SS: [stats.visitorId!] } } : {}),
|
|
924
|
+
},
|
|
925
|
+
},
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Generate command to get realtime stats (last N minutes)
|
|
931
|
+
*/
|
|
932
|
+
getRealtimeStatsCommand(siteId: string, minutes: number = 5): {
|
|
933
|
+
command: 'Query'
|
|
934
|
+
input: {
|
|
935
|
+
TableName: string
|
|
936
|
+
KeyConditionExpression: string
|
|
937
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
938
|
+
ScanIndexForward: boolean
|
|
939
|
+
Limit: number
|
|
940
|
+
}
|
|
941
|
+
} {
|
|
942
|
+
const keys = AnalyticsKeyPatterns.realtimeStats
|
|
943
|
+
const now = new Date()
|
|
944
|
+
const startMinute = new Date(now.getTime() - minutes * 60 * 1000)
|
|
945
|
+
.toISOString()
|
|
946
|
+
.slice(0, 16) // Truncate to minute
|
|
947
|
+
|
|
948
|
+
return {
|
|
949
|
+
command: 'Query',
|
|
950
|
+
input: {
|
|
951
|
+
TableName: this.options.tableName,
|
|
952
|
+
KeyConditionExpression: 'pk = :pk AND sk >= :start',
|
|
953
|
+
ExpressionAttributeValues: {
|
|
954
|
+
':pk': { S: keys.pk(siteId) },
|
|
955
|
+
':start': { S: keys.sk(startMinute) },
|
|
956
|
+
},
|
|
957
|
+
ScanIndexForward: false,
|
|
958
|
+
Limit: minutes,
|
|
959
|
+
},
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// ==========================================================================
|
|
964
|
+
// Geo Stats Operations
|
|
965
|
+
// ==========================================================================
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Generate command to upsert geo stats
|
|
969
|
+
*/
|
|
970
|
+
upsertGeoStatsCommand(stats: GeoStats): {
|
|
971
|
+
command: 'UpdateItem'
|
|
972
|
+
input: {
|
|
973
|
+
TableName: string
|
|
974
|
+
Key: Record<string, unknown>
|
|
975
|
+
UpdateExpression: string
|
|
976
|
+
ExpressionAttributeNames: Record<string, string>
|
|
977
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
978
|
+
}
|
|
979
|
+
} {
|
|
980
|
+
const keys = AnalyticsKeyPatterns.geoStats
|
|
981
|
+
const ttl = this.getTtlForPeriod(stats.period)
|
|
982
|
+
|
|
983
|
+
return {
|
|
984
|
+
command: 'UpdateItem',
|
|
985
|
+
input: {
|
|
986
|
+
TableName: this.options.tableName,
|
|
987
|
+
Key: {
|
|
988
|
+
pk: { S: keys.pk(stats.siteId) },
|
|
989
|
+
sk: { S: keys.sk(stats.period, stats.periodStart, stats.country, stats.region) },
|
|
990
|
+
},
|
|
991
|
+
UpdateExpression: `
|
|
992
|
+
SET #visitors = if_not_exists(#visitors, :zero) + :visitors,
|
|
993
|
+
#pv = if_not_exists(#pv, :zero) + :pv,
|
|
994
|
+
#country = :country,
|
|
995
|
+
#et = :et
|
|
996
|
+
${stats.region ? ', #region = :region' : ''}
|
|
997
|
+
${stats.city ? ', #city = :city' : ''}
|
|
998
|
+
${ttl ? ', #ttl = :ttl' : ''}
|
|
999
|
+
`.trim(),
|
|
1000
|
+
ExpressionAttributeNames: {
|
|
1001
|
+
'#visitors': 'visitors',
|
|
1002
|
+
'#pv': 'pageViews',
|
|
1003
|
+
'#country': 'country',
|
|
1004
|
+
'#et': '_et',
|
|
1005
|
+
...(stats.region ? { '#region': 'region' } : {}),
|
|
1006
|
+
...(stats.city ? { '#city': 'city' } : {}),
|
|
1007
|
+
...(ttl ? { '#ttl': 'ttl' } : {}),
|
|
1008
|
+
},
|
|
1009
|
+
ExpressionAttributeValues: {
|
|
1010
|
+
':visitors': { N: String(stats.visitors) },
|
|
1011
|
+
':pv': { N: String(stats.pageViews) },
|
|
1012
|
+
':country': { S: stats.country },
|
|
1013
|
+
':zero': { N: '0' },
|
|
1014
|
+
':et': { S: 'GeoStats' },
|
|
1015
|
+
...(stats.region ? { ':region': { S: stats.region } } : {}),
|
|
1016
|
+
...(stats.city ? { ':city': { S: stats.city } } : {}),
|
|
1017
|
+
...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Generate command to get geo stats for a period
|
|
1025
|
+
*/
|
|
1026
|
+
getGeoStatsCommand(
|
|
1027
|
+
siteId: string,
|
|
1028
|
+
period: AggregationPeriod,
|
|
1029
|
+
periodStart: string,
|
|
1030
|
+
limit: number = 10,
|
|
1031
|
+
): {
|
|
1032
|
+
command: 'Query'
|
|
1033
|
+
input: {
|
|
1034
|
+
TableName: string
|
|
1035
|
+
KeyConditionExpression: string
|
|
1036
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
1037
|
+
Limit: number
|
|
1038
|
+
}
|
|
1039
|
+
} {
|
|
1040
|
+
const keys = AnalyticsKeyPatterns.geoStats
|
|
1041
|
+
return {
|
|
1042
|
+
command: 'Query',
|
|
1043
|
+
input: {
|
|
1044
|
+
TableName: this.options.tableName,
|
|
1045
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
|
|
1046
|
+
ExpressionAttributeValues: {
|
|
1047
|
+
':pk': { S: keys.pk(siteId) },
|
|
1048
|
+
':skPrefix': { S: `GEOSTATS#${period.toUpperCase()}#${periodStart}` },
|
|
1049
|
+
},
|
|
1050
|
+
Limit: limit,
|
|
1051
|
+
},
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Generate command to get regions for a country
|
|
1057
|
+
*/
|
|
1058
|
+
getRegionsCommand(
|
|
1059
|
+
siteId: string,
|
|
1060
|
+
period: AggregationPeriod,
|
|
1061
|
+
periodStart: string,
|
|
1062
|
+
country?: string,
|
|
1063
|
+
limit: number = 10,
|
|
1064
|
+
): {
|
|
1065
|
+
command: 'Query'
|
|
1066
|
+
input: {
|
|
1067
|
+
TableName: string
|
|
1068
|
+
KeyConditionExpression: string
|
|
1069
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
1070
|
+
FilterExpression?: string
|
|
1071
|
+
ExpressionAttributeNames?: Record<string, string>
|
|
1072
|
+
Limit: number
|
|
1073
|
+
}
|
|
1074
|
+
} {
|
|
1075
|
+
const keys = AnalyticsKeyPatterns.geoStats
|
|
1076
|
+
const skPrefix = country
|
|
1077
|
+
? `GEOSTATS#${period.toUpperCase()}#${periodStart}#${country}#`
|
|
1078
|
+
: `GEOSTATS#${period.toUpperCase()}#${periodStart}`
|
|
1079
|
+
|
|
1080
|
+
const input: ReturnType<typeof this.getRegionsCommand>['input'] = {
|
|
1081
|
+
TableName: this.options.tableName,
|
|
1082
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
|
|
1083
|
+
ExpressionAttributeValues: {
|
|
1084
|
+
':pk': { S: keys.pk(siteId) },
|
|
1085
|
+
':skPrefix': { S: skPrefix },
|
|
1086
|
+
},
|
|
1087
|
+
Limit: limit,
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Filter to only include records with region but without city (region-level aggregates)
|
|
1091
|
+
input.FilterExpression = 'attribute_exists(#region) AND attribute_not_exists(#city)'
|
|
1092
|
+
input.ExpressionAttributeNames = {
|
|
1093
|
+
'#region': 'region',
|
|
1094
|
+
'#city': 'city',
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
return { command: 'Query', input }
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Generate command to get cities, optionally filtered by country and/or region
|
|
1102
|
+
*/
|
|
1103
|
+
getCitiesCommand(
|
|
1104
|
+
siteId: string,
|
|
1105
|
+
period: AggregationPeriod,
|
|
1106
|
+
periodStart: string,
|
|
1107
|
+
options?: { country?: string, region?: string },
|
|
1108
|
+
limit: number = 10,
|
|
1109
|
+
): {
|
|
1110
|
+
command: 'Query'
|
|
1111
|
+
input: {
|
|
1112
|
+
TableName: string
|
|
1113
|
+
KeyConditionExpression: string
|
|
1114
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
1115
|
+
FilterExpression?: string
|
|
1116
|
+
ExpressionAttributeNames?: Record<string, string>
|
|
1117
|
+
Limit: number
|
|
1118
|
+
}
|
|
1119
|
+
} {
|
|
1120
|
+
const keys = AnalyticsKeyPatterns.geoStats
|
|
1121
|
+
let skPrefix = `GEOSTATS#${period.toUpperCase()}#${periodStart}`
|
|
1122
|
+
|
|
1123
|
+
if (options?.country) {
|
|
1124
|
+
skPrefix += `#${options.country}`
|
|
1125
|
+
if (options?.region) {
|
|
1126
|
+
skPrefix += `#${options.region}`
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const input: { TableName: string; KeyConditionExpression: string; ExpressionAttributeValues: Record<string, unknown>; FilterExpression?: string; ExpressionAttributeNames?: Record<string, string>; Limit: number } = {
|
|
1131
|
+
TableName: this.options.tableName,
|
|
1132
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
|
|
1133
|
+
ExpressionAttributeValues: {
|
|
1134
|
+
':pk': { S: keys.pk(siteId) },
|
|
1135
|
+
':skPrefix': { S: skPrefix },
|
|
1136
|
+
},
|
|
1137
|
+
Limit: limit,
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Filter to only include records with city (city-level aggregates)
|
|
1141
|
+
input.FilterExpression = 'attribute_exists(#city)'
|
|
1142
|
+
input.ExpressionAttributeNames = { '#city': 'city' }
|
|
1143
|
+
|
|
1144
|
+
// Add region filter if country is specified but region is not (filter by region attribute)
|
|
1145
|
+
if (options?.country && options?.region) {
|
|
1146
|
+
input.FilterExpression += ' AND #region = :region'
|
|
1147
|
+
input.ExpressionAttributeNames['#region'] = 'region'
|
|
1148
|
+
input.ExpressionAttributeValues[':region'] = { S: options.region }
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
return { command: 'Query', input }
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// ==========================================================================
|
|
1155
|
+
// Device Stats Operations
|
|
1156
|
+
// ==========================================================================
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Generate command to upsert device stats
|
|
1160
|
+
*/
|
|
1161
|
+
upsertDeviceStatsCommand(stats: DeviceStats): {
|
|
1162
|
+
command: 'UpdateItem'
|
|
1163
|
+
input: {
|
|
1164
|
+
TableName: string
|
|
1165
|
+
Key: Record<string, unknown>
|
|
1166
|
+
UpdateExpression: string
|
|
1167
|
+
ExpressionAttributeNames: Record<string, string>
|
|
1168
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
1169
|
+
}
|
|
1170
|
+
} {
|
|
1171
|
+
const keys = AnalyticsKeyPatterns.deviceStats
|
|
1172
|
+
const ttl = this.getTtlForPeriod(stats.period)
|
|
1173
|
+
|
|
1174
|
+
return {
|
|
1175
|
+
command: 'UpdateItem',
|
|
1176
|
+
input: {
|
|
1177
|
+
TableName: this.options.tableName,
|
|
1178
|
+
Key: {
|
|
1179
|
+
pk: { S: keys.pk(stats.siteId) },
|
|
1180
|
+
sk: { S: keys.sk(stats.period, stats.periodStart, stats.dimension, stats.value) },
|
|
1181
|
+
},
|
|
1182
|
+
UpdateExpression: `
|
|
1183
|
+
SET #visitors = if_not_exists(#visitors, :zero) + :visitors,
|
|
1184
|
+
#pv = if_not_exists(#pv, :zero) + :pv,
|
|
1185
|
+
#dimension = :dimension,
|
|
1186
|
+
#value = :value,
|
|
1187
|
+
#et = :et
|
|
1188
|
+
${ttl ? ', #ttl = :ttl' : ''}
|
|
1189
|
+
`.trim(),
|
|
1190
|
+
ExpressionAttributeNames: {
|
|
1191
|
+
'#visitors': 'visitors',
|
|
1192
|
+
'#pv': 'pageViews',
|
|
1193
|
+
'#dimension': 'dimension',
|
|
1194
|
+
'#value': 'value',
|
|
1195
|
+
'#et': '_et',
|
|
1196
|
+
...(ttl ? { '#ttl': 'ttl' } : {}),
|
|
1197
|
+
},
|
|
1198
|
+
ExpressionAttributeValues: {
|
|
1199
|
+
':visitors': { N: String(stats.visitors) },
|
|
1200
|
+
':pv': { N: String(stats.pageViews) },
|
|
1201
|
+
':dimension': { S: stats.dimension },
|
|
1202
|
+
':value': { S: stats.value },
|
|
1203
|
+
':zero': { N: '0' },
|
|
1204
|
+
':et': { S: 'DeviceStats' },
|
|
1205
|
+
...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
|
|
1206
|
+
},
|
|
1207
|
+
},
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Generate command to get device stats for a period
|
|
1213
|
+
*/
|
|
1214
|
+
getDeviceStatsCommand(
|
|
1215
|
+
siteId: string,
|
|
1216
|
+
period: AggregationPeriod,
|
|
1217
|
+
periodStart: string,
|
|
1218
|
+
dimension?: 'device' | 'browser' | 'os' | 'screen',
|
|
1219
|
+
): {
|
|
1220
|
+
command: 'Query'
|
|
1221
|
+
input: {
|
|
1222
|
+
TableName: string
|
|
1223
|
+
KeyConditionExpression: string
|
|
1224
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
1225
|
+
FilterExpression?: string
|
|
1226
|
+
ExpressionAttributeNames?: Record<string, string>
|
|
1227
|
+
}
|
|
1228
|
+
} {
|
|
1229
|
+
const keys = AnalyticsKeyPatterns.deviceStats
|
|
1230
|
+
const input: ReturnType<typeof this.getDeviceStatsCommand>['input'] = {
|
|
1231
|
+
TableName: this.options.tableName,
|
|
1232
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
|
|
1233
|
+
ExpressionAttributeValues: {
|
|
1234
|
+
':pk': { S: keys.pk(siteId) },
|
|
1235
|
+
':skPrefix': { S: `DEVICESTATS#${period.toUpperCase()}#${periodStart}` },
|
|
1236
|
+
},
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (dimension) {
|
|
1240
|
+
input.FilterExpression = '#dim = :dim'
|
|
1241
|
+
input.ExpressionAttributeNames = { '#dim': 'dimension' }
|
|
1242
|
+
input.ExpressionAttributeValues[':dim'] = { S: dimension }
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
return { command: 'Query', input }
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// ==========================================================================
|
|
1249
|
+
// Event Stats Operations
|
|
1250
|
+
// ==========================================================================
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Generate command to upsert event stats
|
|
1254
|
+
*/
|
|
1255
|
+
upsertEventStatsCommand(stats: EventStats): {
|
|
1256
|
+
command: 'UpdateItem'
|
|
1257
|
+
input: {
|
|
1258
|
+
TableName: string
|
|
1259
|
+
Key: Record<string, unknown>
|
|
1260
|
+
UpdateExpression: string
|
|
1261
|
+
ExpressionAttributeNames: Record<string, string>
|
|
1262
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
1263
|
+
}
|
|
1264
|
+
} {
|
|
1265
|
+
const keys = AnalyticsKeyPatterns.eventStats
|
|
1266
|
+
const ttl = this.getTtlForPeriod(stats.period)
|
|
1267
|
+
|
|
1268
|
+
return {
|
|
1269
|
+
command: 'UpdateItem',
|
|
1270
|
+
input: {
|
|
1271
|
+
TableName: this.options.tableName,
|
|
1272
|
+
Key: {
|
|
1273
|
+
pk: { S: keys.pk(stats.siteId) },
|
|
1274
|
+
sk: { S: keys.sk(stats.period, stats.periodStart, stats.eventName) },
|
|
1275
|
+
},
|
|
1276
|
+
UpdateExpression: `
|
|
1277
|
+
SET #count = if_not_exists(#count, :zero) + :count,
|
|
1278
|
+
#uv = if_not_exists(#uv, :zero) + :uv,
|
|
1279
|
+
#totalValue = if_not_exists(#totalValue, :zero) + :totalValue,
|
|
1280
|
+
#eventName = :eventName,
|
|
1281
|
+
#et = :et
|
|
1282
|
+
${ttl ? ', #ttl = :ttl' : ''}
|
|
1283
|
+
`.trim(),
|
|
1284
|
+
ExpressionAttributeNames: {
|
|
1285
|
+
'#count': 'count',
|
|
1286
|
+
'#uv': 'uniqueVisitors',
|
|
1287
|
+
'#totalValue': 'totalValue',
|
|
1288
|
+
'#eventName': 'eventName',
|
|
1289
|
+
'#et': '_et',
|
|
1290
|
+
...(ttl ? { '#ttl': 'ttl' } : {}),
|
|
1291
|
+
},
|
|
1292
|
+
ExpressionAttributeValues: {
|
|
1293
|
+
':count': { N: String(stats.count) },
|
|
1294
|
+
':uv': { N: String(stats.uniqueVisitors) },
|
|
1295
|
+
':totalValue': { N: String(stats.totalValue) },
|
|
1296
|
+
':eventName': { S: stats.eventName },
|
|
1297
|
+
':zero': { N: '0' },
|
|
1298
|
+
':et': { S: 'EventStats' },
|
|
1299
|
+
...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
|
|
1300
|
+
},
|
|
1301
|
+
},
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Generate command to get event stats for a period
|
|
1307
|
+
*/
|
|
1308
|
+
getEventStatsCommand(
|
|
1309
|
+
siteId: string,
|
|
1310
|
+
period: AggregationPeriod,
|
|
1311
|
+
periodStart: string,
|
|
1312
|
+
): {
|
|
1313
|
+
command: 'Query'
|
|
1314
|
+
input: {
|
|
1315
|
+
TableName: string
|
|
1316
|
+
KeyConditionExpression: string
|
|
1317
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
1318
|
+
}
|
|
1319
|
+
} {
|
|
1320
|
+
const keys = AnalyticsKeyPatterns.eventStats
|
|
1321
|
+
return {
|
|
1322
|
+
command: 'Query',
|
|
1323
|
+
input: {
|
|
1324
|
+
TableName: this.options.tableName,
|
|
1325
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
|
|
1326
|
+
ExpressionAttributeValues: {
|
|
1327
|
+
':pk': { S: keys.pk(siteId) },
|
|
1328
|
+
':skPrefix': { S: `EVENTSTATS#${period.toUpperCase()}#${periodStart}` },
|
|
1329
|
+
},
|
|
1330
|
+
},
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// ==========================================================================
|
|
1335
|
+
// Goal Stats Operations
|
|
1336
|
+
// ==========================================================================
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Generate command to upsert goal stats
|
|
1340
|
+
*/
|
|
1341
|
+
upsertGoalStatsCommand(stats: GoalStats): {
|
|
1342
|
+
command: 'UpdateItem'
|
|
1343
|
+
input: {
|
|
1344
|
+
TableName: string
|
|
1345
|
+
Key: Record<string, unknown>
|
|
1346
|
+
UpdateExpression: string
|
|
1347
|
+
ExpressionAttributeNames: Record<string, string>
|
|
1348
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
1349
|
+
}
|
|
1350
|
+
} {
|
|
1351
|
+
const keys = AnalyticsKeyPatterns.goalStats
|
|
1352
|
+
const ttl = this.getTtlForPeriod(stats.period)
|
|
1353
|
+
|
|
1354
|
+
return {
|
|
1355
|
+
command: 'UpdateItem',
|
|
1356
|
+
input: {
|
|
1357
|
+
TableName: this.options.tableName,
|
|
1358
|
+
Key: {
|
|
1359
|
+
pk: { S: keys.pk(stats.siteId) },
|
|
1360
|
+
sk: { S: keys.sk(stats.goalId, stats.period, stats.periodStart) },
|
|
1361
|
+
},
|
|
1362
|
+
UpdateExpression: `
|
|
1363
|
+
SET #conversions = if_not_exists(#conversions, :zero) + :conversions,
|
|
1364
|
+
#uniqueConv = if_not_exists(#uniqueConv, :zero) + :uniqueConv,
|
|
1365
|
+
#revenue = if_not_exists(#revenue, :zero) + :revenue,
|
|
1366
|
+
#goalId = :goalId,
|
|
1367
|
+
#et = :et
|
|
1368
|
+
${ttl ? ', #ttl = :ttl' : ''}
|
|
1369
|
+
`.trim(),
|
|
1370
|
+
ExpressionAttributeNames: {
|
|
1371
|
+
'#conversions': 'conversions',
|
|
1372
|
+
'#uniqueConv': 'uniqueConversions',
|
|
1373
|
+
'#revenue': 'revenue',
|
|
1374
|
+
'#goalId': 'goalId',
|
|
1375
|
+
'#et': '_et',
|
|
1376
|
+
...(ttl ? { '#ttl': 'ttl' } : {}),
|
|
1377
|
+
},
|
|
1378
|
+
ExpressionAttributeValues: {
|
|
1379
|
+
':conversions': { N: String(stats.conversions) },
|
|
1380
|
+
':uniqueConv': { N: String(stats.uniqueConversions) },
|
|
1381
|
+
':revenue': { N: String(stats.revenue) },
|
|
1382
|
+
':goalId': { S: stats.goalId },
|
|
1383
|
+
':zero': { N: '0' },
|
|
1384
|
+
':et': { S: 'GoalStats' },
|
|
1385
|
+
...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
|
|
1386
|
+
},
|
|
1387
|
+
},
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Generate command to get goal stats for a period
|
|
1393
|
+
*/
|
|
1394
|
+
getGoalStatsCommand(
|
|
1395
|
+
siteId: string,
|
|
1396
|
+
goalId: string,
|
|
1397
|
+
period: AggregationPeriod,
|
|
1398
|
+
startPeriod: string,
|
|
1399
|
+
endPeriod: string,
|
|
1400
|
+
): {
|
|
1401
|
+
command: 'Query'
|
|
1402
|
+
input: {
|
|
1403
|
+
TableName: string
|
|
1404
|
+
KeyConditionExpression: string
|
|
1405
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
1406
|
+
ScanIndexForward: boolean
|
|
1407
|
+
}
|
|
1408
|
+
} {
|
|
1409
|
+
const keys = AnalyticsKeyPatterns.goalStats
|
|
1410
|
+
return {
|
|
1411
|
+
command: 'Query',
|
|
1412
|
+
input: {
|
|
1413
|
+
TableName: this.options.tableName,
|
|
1414
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
1415
|
+
ExpressionAttributeValues: {
|
|
1416
|
+
':pk': { S: keys.pk(siteId) },
|
|
1417
|
+
':start': { S: keys.sk(goalId, period, startPeriod) },
|
|
1418
|
+
':end': { S: keys.sk(goalId, period, endPeriod) },
|
|
1419
|
+
},
|
|
1420
|
+
ScanIndexForward: true,
|
|
1421
|
+
},
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// ==========================================================================
|
|
1426
|
+
// Campaign Stats Operations
|
|
1427
|
+
// ==========================================================================
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Generate command to upsert campaign stats
|
|
1431
|
+
*/
|
|
1432
|
+
upsertCampaignStatsCommand(stats: CampaignStats): {
|
|
1433
|
+
command: 'UpdateItem'
|
|
1434
|
+
input: {
|
|
1435
|
+
TableName: string
|
|
1436
|
+
Key: Record<string, unknown>
|
|
1437
|
+
UpdateExpression: string
|
|
1438
|
+
ExpressionAttributeNames: Record<string, string>
|
|
1439
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
1440
|
+
}
|
|
1441
|
+
} {
|
|
1442
|
+
const keys = AnalyticsKeyPatterns.campaignStats
|
|
1443
|
+
const ttl = this.getTtlForPeriod(stats.period)
|
|
1444
|
+
|
|
1445
|
+
return {
|
|
1446
|
+
command: 'UpdateItem',
|
|
1447
|
+
input: {
|
|
1448
|
+
TableName: this.options.tableName,
|
|
1449
|
+
Key: {
|
|
1450
|
+
pk: { S: keys.pk(stats.siteId) },
|
|
1451
|
+
sk: { S: keys.sk(stats.period, stats.periodStart, stats.utmSource, stats.utmCampaign) },
|
|
1452
|
+
},
|
|
1453
|
+
UpdateExpression: `
|
|
1454
|
+
SET #visitors = if_not_exists(#visitors, :zero) + :visitors,
|
|
1455
|
+
#pv = if_not_exists(#pv, :zero) + :pv,
|
|
1456
|
+
#conversions = if_not_exists(#conversions, :zero) + :conversions,
|
|
1457
|
+
#revenue = if_not_exists(#revenue, :zero) + :revenue,
|
|
1458
|
+
#utmSource = :utmSource,
|
|
1459
|
+
#et = :et
|
|
1460
|
+
${stats.utmMedium ? ', #utmMedium = :utmMedium' : ''}
|
|
1461
|
+
${stats.utmCampaign ? ', #utmCampaign = :utmCampaign' : ''}
|
|
1462
|
+
${ttl ? ', #ttl = :ttl' : ''}
|
|
1463
|
+
`.trim(),
|
|
1464
|
+
ExpressionAttributeNames: {
|
|
1465
|
+
'#visitors': 'visitors',
|
|
1466
|
+
'#pv': 'pageViews',
|
|
1467
|
+
'#conversions': 'conversions',
|
|
1468
|
+
'#revenue': 'revenue',
|
|
1469
|
+
'#utmSource': 'utmSource',
|
|
1470
|
+
'#et': '_et',
|
|
1471
|
+
...(stats.utmMedium ? { '#utmMedium': 'utmMedium' } : {}),
|
|
1472
|
+
...(stats.utmCampaign ? { '#utmCampaign': 'utmCampaign' } : {}),
|
|
1473
|
+
...(ttl ? { '#ttl': 'ttl' } : {}),
|
|
1474
|
+
},
|
|
1475
|
+
ExpressionAttributeValues: {
|
|
1476
|
+
':visitors': { N: String(stats.visitors) },
|
|
1477
|
+
':pv': { N: String(stats.pageViews) },
|
|
1478
|
+
':conversions': { N: String(stats.conversions) },
|
|
1479
|
+
':revenue': { N: String(stats.revenue) },
|
|
1480
|
+
':utmSource': { S: stats.utmSource },
|
|
1481
|
+
':zero': { N: '0' },
|
|
1482
|
+
':et': { S: 'CampaignStats' },
|
|
1483
|
+
...(stats.utmMedium ? { ':utmMedium': { S: stats.utmMedium } } : {}),
|
|
1484
|
+
...(stats.utmCampaign ? { ':utmCampaign': { S: stats.utmCampaign } } : {}),
|
|
1485
|
+
...(ttl ? { ':ttl': { N: String(ttl) } } : {}),
|
|
1486
|
+
},
|
|
1487
|
+
},
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* Generate command to get campaign stats for a period
|
|
1493
|
+
*/
|
|
1494
|
+
getCampaignStatsCommand(
|
|
1495
|
+
siteId: string,
|
|
1496
|
+
period: AggregationPeriod,
|
|
1497
|
+
periodStart: string,
|
|
1498
|
+
): {
|
|
1499
|
+
command: 'Query'
|
|
1500
|
+
input: {
|
|
1501
|
+
TableName: string
|
|
1502
|
+
KeyConditionExpression: string
|
|
1503
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
1504
|
+
}
|
|
1505
|
+
} {
|
|
1506
|
+
const keys = AnalyticsKeyPatterns.campaignStats
|
|
1507
|
+
return {
|
|
1508
|
+
command: 'Query',
|
|
1509
|
+
input: {
|
|
1510
|
+
TableName: this.options.tableName,
|
|
1511
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
|
|
1512
|
+
ExpressionAttributeValues: {
|
|
1513
|
+
':pk': { S: keys.pk(siteId) },
|
|
1514
|
+
':skPrefix': { S: `CAMPSTATS#${period.toUpperCase()}#${periodStart}` },
|
|
1515
|
+
},
|
|
1516
|
+
},
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// ==========================================================================
|
|
1521
|
+
// Helper Methods
|
|
1522
|
+
// ==========================================================================
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Get TTL timestamp for a period type
|
|
1526
|
+
*/
|
|
1527
|
+
private getTtlForPeriod(period: AggregationPeriod): number | undefined {
|
|
1528
|
+
if (!this.options.useTtl)
|
|
1529
|
+
return undefined
|
|
1530
|
+
|
|
1531
|
+
const now = Math.floor(Date.now() / 1000)
|
|
1532
|
+
switch (period) {
|
|
1533
|
+
case 'hour':
|
|
1534
|
+
return now + this.options.hourlyAggregateTtl
|
|
1535
|
+
case 'day':
|
|
1536
|
+
return now + this.options.dailyAggregateTtl
|
|
1537
|
+
case 'month':
|
|
1538
|
+
return undefined // Monthly aggregates kept forever
|
|
1539
|
+
default:
|
|
1540
|
+
return undefined
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Get period start string for a date
|
|
1546
|
+
*/
|
|
1547
|
+
static getPeriodStart(date: Date, period: AggregationPeriod): string {
|
|
1548
|
+
const iso = date.toISOString()
|
|
1549
|
+
switch (period) {
|
|
1550
|
+
case 'hour':
|
|
1551
|
+
return `${iso.slice(0, 13)}:00:00.000Z` // 2024-01-15T14:00:00.000Z
|
|
1552
|
+
case 'day':
|
|
1553
|
+
return iso.slice(0, 10) // 2024-01-15
|
|
1554
|
+
case 'month':
|
|
1555
|
+
return iso.slice(0, 7) // 2024-01
|
|
1556
|
+
default:
|
|
1557
|
+
return iso.slice(0, 10)
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Generate a unique ID
|
|
1563
|
+
*/
|
|
1564
|
+
static generateId(): string {
|
|
1565
|
+
return crypto.randomUUID()
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* Hash a visitor identifier (for privacy)
|
|
1570
|
+
*/
|
|
1571
|
+
static async hashVisitorId(
|
|
1572
|
+
ip: string,
|
|
1573
|
+
userAgent: string,
|
|
1574
|
+
siteId: string,
|
|
1575
|
+
salt: string,
|
|
1576
|
+
): Promise<string> {
|
|
1577
|
+
const data = `${ip}|${userAgent}|${siteId}|${salt}`
|
|
1578
|
+
const encoder = new TextEncoder()
|
|
1579
|
+
const dataBuffer = encoder.encode(data)
|
|
1580
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer)
|
|
1581
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
1582
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* Parse referrer to get source
|
|
1587
|
+
*/
|
|
1588
|
+
static parseReferrerSource(referrer: string | undefined): string {
|
|
1589
|
+
if (!referrer)
|
|
1590
|
+
return 'direct'
|
|
1591
|
+
|
|
1592
|
+
try {
|
|
1593
|
+
const url = new URL(referrer)
|
|
1594
|
+
const hostname = url.hostname.toLowerCase()
|
|
1595
|
+
|
|
1596
|
+
// Search engines
|
|
1597
|
+
if (hostname.includes('google'))
|
|
1598
|
+
return 'google'
|
|
1599
|
+
if (hostname.includes('bing'))
|
|
1600
|
+
return 'bing'
|
|
1601
|
+
if (hostname.includes('yahoo'))
|
|
1602
|
+
return 'yahoo'
|
|
1603
|
+
if (hostname.includes('duckduckgo'))
|
|
1604
|
+
return 'duckduckgo'
|
|
1605
|
+
if (hostname.includes('baidu'))
|
|
1606
|
+
return 'baidu'
|
|
1607
|
+
if (hostname.includes('yandex'))
|
|
1608
|
+
return 'yandex'
|
|
1609
|
+
|
|
1610
|
+
// Social media - order matters for substring matches
|
|
1611
|
+
if (hostname.includes('facebook') || hostname.includes('fb.'))
|
|
1612
|
+
return 'facebook'
|
|
1613
|
+
if (hostname.includes('reddit'))
|
|
1614
|
+
return 'reddit' // Check before twitter since reddit contains 't.co'
|
|
1615
|
+
if (hostname.includes('twitter') || hostname === 't.co' || hostname.endsWith('.t.co'))
|
|
1616
|
+
return 'twitter'
|
|
1617
|
+
if (hostname.includes('linkedin'))
|
|
1618
|
+
return 'linkedin'
|
|
1619
|
+
if (hostname.includes('instagram'))
|
|
1620
|
+
return 'instagram'
|
|
1621
|
+
if (hostname.includes('pinterest'))
|
|
1622
|
+
return 'pinterest'
|
|
1623
|
+
if (hostname.includes('youtube'))
|
|
1624
|
+
return 'youtube'
|
|
1625
|
+
if (hostname.includes('tiktok'))
|
|
1626
|
+
return 'tiktok'
|
|
1627
|
+
|
|
1628
|
+
// Return the domain as source
|
|
1629
|
+
return hostname.replace('www.', '')
|
|
1630
|
+
}
|
|
1631
|
+
catch {
|
|
1632
|
+
return 'unknown'
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* Parse user agent for device info
|
|
1638
|
+
*/
|
|
1639
|
+
static parseUserAgent(userAgent: string): {
|
|
1640
|
+
deviceType: DeviceType
|
|
1641
|
+
browser: string
|
|
1642
|
+
browserVersion: string
|
|
1643
|
+
os: string
|
|
1644
|
+
osVersion: string
|
|
1645
|
+
} {
|
|
1646
|
+
// Simple UA parsing - in production, use a proper UA parser library
|
|
1647
|
+
const ua = userAgent.toLowerCase()
|
|
1648
|
+
|
|
1649
|
+
// Device type - check tablet first since iPad contains "mobile" in some UAs
|
|
1650
|
+
let deviceType: DeviceType = 'desktop'
|
|
1651
|
+
if (/ipad|tablet|playbook|silk/i.test(ua)) {
|
|
1652
|
+
deviceType = 'tablet'
|
|
1653
|
+
}
|
|
1654
|
+
else if (/mobile|android|iphone|ipod|blackberry|windows phone/i.test(ua)) {
|
|
1655
|
+
deviceType = 'mobile'
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// Browser detection
|
|
1659
|
+
let browser = 'Unknown'
|
|
1660
|
+
let browserVersion = ''
|
|
1661
|
+
if (ua.includes('firefox')) {
|
|
1662
|
+
browser = 'Firefox'
|
|
1663
|
+
browserVersion = ua.match(/firefox\/([\d.]+)/)?.[1] ?? ''
|
|
1664
|
+
}
|
|
1665
|
+
else if (ua.includes('edg')) {
|
|
1666
|
+
browser = 'Edge'
|
|
1667
|
+
browserVersion = ua.match(/edg\/([\d.]+)/)?.[1] ?? ''
|
|
1668
|
+
}
|
|
1669
|
+
else if (ua.includes('chrome')) {
|
|
1670
|
+
browser = 'Chrome'
|
|
1671
|
+
browserVersion = ua.match(/chrome\/([\d.]+)/)?.[1] ?? ''
|
|
1672
|
+
}
|
|
1673
|
+
else if (ua.includes('safari')) {
|
|
1674
|
+
browser = 'Safari'
|
|
1675
|
+
browserVersion = ua.match(/version\/([\d.]+)/)?.[1] ?? ''
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// OS detection - check mobile OS first since they may contain "mac os" pattern
|
|
1679
|
+
let os = 'Unknown'
|
|
1680
|
+
let osVersion = ''
|
|
1681
|
+
if (ua.includes('iphone') || ua.includes('ipad')) {
|
|
1682
|
+
os = 'iOS'
|
|
1683
|
+
osVersion = ua.match(/os ([\d_]+)/)?.[1]?.replace(/_/g, '.') ?? ''
|
|
1684
|
+
}
|
|
1685
|
+
else if (ua.includes('android')) {
|
|
1686
|
+
os = 'Android'
|
|
1687
|
+
osVersion = ua.match(/android ([\d.]+)/)?.[1] ?? ''
|
|
1688
|
+
}
|
|
1689
|
+
else if (ua.includes('windows')) {
|
|
1690
|
+
os = 'Windows'
|
|
1691
|
+
if (ua.includes('windows nt 10'))
|
|
1692
|
+
osVersion = '10'
|
|
1693
|
+
else if (ua.includes('windows nt 11'))
|
|
1694
|
+
osVersion = '11'
|
|
1695
|
+
}
|
|
1696
|
+
else if (ua.includes('mac os')) {
|
|
1697
|
+
os = 'macOS'
|
|
1698
|
+
osVersion = ua.match(/mac os x ([\d_]+)/)?.[1]?.replace(/_/g, '.') ?? ''
|
|
1699
|
+
}
|
|
1700
|
+
else if (ua.includes('linux')) {
|
|
1701
|
+
os = 'Linux'
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
return { deviceType, browser, browserVersion, os, osVersion }
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// ============================================================================
|
|
1709
|
+
// Analytics Aggregator - Background Job for Rolling Up Stats
|
|
1710
|
+
// ============================================================================
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* Aggregator options
|
|
1714
|
+
*/
|
|
1715
|
+
export interface AggregatorOptions {
|
|
1716
|
+
/** Analytics store instance */
|
|
1717
|
+
store: AnalyticsStore
|
|
1718
|
+
/** Batch size for processing events */
|
|
1719
|
+
batchSize?: number
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* Analytics Aggregator for rolling up raw events into aggregated stats
|
|
1724
|
+
*/
|
|
1725
|
+
export class AnalyticsAggregator {
|
|
1726
|
+
private store: AnalyticsStore
|
|
1727
|
+
private batchSize: number
|
|
1728
|
+
|
|
1729
|
+
constructor(options: AggregatorOptions) {
|
|
1730
|
+
this.store = options.store
|
|
1731
|
+
this.batchSize = options.batchSize ?? 100
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/**
|
|
1735
|
+
* Generate hourly aggregation stats from page views
|
|
1736
|
+
* This would typically be run as a scheduled job
|
|
1737
|
+
*/
|
|
1738
|
+
aggregateHourlyStats(
|
|
1739
|
+
siteId: string,
|
|
1740
|
+
hourStart: Date,
|
|
1741
|
+
pageViews: PageView[],
|
|
1742
|
+
sessions: Session[],
|
|
1743
|
+
): AggregatedStats {
|
|
1744
|
+
const periodStart = AnalyticsStore.getPeriodStart(hourStart, 'hour')
|
|
1745
|
+
|
|
1746
|
+
// Calculate unique visitors using Set
|
|
1747
|
+
const uniqueVisitors = new Set(pageViews.map(pv => pv.visitorId))
|
|
1748
|
+
|
|
1749
|
+
// Calculate bounce rate
|
|
1750
|
+
const bounces = sessions.filter(s => s.isBounce).length
|
|
1751
|
+
const bounceRate = sessions.length > 0 ? bounces / sessions.length : 0
|
|
1752
|
+
|
|
1753
|
+
// Calculate average session duration
|
|
1754
|
+
const totalDuration = sessions.reduce((sum, s) => sum + s.duration, 0)
|
|
1755
|
+
const avgSessionDuration = sessions.length > 0 ? totalDuration / sessions.length : 0
|
|
1756
|
+
|
|
1757
|
+
// Calculate pages per session
|
|
1758
|
+
const totalPages = sessions.reduce((sum, s) => sum + s.pageViewCount, 0)
|
|
1759
|
+
const avgPagesPerSession = sessions.length > 0 ? totalPages / sessions.length : 0
|
|
1760
|
+
|
|
1761
|
+
// Identify new vs returning visitors (simplified - in production, check against visitor history)
|
|
1762
|
+
const newVisitors = uniqueVisitors.size // Simplified
|
|
1763
|
+
const returningVisitors = 0 // Would need historical data
|
|
1764
|
+
|
|
1765
|
+
return {
|
|
1766
|
+
siteId,
|
|
1767
|
+
period: 'hour',
|
|
1768
|
+
periodStart,
|
|
1769
|
+
pageViews: pageViews.length,
|
|
1770
|
+
uniqueVisitors: uniqueVisitors.size,
|
|
1771
|
+
sessions: sessions.length,
|
|
1772
|
+
bounces,
|
|
1773
|
+
bounceRate,
|
|
1774
|
+
avgSessionDuration,
|
|
1775
|
+
avgPagesPerSession,
|
|
1776
|
+
totalTimeOnSite: totalDuration,
|
|
1777
|
+
newVisitors,
|
|
1778
|
+
returningVisitors,
|
|
1779
|
+
createdAt: new Date(),
|
|
1780
|
+
updatedAt: new Date(),
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* Generate page-level stats from page views
|
|
1786
|
+
*/
|
|
1787
|
+
aggregatePageStats(
|
|
1788
|
+
siteId: string,
|
|
1789
|
+
period: AggregationPeriod,
|
|
1790
|
+
periodStart: Date,
|
|
1791
|
+
pageViews: PageView[],
|
|
1792
|
+
): PageStats[] {
|
|
1793
|
+
const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
|
|
1794
|
+
|
|
1795
|
+
// Group by path
|
|
1796
|
+
const pathGroups = new Map<string, PageView[]>()
|
|
1797
|
+
for (const pv of pageViews) {
|
|
1798
|
+
const existing = pathGroups.get(pv.path) || []
|
|
1799
|
+
existing.push(pv)
|
|
1800
|
+
pathGroups.set(pv.path, existing)
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
const results: PageStats[] = []
|
|
1804
|
+
for (const [path, views] of pathGroups) {
|
|
1805
|
+
const uniqueVisitors = new Set(views.map(v => v.visitorId))
|
|
1806
|
+
const entries = views.filter(v => v.isUnique).length
|
|
1807
|
+
const bounces = views.filter(v => v.isBounce).length
|
|
1808
|
+
const exits = views.length // Simplified - would need session data
|
|
1809
|
+
|
|
1810
|
+
// Calculate average time on page
|
|
1811
|
+
const timesOnPage = views.filter(v => v.timeOnPage !== undefined).map(v => v.timeOnPage!)
|
|
1812
|
+
const avgTimeOnPage = timesOnPage.length > 0
|
|
1813
|
+
? timesOnPage.reduce((a, b) => a + b, 0) / timesOnPage.length
|
|
1814
|
+
: 0
|
|
1815
|
+
|
|
1816
|
+
results.push({
|
|
1817
|
+
siteId,
|
|
1818
|
+
period,
|
|
1819
|
+
periodStart: periodStartStr,
|
|
1820
|
+
path,
|
|
1821
|
+
title: views[views.length - 1]?.title,
|
|
1822
|
+
pageViews: views.length,
|
|
1823
|
+
uniqueVisitors: uniqueVisitors.size,
|
|
1824
|
+
entries,
|
|
1825
|
+
exits,
|
|
1826
|
+
bounces,
|
|
1827
|
+
avgTimeOnPage,
|
|
1828
|
+
exitRate: views.length > 0 ? exits / views.length : 0,
|
|
1829
|
+
})
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
return results
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
/**
|
|
1836
|
+
* Generate referrer stats from sessions
|
|
1837
|
+
*/
|
|
1838
|
+
aggregateReferrerStats(
|
|
1839
|
+
siteId: string,
|
|
1840
|
+
period: AggregationPeriod,
|
|
1841
|
+
periodStart: Date,
|
|
1842
|
+
sessions: Session[],
|
|
1843
|
+
): ReferrerStats[] {
|
|
1844
|
+
const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
|
|
1845
|
+
|
|
1846
|
+
// Group by referrer source
|
|
1847
|
+
const sourceGroups = new Map<string, Session[]>()
|
|
1848
|
+
for (const session of sessions) {
|
|
1849
|
+
const source = session.referrerSource || 'direct'
|
|
1850
|
+
const existing = sourceGroups.get(source) || []
|
|
1851
|
+
existing.push(session)
|
|
1852
|
+
sourceGroups.set(source, existing)
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
const results: ReferrerStats[] = []
|
|
1856
|
+
for (const [source, groupSessions] of sourceGroups) {
|
|
1857
|
+
const visitors = new Set(groupSessions.map(s => s.visitorId))
|
|
1858
|
+
const pageViews = groupSessions.reduce((sum, s) => sum + s.pageViewCount, 0)
|
|
1859
|
+
const bounces = groupSessions.filter(s => s.isBounce).length
|
|
1860
|
+
const totalDuration = groupSessions.reduce((sum, s) => sum + s.duration, 0)
|
|
1861
|
+
|
|
1862
|
+
results.push({
|
|
1863
|
+
siteId,
|
|
1864
|
+
period,
|
|
1865
|
+
periodStart: periodStartStr,
|
|
1866
|
+
source,
|
|
1867
|
+
visitors: visitors.size,
|
|
1868
|
+
pageViews,
|
|
1869
|
+
bounceRate: groupSessions.length > 0 ? bounces / groupSessions.length : 0,
|
|
1870
|
+
avgSessionDuration: groupSessions.length > 0 ? totalDuration / groupSessions.length : 0,
|
|
1871
|
+
})
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
return results
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Generate geographic stats from sessions
|
|
1879
|
+
* Creates hierarchical aggregations at country, region, and city levels
|
|
1880
|
+
*/
|
|
1881
|
+
aggregateGeoStats(
|
|
1882
|
+
siteId: string,
|
|
1883
|
+
period: AggregationPeriod,
|
|
1884
|
+
periodStart: Date,
|
|
1885
|
+
sessions: Session[],
|
|
1886
|
+
): GeoStats[] {
|
|
1887
|
+
const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
|
|
1888
|
+
const results: GeoStats[] = []
|
|
1889
|
+
|
|
1890
|
+
// Group by country
|
|
1891
|
+
const countryGroups = new Map<string, Session[]>()
|
|
1892
|
+
for (const session of sessions) {
|
|
1893
|
+
const country = session.country || 'Unknown'
|
|
1894
|
+
const existing = countryGroups.get(country) || []
|
|
1895
|
+
existing.push(session)
|
|
1896
|
+
countryGroups.set(country, existing)
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// Country-level stats
|
|
1900
|
+
for (const [country, countrySessions] of countryGroups) {
|
|
1901
|
+
const visitors = new Set(countrySessions.map(s => s.visitorId))
|
|
1902
|
+
const pageViews = countrySessions.reduce((sum, s) => sum + s.pageViewCount, 0)
|
|
1903
|
+
const bounces = countrySessions.filter(s => s.isBounce).length
|
|
1904
|
+
|
|
1905
|
+
results.push({
|
|
1906
|
+
siteId,
|
|
1907
|
+
period,
|
|
1908
|
+
periodStart: periodStartStr,
|
|
1909
|
+
country,
|
|
1910
|
+
visitors: visitors.size,
|
|
1911
|
+
pageViews,
|
|
1912
|
+
bounceRate: countrySessions.length > 0 ? bounces / countrySessions.length : 0,
|
|
1913
|
+
})
|
|
1914
|
+
|
|
1915
|
+
// Region-level stats (group by country+region)
|
|
1916
|
+
const regionGroups = new Map<string, Session[]>()
|
|
1917
|
+
for (const session of countrySessions) {
|
|
1918
|
+
const region = session.region || 'Unknown'
|
|
1919
|
+
const existing = regionGroups.get(region) || []
|
|
1920
|
+
existing.push(session)
|
|
1921
|
+
regionGroups.set(region, existing)
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
for (const [region, regionSessions] of regionGroups) {
|
|
1925
|
+
const regionVisitors = new Set(regionSessions.map(s => s.visitorId))
|
|
1926
|
+
const regionPageViews = regionSessions.reduce((sum, s) => sum + s.pageViewCount, 0)
|
|
1927
|
+
const regionBounces = regionSessions.filter(s => s.isBounce).length
|
|
1928
|
+
|
|
1929
|
+
results.push({
|
|
1930
|
+
siteId,
|
|
1931
|
+
period,
|
|
1932
|
+
periodStart: periodStartStr,
|
|
1933
|
+
country,
|
|
1934
|
+
region,
|
|
1935
|
+
visitors: regionVisitors.size,
|
|
1936
|
+
pageViews: regionPageViews,
|
|
1937
|
+
bounceRate: regionSessions.length > 0 ? regionBounces / regionSessions.length : 0,
|
|
1938
|
+
})
|
|
1939
|
+
|
|
1940
|
+
// City-level stats (group by country+region+city)
|
|
1941
|
+
const cityGroups = new Map<string, Session[]>()
|
|
1942
|
+
for (const session of regionSessions) {
|
|
1943
|
+
const city = session.city || 'Unknown'
|
|
1944
|
+
const existing = cityGroups.get(city) || []
|
|
1945
|
+
existing.push(session)
|
|
1946
|
+
cityGroups.set(city, existing)
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
for (const [city, citySessions] of cityGroups) {
|
|
1950
|
+
const cityVisitors = new Set(citySessions.map(s => s.visitorId))
|
|
1951
|
+
const cityPageViews = citySessions.reduce((sum, s) => sum + s.pageViewCount, 0)
|
|
1952
|
+
const cityBounces = citySessions.filter(s => s.isBounce).length
|
|
1953
|
+
|
|
1954
|
+
results.push({
|
|
1955
|
+
siteId,
|
|
1956
|
+
period,
|
|
1957
|
+
periodStart: periodStartStr,
|
|
1958
|
+
country,
|
|
1959
|
+
region,
|
|
1960
|
+
city,
|
|
1961
|
+
visitors: cityVisitors.size,
|
|
1962
|
+
pageViews: cityPageViews,
|
|
1963
|
+
bounceRate: citySessions.length > 0 ? cityBounces / citySessions.length : 0,
|
|
1964
|
+
})
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
return results
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* Generate device stats from sessions
|
|
1974
|
+
*/
|
|
1975
|
+
aggregateDeviceStats(
|
|
1976
|
+
siteId: string,
|
|
1977
|
+
period: AggregationPeriod,
|
|
1978
|
+
periodStart: Date,
|
|
1979
|
+
sessions: Session[],
|
|
1980
|
+
): DeviceStats[] {
|
|
1981
|
+
const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
|
|
1982
|
+
const results: DeviceStats[] = []
|
|
1983
|
+
|
|
1984
|
+
// Group by device type
|
|
1985
|
+
const deviceGroups = this.groupByDimension(sessions, 'deviceType')
|
|
1986
|
+
for (const [value, groupSessions] of deviceGroups) {
|
|
1987
|
+
results.push(this.createDeviceStats(siteId, period, periodStartStr, 'device', value, groupSessions))
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// Group by browser
|
|
1991
|
+
const browserGroups = this.groupByDimension(sessions, 'browser')
|
|
1992
|
+
for (const [value, groupSessions] of browserGroups) {
|
|
1993
|
+
results.push(this.createDeviceStats(siteId, period, periodStartStr, 'browser', value, groupSessions))
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// Group by OS
|
|
1997
|
+
const osGroups = this.groupByDimension(sessions, 'os')
|
|
1998
|
+
for (const [value, groupSessions] of osGroups) {
|
|
1999
|
+
results.push(this.createDeviceStats(siteId, period, periodStartStr, 'os', value, groupSessions))
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
return results
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
private groupByDimension(
|
|
2006
|
+
sessions: Session[],
|
|
2007
|
+
dimension: keyof Session,
|
|
2008
|
+
): Map<string, Session[]> {
|
|
2009
|
+
const groups = new Map<string, Session[]>()
|
|
2010
|
+
for (const session of sessions) {
|
|
2011
|
+
const value = String(session[dimension] || 'Unknown')
|
|
2012
|
+
const existing = groups.get(value) || []
|
|
2013
|
+
existing.push(session)
|
|
2014
|
+
groups.set(value, existing)
|
|
2015
|
+
}
|
|
2016
|
+
return groups
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
private createDeviceStats(
|
|
2020
|
+
siteId: string,
|
|
2021
|
+
period: AggregationPeriod,
|
|
2022
|
+
periodStart: string,
|
|
2023
|
+
dimension: DeviceStats['dimension'],
|
|
2024
|
+
value: string,
|
|
2025
|
+
sessions: Session[],
|
|
2026
|
+
): DeviceStats {
|
|
2027
|
+
const visitors = new Set(sessions.map(s => s.visitorId))
|
|
2028
|
+
const pageViews = sessions.reduce((sum, s) => sum + s.pageViewCount, 0)
|
|
2029
|
+
const bounces = sessions.filter(s => s.isBounce).length
|
|
2030
|
+
|
|
2031
|
+
return {
|
|
2032
|
+
siteId,
|
|
2033
|
+
period,
|
|
2034
|
+
periodStart,
|
|
2035
|
+
dimension,
|
|
2036
|
+
value,
|
|
2037
|
+
visitors: visitors.size,
|
|
2038
|
+
pageViews,
|
|
2039
|
+
bounceRate: sessions.length > 0 ? bounces / sessions.length : 0,
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// ============================================================================
|
|
2045
|
+
// Goal Matcher - Match PageViews/Events to Goal Definitions
|
|
2046
|
+
// ============================================================================
|
|
2047
|
+
|
|
2048
|
+
/**
|
|
2049
|
+
* Result of a goal match check
|
|
2050
|
+
*/
|
|
2051
|
+
export interface GoalMatchResult {
|
|
2052
|
+
/** Whether the goal was matched */
|
|
2053
|
+
matched: boolean
|
|
2054
|
+
/** The goal that was matched */
|
|
2055
|
+
goal: Goal
|
|
2056
|
+
/** The value attributed to this conversion */
|
|
2057
|
+
value: number
|
|
2058
|
+
/** The item that triggered the match (pageview or event) */
|
|
2059
|
+
trigger: PageView | CustomEvent
|
|
2060
|
+
/** Timestamp of the conversion */
|
|
2061
|
+
timestamp: Date
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* Conversion record for storage
|
|
2066
|
+
*/
|
|
2067
|
+
export interface Conversion {
|
|
2068
|
+
/** Unique conversion ID */
|
|
2069
|
+
id: string
|
|
2070
|
+
/** Site ID */
|
|
2071
|
+
siteId: string
|
|
2072
|
+
/** Goal ID */
|
|
2073
|
+
goalId: string
|
|
2074
|
+
/** Visitor ID */
|
|
2075
|
+
visitorId: string
|
|
2076
|
+
/** Session ID */
|
|
2077
|
+
sessionId: string
|
|
2078
|
+
/** Conversion value */
|
|
2079
|
+
value: number
|
|
2080
|
+
/** Path where conversion occurred */
|
|
2081
|
+
path: string
|
|
2082
|
+
/** Timestamp */
|
|
2083
|
+
timestamp: Date
|
|
2084
|
+
/** TTL for auto-deletion */
|
|
2085
|
+
ttl?: number
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/**
|
|
2089
|
+
* Goal Matcher for checking if pageviews/events match goal definitions
|
|
2090
|
+
*/
|
|
2091
|
+
export class GoalMatcher {
|
|
2092
|
+
private goals: Goal[]
|
|
2093
|
+
private compiledPatterns: Map<string, RegExp>
|
|
2094
|
+
|
|
2095
|
+
constructor(goals: Goal[]) {
|
|
2096
|
+
this.goals = goals.filter(g => g.isActive)
|
|
2097
|
+
this.compiledPatterns = new Map()
|
|
2098
|
+
|
|
2099
|
+
// Pre-compile regex patterns for performance
|
|
2100
|
+
for (const goal of this.goals) {
|
|
2101
|
+
if (goal.matchType === 'regex') {
|
|
2102
|
+
try {
|
|
2103
|
+
this.compiledPatterns.set(goal.id, new RegExp(goal.pattern))
|
|
2104
|
+
}
|
|
2105
|
+
catch {
|
|
2106
|
+
// Invalid regex - skip this goal
|
|
2107
|
+
console.warn(`Invalid regex pattern for goal ${goal.id}: ${goal.pattern}`)
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
/**
|
|
2114
|
+
* Check if a pageview matches any pageview-type goals
|
|
2115
|
+
*/
|
|
2116
|
+
matchPageView(pageView: PageView): GoalMatchResult[] {
|
|
2117
|
+
const results: GoalMatchResult[] = []
|
|
2118
|
+
|
|
2119
|
+
for (const goal of this.goals) {
|
|
2120
|
+
if (goal.type !== 'pageview')
|
|
2121
|
+
continue
|
|
2122
|
+
|
|
2123
|
+
if (this.matchesPattern(pageView.path, goal)) {
|
|
2124
|
+
results.push({
|
|
2125
|
+
matched: true,
|
|
2126
|
+
goal,
|
|
2127
|
+
value: goal.value ?? 0,
|
|
2128
|
+
trigger: pageView,
|
|
2129
|
+
timestamp: pageView.timestamp,
|
|
2130
|
+
})
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
return results
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* Check if a custom event matches any event-type goals
|
|
2139
|
+
*/
|
|
2140
|
+
matchEvent(event: CustomEvent): GoalMatchResult[] {
|
|
2141
|
+
const results: GoalMatchResult[] = []
|
|
2142
|
+
|
|
2143
|
+
for (const goal of this.goals) {
|
|
2144
|
+
if (goal.type !== 'event')
|
|
2145
|
+
continue
|
|
2146
|
+
|
|
2147
|
+
if (this.matchesPattern(event.name, goal)) {
|
|
2148
|
+
results.push({
|
|
2149
|
+
matched: true,
|
|
2150
|
+
goal,
|
|
2151
|
+
value: event.value ?? goal.value ?? 0,
|
|
2152
|
+
trigger: event,
|
|
2153
|
+
timestamp: event.timestamp,
|
|
2154
|
+
})
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
return results
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/**
|
|
2162
|
+
* Check if a value matches a goal's pattern
|
|
2163
|
+
*/
|
|
2164
|
+
private matchesPattern(value: string, goal: Goal): boolean {
|
|
2165
|
+
switch (goal.matchType) {
|
|
2166
|
+
case 'exact':
|
|
2167
|
+
return value === goal.pattern
|
|
2168
|
+
|
|
2169
|
+
case 'contains':
|
|
2170
|
+
return value.includes(goal.pattern)
|
|
2171
|
+
|
|
2172
|
+
case 'regex': {
|
|
2173
|
+
const regex = this.compiledPatterns.get(goal.id)
|
|
2174
|
+
return regex ? regex.test(value) : false
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
default:
|
|
2178
|
+
return false
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* Get all active goals
|
|
2184
|
+
*/
|
|
2185
|
+
getActiveGoals(): Goal[] {
|
|
2186
|
+
return this.goals
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/**
|
|
2190
|
+
* Create a conversion record from a goal match
|
|
2191
|
+
*/
|
|
2192
|
+
static createConversion(
|
|
2193
|
+
siteId: string,
|
|
2194
|
+
match: GoalMatchResult,
|
|
2195
|
+
): Conversion {
|
|
2196
|
+
const trigger = match.trigger
|
|
2197
|
+
return {
|
|
2198
|
+
id: AnalyticsStore.generateId(),
|
|
2199
|
+
siteId,
|
|
2200
|
+
goalId: match.goal.id,
|
|
2201
|
+
visitorId: trigger.visitorId,
|
|
2202
|
+
sessionId: trigger.sessionId,
|
|
2203
|
+
value: match.value,
|
|
2204
|
+
path: trigger.path,
|
|
2205
|
+
timestamp: match.timestamp,
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
// ============================================================================
|
|
2211
|
+
// Analytics Query API - High-Level Dashboard Queries
|
|
2212
|
+
// ============================================================================
|
|
2213
|
+
|
|
2214
|
+
/**
|
|
2215
|
+
* Date range for queries
|
|
2216
|
+
*/
|
|
2217
|
+
export interface DateRange {
|
|
2218
|
+
start: Date
|
|
2219
|
+
end: Date
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* Dashboard summary data
|
|
2224
|
+
*/
|
|
2225
|
+
export interface DashboardSummary {
|
|
2226
|
+
/** Total page views in period */
|
|
2227
|
+
pageViews: number
|
|
2228
|
+
/** Unique visitors in period */
|
|
2229
|
+
uniqueVisitors: number
|
|
2230
|
+
/** Total sessions in period */
|
|
2231
|
+
sessions: number
|
|
2232
|
+
/** Bounce rate (0-1) */
|
|
2233
|
+
bounceRate: number
|
|
2234
|
+
/** Average session duration (ms) */
|
|
2235
|
+
avgSessionDuration: number
|
|
2236
|
+
/** Average pages per session */
|
|
2237
|
+
avgPagesPerSession: number
|
|
2238
|
+
/** Comparison with previous period */
|
|
2239
|
+
comparison?: {
|
|
2240
|
+
pageViewsChange: number
|
|
2241
|
+
visitorsChange: number
|
|
2242
|
+
bounceRateChange: number
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* Time series data point
|
|
2248
|
+
*/
|
|
2249
|
+
export interface TimeSeriesPoint {
|
|
2250
|
+
timestamp: string
|
|
2251
|
+
pageViews: number
|
|
2252
|
+
uniqueVisitors: number
|
|
2253
|
+
sessions: number
|
|
2254
|
+
bounceRate: number
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/**
|
|
2258
|
+
* Top item (page, referrer, country, etc.)
|
|
2259
|
+
*/
|
|
2260
|
+
export interface TopItem {
|
|
2261
|
+
name: string
|
|
2262
|
+
value: number
|
|
2263
|
+
percentage: number
|
|
2264
|
+
change?: number
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* Dashboard data response
|
|
2269
|
+
*/
|
|
2270
|
+
export interface DashboardData {
|
|
2271
|
+
summary: DashboardSummary
|
|
2272
|
+
timeSeries: TimeSeriesPoint[]
|
|
2273
|
+
topPages: TopItem[]
|
|
2274
|
+
topReferrers: TopItem[]
|
|
2275
|
+
topCountries: TopItem[]
|
|
2276
|
+
topDevices: TopItem[]
|
|
2277
|
+
topBrowsers: TopItem[]
|
|
2278
|
+
goals?: GoalPerformance[]
|
|
2279
|
+
realtime?: RealtimeData
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/**
|
|
2283
|
+
* Goal performance data
|
|
2284
|
+
*/
|
|
2285
|
+
export interface GoalPerformance {
|
|
2286
|
+
goalId: string
|
|
2287
|
+
goalName: string
|
|
2288
|
+
conversions: number
|
|
2289
|
+
conversionRate: number
|
|
2290
|
+
revenue: number
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
/**
|
|
2294
|
+
* Realtime dashboard data
|
|
2295
|
+
*/
|
|
2296
|
+
export interface RealtimeData {
|
|
2297
|
+
currentVisitors: number
|
|
2298
|
+
pageViewsLastHour: number
|
|
2299
|
+
topActivePages: TopItem[]
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/**
|
|
2303
|
+
* Query options
|
|
2304
|
+
*/
|
|
2305
|
+
export interface QueryOptions {
|
|
2306
|
+
/** Site ID */
|
|
2307
|
+
siteId: string
|
|
2308
|
+
/** Date range */
|
|
2309
|
+
dateRange: DateRange
|
|
2310
|
+
/** Whether to include comparison with previous period */
|
|
2311
|
+
includeComparison?: boolean
|
|
2312
|
+
/** Whether to include realtime data */
|
|
2313
|
+
includeRealtime?: boolean
|
|
2314
|
+
/** Whether to include goal performance */
|
|
2315
|
+
includeGoals?: boolean
|
|
2316
|
+
/** Limit for top items */
|
|
2317
|
+
topLimit?: number
|
|
2318
|
+
/** Timezone for date calculations */
|
|
2319
|
+
timezone?: string
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/**
|
|
2323
|
+
* Analytics Query API for dashboard data
|
|
2324
|
+
*/
|
|
2325
|
+
export class AnalyticsQueryAPI {
|
|
2326
|
+
private store: AnalyticsStore
|
|
2327
|
+
|
|
2328
|
+
constructor(store: AnalyticsStore) {
|
|
2329
|
+
this.store = store
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
/**
|
|
2333
|
+
* Determine the best aggregation period for a date range
|
|
2334
|
+
*/
|
|
2335
|
+
static determinePeriod(dateRange: DateRange): AggregationPeriod {
|
|
2336
|
+
const diffMs = dateRange.end.getTime() - dateRange.start.getTime()
|
|
2337
|
+
const diffDays = diffMs / (1000 * 60 * 60 * 24)
|
|
2338
|
+
|
|
2339
|
+
if (diffDays <= 2) {
|
|
2340
|
+
return 'hour'
|
|
2341
|
+
}
|
|
2342
|
+
else if (diffDays <= 90) {
|
|
2343
|
+
return 'day'
|
|
2344
|
+
}
|
|
2345
|
+
else {
|
|
2346
|
+
return 'month'
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/**
|
|
2351
|
+
* Get the previous period for comparison
|
|
2352
|
+
*/
|
|
2353
|
+
static getPreviousPeriod(dateRange: DateRange): DateRange {
|
|
2354
|
+
const diffMs = dateRange.end.getTime() - dateRange.start.getTime()
|
|
2355
|
+
return {
|
|
2356
|
+
start: new Date(dateRange.start.getTime() - diffMs),
|
|
2357
|
+
end: new Date(dateRange.start.getTime() - 1),
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
/**
|
|
2362
|
+
* Generate query commands for fetching dashboard data
|
|
2363
|
+
* Returns an object with all the DynamoDB commands needed
|
|
2364
|
+
*/
|
|
2365
|
+
generateDashboardQueries(options: QueryOptions): {
|
|
2366
|
+
aggregatedStats: ReturnType<AnalyticsStore['getAggregatedStatsCommand']>
|
|
2367
|
+
topPages: ReturnType<AnalyticsStore['getTopPagesCommand']>
|
|
2368
|
+
realtimeStats?: ReturnType<AnalyticsStore['getRealtimeStatsCommand']>
|
|
2369
|
+
goals?: ReturnType<AnalyticsStore['listGoalsCommand']>
|
|
2370
|
+
previousPeriodStats?: ReturnType<AnalyticsStore['getAggregatedStatsCommand']>
|
|
2371
|
+
} {
|
|
2372
|
+
const period = AnalyticsQueryAPI.determinePeriod(options.dateRange)
|
|
2373
|
+
const startPeriod = AnalyticsStore.getPeriodStart(options.dateRange.start, period)
|
|
2374
|
+
const endPeriod = AnalyticsStore.getPeriodStart(options.dateRange.end, period)
|
|
2375
|
+
const limit = options.topLimit ?? 10
|
|
2376
|
+
|
|
2377
|
+
const queries: ReturnType<AnalyticsQueryAPI['generateDashboardQueries']> = {
|
|
2378
|
+
aggregatedStats: this.store.getAggregatedStatsCommand(
|
|
2379
|
+
options.siteId,
|
|
2380
|
+
period,
|
|
2381
|
+
startPeriod,
|
|
2382
|
+
endPeriod,
|
|
2383
|
+
),
|
|
2384
|
+
topPages: this.store.getTopPagesCommand(
|
|
2385
|
+
options.siteId,
|
|
2386
|
+
period,
|
|
2387
|
+
endPeriod, // Use end date for most recent stats
|
|
2388
|
+
limit,
|
|
2389
|
+
),
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
if (options.includeRealtime) {
|
|
2393
|
+
queries.realtimeStats = this.store.getRealtimeStatsCommand(options.siteId, 5)
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
if (options.includeGoals) {
|
|
2397
|
+
queries.goals = this.store.listGoalsCommand(options.siteId)
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
if (options.includeComparison) {
|
|
2401
|
+
const prevPeriod = AnalyticsQueryAPI.getPreviousPeriod(options.dateRange)
|
|
2402
|
+
const prevStartPeriod = AnalyticsStore.getPeriodStart(prevPeriod.start, period)
|
|
2403
|
+
const prevEndPeriod = AnalyticsStore.getPeriodStart(prevPeriod.end, period)
|
|
2404
|
+
|
|
2405
|
+
queries.previousPeriodStats = this.store.getAggregatedStatsCommand(
|
|
2406
|
+
options.siteId,
|
|
2407
|
+
period,
|
|
2408
|
+
prevStartPeriod,
|
|
2409
|
+
prevEndPeriod,
|
|
2410
|
+
)
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
return queries
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
/**
|
|
2417
|
+
* Generate query command for fetching top regions
|
|
2418
|
+
* @param siteId - Site ID
|
|
2419
|
+
* @param options - Query options including dateRange, country filter, and limit
|
|
2420
|
+
*/
|
|
2421
|
+
getRegions(
|
|
2422
|
+
siteId: string,
|
|
2423
|
+
options: {
|
|
2424
|
+
dateRange: DateRange
|
|
2425
|
+
country?: string
|
|
2426
|
+
limit?: number
|
|
2427
|
+
},
|
|
2428
|
+
): ReturnType<AnalyticsStore['getRegionsCommand']> {
|
|
2429
|
+
const period = AnalyticsQueryAPI.determinePeriod(options.dateRange)
|
|
2430
|
+
const periodStart = AnalyticsStore.getPeriodStart(options.dateRange.end, period)
|
|
2431
|
+
const limit = options.limit ?? 10
|
|
2432
|
+
|
|
2433
|
+
return this.store.getRegionsCommand(
|
|
2434
|
+
siteId,
|
|
2435
|
+
period,
|
|
2436
|
+
periodStart,
|
|
2437
|
+
options.country,
|
|
2438
|
+
limit,
|
|
2439
|
+
)
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
/**
|
|
2443
|
+
* Generate query command for fetching top cities
|
|
2444
|
+
* @param siteId - Site ID
|
|
2445
|
+
* @param options - Query options including dateRange, country/region filters, and limit
|
|
2446
|
+
*/
|
|
2447
|
+
getCities(
|
|
2448
|
+
siteId: string,
|
|
2449
|
+
options: {
|
|
2450
|
+
dateRange: DateRange
|
|
2451
|
+
country?: string
|
|
2452
|
+
region?: string
|
|
2453
|
+
limit?: number
|
|
2454
|
+
},
|
|
2455
|
+
): ReturnType<AnalyticsStore['getCitiesCommand']> {
|
|
2456
|
+
const period = AnalyticsQueryAPI.determinePeriod(options.dateRange)
|
|
2457
|
+
const periodStart = AnalyticsStore.getPeriodStart(options.dateRange.end, period)
|
|
2458
|
+
const limit = options.limit ?? 10
|
|
2459
|
+
|
|
2460
|
+
return this.store.getCitiesCommand(
|
|
2461
|
+
siteId,
|
|
2462
|
+
period,
|
|
2463
|
+
periodStart,
|
|
2464
|
+
{ country: options.country, region: options.region },
|
|
2465
|
+
limit,
|
|
2466
|
+
)
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
/**
|
|
2470
|
+
* Process aggregated stats results into dashboard summary
|
|
2471
|
+
*/
|
|
2472
|
+
static processSummary(
|
|
2473
|
+
stats: AggregatedStats[],
|
|
2474
|
+
previousStats?: AggregatedStats[],
|
|
2475
|
+
): DashboardSummary {
|
|
2476
|
+
// Sum up all stats in the period
|
|
2477
|
+
const totals = stats.reduce(
|
|
2478
|
+
(acc, s) => ({
|
|
2479
|
+
pageViews: acc.pageViews + s.pageViews,
|
|
2480
|
+
uniqueVisitors: acc.uniqueVisitors + s.uniqueVisitors,
|
|
2481
|
+
sessions: acc.sessions + s.sessions,
|
|
2482
|
+
bounces: acc.bounces + s.bounces,
|
|
2483
|
+
totalTime: acc.totalTime + s.totalTimeOnSite,
|
|
2484
|
+
totalPages: acc.totalPages + (s.sessions * s.avgPagesPerSession),
|
|
2485
|
+
}),
|
|
2486
|
+
{ pageViews: 0, uniqueVisitors: 0, sessions: 0, bounces: 0, totalTime: 0, totalPages: 0 },
|
|
2487
|
+
)
|
|
2488
|
+
|
|
2489
|
+
const summary: DashboardSummary = {
|
|
2490
|
+
pageViews: totals.pageViews,
|
|
2491
|
+
uniqueVisitors: totals.uniqueVisitors,
|
|
2492
|
+
sessions: totals.sessions,
|
|
2493
|
+
bounceRate: totals.sessions > 0 ? totals.bounces / totals.sessions : 0,
|
|
2494
|
+
avgSessionDuration: totals.sessions > 0 ? totals.totalTime / totals.sessions : 0,
|
|
2495
|
+
avgPagesPerSession: totals.sessions > 0 ? totals.totalPages / totals.sessions : 0,
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
if (previousStats && previousStats.length > 0) {
|
|
2499
|
+
const prevTotals = previousStats.reduce(
|
|
2500
|
+
(acc, s) => ({
|
|
2501
|
+
pageViews: acc.pageViews + s.pageViews,
|
|
2502
|
+
uniqueVisitors: acc.uniqueVisitors + s.uniqueVisitors,
|
|
2503
|
+
bounces: acc.bounces + s.bounces,
|
|
2504
|
+
sessions: acc.sessions + s.sessions,
|
|
2505
|
+
}),
|
|
2506
|
+
{ pageViews: 0, uniqueVisitors: 0, bounces: 0, sessions: 0 },
|
|
2507
|
+
)
|
|
2508
|
+
|
|
2509
|
+
const prevBounceRate = prevTotals.sessions > 0 ? prevTotals.bounces / prevTotals.sessions : 0
|
|
2510
|
+
|
|
2511
|
+
summary.comparison = {
|
|
2512
|
+
pageViewsChange: prevTotals.pageViews > 0
|
|
2513
|
+
? ((totals.pageViews - prevTotals.pageViews) / prevTotals.pageViews) * 100
|
|
2514
|
+
: 0,
|
|
2515
|
+
visitorsChange: prevTotals.uniqueVisitors > 0
|
|
2516
|
+
? ((totals.uniqueVisitors - prevTotals.uniqueVisitors) / prevTotals.uniqueVisitors) * 100
|
|
2517
|
+
: 0,
|
|
2518
|
+
bounceRateChange: summary.bounceRate - prevBounceRate,
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
return summary
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
/**
|
|
2526
|
+
* Convert aggregated stats to time series data
|
|
2527
|
+
*/
|
|
2528
|
+
static processTimeSeries(stats: AggregatedStats[]): TimeSeriesPoint[] {
|
|
2529
|
+
return stats
|
|
2530
|
+
.sort((a, b) => a.periodStart.localeCompare(b.periodStart))
|
|
2531
|
+
.map(s => ({
|
|
2532
|
+
timestamp: s.periodStart,
|
|
2533
|
+
pageViews: s.pageViews,
|
|
2534
|
+
uniqueVisitors: s.uniqueVisitors,
|
|
2535
|
+
sessions: s.sessions,
|
|
2536
|
+
bounceRate: s.bounceRate,
|
|
2537
|
+
}))
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
/**
|
|
2541
|
+
* Process page stats into top items
|
|
2542
|
+
*/
|
|
2543
|
+
static processTopPages(pageStats: PageStats[], totalPageViews: number): TopItem[] {
|
|
2544
|
+
return pageStats
|
|
2545
|
+
.sort((a, b) => b.pageViews - a.pageViews)
|
|
2546
|
+
.map(p => ({
|
|
2547
|
+
name: p.path,
|
|
2548
|
+
value: p.pageViews,
|
|
2549
|
+
percentage: totalPageViews > 0 ? (p.pageViews / totalPageViews) * 100 : 0,
|
|
2550
|
+
}))
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/**
|
|
2554
|
+
* Process referrer stats into top items
|
|
2555
|
+
*/
|
|
2556
|
+
static processTopReferrers(referrerStats: ReferrerStats[], totalVisitors: number): TopItem[] {
|
|
2557
|
+
return referrerStats
|
|
2558
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
2559
|
+
.map(r => ({
|
|
2560
|
+
name: r.source,
|
|
2561
|
+
value: r.visitors,
|
|
2562
|
+
percentage: totalVisitors > 0 ? (r.visitors / totalVisitors) * 100 : 0,
|
|
2563
|
+
}))
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/**
|
|
2567
|
+
* Process geo stats into top items
|
|
2568
|
+
*/
|
|
2569
|
+
static processTopCountries(geoStats: GeoStats[], totalVisitors: number): TopItem[] {
|
|
2570
|
+
return geoStats
|
|
2571
|
+
.filter(g => !g.region) // Only country-level stats
|
|
2572
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
2573
|
+
.map(g => ({
|
|
2574
|
+
name: g.country,
|
|
2575
|
+
value: g.visitors,
|
|
2576
|
+
percentage: totalVisitors > 0 ? (g.visitors / totalVisitors) * 100 : 0,
|
|
2577
|
+
}))
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
/**
|
|
2581
|
+
* Process geo stats into top regions
|
|
2582
|
+
*/
|
|
2583
|
+
static processTopRegions(geoStats: GeoStats[], totalVisitors: number): TopItem[] {
|
|
2584
|
+
return geoStats
|
|
2585
|
+
.filter(g => g.region && !g.city) // Only region-level stats (has region, no city)
|
|
2586
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
2587
|
+
.map(g => ({
|
|
2588
|
+
name: g.region!,
|
|
2589
|
+
value: g.visitors,
|
|
2590
|
+
percentage: totalVisitors > 0 ? (g.visitors / totalVisitors) * 100 : 0,
|
|
2591
|
+
metadata: { country: g.country },
|
|
2592
|
+
}))
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
/**
|
|
2596
|
+
* Process geo stats into top cities
|
|
2597
|
+
*/
|
|
2598
|
+
static processTopCities(geoStats: GeoStats[], totalVisitors: number): TopItem[] {
|
|
2599
|
+
return geoStats
|
|
2600
|
+
.filter(g => g.city) // Only city-level stats
|
|
2601
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
2602
|
+
.map(g => ({
|
|
2603
|
+
name: g.city!,
|
|
2604
|
+
value: g.visitors,
|
|
2605
|
+
percentage: totalVisitors > 0 ? (g.visitors / totalVisitors) * 100 : 0,
|
|
2606
|
+
metadata: { country: g.country, region: g.region },
|
|
2607
|
+
}))
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
/**
|
|
2611
|
+
* Process device stats into top items
|
|
2612
|
+
*/
|
|
2613
|
+
static processTopDevices(
|
|
2614
|
+
deviceStats: DeviceStats[],
|
|
2615
|
+
dimension: 'device' | 'browser' | 'os',
|
|
2616
|
+
totalVisitors: number,
|
|
2617
|
+
): TopItem[] {
|
|
2618
|
+
return deviceStats
|
|
2619
|
+
.filter(d => d.dimension === dimension)
|
|
2620
|
+
.sort((a, b) => b.visitors - a.visitors)
|
|
2621
|
+
.map(d => ({
|
|
2622
|
+
name: d.value,
|
|
2623
|
+
value: d.visitors,
|
|
2624
|
+
percentage: totalVisitors > 0 ? (d.visitors / totalVisitors) * 100 : 0,
|
|
2625
|
+
}))
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
/**
|
|
2629
|
+
* Process realtime stats
|
|
2630
|
+
*/
|
|
2631
|
+
static processRealtimeData(
|
|
2632
|
+
realtimeStats: RealtimeStats[],
|
|
2633
|
+
): RealtimeData {
|
|
2634
|
+
// Count unique visitors across all minutes by collecting visitor IDs
|
|
2635
|
+
const uniqueVisitorIds = new Set<string>()
|
|
2636
|
+
for (const stat of realtimeStats) {
|
|
2637
|
+
// visitorIds is stored as a DynamoDB String Set (SS) and comes as an array
|
|
2638
|
+
const visitorIds = stat.visitorIds as string[] | undefined
|
|
2639
|
+
if (visitorIds && Array.isArray(visitorIds)) {
|
|
2640
|
+
for (const id of visitorIds) {
|
|
2641
|
+
uniqueVisitorIds.add(id)
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
const currentVisitors = uniqueVisitorIds.size
|
|
2646
|
+
|
|
2647
|
+
const pageViewsLastHour = realtimeStats.reduce((sum, s) => sum + s.pageViews, 0)
|
|
2648
|
+
|
|
2649
|
+
// Aggregate active pages across all minutes
|
|
2650
|
+
const activePageCounts = new Map<string, number>()
|
|
2651
|
+
for (const stat of realtimeStats) {
|
|
2652
|
+
for (const [page, count] of Object.entries(stat.activePages)) {
|
|
2653
|
+
activePageCounts.set(page, (activePageCounts.get(page) ?? 0) + count)
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
const totalActive = Array.from(activePageCounts.values()).reduce((a, b) => a + b, 0)
|
|
2658
|
+
const topActivePages: TopItem[] = Array.from(activePageCounts.entries())
|
|
2659
|
+
.sort((a, b) => b[1] - a[1])
|
|
2660
|
+
.slice(0, 10)
|
|
2661
|
+
.map(([name, value]) => ({
|
|
2662
|
+
name,
|
|
2663
|
+
value,
|
|
2664
|
+
percentage: totalActive > 0 ? (value / totalActive) * 100 : 0,
|
|
2665
|
+
}))
|
|
2666
|
+
|
|
2667
|
+
return {
|
|
2668
|
+
currentVisitors,
|
|
2669
|
+
pageViewsLastHour,
|
|
2670
|
+
topActivePages,
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
/**
|
|
2675
|
+
* Process goal stats into performance data
|
|
2676
|
+
*/
|
|
2677
|
+
static processGoalPerformance(
|
|
2678
|
+
goals: Goal[],
|
|
2679
|
+
goalStats: GoalStats[],
|
|
2680
|
+
totalVisitors: number,
|
|
2681
|
+
): GoalPerformance[] {
|
|
2682
|
+
return goals.map((goal) => {
|
|
2683
|
+
const stats = goalStats.filter(gs => gs.goalId === goal.id)
|
|
2684
|
+
const totalConversions = stats.reduce((sum, s) => sum + s.conversions, 0)
|
|
2685
|
+
const totalRevenue = stats.reduce((sum, s) => sum + s.revenue, 0)
|
|
2686
|
+
|
|
2687
|
+
return {
|
|
2688
|
+
goalId: goal.id,
|
|
2689
|
+
goalName: goal.name,
|
|
2690
|
+
conversions: totalConversions,
|
|
2691
|
+
conversionRate: totalVisitors > 0 ? totalConversions / totalVisitors : 0,
|
|
2692
|
+
revenue: totalRevenue,
|
|
2693
|
+
}
|
|
2694
|
+
})
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// ============================================================================
|
|
2699
|
+
// Aggregation Pipeline - Scheduled Job Utilities
|
|
2700
|
+
// ============================================================================
|
|
2701
|
+
|
|
2702
|
+
/**
|
|
2703
|
+
* Pipeline job configuration
|
|
2704
|
+
*/
|
|
2705
|
+
export interface PipelineJobConfig {
|
|
2706
|
+
/** Site ID to process */
|
|
2707
|
+
siteId: string
|
|
2708
|
+
/** Period to aggregate */
|
|
2709
|
+
period: AggregationPeriod
|
|
2710
|
+
/** Start time for aggregation window */
|
|
2711
|
+
windowStart: Date
|
|
2712
|
+
/** End time for aggregation window */
|
|
2713
|
+
windowEnd: Date
|
|
2714
|
+
/** Whether to delete raw events after aggregation */
|
|
2715
|
+
deleteRawEvents?: boolean
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
/**
|
|
2719
|
+
* Pipeline job result
|
|
2720
|
+
*/
|
|
2721
|
+
export interface PipelineJobResult {
|
|
2722
|
+
/** Job configuration */
|
|
2723
|
+
config: PipelineJobConfig
|
|
2724
|
+
/** Whether the job succeeded */
|
|
2725
|
+
success: boolean
|
|
2726
|
+
/** Error message if failed */
|
|
2727
|
+
error?: string
|
|
2728
|
+
/** Number of page views processed */
|
|
2729
|
+
pageViewsProcessed: number
|
|
2730
|
+
/** Number of sessions processed */
|
|
2731
|
+
sessionsProcessed: number
|
|
2732
|
+
/** Number of events processed */
|
|
2733
|
+
eventsProcessed: number
|
|
2734
|
+
/** Number of conversions tracked */
|
|
2735
|
+
conversionsTracked: number
|
|
2736
|
+
/** Commands generated for DynamoDB writes */
|
|
2737
|
+
commands: Array<{
|
|
2738
|
+
command: string
|
|
2739
|
+
input: Record<string, unknown>
|
|
2740
|
+
}>
|
|
2741
|
+
/** Processing duration (ms) */
|
|
2742
|
+
durationMs: number
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
/**
|
|
2746
|
+
* Aggregation job status
|
|
2747
|
+
*/
|
|
2748
|
+
export interface AggregationJobStatus {
|
|
2749
|
+
/** Job ID */
|
|
2750
|
+
jobId: string
|
|
2751
|
+
/** Site ID */
|
|
2752
|
+
siteId: string
|
|
2753
|
+
/** Period being aggregated */
|
|
2754
|
+
period: AggregationPeriod
|
|
2755
|
+
/** Job status */
|
|
2756
|
+
status: 'pending' | 'running' | 'completed' | 'failed'
|
|
2757
|
+
/** Start time */
|
|
2758
|
+
startedAt?: Date
|
|
2759
|
+
/** Completion time */
|
|
2760
|
+
completedAt?: Date
|
|
2761
|
+
/** Error if failed */
|
|
2762
|
+
error?: string
|
|
2763
|
+
/** Progress percentage (0-100) */
|
|
2764
|
+
progress: number
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
/**
|
|
2768
|
+
* Aggregation Pipeline for scheduled processing of analytics data
|
|
2769
|
+
*/
|
|
2770
|
+
export class AggregationPipeline {
|
|
2771
|
+
private store: AnalyticsStore
|
|
2772
|
+
private aggregator: AnalyticsAggregator
|
|
2773
|
+
|
|
2774
|
+
constructor(store: AnalyticsStore, aggregator?: AnalyticsAggregator) {
|
|
2775
|
+
this.store = store
|
|
2776
|
+
this.aggregator = aggregator ?? new AnalyticsAggregator({ store })
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
/**
|
|
2780
|
+
* Generate commands to run an aggregation job
|
|
2781
|
+
* This processes raw events and generates aggregated stats
|
|
2782
|
+
*/
|
|
2783
|
+
runAggregationJob(
|
|
2784
|
+
config: PipelineJobConfig,
|
|
2785
|
+
pageViews: PageView[],
|
|
2786
|
+
sessions: Session[],
|
|
2787
|
+
events: CustomEvent[],
|
|
2788
|
+
goals: Goal[],
|
|
2789
|
+
): PipelineJobResult {
|
|
2790
|
+
const startTime = Date.now()
|
|
2791
|
+
const commands: PipelineJobResult['commands'] = []
|
|
2792
|
+
let conversionsTracked = 0
|
|
2793
|
+
|
|
2794
|
+
try {
|
|
2795
|
+
// 1. Generate aggregated stats
|
|
2796
|
+
const aggregatedStats = this.aggregator.aggregateHourlyStats(
|
|
2797
|
+
config.siteId,
|
|
2798
|
+
config.windowStart,
|
|
2799
|
+
pageViews,
|
|
2800
|
+
sessions,
|
|
2801
|
+
)
|
|
2802
|
+
// Adjust period if not hourly
|
|
2803
|
+
aggregatedStats.period = config.period
|
|
2804
|
+
aggregatedStats.periodStart = AnalyticsStore.getPeriodStart(config.windowStart, config.period)
|
|
2805
|
+
|
|
2806
|
+
const statsCmd = this.store.upsertAggregatedStatsCommand(aggregatedStats)
|
|
2807
|
+
commands.push({ command: statsCmd.command, input: statsCmd.input })
|
|
2808
|
+
|
|
2809
|
+
// 2. Generate page stats
|
|
2810
|
+
const pageStats = this.aggregator.aggregatePageStats(
|
|
2811
|
+
config.siteId,
|
|
2812
|
+
config.period,
|
|
2813
|
+
config.windowStart,
|
|
2814
|
+
pageViews,
|
|
2815
|
+
)
|
|
2816
|
+
for (const ps of pageStats) {
|
|
2817
|
+
const cmd = this.store.upsertPageStatsCommand(ps)
|
|
2818
|
+
commands.push({ command: cmd.command, input: cmd.input })
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
// 3. Generate referrer stats
|
|
2822
|
+
const referrerStats = this.aggregator.aggregateReferrerStats(
|
|
2823
|
+
config.siteId,
|
|
2824
|
+
config.period,
|
|
2825
|
+
config.windowStart,
|
|
2826
|
+
sessions,
|
|
2827
|
+
)
|
|
2828
|
+
for (const rs of referrerStats) {
|
|
2829
|
+
const cmd = this.store.upsertReferrerStatsCommand(rs)
|
|
2830
|
+
commands.push({ command: cmd.command, input: cmd.input })
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
// 4. Generate geo stats
|
|
2834
|
+
const geoStats = this.aggregator.aggregateGeoStats(
|
|
2835
|
+
config.siteId,
|
|
2836
|
+
config.period,
|
|
2837
|
+
config.windowStart,
|
|
2838
|
+
sessions,
|
|
2839
|
+
)
|
|
2840
|
+
for (const gs of geoStats) {
|
|
2841
|
+
const cmd = this.store.upsertGeoStatsCommand(gs)
|
|
2842
|
+
commands.push({ command: cmd.command, input: cmd.input })
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
// 5. Generate device stats
|
|
2846
|
+
const deviceStats = this.aggregator.aggregateDeviceStats(
|
|
2847
|
+
config.siteId,
|
|
2848
|
+
config.period,
|
|
2849
|
+
config.windowStart,
|
|
2850
|
+
sessions,
|
|
2851
|
+
)
|
|
2852
|
+
for (const ds of deviceStats) {
|
|
2853
|
+
const cmd = this.store.upsertDeviceStatsCommand(ds)
|
|
2854
|
+
commands.push({ command: cmd.command, input: cmd.input })
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
// 6. Process goal conversions
|
|
2858
|
+
if (goals.length > 0) {
|
|
2859
|
+
const goalMatcher = new GoalMatcher(goals)
|
|
2860
|
+
const conversions: Conversion[] = []
|
|
2861
|
+
|
|
2862
|
+
// Match pageviews to goals
|
|
2863
|
+
for (const pv of pageViews) {
|
|
2864
|
+
const matches = goalMatcher.matchPageView(pv)
|
|
2865
|
+
for (const match of matches) {
|
|
2866
|
+
conversions.push(GoalMatcher.createConversion(config.siteId, match))
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
// Match events to goals
|
|
2871
|
+
for (const event of events) {
|
|
2872
|
+
const matches = goalMatcher.matchEvent(event)
|
|
2873
|
+
for (const match of matches) {
|
|
2874
|
+
conversions.push(GoalMatcher.createConversion(config.siteId, match))
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
conversionsTracked = conversions.length
|
|
2879
|
+
|
|
2880
|
+
// Generate goal stats grouped by goal
|
|
2881
|
+
const goalGroups = new Map<string, Conversion[]>()
|
|
2882
|
+
for (const conv of conversions) {
|
|
2883
|
+
const existing = goalGroups.get(conv.goalId) ?? []
|
|
2884
|
+
existing.push(conv)
|
|
2885
|
+
goalGroups.set(conv.goalId, existing)
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
for (const [goalId, convs] of goalGroups) {
|
|
2889
|
+
const goalStats = this.createGoalStats(
|
|
2890
|
+
config.siteId,
|
|
2891
|
+
goalId,
|
|
2892
|
+
config.period,
|
|
2893
|
+
config.windowStart,
|
|
2894
|
+
convs,
|
|
2895
|
+
aggregatedStats.uniqueVisitors,
|
|
2896
|
+
)
|
|
2897
|
+
const cmd = this.store.upsertGoalStatsCommand(goalStats)
|
|
2898
|
+
commands.push({ command: cmd.command, input: cmd.input })
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// 7. Generate event stats
|
|
2903
|
+
const eventStats = this.aggregateEventStats(
|
|
2904
|
+
config.siteId,
|
|
2905
|
+
config.period,
|
|
2906
|
+
config.windowStart,
|
|
2907
|
+
events,
|
|
2908
|
+
)
|
|
2909
|
+
for (const es of eventStats) {
|
|
2910
|
+
const cmd = this.store.upsertEventStatsCommand(es)
|
|
2911
|
+
commands.push({ command: cmd.command, input: cmd.input })
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
return {
|
|
2915
|
+
config,
|
|
2916
|
+
success: true,
|
|
2917
|
+
pageViewsProcessed: pageViews.length,
|
|
2918
|
+
sessionsProcessed: sessions.length,
|
|
2919
|
+
eventsProcessed: events.length,
|
|
2920
|
+
conversionsTracked,
|
|
2921
|
+
commands,
|
|
2922
|
+
durationMs: Date.now() - startTime,
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
catch (error) {
|
|
2926
|
+
return {
|
|
2927
|
+
config,
|
|
2928
|
+
success: false,
|
|
2929
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
2930
|
+
pageViewsProcessed: 0,
|
|
2931
|
+
sessionsProcessed: 0,
|
|
2932
|
+
eventsProcessed: 0,
|
|
2933
|
+
conversionsTracked: 0,
|
|
2934
|
+
commands,
|
|
2935
|
+
durationMs: Date.now() - startTime,
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/**
|
|
2941
|
+
* Get the next scheduled job times for a site
|
|
2942
|
+
*/
|
|
2943
|
+
static getScheduledJobTimes(now: Date = new Date()): {
|
|
2944
|
+
hourly: Date
|
|
2945
|
+
daily: Date
|
|
2946
|
+
monthly: Date
|
|
2947
|
+
} {
|
|
2948
|
+
// Hourly: next hour boundary
|
|
2949
|
+
const hourly = new Date(now)
|
|
2950
|
+
hourly.setMinutes(0, 0, 0)
|
|
2951
|
+
hourly.setHours(hourly.getHours() + 1)
|
|
2952
|
+
|
|
2953
|
+
// Daily: next midnight UTC
|
|
2954
|
+
const daily = new Date(now)
|
|
2955
|
+
daily.setUTCHours(0, 0, 0, 0)
|
|
2956
|
+
daily.setUTCDate(daily.getUTCDate() + 1)
|
|
2957
|
+
|
|
2958
|
+
// Monthly: first of next month UTC
|
|
2959
|
+
const monthly = new Date(now)
|
|
2960
|
+
monthly.setUTCHours(0, 0, 0, 0)
|
|
2961
|
+
monthly.setUTCDate(1)
|
|
2962
|
+
monthly.setUTCMonth(monthly.getUTCMonth() + 1)
|
|
2963
|
+
|
|
2964
|
+
return { hourly, daily, monthly }
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
/**
|
|
2968
|
+
* Generate a cron expression for aggregation jobs
|
|
2969
|
+
*/
|
|
2970
|
+
static getCronExpression(period: AggregationPeriod): string {
|
|
2971
|
+
switch (period) {
|
|
2972
|
+
case 'hour':
|
|
2973
|
+
return '0 * * * *' // Every hour at minute 0
|
|
2974
|
+
case 'day':
|
|
2975
|
+
return '0 0 * * *' // Every day at midnight
|
|
2976
|
+
case 'month':
|
|
2977
|
+
return '0 0 1 * *' // First day of every month
|
|
2978
|
+
default:
|
|
2979
|
+
return '0 * * * *'
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
/**
|
|
2984
|
+
* Get the aggregation window for a period
|
|
2985
|
+
*/
|
|
2986
|
+
static getAggregationWindow(period: AggregationPeriod, referenceTime: Date = new Date()): {
|
|
2987
|
+
start: Date
|
|
2988
|
+
end: Date
|
|
2989
|
+
} {
|
|
2990
|
+
const end = new Date(referenceTime)
|
|
2991
|
+
|
|
2992
|
+
switch (period) {
|
|
2993
|
+
case 'hour': {
|
|
2994
|
+
// Previous hour
|
|
2995
|
+
end.setMinutes(0, 0, 0)
|
|
2996
|
+
const start = new Date(end)
|
|
2997
|
+
start.setHours(start.getHours() - 1)
|
|
2998
|
+
return { start, end }
|
|
2999
|
+
}
|
|
3000
|
+
case 'day': {
|
|
3001
|
+
// Previous day
|
|
3002
|
+
end.setUTCHours(0, 0, 0, 0)
|
|
3003
|
+
const start = new Date(end)
|
|
3004
|
+
start.setUTCDate(start.getUTCDate() - 1)
|
|
3005
|
+
return { start, end }
|
|
3006
|
+
}
|
|
3007
|
+
case 'month': {
|
|
3008
|
+
// Previous month
|
|
3009
|
+
end.setUTCDate(1)
|
|
3010
|
+
end.setUTCHours(0, 0, 0, 0)
|
|
3011
|
+
const start = new Date(end)
|
|
3012
|
+
start.setUTCMonth(start.getUTCMonth() - 1)
|
|
3013
|
+
return { start, end }
|
|
3014
|
+
}
|
|
3015
|
+
default:
|
|
3016
|
+
return { start: end, end }
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
/**
|
|
3021
|
+
* Create a job configuration for a site and period
|
|
3022
|
+
*/
|
|
3023
|
+
static createJobConfig(
|
|
3024
|
+
siteId: string,
|
|
3025
|
+
period: AggregationPeriod,
|
|
3026
|
+
referenceTime?: Date,
|
|
3027
|
+
): PipelineJobConfig {
|
|
3028
|
+
const window = AggregationPipeline.getAggregationWindow(period, referenceTime)
|
|
3029
|
+
return {
|
|
3030
|
+
siteId,
|
|
3031
|
+
period,
|
|
3032
|
+
windowStart: window.start,
|
|
3033
|
+
windowEnd: window.end,
|
|
3034
|
+
deleteRawEvents: period === 'hour', // Only delete after hourly aggregation
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
/**
|
|
3039
|
+
* Helper to aggregate event stats
|
|
3040
|
+
*/
|
|
3041
|
+
private aggregateEventStats(
|
|
3042
|
+
siteId: string,
|
|
3043
|
+
period: AggregationPeriod,
|
|
3044
|
+
periodStart: Date,
|
|
3045
|
+
events: CustomEvent[],
|
|
3046
|
+
): EventStats[] {
|
|
3047
|
+
const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
|
|
3048
|
+
|
|
3049
|
+
// Group by event name
|
|
3050
|
+
const eventGroups = new Map<string, CustomEvent[]>()
|
|
3051
|
+
for (const event of events) {
|
|
3052
|
+
const existing = eventGroups.get(event.name) ?? []
|
|
3053
|
+
existing.push(event)
|
|
3054
|
+
eventGroups.set(event.name, existing)
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
const results: EventStats[] = []
|
|
3058
|
+
for (const [eventName, groupEvents] of eventGroups) {
|
|
3059
|
+
const uniqueVisitors = new Set(groupEvents.map(e => e.visitorId))
|
|
3060
|
+
const values = groupEvents.filter(e => e.value !== undefined).map(e => e.value!)
|
|
3061
|
+
const totalValue = values.reduce((a, b) => a + b, 0)
|
|
3062
|
+
|
|
3063
|
+
results.push({
|
|
3064
|
+
siteId,
|
|
3065
|
+
period,
|
|
3066
|
+
periodStart: periodStartStr,
|
|
3067
|
+
eventName,
|
|
3068
|
+
count: groupEvents.length,
|
|
3069
|
+
uniqueVisitors: uniqueVisitors.size,
|
|
3070
|
+
totalValue,
|
|
3071
|
+
avgValue: values.length > 0 ? totalValue / values.length : 0,
|
|
3072
|
+
})
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
return results
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
/**
|
|
3079
|
+
* Helper to create goal stats
|
|
3080
|
+
*/
|
|
3081
|
+
private createGoalStats(
|
|
3082
|
+
siteId: string,
|
|
3083
|
+
goalId: string,
|
|
3084
|
+
period: AggregationPeriod,
|
|
3085
|
+
periodStart: Date,
|
|
3086
|
+
conversions: Conversion[],
|
|
3087
|
+
totalVisitors: number,
|
|
3088
|
+
): GoalStats {
|
|
3089
|
+
const periodStartStr = AnalyticsStore.getPeriodStart(periodStart, period)
|
|
3090
|
+
const uniqueConversions = new Set(conversions.map(c => c.visitorId))
|
|
3091
|
+
const revenue = conversions.reduce((sum, c) => sum + c.value, 0)
|
|
3092
|
+
|
|
3093
|
+
return {
|
|
3094
|
+
siteId,
|
|
3095
|
+
goalId,
|
|
3096
|
+
period,
|
|
3097
|
+
periodStart: periodStartStr,
|
|
3098
|
+
conversions: conversions.length,
|
|
3099
|
+
uniqueConversions: uniqueConversions.size,
|
|
3100
|
+
conversionRate: totalVisitors > 0 ? uniqueConversions.size / totalVisitors : 0,
|
|
3101
|
+
revenue,
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
// ============================================================================
|
|
3107
|
+
// Tracking Script Generator
|
|
3108
|
+
// ============================================================================
|
|
3109
|
+
|
|
3110
|
+
/**
|
|
3111
|
+
* Options for generating tracking script
|
|
3112
|
+
*/
|
|
3113
|
+
export interface TrackingScriptOptions {
|
|
3114
|
+
/** Site ID */
|
|
3115
|
+
siteId: string
|
|
3116
|
+
/** API endpoint URL */
|
|
3117
|
+
apiEndpoint: string
|
|
3118
|
+
/** Whether to respect Do Not Track */
|
|
3119
|
+
honorDnt?: boolean
|
|
3120
|
+
/** Whether to track hash changes as page views */
|
|
3121
|
+
trackHashChanges?: boolean
|
|
3122
|
+
/** Whether to track outbound links (legacy; superseded by trackLinkClicks) */
|
|
3123
|
+
trackOutboundLinks?: boolean
|
|
3124
|
+
/** Whether to track link clicks (outbound, internal, download, mailto, tel) */
|
|
3125
|
+
trackLinkClicks?: boolean
|
|
3126
|
+
/** Whether to track SPA route changes (history pushState/replaceState/popstate) as page views */
|
|
3127
|
+
trackSpaRoutes?: boolean
|
|
3128
|
+
/** Whether to track engagement (max scroll depth + active time on page), flushed on page-leave */
|
|
3129
|
+
trackEngagement?: boolean
|
|
3130
|
+
/** Use stealth mode (shorter endpoint paths, less identifiable) */
|
|
3131
|
+
stealthMode?: boolean
|
|
3132
|
+
/** Whether to track Core Web Vitals (LCP, FID, CLS, TTFB, INP) */
|
|
3133
|
+
trackWebVitals?: boolean
|
|
3134
|
+
/** Ingest-only error key (Sentry-style DSN key). When set, captured errors
|
|
3135
|
+
*/
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
/**
|
|
3139
|
+
* Generate minimal tracking script
|
|
3140
|
+
*/
|
|
3141
|
+
/* eslint-disable prefer-const, pickier/no-unused-vars, general/prefer-template */
|
|
3142
|
+
export function generateTrackingScript(options: TrackingScriptOptions): string {
|
|
3143
|
+
const endpoint = options.stealthMode ? '/t' : '/collect'
|
|
3144
|
+
const webVitalsCode = options.trackWebVitals !== false ? `
|
|
3145
|
+
// Core Web Vitals tracking
|
|
3146
|
+
var vitalsSent={};
|
|
3147
|
+
function sendVital(name,value,rating){
|
|
3148
|
+
if(vitalsSent[name])return;
|
|
3149
|
+
vitalsSent[name]=true;
|
|
3150
|
+
t('vitals',{metric:name,value:Math.round(value),rating:rating},true);
|
|
3151
|
+
}
|
|
3152
|
+
function getRating(name,value){
|
|
3153
|
+
var thresholds={LCP:[2500,4000],FID:[100,300],CLS:[0.1,0.25],FCP:[1800,3000],TTFB:[800,1800],INP:[200,500]};
|
|
3154
|
+
var t=thresholds[name]||[0,0];
|
|
3155
|
+
return value<=t[0]?'good':value<=t[1]?'needs-improvement':'poor';
|
|
3156
|
+
}
|
|
3157
|
+
// LCP - Largest Contentful Paint
|
|
3158
|
+
if(w.PerformanceObserver){
|
|
3159
|
+
try{
|
|
3160
|
+
new PerformanceObserver(function(l){
|
|
3161
|
+
var entries=l.getEntries();
|
|
3162
|
+
var last=entries[entries.length-1];
|
|
3163
|
+
if(last)sendVital('LCP',last.startTime,getRating('LCP',last.startTime));
|
|
3164
|
+
}).observe({type:'largest-contentful-paint',buffered:true});
|
|
3165
|
+
}
|
|
3166
|
+
catch (e){}
|
|
3167
|
+
// FCP - First Contentful Paint
|
|
3168
|
+
try{
|
|
3169
|
+
new PerformanceObserver(function(l){
|
|
3170
|
+
for(var e of l.getEntries()){if(e.name==='first-contentful-paint')sendVital('FCP',e.startTime,getRating('FCP',e.startTime));}
|
|
3171
|
+
}).observe({type:'paint',buffered:true});
|
|
3172
|
+
}
|
|
3173
|
+
catch (e){}
|
|
3174
|
+
// FID - First Input Delay
|
|
3175
|
+
try{
|
|
3176
|
+
new PerformanceObserver(function(l){
|
|
3177
|
+
var entry=l.getEntries()[0];
|
|
3178
|
+
if(entry)sendVital('FID',entry.processingStart-entry.startTime,getRating('FID',entry.processingStart-entry.startTime));
|
|
3179
|
+
}).observe({type:'first-input',buffered:true});
|
|
3180
|
+
}
|
|
3181
|
+
catch (e){}
|
|
3182
|
+
// CLS - Cumulative Layout Shift
|
|
3183
|
+
var clsValue=0;
|
|
3184
|
+
try{
|
|
3185
|
+
new PerformanceObserver(function(l){
|
|
3186
|
+
for(var e of l.getEntries()){if(!e.hadRecentInput)clsValue+=e.value;}
|
|
3187
|
+
}).observe({type:'layout-shift',buffered:true});
|
|
3188
|
+
w.addEventListener('visibilitychange',function(){
|
|
3189
|
+
if(d.visibilityState==='hidden')sendVital('CLS',clsValue*1000,getRating('CLS',clsValue));
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
catch (e){}
|
|
3193
|
+
// INP - Interaction to Next Paint
|
|
3194
|
+
var inpValue=0;
|
|
3195
|
+
try{
|
|
3196
|
+
new PerformanceObserver(function(l){
|
|
3197
|
+
for(var e of l.getEntries()){
|
|
3198
|
+
if(e.interactionId&&e.duration>inpValue)inpValue=e.duration;
|
|
3199
|
+
}
|
|
3200
|
+
}).observe({type:'event',buffered:true,durationThreshold:16});
|
|
3201
|
+
w.addEventListener('visibilitychange',function(){
|
|
3202
|
+
if(d.visibilityState==='hidden'&&inpValue>0)sendVital('INP',inpValue,getRating('INP',inpValue));
|
|
3203
|
+
});
|
|
3204
|
+
}
|
|
3205
|
+
catch (e){}
|
|
3206
|
+
}
|
|
3207
|
+
// TTFB - Time to First Byte
|
|
3208
|
+
w.addEventListener('load',function(){
|
|
3209
|
+
var nav=performance.getEntriesByType('navigation')[0];
|
|
3210
|
+
if(nav)sendVital('TTFB',nav.responseStart,getRating('TTFB',nav.responseStart));
|
|
3211
|
+
});` : ''
|
|
3212
|
+
|
|
3213
|
+
|
|
3214
|
+
return `
|
|
3215
|
+
<!-- Analytics -->
|
|
3216
|
+
<script data-site="${options.siteId}" data-api="${options.apiEndpoint}" defer>
|
|
3217
|
+
(function(){
|
|
3218
|
+
'use strict';
|
|
3219
|
+
var d=document,w=window,n=navigator,s=d.currentScript;
|
|
3220
|
+
// Double-include guard (#138) + version identity (#179): the guard value IS
|
|
3221
|
+
// the tracker version, so support can read it off any live page.
|
|
3222
|
+
if(w.__tsa)return;w.__tsa="${TRACKER_VERSION}";
|
|
3223
|
+
var site=s.dataset.site,api=s.dataset.api;
|
|
3224
|
+
if((!site||!api)&&s.src){try{var su=new URL(s.src);if(!api)api=su.origin;var sm=su.pathname.match(/\\/sites\\/([^/]+)\\/script/);if(!site&&sm)site=sm[1];}catch(_e){}}
|
|
3225
|
+
${options.honorDnt ? 'if(n.doNotTrack==="1"||n.globalPrivacyControl===true)return;' : ''}
|
|
3226
|
+
var q=[],sk='_tsa_sid',sid;try{sid=sessionStorage.getItem(sk)}
|
|
3227
|
+
catch (e){}if(!sid){sid=Math.random().toString(36).slice(2);try{sessionStorage.setItem(sk,sid)}
|
|
3228
|
+
catch (e){}}
|
|
3229
|
+
// Client-side browser detection
|
|
3230
|
+
var br=s.dataset.browser||'';
|
|
3231
|
+
if(!br)try{
|
|
3232
|
+
var ua=n.userAgent||'';
|
|
3233
|
+
var uad=n.userAgentData;
|
|
3234
|
+
// Check userAgentData.brands for non-generic browser brands
|
|
3235
|
+
if(uad&&uad.brands){
|
|
3236
|
+
var b=uad.brands.find(function(x){return x.brand&&!/chromium|not.*brand|google chrome/i.test(x.brand)});
|
|
3237
|
+
if(b)br=b.brand;
|
|
3238
|
+
}
|
|
3239
|
+
// Fallback to UA string patterns
|
|
3240
|
+
if(!br||br==='Google Chrome'){
|
|
3241
|
+
if(/Arc\//i.test(ua))br='Arc';
|
|
3242
|
+
else if(/Edg\//i.test(ua))br='Edge';
|
|
3243
|
+
else if(/OPR\/|Opera/i.test(ua))br='Opera';
|
|
3244
|
+
else if(/Brave/i.test(ua))br='Brave';
|
|
3245
|
+
else if(/Vivaldi/i.test(ua))br='Vivaldi';
|
|
3246
|
+
else if(/Firefox/i.test(ua))br='Firefox';
|
|
3247
|
+
else if(/Safari/i.test(ua)&&!/Chrome/i.test(ua))br='Safari';
|
|
3248
|
+
else if(/Chrome/i.test(ua))br='Chrome';
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
catch (e){}
|
|
3252
|
+
// Coarse timezone for privacy-first country stats: the server maps it to a
|
|
3253
|
+
// country and discards it — no IP geolocation, no third parties.
|
|
3254
|
+
var tzn='';try{tzn=Intl.DateTimeFormat().resolvedOptions().timeZone||''}
|
|
3255
|
+
catch (e){}
|
|
3256
|
+
function t(e,p,b){
|
|
3257
|
+
var body=JSON.stringify({
|
|
3258
|
+
s:site,sid:sid,e:e,p:p||{},
|
|
3259
|
+
u:location.href,r:d.referrer,t:d.title,
|
|
3260
|
+
sw:screen.width,sh:screen.height,
|
|
3261
|
+
br:br,tz:tzn,v:"${TRACKER_VERSION}",
|
|
3262
|
+
eid:Math.random().toString(36).slice(2)+Date.now().toString(36)
|
|
3263
|
+
});
|
|
3264
|
+
if(b&&n.sendBeacon){try{if(n.sendBeacon(api+'${endpoint}',body))return;}catch(_e){}}
|
|
3265
|
+
var x=new XMLHttpRequest();
|
|
3266
|
+
x.open('POST',api+'${endpoint}',true);
|
|
3267
|
+
x.setRequestHeader('Content-Type','application/json');
|
|
3268
|
+
x.send(body);
|
|
3269
|
+
}
|
|
3270
|
+
function pv(){t('pageview',undefined,true);}
|
|
3271
|
+
${options.trackHashChanges ? 'w.addEventListener(\'hashchange\',pv);' : ''}
|
|
3272
|
+
${options.trackSpaRoutes
|
|
3273
|
+
? `
|
|
3274
|
+
var lastPath=location.pathname+location.search;
|
|
3275
|
+
function spaPv(){
|
|
3276
|
+
var p=location.pathname+location.search;
|
|
3277
|
+
if(p===lastPath)return;
|
|
3278
|
+
lastPath=p;
|
|
3279
|
+
if(typeof engFlush!=='undefined'){engMax=0;engActive=0;engSent=false;engStart=Date.now();}
|
|
3280
|
+
pv();
|
|
3281
|
+
}
|
|
3282
|
+
var _ps=history.pushState;
|
|
3283
|
+
history.pushState=function(){if(typeof engFlush!=='undefined')engFlush();_ps.apply(this,arguments);spaPv();};
|
|
3284
|
+
var _rs=history.replaceState;
|
|
3285
|
+
history.replaceState=function(){if(typeof engFlush!=='undefined')engFlush();_rs.apply(this,arguments);spaPv();};
|
|
3286
|
+
w.addEventListener('popstate',spaPv);`
|
|
3287
|
+
: ''}
|
|
3288
|
+
${options.trackOutboundLinks
|
|
3289
|
+
? `
|
|
3290
|
+
d.addEventListener('click',function(e){
|
|
3291
|
+
var a=e.target.closest('a');
|
|
3292
|
+
if(a&&a.hostname!==location.hostname){
|
|
3293
|
+
t('outbound',{url:a.href});
|
|
3294
|
+
}
|
|
3295
|
+
});`
|
|
3296
|
+
: ''}
|
|
3297
|
+
${options.trackLinkClicks
|
|
3298
|
+
? `
|
|
3299
|
+
d.addEventListener('click',function(e){
|
|
3300
|
+
var a=e.target&&e.target.closest&&e.target.closest('a');
|
|
3301
|
+
if(!a||!a.href)return;
|
|
3302
|
+
var proto=a.protocol,kind;
|
|
3303
|
+
if(proto==='mailto:')kind='mailto';
|
|
3304
|
+
else if(proto==='tel:')kind='tel';
|
|
3305
|
+
else if(proto==='http:'||proto==='https:'){
|
|
3306
|
+
if(a.hasAttribute('download')||/\\.(pdf|zip|docx?|xlsx?|pptx?|dmg|pkg|exe|csv|tsv|mp3|mp4|mov|wav|gz|tgz|rar|7z|apk|iso|bin|deb|rpm|msi)$/i.test(a.pathname))kind='download';
|
|
3307
|
+
else if(a.hostname!==location.hostname)kind='outbound';
|
|
3308
|
+
else{var h=a.getAttribute('href')||'';if(h.charAt(0)==='#')return;kind='internal';}
|
|
3309
|
+
}else return;
|
|
3310
|
+
t('click',{url:a.href,kind:kind,text:(a.innerText||a.textContent||'').replace(/\\s+/g,' ').trim().slice(0,200)},true);
|
|
3311
|
+
},true);`
|
|
3312
|
+
: ''}
|
|
3313
|
+
${options.trackEngagement
|
|
3314
|
+
? `
|
|
3315
|
+
var engStart=Date.now(),engActive=0,engMax=0,engSent=false;
|
|
3316
|
+
function engScroll(){
|
|
3317
|
+
var st=w.pageYOffset||d.documentElement.scrollTop;
|
|
3318
|
+
var dh=Math.max(d.body.scrollHeight,d.documentElement.scrollHeight)-w.innerHeight;
|
|
3319
|
+
if(dh<=0)return;
|
|
3320
|
+
var p=Math.round((st/dh)*100);
|
|
3321
|
+
if(p>engMax)engMax=p>100?100:p;
|
|
3322
|
+
}
|
|
3323
|
+
var engThr=null;
|
|
3324
|
+
w.addEventListener('scroll',function(){
|
|
3325
|
+
if(engThr)return;
|
|
3326
|
+
engThr=setTimeout(function(){engThr=null;engScroll();},250);
|
|
3327
|
+
},{passive:true});
|
|
3328
|
+
function engAccum(){engActive+=Date.now()-engStart;engStart=Date.now();}
|
|
3329
|
+
function engFlush(){
|
|
3330
|
+
if(d.visibilityState!=='hidden')engAccum();
|
|
3331
|
+
var secs=Math.round(engActive/1000);
|
|
3332
|
+
if(secs<1&&engSent)return;
|
|
3333
|
+
engSent=true;engActive=0;
|
|
3334
|
+
t('engagement',{scrollDepth:engMax,timeOnPage:secs},true);
|
|
3335
|
+
}
|
|
3336
|
+
d.addEventListener('visibilitychange',function(){
|
|
3337
|
+
if(d.visibilityState==='hidden'){engAccum();engFlush();}
|
|
3338
|
+
else{engStart=Date.now();}
|
|
3339
|
+
});
|
|
3340
|
+
w.addEventListener('pagehide',engFlush);`
|
|
3341
|
+
: ''}
|
|
3342
|
+
if(d.readyState==='complete')pv();
|
|
3343
|
+
else w.addEventListener('load',pv);
|
|
3344
|
+
w.fathom={track:function(n,v,p){var o={};if(p)for(var k in p)o[k]=p[k];o.name=n;o.value=v;t('event',o);}};\n${webVitalsCode}
|
|
3345
|
+
})();
|
|
3346
|
+
</script>
|
|
3347
|
+
`.trim()
|
|
3348
|
+
}
|
|
3349
|
+
/* eslint-enable prefer-const, pickier/no-unused-vars, general/prefer-template */
|