From 1b16a2a57a5c88374c3f0c29a3d2b250f4097a7d Mon Sep 17 00:00:00 2001 From: Marcelo Soares Date: Thu, 28 May 2026 16:13:34 -0300 Subject: [PATCH] feat: Add a `flush` method to the base `Analytics` class --- .../lib/src/analytics/analytics.dart | 44 ++++++- .../cli_tools/lib/src/analytics/mixpanel.dart | 31 ++--- .../cli_tools/lib/src/analytics/posthog.dart | 29 ++--- .../test/analytics/analytics_test.dart | 111 ++++++++++++++++++ 4 files changed, 168 insertions(+), 47 deletions(-) create mode 100644 packages/cli_tools/test/analytics/analytics_test.dart diff --git a/packages/cli_tools/lib/src/analytics/analytics.dart b/packages/cli_tools/lib/src/analytics/analytics.dart index d3367af..694d8e1 100644 --- a/packages/cli_tools/lib/src/analytics/analytics.dart +++ b/packages/cli_tools/lib/src/analytics/analytics.dart @@ -1,16 +1,40 @@ -/// Interface for analytics services. -abstract interface class Analytics { +/// Base class for analytics services. +abstract class Analytics { + final _pendingTracks = >{}; + /// Clean up resources. - void cleanUp(); + void cleanUp() {} + + /// Flush pending analytics events. + Future flush() async { + while (_pendingTracks.isNotEmpty) { + await Future.wait([ + for (final pendingTrack in _pendingTracks) + pendingTrack.catchError((final _) {}), + ]); + } + } /// Track an event. void track({ required final String event, final Map properties = const {}, + }) { + late final Future pendingTrack; + pendingTrack = sendEvent(event: event, properties: properties) + .catchError((final _) {}) + .whenComplete(() => _pendingTracks.remove(pendingTrack)); + _pendingTracks.add(pendingTrack); + } + + /// Send an event to the analytics service. + Future sendEvent({ + required final String event, + final Map properties = const {}, }); } -class CompoundAnalytics implements Analytics { +class CompoundAnalytics extends Analytics { final List providers; CompoundAnalytics(this.providers); @@ -23,10 +47,18 @@ class CompoundAnalytics implements Analytics { } @override - void track({ + Future flush() async { + await Future.wait([ + for (final provider in providers) + Future.sync(provider.flush).catchError((final _) {}), + ]); + } + + @override + Future sendEvent({ required final String event, final Map properties = const {}, - }) { + }) async { for (final provider in providers) { provider.track( event: event, diff --git a/packages/cli_tools/lib/src/analytics/mixpanel.dart b/packages/cli_tools/lib/src/analytics/mixpanel.dart index b1040be..8f112d6 100644 --- a/packages/cli_tools/lib/src/analytics/mixpanel.dart +++ b/packages/cli_tools/lib/src/analytics/mixpanel.dart @@ -8,7 +8,7 @@ import 'analytics.dart'; import 'helpers.dart'; /// Analytics service for MixPanel. -class MixPanelAnalytics implements Analytics { +class MixPanelAnalytics extends Analytics { static const _defaultEndpoint = 'https://api.mixpanel.com/track'; static const _defaultTimeout = Duration(seconds: 2); @@ -52,10 +52,7 @@ class MixPanelAnalytics implements Analytics { } @override - void cleanUp() {} - - @override - void track({ + Future sendEvent({ required final String event, final Map properties = const {}, }) { @@ -71,21 +68,13 @@ class MixPanelAnalytics implements Analytics { }, }); - _quietPost(payload); - } - - Future _quietPost(final String payload) async { - try { - await http.post( - _endpoint, - body: 'data=$payload', - headers: { - 'Accept': 'text/plain', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - ).timeout(_timeout); - } catch (e) { - return; - } + return http.post( + _endpoint, + body: 'data=$payload', + headers: { + 'Accept': 'text/plain', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ).timeout(_timeout); } } diff --git a/packages/cli_tools/lib/src/analytics/posthog.dart b/packages/cli_tools/lib/src/analytics/posthog.dart index b645168..fcbbd43 100644 --- a/packages/cli_tools/lib/src/analytics/posthog.dart +++ b/packages/cli_tools/lib/src/analytics/posthog.dart @@ -8,7 +8,7 @@ import 'analytics.dart'; import 'helpers.dart'; /// Analytics service for PostHog. -class PostHogAnalytics implements Analytics { +class PostHogAnalytics extends Analytics { static const _defaultHost = 'https://eu.i.posthog.com'; static const _defaultTimeout = Duration(seconds: 2); static const _defaultLibName = 'cli_tools'; @@ -36,10 +36,7 @@ class PostHogAnalytics implements Analytics { _libName = libName; @override - void cleanUp() {} - - @override - void track({ + Future sendEvent({ required final String event, final Map properties = const {}, }) { @@ -57,20 +54,12 @@ class PostHogAnalytics implements Analytics { }, }; - _quietPost(eventData); - } - - Future _quietPost(final Map eventData) async { - try { - await http - .post( - _endpoint, - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(eventData), - ) - .timeout(_timeout); - } catch (e) { - return; - } + return http + .post( + _endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(eventData), + ) + .timeout(_timeout); } } diff --git a/packages/cli_tools/test/analytics/analytics_test.dart b/packages/cli_tools/test/analytics/analytics_test.dart new file mode 100644 index 0000000..6393623 --- /dev/null +++ b/packages/cli_tools/test/analytics/analytics_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:cli_tools/analytics.dart'; +import 'package:test/test.dart'; + +import '../test_utils/test_utils.dart' show flushEventQueue; + +void main() { + test( + 'Given analytics with a pending event, ' + 'when flushing before the event send completes, ' + 'then flush waits until the event send completes.', + () async { + final analytics = PendingAnalytics(); + analytics.track(event: 'test'); + + var isFlushed = false; + final flush = analytics.flush().then((final _) { + isFlushed = true; + }); + + await flushEventQueue(); + expect(isFlushed, isFalse); + + analytics.completers.single.complete(); + await flush; + expect(isFlushed, isTrue); + }, + ); + + test( + 'Given analytics with a pending event, ' + 'when the event send fails while flushing, ' + 'then flush waits for the event and ignores the send failure.', + () async { + final analytics = PendingAnalytics(); + + analytics.track(event: 'test'); + + final flush = expectLater(analytics.flush(), completes); + await flushEventQueue(); + + analytics.completers.single.completeError( + StateError('Failed to send event.'), + ); + + await expectLater(flush, completes); + }, + ); + + test( + 'Given compound analytics with a provider that fails to flush, ' + 'when flushing the compound analytics, ' + 'then all providers are flushed and the failure is ignored.', + () async { + final failingProvider = FailingFlushAnalytics(); + final recordingProvider = RecordingFlushAnalytics(); + final analytics = CompoundAnalytics([failingProvider, recordingProvider]); + + await expectLater(analytics.flush(), completes); + + expect(failingProvider.didFlush, isTrue); + expect(recordingProvider.didFlush, isTrue); + }, + ); +} + +class PendingAnalytics extends Analytics { + final completers = >[]; + + @override + Future sendEvent({ + required final String event, + final Map properties = const {}, + }) { + final completer = Completer(); + completers.add(completer); + return completer.future; + } +} + +class FailingFlushAnalytics extends Analytics { + var didFlush = false; + + @override + Future flush() { + didFlush = true; + throw StateError('Failed to flush events.'); + } + + @override + Future sendEvent({ + required final String event, + final Map properties = const {}, + }) async {} +} + +class RecordingFlushAnalytics extends Analytics { + var didFlush = false; + + @override + Future flush() async { + didFlush = true; + } + + @override + Future sendEvent({ + required final String event, + final Map properties = const {}, + }) async {} +}