Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/SDK/Language/Flutter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions templates/flutter/lib/package.dart.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String, dynamic>? props, String? propertyId, int? engagementTime}) =>
/// analytics.createEvent(
/// propertyId: propertyId ?? '<PROPERTY_ID>',
/// name: name,
/// url: '<APP_URL>',
/// engagementTime: engagementTime,
/// props: (props ?? const {})
/// .entries
/// .expand((entry) => [entry.key, '${entry.value}'])
/// .toList(),
/// );
///
/// final tracking = AnalyticsTracking(emit, propertyId: '<PROPERTY_ID>');
/// tracking.enableAllAutoTracking();
/// final tracking = Tracking(Analytics(client), '<PROPERTY_ID>');
/// tracking.start();
///
/// runApp(MaterialApp(
/// navigatorObservers: [
/// AnalyticsObserver(emit, propertyId: '<PROPERTY_ID>'),
/// ],
/// 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://<platform>`
/// elsewhere, since the endpoint only accepts absolute URLs.
final String url;

bool _lifecycleAttached = false;
DateTime? _foregroundSince;
Expand All @@ -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();
}

Expand Down Expand Up @@ -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<String, dynamic>? 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<void>` first.
unawaited(pending.then<void>((_) {}).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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 ?? '<PROPERTY_ID>',
/// name: name,
/// url: '<APP_URL>',
/// engagementTime: engagementTime,
/// props: (props ?? const {})
/// .entries
/// .expand((entry) => [entry.key, '${entry.value}'])
/// .toList(),
/// ),
/// propertyId: '<PROPERTY_ID>',
/// );
/// ```
///
/// [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<void> Function(
String name, {
Map<String, dynamic>? props,
String? propertyId,
int? engagementTime,
});
import 'tracking.dart';

/// Signature used to derive a screen name from a [Route].
///
Expand All @@ -50,35 +16,21 @@ String? defaultScreenNameExtractor(Route<dynamic> 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 ?? '<PROPERTY_ID>',
/// name: name,
/// url: '<APP_URL>',
/// engagementTime: engagementTime,
/// props: (props ?? const {})
/// .entries
/// .expand((entry) => [entry.key, '${entry.value}'])
/// .toList(),
/// ),
/// propertyId: '<PROPERTY_ID>',
/// ),
/// ],
/// 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].
Expand All @@ -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
Expand Down Expand Up @@ -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<void>` first.
unawaited(pending.then<void>((_) {}).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);
}
}
7 changes: 3 additions & 4 deletions templates/web/src/index.ts.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down
Loading
Loading