diff --git a/src/SDK/Language/Flutter.php b/src/SDK/Language/Flutter.php index 1e6774b089..518cc85ed3 100644 --- a/src/SDK/Language/Flutter.php +++ b/src/SDK/Language/Flutter.php @@ -328,15 +328,15 @@ public function getFiles(): array ], [ 'scope' => 'default', - 'destination' => '/lib/src/analytics_observer.dart', + 'destination' => '/lib/src/tracking.dart', 'requires' => 'analytics', - 'template' => 'flutter/lib/src/analytics_observer.dart.twig', + 'template' => 'flutter/lib/src/tracking.dart.twig', ], [ 'scope' => 'default', - 'destination' => '/lib/src/analytics_tracking.dart', + 'destination' => '/lib/src/tracking_observer.dart', 'requires' => 'analytics', - 'template' => 'flutter/lib/src/analytics_tracking.dart.twig', + 'template' => 'flutter/lib/src/tracking_observer.dart.twig', ], [ 'scope' => 'default', diff --git a/templates/flutter/lib/package.dart.twig b/templates/flutter/lib/package.dart.twig index f54d71f869..56ddf62127 100644 --- a/templates/flutter/lib/package.dart.twig +++ b/templates/flutter/lib/package.dart.twig @@ -31,8 +31,8 @@ export 'src/realtime_subscription.dart'; export 'src/realtime_message.dart'; export 'src/input_file.dart'; {% if services['analytics'] is defined %} -export 'src/analytics_observer.dart'; -export 'src/analytics_tracking.dart'; +export 'src/tracking.dart'; +export 'src/tracking_observer.dart'; {% endif %} part 'query.dart'; diff --git a/templates/flutter/lib/src/analytics_tracking.dart.twig b/templates/flutter/lib/src/tracking.dart.twig similarity index 62% rename from templates/flutter/lib/src/analytics_tracking.dart.twig rename to templates/flutter/lib/src/tracking.dart.twig index 0026e984a6..0210399b79 100644 --- a/templates/flutter/lib/src/analytics_tracking.dart.twig +++ b/templates/flutter/lib/src/tracking.dart.twig @@ -1,22 +1,20 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'analytics_observer.dart'; +import '../{{ language.params.packageName }}.dart' show Analytics; -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-tracking helpers for the generated [Analytics] service: app-lifecycle +/// events (backgrounded / foregrounded) and manual `screenView` / `event` +/// calls, all sent through `Analytics.createEvent`. /// -/// 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. +/// Auto-emitted event names follow `snake_case` + lowercase (`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. +/// Attach a [TrackingObserver] to your `MaterialApp` for automatic route +/// tracking. /// /// [enableAutoLifecycleEvents] calls [WidgetsFlutterBinding.ensureInitialized] /// internally, so it is safe to invoke before [runApp] without wiring up the @@ -36,38 +34,24 @@ export 'analytics_observer.dart' show AnalyticsEventEmitter, AnalyticsObserver, /// Typical wiring: /// /// ```dart -/// final analytics = Analytics(client); -/// -/// // `createEvent` requires `url` and takes `props` as a flat alternating -/// // key/value list, so the emitter adapts this module's option shape. -/// void emit(String name, {Map? props, String? propertyId, int? engagementTime}) => -/// analytics.createEvent( -/// propertyId: propertyId ?? '', -/// name: name, -/// url: '', -/// engagementTime: engagementTime, -/// props: (props ?? const {}) -/// .entries -/// .expand((entry) => [entry.key, '${entry.value}']) -/// .toList(), -/// ); -/// -/// final tracking = AnalyticsTracking(emit, propertyId: ''); -/// tracking.enableAllAutoTracking(); +/// final tracking = Tracking(Analytics(client), ''); +/// tracking.start(); /// /// runApp(MaterialApp( -/// navigatorObservers: [ -/// AnalyticsObserver(emit, propertyId: ''), -/// ], +/// navigatorObservers: [TrackingObserver(tracking)], /// home: MyApp(), /// )); /// ``` -class AnalyticsTracking with WidgetsBindingObserver { - final AnalyticsEventEmitter _emit; +class Tracking with WidgetsBindingObserver { + final Analytics _analytics; + + /// Analytics property every event is recorded against. + final String propertyId; - /// Analytics property (or snippet) ID forwarded with every emitted event. - /// Optional — leave null when the emitter closure already supplies one. - final String? propertyId; + /// Base URL events are reported under; screen names resolve against it. + /// Defaults to the page origin on Flutter web and `app://` + /// elsewhere, since the endpoint only accepts absolute URLs. + final String url; bool _lifecycleAttached = false; DateTime? _foregroundSince; @@ -77,11 +61,18 @@ class AnalyticsTracking with WidgetsBindingObserver { /// fraction of a second to truncation on every one of them. Duration _engagementCarry = Duration.zero; - AnalyticsTracking(AnalyticsEventEmitter emit, {this.propertyId}) : _emit = emit; + Tracking(Analytics analytics, this.propertyId, {String? url}) + : _analytics = analytics, + url = + url ?? + (kIsWeb + ? Uri.base.origin + : 'app://${defaultTargetPlatform.name.toLowerCase()}'); - /// Enable the opinionated default auto-tracking set. Currently this covers - /// lifecycle events; route tracking is opt-in via [AnalyticsObserver]. - void enableAllAutoTracking() { + /// Start the default auto-tracking set: app-lifecycle events, as + /// [enableAutoLifecycleEvents]. Route tracking is opt-in via + /// [TrackingObserver]. + void start() { enableAutoLifecycleEvents(); } @@ -117,35 +108,42 @@ class AnalyticsTracking with WidgetsBindingObserver { WidgetsBinding.instance.removeObserver(this); } - /// Emit an ad-hoc analytics event. Emitter exceptions are swallowed so - /// analytics failures never surface to the host app. + /// Emit an ad-hoc analytics event. [url] may be absolute or a path resolved + /// against [Tracking.url]. Request failures are swallowed so analytics never + /// surface errors to the host app. void event( String name, { + String? url, Map? props, int? engagementTime, }) { try { - // The emitter usually returns the request future; without catching it a - // rejected request surfaces as an unhandled async error in the host app. - final pending = _emit( - name, - props: props, - propertyId: propertyId, - engagementTime: engagementTime, + unawaited( + _analytics + .createEvent( + propertyId: propertyId, + name: name, + url: + url == null + ? this.url + : Uri.parse(this.url).resolve(url).toString(), + engagementTime: engagementTime, + // The endpoint takes props as a flat alternating key/value list. + props: + props?.entries + .expand((entry) => [entry.key, '${entry.value}']) + .toList(), + ) + .catchError((Object _) {}), ); - if (pending is Future) { - // `catchError` on a typed future must return that future's type, so an - // empty handler would itself throw. Narrow to `Future` first. - unawaited(pending.then((_) {}).catchError((Object _) {})); - } } catch (_) { - // Silent — see class docs. + // A malformed [url] throws before the request starts. } } /// Mobile-idiomatic screen-view shortcut. Renders as a `screen_view` /// analytics event with `screen`, optional `screenClass` and any - /// caller-supplied properties. + /// caller-supplied properties. [name] is also the event URL's path. void screenView( String name, { String? className, @@ -158,7 +156,7 @@ class AnalyticsTracking with WidgetsBindingObserver { if (props != null) { merged.addAll(props); } - event('screen_view', props: merged); + event('screen_view', url: name, props: merged); } @override diff --git a/templates/flutter/lib/src/analytics_observer.dart.twig b/templates/flutter/lib/src/tracking_observer.dart.twig similarity index 50% rename from templates/flutter/lib/src/analytics_observer.dart.twig rename to templates/flutter/lib/src/tracking_observer.dart.twig index d79d0bac59..43b9c1801e 100644 --- a/templates/flutter/lib/src/analytics_observer.dart.twig +++ b/templates/flutter/lib/src/tracking_observer.dart.twig @@ -1,40 +1,6 @@ -import 'dart:async'; - import 'package:flutter/widgets.dart'; -/// Signature used to emit an analytics event from an [AnalyticsObserver] or -/// [AnalyticsTracking] instance. -/// -/// The generated `Analytics` service exposes -/// `createEvent({required String propertyId, required String name, required String url, ...})`, -/// so the typical wiring is: -/// -/// ```dart -/// final analytics = Analytics(client); -/// final observer = AnalyticsObserver( -/// (name, {props, propertyId, engagementTime}) => analytics.createEvent( -/// propertyId: propertyId ?? '', -/// name: name, -/// url: '', -/// engagementTime: engagementTime, -/// props: (props ?? const {}) -/// .entries -/// .expand((entry) => [entry.key, '${entry.value}']) -/// .toList(), -/// ), -/// 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 = FutureOr Function( - String name, { - Map? props, - String? propertyId, - int? engagementTime, -}); +import 'tracking.dart'; /// Signature used to derive a screen name from a [Route]. /// @@ -50,35 +16,21 @@ 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. +/// [NavigatorObserver] that fires an analytics event through a [Tracking] +/// 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.createEvent( -/// propertyId: propertyId ?? '', -/// name: name, -/// url: '', -/// engagementTime: engagementTime, -/// props: (props ?? const {}) -/// .entries -/// .expand((entry) => [entry.key, '${entry.value}']) -/// .toList(), -/// ), -/// propertyId: '', -/// ), -/// ], +/// navigatorObservers: [TrackingObserver(tracking)], /// ... /// ); /// ``` -class AnalyticsObserver extends NavigatorObserver { - /// Callback used to send the resulting `screen_view` event. - final AnalyticsEventEmitter emit; +class TrackingObserver extends NavigatorObserver { + /// Tracker the resulting `screen_view` events are sent through. + final Tracking tracking; /// Optional route-name extractor. Defaults to [defaultScreenNameExtractor] /// which reads [RouteSettings.name]. @@ -87,15 +39,10 @@ class AnalyticsObserver extends NavigatorObserver { /// 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, { + TrackingObserver( + this.tracking, { ScreenNameExtractor? nameExtractor, this.eventName = 'screen_view', - this.propertyId, }) : nameExtractor = nameExtractor ?? defaultScreenNameExtractor; /// The route the user is actually looking at. `didRemove` fires for buried @@ -155,22 +102,11 @@ class AnalyticsObserver extends NavigatorObserver { 'screen': name, 'trigger': trigger, }; - final previousName = previousRoute == null ? null : nameExtractor(previousRoute); + final previousName = + previousRoute == null ? null : nameExtractor(previousRoute); if (previousName != null && previousName.isNotEmpty) { props['previous'] = previousName; } - try { - // The emitter usually returns the request future; without catching it a - // rejected request surfaces as an unhandled async error in the host app. - final pending = emit(eventName, props: props, propertyId: propertyId); - if (pending is Future) { - // `catchError` on a typed future must return that future's type, so an - // empty handler would itself throw. Narrow to `Future` first. - unawaited(pending.then((_) {}).catchError((Object _) {})); - } - } catch (_) { - // Emitter failures must never propagate into the navigator stack — - // swallow so analytics can't crash the app. - } + tracking.event(eventName, url: name, props: props); } } diff --git a/templates/web/src/index.ts.twig b/templates/web/src/index.ts.twig index d7f8b2228a..212ef88fee 100644 --- a/templates/web/src/index.ts.twig +++ b/templates/web/src/index.ts.twig @@ -14,7 +14,7 @@ export { {{service.name | caseUcfirst}} } from './services/{{service.name | case export { Realtime } from './services/realtime'; export { Push } from './services/push'; {% if services['analytics'] is defined %} -export { AnalyticsTracking } from './services/analytics-tracking'; +export { Tracking } from './services/analytics-tracking'; {% endif %} export type { Models, @@ -32,11 +32,10 @@ export type { } from './services/push'; {% if services['analytics'] is defined %} export type { - AnalyticsEventEmitter, - AnalyticsEventOptions, - AnalyticsTrackingOptions, DownloadTrackingOptions, OutboundTrackingOptions, + TrackingEventOptions, + TrackingOptions, } from './services/analytics-tracking'; {% endif %} export type { QueryTypes, QueryTypesList } from './query'; diff --git a/templates/web/src/services/analytics-tracking.ts.twig b/templates/web/src/services/analytics-tracking.ts.twig index b78d2e36e7..04f6b31b19 100644 --- a/templates/web/src/services/analytics-tracking.ts.twig +++ b/templates/web/src/services/analytics-tracking.ts.twig @@ -1,40 +1,15 @@ +import type { Analytics } from './analytics'; + /** - * Analytics auto-tracking helpers. - * - * These are hand-written companions to the generated `Analytics` service. - * The generated service exposes `createEvent(params)` for arbitrary events; - * this module wires up common auto-tracking behaviours (pageviews, outbound - * links, downloads, scroll depth, active engagement) on top of it. - * - * The tracking helpers accept a plain event-emitter function so they do not - * hard-couple to the generated `Analytics` class. The emitter adapts this - * module's option shape to `createEvent`: `url` is required there, and `props` - * is a flat alternating key/value list rather than an object. + * Auto-tracking helpers for the generated `Analytics` service: pageviews, + * outbound links, downloads, scroll depth and active engagement, all sent + * through `Analytics.createEvent`. * * ```ts - * const analytics = new Analytics(client); - * const tracking = new AnalyticsTracking( - * (name, options) => analytics.createEvent({ - * propertyId: options?.propertyId ?? '', - * name, - * url: options?.url ?? window.location.href, - * referrer: options?.referrer, - * scrollDepth: options?.scrollDepth, - * engagementTime: options?.engagementTime, - * props: Object.entries(options?.props ?? {}).flatMap( - * ([key, value]) => [key, String(value)], - * ), - * }), - * { propertyId: '' } - * ); - * tracking.enableAllAutoTracking(); + * const tracking = new Tracking(new Analytics(client), ''); + * tracking.start(); * ``` * - * `propertyId` is optional: when set it is merged into the options of every - * emitted event, so an emitter that forwards `options.propertyId` (as above) - * picks it up 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. * @@ -49,33 +24,20 @@ * themselves. */ -export type AnalyticsEventOptions = { +export type TrackingEventOptions = { props?: Record; - url?: string; + url?: string; // defaults to the current page URL 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 + scrollDepth?: number; // 0-100 percentage + engagementTime?: number; // seconds since the previous flush }; -export type AnalyticsEventEmitter = ( - name: string, - options?: AnalyticsEventOptions, -) => unknown; - -export type AnalyticsTrackingOptions = { +export type TrackingOptions = { /** * 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 = { @@ -188,10 +150,10 @@ const SCROLL_DEPTH_THROTTLE_MS = 200; type Cleanup = () => void; -export class AnalyticsTracking { - private readonly emit: AnalyticsEventEmitter; +export class Tracking { + private readonly analytics: Analytics; + private readonly propertyId: string; private readonly respectDoNotTrack: boolean; - private readonly propertyId?: string; private pageviewCleanups: Cleanup[] = []; private outboundCleanup?: Cleanup; @@ -208,22 +170,27 @@ export class AnalyticsTracking { private engagementAccumulatedMs = 0; private engagementActive = false; + /** + * @param analytics - The generated `Analytics` service events are sent through. + * @param propertyId - Analytics property every event is recorded against. + */ constructor( - emit: AnalyticsEventEmitter, - options: AnalyticsTrackingOptions = {}, + analytics: Analytics, + propertyId: string, + options: TrackingOptions = {}, ) { - this.emit = emit; + this.analytics = analytics; + this.propertyId = propertyId; 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. + * Start the default auto-tracking set: pageviews, outbound links, scroll + * depth and active engagement time. Downloads are opt-in via + * `enableAutoDownloadTracking()` because the extension list is + * application-specific. */ - public enableAllAutoTracking(): void { + public start(): void { this.enableAutoPageviews(); this.enableAutoOutboundTracking(); this.enableAutoScrollDepth(); @@ -231,17 +198,35 @@ export class AnalyticsTracking { } /** - * Track an event manually. Respects the DNT setting and swallows emitter - * exceptions so tracking failures never surface to the host page. + * Track an event manually. Respects the DNT setting and swallows request + * failures so tracking never surfaces errors to the host page. */ - public track(name: string, options?: AnalyticsEventOptions): void { + public track(name: string, options: TrackingEventOptions = {}): void { if (!this.canTrack()) { return; } try { - this.emit(name, this.withPropertyId(options)); + // Missing required params throw synchronously rather than rejecting. + this.analytics + .createEvent({ + propertyId: this.propertyId, + name, + url: options.url ?? this.currentPageUrl(), + referrer: options.referrer, + scrollDepth: options.scrollDepth, + engagementTime: options.engagementTime, + // The endpoint takes props as a flat alternating key/value list. + props: options.props + ? Object.entries(options.props).flatMap( + ([key, value]) => [key, String(value)], + ) + : undefined, + }) + .catch((error) => { + console.warn('Tracking: failed to send event', error); + }); } catch (error) { - console.warn('AnalyticsTracking: emitter threw', error); + console.warn('Tracking: failed to send event', error); } } @@ -634,24 +619,6 @@ export class AnalyticsTracking { 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; @@ -778,8 +745,6 @@ export class AnalyticsTracking { // 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, diff --git a/tests/generation/GenerationTest.php b/tests/generation/GenerationTest.php index db8dcb0bc8..065a9348c4 100644 --- a/tests/generation/GenerationTest.php +++ b/tests/generation/GenerationTest.php @@ -302,11 +302,11 @@ public function testFixtureSelection(string $name): void public function testAnalyticsCompanionsFollowTheService(): void { $companions = [ - 'flutter' => ['lib/src/analytics_observer.dart', 'lib/src/analytics_tracking.dart'], + 'flutter' => ['lib/src/tracking.dart', 'lib/src/tracking_observer.dart'], 'web' => ['src/services/analytics-tracking.ts'], ]; $entrypoints = [ - 'flutter' => ['lib/packageName.dart', 'analytics_observer.dart'], + 'flutter' => ['lib/packageName.dart', 'src/tracking'], 'web' => ['src/index.ts', 'analytics-tracking'], ];