ahoy_analytics 0.1.0
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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +163 -0
- data/Rakefile +6 -0
- data/app/assets/ahoy_analytics/build/assets/Combination-BpSXUjp9.js +41 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-5KyfCxh6.css +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-dashboard-uOXx8zYZ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-layout-ClAft5OU.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-tracker-B3f8P98z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-ui-DMSkNqd6.js +90 -0
- data/app/assets/ahoy_analytics/build/assets/behaviors-panel-ChNGYbdH.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/button-JVCrlR4s.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/cable-DO-7y1-E.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/createLucideIcon-BGzacY2v.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/date-range-dialog-DWDp3cLG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/details-button-NqKfSGEG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/devices-panel-cXvlmNBY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dialog-path-BBPNlB4Z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dropdown-menu-Adj3O5fh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/filter-dialog-BN-rf4lp.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-B1K1NTKT.js +3 -0
- data/app/assets/ahoy_analytics/build/assets/index-BcHeb-Rh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-DzpzLoG4.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-vX97OY1J.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/input-e4v_v0kE.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/jsx-runtime-u17CrQMm.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/last-load-context-De5uA95L.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/list-table-ChHEzzF9.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/live-Cp2MHECh.js +2 -0
- data/app/assets/ahoy_analytics/build/assets/locations-panel-BaISRmaQ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/mercator-BnxX5RzL.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/pages-panel-Bh25L8mP.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/panel-tabs-B2kvGFJx.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/query-context-B-PgE00D.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/remote-details-dialog-DDTcKaM5.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/show-CCRicksg.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/simple-tabs-D6G6Bs0k.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/site-context-BNteYRlR.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/sources-panel-DyB21hxD.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-bar-FSiLBjq6.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-stats-context-DU15P9jS.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/use-debounce-VBpXQRL8.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/user-context-DbYteluY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-globe-BWLDihid.js +4789 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-graph-uKXjLvcu.js +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/brave.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chrome.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chromium.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/duckduckgo.svg +2151 -0
- data/app/assets/ahoy_analytics/images/icon/browser/edge.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/browser/firefox.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/opera.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/safari.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/browser/samsung-internet.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/uc.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/vivaldi.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/yandex.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/android.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/chrome_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/os/fedora.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/freebsd.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/gnu_linux.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ios.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ipad_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/mac.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ubuntu.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/windows.png +0 -0
- data/app/assets/stylesheets/ahoy_analytics/application.css +15 -0
- data/app/channels/ahoy_analytics/analytics_channel.rb +9 -0
- data/app/controllers/ahoy_analytics/analytics_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/application_controller.rb +8 -0
- data/app/controllers/ahoy_analytics/assets_controller.rb +46 -0
- data/app/controllers/ahoy_analytics/base_controller.rb +285 -0
- data/app/controllers/ahoy_analytics/behaviors_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/devices_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/export_controller.rb +12 -0
- data/app/controllers/ahoy_analytics/live_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/locations_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/main_graph_controller.rb +10 -0
- data/app/controllers/ahoy_analytics/pages_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/referrers_controller.rb +34 -0
- data/app/controllers/ahoy_analytics/search_terms_controller.rb +47 -0
- data/app/controllers/ahoy_analytics/sources_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/top_stats_controller.rb +13 -0
- data/app/controllers/concerns/ahoy_analytics/set_current_request.rb +17 -0
- data/app/frontend/components/analytics/hex-highlights.tsx +165 -0
- data/app/frontend/components/analytics/hex-land-layer.tsx +61 -0
- data/app/frontend/components/analytics/metric-card.tsx +138 -0
- data/app/frontend/components/analytics/sessions-by-location.tsx +62 -0
- data/app/frontend/components/analytics/visitor-globe.tsx +424 -0
- data/app/frontend/components/ui/accordion.tsx +64 -0
- data/app/frontend/components/ui/alert.tsx +66 -0
- data/app/frontend/components/ui/avatar.tsx +53 -0
- data/app/frontend/components/ui/badge.tsx +46 -0
- data/app/frontend/components/ui/button.tsx +62 -0
- data/app/frontend/components/ui/calendar.tsx +212 -0
- data/app/frontend/components/ui/card.tsx +91 -0
- data/app/frontend/components/ui/checkbox.tsx +32 -0
- data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
- data/app/frontend/components/ui/input.tsx +21 -0
- data/app/frontend/components/ui/label.tsx +22 -0
- data/app/frontend/components/ui/popover.tsx +46 -0
- data/app/frontend/components/ui/select.tsx +183 -0
- data/app/frontend/components/ui/separator.tsx +26 -0
- data/app/frontend/components/ui/sheet.tsx +139 -0
- data/app/frontend/components/ui/sidebar.tsx +726 -0
- data/app/frontend/components/ui/skeleton.tsx +13 -0
- data/app/frontend/components/ui/sonner.tsx +33 -0
- data/app/frontend/components/ui/tooltip.tsx +59 -0
- data/app/frontend/data/countries-110m.json +1 -0
- data/app/frontend/data/globe-data.json +1 -0
- data/app/frontend/entrypoints/analytics-tracker.ts +680 -0
- data/app/frontend/entrypoints/analytics-ui.tsx +26 -0
- data/app/frontend/entrypoints/analytics.css +77 -0
- data/app/frontend/layouts/analytics-layout.tsx +28 -0
- data/app/frontend/lib/cable.ts +13 -0
- data/app/frontend/lib/geocode.ts +65 -0
- data/app/frontend/lib/utils.ts +6 -0
- data/app/frontend/pages/admin/analytics/api.ts +221 -0
- data/app/frontend/pages/admin/analytics/hooks/use-debounce.ts +36 -0
- data/app/frontend/pages/admin/analytics/last-load-context.tsx +29 -0
- data/app/frontend/pages/admin/analytics/lib/base-path.ts +28 -0
- data/app/frontend/pages/admin/analytics/lib/dialog-path.ts +242 -0
- data/app/frontend/pages/admin/analytics/lib/number-formatter.ts +100 -0
- data/app/frontend/pages/admin/analytics/live.tsx +608 -0
- data/app/frontend/pages/admin/analytics/query-context.tsx +61 -0
- data/app/frontend/pages/admin/analytics/show.tsx +40 -0
- data/app/frontend/pages/admin/analytics/site-context.tsx +22 -0
- data/app/frontend/pages/admin/analytics/top-stats-context.tsx +37 -0
- data/app/frontend/pages/admin/analytics/types.ts +161 -0
- data/app/frontend/pages/admin/analytics/ui/analytics-dashboard.tsx +60 -0
- data/app/frontend/pages/admin/analytics/ui/behaviors-panel.tsx +456 -0
- data/app/frontend/pages/admin/analytics/ui/date-range-dialog.tsx +173 -0
- data/app/frontend/pages/admin/analytics/ui/details-button.tsx +33 -0
- data/app/frontend/pages/admin/analytics/ui/devices-panel.tsx +474 -0
- data/app/frontend/pages/admin/analytics/ui/filter-dialog.tsx +558 -0
- data/app/frontend/pages/admin/analytics/ui/list-table.tsx +346 -0
- data/app/frontend/pages/admin/analytics/ui/locations-panel.tsx +566 -0
- data/app/frontend/pages/admin/analytics/ui/pages-panel.tsx +207 -0
- data/app/frontend/pages/admin/analytics/ui/panel-tabs.tsx +65 -0
- data/app/frontend/pages/admin/analytics/ui/remote-details-dialog.tsx +356 -0
- data/app/frontend/pages/admin/analytics/ui/simple-tabs.tsx +54 -0
- data/app/frontend/pages/admin/analytics/ui/sources-panel.tsx +771 -0
- data/app/frontend/pages/admin/analytics/ui/top-bar.tsx +793 -0
- data/app/frontend/pages/admin/analytics/ui/visitor-graph.tsx +891 -0
- data/app/frontend/pages/admin/analytics/user-context.tsx +22 -0
- data/app/frontend/styles/shared.css +156 -0
- data/app/helpers/ahoy_analytics/application_helper.rb +96 -0
- data/app/jobs/ahoy_analytics/application_job.rb +4 -0
- data/app/jobs/ahoy_analytics/update_job.rb +12 -0
- data/app/mailers/ahoy_analytics/application_mailer.rb +6 -0
- data/app/models/ahoy/event/filters.rb +7 -0
- data/app/models/ahoy/event.rb +9 -0
- data/app/models/ahoy/visit/cache_key.rb +15 -0
- data/app/models/ahoy/visit/constants.rb +11 -0
- data/app/models/ahoy/visit/devices.rb +144 -0
- data/app/models/ahoy/visit/export.rb +24 -0
- data/app/models/ahoy/visit/filters.rb +286 -0
- data/app/models/ahoy/visit/imports.rb +36 -0
- data/app/models/ahoy/visit/locations.rb +276 -0
- data/app/models/ahoy/visit/metrics.rb +473 -0
- data/app/models/ahoy/visit/ordering.rb +110 -0
- data/app/models/ahoy/visit/pages.rb +533 -0
- data/app/models/ahoy/visit/pagination.rb +17 -0
- data/app/models/ahoy/visit/ranges.rb +227 -0
- data/app/models/ahoy/visit/series.rb +177 -0
- data/app/models/ahoy/visit/sources.rb +418 -0
- data/app/models/ahoy/visit/url_labels.rb +32 -0
- data/app/models/ahoy/visit.rb +143 -0
- data/app/models/ahoy_analytics/application_record.rb +5 -0
- data/app/models/ahoy_analytics/current.rb +8 -0
- data/app/models/ahoy_analytics/funnel.rb +16 -0
- data/app/models/ahoy_analytics/imported_entry_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_exit_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_page.rb +5 -0
- data/app/models/ahoy_analytics/live_stats.rb +152 -0
- data/app/models/ahoy_analytics/setting.rb +19 -0
- data/app/models/analytics/source_catalog.rb +48 -0
- data/app/views/layouts/ahoy_analytics/application.html.erb +15 -0
- data/config/routes.rb +21 -0
- data/config/vite.json +22 -0
- data/db/migrate/20251006104056_create_ahoy_visits_and_events.rb +62 -0
- data/db/migrate/20251006105012_add_analytics_fields_to_ahoy_visits.rb +11 -0
- data/db/migrate/20251012090000_create_analytics_funnels_and_imports.rb +52 -0
- data/db/migrate/20251013021500_add_analytics_indexes.rb +14 -0
- data/lib/ahoy_analytics/ahoy_store.rb +429 -0
- data/lib/ahoy_analytics/asset_manifest.rb +56 -0
- data/lib/ahoy_analytics/device_bucket.rb +39 -0
- data/lib/ahoy_analytics/engine.rb +55 -0
- data/lib/ahoy_analytics/maxmind_geo.rb +77 -0
- data/lib/ahoy_analytics/version.rb +3 -0
- data/lib/ahoy_analytics.rb +52 -0
- data/lib/generators/ahoy_analytics/install/install_generator.rb +111 -0
- data/lib/generators/ahoy_analytics/install/templates/initializer.rb +28 -0
- data/lib/tasks/ahoy_analytics_tasks.rake +4 -0
- metadata +352 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone Analytics Tracker
|
|
3
|
+
*
|
|
4
|
+
* Universal tracker that works with:
|
|
5
|
+
* - Multi-page apps (traditional server-rendered navigation)
|
|
6
|
+
* - Single-page apps (React Router, Vue Router, Inertia.js, etc.)
|
|
7
|
+
* - Hybrid apps (mix of both)
|
|
8
|
+
*
|
|
9
|
+
* Similar to Plausible.io, wraps the History API to detect navigation.
|
|
10
|
+
* Usage: Add to <head> with <%= ahoy_analytics_tracking_tag %>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface AnalyticsConfig {
|
|
14
|
+
// Ahoy endpoints
|
|
15
|
+
eventsEndpoint: string
|
|
16
|
+
visitsEndpoint: string
|
|
17
|
+
// Filters
|
|
18
|
+
excludePaths: string[]
|
|
19
|
+
includePaths?: string[] // if provided, only paths matching any will be tracked (plausible-style)
|
|
20
|
+
excludeAssets: string[]
|
|
21
|
+
// Storage + transport
|
|
22
|
+
useCookies: boolean // true = cookies (ahoy.js style); false = cookieless (localStorage). Default: false
|
|
23
|
+
visitDurationMinutes: number // new visit after X minutes of inactivity. Default: 240 (4h)
|
|
24
|
+
useBeaconForEvents: boolean // prefer sendBeacon for events. Default: true
|
|
25
|
+
trackVisits: boolean // create visit on frontend. Default: true
|
|
26
|
+
// Routing behavior
|
|
27
|
+
hashBasedRouting?: boolean // when true, treat hash changes as navigation (off by default)
|
|
28
|
+
// Dev
|
|
29
|
+
debug?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class StandaloneAnalytics {
|
|
33
|
+
private visitToken: string | null = null
|
|
34
|
+
private visitExpiresAt: number | null = null
|
|
35
|
+
private visitorToken: string | null = null
|
|
36
|
+
private lastTrackedHref: string | null = null
|
|
37
|
+
// Dedup key for pageviews: pathname + search (or + hash if hashBasedRouting)
|
|
38
|
+
private lastTrackedPageKey: string | null = null
|
|
39
|
+
private config: AnalyticsConfig
|
|
40
|
+
private ensureVisitPromise: Promise<void> | null = null
|
|
41
|
+
|
|
42
|
+
// Engagement tracking state (plausible-like)
|
|
43
|
+
private listeningOnEngagement = false
|
|
44
|
+
private currentEngagementIgnored = false
|
|
45
|
+
private currentEngagementURL: string | null = null
|
|
46
|
+
private currentEngagementMaxScrollDepth = -1
|
|
47
|
+
private runningEngagementStart = 0
|
|
48
|
+
private currentEngagementTime = 0
|
|
49
|
+
private currentDocumentHeight = 0
|
|
50
|
+
private maxScrollDepthPx = 0
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
this.config = {
|
|
54
|
+
eventsEndpoint: '/ahoy/events',
|
|
55
|
+
visitsEndpoint: '/ahoy/visits',
|
|
56
|
+
// Defaults similar to our app; can be overridden by data-* attributes or window.analyticsConfig
|
|
57
|
+
// Exclude internal/system endpoints to avoid accidental tracking
|
|
58
|
+
excludePaths: ['/admin', '/.well-known', '/ahoy', '/cable'],
|
|
59
|
+
excludeAssets: [
|
|
60
|
+
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
|
|
61
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
62
|
+
'.pdf', '.zip', '.tar', '.gz',
|
|
63
|
+
'.mp4', '.webm', '.mp3', '.wav',
|
|
64
|
+
'.css', '.js', '.map', '.json'
|
|
65
|
+
],
|
|
66
|
+
useCookies: false,
|
|
67
|
+
visitDurationMinutes: 30, // create a new visit after 30 minutes of inactivity (web analytics standard)
|
|
68
|
+
useBeaconForEvents: true,
|
|
69
|
+
trackVisits: true,
|
|
70
|
+
hashBasedRouting: false,
|
|
71
|
+
debug: false,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
init(): void {
|
|
76
|
+
if (typeof window === 'undefined') return
|
|
77
|
+
|
|
78
|
+
// Optional runtime overrides via window.analyticsConfig
|
|
79
|
+
try {
|
|
80
|
+
const overrides = (window as any).analyticsConfig as Partial<AnalyticsConfig> | undefined
|
|
81
|
+
if (overrides && typeof overrides === 'object') {
|
|
82
|
+
this.config = { ...this.config, ...overrides }
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
|
|
86
|
+
// Read plausible-style include/exclude from script tag if present
|
|
87
|
+
this.readScriptAttributes()
|
|
88
|
+
|
|
89
|
+
// Load tokens (meta or storage) and ensure a visit exists
|
|
90
|
+
this.bootstrapIdentity()
|
|
91
|
+
if (this.config.trackVisits) this.ensureVisitPromise = this.ensureActiveVisit()
|
|
92
|
+
|
|
93
|
+
// Track initial page load only when the document is visible.
|
|
94
|
+
// This prevents background prerenders/prefetches from counting and avoids
|
|
95
|
+
// double pageviews when a hidden page is spun up by the browser.
|
|
96
|
+
const trackWhenVisible = () => {
|
|
97
|
+
if (document.visibilityState === 'visible') {
|
|
98
|
+
document.removeEventListener('visibilitychange', trackWhenVisible)
|
|
99
|
+
void this.trackPageview()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const triggerInitial = () => {
|
|
104
|
+
if (document.visibilityState === 'visible') {
|
|
105
|
+
void this.trackPageview()
|
|
106
|
+
} else {
|
|
107
|
+
document.addEventListener('visibilitychange', trackWhenVisible)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (document.readyState === 'loading') {
|
|
112
|
+
document.addEventListener('DOMContentLoaded', triggerInitial)
|
|
113
|
+
} else {
|
|
114
|
+
triggerInitial()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Listen for navigation events (works with both regular links and Inertia)
|
|
118
|
+
this.setupNavigationListener()
|
|
119
|
+
|
|
120
|
+
// Engagement and auto-capture listeners
|
|
121
|
+
this.initEngagement()
|
|
122
|
+
this.initAutoCapture()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private bootstrapIdentity(): void {
|
|
126
|
+
// First try meta tokens (if server provided)
|
|
127
|
+
const visitMeta = document.querySelector<HTMLMetaElement>('meta[name="ahoy-visit"]')
|
|
128
|
+
const visitorMeta = document.querySelector<HTMLMetaElement>('meta[name="ahoy-visitor"]')
|
|
129
|
+
if (visitMeta?.content) this.visitToken = visitMeta.content
|
|
130
|
+
if (visitorMeta?.content) this.visitorToken = visitorMeta.content
|
|
131
|
+
|
|
132
|
+
// Load from storage if missing (supports cookies or localStorage)
|
|
133
|
+
if (!this.visitorToken) this.visitorToken = this.getOrCreateVisitorToken()
|
|
134
|
+
// visit may expire; refresh if needed
|
|
135
|
+
const vt = this.getStoredVisit()
|
|
136
|
+
if (vt) {
|
|
137
|
+
this.visitToken = vt.token
|
|
138
|
+
this.visitExpiresAt = vt.expiresAt
|
|
139
|
+
}
|
|
140
|
+
if (!this.visitToken || this.isVisitExpired()) {
|
|
141
|
+
this.createNewVisitToken()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private setupNavigationListener(): void {
|
|
146
|
+
// Wrap history.pushState and history.replaceState to detect SPA navigation
|
|
147
|
+
// This works for ANY SPA framework (React Router, Vue Router, Inertia, etc.)
|
|
148
|
+
const originalPushState = history.pushState
|
|
149
|
+
const originalReplaceState = history.replaceState
|
|
150
|
+
|
|
151
|
+
if (originalPushState) {
|
|
152
|
+
history.pushState = (...args) => {
|
|
153
|
+
this.prePageview()
|
|
154
|
+
originalPushState.apply(history, args)
|
|
155
|
+
if (this.config.debug) console.debug('[analytics] pushState')
|
|
156
|
+
void this.trackPageview()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Keep replaceState hook to catch rare replace-only navigations (e.g., Inertia Link with `replace`),
|
|
161
|
+
// while dedup in trackPageview() ensures we don't double count when frameworks call replaceState
|
|
162
|
+
// after pushState to update state/scroll.
|
|
163
|
+
if (originalReplaceState) {
|
|
164
|
+
history.replaceState = (...args) => {
|
|
165
|
+
this.prePageview()
|
|
166
|
+
originalReplaceState.apply(history, args)
|
|
167
|
+
if (this.config.debug) console.debug('[analytics] replaceState')
|
|
168
|
+
void this.trackPageview()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Listen for popstate (back/forward buttons)
|
|
173
|
+
window.addEventListener('popstate', () => {
|
|
174
|
+
this.prePageview()
|
|
175
|
+
if (this.config.debug) console.debug('[analytics] popstate')
|
|
176
|
+
void this.trackPageview()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Optional: treat hash changes as navigation (off by default)
|
|
180
|
+
if (this.config.hashBasedRouting) {
|
|
181
|
+
window.addEventListener('hashchange', () => {
|
|
182
|
+
this.prePageview()
|
|
183
|
+
if (this.config.debug) console.debug('[analytics] hashchange')
|
|
184
|
+
void this.trackPageview()
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async trackPageview(): Promise<void> {
|
|
190
|
+
if (typeof window === 'undefined') return
|
|
191
|
+
|
|
192
|
+
// Skip tracking in iframes to avoid embedded/preview contexts skewing data
|
|
193
|
+
try { if (window.top !== window.self) return } catch { /* cross-origin, treat as iframe */ return }
|
|
194
|
+
|
|
195
|
+
const href = window.location.href
|
|
196
|
+
const pathname = window.location.pathname
|
|
197
|
+
const pathQuery = pathname + window.location.search
|
|
198
|
+
const pageKey = this.config.hashBasedRouting ? (pathQuery + window.location.hash) : pathQuery
|
|
199
|
+
|
|
200
|
+
// Skip if same path+query as last tracked (ignore hash-only changes)
|
|
201
|
+
if (this.lastTrackedPageKey === pageKey) return
|
|
202
|
+
// Claim this pageKey immediately to avoid double-fire when pushState/replaceState
|
|
203
|
+
// happen back-to-back during the same navigation tick (Inertia / SPA frameworks).
|
|
204
|
+
this.lastTrackedPageKey = pageKey
|
|
205
|
+
if (this.config.debug) console.debug('[analytics] trackPageview pageKey=', pageKey)
|
|
206
|
+
|
|
207
|
+
// Skip excluded paths
|
|
208
|
+
if (this.shouldExclude(pathname)) {
|
|
209
|
+
this.lastTrackedHref = href
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Ensure the visit is created before the first pageview to avoid
|
|
214
|
+
// the server implicitly creating a visit for /ahoy/events
|
|
215
|
+
if (this.ensureVisitPromise) {
|
|
216
|
+
try { await this.ensureVisitPromise } catch {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const referrer = this.lastTrackedHref || document.referrer || ''
|
|
220
|
+
|
|
221
|
+
// Plausible-style event name and props
|
|
222
|
+
this.sendEvent({
|
|
223
|
+
name: 'pageview',
|
|
224
|
+
page: pathQuery,
|
|
225
|
+
url: href,
|
|
226
|
+
title: document.title,
|
|
227
|
+
referrer,
|
|
228
|
+
screenSize: `${window.innerWidth}x${window.innerHeight}`
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
this.lastTrackedHref = href
|
|
232
|
+
this.postPageview({ url: href, page: pathQuery })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private shouldExclude(pathname: string): boolean {
|
|
236
|
+
const lowerPath = pathname.toLowerCase()
|
|
237
|
+
|
|
238
|
+
// Exclude admin paths
|
|
239
|
+
if (this.config.excludePaths.some(path => lowerPath.startsWith(path))) {
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Exclude static assets
|
|
244
|
+
if (this.config.excludeAssets.some(ext => lowerPath.endsWith(ext))) {
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Exclude special files
|
|
249
|
+
const specialFiles = [
|
|
250
|
+
'/favicon.ico',
|
|
251
|
+
'/robots.txt',
|
|
252
|
+
'/sitemap.xml',
|
|
253
|
+
'/manifest.json',
|
|
254
|
+
'/browserconfig.xml',
|
|
255
|
+
// Chrome DevTools well-known JSON
|
|
256
|
+
'/.well-known/appspecific/com.chrome.devtools.json'
|
|
257
|
+
]
|
|
258
|
+
if (specialFiles.includes(lowerPath)) {
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Exclude apple-touch-icon variants
|
|
263
|
+
if (lowerPath.includes('apple-touch-icon')) {
|
|
264
|
+
return true
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Apply plausible-style include/exclude rules
|
|
268
|
+
const inc = this.config.includePaths && this.config.includePaths.length > 0
|
|
269
|
+
if (inc) {
|
|
270
|
+
const pass = this.config.includePaths!.some((p) => this.pathMatches(p, lowerPath))
|
|
271
|
+
if (!pass) return true
|
|
272
|
+
}
|
|
273
|
+
if (this.config.excludePaths && this.config.excludePaths.length > 0) {
|
|
274
|
+
if (this.config.excludePaths.some((p) => this.pathMatches(p, lowerPath))) return true
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return false
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private sendEvent(properties: {
|
|
281
|
+
name: string
|
|
282
|
+
page: string
|
|
283
|
+
url: string
|
|
284
|
+
title: string
|
|
285
|
+
referrer: string
|
|
286
|
+
screenSize: string
|
|
287
|
+
}, extra?: Record<string, any>): void {
|
|
288
|
+
if (typeof window === 'undefined') return
|
|
289
|
+
|
|
290
|
+
const event = {
|
|
291
|
+
name: properties.name,
|
|
292
|
+
properties: {
|
|
293
|
+
page: properties.page,
|
|
294
|
+
url: properties.url,
|
|
295
|
+
title: properties.title,
|
|
296
|
+
referrer: properties.referrer,
|
|
297
|
+
screen_size: properties.screenSize,
|
|
298
|
+
...(extra || {})
|
|
299
|
+
},
|
|
300
|
+
time: Date.now() / 1000
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (this.config.debug) {
|
|
304
|
+
try {
|
|
305
|
+
console.debug('[analytics] sendEvent', { name: event.name, page: (event as any).properties.page, url: (event as any).properties.url })
|
|
306
|
+
} catch {}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Prefer sendBeacon with FormData + authenticity_token (like ahoy.js)
|
|
310
|
+
const canBeacon = this.config.useBeaconForEvents && typeof navigator.sendBeacon === 'function'
|
|
311
|
+
if (canBeacon) {
|
|
312
|
+
const data: Record<string, string> = {}
|
|
313
|
+
// Ahoy expects events_json to be a JSON string of the events array (not wrapped)
|
|
314
|
+
data['events_json'] = JSON.stringify([event])
|
|
315
|
+
// Include tokens explicitly so we can be cookieless
|
|
316
|
+
if (this.visitToken) data['visit_token'] = this.visitToken
|
|
317
|
+
if (this.visitorToken) data['visitor_token'] = this.visitorToken
|
|
318
|
+
|
|
319
|
+
// Rails CSRF param/name
|
|
320
|
+
const csrfParam = this.getCSRFParam()
|
|
321
|
+
const csrfToken = this.getCSRFToken()
|
|
322
|
+
if (csrfParam && csrfToken) data[csrfParam] = csrfToken
|
|
323
|
+
|
|
324
|
+
const form = new FormData()
|
|
325
|
+
Object.entries(data).forEach(([k, v]) => form.append(k, v))
|
|
326
|
+
navigator.sendBeacon(this.config.eventsEndpoint, form)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Fallback: JSON + CSRF header via fetch
|
|
331
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
332
|
+
const csrfToken = this.getCSRFToken()
|
|
333
|
+
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
|
|
334
|
+
|
|
335
|
+
const jsonPayload = {
|
|
336
|
+
visit_token: this.visitToken,
|
|
337
|
+
visitor_token: this.visitorToken,
|
|
338
|
+
events: [event]
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
fetch(this.config.eventsEndpoint, {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers,
|
|
344
|
+
body: JSON.stringify(jsonPayload),
|
|
345
|
+
credentials: 'same-origin',
|
|
346
|
+
keepalive: true
|
|
347
|
+
}).catch(() => { /* never block app */ })
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private generateToken(): string {
|
|
351
|
+
// Generate random token (UUIDv4-like hex)
|
|
352
|
+
const cryptoObj = (typeof globalThis !== 'undefined' ? (globalThis as any).crypto : undefined) as Crypto | undefined
|
|
353
|
+
if (cryptoObj?.randomUUID) return cryptoObj.randomUUID().replace(/-/g, '')
|
|
354
|
+
const array = new Uint8Array(16)
|
|
355
|
+
if (cryptoObj?.getRandomValues) {
|
|
356
|
+
cryptoObj.getRandomValues(array)
|
|
357
|
+
} else {
|
|
358
|
+
// Fallback to Math.random when Web Crypto is unavailable (very rare)
|
|
359
|
+
for (let i = 0; i < array.length; i++) array[i] = Math.floor(Math.random() * 256)
|
|
360
|
+
}
|
|
361
|
+
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private getOrCreateVisitorToken(): string {
|
|
365
|
+
if (this.config.useCookies) {
|
|
366
|
+
const cookie = this.getCookie('ahoy_visitor')
|
|
367
|
+
if (cookie) return cookie
|
|
368
|
+
const token = this.generateToken()
|
|
369
|
+
this.setCookie('ahoy_visitor', token, 60 * 24 * 365 * 2) // 2 years
|
|
370
|
+
return token
|
|
371
|
+
} else {
|
|
372
|
+
const stored = localStorage.getItem('ahoy_visitor')
|
|
373
|
+
if (stored) return stored
|
|
374
|
+
const token = this.generateToken()
|
|
375
|
+
localStorage.setItem('ahoy_visitor', token)
|
|
376
|
+
return token
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private getCSRFToken(): string | null {
|
|
381
|
+
const meta = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
|
|
382
|
+
return meta?.content || null
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private getCSRFParam(): string | null {
|
|
386
|
+
const meta = document.querySelector<HTMLMetaElement>('meta[name="csrf-param"]')
|
|
387
|
+
return meta?.content || null
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ----- Visit lifecycle -----
|
|
391
|
+
private isVisitExpired(): boolean {
|
|
392
|
+
if (!this.visitExpiresAt) return true
|
|
393
|
+
return Date.now() > this.visitExpiresAt
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private createNewVisitToken(): void {
|
|
397
|
+
this.visitToken = this.generateToken()
|
|
398
|
+
const ttlMs = this.config.visitDurationMinutes * 60 * 1000
|
|
399
|
+
this.visitExpiresAt = Date.now() + ttlMs
|
|
400
|
+
this.storeVisit(this.visitToken, this.visitExpiresAt)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private ensureActiveVisit(): Promise<void> {
|
|
404
|
+
if (!this.visitToken || this.isVisitExpired()) this.createNewVisitToken()
|
|
405
|
+
// Send visit to server (JSON + CSRF header); mirrors ahoy.js createVisit
|
|
406
|
+
const payload: Record<string, any> = {
|
|
407
|
+
visit_token: this.visitToken,
|
|
408
|
+
visitor_token: this.visitorToken,
|
|
409
|
+
platform: 'Web',
|
|
410
|
+
landing_page: window.location.href,
|
|
411
|
+
screen_width: window.innerWidth,
|
|
412
|
+
screen_height: window.innerHeight,
|
|
413
|
+
screen_size: `${window.innerWidth}x${window.innerHeight}`,
|
|
414
|
+
js: true
|
|
415
|
+
}
|
|
416
|
+
if (document.referrer) payload.referrer = document.referrer
|
|
417
|
+
|
|
418
|
+
// Include UTM parameters from the landing URL (Plausible-compatible behavior)
|
|
419
|
+
try {
|
|
420
|
+
const params = new URLSearchParams(window.location.search)
|
|
421
|
+
const utm_source = params.get('utm_source') || params.get('source') || params.get('ref')
|
|
422
|
+
const utm_medium = params.get('utm_medium')
|
|
423
|
+
const utm_campaign = params.get('utm_campaign')
|
|
424
|
+
const utm_content = params.get('utm_content')
|
|
425
|
+
const utm_term = params.get('utm_term')
|
|
426
|
+
if (utm_source) payload.utm_source = utm_source
|
|
427
|
+
if (utm_medium) payload.utm_medium = utm_medium
|
|
428
|
+
if (utm_campaign) payload.utm_campaign = utm_campaign
|
|
429
|
+
if (utm_content) payload.utm_content = utm_content
|
|
430
|
+
if (utm_term) payload.utm_term = utm_term
|
|
431
|
+
} catch {}
|
|
432
|
+
|
|
433
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
434
|
+
const csrf = this.getCSRFToken()
|
|
435
|
+
if (csrf) headers['X-CSRF-Token'] = csrf
|
|
436
|
+
|
|
437
|
+
return fetch(this.config.visitsEndpoint, {
|
|
438
|
+
method: 'POST',
|
|
439
|
+
headers,
|
|
440
|
+
body: JSON.stringify(payload),
|
|
441
|
+
credentials: 'same-origin',
|
|
442
|
+
keepalive: true
|
|
443
|
+
}).then(() => { /* ok */ }).catch(() => { /* ignore analytics errors */ })
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private getStoredVisit(): { token: string, expiresAt: number } | null {
|
|
447
|
+
if (this.config.useCookies) {
|
|
448
|
+
const token = this.getCookie('ahoy_visit')
|
|
449
|
+
const exp = this.getCookie('ahoy_visit_expires')
|
|
450
|
+
if (token && exp) return { token, expiresAt: parseInt(exp, 10) }
|
|
451
|
+
return null
|
|
452
|
+
} else {
|
|
453
|
+
const token = localStorage.getItem('ahoy_visit')
|
|
454
|
+
const exp = localStorage.getItem('ahoy_visit_expires')
|
|
455
|
+
if (token && exp) return { token, expiresAt: parseInt(exp, 10) }
|
|
456
|
+
return null
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private storeVisit(token: string, expiresAt: number): void {
|
|
461
|
+
if (this.config.useCookies) {
|
|
462
|
+
this.setCookie('ahoy_visit', token, this.config.visitDurationMinutes)
|
|
463
|
+
// store absolute expiry (ms since epoch)
|
|
464
|
+
this.setCookie('ahoy_visit_expires', String(expiresAt), this.config.visitDurationMinutes)
|
|
465
|
+
} else {
|
|
466
|
+
localStorage.setItem('ahoy_visit', token)
|
|
467
|
+
localStorage.setItem('ahoy_visit_expires', String(expiresAt))
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ----- Cookie helpers -----
|
|
472
|
+
private setCookie(name: string, value: string, ttlMinutes: number) {
|
|
473
|
+
const d = new Date()
|
|
474
|
+
d.setTime(d.getTime() + ttlMinutes * 60 * 1000)
|
|
475
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${d.toUTCString()}; path=/; samesite=lax`
|
|
476
|
+
}
|
|
477
|
+
private getCookie(name: string): string | null {
|
|
478
|
+
const match = document.cookie.match(new RegExp('(^|; )' + name.replace(/([.$?*|{}()\[\]\\/+^])/g, '\\$1') + '=([^;]*)'))
|
|
479
|
+
return match ? decodeURIComponent(match[2]) : null
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ----- Engagement (Plausible-like) -----
|
|
483
|
+
private initEngagement(): void {
|
|
484
|
+
this.currentDocumentHeight = this.getDocumentHeight()
|
|
485
|
+
this.maxScrollDepthPx = this.getCurrentScrollDepthPx()
|
|
486
|
+
|
|
487
|
+
window.addEventListener('load', () => {
|
|
488
|
+
this.currentDocumentHeight = this.getDocumentHeight()
|
|
489
|
+
let count = 0
|
|
490
|
+
const interval = setInterval(() => {
|
|
491
|
+
this.currentDocumentHeight = this.getDocumentHeight()
|
|
492
|
+
if (++count === 15) clearInterval(interval)
|
|
493
|
+
}, 200)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
document.addEventListener('scroll', () => {
|
|
497
|
+
this.currentDocumentHeight = this.getDocumentHeight()
|
|
498
|
+
const cur = this.getCurrentScrollDepthPx()
|
|
499
|
+
if (cur > this.maxScrollDepthPx) this.maxScrollDepthPx = cur
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private prePageview(): void {
|
|
504
|
+
if (this.listeningOnEngagement) {
|
|
505
|
+
this.triggerEngagement()
|
|
506
|
+
this.currentDocumentHeight = this.getDocumentHeight()
|
|
507
|
+
this.maxScrollDepthPx = this.getCurrentScrollDepthPx()
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private postPageview(payload: { url: string, page: string }): void {
|
|
512
|
+
this.currentEngagementIgnored = false
|
|
513
|
+
this.currentEngagementURL = payload.url
|
|
514
|
+
this.currentEngagementMaxScrollDepth = -1
|
|
515
|
+
this.currentEngagementTime = 0
|
|
516
|
+
this.runningEngagementStart = Date.now()
|
|
517
|
+
this.registerEngagementListeners()
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private onVisibilityChange = (): void => {
|
|
521
|
+
if (document.visibilityState === 'visible' && document.hasFocus() && this.runningEngagementStart === 0) {
|
|
522
|
+
this.runningEngagementStart = Date.now()
|
|
523
|
+
} else if (document.visibilityState === 'hidden' || !document.hasFocus()) {
|
|
524
|
+
this.currentEngagementTime = this.getEngagementTime()
|
|
525
|
+
this.runningEngagementStart = 0
|
|
526
|
+
this.triggerEngagement()
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private registerEngagementListeners(): void {
|
|
531
|
+
if (!this.listeningOnEngagement) {
|
|
532
|
+
document.addEventListener('visibilitychange', this.onVisibilityChange)
|
|
533
|
+
window.addEventListener('blur', this.onVisibilityChange)
|
|
534
|
+
window.addEventListener('focus', this.onVisibilityChange)
|
|
535
|
+
this.listeningOnEngagement = true
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private getEngagementTime(): number {
|
|
540
|
+
if (this.runningEngagementStart) {
|
|
541
|
+
return this.currentEngagementTime + (Date.now() - this.runningEngagementStart)
|
|
542
|
+
} else {
|
|
543
|
+
return this.currentEngagementTime
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private triggerEngagement(): void {
|
|
548
|
+
const engagementTime = this.getEngagementTime()
|
|
549
|
+
const increasedScroll = this.currentEngagementMaxScrollDepth < this.maxScrollDepthPx
|
|
550
|
+
// Send if first engagement (maxScrollDepth=-1), or scroll increased, or >=3s engaged
|
|
551
|
+
if (!this.currentEngagementIgnored && (increasedScroll || engagementTime >= 3000 || this.currentEngagementMaxScrollDepth === -1)) {
|
|
552
|
+
this.currentEngagementMaxScrollDepth = this.maxScrollDepthPx
|
|
553
|
+
const sd = this.currentDocumentHeight > 0 ? Math.round((this.maxScrollDepthPx / this.currentDocumentHeight) * 100) : 0
|
|
554
|
+
const url = this.currentEngagementURL || window.location.href
|
|
555
|
+
this.sendEvent({
|
|
556
|
+
name: 'engagement',
|
|
557
|
+
page: window.location.pathname + window.location.search,
|
|
558
|
+
url,
|
|
559
|
+
title: document.title,
|
|
560
|
+
referrer: document.referrer || '',
|
|
561
|
+
screenSize: `${window.innerWidth}x${window.innerHeight}`
|
|
562
|
+
}, { engaged_ms: engagementTime, scroll_depth: sd })
|
|
563
|
+
// Reset timers
|
|
564
|
+
this.runningEngagementStart = 0
|
|
565
|
+
this.currentEngagementTime = 0
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private getDocumentHeight(): number {
|
|
570
|
+
const body = document.body as HTMLElement | null
|
|
571
|
+
const el = document.documentElement as HTMLElement | null
|
|
572
|
+
const b = body || ({} as any)
|
|
573
|
+
const d = el || ({} as any)
|
|
574
|
+
return Math.max(
|
|
575
|
+
b.scrollHeight || 0,
|
|
576
|
+
b.offsetHeight || 0,
|
|
577
|
+
b.clientHeight || 0,
|
|
578
|
+
d.scrollHeight || 0,
|
|
579
|
+
d.offsetHeight || 0,
|
|
580
|
+
d.clientHeight || 0
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private getCurrentScrollDepthPx(): number {
|
|
585
|
+
const body = document.body as HTMLElement | null
|
|
586
|
+
const el = document.documentElement as HTMLElement | null
|
|
587
|
+
const viewportHeight = window.innerHeight || (el && el.clientHeight) || 0
|
|
588
|
+
const scrollTop = window.scrollY || (el && (el as any).scrollTop) || (body && (body as any).scrollTop) || 0
|
|
589
|
+
return this.currentDocumentHeight <= viewportHeight ? this.currentDocumentHeight : (scrollTop as number) + viewportHeight
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ----- Auto-capture: outbound links & file downloads -----
|
|
593
|
+
private initAutoCapture(): void {
|
|
594
|
+
const handler = (event: Event) => {
|
|
595
|
+
// auxclick only for middle button
|
|
596
|
+
if (event.type === 'auxclick' && (event as MouseEvent).button !== 1) return
|
|
597
|
+
const link = this.getLinkEl(event.target as Element | null)
|
|
598
|
+
if (!link || !link.href) return
|
|
599
|
+
try {
|
|
600
|
+
const url = new URL(link.href, window.location.origin)
|
|
601
|
+
// Ignore well-known DevTools config file
|
|
602
|
+
if (url.pathname.startsWith('/.well-known/')) return
|
|
603
|
+
} catch {}
|
|
604
|
+
const hrefWithoutQuery = link.href.split('?')[0]
|
|
605
|
+
if (this.isOutboundLink(link)) {
|
|
606
|
+
this.sendEvent({ name: 'Outbound Link: Click', page: window.location.pathname + window.location.search, url: link.href, title: document.title, referrer: document.referrer || '', screenSize: `${window.innerWidth}x${window.innerHeight}` })
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
if (this.isDownloadToTrack(hrefWithoutQuery)) {
|
|
610
|
+
this.sendEvent({ name: 'File Download', page: window.location.pathname + window.location.search, url: hrefWithoutQuery, title: document.title, referrer: document.referrer || '', screenSize: `${window.innerWidth}x${window.innerHeight}` })
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
document.addEventListener('click', handler)
|
|
614
|
+
document.addEventListener('auxclick', handler)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Parse plausible-style data attributes from the <script> element that loaded this file
|
|
618
|
+
private readScriptAttributes(): void {
|
|
619
|
+
try {
|
|
620
|
+
const scripts = Array.from(document.getElementsByTagName('script')) as HTMLScriptElement[]
|
|
621
|
+
// Heuristic: find a script whose src contains 'analytics' and either data-include or data-exclude set
|
|
622
|
+
const el = scripts.reverse().find((s) => (s.getAttribute('data-include') || s.getAttribute('data-exclude') || (s.src && s.src.includes('analytics'))))
|
|
623
|
+
if (!el) return
|
|
624
|
+
|
|
625
|
+
const includeAttr = el.getAttribute('data-include')
|
|
626
|
+
const excludeAttr = el.getAttribute('data-exclude')
|
|
627
|
+
if (includeAttr) this.config.includePaths = includeAttr.split(',').map((t) => t.trim()).filter(Boolean)
|
|
628
|
+
if (excludeAttr) this.config.excludePaths = [...this.config.excludePaths, ...excludeAttr.split(',').map((t) => t.trim()).filter(Boolean)]
|
|
629
|
+
} catch {}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Wildcard matching similar to Plausible tracker
|
|
633
|
+
// Supports '*' (single segment) and '**' (any), anchored to start and end by default
|
|
634
|
+
private pathMatches(wildcardPath: string, actualPath: string): boolean {
|
|
635
|
+
const wc = wildcardPath.trim()
|
|
636
|
+
const pattern = '^' + wc
|
|
637
|
+
.replace(/\./g, '\\.')
|
|
638
|
+
.replace(/\*\*/g, '.*')
|
|
639
|
+
.replace(/([^\\])\*/g, '$1[^\\s\/]*') + '\\/?$'
|
|
640
|
+
try {
|
|
641
|
+
return new RegExp(pattern).test(actualPath)
|
|
642
|
+
} catch {
|
|
643
|
+
return false
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private getLinkEl(node: Element | null): HTMLAnchorElement | null {
|
|
648
|
+
let el: Element | null = node
|
|
649
|
+
let depth = 0
|
|
650
|
+
while (el && depth < 5) {
|
|
651
|
+
if (el instanceof HTMLAnchorElement && el.href) return el
|
|
652
|
+
el = el.parentElement
|
|
653
|
+
depth++
|
|
654
|
+
}
|
|
655
|
+
return null
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private isOutboundLink(link: HTMLAnchorElement): boolean {
|
|
659
|
+
try {
|
|
660
|
+
return !!link.host && link.host !== window.location.host
|
|
661
|
+
} catch { return false }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private isDownloadToTrack(url: string | undefined | null): boolean {
|
|
665
|
+
if (!url) return false
|
|
666
|
+
const DEFAULT_FILE_TYPES = ['pdf','xlsx','docx','txt','rtf','csv','exe','key','pps','ppt','pptx','7z','pkg','rar','gz','zip','avi','mov','mp4','mpeg','wmv','midi','mp3','wav','wma','dmg']
|
|
667
|
+
const ext = url.split('.').pop()?.toLowerCase()
|
|
668
|
+
return !!ext && DEFAULT_FILE_TYPES.includes(ext)
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Auto-initialize when script loads (singleton pattern)
|
|
673
|
+
if (typeof window !== 'undefined') {
|
|
674
|
+
// Prevent multiple instances
|
|
675
|
+
if (!(window as any).__analyticsInitialized) {
|
|
676
|
+
const analytics = new StandaloneAnalytics()
|
|
677
|
+
analytics.init()
|
|
678
|
+
;(window as any).__analyticsInitialized = true
|
|
679
|
+
}
|
|
680
|
+
}
|