@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,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GA4 Data API importer — Phase B of #155.
|
|
3
|
+
*
|
|
4
|
+
* Pulls a property's full daily history straight from Google's Analytics
|
|
5
|
+
* Data API and feeds it through the same write phase as the CSV importer
|
|
6
|
+
* (collision policy, zero-fill, batched rollup writes).
|
|
7
|
+
*
|
|
8
|
+
* Auth is a SERVICE ACCOUNT key, not OAuth: the user creates a service
|
|
9
|
+
* account in Google Cloud, grants its email Viewer access on the GA4
|
|
10
|
+
* property, and pastes the JSON key. No consent screens, no app
|
|
11
|
+
* verification, no refresh tokens. The key is used for this import only and
|
|
12
|
+
* is NEVER persisted.
|
|
13
|
+
*
|
|
14
|
+
* Test seams: GA4_TOKEN_URL / GA4_API_BASE env overrides let the test
|
|
15
|
+
* harness stand in for Google.
|
|
16
|
+
*/
|
|
17
|
+
import { type DayAccumulator, dayAcc, writeImportedDays, type GaImportResult } from './ga-import'
|
|
18
|
+
|
|
19
|
+
export interface ServiceAccountKey {
|
|
20
|
+
client_email: string
|
|
21
|
+
private_key: string
|
|
22
|
+
token_uri?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TOKEN_URL = (): string => process.env.GA4_TOKEN_URL || 'https://oauth2.googleapis.com/token'
|
|
26
|
+
const API_BASE = (): string => process.env.GA4_API_BASE || 'https://analyticsdata.googleapis.com'
|
|
27
|
+
|
|
28
|
+
function b64url(data: Uint8Array | string): string {
|
|
29
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
|
|
30
|
+
let bin = ''
|
|
31
|
+
for (const b of bytes) bin += String.fromCharCode(b)
|
|
32
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** PEM (PKCS8) → DER bytes (backed by a plain ArrayBuffer for WebCrypto). */
|
|
36
|
+
function pemToDer(pem: string): Uint8Array<ArrayBuffer> {
|
|
37
|
+
const body = pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, '')
|
|
38
|
+
const bin = atob(body)
|
|
39
|
+
const buf = new ArrayBuffer(bin.length)
|
|
40
|
+
const out = new Uint8Array(buf)
|
|
41
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Exchange the service-account key for an access token (RS256 JWT grant). */
|
|
46
|
+
export async function getAccessToken(key: ServiceAccountKey): Promise<string> {
|
|
47
|
+
const now = Math.floor(Date.now() / 1000)
|
|
48
|
+
const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
|
49
|
+
const claims = b64url(JSON.stringify({
|
|
50
|
+
iss: key.client_email,
|
|
51
|
+
scope: 'https://www.googleapis.com/auth/analytics.readonly',
|
|
52
|
+
aud: key.token_uri || TOKEN_URL(),
|
|
53
|
+
iat: now,
|
|
54
|
+
exp: now + 3600,
|
|
55
|
+
}))
|
|
56
|
+
const signingInput = `${header}.${claims}`
|
|
57
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
58
|
+
'pkcs8',
|
|
59
|
+
pemToDer(key.private_key),
|
|
60
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
61
|
+
false,
|
|
62
|
+
['sign'],
|
|
63
|
+
)
|
|
64
|
+
const signature = new Uint8Array(await crypto.subtle.sign('RSASSA-PKCS1-v1_5', cryptoKey, new TextEncoder().encode(signingInput)))
|
|
65
|
+
const jwt = `${signingInput}.${b64url(signature)}`
|
|
66
|
+
|
|
67
|
+
const res = await fetch(TOKEN_URL(), {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
70
|
+
body: `grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')}&assertion=${encodeURIComponent(jwt)}`,
|
|
71
|
+
})
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
const body = await res.text()
|
|
74
|
+
throw new Error(`Google token exchange failed (${res.status}): ${body.slice(0, 300)}`)
|
|
75
|
+
}
|
|
76
|
+
const data = await res.json() as { access_token?: string }
|
|
77
|
+
if (!data.access_token)
|
|
78
|
+
throw new Error('Google token exchange returned no access_token')
|
|
79
|
+
return data.access_token
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface GaReportRow {
|
|
83
|
+
dimensionValues?: Array<{ value?: string }>
|
|
84
|
+
metricValues?: Array<{ value?: string }>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface RunReportResponse {
|
|
88
|
+
rows?: GaReportRow[]
|
|
89
|
+
rowCount?: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function runReport(
|
|
93
|
+
token: string,
|
|
94
|
+
propertyId: string,
|
|
95
|
+
body: Record<string, unknown>,
|
|
96
|
+
): Promise<GaReportRow[]> {
|
|
97
|
+
const rows: GaReportRow[] = []
|
|
98
|
+
const limit = 100_000
|
|
99
|
+
let offset = 0
|
|
100
|
+
for (let page = 0; page < 30; page++) {
|
|
101
|
+
const res = await fetch(`${API_BASE()}/v1beta/properties/${propertyId}:runReport`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({ ...body, limit, offset }),
|
|
105
|
+
})
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
const errBody = await res.text()
|
|
108
|
+
throw new Error(`GA4 runReport failed (${res.status}): ${errBody.slice(0, 300)}`)
|
|
109
|
+
}
|
|
110
|
+
const data = await res.json() as RunReportResponse
|
|
111
|
+
rows.push(...(data.rows || []))
|
|
112
|
+
offset += limit
|
|
113
|
+
if (!data.rows || data.rows.length < limit || rows.length >= (data.rowCount ?? rows.length))
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
return rows
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const nnum = (v: string | undefined): number => {
|
|
120
|
+
const n = Number(v)
|
|
121
|
+
return Number.isFinite(n) ? n : 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isoDay(gaDate: string | undefined): string | null {
|
|
125
|
+
const m = String(gaDate || '').match(/^(\d{4})(\d{2})(\d{2})$/)
|
|
126
|
+
return m ? `${m[1]}-${m[2]}-${m[3]}` : null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The report set: one per rollup dimension family (+ daily scalars). */
|
|
130
|
+
export const GA4_REPORTS: Array<{ kind: string, dimensions: string[], metrics: string[] }> = [
|
|
131
|
+
{ kind: 'traffic', dimensions: ['date'], metrics: ['screenPageViews', 'activeUsers', 'sessions', 'bounceRate', 'averageSessionDuration', 'eventCount'] },
|
|
132
|
+
{ kind: 'pages', dimensions: ['date', 'pagePath'], metrics: ['screenPageViews', 'activeUsers'] },
|
|
133
|
+
{ kind: 'sources', dimensions: ['date', 'sessionSource'], metrics: ['activeUsers', 'sessions', 'screenPageViews'] },
|
|
134
|
+
{ kind: 'devices', dimensions: ['date', 'deviceCategory'], metrics: ['activeUsers'] },
|
|
135
|
+
{ kind: 'browsers', dimensions: ['date', 'browser'], metrics: ['activeUsers'] },
|
|
136
|
+
{ kind: 'os', dimensions: ['date', 'operatingSystem'], metrics: ['activeUsers'] },
|
|
137
|
+
{ kind: 'countries', dimensions: ['date', 'country'], metrics: ['activeUsers'] },
|
|
138
|
+
{ kind: 'regions', dimensions: ['date', 'country', 'region'], metrics: ['activeUsers'] },
|
|
139
|
+
{ kind: 'cities', dimensions: ['date', 'country', 'region', 'city'], metrics: ['activeUsers'] },
|
|
140
|
+
{ kind: 'events', dimensions: ['date', 'eventName'], metrics: ['eventCount', 'activeUsers'] },
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Fold one report's rows into the per-day accumulators. Pure — unit-testable
|
|
145
|
+
* with canned API responses.
|
|
146
|
+
*/
|
|
147
|
+
export function foldReport(days: Map<string, DayAccumulator>, kind: string, rows: GaReportRow[]): number {
|
|
148
|
+
let folded = 0
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
const dims = (row.dimensionValues || []).map(d => d.value || '')
|
|
151
|
+
const mets = (row.metricValues || []).map(m => m.value)
|
|
152
|
+
const day = isoDay(dims[0])
|
|
153
|
+
if (!day)
|
|
154
|
+
continue
|
|
155
|
+
const acc = dayAcc(days, day)
|
|
156
|
+
folded++
|
|
157
|
+
if (kind === 'traffic') {
|
|
158
|
+
acc.hasTraffic = true
|
|
159
|
+
acc.scalars.views += nnum(mets[0])
|
|
160
|
+
acc.scalars.visitors += nnum(mets[1])
|
|
161
|
+
acc.scalars.sessions += nnum(mets[2])
|
|
162
|
+
const sessions = nnum(mets[2]) || nnum(mets[1])
|
|
163
|
+
acc.scalars.bounces += Math.round(sessions * nnum(mets[3])) // bounceRate is 0..1
|
|
164
|
+
acc.scalars.totalDuration += Math.round(sessions * nnum(mets[4]) * 1000)
|
|
165
|
+
acc.scalars.events += nnum(mets[5])
|
|
166
|
+
}
|
|
167
|
+
else if (kind === 'pages') {
|
|
168
|
+
const path = dims[1] || '/'
|
|
169
|
+
const cell = (acc.pages[path] ||= { w: 0, v: 0, e: 0 })
|
|
170
|
+
cell.w += nnum(mets[0])
|
|
171
|
+
cell.v += nnum(mets[1])
|
|
172
|
+
}
|
|
173
|
+
else if (kind === 'sources') {
|
|
174
|
+
const raw = dims[1] || ''
|
|
175
|
+
const source = !raw || /^\(direct\)$|^\(none\)$/i.test(raw) ? 'Direct' : raw
|
|
176
|
+
const cell = (acc.sources[source] ||= { v: 0, w: 0 })
|
|
177
|
+
cell.v += nnum(mets[0])
|
|
178
|
+
cell.w += nnum(mets[2]) || nnum(mets[1])
|
|
179
|
+
}
|
|
180
|
+
else if (kind === 'events') {
|
|
181
|
+
const name = dims[1] || 'unknown'
|
|
182
|
+
const cell = (acc.events[name] ||= { c: 0, v: 0, val: 0 })
|
|
183
|
+
cell.c += nnum(mets[0])
|
|
184
|
+
cell.v += nnum(mets[1])
|
|
185
|
+
}
|
|
186
|
+
else if (kind === 'regions') {
|
|
187
|
+
const key = `${dims[1] || 'Unknown'}:${dims[2] || 'Unknown'}`
|
|
188
|
+
const cell = (acc.regions[key] ||= { v: 0 })
|
|
189
|
+
cell.v += nnum(mets[0])
|
|
190
|
+
}
|
|
191
|
+
else if (kind === 'cities') {
|
|
192
|
+
const key = `${dims[1] || 'Unknown'}:${dims[2] || 'Unknown'}:${dims[3] || 'Unknown'}`
|
|
193
|
+
const cell = (acc.cities[key] ||= { v: 0 })
|
|
194
|
+
cell.v += nnum(mets[0])
|
|
195
|
+
}
|
|
196
|
+
else if (kind === 'devices' || kind === 'browsers' || kind === 'os' || kind === 'countries') {
|
|
197
|
+
let value = (dims[1] || '').trim()
|
|
198
|
+
if (!value || value === '(not set)')
|
|
199
|
+
continue
|
|
200
|
+
if (kind === 'devices')
|
|
201
|
+
value = value.toLowerCase()
|
|
202
|
+
const cell = ((acc as any)[kind][value] ||= { v: 0 })
|
|
203
|
+
cell.v += nnum(mets[0])
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return folded
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface Ga4ApiImportOptions {
|
|
210
|
+
propertyId: string
|
|
211
|
+
serviceAccountKey: ServiceAccountKey
|
|
212
|
+
/** ISO date; defaults to GA4's earliest possible day. */
|
|
213
|
+
startDate?: string
|
|
214
|
+
/** ISO date; defaults to yesterday. */
|
|
215
|
+
endDate?: string
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Pull the property's history from the Data API and import it. */
|
|
219
|
+
export async function importFromGa4Api(siteId: string, options: Ga4ApiImportOptions): Promise<GaImportResult> {
|
|
220
|
+
const propertyId = options.propertyId.replace(/^properties\//, '').trim()
|
|
221
|
+
if (!/^\d+$/.test(propertyId))
|
|
222
|
+
throw new Error('propertyId must be the numeric GA4 property id')
|
|
223
|
+
const token = await getAccessToken(options.serviceAccountKey)
|
|
224
|
+
|
|
225
|
+
const dateRange = {
|
|
226
|
+
startDate: options.startDate || '2015-08-14',
|
|
227
|
+
endDate: options.endDate || 'yesterday',
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const days = new Map<string, DayAccumulator>()
|
|
231
|
+
const files: GaImportResult['files'] = []
|
|
232
|
+
for (const report of GA4_REPORTS) {
|
|
233
|
+
const rows = await runReport(token, propertyId, {
|
|
234
|
+
dateRanges: [dateRange],
|
|
235
|
+
dimensions: report.dimensions.map(name => ({ name })),
|
|
236
|
+
metrics: report.metrics.map(name => ({ name })),
|
|
237
|
+
})
|
|
238
|
+
const folded = foldReport(days, report.kind, rows)
|
|
239
|
+
files.push({ name: `ga4-api:${report.kind}`, kind: report.kind, rows: folded, days: 0 })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const write = await writeImportedDays(siteId, days)
|
|
243
|
+
return { files, ...write }
|
|
244
|
+
}
|
package/src/lib/goals.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal matching and conversion logic
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateId } from '../../src/index'
|
|
6
|
+
import { Goal, Conversion } from '../../src/models/orm'
|
|
7
|
+
import { getCachedGoals, setCachedGoals, hasConverted, markConverted } from '../utils/cache'
|
|
8
|
+
import { dynamodb, TABLE_NAME, isConditionalCheckFailed } from './dynamodb'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get goals for a site (with caching)
|
|
12
|
+
*/
|
|
13
|
+
export async function getGoalsForSite(siteId: string): Promise<Goal[]> {
|
|
14
|
+
const cached = getCachedGoals(siteId)
|
|
15
|
+
if (cached) {
|
|
16
|
+
return cached
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const goals = await Goal.forSite(siteId).active().get()
|
|
21
|
+
setCachedGoals(siteId, goals)
|
|
22
|
+
return goals
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
console.error('[Goals] Failed to fetch goals:', err)
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Context for goal matching
|
|
32
|
+
*/
|
|
33
|
+
export interface GoalMatchContext {
|
|
34
|
+
path: string
|
|
35
|
+
eventName?: string
|
|
36
|
+
/**
|
|
37
|
+
* The triggering event's value — used as the conversion value for
|
|
38
|
+
* variable-price goals (e.g. a purchase), falling back to the goal's static
|
|
39
|
+
* value when the event carries none (#132).
|
|
40
|
+
*/
|
|
41
|
+
eventValue?: number
|
|
42
|
+
sessionDurationMinutes?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if a goal matches the given context
|
|
47
|
+
*/
|
|
48
|
+
export function matchGoal(goal: Goal, context: GoalMatchContext): boolean {
|
|
49
|
+
if (!goal.isActive) return false
|
|
50
|
+
|
|
51
|
+
switch (goal.type) {
|
|
52
|
+
case 'pageview':
|
|
53
|
+
return matchPattern(goal.pattern, context.path, goal.matchType)
|
|
54
|
+
|
|
55
|
+
case 'event':
|
|
56
|
+
if (!context.eventName) return false
|
|
57
|
+
return matchPattern(goal.pattern, context.eventName, goal.matchType)
|
|
58
|
+
|
|
59
|
+
case 'duration':
|
|
60
|
+
if (context.sessionDurationMinutes === undefined) return false
|
|
61
|
+
const threshold = goal.durationMinutes || 0
|
|
62
|
+
return context.sessionDurationMinutes >= threshold
|
|
63
|
+
|
|
64
|
+
default:
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Match a pattern against a value
|
|
71
|
+
*/
|
|
72
|
+
function matchPattern(pattern: string, value: string, matchType: string): boolean {
|
|
73
|
+
if (!pattern || !value) return false
|
|
74
|
+
|
|
75
|
+
switch (matchType) {
|
|
76
|
+
case 'exact':
|
|
77
|
+
return value === pattern
|
|
78
|
+
|
|
79
|
+
case 'contains':
|
|
80
|
+
return value.includes(pattern)
|
|
81
|
+
|
|
82
|
+
case 'regex':
|
|
83
|
+
try {
|
|
84
|
+
const regex = new RegExp(pattern)
|
|
85
|
+
return regex.test(value)
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
console.warn(`[Goals] Invalid regex pattern: ${pattern}`)
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
default:
|
|
93
|
+
return value === pattern
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Metadata for conversion attribution
|
|
99
|
+
*/
|
|
100
|
+
export interface ConversionMetadata {
|
|
101
|
+
referrerSource?: string
|
|
102
|
+
utmSource?: string
|
|
103
|
+
utmMedium?: string
|
|
104
|
+
utmCampaign?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Durable once-per-session conversion claim (#174). The in-process
|
|
110
|
+
* hasConverted Map dedupes only within one instance (capped at 1,000 sessions
|
|
111
|
+
* with arbitrary eviction) — on Lambda the same session converted once per
|
|
112
|
+
* concurrent instance, inflating a revenue-grade metric. A conditional put on
|
|
113
|
+
* a CONVLOCK item makes exactly one instance win; the Map stays as a cheap
|
|
114
|
+
* fast-path.
|
|
115
|
+
*/
|
|
116
|
+
async function claimConversionLock(siteId: string, sessionId: string, goalId: string): Promise<boolean> {
|
|
117
|
+
try {
|
|
118
|
+
await dynamodb.putItem({
|
|
119
|
+
TableName: TABLE_NAME,
|
|
120
|
+
Item: {
|
|
121
|
+
pk: { S: `SITE#${siteId}` },
|
|
122
|
+
sk: { S: `CONVLOCK#${sessionId}#${goalId}` },
|
|
123
|
+
// Sessions idle out after 30 minutes — 24h is generous headroom.
|
|
124
|
+
ttl: { N: String(Math.floor(Date.now() / 1000) + 24 * 60 * 60) },
|
|
125
|
+
},
|
|
126
|
+
ConditionExpression: 'attribute_not_exists(pk)',
|
|
127
|
+
})
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
if (isConditionalCheckFailed(e))
|
|
132
|
+
return false
|
|
133
|
+
throw e
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check and record conversions for all matching goals
|
|
139
|
+
*/
|
|
140
|
+
export async function checkAndRecordConversions(
|
|
141
|
+
siteId: string,
|
|
142
|
+
visitorId: string,
|
|
143
|
+
sessionId: string,
|
|
144
|
+
context: GoalMatchContext,
|
|
145
|
+
metadata: ConversionMetadata
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
try {
|
|
148
|
+
const goals = await getGoalsForSite(siteId)
|
|
149
|
+
if (goals.length === 0) return
|
|
150
|
+
|
|
151
|
+
const timestamp = new Date()
|
|
152
|
+
|
|
153
|
+
for (const goal of goals) {
|
|
154
|
+
// Skip if already converted in this session
|
|
155
|
+
if (hasConverted(siteId, sessionId, goal.id)) continue
|
|
156
|
+
|
|
157
|
+
if (matchGoal(goal, context)) {
|
|
158
|
+
// Durable claim first — exactly one instance records this conversion.
|
|
159
|
+
const claimed = await claimConversionLock(siteId, sessionId, goal.id)
|
|
160
|
+
if (!claimed) {
|
|
161
|
+
markConverted(siteId, sessionId, goal.id)
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
// Record conversion
|
|
165
|
+
await Conversion.record({
|
|
166
|
+
id: generateId(),
|
|
167
|
+
siteId,
|
|
168
|
+
goalId: goal.id,
|
|
169
|
+
visitorId,
|
|
170
|
+
sessionId,
|
|
171
|
+
// Use the event's actual value for variable-price goals; fall back to
|
|
172
|
+
// the goal's configured static value otherwise (#132).
|
|
173
|
+
value: context.eventValue ?? goal.value,
|
|
174
|
+
path: context.path,
|
|
175
|
+
referrerSource: metadata.referrerSource,
|
|
176
|
+
utmSource: metadata.utmSource,
|
|
177
|
+
utmMedium: metadata.utmMedium,
|
|
178
|
+
utmCampaign: metadata.utmCampaign,
|
|
179
|
+
timestamp,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
markConverted(siteId, sessionId, goal.id)
|
|
183
|
+
console.log(`[Goals] Conversion recorded: ${goal.name} for session ${sessionId}`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
console.error('[Goals] Error checking conversions:', err)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Calculate conversion rate
|
|
194
|
+
*/
|
|
195
|
+
export function calculateConversionRate(conversions: number, totalVisitors: number): number {
|
|
196
|
+
if (totalVisitors === 0) return 0
|
|
197
|
+
return (conversions / totalVisitors) * 100
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Format conversion rate for display
|
|
202
|
+
*/
|
|
203
|
+
export function formatConversionRate(rate: number): string {
|
|
204
|
+
return `${rate.toFixed(2)}%`
|
|
205
|
+
}
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-site hourly ingest counters (#175).
|
|
3
|
+
*
|
|
4
|
+
* Every beacon outcome — collected or dropped, and WHY — increments an
|
|
5
|
+
* in-process buffer that flushes to one item per site per hour
|
|
6
|
+
* (sk = INGEST#YYYY-MM-DDTHH) via atomic ADDs. This is what makes silent
|
|
7
|
+
* ingest failures visible: the CORS bug shipped twice with nothing to detect
|
|
8
|
+
* it because drops looked identical to no-traffic.
|
|
9
|
+
*
|
|
10
|
+
* Buffered (not per-event writes) so counting adds no per-beacon latency and
|
|
11
|
+
* at most one WCU per site-hour-flush. A dying instance loses at most the
|
|
12
|
+
* last FLUSH_MS of counts — acceptable for an ops signal.
|
|
13
|
+
*/
|
|
14
|
+
import { dynamodb, TABLE_NAME } from './dynamodb'
|
|
15
|
+
import { log } from './log'
|
|
16
|
+
|
|
17
|
+
export type IngestOutcome
|
|
18
|
+
= 'collected' | 'bot' | 'firewall' | 'dedup' | 'invalid'
|
|
19
|
+
| 'rate_limited' | 'excluded' | 'quota'
|
|
20
|
+
|
|
21
|
+
const FLUSH_MS = 15_000
|
|
22
|
+
const MAX_BUFFERED_KEYS = 500
|
|
23
|
+
const COUNTER_TTL_S = 90 * 24 * 60 * 60
|
|
24
|
+
|
|
25
|
+
// site|hour -> field -> pending increment
|
|
26
|
+
const buffer = new Map<string, Map<string, number>>()
|
|
27
|
+
let flushTimer: ReturnType<typeof setInterval> | null = null
|
|
28
|
+
let flushing = false
|
|
29
|
+
|
|
30
|
+
function hourKey(now: Date = new Date()): string {
|
|
31
|
+
return now.toISOString().slice(0, 13)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Sanitize a tracker version into a counter field name (v_1_2_3). */
|
|
35
|
+
function versionField(version: string): string {
|
|
36
|
+
return `v_${version.replace(/[^\w.-]/g, '').replace(/[.-]/g, '_').slice(0, 24)}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Count one beacon outcome for a site (buffered; flushes on a timer). */
|
|
40
|
+
export function recordIngest(siteId: string, outcome: IngestOutcome, trackerVersion?: string): void {
|
|
41
|
+
if (!siteId)
|
|
42
|
+
return
|
|
43
|
+
const key = `${siteId}|${hourKey()}`
|
|
44
|
+
let fields = buffer.get(key)
|
|
45
|
+
if (!fields) {
|
|
46
|
+
fields = new Map()
|
|
47
|
+
buffer.set(key, fields)
|
|
48
|
+
}
|
|
49
|
+
fields.set(outcome, (fields.get(outcome) || 0) + 1)
|
|
50
|
+
if (outcome === 'collected' && trackerVersion)
|
|
51
|
+
fields.set(versionField(trackerVersion), (fields.get(versionField(trackerVersion)) || 0) + 1)
|
|
52
|
+
|
|
53
|
+
if (!flushTimer) {
|
|
54
|
+
flushTimer = setInterval(() => { void flushIngestCounters() }, FLUSH_MS)
|
|
55
|
+
// Never keep the process alive just to flush counters.
|
|
56
|
+
if (typeof (flushTimer as any).unref === 'function')
|
|
57
|
+
(flushTimer as any).unref()
|
|
58
|
+
}
|
|
59
|
+
if (buffer.size > MAX_BUFFERED_KEYS)
|
|
60
|
+
void flushIngestCounters()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Flush buffered counts to DynamoDB (atomic ADDs). Exported for tests/tick. */
|
|
64
|
+
export async function flushIngestCounters(): Promise<number> {
|
|
65
|
+
if (flushing || buffer.size === 0)
|
|
66
|
+
return 0
|
|
67
|
+
flushing = true
|
|
68
|
+
const entries = [...buffer.entries()]
|
|
69
|
+
buffer.clear()
|
|
70
|
+
let flushed = 0
|
|
71
|
+
try {
|
|
72
|
+
for (const [key, fields] of entries) {
|
|
73
|
+
const [siteId, hour] = key.split('|')
|
|
74
|
+
const names: Record<string, string> = {}
|
|
75
|
+
const values: Record<string, unknown> = {}
|
|
76
|
+
const adds: string[] = []
|
|
77
|
+
let i = 0
|
|
78
|
+
for (const [field, n] of fields) {
|
|
79
|
+
names[`#f${i}`] = field
|
|
80
|
+
values[`:f${i}`] = { N: String(n) }
|
|
81
|
+
adds.push(`#f${i} :f${i}`)
|
|
82
|
+
i++
|
|
83
|
+
}
|
|
84
|
+
names['#ttl'] = 'ttl'
|
|
85
|
+
values[':ttl'] = { N: String(Math.floor(Date.now() / 1000) + COUNTER_TTL_S) }
|
|
86
|
+
await dynamodb.updateItem({
|
|
87
|
+
TableName: TABLE_NAME,
|
|
88
|
+
Key: { pk: { S: `SITE#${siteId}` }, sk: { S: `INGEST#${hour}` } },
|
|
89
|
+
UpdateExpression: `ADD ${adds.join(', ')} SET #ttl = if_not_exists(#ttl, :ttl)`,
|
|
90
|
+
ExpressionAttributeNames: names,
|
|
91
|
+
ExpressionAttributeValues: values,
|
|
92
|
+
})
|
|
93
|
+
flushed++
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
log.warn('ingest_counters.flush_failed', { error: (e as Error).message })
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
flushing = false
|
|
101
|
+
}
|
|
102
|
+
return flushed
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Hourly counter rows for a site over the trailing `hours` (default 48). */
|
|
106
|
+
export async function readIngestCounters(siteId: string, hours = 48): Promise<Array<Record<string, unknown>>> {
|
|
107
|
+
const { queryAllItems, unmarshall } = await import('./dynamodb')
|
|
108
|
+
const start = new Date(Date.now() - hours * 60 * 60 * 1000)
|
|
109
|
+
const res = await queryAllItems({
|
|
110
|
+
TableName: TABLE_NAME,
|
|
111
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
112
|
+
ExpressionAttributeValues: {
|
|
113
|
+
':pk': { S: `SITE#${siteId}` },
|
|
114
|
+
':start': { S: `INGEST#${hourKey(start)}` },
|
|
115
|
+
':end': { S: `INGEST#${hourKey()}~` },
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
return (res.Items || []).map(unmarshall).map((it: any) => {
|
|
119
|
+
// eslint-disable-next-line pickier/no-unused-vars -- pk/ttl destructured to exclude them from the rest spread
|
|
120
|
+
const { pk, sk, ttl, ...counts } = it
|
|
121
|
+
return { hour: String(sk).slice('INGEST#'.length), ...counts }
|
|
122
|
+
})
|
|
123
|
+
}
|
package/src/lib/log.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leveled logger (#175). JSON lines in production (parseable by CloudWatch /
|
|
3
|
+
* any log shipper), human-readable in dev. `debug` is emitted only when
|
|
4
|
+
* ANALYTICS_DEBUG=true. Use instead of bare console.* in operational paths so
|
|
5
|
+
* failures are queryable (level, event, fields) rather than prose.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
type Fields = Record<string, unknown>
|
|
9
|
+
|
|
10
|
+
const JSON_LOGS: boolean = process.env.LOG_FORMAT === 'json'
|
|
11
|
+
|| (process.env.NODE_ENV === 'production' && process.env.LOG_FORMAT !== 'pretty')
|
|
12
|
+
|
|
13
|
+
function emit(level: 'debug' | 'info' | 'warn' | 'error', event: string, fields?: Fields): void {
|
|
14
|
+
if (level === 'debug' && process.env.ANALYTICS_DEBUG !== 'true')
|
|
15
|
+
return
|
|
16
|
+
if (JSON_LOGS) {
|
|
17
|
+
// eslint-disable-next-line no-console
|
|
18
|
+
console[level === 'debug' ? 'log' : level](JSON.stringify({ ts: new Date().toISOString(), level, event, ...fields }))
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
const suffix = fields && Object.keys(fields).length > 0 ? ` ${JSON.stringify(fields)}` : ''
|
|
22
|
+
// eslint-disable-next-line no-console
|
|
23
|
+
console[level === 'debug' ? 'log' : level](`[${event}]${suffix}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const log: {
|
|
27
|
+
debug: (event: string, fields?: Fields) => void
|
|
28
|
+
info: (event: string, fields?: Fields) => void
|
|
29
|
+
warn: (event: string, fields?: Fields) => void
|
|
30
|
+
error: (event: string, fields?: Fields) => void
|
|
31
|
+
} = {
|
|
32
|
+
debug: (event, fields) => emit('debug', event, fields),
|
|
33
|
+
info: (event, fields) => emit('info', event, fields),
|
|
34
|
+
warn: (event, fields) => emit('warn', event, fields),
|
|
35
|
+
error: (event, fields) => emit('error', event, fields),
|
|
36
|
+
}
|