@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,148 @@
1
+ /**
2
+ * In-memory caching utilities for Lambda
3
+ *
4
+ * Note: These caches reset on cold start and are not shared across Lambda instances.
5
+ * They're designed to reduce database queries within a single request or warm instance.
6
+ */
7
+
8
+ import type { Session } from '../../src/types'
9
+ import type { Goal } from '../../src/models/orm'
10
+
11
+ // Session cache
12
+ const sessionCache = new Map<string, { session: Session; expires: number }>()
13
+
14
+ /**
15
+ * Get a session from cache
16
+ */
17
+ export function getSession(key: string): Session | null {
18
+ const cached = sessionCache.get(key)
19
+ if (cached && cached.expires > Date.now()) {
20
+ return cached.session
21
+ }
22
+ sessionCache.delete(key)
23
+ return null
24
+ }
25
+
26
+ /**
27
+ * Store a session in cache
28
+ */
29
+ export function setSession(key: string, session: Session, ttlSeconds = 1800): void {
30
+ sessionCache.set(key, {
31
+ session,
32
+ expires: Date.now() + ttlSeconds * 1000,
33
+ })
34
+ }
35
+
36
+ /**
37
+ * Delete a session from cache
38
+ */
39
+ export function deleteSession(key: string): void {
40
+ sessionCache.delete(key)
41
+ }
42
+
43
+ // Goal cache - stores goals per site for fast lookup during collect
44
+ const goalCache = new Map<string, { goals: Goal[]; expires: number }>()
45
+ const GOAL_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
46
+
47
+ /**
48
+ * Get cached goals for a site
49
+ */
50
+ export function getCachedGoals(siteId: string): Goal[] | null {
51
+ const cached = goalCache.get(siteId)
52
+ if (cached && cached.expires > Date.now()) {
53
+ return cached.goals
54
+ }
55
+ goalCache.delete(siteId)
56
+ return null
57
+ }
58
+
59
+ /**
60
+ * Store goals in cache
61
+ */
62
+ export function setCachedGoals(siteId: string, goals: Goal[]): void {
63
+ goalCache.set(siteId, {
64
+ goals,
65
+ expires: Date.now() + GOAL_CACHE_TTL,
66
+ })
67
+ }
68
+
69
+ /**
70
+ * Invalidate goal cache for a site
71
+ */
72
+ export function invalidateGoalCache(siteId: string): void {
73
+ goalCache.delete(siteId)
74
+ }
75
+
76
+ // Session conversion deduplication - prevents same goal from converting multiple times per session
77
+ const sessionConversions = new Map<string, Set<string>>()
78
+
79
+ /**
80
+ * Check if a goal has already been converted in this session
81
+ */
82
+ export function hasConverted(siteId: string, sessionId: string, goalId: string): boolean {
83
+ const key = `${siteId}:${sessionId}`
84
+ const converted = sessionConversions.get(key)
85
+ return converted?.has(goalId) ?? false
86
+ }
87
+
88
+ /**
89
+ * Mark a goal as converted for this session
90
+ */
91
+ export function markConverted(siteId: string, sessionId: string, goalId: string): void {
92
+ const key = `${siteId}:${sessionId}`
93
+ let converted = sessionConversions.get(key)
94
+ if (!converted) {
95
+ converted = new Set<string>()
96
+ sessionConversions.set(key, converted)
97
+ }
98
+ converted.add(goalId)
99
+
100
+ // Clean up old session conversion entries (keep last 1000)
101
+ if (sessionConversions.size > 1000) {
102
+ const keysToDelete = Array.from(sessionConversions.keys()).slice(0, 100)
103
+ keysToDelete.forEach(k => sessionConversions.delete(k))
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Get all converted goals for a session
109
+ */
110
+ export function getConvertedGoals(siteId: string, sessionId: string): Set<string> {
111
+ const key = `${siteId}:${sessionId}`
112
+ return sessionConversions.get(key) || new Set()
113
+ }
114
+
115
+ // Generic cache for any data
116
+ const genericCache = new Map<string, { data: unknown; expires: number }>()
117
+
118
+ /**
119
+ * Get data from generic cache
120
+ */
121
+ export function getFromCache<T>(key: string): T | null {
122
+ const cached = genericCache.get(key)
123
+ if (cached && cached.expires > Date.now()) {
124
+ return cached.data as T
125
+ }
126
+ genericCache.delete(key)
127
+ return null
128
+ }
129
+
130
+ /**
131
+ * Store data in generic cache
132
+ */
133
+ export function setInCache<T>(key: string, data: T, ttlMs: number): void {
134
+ genericCache.set(key, {
135
+ data,
136
+ expires: Date.now() + ttlMs,
137
+ })
138
+ }
139
+
140
+ /**
141
+ * Clear all caches (useful for testing)
142
+ */
143
+ export function clearAllCaches(): void {
144
+ sessionCache.clear()
145
+ goalCache.clear()
146
+ sessionConversions.clear()
147
+ genericCache.clear()
148
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Date utilities for the analytics API
3
+ */
4
+
5
+ /**
6
+ * Parse date range from query parameters
7
+ */
8
+ export function parseDateRange(query: Record<string, string> | undefined): { startDate: Date; endDate: Date } {
9
+ const now = new Date()
10
+ const endDate = query?.endDate ? new Date(query.endDate) : now
11
+ let startDate: Date
12
+
13
+ if (query?.startDate) {
14
+ startDate = new Date(query.startDate)
15
+ }
16
+ else {
17
+ // Default to last 30 days
18
+ startDate = new Date(now)
19
+ startDate.setDate(startDate.getDate() - 30)
20
+ }
21
+
22
+ return { startDate, endDate }
23
+ }
24
+
25
+ /**
26
+ * Parse date range from dateRange string (e.g., '6h', '24h', '7d', '30d')
27
+ */
28
+ export function parseDateRangeString(dateRange: string | undefined): { startDate: Date; endDate: Date } {
29
+ const now = new Date()
30
+ const endDate = now
31
+ const startDate = new Date(now)
32
+
33
+ const range = dateRange || '30d'
34
+ const value = parseInt(range.slice(0, -1))
35
+ const unit = range.slice(-1)
36
+
37
+ switch (unit) {
38
+ case 'h':
39
+ startDate.setHours(startDate.getHours() - value)
40
+ break
41
+ case 'd':
42
+ startDate.setDate(startDate.getDate() - value)
43
+ break
44
+ case 'w':
45
+ startDate.setDate(startDate.getDate() - value * 7)
46
+ break
47
+ case 'm':
48
+ startDate.setMonth(startDate.getMonth() - value)
49
+ break
50
+ case 'y':
51
+ startDate.setFullYear(startDate.getFullYear() - value)
52
+ break
53
+ default:
54
+ startDate.setDate(startDate.getDate() - 30)
55
+ }
56
+
57
+ return { startDate, endDate }
58
+ }
59
+
60
+ /**
61
+ * Format duration in mm:ss format
62
+ */
63
+ export function formatDuration(ms: number): string {
64
+ const totalSeconds = Math.floor(ms / 1000)
65
+ const minutes = Math.floor(totalSeconds / 60)
66
+ const seconds = totalSeconds % 60
67
+ return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
68
+ }
69
+
70
+ /**
71
+ * Format duration in human readable format
72
+ */
73
+ export function formatDurationHuman(ms: number): string {
74
+ const totalSeconds = Math.floor(ms / 1000)
75
+ if (totalSeconds < 60) return `${totalSeconds}s`
76
+ const minutes = Math.floor(totalSeconds / 60)
77
+ if (minutes < 60) return `${minutes}m ${totalSeconds % 60}s`
78
+ const hours = Math.floor(minutes / 60)
79
+ return `${hours}h ${minutes % 60}m`
80
+ }
81
+
82
+ /**
83
+ * Get ISO date string for a date (YYYY-MM-DD)
84
+ */
85
+ export function toISODateString(date: Date): string {
86
+ return date.toISOString().split('T')[0]
87
+ }
88
+
89
+ /**
90
+ * Get start of day for a date
91
+ */
92
+ export function startOfDay(date: Date): Date {
93
+ const d = new Date(date)
94
+ d.setHours(0, 0, 0, 0)
95
+ return d
96
+ }
97
+
98
+ /**
99
+ * Get end of day for a date
100
+ */
101
+ export function endOfDay(date: Date): Date {
102
+ const d = new Date(date)
103
+ d.setHours(23, 59, 59, 999)
104
+ return d
105
+ }
106
+
107
+ /**
108
+ * Get the appropriate time interval for aggregation based on date range
109
+ */
110
+ export function getTimeInterval(startDate: Date, endDate: Date): 'hour' | 'day' | 'week' | 'month' {
111
+ const diffMs = endDate.getTime() - startDate.getTime()
112
+ const diffDays = diffMs / (1000 * 60 * 60 * 24)
113
+
114
+ if (diffDays <= 2) return 'hour'
115
+ if (diffDays <= 60) return 'day'
116
+ if (diffDays <= 180) return 'week'
117
+ return 'month'
118
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared dashboard filter layer.
3
+ *
4
+ * Parses the common filter dimensions from a stats request's query params and
5
+ * applies them uniformly to session/pageview records, so every report can be
6
+ * sliced by browser/device/OS/country/referrer/channel/UTM — not just country.
7
+ */
8
+
9
+ import { getReferrerSourceChannel } from './geolocation'
10
+
11
+ export interface StatFilters {
12
+ country?: string
13
+ region?: string
14
+ city?: string
15
+ /** Maps to the record's deviceType */
16
+ device?: string
17
+ browser?: string
18
+ os?: string
19
+ /** Maps to the record's referrerSource */
20
+ referrerSource?: string
21
+ /** Acquisition channel (derived from referrerSource at match time) */
22
+ channel?: string
23
+ utmSource?: string
24
+ utmMedium?: string
25
+ utmCampaign?: string
26
+ }
27
+
28
+ /**
29
+ * Extract the supported filter dimensions from query params. Accepts both the
30
+ * dashboard's short names (device, referrer) and the canonical UTM names.
31
+ */
32
+ export function parseFilters(query: Record<string, string>): StatFilters {
33
+ return {
34
+ country: query.country || undefined,
35
+ region: query.region || undefined,
36
+ city: query.city || undefined,
37
+ device: query.device || undefined,
38
+ browser: query.browser || undefined,
39
+ os: query.os || undefined,
40
+ referrerSource: query.referrer || query.referrerSource || undefined,
41
+ channel: query.channel || undefined,
42
+ utmSource: query.utm_source || query.utmSource || undefined,
43
+ utmMedium: query.utm_medium || query.utmMedium || undefined,
44
+ utmCampaign: query.utm_campaign || query.utmCampaign || undefined,
45
+ }
46
+ }
47
+
48
+ /** Whether any filter dimension is active. */
49
+ export function hasFilters(filters: StatFilters): boolean {
50
+ return Object.values(filters).some(v => v !== undefined && v !== '')
51
+ }
52
+
53
+ /**
54
+ * Whether a session/pageview record matches every active filter. Records expose
55
+ * the filtered fields directly (deviceType, browser, country, referrerSource,
56
+ * utm*), except `channel`, which is derived from referrerSource.
57
+ */
58
+ export function matchesFilters(item: Record<string, any>, filters: StatFilters): boolean {
59
+ if (filters.country && item.country !== filters.country) return false
60
+ if (filters.region && item.region !== filters.region) return false
61
+ if (filters.city && item.city !== filters.city) return false
62
+ if (filters.device && item.deviceType !== filters.device) return false
63
+ if (filters.browser && item.browser !== filters.browser) return false
64
+ if (filters.os && item.os !== filters.os) return false
65
+ if (filters.referrerSource && item.referrerSource !== filters.referrerSource) return false
66
+ if (filters.channel && getReferrerSourceChannel(item.referrerSource) !== filters.channel) return false
67
+ if (filters.utmSource && item.utmSource !== filters.utmSource) return false
68
+ if (filters.utmMedium && item.utmMedium !== filters.utmMedium) return false
69
+ if (filters.utmCampaign && item.utmCampaign !== filters.utmCampaign) return false
70
+ return true
71
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Geolocation utilities
3
+ */
4
+
5
+ // Country code to name mapping
6
+ export const COUNTRY_NAMES: Record<string, string> = {
7
+ US: 'United States', GB: 'United Kingdom', CA: 'Canada', AU: 'Australia',
8
+ DE: 'Germany', FR: 'France', JP: 'Japan', CN: 'China', IN: 'India',
9
+ BR: 'Brazil', MX: 'Mexico', ES: 'Spain', IT: 'Italy', NL: 'Netherlands',
10
+ SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', CH: 'Switzerland',
11
+ AT: 'Austria', BE: 'Belgium', PL: 'Poland', RU: 'Russia', KR: 'South Korea',
12
+ SG: 'Singapore', HK: 'Hong Kong', TW: 'Taiwan', NZ: 'New Zealand',
13
+ IE: 'Ireland', PT: 'Portugal', CZ: 'Czech Republic', GR: 'Greece',
14
+ IL: 'Israel', ZA: 'South Africa', AR: 'Argentina', CL: 'Chile',
15
+ CO: 'Colombia', PH: 'Philippines', TH: 'Thailand', MY: 'Malaysia',
16
+ ID: 'Indonesia', VN: 'Vietnam', AE: 'UAE', SA: 'Saudi Arabia',
17
+ TR: 'Turkey', UA: 'Ukraine', RO: 'Romania', HU: 'Hungary',
18
+ }
19
+
20
+ // Reverse lookup: display name → ISO code (countries are stored by display
21
+ // name when COUNTRY_NAMES knows them, bare ISO code otherwise).
22
+ const NAME_TO_CODE: Record<string, string> = Object.fromEntries(
23
+ Object.entries(COUNTRY_NAMES).map(([code, name]) => [name, code]),
24
+ )
25
+
26
+ /** ISO-3166 alpha-2 code for a stored country value (name or code). */
27
+ export function countryCodeOf(nameOrCode: string): string | undefined {
28
+ if (/^[A-Z]{2}$/i.test(nameOrCode))
29
+ return nameOrCode.toUpperCase()
30
+ return NAME_TO_CODE[nameOrCode]
31
+ }
32
+
33
+ /** Emoji flag (regional indicator pair) for an ISO alpha-2 code — Fathom-style. */
34
+ export function countryFlagEmoji(code: string): string {
35
+ if (!/^[A-Z]{2}$/i.test(code))
36
+ return ''
37
+ const cc = code.toUpperCase()
38
+ return String.fromCodePoint(0x1F1E6 + cc.charCodeAt(0) - 65, 0x1F1E6 + cc.charCodeAt(1) - 65)
39
+ }
40
+
41
+ // IP geolocation cache (in-memory, resets on cold start)
42
+ const ipGeoCache = new Map<string, { country: string; expires: number }>()
43
+
44
+ /**
45
+ * Get country from CloudFront/Cloudflare headers
46
+ */
47
+ export function getCountryFromHeaders(headers: Record<string, string> | undefined): string | undefined {
48
+ if (!headers) return undefined
49
+
50
+ // CloudFront provides country code in these headers
51
+ const countryCode = headers['cloudfront-viewer-country']
52
+ || headers['CloudFront-Viewer-Country']
53
+ || headers['x-country-code']
54
+ || headers['cf-ipcountry'] // Cloudflare
55
+
56
+ if (countryCode && countryCode !== 'XX') {
57
+ return COUNTRY_NAMES[countryCode.toUpperCase()] || countryCode.toUpperCase()
58
+ }
59
+
60
+ return undefined
61
+ }
62
+
63
+ /**
64
+ * Get country from IP address using geolocation services
65
+ */
66
+ /**
67
+ * Reduce an IP's precision before it leaves the system (e.g. to a third-party
68
+ * geo API), per config.privacy.ipAnonymization (#144). 'partial' drops the host
69
+ * portion (IPv4 /24, IPv6 /48) — still country-accurate; 'full' drops it
70
+ * entirely; 'none' passes through.
71
+ */
72
+ export function anonymizeIp(ip: string, mode: string = 'partial'): string {
73
+ if (!ip || mode === 'none')
74
+ return ip
75
+ if (mode === 'full')
76
+ return ''
77
+ if (ip.includes(':')) {
78
+ const parts = ip.split(':')
79
+ return `${parts.slice(0, 3).join(':')}::`
80
+ }
81
+ const octets = ip.split('.')
82
+ if (octets.length === 4) {
83
+ octets[3] = '0'
84
+ return octets.join('.')
85
+ }
86
+ return ip
87
+ }
88
+
89
+ export async function getCountryFromIP(ip: string): Promise<string | undefined> {
90
+ if (!ip || ip === 'unknown' || ip === '127.0.0.1' || ip.startsWith('10.') || ip.startsWith('192.168.') || ip.startsWith('172.')) {
91
+ return undefined
92
+ }
93
+
94
+ // Check cache first
95
+ const cached = ipGeoCache.get(ip)
96
+ if (cached && cached.expires > Date.now()) {
97
+ return cached.country
98
+ }
99
+
100
+ // HTTPS-only geolocation services — the plaintext-HTTP ip-api.com provider was
101
+ // removed so visitor IPs never traverse the network in the clear (#144).
102
+ const services = [
103
+ // ipapi.co - HTTPS, free tier 1000/day
104
+ async () => {
105
+ const response = await fetch(`https://ipapi.co/${ip}/json/`, {
106
+ signal: AbortSignal.timeout(3000),
107
+ headers: { 'User-Agent': 'ts-analytics/1.0' },
108
+ })
109
+ if (!response.ok) return null
110
+ const data = await response.json() as { country_name?: string; error?: boolean }
111
+ if (data.error) return null
112
+ return data.country_name
113
+ },
114
+ ]
115
+
116
+ for (const service of services) {
117
+ try {
118
+ const country = await service()
119
+ if (country) {
120
+ // Cache for 24 hours
121
+ ipGeoCache.set(ip, { country, expires: Date.now() + 24 * 60 * 60 * 1000 })
122
+ console.log(`[GeoIP] Resolved ${ip} to ${country}`)
123
+ return country
124
+ }
125
+ }
126
+ catch (err) {
127
+ // Try next service
128
+ console.log(`[GeoIP] Service failed for ${ip}:`, err)
129
+ }
130
+ }
131
+
132
+ console.log(`[GeoIP] Failed to resolve country for ${ip}`)
133
+ return undefined
134
+ }
135
+
136
+ /**
137
+ * Marketing channel a referrer belongs to.
138
+ */
139
+ export type ReferrerChannel = 'Search' | 'Social' | 'AI' | 'Email' | 'Referral' | 'Paid' | 'Direct'
140
+
141
+ interface ReferrerEntry {
142
+ /** Substring match (broad, for ccTLD coverage e.g. google.*) or RegExp (precise, for short ambiguous hosts e.g. t.co). */
143
+ match: string | RegExp
144
+ name: string
145
+ channel: ReferrerChannel
146
+ }
147
+
148
+ // Ordered: the first match wins, so more-specific hosts come before broader ones
149
+ // (e.g. mail.google / gemini.google before google; reddit before t.co).
150
+ const REFERRER_MAP: ReferrerEntry[] = [
151
+ // Webmail (before search engines so mail.google != Google)
152
+ { match: 'mail.google', name: 'Gmail', channel: 'Email' },
153
+ { match: 'mail.yahoo', name: 'Yahoo Mail', channel: 'Email' },
154
+ { match: 'outlook.', name: 'Outlook', channel: 'Email' },
155
+ { match: 'mail.proton', name: 'Proton Mail', channel: 'Email' },
156
+
157
+ // AI assistants (before search; gemini.google contains "google")
158
+ { match: 'chatgpt.', name: 'ChatGPT', channel: 'AI' },
159
+ { match: 'chat.openai', name: 'ChatGPT', channel: 'AI' },
160
+ { match: 'openai.', name: 'ChatGPT', channel: 'AI' },
161
+ { match: 'perplexity', name: 'Perplexity', channel: 'AI' },
162
+ { match: 'gemini.google', name: 'Gemini', channel: 'AI' },
163
+ { match: 'bard.google', name: 'Gemini', channel: 'AI' },
164
+ { match: 'claude.ai', name: 'Claude', channel: 'AI' },
165
+ { match: 'copilot.microsoft', name: 'Copilot', channel: 'AI' },
166
+ { match: /(^|\.)you\.com$/, name: 'You.com', channel: 'AI' },
167
+ { match: 'phind.', name: 'Phind', channel: 'AI' },
168
+
169
+ // Search engines
170
+ { match: 'google', name: 'Google', channel: 'Search' },
171
+ { match: 'bing', name: 'Bing', channel: 'Search' },
172
+ { match: 'duckduckgo', name: 'DuckDuckGo', channel: 'Search' },
173
+ { match: 'yahoo', name: 'Yahoo', channel: 'Search' },
174
+ { match: 'yandex', name: 'Yandex', channel: 'Search' },
175
+ { match: 'baidu', name: 'Baidu', channel: 'Search' },
176
+ { match: 'ecosia', name: 'Ecosia', channel: 'Search' },
177
+ { match: 'startpage', name: 'Startpage', channel: 'Search' },
178
+ { match: 'search.brave', name: 'Brave Search', channel: 'Search' },
179
+ { match: 'qwant', name: 'Qwant', channel: 'Search' },
180
+ { match: 'search.marginalia', name: 'Marginalia', channel: 'Search' },
181
+ { match: /(^|\.)ask\.com$/, name: 'Ask', channel: 'Search' },
182
+
183
+ // Social (reddit before t.co; precise regex for short ambiguous hosts)
184
+ { match: 'reddit', name: 'Reddit', channel: 'Social' },
185
+ { match: 'facebook', name: 'Facebook', channel: 'Social' },
186
+ { match: /(^|\.)fb\.com$/, name: 'Facebook', channel: 'Social' },
187
+ { match: /(^|\.)fb\.me$/, name: 'Facebook', channel: 'Social' },
188
+ { match: 'instagram', name: 'Instagram', channel: 'Social' },
189
+ { match: /^t\.co$/, name: 'Twitter', channel: 'Social' },
190
+ { match: 'twitter', name: 'Twitter', channel: 'Social' },
191
+ { match: /(^|\.)x\.com$/, name: 'Twitter', channel: 'Social' },
192
+ { match: 'linkedin', name: 'LinkedIn', channel: 'Social' },
193
+ { match: /(^|\.)lnkd\.in$/, name: 'LinkedIn', channel: 'Social' },
194
+ { match: 'youtube', name: 'YouTube', channel: 'Social' },
195
+ { match: /(^|\.)youtu\.be$/, name: 'YouTube', channel: 'Social' },
196
+ { match: 'pinterest', name: 'Pinterest', channel: 'Social' },
197
+ { match: 'tiktok', name: 'TikTok', channel: 'Social' },
198
+ { match: 'threads.net', name: 'Threads', channel: 'Social' },
199
+ { match: 'mastodon', name: 'Mastodon', channel: 'Social' },
200
+ { match: 'quora', name: 'Quora', channel: 'Social' },
201
+ { match: 'snapchat', name: 'Snapchat', channel: 'Social' },
202
+ { match: 'tumblr', name: 'Tumblr', channel: 'Social' },
203
+ { match: 'telegram', name: 'Telegram', channel: 'Social' },
204
+ { match: /^t\.me$/, name: 'Telegram', channel: 'Social' },
205
+ { match: 'news.ycombinator', name: 'Hacker News', channel: 'Social' },
206
+ { match: 'ycombinator', name: 'Hacker News', channel: 'Social' },
207
+
208
+ // Dev / community / content
209
+ { match: 'github', name: 'GitHub', channel: 'Referral' },
210
+ { match: 'gitlab', name: 'GitLab', channel: 'Referral' },
211
+ { match: 'stackoverflow', name: 'Stack Overflow', channel: 'Referral' },
212
+ { match: 'medium.com', name: 'Medium', channel: 'Referral' },
213
+ { match: 'dev.to', name: 'DEV', channel: 'Referral' },
214
+ ]
215
+
216
+ function matchReferrer(host: string): ReferrerEntry | undefined {
217
+ return REFERRER_MAP.find(e => typeof e.match === 'string' ? host.includes(e.match) : e.match.test(host))
218
+ }
219
+
220
+ /**
221
+ * Parse a referrer URL into a normalized, human-readable source name
222
+ * (e.g. "Google", "Reddit", "ChatGPT"). Unknown hosts fall back to the
223
+ * bare domain (www. stripped). Empty → "Direct", invalid → "Unknown".
224
+ */
225
+ export function parseReferrerSource(referrer?: string): string {
226
+ if (!referrer) return 'Direct'
227
+ try {
228
+ const host = new URL(referrer).hostname.toLowerCase()
229
+ const entry = matchReferrer(host)
230
+ if (entry) return entry.name
231
+ return host.startsWith('www.') ? host.slice(4) : host
232
+ }
233
+ catch {
234
+ return 'Unknown'
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Classify traffic into a marketing channel. Paid/email are inferred from
240
+ * UTM medium and ad click IDs (gclid/fbclid); otherwise from the referrer.
241
+ */
242
+ export function getReferrerChannel(opts: {
243
+ referrer?: string
244
+ utmMedium?: string
245
+ gclid?: string
246
+ fbclid?: string
247
+ }): ReferrerChannel {
248
+ const medium = (opts.utmMedium || '').toLowerCase()
249
+ if (opts.gclid || opts.fbclid
250
+ || ['cpc', 'ppc', 'paid', 'paidsearch', 'paid-search', 'paid_search', 'display', 'cpm', 'retargeting', 'affiliate'].includes(medium)) {
251
+ return 'Paid'
252
+ }
253
+ if (medium === 'email' || medium === 'newsletter') return 'Email'
254
+ if (medium === 'social' || medium === 'social-media') return 'Social'
255
+ if (medium === 'organic' || medium === 'search') return 'Search'
256
+ if (medium === 'referral') return 'Referral'
257
+ if (!opts.referrer) return 'Direct'
258
+ try {
259
+ const host = new URL(opts.referrer).hostname.toLowerCase()
260
+ return matchReferrer(host)?.channel ?? 'Referral'
261
+ }
262
+ catch {
263
+ return 'Direct'
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Channel for an already-normalized source name (e.g. a stored referrerSource).
269
+ * Used at query time where the original referrer/UTM is no longer available, so
270
+ * it can only classify Search/Social/AI/Referral/Direct (not Paid/Email).
271
+ */
272
+ export function getReferrerSourceChannel(source?: string): ReferrerChannel {
273
+ if (!source || source === 'Direct' || source === 'direct') return 'Direct'
274
+ const entry = REFERRER_MAP.find(e => e.name === source)
275
+ return entry?.channel ?? 'Referral'
276
+ }
277
+
278
+ // Known referral-spam / ghost-spam domains whose hits are fabricated by bots.
279
+ const SPAM_REFERRERS: string[] = [
280
+ 'semalt.com', 'buttons-for-website.com', 'buttons-for-your-website.com',
281
+ 'darodar.com', 'best-seo-offer.com', 'best-seo-solution.com',
282
+ 'free-share-buttons.com', 'free-social-buttons.com', 'get-free-traffic-now.com',
283
+ 'social-buttons.com', 'success-seo.com', 'trafficmonetizer.org',
284
+ 'simple-share-buttons.com', '4webmasters.org', 'ilovevitaly.com',
285
+ 'priceg.com', 'blackhatworth.com', 'econom.co', 'savetubevideo.com',
286
+ 'kambasoft.com', 'voucherssite.com', 'sharebutton.net', 'sitevaluation.org',
287
+ 'dailyrank.net', 'lifehacĸer.com', 'o-o-6-o-o.com', 'humanorightswatch.org',
288
+ 'guardlink.org', 'cenoval.ru', 'descargar-musica-gratis.net',
289
+ ]
290
+
291
+ /**
292
+ * Whether a referrer is a known referral-spam domain (so its hits can be dropped).
293
+ */
294
+ export function isSpamReferrer(referrer?: string): boolean {
295
+ if (!referrer) return false
296
+ try {
297
+ const host = new URL(referrer).hostname.toLowerCase().replace(/^www\./, '')
298
+ return SPAM_REFERRERS.some(d => host === d || host.endsWith(`.${d}`))
299
+ }
300
+ catch {
301
+ return false
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Get region from headers (CloudFront/Cloudflare)
307
+ */
308
+ export function getRegionFromHeaders(headers: Record<string, string> | undefined): string | undefined {
309
+ if (!headers) return undefined
310
+ return headers['cloudfront-viewer-country-region-name']
311
+ || headers['CloudFront-Viewer-Country-Region-Name']
312
+ || headers['cf-region']
313
+ }
314
+
315
+ /**
316
+ * Get city from headers (CloudFront/Cloudflare)
317
+ */
318
+ export function getCityFromHeaders(headers: Record<string, string> | undefined): string | undefined {
319
+ if (!headers) return undefined
320
+ return headers['cloudfront-viewer-city']
321
+ || headers['CloudFront-Viewer-City']
322
+ || headers['cf-ipcity']
323
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Utils barrel export
3
+ */
4
+
5
+ export * from './response'
6
+ export * from './date'
7
+ export * from './user-agent'
8
+ export * from './geolocation'
9
+ export * from './cache'