diff --git a/README.md b/README.md index 3450743..525c8bf 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ It is build on top of Snowplow's native [iOS](https://github.com/snowplow/snowpl | Feature | Android | iOS | Web | |---|---|---|---| | Manual tracking of events: screen views, self-describing, structured, timing, consent granted and withdrawal | ✔ | ✔ | ✔ | +| Automatic tracking of views events from Navigator API | ✔ | ✔ | ✔ | | Adding custom context entities to events | ✔ | ✔ | ✔ | | Support for multiple trackers | ✔ | ✔ | ✔ | | Configurable subject properties | ✔ | ✔ | partly | @@ -71,7 +72,7 @@ The endpoint is the URI of the Snowplow collector to send the events to. There are additional optional arguments to configure the tracker, please refer to the documentation for a complete specification. ```dart -Tracker tracker = await Snowplow.createTracker( +SnowplowTracker tracker = await Snowplow.createTracker( namespace: 'ns1', endpoint: 'http://...' ); diff --git a/doc/01-getting-started.md b/doc/01-getting-started.md index 57cf592..a960b8d 100644 --- a/doc/01-getting-started.md +++ b/doc/01-getting-started.md @@ -40,7 +40,7 @@ Tracker namespace identifies the tracker instance, you may create multiple track The endpoint is the URI of the Snowplow collector to send the events to. ```dart -Tracker tracker = await Snowplow.createTracker( +SnowplowTracker tracker = await Snowplow.createTracker( namespace: 'ns1', endpoint: 'http://...' ); diff --git a/doc/02-configuration.md b/doc/02-configuration.md index f435bd0..df8f6b8 100644 --- a/doc/02-configuration.md +++ b/doc/02-configuration.md @@ -1,9 +1,9 @@ # Initialization and configuration -The package provides a single method to initialize and configure a new tracker, the `Snowplow.createTracker` method. It accepts configuration parameters for the tracker and returns a `Tracker` instance. +The package provides a single method to initialize and configure a new tracker, the `Snowplow.createTracker` method. It accepts configuration parameters for the tracker and returns a `SnowplowTracker` instance. ```dart -Tracker tracker = await Snowplow.createTracker( +SnowplowTracker tracker = await Snowplow.createTracker( namespace: 'ns1', endpoint: 'http://...', method: Method.post, @@ -13,7 +13,7 @@ Tracker tracker = await Snowplow.createTracker( ); ``` -The method returns a `Tracker` instance. This can be later used for tracking events, or accessing tracker properties. However, all methods provided by the `Tracker` instance are also available as static functions in the `Snowplow` class but they require passing the tracker namespace as string. +The method returns a `SnowplowTracker` instance. This can be later used for tracking events, or accessing tracker properties. However, all methods provided by the `SnowplowTracker` instance are also available as static functions in the `Snowplow` class but they require passing the tracker namespace as string. The only required attributes of the `Snowplow.createTracker` method are `namespace` used to identify the tracker, and the Snowplow collector `endpoint`. Additionally, one can configure the HTTP method to be used when sending events to the collector and provide configuration by instantiating classes for `TrackerConfiguration`, `SubjectConfiguration`, or `GdprConfiguration`. The following arguments are accepted by the `Snowplow.createTracker` method: @@ -39,7 +39,11 @@ The only required attributes of the `Snowplow.createTracker` method are `namespa | `geoLocationContext` | `bool?` | Indicates whether geo-location context should be attached to tracked events. | ✔ | ✔ | ✔ | false | | `sessionContext` | `bool?` | Indicates whether session context should be attached to tracked events. | ✔ | ✔ | ✔ | true | | `webPageContext` | `bool?` | Indicates whether context about current web page should be attached to tracked events. | | | ✔ | true | -| `activityTrackingConfig` | ActivityTrackingConfiguration?` | Enables activity tracking using page pings on the Web. | | | ✔ | true | +| `webActivityTracking` | WebActivityTracking?` | Enables activity tracking using page views and pings on the Web. | | | ✔ | true | + +The optional `WebActivityTracking` property configures page tracking on Web. Initializing the configuration will inform `SnowplowObserver` observers (see section on auto-tracking in "Tracking events") to auto track `PageViewEvent` events instead of `ScreenView` events on navigation changes. Further, setting the `minimumVisitLength` and `heartbeatDelay` properties of the `WebActivityTracking` instance will enable activity tracking using 'page ping' events on Web. + +Activity tracking monitors whether a user continues to engage with a page over time, and record how he / she digests content on the page over time. That is accomplished using 'page ping' events. If activity tracking is enabled, the web page is monitored to see if a user is engaging with it. (E.g. is the tab in focus, does the mouse move over the page, does the user scroll etc.) If any of these things occur in a set period of time (`minimumVisitLength` seconds from page load and every `heartbeatDelay` seconds after that), a page ping event fires, and records the maximum scroll left / right and up / down in the last ping period. If there is no activity in the page (e.g. because the user is on a different tab in his / her browser), no page ping fires. ## Configuration of subject information: `SubjectConfiguration` diff --git a/doc/03-tracking-events.md b/doc/03-tracking-events.md index f3da068..9c75349 100644 --- a/doc/03-tracking-events.md +++ b/doc/03-tracking-events.md @@ -185,3 +185,49 @@ tracker.track(ConsentWithdrawn( documentDescription: 'description1', )); ``` + +## Automatically tracking view events using navigator observer + +There is also an option to automatically track view events when currently active pages change through the [Navigator API](https://api.flutter.dev/flutter/widgets/Navigator-class.html). + +To activate this feature, one has to register a `SnowplowObserver` retrieved from the tracker instance using `SnowplowTracker.getObserver()`. The retrieved observer can be added to `navigatorObservers` in `MaterialApp`: + +```dart +MaterialApp( + navigatorObservers: [ + tracker.getObserver() + ], + ... +); +``` + +If using the `Router` API with the `MaterialApp.router` constructor, add the observer to the `observers` of your `Navigator` instance, e.g.: + +```dart +Navigator( + observers: [tracker.getObserver()], + ... +); +``` + +The `SnowplowObserver` automatically tracks `PageViewEvent` and `ScreenView` events when the currently active `ModalRoute` of the navigator changes. + +By default, `ScreenView` events are tracked on all platforms. In case `TrackerConfiguration.webActivityTracking` is configured when creating the tracker, `PageViewEvent` events will be tracked on Web instead of `ScreenView` events (`ScreenView` events will still be tracked on other platforms). + +The `SnowplowTracker.getObserver()` function takes an optional `nameExtractor` function as argument which is used to extract a name from new routes that is used in tracked `ScreenView` or `PageViewEvent` events. + +The following operations will result in tracking a view event: + +```dart +Navigator.pushNamed(context, '/contact/123'); + +Navigator.push(context, MaterialPageRoute( + settings: RouteSettings(name: '/contact/123'), + builder: (_) => ContactDetail(123))); + +Navigator.pushReplacement(context, MaterialPageRoute( + settings: RouteSettings(name: '/contact/123'), + builder: (_) => ContactDetail(123))); + +Navigator.pop(context); +``` diff --git a/example/integration_test/configuration_test.dart b/example/integration_test/configuration_test.dart index 8d9bbb7..cc02aed 100644 --- a/example/integration_test/configuration_test.dart +++ b/example/integration_test/configuration_test.dart @@ -15,7 +15,6 @@ import 'package:integration_test/integration_test.dart'; import 'package:snowplow_tracker/snowplow_tracker.dart'; import 'helpers.dart'; -import 'package:snowplow_tracker_example/main.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -25,9 +24,7 @@ void main() { }); testWidgets("sets and changes user id", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - - Tracker tracker = await Snowplow.createTracker( + SnowplowTracker tracker = await Snowplow.createTracker( namespace: 'test', endpoint: SnowplowTests.microEndpoint, subjectConfig: const SubjectConfiguration(userId: 'XYZ')); @@ -57,14 +54,13 @@ void main() { if (!kIsWeb) { return; } - await tester.pumpWidget(const MyApp(testing: true)); - Tracker withoutContext = await Snowplow.createTracker( + SnowplowTracker withoutContext = await Snowplow.createTracker( namespace: 'withoutContext', endpoint: SnowplowTests.microEndpoint, trackerConfig: const TrackerConfiguration(webPageContext: false)); - Tracker withContext = await Snowplow.createTracker( + SnowplowTracker withContext = await Snowplow.createTracker( namespace: 'withContext', endpoint: SnowplowTests.microEndpoint, trackerConfig: const TrackerConfiguration(webPageContext: true)); @@ -91,9 +87,7 @@ void main() { }); testWidgets("attaches gdpr context to events", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - - Tracker tracker = await Snowplow.createTracker( + SnowplowTracker tracker = await Snowplow.createTracker( namespace: 'gdpr', endpoint: SnowplowTests.microEndpoint, trackerConfig: const TrackerConfiguration(), @@ -124,9 +118,7 @@ void main() { testWidgets("sets app ID and platform based on configuration", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - - Tracker tracker = await Snowplow.createTracker( + SnowplowTracker tracker = await Snowplow.createTracker( namespace: 'app-platform', endpoint: SnowplowTests.microEndpoint, trackerConfig: const TrackerConfiguration( diff --git a/example/integration_test/events_test.dart b/example/integration_test/events_test.dart index 95490af..7d5af09 100644 --- a/example/integration_test/events_test.dart +++ b/example/integration_test/events_test.dart @@ -31,8 +31,6 @@ void main() { }); testWidgets("tracks a structured event", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - await Snowplow.track( const Structured(category: 'category', action: 'action'), tracker: "test"); @@ -50,8 +48,6 @@ void main() { }); testWidgets("tracks a self-describing event", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - const selfDescribing = SelfDescribing( schema: 'iglu:com.snowplowanalytics.snowplow/link_click/jsonschema/1-0-1', data: {'targetUrl': 'http://a-target-url.com'}, @@ -74,7 +70,6 @@ void main() { }); testWidgets("tracks a screen view event", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); String id = const Uuid().v4(); var screenView = ScreenView(id: id, name: 'name'); @@ -92,8 +87,6 @@ void main() { }); testWidgets("tracks a timing event", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - var timing = const Timing(category: 'cat', variable: 'var', timing: 10, label: 'l'); await Snowplow.track(timing, tracker: 'test'); @@ -109,8 +102,6 @@ void main() { }); testWidgets("tracks a consent granted event", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - final consentGranted = ConsentGranted( expiry: DateTime.parse('2021-12-30T09:03:51.196Z'), documentId: '1234', @@ -129,8 +120,6 @@ void main() { }); testWidgets("tracks a consent withdrawn event", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - const consentWithdrawn = ConsentWithdrawn( all: false, documentId: '1234', @@ -150,8 +139,6 @@ void main() { testWidgets("tracks an event with custom context", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - await Snowplow.track( const Structured(category: 'category', action: 'action'), tracker: "test", @@ -183,29 +170,45 @@ void main() { isTrue); }); - testWidgets("tracks a page view event on web", (WidgetTester tester) async { + testWidgets( + "tracks a page view event on web after loading page if web activity tracking", + (WidgetTester tester) async { if (!kIsWeb) { return; } - await tester.pumpWidget(const MyApp(testing: true)); - - await Snowplow.track(const PageViewEvent(), tracker: 'test'); + SnowplowTracker tracker = await Snowplow.createTracker( + namespace: 'web', + endpoint: SnowplowTests.microEndpoint, + trackerConfig: const TrackerConfiguration( + webActivityTracking: WebActivityTracking())); + await tester.pumpWidget(MyApp(tracker: tracker)); expect( await SnowplowTests.checkMicroGood((events) => (events.length == 1) && - (events[0]['event']['page_title'] == 'Demo App') && + (events[0]['event']['page_title'] == '/') && (events[0]['event']['page_url'] != null) && (events[0]['event']['page_referrer'] != null)), isTrue); }); + testWidgets("tracks a screen view event after loading page", + (WidgetTester tester) async { + await tester.pumpWidget(MyApp(tracker: SnowplowTests.tracker!)); + + expect( + await SnowplowTests.checkMicroGood((events) => + (events.length == 1) && + (events[0]['event']['unstruct_event']['data']['data']['name'] == + '/')), + isTrue); + }); + testWidgets("raises an exception when tracking page view event on mobile", (WidgetTester tester) async { if (kIsWeb) { return; } - await tester.pumpWidget(const MyApp(testing: true)); try { await Snowplow.track(const PageViewEvent(), tracker: 'test'); diff --git a/example/integration_test/helpers.dart b/example/integration_test/helpers.dart index 41a0acf..1690202 100644 --- a/example/integration_test/helpers.dart +++ b/example/integration_test/helpers.dart @@ -13,14 +13,18 @@ import 'package:http/http.dart' as http; import 'package:flutter_test/flutter_test.dart'; import 'package:snowplow_tracker/snowplow.dart'; +import 'package:snowplow_tracker/tracker.dart'; import 'dart:convert'; class SnowplowTests { + static SnowplowTracker? tracker; + static const microEndpoint = String.fromEnvironment('ENDPOINT', defaultValue: 'http://0.0.0.0:9090'); static Future createTracker() async { - await Snowplow.createTracker(namespace: 'test', endpoint: microEndpoint); + tracker = await Snowplow.createTracker( + namespace: 'test', endpoint: microEndpoint); } static Future resetMicro() async { diff --git a/example/integration_test/session_test.dart b/example/integration_test/session_test.dart index 2393ef9..82ab7a5 100644 --- a/example/integration_test/session_test.dart +++ b/example/integration_test/session_test.dart @@ -17,13 +17,12 @@ import 'package:snowplow_tracker/tracker.dart'; import 'package:integration_test/integration_test.dart'; import 'helpers.dart'; -import 'package:snowplow_tracker_example/main.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() async { - await SnowplowTests.createTracker(); + SnowplowTests.createTracker(); }); setUp(() async { @@ -32,11 +31,8 @@ void main() { testWidgets("maintains the same session context", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - - await Snowplow.track( - const Structured(category: 'category', action: 'action'), - tracker: "test"); + await SnowplowTests.tracker + ?.track(const Structured(category: 'category', action: 'action')); dynamic clientSession1; expect( @@ -60,9 +56,8 @@ void main() { await SnowplowTests.resetMicro(); - await Snowplow.track( - const Structured(category: 'category', action: 'action'), - tracker: "test"); + await SnowplowTests.tracker + ?.track(const Structured(category: 'category', action: 'action')); dynamic clientSession2; expect( @@ -90,11 +85,8 @@ void main() { testWidgets("tracks the same session information as returned from API", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - - await Snowplow.track( - const Structured(category: 'category', action: 'action'), - tracker: "test"); + await SnowplowTests.tracker + ?.track(const Structured(category: 'category', action: 'action')); dynamic clientSession; expect( @@ -123,9 +115,7 @@ void main() { testWidgets("doesn't add session context when disabled", (WidgetTester tester) async { - await tester.pumpWidget(const MyApp(testing: true)); - - Tracker tracker = await Snowplow.createTracker( + SnowplowTracker tracker = await Snowplow.createTracker( namespace: 'test-without-session', endpoint: SnowplowTests.microEndpoint, trackerConfig: const TrackerConfiguration(sessionContext: false)); diff --git a/example/lib/main.dart b/example/lib/main.dart index 4eafa5d..0e999c4 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -9,228 +9,48 @@ // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'dart:async'; - import 'package:snowplow_tracker/snowplow_tracker.dart'; - -void main() { - runApp(const MyApp()); +import 'main_page.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + /// create a Snowplow tracker instance + final SnowplowTracker tracker = await Snowplow.createTracker( + namespace: "ns1", + endpoint: const String.fromEnvironment('ENDPOINT', + defaultValue: 'http://0.0.0.0:9090'), + trackerConfig: const TrackerConfiguration( + webPageContext: false, + webActivityTracking: + WebActivityTracking(minimumVisitLength: 15, heartbeatDelay: 10)), + gdprConfig: const GdprConfiguration( + basisForProcessing: 'consent', + documentId: 'consentDoc-abc123', + documentVersion: '0.1.0', + documentDescription: + 'this document describes consent basis for processing'), + subjectConfig: const SubjectConfiguration(userId: 'XYZ')); + + runApp(MyApp(tracker: tracker)); } class MyApp extends StatefulWidget { - final bool? testing; - const MyApp({Key? key, this.testing}) : super(key: key); + final SnowplowTracker tracker; + const MyApp({Key? key, required this.tracker}) : super(key: key); @override - // ignore: no_logic_in_create_state - State createState() => _MyAppState(testing: testing); + State createState() => _MyAppState(); } class _MyAppState extends State with WidgetsBindingObserver { - int _numberOfEventsSent = 0; - String _sessionId = 'Unknown'; - String _sessionUserId = 'Unknown'; - int? _sessionIndex; - final bool? testing; - - _MyAppState({this.testing}) : super(); - - @override - void initState() { - super.initState(); - initPlatformState(); - } - - Future initPlatformState() async { - if (testing == null || testing == false) { - await Snowplow.createTracker( - namespace: "ns1", - endpoint: const String.fromEnvironment('ENDPOINT', - defaultValue: 'http://0.0.0.0:9090'), - trackerConfig: const TrackerConfiguration( - webPageContext: false, - activityTrackingConfig: ActivityTrackingConfiguration( - minimumVisitLength: 15, heartbeatDelay: 10)), - gdprConfig: const GdprConfiguration( - basisForProcessing: 'consent', - documentId: 'consentDoc-abc123', - documentVersion: '0.1.0', - documentDescription: - 'this document describes consent basis for processing'), - subjectConfig: const SubjectConfiguration(userId: 'XYZ')); - // await Snowplow.setUserId('XYZ', tracker: 'ns1'); - updateState(); - } - - WidgetsBinding.instance?.addObserver(this); - } - - Future updateState() async { - String? sessionId; - String? sessionUserId; - int? sessionIndex; - - try { - sessionId = await Snowplow.getSessionId(tracker: 'ns1') ?? 'Unknown'; - sessionUserId = - await Snowplow.getSessionUserId(tracker: 'ns1') ?? 'Unknown'; - sessionIndex = await Snowplow.getSessionIndex(tracker: 'ns1'); - } on PlatformException catch (err) { - if (kDebugMode) { - print(err); - } - } - - if (!mounted) return; - - setState(() { - _sessionId = sessionId ?? 'Unknown'; - _sessionUserId = sessionUserId ?? 'Unknown'; - _sessionIndex = sessionIndex; - }); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - updateState(); - } - - Future trackEvent(event, {List? contexts}) async { - Snowplow.track(event, tracker: "ns1", contexts: contexts); - - setState(() { - _numberOfEventsSent += 1; - }); - } - @override Widget build(BuildContext context) { + SnowplowTracker tracker = widget.tracker; return MaterialApp( - title: 'Demo App', - home: Scaffold( - appBar: AppBar( - title: const Text('Plugin example app'), - ), - body: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 24.0), - child: Center( - child: Column(children: [ - Text('Number of events sent: $_numberOfEventsSent'), - const SizedBox(height: 24.0), - ElevatedButton( - onPressed: () { - const structured = Structured( - category: 'shop', - action: 'add-to-basket', - label: 'Add To Basket', - property: 'pcs', - value: 2.00, - ); - trackEvent(structured); - }, - child: const Text('Send Structured Event'), - ), - ElevatedButton( - onPressed: () { - const event = SelfDescribing( - schema: - 'iglu:com.snowplowanalytics.snowplow/link_click/jsonschema/1-0-1', - data: {'targetUrl': 'http://a-target-url.com'}, - ); - trackEvent(event); - }, - child: const Text('Send Self-Describing Event'), - ), - ElevatedButton( - onPressed: () { - const event = ScreenView( - id: '2c295365-eae9-4243-a3ee-5c4b7baccc8f', - name: 'home', - type: 'full', - transitionType: 'none'); - trackEvent(event); - }, - child: const Text('Send Screen View Event'), - ), - ElevatedButton( - onPressed: () { - const event = Timing( - category: 'category', - variable: 'variable', - timing: 1, - label: 'label', - ); - trackEvent(event); - }, - child: const Text('Send Timing Event'), - ), - ElevatedButton( - onPressed: () { - final event = ConsentGranted( - expiry: DateTime.parse('2021-12-30T09:03:51.196111Z'), - documentId: '1234', - version: '5', - name: 'name1', - documentDescription: 'description1', - ); - trackEvent(event); - }, - child: const Text('Send Consent Granted Event'), - ), - ElevatedButton( - onPressed: () { - const event = ConsentWithdrawn( - all: false, - documentId: '1234', - version: '5', - name: 'name1', - documentDescription: 'description1', - ); - trackEvent(event); - }, - child: const Text('Send Consent Withdrawn Event'), - ), - kIsWeb - ? ElevatedButton( - onPressed: () { - trackEvent(const PageViewEvent()); - }, - child: const Text('Send Page View Event'), - ) - : const Text('Page view tracking not available'), - ElevatedButton( - onPressed: () { - const structured = Structured( - category: 'shop', - action: 'add-to-basket', - label: 'Add To Basket', - property: 'pcs', - value: 2.00, - ); - trackEvent(structured, contexts: [ - const SelfDescribing( - schema: 'iglu:org.schema/WebPage/jsonschema/1-0-0', - data: { - 'keywords': ['tester'] - }) - ]); - }, - child: const Text('Send Structured Event With Context'), - ), - const SizedBox(height: 24.0), - Text('Session ID: $_sessionId'), - const SizedBox(height: 5.0), - Text('Session user ID: $_sessionUserId'), - const SizedBox(height: 5.0), - Text('Session index: $_sessionIndex'), - const SizedBox(height: 5.0) - ]), - ), - ), - ), - ); + title: 'Demo App', + home: MainPage(tracker: tracker), + navigatorObservers: [tracker.getObserver()]); } } diff --git a/example/lib/main_page.dart b/example/lib/main_page.dart new file mode 100644 index 0000000..4929ab5 --- /dev/null +++ b/example/lib/main_page.dart @@ -0,0 +1,210 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:snowplow_tracker/snowplow_tracker.dart'; + +import 'nested_page.dart'; + +class MainPage extends StatefulWidget { + const MainPage({ + Key? key, + required this.tracker, + }) : super(key: key); + + final SnowplowTracker tracker; + + @override + _MainPageState createState() => _MainPageState(); +} + +class _MainPageState extends State with WidgetsBindingObserver { + int _numberOfEventsSent = 0; + String _sessionId = 'Unknown'; + String _sessionUserId = 'Unknown'; + int? _sessionIndex; + + Future trackEvent(event, {List? contexts}) async { + widget.tracker.track(event, contexts: contexts); + + setState(() { + _numberOfEventsSent += 1; + }); + } + + @override + void initState() { + super.initState(); + + updateState(); + + WidgetsBinding.instance?.addObserver(this); + } + + Future updateState() async { + String? sessionId; + String? sessionUserId; + int? sessionIndex; + + try { + sessionId = await widget.tracker.sessionId ?? 'Unknown'; + sessionUserId = await widget.tracker.sessionUserId ?? 'Unknown'; + sessionIndex = await widget.tracker.sessionIndex; + } on PlatformException catch (err) { + if (kDebugMode) { + print(err); + } + } + + if (!mounted) return; + + setState(() { + _sessionId = sessionId ?? 'Unknown'; + _sessionUserId = sessionUserId ?? 'Unknown'; + _sessionIndex = sessionIndex; + }); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + updateState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Snowplow example app'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Center( + child: Column(children: [ + Text('Number of events sent: $_numberOfEventsSent'), + const SizedBox(height: 24.0), + ElevatedButton( + onPressed: () { + const structured = Structured( + category: 'shop', + action: 'add-to-basket', + label: 'Add To Basket', + property: 'pcs', + value: 2.00, + ); + trackEvent(structured); + }, + child: const Text('Send Structured Event'), + ), + ElevatedButton( + onPressed: () { + const event = SelfDescribing( + schema: + 'iglu:com.snowplowanalytics.snowplow/link_click/jsonschema/1-0-1', + data: {'targetUrl': 'http://a-target-url.com'}, + ); + trackEvent(event); + }, + child: const Text('Send Self-Describing Event'), + ), + ElevatedButton( + onPressed: () { + const event = ScreenView( + id: '2c295365-eae9-4243-a3ee-5c4b7baccc8f', + name: 'home', + type: 'full', + transitionType: 'none'); + trackEvent(event); + }, + child: const Text('Send Screen View Event'), + ), + ElevatedButton( + onPressed: () { + const event = Timing( + category: 'category', + variable: 'variable', + timing: 1, + label: 'label', + ); + trackEvent(event); + }, + child: const Text('Send Timing Event'), + ), + ElevatedButton( + onPressed: () { + final event = ConsentGranted( + expiry: DateTime.parse('2021-12-30T09:03:51.196111Z'), + documentId: '1234', + version: '5', + name: 'name1', + documentDescription: 'description1', + ); + trackEvent(event); + }, + child: const Text('Send Consent Granted Event'), + ), + ElevatedButton( + onPressed: () { + const event = ConsentWithdrawn( + all: false, + documentId: '1234', + version: '5', + name: 'name1', + documentDescription: 'description1', + ); + trackEvent(event); + }, + child: const Text('Send Consent Withdrawn Event'), + ), + kIsWeb + ? ElevatedButton( + onPressed: () { + trackEvent(const PageViewEvent()); + }, + child: const Text('Send Page View Event'), + ) + : const Text('Page view tracking not available'), + ElevatedButton( + onPressed: () { + const structured = Structured( + category: 'shop', + action: 'add-to-basket', + label: 'Add To Basket', + property: 'pcs', + value: 2.00, + ); + trackEvent(structured, contexts: [ + const SelfDescribing( + schema: 'iglu:org.schema/WebPage/jsonschema/1-0-0', + data: { + 'keywords': ['tester'] + }) + ]); + }, + child: const Text('Send Structured Event With Context'), + ), + const SizedBox(height: 24.0), + Text('Session ID: $_sessionId'), + const SizedBox(height: 5.0), + Text('Session user ID: $_sessionUserId'), + const SizedBox(height: 5.0), + Text('Session index: $_sessionIndex'), + const SizedBox(height: 5.0) + ]), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + settings: const RouteSettings(name: NestedPage.routeName), + builder: (BuildContext context) { + return NestedPage(tracker: widget.tracker); + }, + ), + ); + }, + child: const Icon(Icons.help), + ), + ); + } +} diff --git a/example/lib/nested_page.dart b/example/lib/nested_page.dart new file mode 100644 index 0000000..2fc98d1 --- /dev/null +++ b/example/lib/nested_page.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:snowplow_tracker/snowplow_tracker.dart'; +import 'package:uuid/uuid.dart'; +import 'overview.dart'; +import 'snowplow_bdp.dart'; + +class NestedPage extends StatefulWidget { + const NestedPage({required this.tracker, Key? key}) : super(key: key); + + final SnowplowTracker tracker; + + static const String routeName = '/tab'; + + @override + State createState() => _NestedPageState(); +} + +class _NestedPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _controller = TabController( + vsync: this, + length: tabs.length, + initialIndex: selectedIndex, + ); + int selectedIndex = 0; + + final List tabs = [ + const Tab(text: 'Flutter Tracker'), + const Tab(text: 'Snowplow BDP'), + ]; + + @override + void initState() { + super.initState(); + _controller.addListener(() { + setState(() { + if (selectedIndex != _controller.index) { + selectedIndex = _controller.index; + _trackCurrentTabChange(); + } + }); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: _controller, + tabs: tabs, + ), + ), + body: TabBarView( + controller: _controller, + children: tabs.map((Tab tab) { + return Center( + child: tab.text == 'Snowplow BDP' + ? const SnowplowBDP() + : const Overview()); + }).toList(), + ), + ); + } + + void _trackCurrentTabChange() { + final tabName = selectedIndex == 0 ? 'tracker' : 'bdp'; + final title = '${NestedPage.routeName}/$tabName'; + final event = widget.tracker.tracksPageViews + ? PageViewEvent(title: title) + : ScreenView(name: title, id: const Uuid().v4()); + widget.tracker.track(event); + } +} diff --git a/example/lib/overview.dart b/example/lib/overview.dart new file mode 100644 index 0000000..da2341d --- /dev/null +++ b/example/lib/overview.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +class Overview extends StatelessWidget { + const Overview({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Markdown( + data: + '''The Snowplow Flutter Tracker allows you to add analytics to your Flutter apps when using a [Snowplow](https://github.com/snowplow/snowplow) pipeline. + +With this tracker you can collect granular event-level data as your users interact with your Flutter applications. +It is build on top of Snowplow's native [iOS](https://github.com/snowplow/snowplow-objc-tracker) and [Android](https://github.com/snowplow/snowplow-android-tracker) and [web](https://github.com/snowplow/snowplow-javascript-tracker) trackers, in order to support the full range of out-of-the-box Snowplow events and tracking capabilities. + +## Quick Start + +### Installation + +Add the Snowplow tracker as a dependency to your Flutter application: + +```bash +flutter pub add snowplow_tracker +``` + +This will add a line with the dependency like this to your `pubspec.yaml`: + +```yml +dependencies: + snowplow_tracker: ^0.1.0 +``` + +Import the package into your Dart code: + +```dart +import 'package:snowplow_tracker/snowplow_tracker.dart' +``` + +#### Installation on Web + +If using the tracker within a Flutter app for Web, you will also need to import the Snowplow JavaScript Tracker in your `index.html` file. Please load the JS tracker with the Snowplow tag as [described in the official documentation](https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v3/tracker-setup/loading/). Do not change the global function name `snowplow` that is used to access the tracker – the Flutter APIs assume that it remains the default as shown in documentation. + +Make sure to use JavaScript tracker version `3.2` or newer. You may also refer to the [example project](https://github.com/snowplow-incubator/snowplow-flutter-tracker/tree/main/example) in the Flutter tracker repository to see this in action. + +### Using the Tracker + +Instantiate a tracker using the `Snowplow.createTracker` function. +You may create the tracker in the `initState()` of your main widget. +The function takes two required arguments: `namespace` and `endpoint`. +Tracker namespace identifies the tracker instance; you may create multiple trackers with different namespaces. +The endpoint is the URI of the Snowplow collector to send the events to. +There are additional optional arguments to configure the tracker, please refer to the documentation for a complete specification. + +```dart +SnowplowTracker tracker = await Snowplow.createTracker( + namespace: 'ns1', + endpoint: 'http://...' +); +``` + +To track events, simply instantiate their respective types (e.g., `ScreenView`, `SelfDescribing`, `Structured`) and pass them to the `tracker.track` or `Snowplow.track` methods. +Please refer to the documentation for specification of event properties. + +```dart +// Tracking a screen view event +tracker.track(ScreenView( + id: '2c295365-eae9-4243-a3ee-5c4b7baccc8f', + name: 'home', + type: 'full', + transitionType: 'none')); +''', + ); + } +} diff --git a/example/lib/snowplow_bdp.dart b/example/lib/snowplow_bdp.dart new file mode 100644 index 0000000..42c0bf4 --- /dev/null +++ b/example/lib/snowplow_bdp.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +class SnowplowBDP extends StatelessWidget { + const SnowplowBDP({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Markdown( + data: + '''# Snowplow BDP: the behavioral data platform that drives more value, faster + +Behavioral data is generated in / collected from different digital touchpoints, typically including websites and mobile apps (referred to as "Sources"). Sources can include third party SaaS solutions that support webhooks (e.g. Zendesk, Mailgun).  + +Snowplow BDP processes and delivers this behavioral data to different endpoints (referred to as "Destinations"). From there, companies use that data to accomplish different use cases. Destinations include data warehouses (AWS Redshift, GCP BigQuery, SnowflakeDB), data lakes (AWS S3, GCP GCS) and streams (e.g. AWS Kinesis, GCP Pub/Sub). + +As part of Snowplow BDP we provide standard models (e.g. web or mobile models) that transform the data in the data warehouse to make it easier to consume by downstream applications and systems (e.g. business intelligence). + +Key features +------------ + +### Data processing-as-a-Service + +Snowplow BDP is provided as a service. Snowplow takes responsibility for the setup, administration and successful running of the Snowplow behavioral data pipeline(s) and all related technology infrastructure.  + +Please note that the Snowplow team can only do the above subject to the customer providing Snowplow with the required access levels to their cloud infrastructure, and compliance with all Snowplow Documentation and reasonable instructions. + +### A UI and API are provided to facilitate pipeline management and monitoring + +Snowplow BDP customers can manage the setup and configuration of their Snowplow pipeline via a UI and API on console.snowplowanalytics.com. This provides functionality to: + +- View and update pipeline configuration, including testing changes in a development environment before pushing them to production2 +- Manage and evolve event and entity definitions +- Monitor and enhance data quality + +### Open core + +The core data processing elements of the Snowplow pipeline are predominantly open source and the code is available on GitHub under the Apache 2 license. + +Key elements of the Snowplow BDP technology stack, including the UI and API, are proprietary. + +### Data residency + +All data processed and collected with Snowplow BDP is undertaken within the customer's own cloud account (e.g. AWS, GCP). The customer decides: + +- What data is collected +- Where it is processed and stored (e.g. what cloud and region) +- What the data is used for and who has access to it + +It is the customer's obligation (and not Snowplow's) to maintain and administer the cloud account.  Each of customer's and Snowplow's obligations with respect to data protection and privacy are set forth in a Data Protection Agreement. +''', + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index a5da682..66a0cc5 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -8,6 +8,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.6" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" async: dependency: transitive description: @@ -95,6 +102,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.4" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.9" flutter_test: dependency: "direct dev" description: flutter @@ -143,6 +157,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + markdown: + dependency: transitive + description: + name: markdown + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" matcher: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1844990..4fea863 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -31,6 +31,8 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 + flutter_markdown: ^0.6.9 + dev_dependencies: integration_test: sdk: flutter diff --git a/lib/configurations/activity_tracking_configuration.dart b/lib/configurations/activity_tracking_configuration.dart deleted file mode 100644 index d35e6b8..0000000 --- a/lib/configurations/activity_tracking_configuration.dart +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) 2022 Snowplow Analytics Ltd. All rights reserved. -// -// This program is licensed to you under the Apache License Version 2.0, -// and you may not use this file except in compliance with the Apache License Version 2.0. -// You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the Apache License Version 2.0 is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - -import 'package:flutter/foundation.dart'; - -/// Activity tracking monitors whether a user continues to engage with a page over time, and record how he / she digests content on the page over time. -/// -/// That is accomplished using ‘page ping’ events. If activity tracking is enabled, the web page is monitored to see if a user is engaging with it. (E.g. is the tab in focus, does the mouse move over the page, does the user scroll etc.) If any of these things occur in a set period of time, a page ping event fires, and records the maximum scroll left / right and up / down in the last ping period. If there is no activity in the page (e.g. because the user is on a different tab in his / her browser), no page ping fires. -/// -/// Only available on Web, ignored on mobile. -/// {@category Sessions and data model} -/// {@category Initialization and configuration} -@immutable -class ActivityTrackingConfiguration { - /// Indicates whether activity tracking is enabled or not. - final bool enabled; - - /// Time period from page load before the first page ping occurs, in seconds. - final int minimumVisitLength; - - /// Number of seconds between each page ping, once they have started. - final int heartbeatDelay; - - const ActivityTrackingConfiguration( - {required this.minimumVisitLength, - required this.heartbeatDelay, - this.enabled = true}); - - Map toMap() { - final conf = { - 'enabled': enabled, - 'minimumVisitLength': minimumVisitLength, - 'heartbeatDelay': heartbeatDelay - }; - conf.removeWhere((key, value) => value == null); - return conf; - } -} diff --git a/lib/configurations/tracker_configuration.dart b/lib/configurations/tracker_configuration.dart index 5b2817d..92ea2cc 100644 --- a/lib/configurations/tracker_configuration.dart +++ b/lib/configurations/tracker_configuration.dart @@ -10,11 +10,12 @@ // See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. import 'package:flutter/foundation.dart'; -import 'package:snowplow_tracker/configurations/activity_tracking_configuration.dart'; +import 'package:snowplow_tracker/configurations/web_activity_tracking.dart'; /// Configuration of the tracker and the core tracker properties. /// -/// Indicates what should be tracked in terms of automatic tracking and contexts/entities to attach to the events. +/// Indicates what should be tracked in terms of automatic tracking and +/// contexts/entities to attach to the events. /// {@category Sessions and data model} /// {@category Initialization and configuration} @immutable @@ -49,13 +50,15 @@ class TrackerConfiguration { /// Defaults to true. final bool? sessionContext; - /// Indicates whether context about current web page should be attached to tracked events. + /// Indicates whether context about current web page should be attached to + /// tracked events. /// /// Only available on Web, defaults to true. final bool? webPageContext; - /// Configuration for activity tracking on the Web. - final ActivityTrackingConfiguration? activityTrackingConfig; + /// Configuration for activity tracking on the Web and use of `PageViewEvent` + /// events in auto tracking from [SnowplowObserver] observers. + final WebActivityTracking? webActivityTracking; const TrackerConfiguration( {this.appId, @@ -65,7 +68,7 @@ class TrackerConfiguration { this.geoLocationContext, this.sessionContext, this.webPageContext, - this.activityTrackingConfig}); + this.webActivityTracking}); Map toMap() { final conf = { @@ -76,7 +79,7 @@ class TrackerConfiguration { 'geoLocationContext': geoLocationContext, 'sessionContext': sessionContext, 'webPageContext': webPageContext, - 'activityTrackingConfig': activityTrackingConfig?.toMap(), + 'webActivityTracking': webActivityTracking?.toMap(), }; conf.removeWhere((key, value) => value == null); return conf; diff --git a/lib/configurations/web_activity_tracking.dart b/lib/configurations/web_activity_tracking.dart new file mode 100644 index 0000000..55436c9 --- /dev/null +++ b/lib/configurations/web_activity_tracking.dart @@ -0,0 +1,68 @@ +// Copyright (c) 2022 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License Version 2.0. +// You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + +import 'package:flutter/foundation.dart'; + +/// Configuration for the behavior of page tracking on Web. Initializing the +/// configuration will inform [SnowplowObserver] observers to auto track +/// `PageViewEvent` events instead of `ScreenView` events on navigation changes. +/// Further, setting the [minimumVisitLength] and [heartbeatDelay] properties +/// will enable activity tracking using 'page ping' events on Web. +/// +/// Activity tracking monitors whether a user continues to engage with a page +/// over time, and record how he / she digests content on the page over time. +/// That is accomplished using 'page ping' events. If activity tracking is +/// enabled, the web page is monitored to see if a user is engaging with it. +/// (E.g. is the tab in focus, does the mouse move over the page, does the user +/// scroll etc.) If any of these things occur in a set period of time, a page +/// ping event fires, and records the maximum scroll left / right and up / down +/// in the last ping period. If there is no activity in the page (e.g. because +/// the user is on a different tab in his / her browser), no page ping fires. +/// +/// Only available on Web, ignored on mobile. +/// {@category Sessions and data model} +/// {@category Initialization and configuration} +@immutable +class WebActivityTracking { + /// Indicates whether to track page views from navigator observer. + /// + /// The observer can be accessed through the `observer` property in `SnowplowTracker` instance. + final bool trackPageViewsInObserver; + + /// Time period from page load before the first page ping occurs, in seconds. + /// + /// Activity tracking will not be enabled if null. + final int? minimumVisitLength; + + /// Number of seconds between each page ping, once they have started. + /// + /// Activity tracking will not be enabled if null. + final int? heartbeatDelay; + + const WebActivityTracking( + {this.minimumVisitLength, + this.heartbeatDelay, + this.trackPageViewsInObserver = true}); + + Map toMap() { + final conf = { + 'minimumVisitLength': minimumVisitLength, + 'heartbeatDelay': heartbeatDelay + }; + conf.removeWhere((key, value) => value == null); + return conf; + } + + /// Indicates whether activity tracking is enabled or not. + bool get enableActivityTracking { + return minimumVisitLength != null && heartbeatDelay != null; + } +} diff --git a/lib/snowplow.dart b/lib/snowplow.dart index dcb94f0..a00e8a0 100644 --- a/lib/snowplow.dart +++ b/lib/snowplow.dart @@ -32,7 +32,7 @@ class Snowplow { /// /// [endpoint] refers to the Snowplow collector endpoint. /// [method] is the HTTP method used to send events to collector and it defaults to POST. - static Future createTracker( + static Future createTracker( {required String namespace, required String endpoint, Method? method, @@ -46,7 +46,7 @@ class Snowplow { subjectConfig: subjectConfig, gdprConfig: gdprConfig); await _channel.invokeMethod('createTracker', configuration.toMap()); - return Tracker(namespace: configuration.namespace); + return SnowplowTracker(configuration: configuration); } /// Tracks the given event using the specified [tracker] namespace and with optional context entities. diff --git a/lib/snowplow_observer.dart b/lib/snowplow_observer.dart new file mode 100644 index 0000000..e7a0a49 --- /dev/null +++ b/lib/snowplow_observer.dart @@ -0,0 +1,67 @@ +// Copyright (c) 2022 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License Version 2.0. +// You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + +import 'package:flutter/widgets.dart'; +import 'package:uuid/uuid.dart'; +import 'package:snowplow_tracker/snowplow_tracker.dart'; + +/// Signature for a function that extracts view name from [RouteSettings] to be +/// used in `ScreenView` or `PageViewEvent` events tracked in the observer. +typedef ScreenNameExtractor = String? Function(RouteSettings settings); + +String? defaultNameExtractor(RouteSettings settings) => settings.name; + +/// Route observer that tracks `ScreenView` or `PageViewEvent` events when the +/// currently active [ModalRoute] changes. +/// +/// See the documentation in `SnowplowTracker.getObserver` for a more in-depth guide. +class SnowplowObserver extends RouteObserver> { + SnowplowObserver( + {required this.tracker, this.nameExtractor = defaultNameExtractor}); + + final ScreenNameExtractor nameExtractor; + final SnowplowTracker tracker; + + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + if (route is PageRoute) { + _trackRoute(route); + } + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + super.didReplace(newRoute: newRoute, oldRoute: oldRoute); + if (newRoute is PageRoute) { + _trackRoute(newRoute); + } + } + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + if (previousRoute is PageRoute && route is PageRoute) { + _trackRoute(previousRoute); + } + } + + void _trackRoute(PageRoute route) { + final String? screenName = nameExtractor(route.settings); + if (tracker.tracksPageViews) { + tracker.track(PageViewEvent(title: screenName)); + } else { + if (screenName != null) { + tracker.track(ScreenView(name: screenName, id: const Uuid().v4())); + } + } + } +} diff --git a/lib/snowplow_tracker.dart b/lib/snowplow_tracker.dart index f35b618..cd00de1 100644 --- a/lib/snowplow_tracker.dart +++ b/lib/snowplow_tracker.dart @@ -11,12 +11,13 @@ export 'snowplow.dart'; export 'tracker.dart'; +export 'snowplow_observer.dart'; export 'configurations/gdpr_configuration.dart'; export 'configurations/network_configuration.dart'; export 'configurations/subject_configuration.dart'; export 'configurations/tracker_configuration.dart'; -export 'configurations/activity_tracking_configuration.dart'; +export 'configurations/web_activity_tracking.dart'; export 'events/consent_granted.dart'; export 'events/consent_withdrawn.dart'; diff --git a/lib/src/web/readers/configurations/tracker_configuration_reader.dart b/lib/src/web/readers/configurations/tracker_configuration_reader.dart index 76e5699..f24a1f7 100644 --- a/lib/src/web/readers/configurations/tracker_configuration_reader.dart +++ b/lib/src/web/readers/configurations/tracker_configuration_reader.dart @@ -10,7 +10,7 @@ // See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. import 'package:snowplow_tracker/configurations/tracker_configuration.dart'; -import 'package:snowplow_tracker/src/web/readers/configurations/activity_tracking_configuration_reader.dart'; +import 'package:snowplow_tracker/src/web/readers/configurations/web_activity_tracking_reader.dart'; class TrackerConfigurationReader extends TrackerConfiguration { TrackerConfigurationReader(dynamic map) @@ -24,10 +24,9 @@ class TrackerConfigurationReader extends TrackerConfiguration { geoLocationContext: map['geoLocationContext'], sessionContext: map['sessionContext'], webPageContext: map['webPageContext'], - activityTrackingConfig: map['activityTrackingConfig'] == null + webActivityTracking: map['webActivityTracking'] == null ? null - : ActivityTrackingConfigurationReader( - map['activityTrackingConfig'])); + : WebActivityTrackingReader(map['webActivityTracking'])); void addTrackerOptions(dynamic options) { if (appId != null) { diff --git a/lib/src/web/readers/configurations/activity_tracking_configuration_reader.dart b/lib/src/web/readers/configurations/web_activity_tracking_reader.dart similarity index 75% rename from lib/src/web/readers/configurations/activity_tracking_configuration_reader.dart rename to lib/src/web/readers/configurations/web_activity_tracking_reader.dart index 45ab7e0..558bdd4 100644 --- a/lib/src/web/readers/configurations/activity_tracking_configuration_reader.dart +++ b/lib/src/web/readers/configurations/web_activity_tracking_reader.dart @@ -9,13 +9,11 @@ // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. -import 'package:snowplow_tracker/configurations/activity_tracking_configuration.dart'; +import 'package:snowplow_tracker/configurations/web_activity_tracking.dart'; -class ActivityTrackingConfigurationReader - extends ActivityTrackingConfiguration { - ActivityTrackingConfigurationReader(dynamic map) +class WebActivityTrackingReader extends WebActivityTracking { + WebActivityTrackingReader(dynamic map) : super( - enabled: map['enabled'], minimumVisitLength: map['minimumVisitLength'], heartbeatDelay: map['heartbeatDelay']); } diff --git a/lib/src/web/snowplow_tracker_controller.dart b/lib/src/web/snowplow_tracker_controller.dart index ecb120e..03a2d49 100644 --- a/lib/src/web/snowplow_tracker_controller.dart +++ b/lib/src/web/snowplow_tracker_controller.dart @@ -38,14 +38,16 @@ class SnowplowTrackerController { })); } - if (configuration.trackerConfig?.activityTrackingConfig?.enabled ?? false) { - final activityTrackingConfig = - configuration.trackerConfig!.activityTrackingConfig!; + if (configuration + .trackerConfig?.webActivityTracking?.enableActivityTracking ?? + false) { + final webActivityTracking = + configuration.trackerConfig!.webActivityTracking!; snowplow( 'enableActivityTracking', jsify({ - 'minimumVisitLength': activityTrackingConfig.minimumVisitLength, - 'heartbeatDelay': activityTrackingConfig.heartbeatDelay + 'minimumVisitLength': webActivityTracking.minimumVisitLength, + 'heartbeatDelay': webActivityTracking.heartbeatDelay })); } diff --git a/lib/tracker.dart b/lib/tracker.dart index 9f6d6c6..814d35d 100644 --- a/lib/tracker.dart +++ b/lib/tracker.dart @@ -11,18 +11,21 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:snowplow_tracker/events/event.dart'; import 'package:snowplow_tracker/events/self_describing.dart'; import 'package:snowplow_tracker/snowplow.dart'; +import 'package:snowplow_tracker/snowplow_observer.dart'; +import 'package:snowplow_tracker/configurations/configuration.dart'; -/// Instance of an initialized Snowplow tracker identified by [namespace]. +/// Instance of an initialized Snowplow tracker identified by a [namespace]. /// /// {@category Getting started} -class Tracker { - /// Unique tracker namespace. - final String namespace; +class SnowplowTracker { + /// Tracker configuration. + final Configuration configuration; - const Tracker({required this.namespace}); + const SnowplowTracker({required this.configuration}); /// Tracks the given event with optional context entities. Future track(Event event, {List? contexts}) async { @@ -54,4 +57,80 @@ class Tracker { Future get sessionIndex async { return await Snowplow.getSessionIndex(tracker: namespace); } + + /// Returns a [SnowplowObserver] for automatically tracking `PageViewEvent` + /// and `ScreenView` events from a navigator when the currently active + /// [ModalRoute] of the navigator changes. + /// + /// `ScreenView` events are tracked on all platforms. Optionally, + /// `PageViewEvent` events may be tracked on Web if + /// `TrackerConfiguration.webActivityTracking` is configured when creating + /// the tracker. + /// + /// The [nameExtractor] function is used to extract a name + /// from [RouteSettings] of the now active route and that name is used in + /// tracked `ScreenView` or `PageViewEvent` events. + /// + /// The following operations will result in tracking a view event: + /// + /// ```dart + /// Navigator.pushNamed(context, '/contact/123'); + /// + /// Navigator.push(context, MaterialPageRoute( + /// settings: RouteSettings(name: '/contact/123'), + /// builder: (_) => ContactDetail(123))); + /// + /// Navigator.pushReplacement(context, MaterialPageRoute( + /// settings: RouteSettings(name: '/contact/123'), + /// builder: (_) => ContactDetail(123))); + /// + /// Navigator.pop(context); + /// ``` + /// + /// If using [MaterialApp], add the retrieved observer to + /// `navigatorObservers`, e.g.: + /// + /// ```dart + /// MaterialApp( + /// navigatorObservers: [ + /// tracker.getObserver() + /// ], + /// ... + /// ); + /// ``` + /// + /// If using the `Router` API with the `MaterialApp.router` constructor, + /// add the observer to the `observers` of your [Navigator] instance, e.g.: + /// + /// ```dart + /// return Navigator( + /// observers: [tracker.getObserver()], + /// ... + /// ); + /// ``` + /// + /// You can also trigger view event tracking within your [ModalRoute] by implementing + /// [RouteAware>] and subscribing it to [SnowplowObserver]. + /// See the [RouteObserver>] docs for an example. + SnowplowObserver getObserver( + {ScreenNameExtractor nameExtractor = defaultNameExtractor}) { + return SnowplowObserver(tracker: this, nameExtractor: nameExtractor); + } + + /// Namespace that identifies the tracker. + String get namespace { + return configuration.namespace; + } + + /// Returns true if current platform is Web and `PageViewEvent` tracking is + /// enabled in `trackerConfig.webActivityTracking` configuration. + /// + /// Indicates whether page view events will be tracked in any initialized + /// observers. If false, screen view events will be tracked. + bool get tracksPageViews { + return kIsWeb && + (configuration + .trackerConfig?.webActivityTracking?.trackPageViewsInObserver ?? + false); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 7fe1f27..5806146 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,13 +14,13 @@ dependencies: flutter_web_plugins: sdk: flutter js: ^0.6.3 + uuid: ^3.0.5 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^1.0.0 http: ^0.13.3 - uuid: ^3.0.5 flutter: plugin: diff --git a/test/tracker_test.dart b/test/tracker_test.dart index b934c7d..ee40d28 100644 --- a/test/tracker_test.dart +++ b/test/tracker_test.dart @@ -19,7 +19,7 @@ void main() { const MethodChannel channel = MethodChannel('snowplow_tracker'); String? method; dynamic arguments; - Tracker? tracker; + SnowplowTracker? tracker; dynamic returnValue; TestWidgetsFlutterBinding.ensureInitialized();