@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.
Files changed (237) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +361 -0
  4. package/bin/cli.ts +169 -0
  5. package/dist/Analytics.d.ts +558 -0
  6. package/dist/api.d.ts +109 -0
  7. package/dist/batching.d.ts +93 -0
  8. package/dist/chunk-2mx7fq49.js +4 -0
  9. package/dist/chunk-3z29508k.js +204 -0
  10. package/dist/chunk-deephkz6.js +56 -0
  11. package/dist/chunk-j261vgyp.js +305 -0
  12. package/dist/chunk-xga17tz7.js +207 -0
  13. package/dist/config.d.ts +132 -0
  14. package/dist/dashboard/components/index.d.ts +80 -0
  15. package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
  16. package/dist/dashboard/index.d.ts +110 -0
  17. package/dist/dashboard/types/index.d.ts +144 -0
  18. package/dist/dashboard/utils/index.d.ts +81 -0
  19. package/dist/dynamodb.d.ts +86 -0
  20. package/dist/funnels.d.ts +104 -0
  21. package/dist/geolocation.d.ts +96 -0
  22. package/dist/index.d.ts +371 -0
  23. package/dist/index.js +10211 -0
  24. package/dist/infrastructure/cdk.d.ts +60 -0
  25. package/dist/infrastructure/cloudformation.d.ts +44 -0
  26. package/dist/infrastructure/index.d.ts +45 -0
  27. package/dist/infrastructure/setup.d.ts +133 -0
  28. package/dist/integrations/cloudflare.d.ts +76 -0
  29. package/dist/integrations/hono.d.ts +60 -0
  30. package/dist/integrations/index.d.ts +24 -0
  31. package/dist/integrations/nuxt.d.ts +7 -0
  32. package/dist/integrations/nuxt.js +42 -0
  33. package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
  34. package/dist/integrations/runtime/use-ts-analytics.js +17 -0
  35. package/dist/integrations/stx.d.ts +88 -0
  36. package/dist/integrations/stx.js +18 -0
  37. package/dist/lib/crypto-random.d.ts +4 -0
  38. package/dist/lib/salt.d.ts +16 -0
  39. package/dist/local.d.ts +56 -0
  40. package/dist/model-connector.d.ts +145 -0
  41. package/dist/models/AggregatedStats.d.ts +9 -0
  42. package/dist/models/CampaignStats.d.ts +9 -0
  43. package/dist/models/Conversion.d.ts +11 -0
  44. package/dist/models/CustomEvent.d.ts +11 -0
  45. package/dist/models/DeviceStats.d.ts +9 -0
  46. package/dist/models/EventStats.d.ts +9 -0
  47. package/dist/models/GeoStats.d.ts +9 -0
  48. package/dist/models/Goal.d.ts +9 -0
  49. package/dist/models/GoalStats.d.ts +9 -0
  50. package/dist/models/PageStats.d.ts +11 -0
  51. package/dist/models/PageView.d.ts +13 -0
  52. package/dist/models/RealtimeStats.d.ts +9 -0
  53. package/dist/models/ReferrerStats.d.ts +9 -0
  54. package/dist/models/Session.d.ts +11 -0
  55. package/dist/models/Site.d.ts +11 -0
  56. package/dist/models/index.d.ts +28 -0
  57. package/dist/models/types.d.ts +60 -0
  58. package/dist/sqs-buffering.d.ts +243 -0
  59. package/dist/stacks-integration.d.ts +159 -0
  60. package/dist/tracking-script.d.ts +71 -0
  61. package/dist/tracking.d.ts +17 -0
  62. package/dist/tracking.js +26 -0
  63. package/dist/types.d.ts +595 -0
  64. package/dist/utils/geolocation.d.ts +111 -0
  65. package/dist/utils/user-agent.d.ts +17 -0
  66. package/dist/version.d.ts +7 -0
  67. package/package.json +119 -0
  68. package/src/Analytics.ts +3349 -0
  69. package/src/api.ts +1286 -0
  70. package/src/assets/crosswind.css +2220 -0
  71. package/src/batching.ts +452 -0
  72. package/src/components/dashboard/index.ts +18 -0
  73. package/src/config.ts +456 -0
  74. package/src/dashboard/Dashboard.stx +1517 -0
  75. package/src/dashboard/components/AlertCard.stx +177 -0
  76. package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
  77. package/src/dashboard/components/AnimatedNumber.stx +86 -0
  78. package/src/dashboard/components/BarChart.stx +220 -0
  79. package/src/dashboard/components/BrowserBreakdown.stx +98 -0
  80. package/src/dashboard/components/BrowsersTable.stx +125 -0
  81. package/src/dashboard/components/CampaignBreakdown.stx +163 -0
  82. package/src/dashboard/components/CampaignTable.stx +238 -0
  83. package/src/dashboard/components/CountryList.stx +101 -0
  84. package/src/dashboard/components/DataTable.stx +226 -0
  85. package/src/dashboard/components/DateRangePicker.stx +77 -0
  86. package/src/dashboard/components/DeviceBreakdown.stx +94 -0
  87. package/src/dashboard/components/DevicesTable.stx +163 -0
  88. package/src/dashboard/components/DonutChart.stories.ts +55 -0
  89. package/src/dashboard/components/DonutChart.stx +157 -0
  90. package/src/dashboard/components/EmptyState.stx +90 -0
  91. package/src/dashboard/components/EngagementMetrics.stx +183 -0
  92. package/src/dashboard/components/EventsSection.stx +192 -0
  93. package/src/dashboard/components/FilterBar.stx +104 -0
  94. package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
  95. package/src/dashboard/components/FunnelChart.stx +142 -0
  96. package/src/dashboard/components/GeoTable.stx +337 -0
  97. package/src/dashboard/components/GoalsPanel.stx +109 -0
  98. package/src/dashboard/components/Header.stx +39 -0
  99. package/src/dashboard/components/HeatmapChart.stx +140 -0
  100. package/src/dashboard/components/LiveActivityFeed.stx +172 -0
  101. package/src/dashboard/components/MetricComparison.stx +106 -0
  102. package/src/dashboard/components/MiniStats.stx +96 -0
  103. package/src/dashboard/components/OSBreakdown.stx +124 -0
  104. package/src/dashboard/components/PageDetailCard.stx +102 -0
  105. package/src/dashboard/components/PagesTable.stx +127 -0
  106. package/src/dashboard/components/ProgressRing.stx +89 -0
  107. package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
  108. package/src/dashboard/components/RealtimeCounter.stx +46 -0
  109. package/src/dashboard/components/ReferrersTable.stx +180 -0
  110. package/src/dashboard/components/SparklineChart.stx +160 -0
  111. package/src/dashboard/components/StatCard.stories.ts +58 -0
  112. package/src/dashboard/components/StatCard.stx +90 -0
  113. package/src/dashboard/components/SummaryStats.stx +81 -0
  114. package/src/dashboard/components/TabNav.stx +66 -0
  115. package/src/dashboard/components/ThemeSwitcher.stx +124 -0
  116. package/src/dashboard/components/TimeSeriesChart.stx +106 -0
  117. package/src/dashboard/components/TopList.stories.ts +58 -0
  118. package/src/dashboard/components/TopList.stx +74 -0
  119. package/src/dashboard/components/TrendIndicator.stx +84 -0
  120. package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
  121. package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
  122. package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
  123. package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
  124. package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
  125. package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
  126. package/src/dashboard/components/heatmap/index.ts +12 -0
  127. package/src/dashboard/components/index.ts +86 -0
  128. package/src/dashboard/composables/useAnalytics.ts +465 -0
  129. package/src/dashboard/demo/DemoApp.stx +370 -0
  130. package/src/dashboard/demo/index.ts +8 -0
  131. package/src/dashboard/demo/mockData.ts +234 -0
  132. package/src/dashboard/index.ts +117 -0
  133. package/src/dashboard/stx-shim.d.ts +4 -0
  134. package/src/dashboard/types/index.ts +203 -0
  135. package/src/dashboard/utils/index.ts +426 -0
  136. package/src/dynamodb.ts +344 -0
  137. package/src/funnels.ts +534 -0
  138. package/src/geolocation.ts +515 -0
  139. package/src/handlers/alerts.ts +328 -0
  140. package/src/handlers/annotations.ts +128 -0
  141. package/src/handlers/api-keys.ts +240 -0
  142. package/src/handlers/auth.ts +1020 -0
  143. package/src/handlers/authz.ts +137 -0
  144. package/src/handlers/collect.ts +724 -0
  145. package/src/handlers/data.ts +625 -0
  146. package/src/handlers/experiments.ts +138 -0
  147. package/src/handlers/funnels.ts +216 -0
  148. package/src/handlers/goals.ts +218 -0
  149. package/src/handlers/heatmaps.ts +272 -0
  150. package/src/handlers/index.ts +56 -0
  151. package/src/handlers/lib/read-cache.ts +63 -0
  152. package/src/handlers/misc.ts +486 -0
  153. package/src/handlers/oauth.ts +233 -0
  154. package/src/handlers/performance.ts +388 -0
  155. package/src/handlers/sessions.ts +413 -0
  156. package/src/handlers/sharing.ts +198 -0
  157. package/src/handlers/stats.ts +1368 -0
  158. package/src/handlers/team.ts +161 -0
  159. package/src/handlers/uptime.ts +283 -0
  160. package/src/handlers/views.ts +390 -0
  161. package/src/handlers/webhooks.ts +226 -0
  162. package/src/heatmap/index.ts +32 -0
  163. package/src/heatmap/tracking-script.ts +452 -0
  164. package/src/heatmap/types.ts +79 -0
  165. package/src/index.ts +387 -0
  166. package/src/infrastructure/cdk.ts +496 -0
  167. package/src/infrastructure/cloudformation.ts +595 -0
  168. package/src/infrastructure/index.ts +48 -0
  169. package/src/infrastructure/setup.ts +611 -0
  170. package/src/integrations/cloudflare.ts +732 -0
  171. package/src/integrations/hono.ts +589 -0
  172. package/src/integrations/index.ts +27 -0
  173. package/src/integrations/nuxt.ts +78 -0
  174. package/src/integrations/runtime/use-ts-analytics.ts +32 -0
  175. package/src/integrations/stx.ts +138 -0
  176. package/src/jobs/index.ts +127 -0
  177. package/src/lib/crypto-random.ts +41 -0
  178. package/src/lib/ddb-errors.ts +20 -0
  179. package/src/lib/dynamodb.ts +216 -0
  180. package/src/lib/email.ts +38 -0
  181. package/src/lib/ga-import.ts +471 -0
  182. package/src/lib/ga4-api.ts +244 -0
  183. package/src/lib/goals.ts +205 -0
  184. package/src/lib/index.ts +6 -0
  185. package/src/lib/ingest-counters.ts +123 -0
  186. package/src/lib/log.ts +36 -0
  187. package/src/lib/membership.ts +98 -0
  188. package/src/lib/plans.ts +90 -0
  189. package/src/lib/quota.ts +46 -0
  190. package/src/lib/rate-limit.ts +27 -0
  191. package/src/lib/rollups.ts +472 -0
  192. package/src/lib/salt.ts +97 -0
  193. package/src/lib/scheduler.ts +97 -0
  194. package/src/lib/significance.ts +66 -0
  195. package/src/lib/site-retention.ts +56 -0
  196. package/src/local.ts +360 -0
  197. package/src/model-connector.ts +616 -0
  198. package/src/models/AggregatedStats.ts +162 -0
  199. package/src/models/CampaignStats.ts +141 -0
  200. package/src/models/Conversion.ts +135 -0
  201. package/src/models/CustomEvent.ts +123 -0
  202. package/src/models/DeviceStats.ts +105 -0
  203. package/src/models/EventStats.ts +108 -0
  204. package/src/models/GeoStats.ts +112 -0
  205. package/src/models/Goal.ts +105 -0
  206. package/src/models/GoalStats.ts +106 -0
  207. package/src/models/PageStats.ts +152 -0
  208. package/src/models/PageView.ts +277 -0
  209. package/src/models/RealtimeStats.ts +96 -0
  210. package/src/models/ReferrerStats.ts +115 -0
  211. package/src/models/Session.ts +235 -0
  212. package/src/models/Site.ts +115 -0
  213. package/src/models/index.ts +50 -0
  214. package/src/models/orm/index.ts +2043 -0
  215. package/src/models/types.ts +71 -0
  216. package/src/router.ts +521 -0
  217. package/src/sqs-buffering.ts +806 -0
  218. package/src/stacks-integration.ts +527 -0
  219. package/src/tracking-script.ts +944 -0
  220. package/src/tracking.ts +27 -0
  221. package/src/types/analytics.ts +219 -0
  222. package/src/types/api.ts +75 -0
  223. package/src/types/bun-router.d.ts +15 -0
  224. package/src/types/dashboard.ts +62 -0
  225. package/src/types/index.ts +66 -0
  226. package/src/types/stx.d.ts +27 -0
  227. package/src/types/window.d.ts +101 -0
  228. package/src/types.ts +911 -0
  229. package/src/utils/cache.ts +148 -0
  230. package/src/utils/date.ts +118 -0
  231. package/src/utils/filters.ts +71 -0
  232. package/src/utils/geolocation.ts +323 -0
  233. package/src/utils/index.ts +9 -0
  234. package/src/utils/response.ts +180 -0
  235. package/src/utils/timezone-country.ts +242 -0
  236. package/src/utils/user-agent.ts +110 -0
  237. 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
+ }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Lib barrel export
3
+ */
4
+
5
+ export * from './dynamodb'
6
+ export * from './goals'
@@ -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
+ }