@stacksjs/ts-analytics 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/LICENSE.md +21 -0
- package/README.md +361 -0
- package/bin/cli.ts +169 -0
- package/dist/Analytics.d.ts +558 -0
- package/dist/api.d.ts +109 -0
- package/dist/batching.d.ts +93 -0
- package/dist/chunk-2mx7fq49.js +4 -0
- package/dist/chunk-3z29508k.js +204 -0
- package/dist/chunk-deephkz6.js +56 -0
- package/dist/chunk-j261vgyp.js +305 -0
- package/dist/chunk-xga17tz7.js +207 -0
- package/dist/config.d.ts +132 -0
- package/dist/dashboard/components/index.d.ts +80 -0
- package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
- package/dist/dashboard/index.d.ts +110 -0
- package/dist/dashboard/types/index.d.ts +144 -0
- package/dist/dashboard/utils/index.d.ts +81 -0
- package/dist/dynamodb.d.ts +86 -0
- package/dist/funnels.d.ts +104 -0
- package/dist/geolocation.d.ts +96 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +10211 -0
- package/dist/infrastructure/cdk.d.ts +60 -0
- package/dist/infrastructure/cloudformation.d.ts +44 -0
- package/dist/infrastructure/index.d.ts +45 -0
- package/dist/infrastructure/setup.d.ts +133 -0
- package/dist/integrations/cloudflare.d.ts +76 -0
- package/dist/integrations/hono.d.ts +60 -0
- package/dist/integrations/index.d.ts +24 -0
- package/dist/integrations/nuxt.d.ts +7 -0
- package/dist/integrations/nuxt.js +42 -0
- package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
- package/dist/integrations/runtime/use-ts-analytics.js +17 -0
- package/dist/integrations/stx.d.ts +88 -0
- package/dist/integrations/stx.js +18 -0
- package/dist/lib/crypto-random.d.ts +4 -0
- package/dist/lib/salt.d.ts +16 -0
- package/dist/local.d.ts +56 -0
- package/dist/model-connector.d.ts +145 -0
- package/dist/models/AggregatedStats.d.ts +9 -0
- package/dist/models/CampaignStats.d.ts +9 -0
- package/dist/models/Conversion.d.ts +11 -0
- package/dist/models/CustomEvent.d.ts +11 -0
- package/dist/models/DeviceStats.d.ts +9 -0
- package/dist/models/EventStats.d.ts +9 -0
- package/dist/models/GeoStats.d.ts +9 -0
- package/dist/models/Goal.d.ts +9 -0
- package/dist/models/GoalStats.d.ts +9 -0
- package/dist/models/PageStats.d.ts +11 -0
- package/dist/models/PageView.d.ts +13 -0
- package/dist/models/RealtimeStats.d.ts +9 -0
- package/dist/models/ReferrerStats.d.ts +9 -0
- package/dist/models/Session.d.ts +11 -0
- package/dist/models/Site.d.ts +11 -0
- package/dist/models/index.d.ts +28 -0
- package/dist/models/types.d.ts +60 -0
- package/dist/sqs-buffering.d.ts +243 -0
- package/dist/stacks-integration.d.ts +159 -0
- package/dist/tracking-script.d.ts +71 -0
- package/dist/tracking.d.ts +17 -0
- package/dist/tracking.js +26 -0
- package/dist/types.d.ts +595 -0
- package/dist/utils/geolocation.d.ts +111 -0
- package/dist/utils/user-agent.d.ts +17 -0
- package/dist/version.d.ts +7 -0
- package/package.json +119 -0
- package/src/Analytics.ts +3349 -0
- package/src/api.ts +1286 -0
- package/src/assets/crosswind.css +2220 -0
- package/src/batching.ts +452 -0
- package/src/components/dashboard/index.ts +18 -0
- package/src/config.ts +456 -0
- package/src/dashboard/Dashboard.stx +1517 -0
- package/src/dashboard/components/AlertCard.stx +177 -0
- package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
- package/src/dashboard/components/AnimatedNumber.stx +86 -0
- package/src/dashboard/components/BarChart.stx +220 -0
- package/src/dashboard/components/BrowserBreakdown.stx +98 -0
- package/src/dashboard/components/BrowsersTable.stx +125 -0
- package/src/dashboard/components/CampaignBreakdown.stx +163 -0
- package/src/dashboard/components/CampaignTable.stx +238 -0
- package/src/dashboard/components/CountryList.stx +101 -0
- package/src/dashboard/components/DataTable.stx +226 -0
- package/src/dashboard/components/DateRangePicker.stx +77 -0
- package/src/dashboard/components/DeviceBreakdown.stx +94 -0
- package/src/dashboard/components/DevicesTable.stx +163 -0
- package/src/dashboard/components/DonutChart.stories.ts +55 -0
- package/src/dashboard/components/DonutChart.stx +157 -0
- package/src/dashboard/components/EmptyState.stx +90 -0
- package/src/dashboard/components/EngagementMetrics.stx +183 -0
- package/src/dashboard/components/EventsSection.stx +192 -0
- package/src/dashboard/components/FilterBar.stx +104 -0
- package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
- package/src/dashboard/components/FunnelChart.stx +142 -0
- package/src/dashboard/components/GeoTable.stx +337 -0
- package/src/dashboard/components/GoalsPanel.stx +109 -0
- package/src/dashboard/components/Header.stx +39 -0
- package/src/dashboard/components/HeatmapChart.stx +140 -0
- package/src/dashboard/components/LiveActivityFeed.stx +172 -0
- package/src/dashboard/components/MetricComparison.stx +106 -0
- package/src/dashboard/components/MiniStats.stx +96 -0
- package/src/dashboard/components/OSBreakdown.stx +124 -0
- package/src/dashboard/components/PageDetailCard.stx +102 -0
- package/src/dashboard/components/PagesTable.stx +127 -0
- package/src/dashboard/components/ProgressRing.stx +89 -0
- package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
- package/src/dashboard/components/RealtimeCounter.stx +46 -0
- package/src/dashboard/components/ReferrersTable.stx +180 -0
- package/src/dashboard/components/SparklineChart.stx +160 -0
- package/src/dashboard/components/StatCard.stories.ts +58 -0
- package/src/dashboard/components/StatCard.stx +90 -0
- package/src/dashboard/components/SummaryStats.stx +81 -0
- package/src/dashboard/components/TabNav.stx +66 -0
- package/src/dashboard/components/ThemeSwitcher.stx +124 -0
- package/src/dashboard/components/TimeSeriesChart.stx +106 -0
- package/src/dashboard/components/TopList.stories.ts +58 -0
- package/src/dashboard/components/TopList.stx +74 -0
- package/src/dashboard/components/TrendIndicator.stx +84 -0
- package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
- package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
- package/src/dashboard/components/heatmap/index.ts +12 -0
- package/src/dashboard/components/index.ts +86 -0
- package/src/dashboard/composables/useAnalytics.ts +465 -0
- package/src/dashboard/demo/DemoApp.stx +370 -0
- package/src/dashboard/demo/index.ts +8 -0
- package/src/dashboard/demo/mockData.ts +234 -0
- package/src/dashboard/index.ts +117 -0
- package/src/dashboard/stx-shim.d.ts +4 -0
- package/src/dashboard/types/index.ts +203 -0
- package/src/dashboard/utils/index.ts +426 -0
- package/src/dynamodb.ts +344 -0
- package/src/funnels.ts +534 -0
- package/src/geolocation.ts +515 -0
- package/src/handlers/alerts.ts +328 -0
- package/src/handlers/annotations.ts +128 -0
- package/src/handlers/api-keys.ts +240 -0
- package/src/handlers/auth.ts +1020 -0
- package/src/handlers/authz.ts +137 -0
- package/src/handlers/collect.ts +724 -0
- package/src/handlers/data.ts +625 -0
- package/src/handlers/experiments.ts +138 -0
- package/src/handlers/funnels.ts +216 -0
- package/src/handlers/goals.ts +218 -0
- package/src/handlers/heatmaps.ts +272 -0
- package/src/handlers/index.ts +56 -0
- package/src/handlers/lib/read-cache.ts +63 -0
- package/src/handlers/misc.ts +486 -0
- package/src/handlers/oauth.ts +233 -0
- package/src/handlers/performance.ts +388 -0
- package/src/handlers/sessions.ts +413 -0
- package/src/handlers/sharing.ts +198 -0
- package/src/handlers/stats.ts +1368 -0
- package/src/handlers/team.ts +161 -0
- package/src/handlers/uptime.ts +283 -0
- package/src/handlers/views.ts +390 -0
- package/src/handlers/webhooks.ts +226 -0
- package/src/heatmap/index.ts +32 -0
- package/src/heatmap/tracking-script.ts +452 -0
- package/src/heatmap/types.ts +79 -0
- package/src/index.ts +387 -0
- package/src/infrastructure/cdk.ts +496 -0
- package/src/infrastructure/cloudformation.ts +595 -0
- package/src/infrastructure/index.ts +48 -0
- package/src/infrastructure/setup.ts +611 -0
- package/src/integrations/cloudflare.ts +732 -0
- package/src/integrations/hono.ts +589 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/nuxt.ts +78 -0
- package/src/integrations/runtime/use-ts-analytics.ts +32 -0
- package/src/integrations/stx.ts +138 -0
- package/src/jobs/index.ts +127 -0
- package/src/lib/crypto-random.ts +41 -0
- package/src/lib/ddb-errors.ts +20 -0
- package/src/lib/dynamodb.ts +216 -0
- package/src/lib/email.ts +38 -0
- package/src/lib/ga-import.ts +471 -0
- package/src/lib/ga4-api.ts +244 -0
- package/src/lib/goals.ts +205 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/ingest-counters.ts +123 -0
- package/src/lib/log.ts +36 -0
- package/src/lib/membership.ts +98 -0
- package/src/lib/plans.ts +90 -0
- package/src/lib/quota.ts +46 -0
- package/src/lib/rate-limit.ts +27 -0
- package/src/lib/rollups.ts +472 -0
- package/src/lib/salt.ts +97 -0
- package/src/lib/scheduler.ts +97 -0
- package/src/lib/significance.ts +66 -0
- package/src/lib/site-retention.ts +56 -0
- package/src/local.ts +360 -0
- package/src/model-connector.ts +616 -0
- package/src/models/AggregatedStats.ts +162 -0
- package/src/models/CampaignStats.ts +141 -0
- package/src/models/Conversion.ts +135 -0
- package/src/models/CustomEvent.ts +123 -0
- package/src/models/DeviceStats.ts +105 -0
- package/src/models/EventStats.ts +108 -0
- package/src/models/GeoStats.ts +112 -0
- package/src/models/Goal.ts +105 -0
- package/src/models/GoalStats.ts +106 -0
- package/src/models/PageStats.ts +152 -0
- package/src/models/PageView.ts +277 -0
- package/src/models/RealtimeStats.ts +96 -0
- package/src/models/ReferrerStats.ts +115 -0
- package/src/models/Session.ts +235 -0
- package/src/models/Site.ts +115 -0
- package/src/models/index.ts +50 -0
- package/src/models/orm/index.ts +2043 -0
- package/src/models/types.ts +71 -0
- package/src/router.ts +521 -0
- package/src/sqs-buffering.ts +806 -0
- package/src/stacks-integration.ts +527 -0
- package/src/tracking-script.ts +944 -0
- package/src/tracking.ts +27 -0
- package/src/types/analytics.ts +219 -0
- package/src/types/api.ts +75 -0
- package/src/types/bun-router.d.ts +15 -0
- package/src/types/dashboard.ts +62 -0
- package/src/types/index.ts +66 -0
- package/src/types/stx.d.ts +27 -0
- package/src/types/window.d.ts +101 -0
- package/src/types.ts +911 -0
- package/src/utils/cache.ts +148 -0
- package/src/utils/date.ts +118 -0
- package/src/utils/filters.ts +71 -0
- package/src/utils/geolocation.ts +323 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/response.ts +180 -0
- package/src/utils/timezone-country.ts +242 -0
- package/src/utils/user-agent.ts +110 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GA4 CSV importer — Phase A of #155.
|
|
3
|
+
*
|
|
4
|
+
* Migrating users start from zero history; this backfills it WITHOUT
|
|
5
|
+
* fabricating raw events: GA4 exports are daily aggregates by dimension,
|
|
6
|
+
* which map 1:1 onto the rollup store the dashboard already reads for
|
|
7
|
+
* settled days (#172) — ROLLUP#DAY (scalars) + ROLLUP#DIMS (breakdowns).
|
|
8
|
+
* Even the uniques semantics line up: GA reports daily users and our
|
|
9
|
+
* invariant is "uniques = sum of daily uniques".
|
|
10
|
+
*
|
|
11
|
+
* Input: one or more CSVs exported from GA4 (Explorations or the report UI
|
|
12
|
+
* with a Date dimension added; UA exports with the same columns also work).
|
|
13
|
+
* Files are auto-detected by header:
|
|
14
|
+
* - traffic: Date + Views/Users/Sessions (+ optional bounce/duration/events)
|
|
15
|
+
* - pages: Date + Page path + Views (+ Users)
|
|
16
|
+
* - sources: Date + Session source|Channel group + Users (+ Views|Sessions)
|
|
17
|
+
* - devices/browsers/os/countries/regions/cities: Date + that dimension + Users
|
|
18
|
+
* - events: Date + Event name + Event count (+ Users)
|
|
19
|
+
*
|
|
20
|
+
* Import policy (honest + idempotent):
|
|
21
|
+
* - A day that already has a REAL (tracked) rollup is never overwritten;
|
|
22
|
+
* re-importing over previous imports is fine (items carry source:'ga-import').
|
|
23
|
+
* - Incomplete days (today onward) are skipped.
|
|
24
|
+
* - The imported span is zero-filled through yesterday so the dashboard's
|
|
25
|
+
* contiguous-settled-prefix reads (#172) never die at a gap between the
|
|
26
|
+
* end of GA history and the start of live tracking.
|
|
27
|
+
*/
|
|
28
|
+
import { dynamodb, marshall, TABLE_NAME, unmarshall } from './dynamodb'
|
|
29
|
+
|
|
30
|
+
export interface GaImportFileResult {
|
|
31
|
+
name: string
|
|
32
|
+
kind: string
|
|
33
|
+
rows: number
|
|
34
|
+
days: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GaImportResult {
|
|
38
|
+
files: GaImportFileResult[]
|
|
39
|
+
daysWritten: number
|
|
40
|
+
daysSkipped: string[]
|
|
41
|
+
zeroFilled: number
|
|
42
|
+
span: { start: string, end: string } | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Minimal CSV parser: quoted fields, embedded commas/quotes, CRLF, BOM, and GA's leading # comment lines. */
|
|
46
|
+
export function parseCsv(text: string): string[][] {
|
|
47
|
+
const src = text.replace(/^/, '')
|
|
48
|
+
const rows: string[][] = []
|
|
49
|
+
let row: string[] = []
|
|
50
|
+
let field = ''
|
|
51
|
+
let inQuotes = false
|
|
52
|
+
for (let i = 0; i < src.length; i++) {
|
|
53
|
+
const c = src[i]
|
|
54
|
+
if (inQuotes) {
|
|
55
|
+
if (c === '"') {
|
|
56
|
+
if (src[i + 1] === '"') {
|
|
57
|
+
field += '"'
|
|
58
|
+
i++
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
inQuotes = false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
field += c
|
|
66
|
+
}
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (c === '"') {
|
|
70
|
+
inQuotes = true
|
|
71
|
+
}
|
|
72
|
+
else if (c === ',') {
|
|
73
|
+
row.push(field)
|
|
74
|
+
field = ''
|
|
75
|
+
}
|
|
76
|
+
else if (c === '\n' || c === '\r') {
|
|
77
|
+
if (c === '\r' && src[i + 1] === '\n')
|
|
78
|
+
i++
|
|
79
|
+
row.push(field)
|
|
80
|
+
field = ''
|
|
81
|
+
if (row.length > 1 || row[0] !== '')
|
|
82
|
+
rows.push(row)
|
|
83
|
+
row = []
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
field += c
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (field !== '' || row.length > 0) {
|
|
90
|
+
row.push(field)
|
|
91
|
+
if (row.length > 1 || row[0] !== '')
|
|
92
|
+
rows.push(row)
|
|
93
|
+
}
|
|
94
|
+
// GA4 prepends metadata lines starting with '#' — drop them.
|
|
95
|
+
return rows.filter(r => !String(r[0] || '').trimStart().startsWith('#'))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Normalize a header for matching: lowercase, alphanumerics only. */
|
|
99
|
+
function norm(h: string): string {
|
|
100
|
+
return h.toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Column aliases (GA4 + UA English exports + our canonical names). */
|
|
104
|
+
const COLS: Record<string, string[]> = {
|
|
105
|
+
date: ['date', 'day'],
|
|
106
|
+
views: ['views', 'screenpageviews', 'pageviews', 'viewsperpage'],
|
|
107
|
+
visitors: ['activeusers', 'totalusers', 'users', 'visitors', 'people'],
|
|
108
|
+
sessions: ['sessions', 'visits'],
|
|
109
|
+
bounceRate: ['bouncerate'],
|
|
110
|
+
engagementRate: ['engagementrate'],
|
|
111
|
+
avgSessionSeconds: ['averagesessionduration', 'avgsessionduration'],
|
|
112
|
+
eventCount: ['eventcount', 'events', 'totalevents'],
|
|
113
|
+
page: ['pagepathandscreenclass', 'pagepath', 'pagepathscreenclass', 'landingpage', 'page'],
|
|
114
|
+
source: ['sessionsource', 'firstusersource', 'sessiondefaultchannelgroup', 'sessiondefaultchannelgrouping', 'defaultchannelgroup', 'source', 'referrersource'],
|
|
115
|
+
device: ['devicecategory', 'device'],
|
|
116
|
+
browser: ['browser'],
|
|
117
|
+
os: ['operatingsystem', 'os'],
|
|
118
|
+
country: ['country'],
|
|
119
|
+
region: ['region'],
|
|
120
|
+
city: ['city', 'town'],
|
|
121
|
+
eventName: ['eventname'],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function findCol(headers: string[], key: string): number {
|
|
125
|
+
const wanted = COLS[key]
|
|
126
|
+
for (let i = 0; i < headers.length; i++) {
|
|
127
|
+
if (wanted.includes(norm(headers[i])))
|
|
128
|
+
return i
|
|
129
|
+
}
|
|
130
|
+
return -1
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** '20260101', '2026-01-01', or '01/01/2026' (UA) → '2026-01-01' (or null). */
|
|
134
|
+
export function parseGaDate(value: string): string | null {
|
|
135
|
+
const v = value.trim()
|
|
136
|
+
let m = v.match(/^(\d{4})(\d{2})(\d{2})$/)
|
|
137
|
+
if (m)
|
|
138
|
+
return `${m[1]}-${m[2]}-${m[3]}`
|
|
139
|
+
m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
|
140
|
+
if (m)
|
|
141
|
+
return v
|
|
142
|
+
m = v.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
|
|
143
|
+
if (m)
|
|
144
|
+
return `${m[3]}-${m[1].padStart(2, '0')}-${m[2].padStart(2, '0')}`
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function num(value: string | undefined): number {
|
|
149
|
+
if (value === undefined)
|
|
150
|
+
return 0
|
|
151
|
+
const v = value.replace(/[%,\s]/g, '').replace(/^</, '')
|
|
152
|
+
const n = Number(v)
|
|
153
|
+
return Number.isFinite(n) ? n : 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Rate cells come as '0.42', '42%', or '42.0' — normalize to 0..1. */
|
|
157
|
+
function rate(value: string | undefined): number {
|
|
158
|
+
const n = num(value)
|
|
159
|
+
return n > 1 ? n / 100 : n
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type GaFileKind
|
|
163
|
+
= 'traffic' | 'pages' | 'sources' | 'devices' | 'browsers' | 'os'
|
|
164
|
+
| 'countries' | 'regions' | 'cities' | 'events' | 'unknown'
|
|
165
|
+
|
|
166
|
+
export function detectKind(headers: string[]): GaFileKind {
|
|
167
|
+
if (findCol(headers, 'date') === -1)
|
|
168
|
+
return 'unknown'
|
|
169
|
+
if (findCol(headers, 'page') !== -1)
|
|
170
|
+
return 'pages'
|
|
171
|
+
if (findCol(headers, 'eventName') !== -1)
|
|
172
|
+
return 'events'
|
|
173
|
+
if (findCol(headers, 'source') !== -1)
|
|
174
|
+
return 'sources'
|
|
175
|
+
if (findCol(headers, 'city') !== -1)
|
|
176
|
+
return 'cities'
|
|
177
|
+
if (findCol(headers, 'region') !== -1)
|
|
178
|
+
return 'regions'
|
|
179
|
+
if (findCol(headers, 'country') !== -1)
|
|
180
|
+
return 'countries'
|
|
181
|
+
if (findCol(headers, 'browser') !== -1)
|
|
182
|
+
return 'browsers'
|
|
183
|
+
if (findCol(headers, 'os') !== -1)
|
|
184
|
+
return 'os'
|
|
185
|
+
if (findCol(headers, 'device') !== -1)
|
|
186
|
+
return 'devices'
|
|
187
|
+
if (findCol(headers, 'views') !== -1 || findCol(headers, 'visitors') !== -1 || findCol(headers, 'sessions') !== -1)
|
|
188
|
+
return 'traffic'
|
|
189
|
+
return 'unknown'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface DayAccumulator {
|
|
193
|
+
scalars: { views: number, visitors: number, sessions: number, bounces: number, totalDuration: number, events: number }
|
|
194
|
+
hasTraffic: boolean
|
|
195
|
+
pages: Record<string, { w: number, v: number, e: number }>
|
|
196
|
+
sources: Record<string, { v: number, w: number }>
|
|
197
|
+
devices: Record<string, { v: number }>
|
|
198
|
+
browsers: Record<string, { v: number }>
|
|
199
|
+
os: Record<string, { v: number }>
|
|
200
|
+
countries: Record<string, { v: number }>
|
|
201
|
+
regions: Record<string, { v: number }>
|
|
202
|
+
cities: Record<string, { v: number }>
|
|
203
|
+
events: Record<string, { c: number, v: number, val: number }>
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function dayAcc(days: Map<string, DayAccumulator>, day: string): DayAccumulator {
|
|
207
|
+
let acc = days.get(day)
|
|
208
|
+
if (!acc) {
|
|
209
|
+
acc = {
|
|
210
|
+
scalars: { views: 0, visitors: 0, sessions: 0, bounces: 0, totalDuration: 0, events: 0 },
|
|
211
|
+
hasTraffic: false,
|
|
212
|
+
pages: {},
|
|
213
|
+
sources: {},
|
|
214
|
+
devices: {},
|
|
215
|
+
browsers: {},
|
|
216
|
+
os: {},
|
|
217
|
+
countries: {},
|
|
218
|
+
regions: {},
|
|
219
|
+
cities: {},
|
|
220
|
+
events: {},
|
|
221
|
+
}
|
|
222
|
+
days.set(day, acc)
|
|
223
|
+
}
|
|
224
|
+
return acc
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Parse the provided files into per-day rollup data. Pure — unit-testable
|
|
229
|
+
* without DynamoDB.
|
|
230
|
+
*/
|
|
231
|
+
export function buildDailyData(files: Array<{ name: string, content: string }>): { days: Map<string, DayAccumulator>, results: GaImportFileResult[] } {
|
|
232
|
+
const days = new Map<string, DayAccumulator>()
|
|
233
|
+
const results: GaImportFileResult[] = []
|
|
234
|
+
|
|
235
|
+
for (const file of files) {
|
|
236
|
+
const rows = parseCsv(file.content)
|
|
237
|
+
if (rows.length < 2) {
|
|
238
|
+
results.push({ name: file.name, kind: 'unknown', rows: 0, days: 0 })
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
const headers = rows[0]
|
|
242
|
+
const kind = detectKind(headers)
|
|
243
|
+
const dateCol = findCol(headers, 'date')
|
|
244
|
+
const daysSeen = new Set<string>()
|
|
245
|
+
let parsed = 0
|
|
246
|
+
if (kind === 'unknown') {
|
|
247
|
+
results.push({ name: file.name, kind, rows: 0, days: 0 })
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const col = (key: string): number => findCol(headers, key)
|
|
252
|
+
const cViews = col('views')
|
|
253
|
+
const cVisitors = col('visitors')
|
|
254
|
+
const cSessions = col('sessions')
|
|
255
|
+
const cBounce = col('bounceRate')
|
|
256
|
+
const cEngage = col('engagementRate')
|
|
257
|
+
const cAvgDur = col('avgSessionSeconds')
|
|
258
|
+
const cEvents = col('eventCount')
|
|
259
|
+
|
|
260
|
+
for (const row of rows.slice(1)) {
|
|
261
|
+
const day = parseGaDate(String(row[dateCol] ?? ''))
|
|
262
|
+
if (!day)
|
|
263
|
+
continue // Totals/blank rows GA appends
|
|
264
|
+
const acc = dayAcc(days, day)
|
|
265
|
+
daysSeen.add(day)
|
|
266
|
+
parsed++
|
|
267
|
+
const views = num(row[cViews])
|
|
268
|
+
const visitors = num(row[cVisitors])
|
|
269
|
+
const sessions = num(row[cSessions])
|
|
270
|
+
|
|
271
|
+
if (kind === 'traffic') {
|
|
272
|
+
acc.hasTraffic = true
|
|
273
|
+
acc.scalars.views += views
|
|
274
|
+
acc.scalars.visitors += visitors
|
|
275
|
+
acc.scalars.sessions += sessions
|
|
276
|
+
acc.scalars.events += num(row[cEvents])
|
|
277
|
+
const sess = sessions || visitors
|
|
278
|
+
if (cBounce !== -1)
|
|
279
|
+
acc.scalars.bounces += Math.round(sess * rate(row[cBounce]))
|
|
280
|
+
else if (cEngage !== -1)
|
|
281
|
+
acc.scalars.bounces += Math.round(sess * (1 - rate(row[cEngage])))
|
|
282
|
+
if (cAvgDur !== -1)
|
|
283
|
+
acc.scalars.totalDuration += Math.round(sess * num(row[cAvgDur]) * 1000)
|
|
284
|
+
}
|
|
285
|
+
else if (kind === 'pages') {
|
|
286
|
+
const path = String(row[col('page')] || '').trim() || '/'
|
|
287
|
+
const cell = (acc.pages[path] ||= { w: 0, v: 0, e: 0 })
|
|
288
|
+
cell.w += views || visitors
|
|
289
|
+
cell.v += visitors
|
|
290
|
+
}
|
|
291
|
+
else if (kind === 'sources') {
|
|
292
|
+
const raw = String(row[col('source')] || '').trim()
|
|
293
|
+
const source = !raw || /^\(direct\)$|^direct$|^\(none\)$/i.test(raw) ? 'Direct' : raw
|
|
294
|
+
const cell = (acc.sources[source] ||= { v: 0, w: 0 })
|
|
295
|
+
cell.v += visitors || sessions
|
|
296
|
+
cell.w += views || sessions || visitors
|
|
297
|
+
}
|
|
298
|
+
else if (kind === 'events') {
|
|
299
|
+
const name = String(row[col('eventName')] || '').trim() || 'unknown'
|
|
300
|
+
const cell = (acc.events[name] ||= { c: 0, v: 0, val: 0 })
|
|
301
|
+
cell.c += num(row[cEvents]) || views || 1
|
|
302
|
+
cell.v += visitors
|
|
303
|
+
}
|
|
304
|
+
else if (kind === 'regions') {
|
|
305
|
+
const region = String(row[col('region')] || '').trim() || 'Unknown'
|
|
306
|
+
const country = String(row[col('country')] || '').trim() || 'Unknown'
|
|
307
|
+
const key = `${country}:${region}`
|
|
308
|
+
const cell = (acc.regions[key] ||= { v: 0 })
|
|
309
|
+
cell.v += visitors || sessions
|
|
310
|
+
}
|
|
311
|
+
else if (kind === 'cities') {
|
|
312
|
+
const city = String(row[col('city')] || '').trim() || 'Unknown'
|
|
313
|
+
const region = String(row[col('region')] || '').trim() || 'Unknown'
|
|
314
|
+
const country = String(row[col('country')] || '').trim() || 'Unknown'
|
|
315
|
+
const key = `${country}:${region}:${city}`
|
|
316
|
+
const cell = (acc.cities[key] ||= { v: 0 })
|
|
317
|
+
cell.v += visitors || sessions
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
// Single-key visitor dimensions: devices/browsers/os/countries.
|
|
321
|
+
const colKey = kind === 'devices' ? 'device' : kind === 'browsers' ? 'browser' : kind === 'os' ? 'os' : 'country'
|
|
322
|
+
let value = String(row[col(colKey)] || '').trim()
|
|
323
|
+
if (!value)
|
|
324
|
+
continue
|
|
325
|
+
if (kind === 'devices')
|
|
326
|
+
value = value.toLowerCase()
|
|
327
|
+
const cell = ((acc as any)[kind][value] ||= { v: 0 })
|
|
328
|
+
cell.v += visitors || sessions
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
results.push({ name: file.name, kind, rows: parsed, days: daysSeen.size })
|
|
332
|
+
}
|
|
333
|
+
return { days, results }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* When no traffic file was provided, derive day scalars from the dimension
|
|
338
|
+
* files (documented approximation): views from pages, visitors/sessions from
|
|
339
|
+
* sources (each session has exactly one source, so per-source user counts sum
|
|
340
|
+
* to ~daily users — the same semantics our own rollups use).
|
|
341
|
+
*/
|
|
342
|
+
function deriveScalars(acc: DayAccumulator): void {
|
|
343
|
+
if (acc.hasTraffic)
|
|
344
|
+
return
|
|
345
|
+
const pageViews = Object.values(acc.pages).reduce((sum, c) => sum + c.w, 0)
|
|
346
|
+
const sourceVisitors = Object.values(acc.sources).reduce((sum, c) => sum + c.v, 0)
|
|
347
|
+
const dimVisitors = Object.values(acc.devices).reduce((sum, c) => sum + c.v, 0)
|
|
348
|
+
acc.scalars.views = pageViews
|
|
349
|
+
acc.scalars.visitors = sourceVisitors || dimVisitors
|
|
350
|
+
acc.scalars.sessions = sourceVisitors || dimVisitors
|
|
351
|
+
acc.scalars.events = Object.values(acc.events).reduce((sum, c) => sum + c.c, 0)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Write the parsed CSV files into the rollup store. */
|
|
355
|
+
export async function importGaData(siteId: string, files: Array<{ name: string, content: string }>): Promise<GaImportResult> {
|
|
356
|
+
const { days, results } = buildDailyData(files)
|
|
357
|
+
const write = await writeImportedDays(siteId, days)
|
|
358
|
+
return { files: results, ...write }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Shared write phase for both import paths (CSV + Data API): collision policy,
|
|
363
|
+
* zero-fill, and batched rollup writes.
|
|
364
|
+
*/
|
|
365
|
+
export async function writeImportedDays(siteId: string, days: Map<string, DayAccumulator>): Promise<Omit<GaImportResult, 'files'>> {
|
|
366
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
367
|
+
const importedDays = [...days.keys()].filter(d => d < today).sort()
|
|
368
|
+
if (importedDays.length === 0)
|
|
369
|
+
return { daysWritten: 0, daysSkipped: [], zeroFilled: 0, span: null }
|
|
370
|
+
|
|
371
|
+
// Zero-fill from the span start through YESTERDAY so contiguous-prefix
|
|
372
|
+
// reads never break between GA history and live tracking (#172).
|
|
373
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
|
374
|
+
const allDays: string[] = []
|
|
375
|
+
const cursor = new Date(`${importedDays[0]}T00:00:00.000Z`)
|
|
376
|
+
while (cursor.toISOString().slice(0, 10) <= yesterday) {
|
|
377
|
+
allDays.push(cursor.toISOString().slice(0, 10))
|
|
378
|
+
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Never overwrite a day that has a REAL (tracked) rollup.
|
|
382
|
+
const existingReal = new Set<string>()
|
|
383
|
+
{
|
|
384
|
+
const res = await import('./dynamodb').then(m => m.queryAllItems({
|
|
385
|
+
TableName: TABLE_NAME,
|
|
386
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
387
|
+
ExpressionAttributeValues: {
|
|
388
|
+
':pk': { S: `SITE#${siteId}` },
|
|
389
|
+
':start': { S: `ROLLUP#DAY#${allDays[0]}` },
|
|
390
|
+
':end': { S: `ROLLUP#DAY#${allDays[allDays.length - 1]}~` },
|
|
391
|
+
},
|
|
392
|
+
}))
|
|
393
|
+
for (const raw of (res.Items || [])) {
|
|
394
|
+
const item = unmarshall(raw)
|
|
395
|
+
if (item.source !== 'ga-import' && item.day)
|
|
396
|
+
existingReal.add(item.day)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let daysWritten = 0
|
|
401
|
+
let zeroFilled = 0
|
|
402
|
+
const daysSkipped: string[] = []
|
|
403
|
+
const empty = (): DayAccumulator => dayAcc(new Map(), 'x')
|
|
404
|
+
|
|
405
|
+
// Batched writes (25/request — the BatchWriteItem cap): a multi-year import
|
|
406
|
+
// is ~1,500 items, which as sequential puts would blow API-gateway timeouts.
|
|
407
|
+
const pending: Record<string, unknown>[] = []
|
|
408
|
+
const now = new Date().toISOString()
|
|
409
|
+
for (const day of allDays) {
|
|
410
|
+
if (existingReal.has(day)) {
|
|
411
|
+
if (days.has(day))
|
|
412
|
+
daysSkipped.push(day)
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
const acc = days.get(day) ?? empty()
|
|
416
|
+
deriveScalars(acc)
|
|
417
|
+
pending.push({
|
|
418
|
+
pk: `SITE#${siteId}`,
|
|
419
|
+
sk: `ROLLUP#DAY#${day}`,
|
|
420
|
+
day,
|
|
421
|
+
...acc.scalars,
|
|
422
|
+
source: 'ga-import',
|
|
423
|
+
computedAt: now,
|
|
424
|
+
})
|
|
425
|
+
pending.push({
|
|
426
|
+
pk: `SITE#${siteId}`,
|
|
427
|
+
sk: `ROLLUP#DIMS#${day}`,
|
|
428
|
+
day,
|
|
429
|
+
pages: acc.pages,
|
|
430
|
+
sources: acc.sources,
|
|
431
|
+
devices: acc.devices,
|
|
432
|
+
browsers: acc.browsers,
|
|
433
|
+
os: acc.os,
|
|
434
|
+
countries: acc.countries,
|
|
435
|
+
regions: acc.regions,
|
|
436
|
+
cities: acc.cities,
|
|
437
|
+
campaigns: {},
|
|
438
|
+
events: acc.events,
|
|
439
|
+
entries: {},
|
|
440
|
+
exits: {},
|
|
441
|
+
source: 'ga-import',
|
|
442
|
+
computedAt: now,
|
|
443
|
+
})
|
|
444
|
+
if (days.has(day))
|
|
445
|
+
daysWritten++
|
|
446
|
+
else zeroFilled++
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (let i = 0; i < pending.length; i += 25) {
|
|
450
|
+
const batch = pending.slice(i, i + 25)
|
|
451
|
+
let unprocessed = batch.map(item => ({ PutRequest: { Item: marshall(item) } }))
|
|
452
|
+
// Retry unprocessed items (throughput spikes) a few times before failing.
|
|
453
|
+
for (let attempt = 0; unprocessed.length > 0 && attempt < 5; attempt++) {
|
|
454
|
+
const res = await dynamodb.batchWriteItem({
|
|
455
|
+
RequestItems: { [TABLE_NAME]: unprocessed },
|
|
456
|
+
}) as { UnprocessedItems?: Record<string, any[]> }
|
|
457
|
+
unprocessed = res.UnprocessedItems?.[TABLE_NAME] || []
|
|
458
|
+
if (unprocessed.length > 0)
|
|
459
|
+
await new Promise(resolve => setTimeout(resolve, 200 * (attempt + 1)))
|
|
460
|
+
}
|
|
461
|
+
if (unprocessed.length > 0)
|
|
462
|
+
throw new Error(`Import incomplete: ${unprocessed.length} items unwritten after retries`)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
daysWritten,
|
|
467
|
+
daysSkipped,
|
|
468
|
+
zeroFilled,
|
|
469
|
+
span: { start: allDays[0], end: allDays[allDays.length - 1] },
|
|
470
|
+
}
|
|
471
|
+
}
|