diff --git a/src/SDK/Language/Flutter.php b/src/SDK/Language/Flutter.php index a9c4e29331..c1b6e28210 100644 --- a/src/SDK/Language/Flutter.php +++ b/src/SDK/Language/Flutter.php @@ -216,6 +216,16 @@ public function getFiles(): array 'destination' => '/lib/src/realtime_response_connected.dart', 'template' => 'flutter/lib/src/realtime_response_connected.dart.twig', ], + [ + 'scope' => 'default', + 'destination' => '/lib/src/analytics_observer.dart', + 'template' => 'flutter/lib/src/analytics_observer.dart.twig', + ], + [ + 'scope' => 'default', + 'destination' => '/lib/src/analytics_tracking.dart', + 'template' => 'flutter/lib/src/analytics_tracking.dart.twig', + ], [ 'scope' => 'default', 'destination' => '/lib/src/cookie_manager.dart', diff --git a/src/SDK/Language/Web.php b/src/SDK/Language/Web.php index 1af2265a27..3f86eed8ca 100644 --- a/src/SDK/Language/Web.php +++ b/src/SDK/Language/Web.php @@ -55,6 +55,11 @@ public function getFiles(): array 'destination' => 'src/services/realtime.ts', 'template' => 'web/src/services/realtime.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => 'src/services/analytics-tracking.ts', + 'template' => 'web/src/services/analytics-tracking.ts.twig', + ], [ 'scope' => 'default', 'destination' => 'src/models.ts', diff --git a/templates/flutter/lib/package.dart.twig b/templates/flutter/lib/package.dart.twig index 515452fa64..b223287358 100644 --- a/templates/flutter/lib/package.dart.twig +++ b/templates/flutter/lib/package.dart.twig @@ -25,6 +25,8 @@ export 'src/upload_progress.dart'; export 'src/realtime_subscription.dart'; export 'src/realtime_message.dart'; export 'src/input_file.dart'; +export 'src/analytics_observer.dart'; +export 'src/analytics_tracking.dart'; part 'query.dart'; part 'permission.dart'; diff --git a/templates/flutter/lib/src/analytics_observer.dart.twig b/templates/flutter/lib/src/analytics_observer.dart.twig new file mode 100644 index 0000000000..e108355999 --- /dev/null +++ b/templates/flutter/lib/src/analytics_observer.dart.twig @@ -0,0 +1,140 @@ +import 'package:flutter/widgets.dart'; + +/// Signature used to emit an analytics event from an [AnalyticsObserver] or +/// [AnalyticsTracking] instance. +/// +/// The generated `Analytics` service exposes `event({required String name, ...})`, +/// so the typical wiring is: +/// +/// ```dart +/// final analytics = Analytics(client); +/// final observer = AnalyticsObserver( +/// (name, {props, propertyId, engagementTime}) => analytics.event( +/// name: name, +/// props: props, +/// propertyId: propertyId, +/// engagementTime: engagementTime, +/// ), +/// propertyId: '', +/// ); +/// ``` +/// +/// [propertyId] and [engagementTime] map to the endpoint's top-level params of +/// the same name; both are null unless the helper has something to say about +/// them, so a closure is free to ignore either. +typedef AnalyticsEventEmitter = void Function( + String name, { + Map? props, + String? propertyId, + int? engagementTime, +}); + +/// Signature used to derive a screen name from a [Route]. +/// +/// Return `null` to skip tracking the route entirely (for example, dialogs or +/// bottom sheets that shouldn't count as screen views). +typedef ScreenNameExtractor = String? Function(Route route); + +/// Default [ScreenNameExtractor] — uses [RouteSettings.name] when present and +/// falls back to `null` (route is skipped) otherwise. Anonymous routes are +/// generally noise for analytics, so opting out by default keeps dashboards +/// clean. +String? defaultScreenNameExtractor(Route route) { + return route.settings.name; +} + +/// [NavigatorObserver] that fires an analytics event every time a named route +/// is pushed, popped-back-to, replaced or removed. +/// +/// Attach to a `MaterialApp` (or `CupertinoApp`, `WidgetsApp`) via +/// `navigatorObservers`: +/// +/// ```dart +/// MaterialApp( +/// navigatorObservers: [ +/// AnalyticsObserver( +/// (name, {props, propertyId, engagementTime}) => analytics.event( +/// name: name, +/// props: props, +/// propertyId: propertyId, +/// engagementTime: engagementTime, +/// ), +/// propertyId: '', +/// ), +/// ], +/// ... +/// ); +/// ``` +class AnalyticsObserver extends NavigatorObserver { + /// Callback used to send the resulting `screen_view` event. + final AnalyticsEventEmitter emit; + + /// Optional route-name extractor. Defaults to [defaultScreenNameExtractor] + /// which reads [RouteSettings.name]. + final ScreenNameExtractor nameExtractor; + + /// Event name used for screen views. Defaults to `screen_view`. + final String eventName; + + /// Analytics property (or snippet) ID forwarded with every emitted event. + /// Optional — leave null when the emitter closure already supplies one. + final String? propertyId; + + AnalyticsObserver( + this.emit, { + ScreenNameExtractor? nameExtractor, + this.eventName = 'screen_view', + this.propertyId, + }) : nameExtractor = nameExtractor ?? defaultScreenNameExtractor; + + @override + void didPush(Route route, Route? previousRoute) { + _sendScreenView(route, previousRoute, trigger: 'push'); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + if (newRoute != null) { + _sendScreenView(newRoute, oldRoute, trigger: 'replace'); + } + } + + @override + void didPop(Route route, Route? previousRoute) { + if (previousRoute != null) { + _sendScreenView(previousRoute, route, trigger: 'pop'); + } + } + + @override + void didRemove(Route route, Route? previousRoute) { + if (previousRoute != null) { + _sendScreenView(previousRoute, route, trigger: 'remove'); + } + } + + void _sendScreenView( + Route route, + Route? previousRoute, { + required String trigger, + }) { + final name = nameExtractor(route); + if (name == null || name.isEmpty) { + return; + } + final props = { + 'screen': name, + 'trigger': trigger, + }; + final previousName = previousRoute == null ? null : nameExtractor(previousRoute); + if (previousName != null && previousName.isNotEmpty) { + props['previous'] = previousName; + } + try { + emit(eventName, props: props, propertyId: propertyId); + } catch (_) { + // Emitter failures must never propagate into the navigator stack — + // swallow so analytics can't crash the app. + } + } +} diff --git a/templates/flutter/lib/src/analytics_tracking.dart.twig b/templates/flutter/lib/src/analytics_tracking.dart.twig new file mode 100644 index 0000000000..fd9d5aeb73 --- /dev/null +++ b/templates/flutter/lib/src/analytics_tracking.dart.twig @@ -0,0 +1,181 @@ +import 'package:flutter/widgets.dart'; + +import 'analytics_observer.dart'; + +export 'analytics_observer.dart' show AnalyticsEventEmitter, AnalyticsObserver, ScreenNameExtractor; + +/// Companion class for the generated `Analytics` service that wires up the +/// mobile-idiomatic auto-tracking primitives: app-lifecycle events (backgrounded +/// / foregrounded) and manual `screenView` calls. +/// +/// Auto-emitted event names follow `snake_case` + lowercase (`pageview`, +/// `screen_view`, `app_backgrounded`, `app_foregrounded`, etc.). Prop keys use +/// camelCase to match the endpoint's parameter naming. +/// +/// Attach an [AnalyticsObserver] to your `MaterialApp` for automatic route +/// tracking; use [AnalyticsTracking] on top for lifecycle events and the +/// convenience `screenView` / `event` shortcuts. +/// +/// [enableAutoLifecycleEvents] calls [WidgetsFlutterBinding.ensureInitialized] +/// internally, so it is safe to invoke before [runApp] without wiring up the +/// binding manually. Callers who need the binding for their own initialization +/// (async setup, plugin channels, etc.) can still call `ensureInitialized()` +/// themselves — the call is idempotent. +/// +/// Engagement time is reported the same way the Web helper reports it: as a +/// **delta** attached to each `app_backgrounded` event, covering only the +/// foreground time accrued since the last resume. Backgrounding is the mobile +/// equivalent of a browser tab going hidden, and it is where the Web helper +/// flushes too, so the two platforms feed the same additive `engagementTime` +/// column. The seconds ride on the lifecycle event that is already being sent +/// at that exact moment rather than adding a second request for a single +/// number. +/// +/// Typical wiring: +/// +/// ```dart +/// final analytics = Analytics(client); +/// final emit = (String name, {Map? props, String? propertyId, int? engagementTime}) => +/// analytics.event( +/// name: name, +/// props: props, +/// propertyId: propertyId, +/// engagementTime: engagementTime, +/// ); +/// +/// final tracking = AnalyticsTracking(emit, propertyId: ''); +/// tracking.enableAllAutoTracking(); +/// +/// runApp(MaterialApp( +/// navigatorObservers: [ +/// AnalyticsObserver(emit, propertyId: ''), +/// ], +/// home: MyApp(), +/// )); +/// ``` +class AnalyticsTracking with WidgetsBindingObserver { + final AnalyticsEventEmitter _emit; + + /// Analytics property (or snippet) ID forwarded with every emitted event. + /// Optional — leave null when the emitter closure already supplies one. + final String? propertyId; + + bool _lifecycleAttached = false; + DateTime? _foregroundSince; + + /// Sub-second engagement left over from the previous flush. Carried forward + /// so a session made of many short foreground stretches does not lose a + /// fraction of a second to truncation on every one of them. + Duration _engagementCarry = Duration.zero; + + AnalyticsTracking(AnalyticsEventEmitter emit, {this.propertyId}) : _emit = emit; + + /// Enable the opinionated default auto-tracking set. Currently this covers + /// lifecycle events; route tracking is opt-in via [AnalyticsObserver]. + void enableAllAutoTracking() { + enableAutoLifecycleEvents(); + } + + /// Start emitting `app_backgrounded` and `app_foregrounded` events when the + /// host app changes lifecycle state. Idempotent — repeat calls no-op. + /// + /// Calls [WidgetsFlutterBinding.ensureInitialized] internally so this is + /// safe to invoke before [runApp] without the caller having to bootstrap + /// the binding themselves. + void enableAutoLifecycleEvents() { + if (_lifecycleAttached) { + return; + } + // WidgetsBinding.instance throws StateError if the binding has not been + // set up yet. The docstring example wires this call before runApp(), so + // ensure the binding here — the call is idempotent. + WidgetsFlutterBinding.ensureInitialized(); + _lifecycleAttached = true; + _foregroundSince = DateTime.now(); + _engagementCarry = Duration.zero; + WidgetsBinding.instance.addObserver(this); + } + + /// Stop emitting lifecycle events. Safe to call when auto-lifecycle was + /// never enabled. + void disableAutoLifecycleEvents() { + if (!_lifecycleAttached) { + return; + } + _lifecycleAttached = false; + _foregroundSince = null; + _engagementCarry = Duration.zero; + WidgetsBinding.instance.removeObserver(this); + } + + /// Emit an ad-hoc analytics event. Emitter exceptions are swallowed so + /// analytics failures never surface to the host app. + void event( + String name, { + Map? props, + int? engagementTime, + }) { + try { + _emit( + name, + props: props, + propertyId: propertyId, + engagementTime: engagementTime, + ); + } catch (_) { + // Silent — see class docs. + } + } + + /// Mobile-idiomatic screen-view shortcut. Renders as a `screen_view` + /// analytics event with `screen`, optional `screenClass` and any + /// caller-supplied properties. + void screenView( + String name, { + String? className, + Map? props, + }) { + final merged = {'screen': name}; + if (className != null) { + merged['screenClass'] = className; + } + if (props != null) { + merged.addAll(props); + } + event('screen_view', props: merged); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + // On Flutter desktop and web the lifecycle transitions through both + // `hidden` and `paused` when the window is minimised / closed. Guard + // on `_foregroundSince` so we emit `app_backgrounded` exactly once + // per background transition instead of firing a second empty event — + // which is also what keeps the engagement delta from being billed + // twice for one transition. + final since = _foregroundSince; + if (since == null) { + break; + } + _foregroundSince = null; + final elapsed = _engagementCarry + DateTime.now().difference(since); + final seconds = elapsed.inSeconds; + _engagementCarry = elapsed - Duration(seconds: seconds); + event( + 'app_backgrounded', + engagementTime: seconds > 0 ? seconds : null, + ); + break; + case AppLifecycleState.resumed: + _foregroundSince = DateTime.now(); + event('app_foregrounded'); + break; + case AppLifecycleState.inactive: + case AppLifecycleState.detached: + break; + } + } +} diff --git a/templates/web/src/index.ts.twig b/templates/web/src/index.ts.twig index c8595159f6..810b2cf80e 100644 --- a/templates/web/src/index.ts.twig +++ b/templates/web/src/index.ts.twig @@ -10,8 +10,16 @@ export { Client, Query, {{spec.title | caseUcfirst}}Exception } from './client'; export { {{service.name | caseUcfirst}} } from './services/{{service.name | caseKebab}}'; {% endfor %} export { Realtime } from './services/realtime'; +export { AnalyticsTracking } from './services/analytics-tracking'; export type { Models, Payload, RealtimeResponseEvent, UploadProgress } from './client'; export type { RealtimeSubscription } from './services/realtime'; +export type { + AnalyticsEventEmitter, + AnalyticsEventOptions, + AnalyticsTrackingOptions, + DownloadTrackingOptions, + OutboundTrackingOptions +} from './services/analytics-tracking'; export type { QueryTypes, QueryTypesList } from './query'; export { Permission } from './permission'; export { Role } from './role'; diff --git a/templates/web/src/services/analytics-tracking.ts.twig b/templates/web/src/services/analytics-tracking.ts.twig new file mode 100644 index 0000000000..1e129bb13f --- /dev/null +++ b/templates/web/src/services/analytics-tracking.ts.twig @@ -0,0 +1,627 @@ +/** + * Analytics auto-tracking helpers. + * + * These are hand-written companions to the generated `Analytics` service. + * The generated service exposes `event(params)` for arbitrary events; this + * module wires up common auto-tracking behaviours (pageviews, outbound links, + * downloads, scroll depth, active engagement) on top of it via composition. + * + * The tracking helpers accept a plain event-emitter function so they do not + * hard-couple to the generated `Analytics` class. Typical wiring: + * + * ```ts + * const analytics = new Analytics(client); + * const tracking = new AnalyticsTracking( + * (name, options) => analytics.event({ name, ...(options ?? {}) }), + * { propertyId: '' } + * ); + * tracking.enableAllAutoTracking(); + * ``` + * + * `propertyId` is optional: when set it is merged into the options of every + * emitted event, so an emitter that spreads its options (as above) forwards it + * automatically instead of every caller hand-rolling it into the closure. + * Per-event `propertyId` always wins over the configured default. + * + * All helpers respect `navigator.doNotTrack === '1'` by default; opt out with + * `respectDoNotTrack: false` in the constructor options. + * + * Engagement time follows Plausible's model: rather than one total at unload, + * a delta is emitted at every natural checkpoint of the visit (tab hidden, SPA + * route change, unload). See `enableAutoEngagementTime` for details. + * + * Auto-emitted event names follow `snake_case` + lowercase (`pageview`, + * `outbound_link`, `file_download`, `screen_view`, etc.). Any prop keys use + * camelCase to match the endpoint's parameter naming. Callers can pass any + * custom event name; the auto-track methods only own the events they emit + * themselves. + */ + +export type AnalyticsEventOptions = { + props?: Record; + url?: string; + referrer?: string; + propertyId?: string; // analytics property (or snippet) ID; forwarded to cloud endpoint as top-level param + scrollDepth?: number; // 0-100 percentage; forwarded to cloud endpoint as top-level param + engagementTime?: number; // seconds since the previous flush; forwarded to cloud endpoint as top-level param +} + +export type AnalyticsEventEmitter = (name: string, options?: AnalyticsEventOptions) => unknown; + +export type AnalyticsTrackingOptions = { + /** + * When true (default) the tracker becomes a no-op if `navigator.doNotTrack` + * is set to `'1'`. Set to false to always fire regardless of the DNT signal. + */ + respectDoNotTrack?: boolean; + + /** + * Analytics property (or snippet) ID to attach to every emitted event. + * Optional — omit it when the emitter closure already supplies one. A + * `propertyId` passed on an individual event takes precedence. + */ + propertyId?: string; +} + +export type OutboundTrackingOptions = { + /** + * When true, links pointing at the current host's subdomains are treated + * as outbound. Defaults to false (subdomains are same-origin for tracking). + */ + includeSubdomains?: boolean; +} + +export type DownloadTrackingOptions = { + /** + * File extensions (without the leading dot) that mark an anchor as a + * download. Defaults cover the common set. + */ + extensions?: string[]; +} + +const DEFAULT_DOWNLOAD_EXTENSIONS = [ + 'pdf', 'zip', 'rar', '7z', 'tar', 'gz', + 'csv', 'tsv', 'xls', 'xlsx', 'doc', 'docx', 'ppt', 'pptx', + 'dmg', 'exe', 'msi', 'pkg', 'deb', 'rpm', 'apk', + 'mp3', 'wav', 'flac', + 'mp4', 'mov', 'avi', 'mkv', 'webm' +]; + +const SCROLL_DEPTH_THROTTLE_MS = 200; + +type Cleanup = () => void; + +export class AnalyticsTracking { + private readonly emit: AnalyticsEventEmitter; + private readonly respectDoNotTrack: boolean; + private readonly propertyId?: string; + + private pageviewCleanups: Cleanup[] = []; + private outboundCleanup?: Cleanup; + private downloadCleanup?: Cleanup; + private scrollCleanups: Cleanup[] = []; + private engagementCleanups: Cleanup[] = []; + + private currentPath?: string; + private maxScrollDepth = 0; + private scrollTicking = false; + private lastScrollReport = 0; + + private engagementStart = 0; + private engagementAccumulatedMs = 0; + private engagementActive = false; + + constructor(emit: AnalyticsEventEmitter, options: AnalyticsTrackingOptions = {}) { + this.emit = emit; + this.respectDoNotTrack = options.respectDoNotTrack !== false; + this.propertyId = options.propertyId; + } + + /** + * Enable the opinionated default auto-tracking set: pageviews, outbound + * links, scroll depth and active engagement time. Downloads must be opted + * into separately via `enableAutoDownloadTracking()` because the extension + * list is application-specific. + */ + public enableAllAutoTracking(): void { + this.enableAutoPageviews(); + this.enableAutoOutboundTracking(); + this.enableAutoScrollDepth(); + this.enableAutoEngagementTime(); + } + + /** + * Track an event manually. Respects the DNT setting and swallows emitter + * exceptions so tracking failures never surface to the host page. + */ + public track(name: string, options?: AnalyticsEventOptions): void { + if (!this.canTrack()) { + return; + } + try { + this.emit(name, this.withPropertyId(options)); + } catch (error) { + console.warn('AnalyticsTracking: emitter threw', error); + } + } + + /** + * Convenience wrapper for pageview-shaped events. + */ + public pageview(url?: string, referrer?: string): void { + this.track('pageview', { + url: url ?? this.currentPageUrl(), + referrer: referrer ?? (typeof document !== 'undefined' ? document.referrer : undefined) + }); + } + + /** + * Enable SPA-aware pageview tracking. Fires an initial pageview on + * enable, then again on every `pushState`, `replaceState`, `popstate` + * or hashchange. Idempotent — repeat calls no-op. + */ + public enableAutoPageviews(): void { + if (this.pageviewCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof history === 'undefined') { + return; + } + + this.currentPath = this.currentPageUrl(); + this.pageview(); + + const handleNavigation = (): void => { + const nextPath = this.currentPageUrl(); + if (nextPath === this.currentPath) { + return; + } + const previous = this.currentPath; + this.currentPath = nextPath; + this.flushScrollDepth(previous); + this.flushEngagement(previous); + this.pageview(nextPath); + // flushEngagement no longer auto-restarts the timer (see its + // implementation note re: bfcache). SPA navigation stays on the + // same document, so restart engagement tracking for the new + // route as long as the tab is visible. + if (this.engagementCleanups.length > 0 && + (typeof document === 'undefined' || document.visibilityState !== 'hidden')) { + this.startEngagement(); + } + }; + + const originalPushState = history.pushState.bind(history); + const originalReplaceState = history.replaceState.bind(history); + + history.pushState = (data: unknown, unused: string, url?: string | URL | null): void => { + originalPushState(data, unused, url); + handleNavigation(); + }; + history.replaceState = (data: unknown, unused: string, url?: string | URL | null): void => { + originalReplaceState(data, unused, url); + handleNavigation(); + }; + + const popstateHandler = (): void => handleNavigation(); + const hashchangeHandler = (): void => handleNavigation(); + + window.addEventListener('popstate', popstateHandler); + window.addEventListener('hashchange', hashchangeHandler); + + this.pageviewCleanups.push(() => { + history.pushState = originalPushState; + history.replaceState = originalReplaceState; + window.removeEventListener('popstate', popstateHandler); + window.removeEventListener('hashchange', hashchangeHandler); + }); + } + + /** + * Disable auto-pageview tracking installed by `enableAutoPageviews`. + * Safe to call when auto-pageviews were never enabled. + */ + public disableAutoPageviews(): void { + for (const cleanup of this.pageviewCleanups) { + cleanup(); + } + this.pageviewCleanups = []; + } + + /** + * Enable click-based outbound link tracking. Detects anchor clicks that + * navigate to a different origin and fires an `outbound_link` event + * (with `hostname` and `href` props) before the browser navigates. + */ + public enableAutoOutboundTracking(options: OutboundTrackingOptions = {}): void { + if (this.outboundCleanup) { + return; + } + if (typeof document === 'undefined') { + return; + } + + const includeSubdomains = options.includeSubdomains === true; + + const clickHandler = (event: MouseEvent): void => { + const anchor = this.findAnchor(event.target); + if (!anchor || !anchor.href) { + return; + } + const target = this.parseUrl(anchor.href); + if (!target || !this.isExternal(target, includeSubdomains)) { + return; + } + this.track('outbound_link', { + url: target.href, + props: { hostname: target.hostname, href: target.href } + }); + }; + + document.addEventListener('click', clickHandler, { capture: true }); + this.outboundCleanup = () => document.removeEventListener('click', clickHandler, { capture: true }); + } + + /** + * Disable outbound-link tracking installed by `enableAutoOutboundTracking`. + * Safe to call when auto-outbound was never enabled. + */ + public disableAutoOutboundTracking(): void { + if (this.outboundCleanup) { + this.outboundCleanup(); + this.outboundCleanup = undefined; + } + } + + /** + * Enable click-based download tracking. Detects anchor clicks that point + * at a file whose extension appears in the configured list and fires a + * `file_download` event with `filename`, `extension` and `href` props. + */ + public enableAutoDownloadTracking(options: DownloadTrackingOptions = {}): void { + if (this.downloadCleanup) { + return; + } + if (typeof document === 'undefined') { + return; + } + + const extensions = (options.extensions ?? DEFAULT_DOWNLOAD_EXTENSIONS) + .map(ext => ext.toLowerCase().replace(/^\./, '')); + const extensionSet = new Set(extensions); + + const clickHandler = (event: MouseEvent): void => { + const anchor = this.findAnchor(event.target); + if (!anchor || !anchor.href) { + return; + } + const url = this.parseUrl(anchor.href); + if (!url) { + return; + } + const filename = url.pathname.split('/').pop() ?? ''; + const extension = filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : undefined; + if (!extension || !extensionSet.has(extension)) { + return; + } + this.track('file_download', { + url: url.href, + props: { filename, extension, href: url.href } + }); + }; + + document.addEventListener('click', clickHandler, { capture: true }); + this.downloadCleanup = () => document.removeEventListener('click', clickHandler, { capture: true }); + } + + /** + * Disable file-download tracking installed by `enableAutoDownloadTracking`. + * Safe to call when auto-download was never enabled. + */ + public disableAutoDownloadTracking(): void { + if (this.downloadCleanup) { + this.downloadCleanup(); + this.downloadCleanup = undefined; + } + } + + /** + * Enable passive scroll-depth tracking. Records the maximum percent of the + * document scrolled and reports it on `beforeunload` and on SPA route + * changes. Uses a throttled passive listener so it is safe on hot paths. + */ + public enableAutoScrollDepth(): void { + if (this.scrollCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + this.maxScrollDepth = 0; + this.lastScrollReport = 0; + + const scrollHandler = (): void => { + if (this.scrollTicking) { + return; + } + this.scrollTicking = true; + window.requestAnimationFrame(() => { + const now = Date.now(); + if (now - this.lastScrollReport < SCROLL_DEPTH_THROTTLE_MS) { + this.scrollTicking = false; + return; + } + this.lastScrollReport = now; + const depth = this.computeScrollDepth(); + if (depth > this.maxScrollDepth) { + this.maxScrollDepth = depth; + } + this.scrollTicking = false; + }); + }; + + const unloadHandler = (): void => { + this.flushScrollDepth(this.currentPageUrl()); + }; + + window.addEventListener('scroll', scrollHandler, { passive: true }); + window.addEventListener('beforeunload', unloadHandler); + window.addEventListener('pagehide', unloadHandler); + + this.scrollCleanups.push(() => { + window.removeEventListener('scroll', scrollHandler); + window.removeEventListener('beforeunload', unloadHandler); + window.removeEventListener('pagehide', unloadHandler); + }); + } + + /** + * Disable scroll-depth tracking installed by `enableAutoScrollDepth`. Safe + * to call when auto-scroll-depth was never enabled. Any accumulated depth + * for the current page is dropped. + */ + public disableAutoScrollDepth(): void { + for (const cleanup of this.scrollCleanups) { + cleanup(); + } + this.scrollCleanups = []; + this.maxScrollDepth = 0; + this.scrollTicking = false; + this.lastScrollReport = 0; + } + + /** + * Enable active engagement-time tracking. Counts time the tab is visible + * and uses `visibilitychange` to exclude background time. + * + * Delivery follows Plausible's model rather than a single total at the + * end of the visit: an `engagement_time` event is emitted at every natural + * checkpoint — the tab going hidden, an SPA route change, and finally + * `beforeunload`/`pagehide`. Unload is kept only as a best-effort last + * flush; it is the least reliable moment to send anything (browsers + * routinely cancel in-flight requests there, and `sendBeacon` cannot set + * the `X-Appwrite-Project` header the endpoint requires), so it must never + * be the only delivery. + * + * Each event carries a **delta** — the seconds accrued since the previous + * flush, not a running total — so the values are additive per session and + * a lost event costs only its own slice. + * + * There is deliberately no periodic ping. The cadence is driven by + * navigation and tab switches, so request volume tracks what the visitor + * actually does instead of multiplying with time on page. + */ + public enableAutoEngagementTime(): void { + if (this.engagementCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + this.engagementAccumulatedMs = 0; + // Do not start the clock for a tab that is already hidden (prerender, + // background tab restore); `visibilitychange` starts it when it is + // actually looked at. + if (document.visibilityState !== 'hidden') { + this.startEngagement(); + } + + const visibilityHandler = (): void => { + if (document.visibilityState === 'hidden') { + // Flush, don't just pause. The page is still fully alive here, + // which makes this the most reliable delivery point in the + // whole visit — and it is the same moment Plausible reports on. + this.flushEngagement(this.currentPageUrl()); + } else if (document.visibilityState === 'visible') { + this.startEngagement(); + } + }; + + const unloadHandler = (): void => { + this.flushEngagement(this.currentPageUrl()); + }; + + const pageshowHandler = (event: PageTransitionEvent): void => { + // When the page is restored from the back-forward cache the + // engagement timer is in a stopped state (flushEngagement paused + // it on pagehide and does not auto-restart). Kick off a fresh + // engagementStart so bfcache dwell time is not billed as + // engagement. + if (event.persisted) { + this.startEngagement(); + } + }; + + document.addEventListener('visibilitychange', visibilityHandler); + window.addEventListener('beforeunload', unloadHandler); + window.addEventListener('pagehide', unloadHandler); + window.addEventListener('pageshow', pageshowHandler); + + this.engagementCleanups.push(() => { + document.removeEventListener('visibilitychange', visibilityHandler); + window.removeEventListener('beforeunload', unloadHandler); + window.removeEventListener('pagehide', unloadHandler); + window.removeEventListener('pageshow', pageshowHandler); + }); + } + + /** + * Disable engagement-time tracking installed by `enableAutoEngagementTime`. + * Safe to call when auto-engagement was never enabled. Any accumulated + * engagement time is dropped without being emitted. + */ + public disableAutoEngagementTime(): void { + for (const cleanup of this.engagementCleanups) { + cleanup(); + } + this.engagementCleanups = []; + this.engagementActive = false; + this.engagementAccumulatedMs = 0; + this.engagementStart = 0; + } + + /** + * Merge the configured `propertyId` into an event's options. Returns the + * caller's object untouched when no default is configured (or the event + * already carries one), so emitters written against the pre-`propertyId` + * shape keep receiving exactly what they received before. + */ + private withPropertyId(options?: AnalyticsEventOptions): AnalyticsEventOptions | undefined { + if (this.propertyId === undefined || options?.propertyId !== undefined) { + return options; + } + return { ...(options ?? {}), propertyId: this.propertyId }; + } + + private canTrack(): boolean { + if (!this.respectDoNotTrack) { + return true; + } + if (typeof navigator === 'undefined') { + return true; + } + return navigator.doNotTrack !== '1'; + } + + private currentPageUrl(): string { + if (typeof window === 'undefined' || !window.location) { + return ''; + } + return window.location.href; + } + + private findAnchor(target: EventTarget | null): HTMLAnchorElement | null { + let node = target as Node | null; + while (node) { + if ((node as HTMLElement).tagName === 'A') { + return node as HTMLAnchorElement; + } + node = (node as Node).parentNode; + } + return null; + } + + private parseUrl(href: string): URL | null { + try { + return new URL(href, window.location.href); + } catch { + return null; + } + } + + private isExternal(target: URL, includeSubdomains: boolean): boolean { + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + return false; + } + if (target.hostname === window.location.hostname) { + return false; + } + if (includeSubdomains) { + return true; + } + const root = this.rootDomain(window.location.hostname); + return this.rootDomain(target.hostname) !== root; + } + + private rootDomain(hostname: string): string { + const parts = hostname.split('.'); + if (parts.length <= 2) { + return hostname; + } + return parts.slice(-2).join('.'); + } + + private computeScrollDepth(): number { + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const viewport = window.innerHeight || document.documentElement.clientHeight; + const total = document.documentElement.scrollHeight; + if (total <= viewport) { + return 100; + } + const depth = Math.round(((scrollTop + viewport) / total) * 100); + return Math.max(0, Math.min(100, depth)); + } + + private flushScrollDepth(url: string | undefined): void { + if (this.scrollCleanups.length === 0) { + return; + } + if (this.maxScrollDepth <= 0) { + return; + } + this.track('scroll_depth', { + url, + scrollDepth: this.maxScrollDepth + }); + this.maxScrollDepth = 0; + } + + private startEngagement(): void { + if (this.engagementActive) { + return; + } + this.engagementActive = true; + this.engagementStart = Date.now(); + } + + private pauseEngagement(): void { + if (!this.engagementActive) { + return; + } + this.engagementActive = false; + this.engagementAccumulatedMs += Date.now() - this.engagementStart; + } + + private flushEngagement(url: string | undefined): void { + if (this.engagementCleanups.length === 0) { + return; + } + this.pauseEngagement(); + // Deltas, not a running total: emit only what accrued since the last + // flush. `floor` plus keeping the sub-second remainder in the + // accumulator is what makes repeated flushes safe — nothing is emitted + // twice, and a visit chopped into many short segments does not lose a + // fraction of a second to rounding on every one of them. + const seconds = Math.floor(this.engagementAccumulatedMs / 1000); + if (seconds > 0) { + // Deduct before emitting so a synchronous emitter that re-enters + // (or throws) can never bill the same seconds again. + this.engagementAccumulatedMs -= seconds * 1000; + this.track('engagement_time', { + url, + engagementTime: seconds + }); + } + // NOTE: intentionally do NOT call startEngagement() here. flushEngagement + // runs on `pagehide`, which is also the last hook before the browser + // may freeze the page into the back-forward cache. Auto-restarting + // would snapshot engagementStart just before the freeze; the next + // pauseEngagement (after bfcache restore) would then bill the entire + // bfcache dwell as active engagement. Restart is driven by explicit + // resume signals: `pageshow` (with `persisted === true`), + // `visibilitychange` back to visible, or the SPA navigation flow in + // `enableAutoPageviews`. + } +}