@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,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
|
+
}
|