From a5ac5c4c4e34f5ac7fa981b0d532558e41637dc7 Mon Sep 17 00:00:00 2001 From: Reza Taghizadeh Date: Sun, 19 Apr 2026 00:56:07 +0200 Subject: [PATCH] feat: add event transformer pipeline for automatic event enrichment --- README.md | 137 +++++++++ .../lib/screens/event_enrichment_screen.dart | 291 ++++++++++++++++++ example/lib/screens/home_screen.dart | 8 + lib/flex_track.dart | 2 + lib/src/core/event_processor.dart | 53 +++- lib/src/core/flex_track.dart | 14 + lib/src/core/flex_track_client.dart | 29 +- lib/src/models/event/enriched_event.dart | 71 +++++ lib/src/models/event/event_transformer.dart | 45 +++ .../event_processor_transformer_test.dart | 141 +++++++++ .../flex_track_client_transformer_test.dart | 77 +++++ test/core/flex_track_transformer_test.dart | 58 ++++ test/models/event/enriched_event_test.dart | 129 ++++++++ test/models/event/event_transformer_test.dart | 63 ++++ test/widgets/transformer_widget_test.dart | 125 ++++++++ 15 files changed, 1223 insertions(+), 20 deletions(-) create mode 100644 example/lib/screens/event_enrichment_screen.dart create mode 100644 lib/src/models/event/enriched_event.dart create mode 100644 lib/src/models/event/event_transformer.dart create mode 100644 test/core/event_processor_transformer_test.dart create mode 100644 test/core/flex_track_client_transformer_test.dart create mode 100644 test/core/flex_track_transformer_test.dart create mode 100644 test/models/event/enriched_event_test.dart create mode 100644 test/models/event/event_transformer_test.dart create mode 100644 test/widgets/transformer_widget_test.dart diff --git a/README.md b/README.md index ff371c6..dfee7d2 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,12 @@ One call site, multiple tracker destinations, centralized policy. - [Creating events](#creating-events) - [Event flags](#event-flags) - [EventCategory values](#eventcategory-values) + - [Event transformers](#event-transformers) + - [App-wide context (setup once)](#app-wide-context-setup-once) + - [Dynamic values (evaluated at dispatch time)](#dynamic-values-evaluated-at-dispatch-time) + - [Conditional transformers](#conditional-transformers) + - [Chaining transformers](#chaining-transformers) + - [EnrichedEvent](#enrichedevent) - [Creating trackers](#creating-trackers) - [Firebase example](#firebase-example) - [Mixpanel example](#mixpanel-example) @@ -89,6 +95,7 @@ One call site, multiple tracker destinations, centralized policy. - [2. Forgetting FlexTrack.reset() between tests](#2-forgetting-flextrackreset-between-tests) - [3. Unstable visibilityKey in FlexImpressionTrack](#3-unstable-visibilitykey-in-fleximpressiontrack) - [4. Missing .routeDefault() at the end of a routing config](#4-missing-routedefault-at-the-end-of-a-routing-config) + - [5. Transformer added with an anonymous function can't be removed](#5-transformer-added-with-an-anonymous-function-cant-be-removed) - [Contributing and license](#contributing-and-license) - [Support](#support) @@ -190,6 +197,7 @@ FlexTrack exists to make that architecture explicit, maintainable, and debuggabl - Multi-tracker routing with a fluent DSL - Event policy controls (consent, PII, sampling, environment modifiers) +- Event transformers to automatically attach shared context to every event - Debug Inspector with live event visibility - `FlexTrackClient` for dependency injection patterns - Widget wrappers for click, impression, mount, and route-view tracking @@ -354,6 +362,113 @@ Rules of thumb: --- +## Event transformers + +Transformers let you automatically attach common parameters to every event — or to a filtered subset — without touching each call site. A transformer is a function that receives the outgoing event and returns a (potentially enriched) event. All transformers run inside `EventProcessor` before routing and before trackers receive the event. + +A common use case: attach the current screen name to every event so your analytics backend always knows which page the event came from. + +### App-wide context (setup once) + +Register transformers right after `FlexTrack.setup()`. They stay active for the lifetime of the app. + +```dart +// lib/main.dart +await FlexTrack.setup([ConsoleTracker(), FirebaseTracker()]); + +// Attach static context to every event. +FlexTrack.addTransformer((event) => EnrichedEvent(event, { + 'app_version': packageInfo.version, + 'platform': Platform.operatingSystem, +})); + +runApp(const MyApp()); +``` + +### Dynamic values (evaluated at dispatch time) + +Because the transformer function is called **at the moment an event is fired**, dynamic values like the current route are always fresh — no stale captures. + +```dart +// lib/main.dart +final routeObserver = FlexTrackRouteObserver(); + +// Register once after setup — currentRoute is read fresh on every event. +FlexTrack.addTransformer((event) => EnrichedEvent(event, { + 'current_route': routeObserver.currentRoute ?? 'unknown', +})); +``` + +### Conditional transformers + +Use `conditionalTransformer` to enrich only events that match a condition. Events that don't match pass through unchanged. + +```dart +FlexTrack.addTransformer( + conditionalTransformer( + (event) => event.category == EventCategory.business, + (event) => EnrichedEvent(event, {'checkout_experiment': 'variant_b'}), + ), +); +``` + +Any predicate works — event name, type, category, or custom property check. + +### Chaining transformers + +Transformers are applied in registration order. Each transformer receives the output of the previous one, so they compose naturally. + +```dart +// Transformer 1 — adds global context +FlexTrack.addTransformer((event) => EnrichedEvent(event, { + 'current_route': routeObserver.currentRoute ?? 'unknown', +})); + +// Transformer 2 — adds session context on top +FlexTrack.addTransformer((event) => EnrichedEvent(event, { + 'session_id': sessionManager.currentSessionId, +})); +``` + +### EnrichedEvent + +`EnrichedEvent` is a `BaseEvent` wrapper. It forwards all metadata from the original event (`category`, `containsPII`, `requiresConsent`, etc.) and overrides `getProperties()` to merge the original properties with the extra ones. Extra properties win on key collision. + +```dart +// Extra properties override originals on the same key. +EnrichedEvent(originalEvent, {'source': 'transformer'}) +``` + +You never need to modify your event classes. `EnrichedEvent` is the only class you need to produce from a transformer. + +**Removing a transformer:** + +To remove a transformer later (e.g. when the user logs out), keep a reference to the function and pass it to `removeTransformer`: + +```dart +BaseEvent _sessionTransformer(BaseEvent event) => + EnrichedEvent(event, {'session_id': sessionManager.currentSessionId}); + +// Add +FlexTrack.addTransformer(_sessionTransformer); + +// Remove later +FlexTrack.removeTransformer(_sessionTransformer); +``` + +`FlexTrack.clearTransformers()` removes all transformers at once. + +**With `FlexTrackClient` (dependency injection):** + +The same API is available on the injectable client: + +```dart +final client = await FlexTrackClient.create([ConsoleTracker()]); +client.addTransformer((event) => EnrichedEvent(event, {'tenant': tenantId})); +``` + +--- + ## Creating trackers All trackers extend `BaseTrackerStrategy`. Implement `doInitialize()` and `doTrack()`. The base class handles: @@ -1034,6 +1149,28 @@ Run `FlexTrack.validate()` during development — it will flag a missing default --- +### 5. Transformer added with an anonymous function can't be removed + +```dart +// ❌ The lambda is a new object each call — removeTransformer won't find it. +FlexTrack.addTransformer((e) => EnrichedEvent(e, {'route': '/home'})); +FlexTrack.removeTransformer((e) => EnrichedEvent(e, {'route': '/home'})); // no-op +``` + +```dart +// ✅ Keep a reference to the exact function instance. +BaseEvent _routeTransformer(BaseEvent e) => + EnrichedEvent(e, {'route': routeObserver.currentRoute ?? 'unknown'}); + +FlexTrack.addTransformer(_routeTransformer); +// Later: +FlexTrack.removeTransformer(_routeTransformer); +``` + +If you never need to remove a transformer individually, anonymous lambdas are fine. Use `clearTransformers()` to remove everything at once. + +--- + ## Contributing and license Source: [github.com/alirezat66/flex_track](https://github.com/alirezat66/flex_track) diff --git a/example/lib/screens/event_enrichment_screen.dart b/example/lib/screens/event_enrichment_screen.dart new file mode 100644 index 0000000..3fd3220 --- /dev/null +++ b/example/lib/screens/event_enrichment_screen.dart @@ -0,0 +1,291 @@ +import 'dart:async'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter/material.dart'; + +import '../events/app_events.dart'; + +class EventEnrichmentScreen extends StatefulWidget { + const EventEnrichmentScreen({super.key}); + + @override + State createState() => _EventEnrichmentScreenState(); +} + +class _EventEnrichmentScreenState extends State { + bool _transformerAdded = false; + EventTransformer? _activeTransformer; + final List _log = []; + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + _subscription = FlexTrack.eventDispatchStream.listen((record) { + if (!mounted) return; + final name = record.event.getName(); + final props = record.event.getProperties(); + setState(() { + _log.insert(0, '[$name] ${props ?? {}}'); + }); + }); + } + + @override + void dispose() { + _subscription?.cancel(); + if (_transformerAdded && _activeTransformer != null) { + FlexTrack.removeTransformer(_activeTransformer!); + } + super.dispose(); + } + + void _toggleTransformer() { + setState(() { + if (_transformerAdded) { + FlexTrack.removeTransformer(_activeTransformer!); + _activeTransformer = null; + _transformerAdded = false; + } else { + _activeTransformer = (event) => EnrichedEvent(event, { + 'current_route': 'event_enrichment_screen', + 'screen_version': '1.0', + }); + FlexTrack.addTransformer(_activeTransformer!); + _transformerAdded = true; + } + }); + } + + Future _fireButtonEvent() async { + await FlexTrack.track(ButtonClickEvent( + buttonId: 'enrichment_demo_button', + buttonText: 'Fire Button Event', + screenName: 'event_enrichment_screen', + )); + } + + Future _firePageViewEvent() async { + await FlexTrack.track(PageViewEvent( + pageName: 'event_enrichment_screen', + parameters: const {'source': 'demo'}, + )); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Event Enrichment'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _TransformerStatusCard(active: _transformerAdded), + const SizedBox(height: 16), + _ToggleTransformerCard( + active: _transformerAdded, + onToggle: _toggleTransformer, + ), + const SizedBox(height: 16), + _FireEventsCard( + onFireButton: _fireButtonEvent, + onFirePageView: _firePageViewEvent, + ), + const SizedBox(height: 16), + _EventLogCard( + log: _log, + onClear: () => setState(() => _log.clear()), + ), + ], + ), + ); + } +} + +class _TransformerStatusCard extends StatelessWidget { + final bool active; + + const _TransformerStatusCard({required this.active}); + + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + leading: Icon( + Icons.circle, + color: active ? Colors.green : Colors.grey, + size: 16, + ), + title: const Text('Active Transformers'), + subtitle: Text(active ? '1 transformer active' : 'No transformers'), + trailing: Text( + active ? '1' : '0', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: active ? Colors.green : Colors.grey, + ), + ), + ), + ); + } +} + +class _ToggleTransformerCard extends StatelessWidget { + final bool active; + final VoidCallback onToggle; + + const _ToggleTransformerCard({ + required this.active, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Route Enricher', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Automatically attaches current_route and screen_version ' + 'to every event.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.tonal( + onPressed: onToggle, + child: Text(active ? 'Remove Transformer' : 'Add Transformer'), + ), + ), + ], + ), + ), + ); + } +} + +class _FireEventsCard extends StatelessWidget { + final VoidCallback onFireButton; + final VoidCallback onFirePageView; + + const _FireEventsCard({ + required this.onFireButton, + required this.onFirePageView, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Fire Test Events', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: onFireButton, + icon: const Icon(Icons.touch_app), + label: const Text('Button Event'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: onFirePageView, + icon: const Icon(Icons.pages), + label: const Text('Page View'), + ), + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FlexClickTrack( + event: ButtonClickEvent( + buttonId: 'flex_click_demo', + buttonText: 'FlexClickTrack Demo', + screenName: 'event_enrichment_screen', + ), + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.ads_click), + label: const Text('FlexClickTrack Demo'), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _EventLogCard extends StatelessWidget { + final List log; + final VoidCallback onClear; + + const _EventLogCard({required this.log, required this.onClear}); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('Event Log', + style: Theme.of(context).textTheme.titleMedium), + ), + TextButton(onPressed: onClear, child: const Text('Clear')), + ], + ), + const SizedBox(height: 8), + Container( + height: 240, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: log.isEmpty + ? const Center( + child: Text('Fire an event to see it here.', + style: TextStyle(color: Colors.grey)), + ) + : ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: log.length, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + log[index], + style: const TextStyle( + fontSize: 11, fontFamily: 'monospace'), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/screens/home_screen.dart b/example/lib/screens/home_screen.dart index c0f25aa..4ed4f12 100644 --- a/example/lib/screens/home_screen.dart +++ b/example/lib/screens/home_screen.dart @@ -8,6 +8,7 @@ import '../events/demo_routing_and_widgets_events.dart'; import '../events/user_events.dart'; import '../utils/gdpr_manager.dart'; import 'ecommerce_screen.dart'; +import 'event_enrichment_screen.dart'; import 'setting_screen.dart'; import 'user_journey_screen.dart'; @@ -35,6 +36,7 @@ class HomeScreenState extends State with FlexTrackRouteViewMixin { const ECommerceScreen(), const UserJourneyScreen(), const SettingsScreen(), + const EventEnrichmentScreen(), ]; @override @@ -99,6 +101,10 @@ class HomeScreenState extends State with FlexTrackRouteViewMixin { icon: Icon(Icons.settings), label: 'Settings', ), + BottomNavigationBarItem( + icon: Icon(Icons.auto_awesome), + label: 'Enrichment', + ), ], ), ); @@ -114,6 +120,8 @@ class HomeScreenState extends State with FlexTrackRouteViewMixin { return 'User Journey'; case 3: return 'Settings'; + case 4: + return 'Enrichment'; default: return 'Unknown'; } diff --git a/lib/flex_track.dart b/lib/flex_track.dart index 6933351..3e89526 100644 --- a/lib/flex_track.dart +++ b/lib/flex_track.dart @@ -60,6 +60,8 @@ export 'src/core/tracker_registry.dart' show TrackerRegistry; // Base event system export 'src/models/event/base_event.dart'; +export 'src/models/event/enriched_event.dart'; +export 'src/models/event/event_transformer.dart'; // Context and consent management export 'src/models/context/tracking_context.dart'; diff --git a/lib/src/core/event_processor.dart b/lib/src/core/event_processor.dart index d7b86c0..1f79262 100644 --- a/lib/src/core/event_processor.dart +++ b/lib/src/core/event_processor.dart @@ -1,4 +1,6 @@ import 'package:flex_track/src/models/event/base_event.dart'; +import 'package:flex_track/src/models/event/event_transformer.dart'; +import 'package:flutter/foundation.dart'; import '../routing/routing_engine.dart'; import '../exceptions/tracker_exception.dart'; @@ -8,6 +10,7 @@ import 'tracker_registry.dart'; class EventProcessor { final TrackerRegistry _trackerRegistry; final RoutingEngine _routingEngine; + final List _transformers = []; bool _hasGeneralConsent = true; bool _hasPIIConsent = true; @@ -57,6 +60,39 @@ class EventProcessor { if (pii != null) _hasPIIConsent = pii; } + /// Register a transformer that will be applied to every event before routing. + void addTransformer(EventTransformer transformer) { + _transformers.add(transformer); + } + + /// Remove a previously registered transformer. + void removeTransformer(EventTransformer transformer) { + _transformers.remove(transformer); + } + + /// Remove all registered transformers. + void clearTransformers() { + _transformers.clear(); + } + + /// The currently registered transformers (unmodifiable). + List get transformers => List.unmodifiable(_transformers); + + BaseEvent _applyTransformers(BaseEvent event) { + var current = event; + for (final transformer in _transformers) { + try { + current = transformer(current); + } catch (e, stack) { + assert(() { + debugPrint('[FlexTrack] Transformer threw, skipping: $e\n$stack'); + return true; + }()); + } + } + return current; + } + /// Process a single event Future processEvent(BaseEvent event) async { if (!_isEnabled) { @@ -74,9 +110,11 @@ class EventProcessor { ); } + final processedEvent = _applyTransformers(event); + // Route the event to determine target trackers final routingResult = _routingEngine.routeEvent( - event, + processedEvent, hasGeneralConsent: _hasGeneralConsent, hasPIIConsent: _hasPIIConsent, availableTrackers: _trackerRegistry.registeredTrackerIds, @@ -85,7 +123,7 @@ class EventProcessor { // If no trackers to send to, return early if (routingResult.targetTrackers.isEmpty) { return EventProcessingResult( - event: event, + event: processedEvent, routingResult: routingResult, trackingResults: [], successful: false, @@ -106,7 +144,7 @@ class EventProcessor { error: TrackerException( 'Tracker not found: $trackerId', trackerId: trackerId, - eventName: event.getName(), + eventName: processedEvent.getName(), code: 'NOT_FOUND', ), )); @@ -120,7 +158,7 @@ class EventProcessor { error: TrackerException( 'Tracker is disabled: $trackerId', trackerId: trackerId, - eventName: event.getName(), + eventName: processedEvent.getName(), code: 'DISABLED', ), )); @@ -128,7 +166,7 @@ class EventProcessor { } try { - await tracker.track(event); + await tracker.track(processedEvent); trackingResults.add(TrackingResult( trackerId: trackerId, successful: true, @@ -143,7 +181,7 @@ class EventProcessor { : TrackerException( 'Failed to track event: $e', trackerId: trackerId, - eventName: event.getName(), + eventName: processedEvent.getName(), originalError: e, ), )); @@ -151,7 +189,7 @@ class EventProcessor { } return EventProcessingResult( - event: event, + event: processedEvent, routingResult: routingResult, trackingResults: trackingResults, successful: anySuccessful, @@ -184,6 +222,7 @@ class EventProcessor { 'isEnabled': _isEnabled, 'hasGeneralConsent': _hasGeneralConsent, 'hasPIIConsent': _hasPIIConsent, + 'transformerCount': _transformers.length, 'trackerRegistry': _trackerRegistry.getDebugInfo(), 'routingEngine': { 'configuration': _routingEngine.configuration.toMap(), diff --git a/lib/src/core/flex_track.dart b/lib/src/core/flex_track.dart index e0304fe..d0fa1e6 100644 --- a/lib/src/core/flex_track.dart +++ b/lib/src/core/flex_track.dart @@ -1,4 +1,5 @@ import 'package:flex_track/src/models/event/base_event.dart'; +import 'package:flex_track/src/models/event/event_transformer.dart'; import 'package:flutter/foundation.dart'; import '../models/routing/routing_config.dart'; @@ -234,6 +235,19 @@ class FlexTrack { await instance._client.flush(); } + // ========== TRANSFORMERS ========== + + /// Register a transformer applied to every event before routing and dispatch. + static void addTransformer(EventTransformer transformer) => + instance._client.addTransformer(transformer); + + /// Remove a previously registered transformer. + static void removeTransformer(EventTransformer transformer) => + instance._client.removeTransformer(transformer); + + /// Remove all registered transformers. + static void clearTransformers() => instance._client.clearTransformers(); + // ========== CONTROL ========== /// Enable event processing diff --git a/lib/src/core/flex_track_client.dart b/lib/src/core/flex_track_client.dart index 54afc1e..0131997 100644 --- a/lib/src/core/flex_track_client.dart +++ b/lib/src/core/flex_track_client.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flex_track/src/models/event/base_event.dart'; +import 'package:flex_track/src/models/event/event_transformer.dart'; import 'package:flutter/foundation.dart'; import '../exceptions/configuration_exception.dart'; @@ -126,15 +127,15 @@ class FlexTrackClient { Future track(BaseEvent event) async { final result = await _eventProcessor.processEvent(event); - _emitDispatchIfDebug(_recordFromResult(event, result)); + _emitDispatchIfDebug(_recordFromResult(result)); return result; } Future> trackAll(List events) async { final results = await _eventProcessor.processEvents(events); if (kDebugMode) { - for (var i = 0; i < events.length; i++) { - _emitDispatchIfDebug(_recordFromResult(events[i], results[i])); + for (final result in results) { + _emitDispatchIfDebug(_recordFromResult(result)); } } return results; @@ -144,8 +145,8 @@ class FlexTrackClient { List events) async { final results = await _eventProcessor.processEventsParallel(events); if (kDebugMode) { - for (var i = 0; i < events.length; i++) { - _emitDispatchIfDebug(_recordFromResult(events[i], results[i])); + for (final result in results) { + _emitDispatchIfDebug(_recordFromResult(result)); } } return results; @@ -207,6 +208,14 @@ class FlexTrackClient { Future flush() => _trackerRegistry.flush(); + void addTransformer(EventTransformer transformer) => + _eventProcessor.addTransformer(transformer); + + void removeTransformer(EventTransformer transformer) => + _eventProcessor.removeTransformer(transformer); + + void clearTransformers() => _eventProcessor.clearTransformers(); + void enable() { _eventProcessor.enable(); _notifyDebugStateIfDebug(); @@ -250,20 +259,14 @@ class FlexTrackClient { } } - /// Flushes trackers if this client was initialized. Call when disposing - /// the client (e.g. test tearDown). Does not reset initialization state; - /// discard the client after [dispose] if you need a fresh configuration. - static EventDispatchRecord _recordFromResult( - BaseEvent event, - EventProcessingResult result, - ) { + static EventDispatchRecord _recordFromResult(EventProcessingResult result) { final targets = List.from(result.routingResult.targetTrackers); final ok = [ for (final t in result.trackingResults) if (t.successful) t.trackerId, ]; return EventDispatchRecord( - event: event, + event: result.event, targetTrackers: targets, successfulTrackerIds: ok, ); diff --git a/lib/src/models/event/enriched_event.dart b/lib/src/models/event/enriched_event.dart new file mode 100644 index 0000000..d801185 --- /dev/null +++ b/lib/src/models/event/enriched_event.dart @@ -0,0 +1,71 @@ +import 'package:flex_track/src/models/event/base_event.dart'; +import 'package:flex_track/src/models/routing/event_category.dart'; +import 'package:flex_track/src/models/routing/tracker_group.dart'; + +/// A [BaseEvent] wrapper that merges extra properties onto an existing event +/// without requiring mutation of the original. +/// +/// All metadata getters are forwarded to [original]. [getProperties] returns +/// original properties merged with [extraProperties]; extra properties take +/// precedence on key collision. +/// +/// Transformers produce [EnrichedEvent]s — user-defined events never need +/// to be modified. +/// +/// Example: +/// ```dart +/// FlexTrack.addTransformer((event) => EnrichedEvent(event, { +/// 'current_route': '/home', +/// 'app_version': '1.2.3', +/// })); +/// ``` +class EnrichedEvent extends BaseEvent { + final BaseEvent _original; + final Map _extraProperties; + + EnrichedEvent(BaseEvent original, Map extraProperties) + : _original = original, + _extraProperties = Map.unmodifiable(extraProperties); + + /// The original unwrapped event. + BaseEvent get original => _original; + + /// The extra properties added by the transformer. + Map get extraProperties => _extraProperties; + + @override + String getName() => _original.getName(); + + @override + Map getProperties() => { + ...?_original.getProperties(), + ..._extraProperties, + }; + + @override + EventCategory? get category => _original.category; + + @override + TrackerGroup? get preferredGroup => _original.preferredGroup; + + @override + bool get containsPII => _original.containsPII; + + @override + bool get requiresConsent => _original.requiresConsent; + + @override + bool get isHighVolume => _original.isHighVolume; + + @override + bool get isEssential => _original.isEssential; + + @override + DateTime get timestamp => _original.timestamp; + + @override + String? get userId => _original.userId; + + @override + String? get sessionId => _original.sessionId; +} diff --git a/lib/src/models/event/event_transformer.dart b/lib/src/models/event/event_transformer.dart new file mode 100644 index 0000000..027501f --- /dev/null +++ b/lib/src/models/event/event_transformer.dart @@ -0,0 +1,45 @@ +import 'package:flex_track/src/models/event/base_event.dart'; +import 'package:flex_track/src/models/event/enriched_event.dart'; + +export 'enriched_event.dart'; + +/// A function that takes a [BaseEvent] and returns a (potentially enriched) +/// [BaseEvent]. The returned event replaces the original for all downstream +/// routing and tracker dispatch. +/// +/// Transformers are applied in registration order. Each transformer receives +/// the output of the previous one, enabling chaining. +/// +/// Use [EnrichedEvent] to attach extra properties without modifying the +/// original event class: +/// ```dart +/// FlexTrack.addTransformer((event) => EnrichedEvent(event, { +/// 'current_route': myRouteObserver.currentRoute, +/// })); +/// ``` +typedef EventTransformer = BaseEvent Function(BaseEvent event); + +/// Returns an [EventTransformer] that applies [transformer] only when +/// [condition] returns `true` for the incoming event. Otherwise the event +/// is passed through unchanged. +/// +/// Example — only enrich UI events with the current route: +/// ```dart +/// FlexTrack.addTransformer( +/// conditionalTransformer( +/// (event) => event.category == EventCategory.ui, +/// (event) => EnrichedEvent(event, {'current_route': myRouter.location}), +/// ), +/// ); +/// ``` +EventTransformer conditionalTransformer( + bool Function(BaseEvent) condition, + EventTransformer transformer, +) { + return (BaseEvent event) { + if (condition(event)) { + return transformer(event); + } + return event; + }; +} diff --git a/test/core/event_processor_transformer_test.dart b/test/core/event_processor_transformer_test.dart new file mode 100644 index 0000000..dded605 --- /dev/null +++ b/test/core/event_processor_transformer_test.dart @@ -0,0 +1,141 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flex_track/src/core/event_processor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils/mock_events.dart'; + +void main() { + group('EventProcessor Transformers', () { + late TrackerRegistry trackerRegistry; + late RoutingEngine routingEngine; + late EventProcessor eventProcessor; + late MockTracker mockTracker; + + setUp(() async { + mockTracker = MockTracker(id: 'test_tracker', name: 'Test Tracker'); + trackerRegistry = TrackerRegistry(); + trackerRegistry.register(mockTracker); + await trackerRegistry.initialize(); + + final routingConfig = RoutingConfiguration( + rules: [ + RoutingRule(isDefault: true, targetGroup: TrackerGroup.all), + ], + ); + routingEngine = RoutingEngine(routingConfig); + eventProcessor = EventProcessor( + trackerRegistry: trackerRegistry, + routingEngine: routingEngine, + ); + }); + + test('single transformer enriches event reaching the tracker', () async { + eventProcessor.addTransformer( + (e) => EnrichedEvent(e, {'route': '/home'}), + ); + + final event = CustomEvent.named('tap'); + await eventProcessor.processEvent(event); + + final captured = mockTracker.capturedEvents.single; + expect(captured.getProperties()!['route'], '/home'); + }); + + test('multiple transformers are applied in registration order', () async { + final order = []; + eventProcessor.addTransformer((e) { + order.add(1); + return EnrichedEvent(e, {'step1': 'a'}); + }); + eventProcessor.addTransformer((e) { + order.add(2); + return EnrichedEvent(e, {'step2': 'b'}); + }); + + await eventProcessor.processEvent(CustomEvent.named('test')); + + expect(order, [1, 2]); + final captured = mockTracker.capturedEvents.single; + expect(captured.getProperties()!['step1'], 'a'); + expect(captured.getProperties()!['step2'], 'b'); + }); + + test('throwing transformer is skipped and pipeline completes', () async { + eventProcessor.addTransformer((_) => throw Exception('bad transformer')); + eventProcessor.addTransformer( + (e) => EnrichedEvent(e, {'after_throw': 'yes'}), + ); + + final result = await eventProcessor.processEvent(CustomEvent.named('x')); + + expect(result.successful, isTrue); + final captured = mockTracker.capturedEvents.single; + expect(captured.getProperties()!['after_throw'], 'yes'); + }); + + test('EventProcessingResult.event is the enriched event', () async { + eventProcessor.addTransformer( + (e) => EnrichedEvent(e, {'enriched': true}), + ); + + final result = + await eventProcessor.processEvent(CustomEvent.named('test')); + + expect(result.event, isA()); + expect(result.event.getProperties()!['enriched'], true); + }); + + test('disabled processor returns early without applying transformers', + () async { + var transformerCalled = false; + eventProcessor.addTransformer((e) { + transformerCalled = true; + return e; + }); + + eventProcessor.disable(); + await eventProcessor.processEvent(CustomEvent.named('test')); + + expect(transformerCalled, isFalse); + }); + + test('getDebugInfo includes transformerCount', () { + expect(eventProcessor.getDebugInfo()['transformerCount'], 0); + eventProcessor.addTransformer((e) => e); + expect(eventProcessor.getDebugInfo()['transformerCount'], 1); + }); + + test('clearTransformers removes all transformers', () async { + eventProcessor.addTransformer( + (e) => EnrichedEvent(e, {'route': '/home'}), + ); + eventProcessor.clearTransformers(); + + await eventProcessor.processEvent(CustomEvent.named('test')); + + final captured = mockTracker.capturedEvents.single; + expect(captured.getProperties()?.containsKey('route'), isNot(true)); + }); + + test('removeTransformer removes only the specified transformer', () async { + EventTransformer t1 = (e) => EnrichedEvent(e, {'t1': 'yes'}); + EventTransformer t2 = (e) => EnrichedEvent(e, {'t2': 'yes'}); + + eventProcessor.addTransformer(t1); + eventProcessor.addTransformer(t2); + eventProcessor.removeTransformer(t1); + + await eventProcessor.processEvent(CustomEvent.named('test')); + + final props = mockTracker.capturedEvents.single.getProperties()!; + expect(props.containsKey('t1'), isFalse); + expect(props['t2'], 'yes'); + }); + + test('transformers getter returns unmodifiable list', () { + eventProcessor.addTransformer((e) => e); + final list = eventProcessor.transformers; + expect(() => list.add((e) => e), throwsUnsupportedError); + }); + }); +} diff --git a/test/core/flex_track_client_transformer_test.dart b/test/core/flex_track_client_transformer_test.dart new file mode 100644 index 0000000..f9298cf --- /dev/null +++ b/test/core/flex_track_client_transformer_test.dart @@ -0,0 +1,77 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils/mock_events.dart'; + +Future<(FlexTrackClient, MockTracker)> _makeClient() async { + final mock = MockTracker(id: 'tracker', name: 'Tracker'); + final client = await FlexTrackClient.create( + [mock], + routing: RoutingConfiguration( + rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], + ), + ); + return (client, mock); +} + +void main() { + group('FlexTrackClient transformers', () { + test('addTransformer enriches events dispatched via track()', () async { + final (client, mock) = await _makeClient(); + + client.addTransformer((e) => EnrichedEvent(e, {'route': '/home'})); + await client.track(CustomEvent.named('tap')); + + expect(mock.capturedEvents.single.getProperties()!['route'], '/home'); + await client.dispose(); + }); + + test('removeTransformer stops that transformer from running', () async { + final (client, mock) = await _makeClient(); + + BaseEvent t1(BaseEvent e) => EnrichedEvent(e, {'t1': 'yes'}); + BaseEvent t2(BaseEvent e) => EnrichedEvent(e, {'t2': 'yes'}); + + client.addTransformer(t1); + client.addTransformer(t2); + client.removeTransformer(t1); + + await client.track(CustomEvent.named('tap')); + + final props = mock.capturedEvents.single.getProperties()!; + expect(props.containsKey('t1'), isFalse); + expect(props['t2'], 'yes'); + await client.dispose(); + }); + + test('clearTransformers removes all transformers', () async { + final (client, mock) = await _makeClient(); + + client.addTransformer((e) => EnrichedEvent(e, {'route': '/home'})); + client.clearTransformers(); + + await client.track(CustomEvent.named('tap')); + + expect(mock.capturedEvents.single.getProperties()?.containsKey('route'), + isNot(true)); + await client.dispose(); + }); + + test('two independent clients have isolated transformer lists', () async { + final (client1, mock1) = await _makeClient(); + final (client2, mock2) = await _makeClient(); + + client1.addTransformer((e) => EnrichedEvent(e, {'client': '1'})); + + await client1.track(CustomEvent.named('e1')); + await client2.track(CustomEvent.named('e2')); + + expect(mock1.capturedEvents.single.getProperties()!['client'], '1'); + expect(mock2.capturedEvents.single.getProperties()?.containsKey('client'), + isNot(true)); + + await client1.dispose(); + await client2.dispose(); + }); + }); +} diff --git a/test/core/flex_track_transformer_test.dart b/test/core/flex_track_transformer_test.dart new file mode 100644 index 0000000..d4990bd --- /dev/null +++ b/test/core/flex_track_transformer_test.dart @@ -0,0 +1,58 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils/mock_events.dart'; + +void main() { + group('FlexTrack static transformer facade', () { + late MockTracker mock; + + setUp(() async { + mock = MockTracker(id: 'tracker', name: 'Tracker'); + await FlexTrack.setup( + [mock], + routing: RoutingConfiguration( + rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], + ), + ); + }); + + tearDown(() async { + await FlexTrack.reset(); + }); + + test('addTransformer enriches events via FlexTrack.track()', () async { + FlexTrack.addTransformer((e) => EnrichedEvent(e, {'route': '/test'})); + await FlexTrack.track(CustomEvent.named('tap')); + + expect(mock.capturedEvents.single.getProperties()!['route'], '/test'); + }); + + test('removeTransformer stops enrichment', () async { + BaseEvent transformer(BaseEvent e) => + EnrichedEvent(e, {'route': '/test'}); + + FlexTrack.addTransformer(transformer); + FlexTrack.removeTransformer(transformer); + + await FlexTrack.track(CustomEvent.named('tap')); + + expect( + mock.capturedEvents.single.getProperties()?.containsKey('route'), + isNot(true), + ); + }); + + test('clearTransformers removes all transformers', () async { + FlexTrack.addTransformer((e) => EnrichedEvent(e, {'a': '1'})); + FlexTrack.addTransformer((e) => EnrichedEvent(e, {'b': '2'})); + FlexTrack.clearTransformers(); + + await FlexTrack.track(CustomEvent.named('tap')); + + final props = mock.capturedEvents.single.getProperties(); + expect(props?.containsKey('a'), isNot(true)); + expect(props?.containsKey('b'), isNot(true)); + }); + }); +} diff --git a/test/models/event/enriched_event_test.dart b/test/models/event/enriched_event_test.dart new file mode 100644 index 0000000..18efc69 --- /dev/null +++ b/test/models/event/enriched_event_test.dart @@ -0,0 +1,129 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../test_utils/mock_events.dart'; + +void main() { + group('EnrichedEvent', () { + late CustomEvent original; + + setUp(() { + original = CustomEvent.named( + 'test_event', + properties: {'original_key': 'original_value', 'shared_key': 'original'}, + category: EventCategory.user, + containsPII: true, + isHighVolume: true, + isEssential: true, + ); + }); + + test('forwards getName from original', () { + final enriched = EnrichedEvent(original, {}); + expect(enriched.getName(), 'test_event'); + }); + + test('returns only extra properties when original has none', () { + final bare = CustomEvent.named('bare'); + final enriched = EnrichedEvent(bare, {'route': '/home'}); + expect(enriched.getProperties(), {'route': '/home'}); + }); + + test('merges original and extra properties', () { + final enriched = EnrichedEvent(original, {'extra_key': 'extra_value'}); + final props = enriched.getProperties(); + expect(props['original_key'], 'original_value'); + expect(props['extra_key'], 'extra_value'); + }); + + test('extra properties take precedence over original on key collision', () { + final enriched = EnrichedEvent(original, {'shared_key': 'enriched'}); + expect(enriched.getProperties()['shared_key'], 'enriched'); + }); + + test('forwards category from original', () { + final enriched = EnrichedEvent(original, {}); + expect(enriched.category, EventCategory.user); + }); + + test('forwards containsPII from original', () { + final enriched = EnrichedEvent(original, {}); + expect(enriched.containsPII, isTrue); + }); + + test('forwards isHighVolume from original', () { + final enriched = EnrichedEvent(original, {}); + expect(enriched.isHighVolume, isTrue); + }); + + test('forwards isEssential from original', () { + final enriched = EnrichedEvent(original, {}); + expect(enriched.isEssential, isTrue); + }); + + test('forwards requiresConsent from original', () { + final enriched = EnrichedEvent(original, {}); + expect(enriched.requiresConsent, original.requiresConsent); + }); + + test('forwards timestamp from original', () { + // Capture once — BaseEvent.timestamp calls DateTime.now() each time + final fixedTime = DateTime(2024, 1, 1); + final fixedEvent = _FixedTimestampEvent(fixedTime); + final enriched = EnrichedEvent(fixedEvent, {}); + expect(enriched.timestamp, equals(fixedTime)); + }); + + test('forwards userId from original', () { + final enriched = EnrichedEvent(original, {}); + expect(enriched.userId, original.userId); + }); + + test('forwards sessionId from original', () { + final enriched = EnrichedEvent(original, {}); + expect(enriched.sessionId, original.sessionId); + }); + + test('forwards preferredGroup from original', () { + final enriched = EnrichedEvent(original, {}); + expect(enriched.preferredGroup, original.preferredGroup); + }); + + test('original getter returns the wrapped event', () { + final enriched = EnrichedEvent(original, {'k': 'v'}); + expect(enriched.original, same(original)); + }); + + test('extraProperties is unmodifiable', () { + final enriched = EnrichedEvent(original, {'k': 'v'}); + expect( + () => enriched.extraProperties['new'] = 'val', + throwsUnsupportedError, + ); + }); + + test('stacking EnrichedEvents layers properties correctly', () { + final first = EnrichedEvent(original, {'layer1': 'a'}); + final second = EnrichedEvent(first, {'layer2': 'b', 'shared_key': 'top'}); + final props = second.getProperties(); + expect(props['original_key'], 'original_value'); + expect(props['layer1'], 'a'); + expect(props['layer2'], 'b'); + expect(props['shared_key'], 'top'); + }); + }); +} + +class _FixedTimestampEvent extends BaseEvent { + final DateTime _timestamp; + _FixedTimestampEvent(this._timestamp); + + @override + String getName() => 'fixed'; + + @override + Map? getProperties() => null; + + @override + DateTime get timestamp => _timestamp; +} diff --git a/test/models/event/event_transformer_test.dart b/test/models/event/event_transformer_test.dart new file mode 100644 index 0000000..152403e --- /dev/null +++ b/test/models/event/event_transformer_test.dart @@ -0,0 +1,63 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../test_utils/mock_events.dart'; + +void main() { + group('conditionalTransformer', () { + test('applies transformer when condition is true', () { + final event = CustomEvent.named('test'); + final transformer = conditionalTransformer( + (_) => true, + (e) => EnrichedEvent(e, {'added': 'yes'}), + ); + + final result = transformer(event); + expect(result, isA()); + expect(result.getProperties()!['added'], 'yes'); + }); + + test('passes event through unchanged when condition is false', () { + final event = CustomEvent.named('test'); + final transformer = conditionalTransformer( + (_) => false, + (e) => EnrichedEvent(e, {'added': 'yes'}), + ); + + final result = transformer(event); + expect(result, same(event)); + }); + + test('condition receives the current event (not the original)', () { + final event = CustomEvent.named('test'); + BaseEvent? receivedEvent; + + final transformer = conditionalTransformer( + (e) { + receivedEvent = e; + return true; + }, + (e) => EnrichedEvent(e, {}), + ); + + transformer(event); + expect(receivedEvent, same(event)); + }); + + test('condition can inspect event category', () { + final uiEvent = CustomEvent.named('click', category: EventCategory.user); + final sysEvent = CustomEvent.named('log', category: EventCategory.system); + + final transformer = conditionalTransformer( + (e) => e.category == EventCategory.user, + (e) => EnrichedEvent(e, {'enriched': true}), + ); + + final uiResult = transformer(uiEvent); + final sysResult = transformer(sysEvent); + + expect(uiResult, isA()); + expect(sysResult, same(sysEvent)); + }); + }); +} diff --git a/test/widgets/transformer_widget_test.dart b/test/widgets/transformer_widget_test.dart new file mode 100644 index 0000000..3af52fb --- /dev/null +++ b/test/widgets/transformer_widget_test.dart @@ -0,0 +1,125 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils/mock_events.dart'; + +void main() { + group('Transformer + widget integration', () { + testWidgets( + 'transformer registered on FlexTrackScope client enriches FlexClickTrack events', + (tester) async { + final mock = MockTracker(id: 'tracker', name: 'Tracker'); + final client = await FlexTrackClient.create( + [mock], + routing: RoutingConfiguration( + rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], + ), + ); + + client.addTransformer( + (e) => EnrichedEvent(e, {'current_route': '/test_route'}), + ); + + await tester.pumpWidget( + MaterialApp( + home: FlexTrackScope( + client: client, + child: Scaffold( + body: FlexClickTrack( + event: CustomEvent.named('tap_test'), + child: const Text('Tap me'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Tap me')); + await tester.pump(); + + expect(mock.capturedEvents, hasLength(1)); + final captured = mock.capturedEvents.single; + expect(captured.getName(), 'tap_test'); + expect(captured.getProperties()!['current_route'], '/test_route'); + + await client.dispose(); + }); + + testWidgets( + 'transformer is not applied after clearTransformers on scoped client', + (tester) async { + final mock = MockTracker(id: 'tracker', name: 'Tracker'); + final client = await FlexTrackClient.create( + [mock], + routing: RoutingConfiguration( + rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], + ), + ); + + client.addTransformer( + (e) => EnrichedEvent(e, {'current_route': '/test_route'}), + ); + client.clearTransformers(); + + await tester.pumpWidget( + MaterialApp( + home: FlexTrackScope( + client: client, + child: Scaffold( + body: FlexClickTrack( + event: CustomEvent.named('tap_test'), + child: const Text('Tap me'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Tap me')); + await tester.pump(); + + expect(mock.capturedEvents, hasLength(1)); + final props = mock.capturedEvents.single.getProperties(); + expect(props?.containsKey('current_route'), isNot(true)); + + await client.dispose(); + }); + + testWidgets('enriched event name is preserved through dispatch', + (tester) async { + final mock = MockTracker(id: 'tracker', name: 'Tracker'); + final client = await FlexTrackClient.create( + [mock], + routing: RoutingConfiguration( + rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], + ), + ); + + client.addTransformer( + (e) => EnrichedEvent(e, {'extra': 'data'}), + ); + + await tester.pumpWidget( + MaterialApp( + home: FlexTrackScope( + client: client, + child: Scaffold( + body: FlexClickTrack( + event: CustomEvent.named('my_event'), + child: const Text('Click'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Click')); + await tester.pump(); + + expect(mock.capturedEvents.single.getName(), 'my_event'); + + await client.dispose(); + }); + }); +}