Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Persistent Dispatch Queue #97

Merged
merged 8 commits into from
May 31, 2023
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,18 @@ await MatomoTracker.instance.initialize(

When using cookieless tracking, neither the user_id nor the first_visit will be sent or saved locally.

## Dispatching

Actions logged are not send to Matomo immediately, but are queued for a configurable duration (defaulting to 10 seconds) before beeing send in batch. A user could terminate your app while there are still undispatched actions in the queue, which by default would be lost. The queue can be configured to be persistent so that such actions would then be send on the next app launch. See the [`DispatchSettings`](https://pub.dev/documentation/matomo_tracker/latest/matomo_tracker/DispatchSettings-class.html) class for more configuration options.

```dart
await MatomoTracker.instance.initialize(
siteId: siteId,
url: 'https://example.com/matomo.php',
dispatchSettings: const DispatchSettings.persistent(),
);
```

# Migration Guide

## v4.0.0
Expand Down
9 changes: 9 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,21 @@ const _matomoEndpoint = 'http://localhost:8765/matomo.php';
const _sideId = 1;
const _testUserId = 'Nelson Pandela';

// Use this as dispatchSettings in MatomoTracker.instance.initialize()
// to test persistent actions. Then run the example, cause some actions
// by clicking around, close the example within 5min to prevent the
// dispatchment of the actions, run the example again, wait at least 5min,
// finally check the Matomo dashboard to see if all actions are there.
const DispatchSettings dispatchSettingsEndToEndTest =
DispatchSettings.persistent(dequeueInterval: Duration(minutes: 5));

void main() async {
WidgetsFlutterBinding.ensureInitialized();
await MatomoTracker.instance.initialize(
siteId: _sideId,
url: _matomoEndpoint,
verbosityLevel: Level.all,
// dispatchSettings: dispatchSettingsEndToEndTest,
);
MatomoTracker.instance.setVisitorUserId(_testUserId);
runApp(const MyApp());
Expand Down
1 change: 1 addition & 0 deletions lib/matomo_tracker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ library matomo_tracker;

export 'src/campaign.dart';
export 'src/content.dart';
export 'src/dispatch_settings.dart';
export 'src/event_info.dart';
export 'src/exceptions.dart';
export 'src/local_storage/local_storage.dart';
Expand Down
130 changes: 130 additions & 0 deletions lib/src/dispatch_settings.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
bool _whereNotOlderThanADay(Map<String, String> action) =>
DispatchSettings.whereNotOlderThan(
const Duration(
hours: 23,
minutes: 59,
seconds: 59,
),
)(action);
TesteurManiak marked this conversation as resolved.
Show resolved Hide resolved

bool _notOlderThan(
Map<String, String> action,
DateTime now,
Duration duration,
) {
if (action['cdt'] case final date?) {
return now.difference(DateTime.parse(date)) <= duration;
}
return false;
}

bool _takeAll(Map<String, String> action) => true;
TesteurManiak marked this conversation as resolved.
Show resolved Hide resolved

bool _dropAll(Map<String, String> action) => false;
TesteurManiak marked this conversation as resolved.
Show resolved Hide resolved

/// Used to filter out unwanted actions of the last session if using
/// [DispatchSettings.persistent].
///
/// Will invoked with the serialized `action` and should return `true` if the
/// action is still valid, or `false` if the action should be dropped.
///
/// Filters can be chained using [DispatchSettings.chain].
///
/// Some build in filters are [DispatchSettings.takeAll],
/// [DispatchSettings.dropAll], [DispatchSettings.whereUserId],
/// [DispatchSettings.whereNotOlderThan] and
/// [DispatchSettings.whereNotOlderThanADay].
typedef PersistenceFilter = bool Function(Map<String, String> action);

/// Controls the behaviour of dispatching actions to Matomo.
class DispatchSettings {
/// Uses a persistent dispatch queue.
///
/// This means that if the app terminates while there are still undispatched
/// actions, those actions are dispatched on next app launch.
///
/// The [onLoad] can be used to filter the stored actions and drop outdated
/// ones. By default, only actions that are younger then a day are retained.
/// See [PersistenceFilter] for some build in filters.
const DispatchSettings.persistent({
Duration dequeueInterval = defaultDequeueInterval,
PersistenceFilter onLoad = whereNotOlderThanADay,
}) : this._(
dequeueInterval,
true,
onLoad,
);

/// Uses a non persistent dispatch queue.
///
/// This means that if the app terminates while there are still undispatched
/// actions, those actions are lost.
const DispatchSettings.nonPersistent({
Duration dequeueInterval = defaultDequeueInterval,
}) : this._(
dequeueInterval,
false,
null,
);

const DispatchSettings._(
this.dequeueInterval,
this.persistentQueue,
this.onLoad,
);

/// The default duration between dispatching actions to the Matomo backend.
static const Duration defaultDequeueInterval = Duration(
seconds: 10,
);

/// Takes all actions.
static const PersistenceFilter takeAll = _takeAll;

/// Drops all actions.
static const PersistenceFilter dropAll = _dropAll;

/// Only takes actions where the userId is `uid`.
static PersistenceFilter whereUserId(String uid) {
return (Map<String, String> action) => action['uid'] == uid;
}

/// Only takes actions that are not older than `duration`.
static PersistenceFilter whereNotOlderThan(Duration duration) =>
(Map<String, String> action) => _notOlderThan(
action,
DateTime.now(),
duration,
);

/// Shorthand for [whereNotOlderThan] with a duration of a day.
static const PersistenceFilter whereNotOlderThanADay = _whereNotOlderThanADay;

/// Combines multiple [PersistenceFilter]s.
///
/// The returned filter is eager, which means that it will return `false`
/// immediately and not check the reaming filters once a filter returned
/// `false`.
static PersistenceFilter chain(Iterable<PersistenceFilter> filters) {
return filters.fold(takeAll, (previousValue, element) {
return (Map<String, String> action) {
if (previousValue(action)) {
return element(action);
} else {
return false;
}
};
});
}

/// How often to dispatch actions to the Matomo backend.
final Duration dequeueInterval;

/// Wheter to store actions persistently before dispatching.
final bool persistentQueue;

/// Used to determine which of the stored actions are still valid.
///
/// Will not be `null` if [persistentQueue] is `true`, or `null` if `false`.
final PersistenceFilter? onLoad;
}
7 changes: 7 additions & 0 deletions lib/src/local_storage/cookieless_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,11 @@ class CookielessStorage implements LocalStorage {

@override
Future<void> setVisitorId(String _) => Future.value();

@override
Future<String?> loadActions() => storage.loadActions();

@override
Future<void> storeActions(String serializedActions) =>
storage.storeActions(serializedActions);
}
3 changes: 3 additions & 0 deletions lib/src/local_storage/local_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ abstract class LocalStorage {
Future<void> setVisitCount(int visitCount);
Future<bool> getOptOut();
Future<void> setOptOut({required bool optOut});
Future<void> storeActions(String serializedActions);
Future<String?> loadActions();

/// {@template local_storage.clear}
/// Clear the following data from the local storage:
///
/// - First visit
/// - Number of visits
/// - Visitor ID
/// - Action Queue
/// {@endtemplate}
Future<void> clear();
}
14 changes: 14 additions & 0 deletions lib/src/local_storage/shared_prefs_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class SharedPrefsStorage implements LocalStorage {
static const kVisitCount = 'matomo_visit_count';
static const kVisitorId = 'matomo_visitor_id';
static const kOptOut = 'matomo_opt_out';
static const kPersistentQueue = 'matomo_persistent_queue';

SharedPreferences? _prefs;

Expand Down Expand Up @@ -79,6 +80,19 @@ class SharedPrefsStorage implements LocalStorage {
prefs.remove(kFirstVisit),
prefs.remove(kVisitCount),
prefs.remove(kVisitorId),
prefs.remove(kPersistentQueue),
]);
}

@override
Future<String?> loadActions() async {
final prefs = await _getSharedPrefs();
return prefs.getString(kPersistentQueue);
}

@override
Future<void> storeActions(String serializedActions) async {
final prefs = await _getSharedPrefs();
await prefs.setString(kPersistentQueue, serializedActions);
}
}
40 changes: 30 additions & 10 deletions lib/src/matomo.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:matomo_tracker/src/assert.dart';
import 'package:matomo_tracker/src/campaign.dart';
import 'package:matomo_tracker/src/content.dart';
import 'package:matomo_tracker/src/dispatch_settings.dart';
import 'package:matomo_tracker/src/event_info.dart';
import 'package:matomo_tracker/src/exceptions.dart';
import 'package:matomo_tracker/src/local_storage/cookieless_storage.dart';
Expand All @@ -18,6 +19,7 @@ import 'package:matomo_tracker/src/logger/logger.dart';
import 'package:matomo_tracker/src/matomo_action.dart';
import 'package:matomo_tracker/src/matomo_dispatcher.dart';
import 'package:matomo_tracker/src/performance_info.dart';
import 'package:matomo_tracker/src/persistent_queue.dart';
import 'package:matomo_tracker/src/platform_info/platform_info.dart';
import 'package:matomo_tracker/src/session.dart';
import 'package:matomo_tracker/src/tracking_order_item.dart';
Expand Down Expand Up @@ -121,7 +123,7 @@ class MatomoTracker {
late final LocalStorage _localStorage;

@visibleForTesting
final queue = Queue<Map<String, String>>();
late final Queue<Map<String, String>> queue;

@visibleForTesting
late Timer dequeueTimer;
Expand All @@ -135,7 +137,8 @@ class MatomoTracker {

String? get authToken => _tokenAuth;

late final Duration _dequeueInterval;
/// Controls how actions are dispatched.
late final DispatchSettings _dispatchSettings;

late final Duration? _pingInterval;

Expand Down Expand Up @@ -179,7 +182,7 @@ class MatomoTracker {
String? visitorId,
String? uid,
String? contentBaseUrl,
Duration dequeueInterval = const Duration(seconds: 10),
DispatchSettings dispatchSettings = const DispatchSettings.nonPersistent(),
Duration? pingInterval = const Duration(seconds: 30),
String? tokenAuth,
LocalStorage? localStorage,
Expand All @@ -203,27 +206,39 @@ class MatomoTracker {
);
}

assertDurationNotNegative(value: dequeueInterval, name: 'dequeueInterval');
assertDurationNotNegative(value: pingInterval, name: 'pingInterval');
assertDurationNotNegative(
value: dispatchSettings.dequeueInterval,
name: 'dequeueInterval',
);
assertDurationNotNegative(
value: pingInterval,
name: 'pingInterval',
);

log.setLogging(level: verbosityLevel);

this.siteId = siteId;
this.url = url;
this.customHeaders = customHeaders;
_dequeueInterval = dequeueInterval;
_pingInterval = pingInterval;
_lock = sync.Lock();
_platformInfo = platformInfo ?? PlatformInfo.instance;
_cookieless = cookieless;
_tokenAuth = tokenAuth;
_newVisit = newVisit;
this.attachLastPvId = attachLastPvId;
_dispatchSettings = dispatchSettings;

final effectiveLocalStorage = localStorage ?? SharedPrefsStorage();
_localStorage = cookieless
? CookielessStorage(storage: effectiveLocalStorage)
: effectiveLocalStorage;
queue = _dispatchSettings.persistentQueue
? await PersistentQueue.load(
storage: _localStorage,
onLoadFilter: _dispatchSettings.onLoad!,
)
: Queue();

final localVisitorId = visitorId ?? await _getVisitorId();
_visitor = Visitor(id: localVisitorId, uid: uid);
Expand Down Expand Up @@ -288,7 +303,7 @@ class MatomoTracker {
);
_initialized = true;

dequeueTimer = Timer.periodic(_dequeueInterval, (_) {
dequeueTimer = Timer.periodic(_dispatchSettings.dequeueInterval, (_) {
_dequeue();
});

Expand All @@ -297,6 +312,10 @@ class MatomoTracker {
_ping();
});
}

if (queue.isNotEmpty) {
await dispatchActions();
}
}

@visibleForTesting
Expand Down Expand Up @@ -352,8 +371,9 @@ class MatomoTracker {
/// {@macro local_storage.clear}
void clear() => _localStorage.clear();

/// Cancel the timer which checks the queued actions to send. (This will not
/// clear the queue.)
/// Cancel the timer which checks the queued actions to send
///
/// This will not clear the queue.
void dispose() {
pingTimer?.cancel();
dequeueTimer.cancel();
Expand All @@ -379,7 +399,7 @@ class MatomoTracker {
}
}
if (!dequeueTimer.isActive) {
dequeueTimer = Timer.periodic(_dequeueInterval, (timer) {
dequeueTimer = Timer.periodic(_dispatchSettings.dequeueInterval, (timer) {
_dequeue();
});
}
Expand Down
Loading