@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/api.ts
ADDED
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ⚠️ LEGACY — NOT the live request path (#95)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// This layer is built on the IN-MEMORY AnalyticsStore and is kept only for
|
|
5
|
+
// backwards compatibility of the published library API. Nothing here serves
|
|
6
|
+
// the bundled server or dashboard.
|
|
7
|
+
//
|
|
8
|
+
// The source of truth for the real API is:
|
|
9
|
+
// src/router.ts + src/handlers/* (DynamoDB-backed, served by server/)
|
|
10
|
+
//
|
|
11
|
+
// Add new endpoints THERE. Do not extend this file.
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// HTTP handlers for analytics endpoints, designed to work with any server framework
|
|
14
|
+
// (Bun, Express, Hono, AWS Lambda, Cloudflare Workers, etc.)
|
|
15
|
+
|
|
16
|
+
import type { AggregationPeriod, CustomEvent, Goal, PageView, Session, Site, SiteSettings } from './Analytics'
|
|
17
|
+
import {
|
|
18
|
+
|
|
19
|
+
AggregationPipeline,
|
|
20
|
+
AnalyticsQueryAPI,
|
|
21
|
+
AnalyticsStore,
|
|
22
|
+
|
|
23
|
+
generateTrackingScript,
|
|
24
|
+
|
|
25
|
+
GoalMatcher,
|
|
26
|
+
|
|
27
|
+
} from './Analytics'
|
|
28
|
+
import {
|
|
29
|
+
GeolocationService,
|
|
30
|
+
type IPGeoResult,
|
|
31
|
+
lookupFromHeaders,
|
|
32
|
+
} from './geolocation'
|
|
33
|
+
import { getDailySalt } from './lib/salt'
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Types
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generic HTTP request interface (framework-agnostic)
|
|
41
|
+
*/
|
|
42
|
+
export interface AnalyticsRequest {
|
|
43
|
+
method: string
|
|
44
|
+
path: string
|
|
45
|
+
params: Record<string, string>
|
|
46
|
+
query: Record<string, string>
|
|
47
|
+
body: unknown
|
|
48
|
+
headers: Record<string, string>
|
|
49
|
+
ip?: string
|
|
50
|
+
userAgent?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generic HTTP response interface
|
|
55
|
+
*/
|
|
56
|
+
export interface AnalyticsResponse {
|
|
57
|
+
status: number
|
|
58
|
+
headers: Record<string, string>
|
|
59
|
+
body: unknown
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* API handler context
|
|
64
|
+
*/
|
|
65
|
+
export interface HandlerContext {
|
|
66
|
+
store: AnalyticsStore
|
|
67
|
+
queryApi: AnalyticsQueryAPI
|
|
68
|
+
pipeline: AggregationPipeline
|
|
69
|
+
/** Daily rotating salt for visitor ID hashing */
|
|
70
|
+
visitorSalt: string
|
|
71
|
+
/** Function to execute DynamoDB commands */
|
|
72
|
+
executeCommand: (command: { command: string, input: Record<string, unknown> }) => Promise<unknown>
|
|
73
|
+
/** Optional: Function to get goals for a site */
|
|
74
|
+
getGoals?: (siteId: string) => Promise<Goal[]>
|
|
75
|
+
/** Optional: Function to get site by ID */
|
|
76
|
+
getSite?: (siteId: string) => Promise<Site | null>
|
|
77
|
+
/** Optional: Session store for managing visitor sessions */
|
|
78
|
+
sessionStore?: SessionStore
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Simple in-memory session store interface
|
|
83
|
+
*/
|
|
84
|
+
export interface SessionStore {
|
|
85
|
+
get: (key: string) => Promise<Session | null>
|
|
86
|
+
set: (key: string, session: Session, ttlSeconds?: number) => Promise<void>
|
|
87
|
+
delete: (key: string) => Promise<void>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Collect endpoint payload (from tracking script)
|
|
92
|
+
*/
|
|
93
|
+
export interface CollectPayload {
|
|
94
|
+
/** Site ID */
|
|
95
|
+
s: string
|
|
96
|
+
/** Session ID */
|
|
97
|
+
sid: string
|
|
98
|
+
/** Event type */
|
|
99
|
+
e: 'pageview' | 'event' | 'outbound' | 'error'
|
|
100
|
+
/** Event properties */
|
|
101
|
+
p?: Record<string, unknown>
|
|
102
|
+
/** Current URL */
|
|
103
|
+
u: string
|
|
104
|
+
/** Referrer */
|
|
105
|
+
r?: string
|
|
106
|
+
/** Page title */
|
|
107
|
+
t?: string
|
|
108
|
+
/** Screen width */
|
|
109
|
+
sw?: number
|
|
110
|
+
/** Screen height */
|
|
111
|
+
sh?: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* API configuration
|
|
116
|
+
*/
|
|
117
|
+
export interface AnalyticsAPIConfig {
|
|
118
|
+
/** DynamoDB table name */
|
|
119
|
+
tableName: string
|
|
120
|
+
/** CORS allowed origins */
|
|
121
|
+
corsOrigins?: string[]
|
|
122
|
+
/** Whether to use TTL for raw events */
|
|
123
|
+
useTtl?: boolean
|
|
124
|
+
/** Raw event TTL in seconds */
|
|
125
|
+
rawEventTtl?: number
|
|
126
|
+
/** Base path for API routes */
|
|
127
|
+
basePath?: string
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// Analytics API Handler Class
|
|
132
|
+
// ============================================================================
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Analytics API - handles all analytics HTTP endpoints
|
|
136
|
+
*/
|
|
137
|
+
export class AnalyticsAPI {
|
|
138
|
+
private config: Required<AnalyticsAPIConfig>
|
|
139
|
+
private store: AnalyticsStore
|
|
140
|
+
private queryApi: AnalyticsQueryAPI
|
|
141
|
+
private pipeline: AggregationPipeline
|
|
142
|
+
|
|
143
|
+
constructor(config: AnalyticsAPIConfig) {
|
|
144
|
+
this.config = {
|
|
145
|
+
corsOrigins: ['*'],
|
|
146
|
+
useTtl: true,
|
|
147
|
+
rawEventTtl: 30 * 24 * 60 * 60,
|
|
148
|
+
basePath: '/api/analytics',
|
|
149
|
+
...config,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.store = new AnalyticsStore({
|
|
153
|
+
tableName: this.config.tableName,
|
|
154
|
+
useTtl: this.config.useTtl,
|
|
155
|
+
rawEventTtl: this.config.rawEventTtl,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
this.queryApi = new AnalyticsQueryAPI(this.store)
|
|
159
|
+
this.pipeline = new AggregationPipeline(this.store)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create handler context for request processing
|
|
164
|
+
*/
|
|
165
|
+
createContext(
|
|
166
|
+
executeCommand: HandlerContext['executeCommand'],
|
|
167
|
+
options?: Partial<Pick<HandlerContext, 'getGoals' | 'getSite' | 'sessionStore'>>,
|
|
168
|
+
): HandlerContext {
|
|
169
|
+
return {
|
|
170
|
+
store: this.store,
|
|
171
|
+
queryApi: this.queryApi,
|
|
172
|
+
pipeline: this.pipeline,
|
|
173
|
+
visitorSalt: this.getDailySalt(),
|
|
174
|
+
executeCommand,
|
|
175
|
+
...options,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get daily rotating salt for visitor ID hashing (secret-seeded, #88)
|
|
181
|
+
*/
|
|
182
|
+
private getDailySalt(): string {
|
|
183
|
+
return getDailySalt()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get CORS headers
|
|
188
|
+
*/
|
|
189
|
+
getCorsHeaders(origin?: string): Record<string, string> {
|
|
190
|
+
const allowedOrigin = this.config.corsOrigins.includes('*')
|
|
191
|
+
? '*'
|
|
192
|
+
: (origin && this.config.corsOrigins.includes(origin) ? origin : this.config.corsOrigins[0])
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
'Access-Control-Allow-Origin': allowedOrigin || '*',
|
|
196
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
197
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
198
|
+
'Access-Control-Max-Age': '86400',
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ==========================================================================
|
|
203
|
+
// Route Handlers
|
|
204
|
+
// ==========================================================================
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Handle OPTIONS preflight request
|
|
208
|
+
*/
|
|
209
|
+
handleOptions(req: AnalyticsRequest): AnalyticsResponse {
|
|
210
|
+
return {
|
|
211
|
+
status: 204,
|
|
212
|
+
headers: this.getCorsHeaders(req.headers.origin),
|
|
213
|
+
body: null,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* POST /collect - Receive tracking events from JavaScript snippet
|
|
219
|
+
*/
|
|
220
|
+
async handleCollect(
|
|
221
|
+
req: AnalyticsRequest,
|
|
222
|
+
ctx: HandlerContext,
|
|
223
|
+
): Promise<AnalyticsResponse> {
|
|
224
|
+
try {
|
|
225
|
+
const payload = req.body as CollectPayload
|
|
226
|
+
|
|
227
|
+
if (!payload?.s || !payload?.e || !payload?.u) {
|
|
228
|
+
return this.errorResponse(400, 'Missing required fields: s, e, u')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Hash visitor ID for privacy
|
|
232
|
+
const visitorId = await AnalyticsStore.hashVisitorId(
|
|
233
|
+
req.ip || 'unknown',
|
|
234
|
+
req.userAgent || req.headers['user-agent'] || 'unknown',
|
|
235
|
+
payload.s,
|
|
236
|
+
ctx.visitorSalt,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
// Parse URL
|
|
240
|
+
let parsedUrl: URL
|
|
241
|
+
try {
|
|
242
|
+
parsedUrl = new URL(payload.u)
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return this.errorResponse(400, 'Invalid URL')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const timestamp = new Date()
|
|
249
|
+
const sessionId = payload.sid
|
|
250
|
+
|
|
251
|
+
// Get or create session
|
|
252
|
+
let session = ctx.sessionStore ? await ctx.sessionStore.get(`${payload.s}:${sessionId}`) : null
|
|
253
|
+
const isNewSession = !session
|
|
254
|
+
const isUnique = isNewSession
|
|
255
|
+
|
|
256
|
+
if (payload.e === 'pageview') {
|
|
257
|
+
// Parse device info from user agent
|
|
258
|
+
const deviceInfo = AnalyticsStore.parseUserAgent(req.userAgent || req.headers['user-agent'] || '')
|
|
259
|
+
const referrerSource = AnalyticsStore.parseReferrerSource(payload.r)
|
|
260
|
+
|
|
261
|
+
// Parse UTM parameters from URL
|
|
262
|
+
const utmSource = parsedUrl.searchParams.get('utm_source') || undefined
|
|
263
|
+
const utmMedium = parsedUrl.searchParams.get('utm_medium') || undefined
|
|
264
|
+
const utmCampaign = parsedUrl.searchParams.get('utm_campaign') || undefined
|
|
265
|
+
const utmContent = parsedUrl.searchParams.get('utm_content') || undefined
|
|
266
|
+
const utmTerm = parsedUrl.searchParams.get('utm_term') || undefined
|
|
267
|
+
|
|
268
|
+
// Get geolocation from headers (Cloudflare, Vercel, etc.) or IP lookup
|
|
269
|
+
let geoResult: IPGeoResult | null = null
|
|
270
|
+
try {
|
|
271
|
+
geoResult = await lookupFromHeaders(req.headers as any)
|
|
272
|
+
// Note: For IP-based lookup (slower), you could use:
|
|
273
|
+
// if (!geoResult && req.ip) {
|
|
274
|
+
// geoResult = await lookupIP(req.ip)
|
|
275
|
+
// }
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// Geolocation lookup failed, continue without it
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Create page view record
|
|
282
|
+
const pageView: PageView = {
|
|
283
|
+
id: AnalyticsStore.generateId(),
|
|
284
|
+
siteId: payload.s,
|
|
285
|
+
visitorId,
|
|
286
|
+
sessionId,
|
|
287
|
+
path: parsedUrl.pathname,
|
|
288
|
+
hostname: parsedUrl.hostname,
|
|
289
|
+
title: payload.t,
|
|
290
|
+
referrer: payload.r,
|
|
291
|
+
referrerSource,
|
|
292
|
+
utmSource,
|
|
293
|
+
utmMedium,
|
|
294
|
+
utmCampaign,
|
|
295
|
+
utmContent,
|
|
296
|
+
utmTerm,
|
|
297
|
+
deviceType: deviceInfo.deviceType,
|
|
298
|
+
browser: deviceInfo.browser,
|
|
299
|
+
browserVersion: deviceInfo.browserVersion,
|
|
300
|
+
os: deviceInfo.os,
|
|
301
|
+
osVersion: deviceInfo.osVersion,
|
|
302
|
+
screenWidth: payload.sw,
|
|
303
|
+
screenHeight: payload.sh,
|
|
304
|
+
// Geolocation data
|
|
305
|
+
country: geoResult?.countryCode,
|
|
306
|
+
region: geoResult?.regionCode || geoResult?.region,
|
|
307
|
+
city: geoResult?.city,
|
|
308
|
+
isUnique,
|
|
309
|
+
isBounce: isNewSession, // Will be updated if more pages viewed
|
|
310
|
+
timestamp,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Store page view
|
|
314
|
+
const pvCommand = this.store.recordPageViewCommand(pageView)
|
|
315
|
+
await ctx.executeCommand(pvCommand)
|
|
316
|
+
|
|
317
|
+
// Update or create session
|
|
318
|
+
if (session) {
|
|
319
|
+
session.pageViewCount += 1
|
|
320
|
+
session.exitPath = parsedUrl.pathname
|
|
321
|
+
session.endedAt = timestamp
|
|
322
|
+
session.isBounce = false
|
|
323
|
+
session.duration = timestamp.getTime() - session.startedAt.getTime()
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
session = {
|
|
327
|
+
id: sessionId,
|
|
328
|
+
siteId: payload.s,
|
|
329
|
+
visitorId,
|
|
330
|
+
entryPath: parsedUrl.pathname,
|
|
331
|
+
exitPath: parsedUrl.pathname,
|
|
332
|
+
referrer: payload.r,
|
|
333
|
+
referrerSource,
|
|
334
|
+
utmSource,
|
|
335
|
+
utmMedium,
|
|
336
|
+
utmCampaign,
|
|
337
|
+
// Geolocation data
|
|
338
|
+
country: geoResult?.countryCode,
|
|
339
|
+
region: geoResult?.regionCode || geoResult?.region,
|
|
340
|
+
city: geoResult?.city,
|
|
341
|
+
deviceType: deviceInfo.deviceType,
|
|
342
|
+
browser: deviceInfo.browser,
|
|
343
|
+
os: deviceInfo.os,
|
|
344
|
+
pageViewCount: 1,
|
|
345
|
+
eventCount: 0,
|
|
346
|
+
isBounce: true,
|
|
347
|
+
duration: 0,
|
|
348
|
+
startedAt: timestamp,
|
|
349
|
+
endedAt: timestamp,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Store session
|
|
354
|
+
const sessionCommand = this.store.upsertSessionCommand(session)
|
|
355
|
+
await ctx.executeCommand(sessionCommand)
|
|
356
|
+
|
|
357
|
+
if (ctx.sessionStore) {
|
|
358
|
+
await ctx.sessionStore.set(`${payload.s}:${sessionId}`, session, 1800) // 30 min TTL
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Update realtime stats with visitor ID for unique visitor tracking
|
|
362
|
+
const minute = timestamp.toISOString().slice(0, 16)
|
|
363
|
+
const realtimeCommand = this.store.updateRealtimeStatsCommand({
|
|
364
|
+
siteId: payload.s,
|
|
365
|
+
minute,
|
|
366
|
+
currentVisitors: 0, // Computed from visitorIds set on retrieval
|
|
367
|
+
pageViews: 1,
|
|
368
|
+
activePages: { [parsedUrl.pathname]: 1 },
|
|
369
|
+
ttl: Math.floor(Date.now() / 1000) + 600, // 10 min TTL
|
|
370
|
+
visitorId, // Track unique visitor ID in DynamoDB String Set
|
|
371
|
+
})
|
|
372
|
+
await ctx.executeCommand(realtimeCommand)
|
|
373
|
+
|
|
374
|
+
// Check for goal conversions
|
|
375
|
+
if (ctx.getGoals) {
|
|
376
|
+
const goals = await ctx.getGoals(payload.s)
|
|
377
|
+
if (goals.length > 0) {
|
|
378
|
+
const matcher = new GoalMatcher(goals)
|
|
379
|
+
const _matches = matcher.matchPageView(pageView)
|
|
380
|
+
// Note: Conversions are typically tracked during aggregation, not real-time
|
|
381
|
+
// This could be extended to store real-time conversion events
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else if (payload.e === 'event') {
|
|
386
|
+
// Custom event
|
|
387
|
+
const eventProps = payload.p || {}
|
|
388
|
+
const customEvent: CustomEvent = {
|
|
389
|
+
id: AnalyticsStore.generateId(),
|
|
390
|
+
siteId: payload.s,
|
|
391
|
+
visitorId,
|
|
392
|
+
sessionId,
|
|
393
|
+
name: String(eventProps.name || 'unknown'),
|
|
394
|
+
value: typeof eventProps.value === 'number' ? eventProps.value : undefined,
|
|
395
|
+
properties: eventProps as Record<string, string | number | boolean>,
|
|
396
|
+
path: parsedUrl.pathname,
|
|
397
|
+
timestamp,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const eventCommand = this.store.recordCustomEventCommand(customEvent)
|
|
401
|
+
await ctx.executeCommand(eventCommand)
|
|
402
|
+
|
|
403
|
+
// Update session event count
|
|
404
|
+
if (session) {
|
|
405
|
+
session.eventCount += 1
|
|
406
|
+
session.endedAt = timestamp
|
|
407
|
+
const sessionCommand = this.store.upsertSessionCommand(session)
|
|
408
|
+
await ctx.executeCommand(sessionCommand)
|
|
409
|
+
|
|
410
|
+
if (ctx.sessionStore) {
|
|
411
|
+
await ctx.sessionStore.set(`${payload.s}:${sessionId}`, session, 1800)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
else if (payload.e === 'outbound') {
|
|
416
|
+
// Outbound link click - tracked as custom event
|
|
417
|
+
const customEvent: CustomEvent = {
|
|
418
|
+
id: AnalyticsStore.generateId(),
|
|
419
|
+
siteId: payload.s,
|
|
420
|
+
visitorId,
|
|
421
|
+
sessionId,
|
|
422
|
+
name: 'outbound_click',
|
|
423
|
+
properties: { url: String(payload.p?.url || '') },
|
|
424
|
+
path: parsedUrl.pathname,
|
|
425
|
+
timestamp,
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const eventCommand = this.store.recordCustomEventCommand(customEvent)
|
|
429
|
+
await ctx.executeCommand(eventCommand)
|
|
430
|
+
}
|
|
431
|
+
else if (payload.e === 'error') {
|
|
432
|
+
// JavaScript error - tracked as custom event with error details
|
|
433
|
+
const errorProps = payload.p || {}
|
|
434
|
+
const customEvent: CustomEvent = {
|
|
435
|
+
id: AnalyticsStore.generateId(),
|
|
436
|
+
siteId: payload.s,
|
|
437
|
+
visitorId,
|
|
438
|
+
sessionId,
|
|
439
|
+
name: 'error',
|
|
440
|
+
properties: {
|
|
441
|
+
message: String(errorProps.message || 'Unknown error'),
|
|
442
|
+
source: String(errorProps.source || ''),
|
|
443
|
+
line: typeof errorProps.line === 'number' ? errorProps.line : 0,
|
|
444
|
+
col: typeof errorProps.col === 'number' ? errorProps.col : 0,
|
|
445
|
+
stack: String(errorProps.stack || ''),
|
|
446
|
+
},
|
|
447
|
+
path: parsedUrl.pathname,
|
|
448
|
+
timestamp,
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const eventCommand = this.store.recordCustomEventCommand(customEvent)
|
|
452
|
+
await ctx.executeCommand(eventCommand)
|
|
453
|
+
|
|
454
|
+
// Update session event count
|
|
455
|
+
if (session) {
|
|
456
|
+
session.eventCount += 1
|
|
457
|
+
session.endedAt = timestamp
|
|
458
|
+
const sessionCommand = this.store.upsertSessionCommand(session)
|
|
459
|
+
await ctx.executeCommand(sessionCommand)
|
|
460
|
+
|
|
461
|
+
if (ctx.sessionStore) {
|
|
462
|
+
await ctx.sessionStore.set(`${payload.s}:${sessionId}`, session, 1800)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Return 1x1 transparent GIF or empty response
|
|
468
|
+
return {
|
|
469
|
+
status: 204,
|
|
470
|
+
headers: {
|
|
471
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
472
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
|
473
|
+
},
|
|
474
|
+
body: null,
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
console.error('Collect error:', error)
|
|
479
|
+
return this.errorResponse(500, 'Internal server error')
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* GET /sites - List sites for current user
|
|
485
|
+
*/
|
|
486
|
+
async handleListSites(
|
|
487
|
+
req: AnalyticsRequest,
|
|
488
|
+
ctx: HandlerContext,
|
|
489
|
+
ownerId: string,
|
|
490
|
+
): Promise<AnalyticsResponse> {
|
|
491
|
+
try {
|
|
492
|
+
const command = this.store.listSitesByOwnerCommand(ownerId)
|
|
493
|
+
const result = await ctx.executeCommand(command) as { Items?: unknown[] }
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
status: 200,
|
|
497
|
+
headers: {
|
|
498
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
499
|
+
'Content-Type': 'application/json',
|
|
500
|
+
},
|
|
501
|
+
body: {
|
|
502
|
+
sites: result.Items || [],
|
|
503
|
+
},
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
console.error('List sites error:', error)
|
|
508
|
+
return this.errorResponse(500, 'Failed to list sites')
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* POST /sites - Create a new site
|
|
514
|
+
*/
|
|
515
|
+
async handleCreateSite(
|
|
516
|
+
req: AnalyticsRequest,
|
|
517
|
+
ctx: HandlerContext,
|
|
518
|
+
ownerId: string,
|
|
519
|
+
): Promise<AnalyticsResponse> {
|
|
520
|
+
try {
|
|
521
|
+
const body = req.body as Partial<Site>
|
|
522
|
+
|
|
523
|
+
if (!body.name || !body.domains?.length) {
|
|
524
|
+
return this.errorResponse(400, 'Missing required fields: name, domains')
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const now = new Date()
|
|
528
|
+
const site: Site = {
|
|
529
|
+
id: AnalyticsStore.generateId(),
|
|
530
|
+
name: body.name,
|
|
531
|
+
domains: body.domains,
|
|
532
|
+
timezone: body.timezone || 'UTC',
|
|
533
|
+
isActive: true,
|
|
534
|
+
ownerId,
|
|
535
|
+
settings: body.settings || this.getDefaultSiteSettings(),
|
|
536
|
+
createdAt: now,
|
|
537
|
+
updatedAt: now,
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const command = this.store.createSiteCommand(site)
|
|
541
|
+
await ctx.executeCommand(command)
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
status: 201,
|
|
545
|
+
headers: {
|
|
546
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
547
|
+
'Content-Type': 'application/json',
|
|
548
|
+
},
|
|
549
|
+
body: { site },
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
console.error('Create site error:', error)
|
|
554
|
+
return this.errorResponse(500, 'Failed to create site')
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* GET /sites/:siteId - Get site details
|
|
560
|
+
*/
|
|
561
|
+
async handleGetSite(
|
|
562
|
+
req: AnalyticsRequest,
|
|
563
|
+
ctx: HandlerContext,
|
|
564
|
+
): Promise<AnalyticsResponse> {
|
|
565
|
+
try {
|
|
566
|
+
const { siteId } = req.params
|
|
567
|
+
|
|
568
|
+
if (!siteId) {
|
|
569
|
+
return this.errorResponse(400, 'Missing siteId')
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const command = this.store.getSiteCommand(siteId)
|
|
573
|
+
const result = await ctx.executeCommand(command) as { Item?: unknown }
|
|
574
|
+
|
|
575
|
+
if (!result.Item) {
|
|
576
|
+
return this.errorResponse(404, 'Site not found')
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
status: 200,
|
|
581
|
+
headers: {
|
|
582
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
583
|
+
'Content-Type': 'application/json',
|
|
584
|
+
},
|
|
585
|
+
body: { site: result.Item },
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
console.error('Get site error:', error)
|
|
590
|
+
return this.errorResponse(500, 'Failed to get site')
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* GET /sites/:siteId/stats - Get dashboard stats
|
|
596
|
+
*/
|
|
597
|
+
async handleGetStats(
|
|
598
|
+
req: AnalyticsRequest,
|
|
599
|
+
ctx: HandlerContext,
|
|
600
|
+
): Promise<AnalyticsResponse> {
|
|
601
|
+
try {
|
|
602
|
+
const { siteId } = req.params
|
|
603
|
+
const { start, end, period, compare } = req.query
|
|
604
|
+
|
|
605
|
+
if (!siteId) {
|
|
606
|
+
return this.errorResponse(400, 'Missing siteId')
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Parse date range
|
|
610
|
+
const startDate = start ? new Date(start) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // Default: last 7 days
|
|
611
|
+
const endDate = end ? new Date(end) : new Date()
|
|
612
|
+
|
|
613
|
+
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
|
614
|
+
return this.errorResponse(400, 'Invalid date format')
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const dateRange = { start: startDate, end: endDate }
|
|
618
|
+
const queries = ctx.queryApi.generateDashboardQueries({
|
|
619
|
+
siteId,
|
|
620
|
+
dateRange,
|
|
621
|
+
includeComparison: compare === 'true',
|
|
622
|
+
includeRealtime: true,
|
|
623
|
+
includeGoals: true,
|
|
624
|
+
topLimit: 10,
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
// Execute queries in parallel
|
|
628
|
+
const [aggregatedResult, topPagesResult, realtimeResult, goalsResult, previousResult] = await Promise.all([
|
|
629
|
+
ctx.executeCommand(queries.aggregatedStats),
|
|
630
|
+
ctx.executeCommand(queries.topPages),
|
|
631
|
+
queries.realtimeStats ? ctx.executeCommand(queries.realtimeStats) : null,
|
|
632
|
+
queries.goals ? ctx.executeCommand(queries.goals) : null,
|
|
633
|
+
queries.previousPeriodStats ? ctx.executeCommand(queries.previousPeriodStats) : null,
|
|
634
|
+
])
|
|
635
|
+
|
|
636
|
+
// Process results
|
|
637
|
+
const stats = (aggregatedResult as { Items?: unknown[] })?.Items || []
|
|
638
|
+
const previousStats = previousResult ? (previousResult as { Items?: unknown[] })?.Items || [] : undefined
|
|
639
|
+
|
|
640
|
+
const summary = AnalyticsQueryAPI.processSummary(
|
|
641
|
+
stats as Parameters<typeof AnalyticsQueryAPI.processSummary>[0],
|
|
642
|
+
previousStats as Parameters<typeof AnalyticsQueryAPI.processSummary>[1],
|
|
643
|
+
)
|
|
644
|
+
const timeSeries = AnalyticsQueryAPI.processTimeSeries(
|
|
645
|
+
stats as Parameters<typeof AnalyticsQueryAPI.processTimeSeries>[0],
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
return {
|
|
649
|
+
status: 200,
|
|
650
|
+
headers: {
|
|
651
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
652
|
+
'Content-Type': 'application/json',
|
|
653
|
+
},
|
|
654
|
+
body: {
|
|
655
|
+
summary,
|
|
656
|
+
timeSeries,
|
|
657
|
+
topPages: (topPagesResult as { Items?: unknown[] })?.Items || [],
|
|
658
|
+
realtime: realtimeResult ? (realtimeResult as { Items?: unknown[] })?.Items || [] : null,
|
|
659
|
+
goals: goalsResult ? (goalsResult as { Items?: unknown[] })?.Items || [] : null,
|
|
660
|
+
dateRange: {
|
|
661
|
+
start: startDate.toISOString(),
|
|
662
|
+
end: endDate.toISOString(),
|
|
663
|
+
period: period || AnalyticsQueryAPI.determinePeriod(dateRange),
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
console.error('Get stats error:', error)
|
|
670
|
+
return this.errorResponse(500, 'Failed to get stats')
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* GET /sites/:siteId/realtime - Get realtime stats
|
|
676
|
+
*/
|
|
677
|
+
async handleGetRealtime(
|
|
678
|
+
req: AnalyticsRequest,
|
|
679
|
+
ctx: HandlerContext,
|
|
680
|
+
): Promise<AnalyticsResponse> {
|
|
681
|
+
try {
|
|
682
|
+
const { siteId } = req.params
|
|
683
|
+
const minutes = Number.parseInt(req.query.minutes || '5', 10)
|
|
684
|
+
|
|
685
|
+
if (!siteId) {
|
|
686
|
+
return this.errorResponse(400, 'Missing siteId')
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const command = this.store.getRealtimeStatsCommand(siteId, minutes)
|
|
690
|
+
const result = await ctx.executeCommand(command) as { Items?: unknown[] }
|
|
691
|
+
|
|
692
|
+
const realtimeData = AnalyticsQueryAPI.processRealtimeData(
|
|
693
|
+
(result.Items || []) as Parameters<typeof AnalyticsQueryAPI.processRealtimeData>[0],
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
status: 200,
|
|
698
|
+
headers: {
|
|
699
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
700
|
+
'Content-Type': 'application/json',
|
|
701
|
+
},
|
|
702
|
+
body: realtimeData,
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch (error) {
|
|
706
|
+
console.error('Get realtime error:', error)
|
|
707
|
+
return this.errorResponse(500, 'Failed to get realtime stats')
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* GET /sites/:siteId/script - Get tracking script
|
|
713
|
+
*/
|
|
714
|
+
handleGetScript(req: AnalyticsRequest): AnalyticsResponse {
|
|
715
|
+
const { siteId } = req.params
|
|
716
|
+
const apiEndpoint = req.query.api || `${req.headers.host}${this.config.basePath}`
|
|
717
|
+
|
|
718
|
+
if (!siteId) {
|
|
719
|
+
return this.errorResponse(400, 'Missing siteId')
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const script = generateTrackingScript({
|
|
723
|
+
siteId,
|
|
724
|
+
apiEndpoint: apiEndpoint.startsWith('http') ? apiEndpoint : `https://${apiEndpoint}`,
|
|
725
|
+
honorDnt: req.query.dnt !== 'false',
|
|
726
|
+
trackHashChanges: req.query.hash === 'true',
|
|
727
|
+
trackOutboundLinks: req.query.outbound !== 'false',
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
status: 200,
|
|
732
|
+
headers: {
|
|
733
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
734
|
+
'Content-Type': 'text/html',
|
|
735
|
+
'Cache-Control': 'public, max-age=3600',
|
|
736
|
+
},
|
|
737
|
+
body: script,
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* GET /sites/:siteId/goals - List goals
|
|
743
|
+
*/
|
|
744
|
+
async handleListGoals(
|
|
745
|
+
req: AnalyticsRequest,
|
|
746
|
+
ctx: HandlerContext,
|
|
747
|
+
): Promise<AnalyticsResponse> {
|
|
748
|
+
try {
|
|
749
|
+
const { siteId } = req.params
|
|
750
|
+
|
|
751
|
+
if (!siteId) {
|
|
752
|
+
return this.errorResponse(400, 'Missing siteId')
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const command = this.store.listGoalsCommand(siteId)
|
|
756
|
+
const result = await ctx.executeCommand(command) as { Items?: unknown[] }
|
|
757
|
+
|
|
758
|
+
return {
|
|
759
|
+
status: 200,
|
|
760
|
+
headers: {
|
|
761
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
762
|
+
'Content-Type': 'application/json',
|
|
763
|
+
},
|
|
764
|
+
body: { goals: result.Items || [] },
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
console.error('List goals error:', error)
|
|
769
|
+
return this.errorResponse(500, 'Failed to list goals')
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* POST /sites/:siteId/goals - Create a goal
|
|
775
|
+
*/
|
|
776
|
+
async handleCreateGoal(
|
|
777
|
+
req: AnalyticsRequest,
|
|
778
|
+
ctx: HandlerContext,
|
|
779
|
+
): Promise<AnalyticsResponse> {
|
|
780
|
+
try {
|
|
781
|
+
const { siteId } = req.params
|
|
782
|
+
const body = req.body as Partial<Goal>
|
|
783
|
+
|
|
784
|
+
if (!siteId) {
|
|
785
|
+
return this.errorResponse(400, 'Missing siteId')
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (!body.name || !body.type || !body.pattern || !body.matchType) {
|
|
789
|
+
return this.errorResponse(400, 'Missing required fields: name, type, pattern, matchType')
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const now = new Date()
|
|
793
|
+
const goal: Goal = {
|
|
794
|
+
id: AnalyticsStore.generateId(),
|
|
795
|
+
siteId,
|
|
796
|
+
name: body.name,
|
|
797
|
+
type: body.type,
|
|
798
|
+
pattern: body.pattern,
|
|
799
|
+
matchType: body.matchType,
|
|
800
|
+
value: body.value,
|
|
801
|
+
isActive: body.isActive ?? true,
|
|
802
|
+
createdAt: now,
|
|
803
|
+
updatedAt: now,
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const command = this.store.createGoalCommand(goal)
|
|
807
|
+
await ctx.executeCommand(command)
|
|
808
|
+
|
|
809
|
+
return {
|
|
810
|
+
status: 201,
|
|
811
|
+
headers: {
|
|
812
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
813
|
+
'Content-Type': 'application/json',
|
|
814
|
+
},
|
|
815
|
+
body: { goal },
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
console.error('Create goal error:', error)
|
|
820
|
+
return this.errorResponse(500, 'Failed to create goal')
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* GET /sites/:siteId/pages - Get top pages
|
|
826
|
+
*/
|
|
827
|
+
async handleGetTopPages(
|
|
828
|
+
req: AnalyticsRequest,
|
|
829
|
+
ctx: HandlerContext,
|
|
830
|
+
): Promise<AnalyticsResponse> {
|
|
831
|
+
try {
|
|
832
|
+
const { siteId } = req.params
|
|
833
|
+
const { start, end, limit } = req.query
|
|
834
|
+
|
|
835
|
+
if (!siteId) {
|
|
836
|
+
return this.errorResponse(400, 'Missing siteId')
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const startDate = start ? new Date(start) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
840
|
+
const endDate = end ? new Date(end) : new Date()
|
|
841
|
+
const dateRange = { start: startDate, end: endDate }
|
|
842
|
+
const period = AnalyticsQueryAPI.determinePeriod(dateRange)
|
|
843
|
+
const periodStart = AnalyticsStore.getPeriodStart(endDate, period)
|
|
844
|
+
|
|
845
|
+
const command = this.store.getTopPagesCommand(
|
|
846
|
+
siteId,
|
|
847
|
+
period,
|
|
848
|
+
periodStart,
|
|
849
|
+
Number.parseInt(limit || '10', 10),
|
|
850
|
+
)
|
|
851
|
+
const result = await ctx.executeCommand(command) as { Items?: unknown[] }
|
|
852
|
+
|
|
853
|
+
return {
|
|
854
|
+
status: 200,
|
|
855
|
+
headers: {
|
|
856
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
857
|
+
'Content-Type': 'application/json',
|
|
858
|
+
},
|
|
859
|
+
body: { pages: result.Items || [] },
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
catch (error) {
|
|
863
|
+
console.error('Get top pages error:', error)
|
|
864
|
+
return this.errorResponse(500, 'Failed to get top pages')
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* GET /sites/:siteId/regions - Get top regions
|
|
870
|
+
* Query params:
|
|
871
|
+
* - start/startDate: Start date (ISO string)
|
|
872
|
+
* - end/endDate: End date (ISO string)
|
|
873
|
+
* - country: Filter by country code (e.g., 'US')
|
|
874
|
+
* - limit: Number of results (default 10)
|
|
875
|
+
*/
|
|
876
|
+
async handleGetRegions(
|
|
877
|
+
req: AnalyticsRequest,
|
|
878
|
+
ctx: HandlerContext,
|
|
879
|
+
): Promise<AnalyticsResponse> {
|
|
880
|
+
try {
|
|
881
|
+
const { siteId } = req.params
|
|
882
|
+
const { start, end, startDate: startDateParam, endDate: endDateParam, country, limit } = req.query
|
|
883
|
+
|
|
884
|
+
if (!siteId) {
|
|
885
|
+
return this.errorResponse(400, 'Missing siteId')
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const startDate = (start || startDateParam) ? new Date(start || startDateParam) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
889
|
+
const endDate = (end || endDateParam) ? new Date(end || endDateParam) : new Date()
|
|
890
|
+
const dateRange = { start: startDate, end: endDate }
|
|
891
|
+
|
|
892
|
+
const command = ctx.queryApi.getRegions(siteId, {
|
|
893
|
+
dateRange,
|
|
894
|
+
country: country || undefined,
|
|
895
|
+
limit: limit ? Number.parseInt(limit, 10) : 10,
|
|
896
|
+
})
|
|
897
|
+
const result = await ctx.executeCommand(command) as { Items?: unknown[] }
|
|
898
|
+
|
|
899
|
+
// Calculate total visitors for percentage calculation
|
|
900
|
+
const items = (result.Items || []) as Array<{ visitors?: { N?: string } }>
|
|
901
|
+
const totalVisitors = items.reduce((sum, item) => {
|
|
902
|
+
return sum + (item.visitors?.N ? Number.parseInt(item.visitors.N, 10) : 0)
|
|
903
|
+
}, 0)
|
|
904
|
+
|
|
905
|
+
const regions = AnalyticsQueryAPI.processTopRegions(
|
|
906
|
+
items as Parameters<typeof AnalyticsQueryAPI.processTopRegions>[0],
|
|
907
|
+
totalVisitors,
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
return {
|
|
911
|
+
status: 200,
|
|
912
|
+
headers: {
|
|
913
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
914
|
+
'Content-Type': 'application/json',
|
|
915
|
+
},
|
|
916
|
+
body: {
|
|
917
|
+
regions,
|
|
918
|
+
dateRange: {
|
|
919
|
+
start: startDate.toISOString(),
|
|
920
|
+
end: endDate.toISOString(),
|
|
921
|
+
},
|
|
922
|
+
filters: {
|
|
923
|
+
country: country || null,
|
|
924
|
+
},
|
|
925
|
+
},
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
catch (error) {
|
|
929
|
+
console.error('Get regions error:', error)
|
|
930
|
+
return this.errorResponse(500, 'Failed to get regions')
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* GET /sites/:siteId/cities - Get top cities
|
|
936
|
+
* Query params:
|
|
937
|
+
* - start/startDate: Start date (ISO string)
|
|
938
|
+
* - end/endDate: End date (ISO string)
|
|
939
|
+
* - country: Filter by country code (e.g., 'US')
|
|
940
|
+
* - region: Filter by region code (e.g., 'CA' for California)
|
|
941
|
+
* - limit: Number of results (default 10)
|
|
942
|
+
*/
|
|
943
|
+
async handleGetCities(
|
|
944
|
+
req: AnalyticsRequest,
|
|
945
|
+
ctx: HandlerContext,
|
|
946
|
+
): Promise<AnalyticsResponse> {
|
|
947
|
+
try {
|
|
948
|
+
const { siteId } = req.params
|
|
949
|
+
const { start, end, startDate: startDateParam, endDate: endDateParam, country, region, limit } = req.query
|
|
950
|
+
|
|
951
|
+
if (!siteId) {
|
|
952
|
+
return this.errorResponse(400, 'Missing siteId')
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const startDate = (start || startDateParam) ? new Date(start || startDateParam) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
956
|
+
const endDate = (end || endDateParam) ? new Date(end || endDateParam) : new Date()
|
|
957
|
+
const dateRange = { start: startDate, end: endDate }
|
|
958
|
+
|
|
959
|
+
const command = ctx.queryApi.getCities(siteId, {
|
|
960
|
+
dateRange,
|
|
961
|
+
country: country || undefined,
|
|
962
|
+
region: region || undefined,
|
|
963
|
+
limit: limit ? Number.parseInt(limit, 10) : 10,
|
|
964
|
+
})
|
|
965
|
+
const result = await ctx.executeCommand(command) as { Items?: unknown[] }
|
|
966
|
+
|
|
967
|
+
// Calculate total visitors for percentage calculation
|
|
968
|
+
const items = (result.Items || []) as Array<{ visitors?: { N?: string } }>
|
|
969
|
+
const totalVisitors = items.reduce((sum, item) => {
|
|
970
|
+
return sum + (item.visitors?.N ? Number.parseInt(item.visitors.N, 10) : 0)
|
|
971
|
+
}, 0)
|
|
972
|
+
|
|
973
|
+
const cities = AnalyticsQueryAPI.processTopCities(
|
|
974
|
+
items as Parameters<typeof AnalyticsQueryAPI.processTopCities>[0],
|
|
975
|
+
totalVisitors,
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
return {
|
|
979
|
+
status: 200,
|
|
980
|
+
headers: {
|
|
981
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
982
|
+
'Content-Type': 'application/json',
|
|
983
|
+
},
|
|
984
|
+
body: {
|
|
985
|
+
cities,
|
|
986
|
+
dateRange: {
|
|
987
|
+
start: startDate.toISOString(),
|
|
988
|
+
end: endDate.toISOString(),
|
|
989
|
+
},
|
|
990
|
+
filters: {
|
|
991
|
+
country: country || null,
|
|
992
|
+
region: region || null,
|
|
993
|
+
},
|
|
994
|
+
},
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
catch (error) {
|
|
998
|
+
console.error('Get cities error:', error)
|
|
999
|
+
return this.errorResponse(500, 'Failed to get cities')
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* POST /aggregate - Trigger aggregation (for scheduled jobs)
|
|
1005
|
+
*/
|
|
1006
|
+
async handleAggregate(
|
|
1007
|
+
req: AnalyticsRequest,
|
|
1008
|
+
ctx: HandlerContext,
|
|
1009
|
+
): Promise<AnalyticsResponse> {
|
|
1010
|
+
try {
|
|
1011
|
+
const body = req.body as {
|
|
1012
|
+
siteId: string
|
|
1013
|
+
period: AggregationPeriod
|
|
1014
|
+
windowStart?: string
|
|
1015
|
+
windowEnd?: string
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (!body.siteId || !body.period) {
|
|
1019
|
+
return this.errorResponse(400, 'Missing required fields: siteId, period')
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Create job config
|
|
1023
|
+
const config = body.windowStart && body.windowEnd
|
|
1024
|
+
? {
|
|
1025
|
+
siteId: body.siteId,
|
|
1026
|
+
period: body.period,
|
|
1027
|
+
windowStart: new Date(body.windowStart),
|
|
1028
|
+
windowEnd: new Date(body.windowEnd),
|
|
1029
|
+
deleteRawEvents: body.period === 'hour',
|
|
1030
|
+
}
|
|
1031
|
+
: AggregationPipeline.createJobConfig(body.siteId, body.period)
|
|
1032
|
+
|
|
1033
|
+
// Fetch raw data for the window
|
|
1034
|
+
// Note: In production, this would query DynamoDB for the raw events
|
|
1035
|
+
// For now, return the job config and let the caller handle data fetching
|
|
1036
|
+
return {
|
|
1037
|
+
status: 200,
|
|
1038
|
+
headers: {
|
|
1039
|
+
...this.getCorsHeaders(req.headers.origin),
|
|
1040
|
+
'Content-Type': 'application/json',
|
|
1041
|
+
},
|
|
1042
|
+
body: {
|
|
1043
|
+
message: 'Aggregation job created',
|
|
1044
|
+
config,
|
|
1045
|
+
nextScheduledTimes: AggregationPipeline.getScheduledJobTimes(),
|
|
1046
|
+
},
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
catch (error) {
|
|
1050
|
+
console.error('Aggregate error:', error)
|
|
1051
|
+
return this.errorResponse(500, 'Failed to create aggregation job')
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// ==========================================================================
|
|
1056
|
+
// Helper Methods
|
|
1057
|
+
// ==========================================================================
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Create error response
|
|
1061
|
+
*/
|
|
1062
|
+
private errorResponse(status: number, message: string): AnalyticsResponse {
|
|
1063
|
+
return {
|
|
1064
|
+
status,
|
|
1065
|
+
headers: {
|
|
1066
|
+
'Content-Type': 'application/json',
|
|
1067
|
+
},
|
|
1068
|
+
body: { error: message },
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Get default site settings
|
|
1074
|
+
*/
|
|
1075
|
+
private getDefaultSiteSettings(): SiteSettings {
|
|
1076
|
+
return {
|
|
1077
|
+
collectGeolocation: false,
|
|
1078
|
+
trackReferrers: true,
|
|
1079
|
+
trackUtmParams: true,
|
|
1080
|
+
trackDeviceType: true,
|
|
1081
|
+
publicDashboard: false,
|
|
1082
|
+
excludedPaths: [],
|
|
1083
|
+
excludedIps: [],
|
|
1084
|
+
dataRetentionDays: 365,
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Get store instance
|
|
1090
|
+
*/
|
|
1091
|
+
getStore(): AnalyticsStore {
|
|
1092
|
+
return this.store
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Get query API instance
|
|
1097
|
+
*/
|
|
1098
|
+
getQueryAPI(): AnalyticsQueryAPI {
|
|
1099
|
+
return this.queryApi
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Get pipeline instance
|
|
1104
|
+
*/
|
|
1105
|
+
getPipeline(): AggregationPipeline {
|
|
1106
|
+
return this.pipeline
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// ============================================================================
|
|
1111
|
+
// Framework Adapters
|
|
1112
|
+
// ============================================================================
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Create a Bun/Hono compatible router handler
|
|
1116
|
+
*/
|
|
1117
|
+
export function createBunRouter(api: AnalyticsAPI, executeCommand: HandlerContext['executeCommand']) {
|
|
1118
|
+
const ctx = api.createContext(executeCommand)
|
|
1119
|
+
|
|
1120
|
+
return {
|
|
1121
|
+
async fetch(request: Request): Promise<Response> {
|
|
1122
|
+
const url = new URL(request.url)
|
|
1123
|
+
const path = url.pathname
|
|
1124
|
+
const method = request.method
|
|
1125
|
+
|
|
1126
|
+
// Parse request into AnalyticsRequest format
|
|
1127
|
+
const req: AnalyticsRequest = {
|
|
1128
|
+
method,
|
|
1129
|
+
path,
|
|
1130
|
+
params: {},
|
|
1131
|
+
query: Object.fromEntries(url.searchParams),
|
|
1132
|
+
body: method !== 'GET' ? await request.json().catch(() => ({})) : {},
|
|
1133
|
+
headers: Object.fromEntries(request.headers as unknown as Iterable<[string, string]>),
|
|
1134
|
+
ip: request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || undefined,
|
|
1135
|
+
userAgent: request.headers.get('user-agent') || undefined,
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Route matching (simplified - use a proper router in production)
|
|
1139
|
+
let response: AnalyticsResponse
|
|
1140
|
+
|
|
1141
|
+
if (method === 'OPTIONS') {
|
|
1142
|
+
response = api.handleOptions(req)
|
|
1143
|
+
}
|
|
1144
|
+
else if (method === 'POST' && path.endsWith('/collect')) {
|
|
1145
|
+
response = await api.handleCollect(req, ctx)
|
|
1146
|
+
}
|
|
1147
|
+
else if (method === 'GET' && path.match(/\/sites\/[^/]+\/stats$/)) {
|
|
1148
|
+
req.params.siteId = path.split('/').slice(-2)[0]
|
|
1149
|
+
response = await api.handleGetStats(req, ctx)
|
|
1150
|
+
}
|
|
1151
|
+
else if (method === 'GET' && path.match(/\/sites\/[^/]+\/realtime$/)) {
|
|
1152
|
+
req.params.siteId = path.split('/').slice(-2)[0]
|
|
1153
|
+
response = await api.handleGetRealtime(req, ctx)
|
|
1154
|
+
}
|
|
1155
|
+
else if (method === 'GET' && path.match(/\/sites\/[^/]+\/script$/)) {
|
|
1156
|
+
req.params.siteId = path.split('/').slice(-2)[0]
|
|
1157
|
+
response = api.handleGetScript(req)
|
|
1158
|
+
}
|
|
1159
|
+
else if (method === 'GET' && path.match(/\/sites\/[^/]+\/goals$/)) {
|
|
1160
|
+
req.params.siteId = path.split('/').slice(-2)[0]
|
|
1161
|
+
response = await api.handleListGoals(req, ctx)
|
|
1162
|
+
}
|
|
1163
|
+
else if (method === 'POST' && path.match(/\/sites\/[^/]+\/goals$/)) {
|
|
1164
|
+
req.params.siteId = path.split('/').slice(-2)[0]
|
|
1165
|
+
response = await api.handleCreateGoal(req, ctx)
|
|
1166
|
+
}
|
|
1167
|
+
else if (method === 'GET' && path.match(/\/sites\/[^/]+\/pages$/)) {
|
|
1168
|
+
req.params.siteId = path.split('/').slice(-2)[0]
|
|
1169
|
+
response = await api.handleGetTopPages(req, ctx)
|
|
1170
|
+
}
|
|
1171
|
+
else if (method === 'GET' && path.match(/\/sites\/[^/]+\/regions$/)) {
|
|
1172
|
+
req.params.siteId = path.split('/').slice(-2)[0]
|
|
1173
|
+
response = await api.handleGetRegions(req, ctx)
|
|
1174
|
+
}
|
|
1175
|
+
else if (method === 'GET' && path.match(/\/sites\/[^/]+\/cities$/)) {
|
|
1176
|
+
req.params.siteId = path.split('/').slice(-2)[0]
|
|
1177
|
+
response = await api.handleGetCities(req, ctx)
|
|
1178
|
+
}
|
|
1179
|
+
else if (method === 'GET' && path.match(/\/sites\/[^/]+$/)) {
|
|
1180
|
+
req.params.siteId = path.split('/').pop()!
|
|
1181
|
+
response = await api.handleGetSite(req, ctx)
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
response = {
|
|
1185
|
+
status: 404,
|
|
1186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1187
|
+
body: { error: 'Not found' },
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
return new Response(
|
|
1192
|
+
response.body ? JSON.stringify(response.body) : null,
|
|
1193
|
+
{
|
|
1194
|
+
status: response.status,
|
|
1195
|
+
headers: response.headers,
|
|
1196
|
+
},
|
|
1197
|
+
)
|
|
1198
|
+
},
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Create AWS Lambda handler
|
|
1204
|
+
*/
|
|
1205
|
+
export function createLambdaHandler(api: AnalyticsAPI, executeCommand: HandlerContext['executeCommand']): (event: {
|
|
1206
|
+
httpMethod: string
|
|
1207
|
+
path: string
|
|
1208
|
+
pathParameters?: Record<string, string>
|
|
1209
|
+
queryStringParameters?: Record<string, string>
|
|
1210
|
+
body?: string
|
|
1211
|
+
headers: Record<string, string>
|
|
1212
|
+
requestContext?: { identity?: { sourceIp?: string } }
|
|
1213
|
+
}) => Promise<{ statusCode: number, headers: Record<string, string>, body: string }> {
|
|
1214
|
+
const ctx = api.createContext(executeCommand)
|
|
1215
|
+
|
|
1216
|
+
return async (event: {
|
|
1217
|
+
httpMethod: string
|
|
1218
|
+
path: string
|
|
1219
|
+
pathParameters?: Record<string, string>
|
|
1220
|
+
queryStringParameters?: Record<string, string>
|
|
1221
|
+
body?: string
|
|
1222
|
+
headers: Record<string, string>
|
|
1223
|
+
requestContext?: { identity?: { sourceIp?: string } }
|
|
1224
|
+
}): Promise<{ statusCode: number, headers: Record<string, string>, body: string }> => {
|
|
1225
|
+
const req: AnalyticsRequest = {
|
|
1226
|
+
method: event.httpMethod,
|
|
1227
|
+
path: event.path,
|
|
1228
|
+
params: event.pathParameters || {},
|
|
1229
|
+
query: event.queryStringParameters || {},
|
|
1230
|
+
body: event.body ? JSON.parse(event.body) : {},
|
|
1231
|
+
headers: event.headers,
|
|
1232
|
+
ip: event.requestContext?.identity?.sourceIp,
|
|
1233
|
+
userAgent: event.headers['User-Agent'] || event.headers['user-agent'],
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
let response: AnalyticsResponse
|
|
1237
|
+
|
|
1238
|
+
// Route based on path and method
|
|
1239
|
+
if (req.method === 'OPTIONS') {
|
|
1240
|
+
response = api.handleOptions(req)
|
|
1241
|
+
}
|
|
1242
|
+
else if (req.method === 'POST' && req.path.endsWith('/collect')) {
|
|
1243
|
+
response = await api.handleCollect(req, ctx)
|
|
1244
|
+
}
|
|
1245
|
+
else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/stats')) {
|
|
1246
|
+
response = await api.handleGetStats(req, ctx)
|
|
1247
|
+
}
|
|
1248
|
+
else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/realtime')) {
|
|
1249
|
+
response = await api.handleGetRealtime(req, ctx)
|
|
1250
|
+
}
|
|
1251
|
+
else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/script')) {
|
|
1252
|
+
response = api.handleGetScript(req)
|
|
1253
|
+
}
|
|
1254
|
+
else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/goals')) {
|
|
1255
|
+
response = await api.handleListGoals(req, ctx)
|
|
1256
|
+
}
|
|
1257
|
+
else if (req.method === 'POST' && req.params.siteId && req.path.endsWith('/goals')) {
|
|
1258
|
+
response = await api.handleCreateGoal(req, ctx)
|
|
1259
|
+
}
|
|
1260
|
+
else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/regions')) {
|
|
1261
|
+
response = await api.handleGetRegions(req, ctx)
|
|
1262
|
+
}
|
|
1263
|
+
else if (req.method === 'GET' && req.params.siteId && req.path.endsWith('/cities')) {
|
|
1264
|
+
response = await api.handleGetCities(req, ctx)
|
|
1265
|
+
}
|
|
1266
|
+
else if (req.method === 'GET' && req.params.siteId && !req.path.includes('/')) {
|
|
1267
|
+
response = await api.handleGetSite(req, ctx)
|
|
1268
|
+
}
|
|
1269
|
+
else if (req.method === 'POST' && req.path.endsWith('/aggregate')) {
|
|
1270
|
+
response = await api.handleAggregate(req, ctx)
|
|
1271
|
+
}
|
|
1272
|
+
else {
|
|
1273
|
+
response = {
|
|
1274
|
+
status: 404,
|
|
1275
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1276
|
+
body: { error: 'Not found' },
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
return {
|
|
1281
|
+
statusCode: response.status,
|
|
1282
|
+
headers: response.headers,
|
|
1283
|
+
body: response.body ? JSON.stringify(response.body) : '',
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|