From 3ae84f7553aa3648df5ce0f3de353c3ff5fd4bf0 Mon Sep 17 00:00:00 2001 From: Reza Taghizadeh Date: Tue, 11 Aug 2026 14:43:24 +0200 Subject: [PATCH 1/8] chore: update flex_track dependency version to 2.0.0 in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 846aa2a..f58c82b 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ One call site, multiple tracker destinations, centralized policy. ```yaml # pubspec.yaml dependencies: - flex_track: ^1.0.0 + flex_track: ^2.0.0 ``` **Step 2 — implement your tracker** (the package ships no vendor SDKs; you write a thin adapter): From 0cf683e84c430221883dbe956b8f20e670e6ae15 Mon Sep 17 00:00:00 2001 From: Reza Taghizadeh Date: Mon, 17 Aug 2026 18:34:08 +0200 Subject: [PATCH 2/8] fix(routing): preserve event identity through enrichment (#27) Make route() subtype-aware and unwrap nested EnrichedEvent values only for type matching while retaining transformed properties for other rules. Align routing debug explanations and add regression coverage.\n\nCloses #17 --- CHANGELOG.md | 8 ++ README.md | 5 ++ lib/src/models/routing/routing_rule.dart | 26 +++++- lib/src/routing/route_config_builder.dart | 1 + lib/src/routing/routing_engine.dart | 2 +- lib/src/routing/routing_rule_builder.dart | 2 + test/routing/routing_identity_test.dart | 98 +++++++++++++++++++++++ 7 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 test/routing/routing_identity_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 20eb667..aa72322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# Unreleased + +## Fixed + +- Type-based routing now matches event subclasses and preserves the original + routing identity through `EnrichedEvent` transformers. Other routing + conditions still evaluate the transformed event. + ## 2.0.0 ### Breaking changes diff --git a/README.md b/README.md index f58c82b..6b49dc4 100644 --- a/README.md +++ b/README.md @@ -474,6 +474,11 @@ FlexTrack.addTransformer((event) => EnrichedEvent(event, { `EnrichedEvent` is a `BaseEvent` wrapper. It forwards all metadata from the original event (`category`, `containsPII`, `requiresConsent`, etc.) and overrides `properties` to merge the original properties with the extra ones. Extra properties win on key collision. +Type-based routes remain anchored to the original event through any number of +`EnrichedEvent` wrappers. A `route()` rule therefore continues +to match enriched purchases and subclasses of `PurchaseEvent`, while +property-based routes can still match properties added by transformers. + ```dart // Extra properties override originals on the same key. EnrichedEvent(originalEvent, {'source': 'transformer'}) diff --git a/lib/src/models/routing/routing_rule.dart b/lib/src/models/routing/routing_rule.dart index 8e4bfa1..6e4bd3f 100644 --- a/lib/src/models/routing/routing_rule.dart +++ b/lib/src/models/routing/routing_rule.dart @@ -1,12 +1,16 @@ import 'package:flex_track/src/models/event/base_event.dart'; +import 'package:flex_track/src/models/event/enriched_event.dart'; import 'event_category.dart'; import 'tracker_group.dart'; +typedef EventTypeMatcher = bool Function(BaseEvent event); + /// Represents a routing rule that determines where events should be sent class RoutingRule { final String? id; final Type? eventType; + final EventTypeMatcher? eventTypeMatcher; final String? eventNamePattern; final RegExp? eventNameRegex; final EventCategory? category; @@ -28,6 +32,7 @@ class RoutingRule { const RoutingRule({ this.id, this.eventType, + this.eventTypeMatcher, this.eventNamePattern, this.eventNameRegex, this.category, @@ -55,7 +60,7 @@ class RoutingRule { if (productionOnly && isDebugMode) return false; // Check event type - if (eventType != null && event.runtimeType != eventType) { + if (!matchesEventType(event)) { return false; } @@ -105,6 +110,23 @@ class RoutingRule { return true; } + /// Whether [event] satisfies this rule's type condition. + /// + /// Routing builders provide a subtype-aware matcher for `route()`. The + /// event is unwrapped only for this type check so transformed properties and + /// metadata continue to participate in the remaining rule conditions. + bool matchesEventType(BaseEvent event) { + if (eventType == null) return true; + + BaseEvent routingEvent = event; + while (routingEvent is EnrichedEvent) { + routingEvent = routingEvent.original; + } + + return eventTypeMatcher?.call(routingEvent) ?? + routingEvent.runtimeType == eventType; + } + /// Returns true if this rule should be applied based on consent bool shouldApply( BaseEvent event, { @@ -139,6 +161,7 @@ class RoutingRule { RoutingRule copyWith({ String? id, Type? eventType, + EventTypeMatcher? eventTypeMatcher, String? eventNamePattern, RegExp? eventNameRegex, EventCategory? category, @@ -160,6 +183,7 @@ class RoutingRule { return RoutingRule( id: id ?? this.id, eventType: eventType ?? this.eventType, + eventTypeMatcher: eventTypeMatcher ?? this.eventTypeMatcher, eventNamePattern: eventNamePattern ?? this.eventNamePattern, eventNameRegex: eventNameRegex ?? this.eventNameRegex, category: category ?? this.category, diff --git a/lib/src/routing/route_config_builder.dart b/lib/src/routing/route_config_builder.dart index 445a87c..9fd7f55 100644 --- a/lib/src/routing/route_config_builder.dart +++ b/lib/src/routing/route_config_builder.dart @@ -98,5 +98,6 @@ class RouteConfigBuilder { // ========== GETTERS FOR RULE BUILDER ========== Type? get eventType => _eventType; + bool matchesEventType(BaseEvent event) => event is T; RoutingBuilder get parent => _parent; } diff --git a/lib/src/routing/routing_engine.dart b/lib/src/routing/routing_engine.dart index 2f4d3c3..b00ff16 100644 --- a/lib/src/routing/routing_engine.dart +++ b/lib/src/routing/routing_engine.dart @@ -181,7 +181,7 @@ class RoutingEngine { String _getRuleNonMatchReason(RoutingRule rule, BaseEvent event) { final reasons = []; - if (rule.eventType != null && event.runtimeType != rule.eventType) { + if (!rule.matchesEventType(event)) { reasons.add( 'Event type mismatch: expected ${rule.eventType}, got ${event.runtimeType}'); } diff --git a/lib/src/routing/routing_rule_builder.dart b/lib/src/routing/routing_rule_builder.dart index 697fcb2..89e24a5 100644 --- a/lib/src/routing/routing_rule_builder.dart +++ b/lib/src/routing/routing_rule_builder.dart @@ -163,6 +163,8 @@ class RoutingRuleBuilder { final rule = RoutingRule( id: _id, eventType: _config.eventType, + eventTypeMatcher: + _config.eventType == null ? null : _config.matchesEventType, eventNamePattern: _config.eventNamePattern, eventNameRegex: _config.eventNameRegex, category: _config.category, diff --git a/test/routing/routing_identity_test.dart b/test/routing/routing_identity_test.dart new file mode 100644 index 0000000..fdd0e93 --- /dev/null +++ b/test/routing/routing_identity_test.dart @@ -0,0 +1,98 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class PurchaseEvent extends BaseEvent { + @override + String get name => 'purchase'; + + @override + Map? get properties => null; +} + +class SubscriptionPurchaseEvent extends PurchaseEvent {} + +void main() { + group('routing identity', () { + late RoutingEngine engine; + + setUp(() { + final configuration = (RoutingBuilder() + ..route().to(['billing']).withPriority(10).and() + ..routeDefault().to(['other']).and()) + .build(); + + engine = RoutingEngine(configuration); + }); + + test('a type route matches subclasses', () { + final result = engine.routeEvent( + SubscriptionPurchaseEvent(), + availableTrackers: {'billing', 'other'}, + ); + + expect(result.targetTrackers, ['billing']); + }); + + test('an enriched event preserves its original type route', () { + final result = engine.routeEvent( + EnrichedEvent(PurchaseEvent(), {'app_version': '2.1.0'}), + availableTrackers: {'billing', 'other'}, + ); + + expect(result.targetTrackers, ['billing']); + }); + + test('nested enrichment preserves the deepest original type route', () { + final result = engine.routeEvent( + EnrichedEvent( + EnrichedEvent(SubscriptionPurchaseEvent(), {'session': 'one'}), + {'app_version': '2.1.0'}, + ), + availableTrackers: {'billing', 'other'}, + ); + + expect(result.targetTrackers, ['billing']); + }); + + test('transformed properties remain visible to property routes', () { + final propertyConfiguration = (RoutingBuilder() + ..routeWithProperty('app_version') + .to(['versioned']) + .withPriority(10) + .and() + ..routeDefault().to(['other']).and()) + .build(); + final propertyEngine = RoutingEngine(propertyConfiguration); + + final result = propertyEngine.routeEvent( + EnrichedEvent(PurchaseEvent(), {'app_version': '2.1.0'}), + availableTrackers: {'versioned', 'other'}, + ); + + expect(result.targetTrackers, ['versioned']); + }); + + test('debug output uses the same routing identity semantics', () { + final event = EnrichedEvent(SubscriptionPurchaseEvent(), const {}); + + final debug = engine.debugEvent( + event, + availableTrackers: {'billing', 'other'}, + ); + + expect(debug.routingResult.targetTrackers, ['billing']); + expect( + debug.matchingRules.where( + (rule) => rule.eventType == PurchaseEvent, + ), + hasLength(1), + ); + expect( + debug.nonMatchingRules.where( + (entry) => entry.rule.eventType == PurchaseEvent, + ), + isEmpty, + ); + }); + }); +} From aa3dc9135e2239ee760925973e4a4392745d8ace Mon Sep 17 00:00:00 2001 From: Reza Taghizadeh Date: Mon, 17 Aug 2026 18:50:41 +0200 Subject: [PATCH 3/8] fix(sampling): make routing decisions deterministic (#28) Replace clock-modulo routing decisions with injectable FNV-1a sampling keyed by user, session, or event name. Add UTF-8 conformance vectors, essential-event bypass, regression coverage, and public documentation. Closes #18 --- CHANGELOG.md | 4 + README.md | 12 +- lib/src/models/routing/routing_config.dart | 8 +- lib/src/models/routing/routing_rule.dart | 12 +- lib/src/routing/routing_engine.dart | 3 +- lib/src/utils/sampling_utils.dart | 57 ++++++++- test/fixtures/sampling_vectors.json | 26 ++++ test/routing/sampling_correctness_test.dart | 135 ++++++++++++++++++++ 8 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 test/fixtures/sampling_vectors.json create mode 100644 test/routing/sampling_correctness_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index aa72322..0ce72cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ - Type-based routing now matches event subclasses and preserves the original routing identity through `EnrichedEvent` transformers. Other routing conditions still evaluate the transformed event. +- Replaced clock-modulo routing sampling with deterministic FNV-1a sampling + keyed by user id, session id, or event name. Essential events bypass + sampling, and published UTF-8 vectors keep future SDK implementations in + parity. ## 2.0.0 diff --git a/README.md b/README.md index 6b49dc4..548f0d6 100644 --- a/README.md +++ b/README.md @@ -928,7 +928,17 @@ GDPRDefaults.applyStrict(routing, compliantTrackers: ['internal']); ## Sampling and performance -Sampling is applied per-rule. Each matching event independently has a random chance of being forwarded at the specified rate. +Sampling is applied per rule and is deterministic by default. FlexTrack hashes +the first non-empty value from `event.userId`, `event.sessionId`, and +`event.name` with FNV-1a over UTF-8 bytes. The resulting stable bucket is +compared with the rule's rate, so the same identity receives the same decision +across launches and SDK implementations. Essential events always bypass +sampling. + +When no user or session identity is available, all events with the same name +share a decision. Supply a stable user or session id when you need a +representative user-level sample. The cross-platform vectors are published in +`test/fixtures/sampling_vectors.json`. | Method | Rate | |--------|------| diff --git a/lib/src/models/routing/routing_config.dart b/lib/src/models/routing/routing_config.dart index 154b37a..d433d8b 100644 --- a/lib/src/models/routing/routing_config.dart +++ b/lib/src/models/routing/routing_config.dart @@ -4,6 +4,7 @@ import 'package:flex_track/src/models/event/base_event.dart'; import 'routing_rule.dart'; import 'tracker_group.dart'; import 'event_category.dart'; +import '../../utils/sampling_utils.dart'; /// Complete routing configuration that contains all rules and settings class RoutingConfiguration { @@ -14,6 +15,7 @@ class RoutingConfiguration { final bool enableSampling; final bool enableConsentChecking; final bool isDebugMode; + final EventSampler sampler; const RoutingConfiguration({ required this.rules, @@ -23,6 +25,7 @@ class RoutingConfiguration { this.enableSampling = true, this.enableConsentChecking = true, this.isDebugMode = false, + this.sampler = const DeterministicEventSampler(), }); /// Creates an empty routing configuration @@ -126,7 +129,7 @@ class RoutingConfiguration { } // Check sampling - if (enableSampling && !rule.shouldSample()) { + if (enableSampling && !rule.shouldSample(event, sampler: sampler)) { continue; } @@ -173,6 +176,7 @@ class RoutingConfiguration { bool? enableSampling, bool? enableConsentChecking, bool? isDebugMode, + EventSampler? sampler, }) { return RoutingConfiguration( rules: rules ?? this.rules, @@ -183,6 +187,7 @@ class RoutingConfiguration { enableConsentChecking: enableConsentChecking ?? this.enableConsentChecking, isDebugMode: isDebugMode ?? this.isDebugMode, + sampler: sampler ?? this.sampler, ); } @@ -251,6 +256,7 @@ class RoutingConfiguration { 'enableSampling': enableSampling, 'enableConsentChecking': enableConsentChecking, 'isDebugMode': isDebugMode, + 'sampler': sampler.runtimeType.toString(), 'rulesCount': rules.length, 'customGroupsCount': customGroups.length, 'customCategoriesCount': customCategories.length, diff --git a/lib/src/models/routing/routing_rule.dart b/lib/src/models/routing/routing_rule.dart index 6e4bd3f..9ebd332 100644 --- a/lib/src/models/routing/routing_rule.dart +++ b/lib/src/models/routing/routing_rule.dart @@ -1,5 +1,6 @@ import 'package:flex_track/src/models/event/base_event.dart'; import 'package:flex_track/src/models/event/enriched_event.dart'; +import 'package:flex_track/src/utils/sampling_utils.dart'; import 'event_category.dart'; import 'tracker_group.dart'; @@ -149,12 +150,11 @@ class RoutingRule { } /// Returns true if this rule should be sampled for the given event - bool shouldSample() { - if (sampleRate >= 1.0) return true; - if (sampleRate <= 0.0) return false; - - // Use a simple random sampling - return (DateTime.now().millisecondsSinceEpoch % 1000) / 1000.0 < sampleRate; + bool shouldSample( + BaseEvent event, { + EventSampler sampler = const DeterministicEventSampler(), + }) { + return sampler.shouldSample(event, sampleRate); } /// Creates a copy of this rule with updated properties diff --git a/lib/src/routing/routing_engine.dart b/lib/src/routing/routing_engine.dart index b00ff16..a71c706 100644 --- a/lib/src/routing/routing_engine.dart +++ b/lib/src/routing/routing_engine.dart @@ -64,7 +64,8 @@ class RoutingEngine { } // Check sampling - if (_configuration.enableSampling && !rule.shouldSample()) { + if (_configuration.enableSampling && + !rule.shouldSample(event, sampler: _configuration.sampler)) { skippedRules.add(SkippedRule( rule: rule, reason: diff --git a/lib/src/utils/sampling_utils.dart b/lib/src/utils/sampling_utils.dart index c14695c..3c54f43 100644 --- a/lib/src/utils/sampling_utils.dart +++ b/lib/src/utils/sampling_utils.dart @@ -1,5 +1,29 @@ +import 'dart:convert'; import 'dart:math' as math; +import '../models/event/base_event.dart'; + +/// Decides whether an event is retained for a routing rule's sample rate. +abstract interface class EventSampler { + bool shouldSample(BaseEvent event, double sampleRate); +} + +/// Cross-platform deterministic sampler using FNV-1a over UTF-8 bytes. +class DeterministicEventSampler implements EventSampler { + const DeterministicEventSampler(); + + @override + bool shouldSample(BaseEvent event, double sampleRate) { + if (event.isEssential) return true; + if (sampleRate >= 1.0) return true; + if (sampleRate <= 0.0) return false; + return SamplingUtils.shouldSampleDeterministic( + SamplingUtils.samplingKey(event), + sampleRate, + ); + } +} + /// Utility class for event sampling operations class SamplingUtils { static final math.Random _random = math.Random(); @@ -19,13 +43,38 @@ class SamplingUtils { if (sampleRate >= 1.0) return true; if (sampleRate <= 0.0) return false; - // Use hash of input for deterministic sampling - final hash = input.hashCode.abs(); - final normalizedHash = (hash % 10000) / 10000.0; + final normalizedHash = stableHash(input) / 0x100000000; return normalizedHash < sampleRate; } + /// Stable FNV-1a 32-bit hash over the UTF-8 bytes of [input]. + /// + /// Unlike Dart's [String.hashCode], this algorithm is explicitly defined + /// and can produce identical sampling decisions on every SDK platform. + static int stableHash(String input) { + var hash = 0x811c9dc5; + for (final byte in utf8.encode(input)) { + hash ^= byte; + hash = (hash * 0x01000193) & 0xffffffff; + } + return hash; + } + + /// Stable identity used by the default routing sampler. + /// + /// Empty identity values are ignored. Event name is the deterministic + /// fallback until an application supplies a user or session identity. + static String samplingKey(BaseEvent event) { + final userId = event.userId; + if (userId != null && userId.isNotEmpty) return userId; + + final sessionId = event.sessionId; + if (sessionId != null && sessionId.isNotEmpty) return sessionId; + + return event.name; + } + /// Check if an event should be sampled based on user ID /// Ensures consistent sampling per user static bool shouldSampleByUserId(String? userId, double sampleRate) { @@ -112,7 +161,7 @@ class SamplingUtils { static int getSamplingBucket(String identifier, int bucketCount) { if (bucketCount <= 0) return 0; - final hash = identifier.hashCode.abs(); + final hash = stableHash(identifier); return hash % bucketCount; } diff --git a/test/fixtures/sampling_vectors.json b/test/fixtures/sampling_vectors.json new file mode 100644 index 0000000..78c4fe5 --- /dev/null +++ b/test/fixtures/sampling_vectors.json @@ -0,0 +1,26 @@ +{ + "algorithm": "fnv1a-32-utf8", + "bucketDivisor": 4294967296, + "vectors": [ + {"input": "", "hash": 2166136261, "at25": false, "at50": false}, + {"input": "a", "hash": 3826002220, "at25": false, "at50": false}, + {"input": "hello", "hash": 1335831723, "at25": false, "at50": true}, + {"input": "purchase", "hash": 2513801058, "at25": false, "at50": false}, + {"input": "user-123", "hash": 2358496403, "at25": false, "at50": false}, + {"input": "session-α", "hash": 2520526275, "at25": false, "at50": false}, + {"input": "你好", "hash": 2257816995, "at25": false, "at50": false}, + {"input": "🙂", "hash": 1470331467, "at25": false, "at50": true}, + {"input": "résumé", "hash": 3068721788, "at25": false, "at50": false}, + {"input": "مرحبا", "hash": 2831450846, "at25": false, "at50": false}, + {"input": "नमस्ते", "hash": 538106393, "at25": true, "at50": true}, + {"input": "Straße", "hash": 499330616, "at25": true, "at50": true}, + {"input": "null\u0000byte", "hash": 1921921222, "at25": false, "at50": true}, + {"input": "line\nbreak", "hash": 527786666, "at25": true, "at50": true}, + {"input": "emoji-🚀", "hash": 2040893807, "at25": false, "at50": true}, + {"input": "UPPER_lower-123", "hash": 1815750080, "at25": false, "at50": true}, + {"input": "café", "hash": 2821410889, "at25": false, "at50": false}, + {"input": "mañana", "hash": 798077619, "at25": true, "at50": true}, + {"input": "東京", "hash": 1759422319, "at25": false, "at50": true}, + {"input": "한국어", "hash": 18907935, "at25": true, "at50": true} + ] +} diff --git a/test/routing/sampling_correctness_test.dart b/test/routing/sampling_correctness_test.dart new file mode 100644 index 0000000..a8e950c --- /dev/null +++ b/test/routing/sampling_correctness_test.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class SamplingEvent extends BaseEvent { + SamplingEvent({this.eventUserId, this.eventSessionId}); + + final String? eventUserId; + final String? eventSessionId; + + @override + String get name => 'purchase'; + + @override + Map? get properties => null; + + @override + String? get userId => eventUserId; + + @override + String? get sessionId => eventSessionId; +} + +class RecordingSampler implements EventSampler { + BaseEvent? event; + double? rate; + + @override + bool shouldSample(BaseEvent event, double sampleRate) { + this.event = event; + rate = sampleRate; + return false; + } +} + +void main() { + group('cross-platform deterministic sampling', () { + final fixture = jsonDecode( + File('test/fixtures/sampling_vectors.json').readAsStringSync(), + ) as Map; + final vectors = fixture['vectors'] as List; + + test('uses the documented FNV-1a UTF-8 vectors', () { + for (final value in vectors.cast>()) { + final input = value['input'] as String; + + expect( + SamplingUtils.stableHash(input), + value['hash'], + reason: 'hash mismatch for ${jsonEncode(input)}', + ); + expect( + SamplingUtils.shouldSampleDeterministic(input, 0.25), + value['at25'], + reason: '25% decision mismatch for ${jsonEncode(input)}', + ); + expect( + SamplingUtils.shouldSampleDeterministic(input, 0.50), + value['at50'], + reason: '50% decision mismatch for ${jsonEncode(input)}', + ); + } + }); + + test('uses user, session, then event name as the stable key', () { + expect(SamplingUtils.samplingKey(SamplingEvent(eventUserId: 'user-1')), + 'user-1'); + expect( + SamplingUtils.samplingKey( + SamplingEvent(eventUserId: '', eventSessionId: 'session-1'), + ), + 'session-1', + ); + expect(SamplingUtils.samplingKey(SamplingEvent()), 'purchase'); + }); + + test('makes repeated routing decisions independent of wall-clock time', () { + final rule = RoutingRule( + targetGroup: TrackerGroup.all, + sampleRate: 0.5, + ); + final event = SamplingEvent(eventUserId: 'stable-user'); + + final decisions = List.generate(1000, (_) => rule.shouldSample(event)); + + expect(decisions.toSet(), hasLength(1)); + }); + + test('always keeps essential events and boundary rate one', () { + final essential = _EssentialSamplingEvent(); + + expect( + const RoutingRule( + targetGroup: TrackerGroup.all, + sampleRate: 0, + ).shouldSample(essential), + isTrue, + ); + expect( + const RoutingRule( + targetGroup: TrackerGroup.all, + sampleRate: 1, + ).shouldSample(SamplingEvent()), + isTrue, + ); + }); + + test('allows the sampler to be injected through routing configuration', () { + final sampler = RecordingSampler(); + final event = SamplingEvent(eventUserId: 'user-1'); + final configuration = RoutingConfiguration( + rules: const [ + RoutingRule(targetGroup: TrackerGroup.all, sampleRate: 0.5), + ], + sampler: sampler, + ); + + final result = RoutingEngine(configuration).routeEvent( + event, + availableTrackers: {'console'}, + ); + + expect(result.targetTrackers, isEmpty); + expect(sampler.event, same(event)); + expect(sampler.rate, 0.5); + }); + }); +} + +class _EssentialSamplingEvent extends SamplingEvent { + @override + bool get isEssential => true; +} From e2d5338cce204faa8c1610d86a8f5dcc5a86de29 Mon Sep 17 00:00:00 2001 From: Reza Taghizadeh Date: Mon, 17 Aug 2026 20:01:46 +0200 Subject: [PATCH 4/8] fix: stabilize event metadata and consent defaults (#29) - add immutable UUID event IDs and UTC timestamps - preserve event metadata during enrichment - default new clients to denied consent - align package version metadata and documentation --- CHANGELOG.md | 6 +++ README.md | 6 +++ example/lib/events/app_events.dart | 6 +-- lib/flex_track.dart | 2 +- lib/src/core/event_processor.dart | 4 +- lib/src/models/event/base_event.dart | 45 +++++++++++++++-- lib/src/models/event/enriched_event.dart | 6 +-- lib/src/models/routing/routing_config.dart | 6 ++- lib/src/routing/routing_engine.dart | 7 +-- test/core/event_processor_test.dart | 1 + .../event_processor_transformer_test.dart | 1 + test/core/flex_track_client_test.dart | 48 +++++++++++++++++++ .../flex_track_client_transformer_test.dart | 1 + test/core/flex_track_facade_test.dart | 6 +++ test/core/flex_track_test.dart | 17 ++++++- test/models/event/base_event_test.dart | 48 +++++++++++++++++++ test/models/event/enriched_event_test.dart | 12 ++++- test/routing/presets/smart_defaults_test.dart | 8 ++++ test/test_utils/mock_events.dart | 8 +++- test/version_test.dart | 15 ++++++ test/widgets/flex_click_track_test.dart | 2 + test/widgets/flex_impression_track_test.dart | 2 + test/widgets/flex_mount_track_test.dart | 2 + test/widgets/flex_route_track_test.dart | 2 + test/widgets/transformer_widget_test.dart | 3 ++ 25 files changed, 239 insertions(+), 25 deletions(-) create mode 100644 test/version_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce72cd..bb37cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ keyed by user id, session id, or event name. Essential events bypass sampling, and published UTF-8 vectors keep future SDK implementations in parity. +- Event instances now capture an immutable UUID v4 identifier and UTC + occurrence timestamp. Enrichment preserves both values. +- New clients now start with general and PII consent denied, matching the + documented privacy-safe default. Disabling consent checking on a routing + configuration now bypasses those checks as configured. +- `flexTrackVersion` now matches the package version declared in `pubspec.yaml`. ## 2.0.0 diff --git a/README.md b/README.md index 548f0d6..482241f 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,12 @@ This package does not bundle Firebase, Mixpanel, Amplitude, or any other analyti Extend `BaseEvent` and implement the `name` and `properties` getters. Everything else is optional. +Every event receives an immutable UUID v4 `eventId` and UTC `timestamp` when +it is constructed. Those values remain unchanged through enrichment and +dispatch. For replay or restored offline events, pass the original metadata to +`super(eventId: storedId, timestamp: storedTimestamp)` from your event +constructor. + ```dart class PurchaseEvent extends BaseEvent { final double amount; diff --git a/example/lib/events/app_events.dart b/example/lib/events/app_events.dart index ebaf130..3a1615d 100644 --- a/example/lib/events/app_events.dart +++ b/example/lib/events/app_events.dart @@ -39,14 +39,12 @@ class AppStartEvent extends BaseEvent { class PageViewEvent extends BaseEvent { final String pageName; final Map? parameters; - @override - final DateTime timestamp; PageViewEvent({ required this.pageName, this.parameters, - DateTime? timestamp, - }) : timestamp = timestamp ?? DateTime.now(); + super.timestamp, + }); @override String get name => 'page_view'; diff --git a/lib/flex_track.dart b/lib/flex_track.dart index f1ba26d..d0c116c 100644 --- a/lib/flex_track.dart +++ b/lib/flex_track.dart @@ -143,7 +143,7 @@ export 'src/core/flex_track.dart' show FlexTrack; // ============= VERSION INFO ============= /// FlexTrack package version -const String flexTrackVersion = '1.0.0'; +const String flexTrackVersion = '2.0.0'; /// FlexTrack package description const String flexTrackDescription = diff --git a/lib/src/core/event_processor.dart b/lib/src/core/event_processor.dart index 4c58fa9..58191fb 100644 --- a/lib/src/core/event_processor.dart +++ b/lib/src/core/event_processor.dart @@ -12,8 +12,8 @@ class EventProcessor { final RoutingEngine _routingEngine; final List _transformers = []; - bool _hasGeneralConsent = true; - bool _hasPIIConsent = true; + bool _hasGeneralConsent = false; + bool _hasPIIConsent = false; bool _isEnabled = true; EventProcessor({ diff --git a/lib/src/models/event/base_event.dart b/lib/src/models/event/base_event.dart index f1cc6f5..977608a 100644 --- a/lib/src/models/event/base_event.dart +++ b/lib/src/models/event/base_event.dart @@ -1,7 +1,24 @@ +import 'dart:math'; + import 'package:flex_track/src/models/routing/event_category.dart'; import 'package:flex_track/src/models/routing/tracker_group.dart'; abstract class BaseEvent { + BaseEvent({String? eventId, DateTime? timestamp}) + : eventId = _resolveEventId(eventId), + timestamp = timestamp ?? DateTime.now().toUtc(); + + /// Stable identifier for this event occurrence. + /// + /// Supply an existing id when reconstructing an event for retry or replay. + /// Otherwise FlexTrack generates an RFC 4122 version 4 UUID. + final String eventId; + + /// Immutable time at which this event occurrence was created. + /// + /// Supply the original value when reconstructing historical events. + final DateTime timestamp; + /// Returns the name of the event. String get name; @@ -32,10 +49,6 @@ abstract class BaseEvent { /// Essential events may bypass consent requirements and sampling bool get isEssential => false; - /// Timestamp when the event was created - /// Defaults to current time, but can be overridden for historical events - DateTime get timestamp => DateTime.now(); - /// Optional user ID associated with this event /// Used for user-specific routing and privacy compliance String? get userId => null; @@ -47,6 +60,7 @@ abstract class BaseEvent { /// Useful for debugging and serialization Map toMap() { return { + 'eventId': eventId, 'name': name, 'properties': properties, 'category': category?.name, @@ -66,3 +80,26 @@ abstract class BaseEvent { return 'Event($name${category != null ? ', category: ${category!.name}' : ''})'; } } + +final Random _eventIdRandom = Random.secure(); + +String _resolveEventId(String? eventId) { + if (eventId != null) { + if (eventId.isEmpty) { + throw ArgumentError.value(eventId, 'eventId', 'Cannot be empty'); + } + return eventId; + } + + final bytes = List.generate(16, (_) => _eventIdRandom.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')); + final value = hex.join(); + + return '${value.substring(0, 8)}-' + '${value.substring(8, 12)}-' + '${value.substring(12, 16)}-' + '${value.substring(16, 20)}-' + '${value.substring(20)}'; +} diff --git a/lib/src/models/event/enriched_event.dart b/lib/src/models/event/enriched_event.dart index baa5519..935f45e 100644 --- a/lib/src/models/event/enriched_event.dart +++ b/lib/src/models/event/enriched_event.dart @@ -25,7 +25,8 @@ class EnrichedEvent extends BaseEvent { EnrichedEvent(BaseEvent original, Map extraProperties) : _original = original, - _extraProperties = Map.unmodifiable(extraProperties); + _extraProperties = Map.unmodifiable(extraProperties), + super(eventId: original.eventId, timestamp: original.timestamp); /// The original unwrapped event. BaseEvent get original => _original; @@ -60,9 +61,6 @@ class EnrichedEvent extends BaseEvent { @override bool get isEssential => _original.isEssential; - @override - DateTime get timestamp => _original.timestamp; - @override String? get userId => _original.userId; diff --git a/lib/src/models/routing/routing_config.dart b/lib/src/models/routing/routing_config.dart index d433d8b..2ed15ab 100644 --- a/lib/src/models/routing/routing_config.dart +++ b/lib/src/models/routing/routing_config.dart @@ -123,8 +123,10 @@ class RoutingConfiguration { } // Check consent requirements - if (!rule.shouldApply(event, - hasGeneralConsent: hasGeneralConsent, hasPIIConsent: hasPIIConsent)) { + if (enableConsentChecking && + !rule.shouldApply(event, + hasGeneralConsent: hasGeneralConsent, + hasPIIConsent: hasPIIConsent)) { continue; } diff --git a/lib/src/routing/routing_engine.dart b/lib/src/routing/routing_engine.dart index a71c706..7c06df8 100644 --- a/lib/src/routing/routing_engine.dart +++ b/lib/src/routing/routing_engine.dart @@ -53,9 +53,10 @@ class RoutingEngine { } // Check if rule should be applied based on consent - if (!rule.shouldApply(event, - hasGeneralConsent: hasGeneralConsent, - hasPIIConsent: hasPIIConsent)) { + if (_configuration.enableConsentChecking && + !rule.shouldApply(event, + hasGeneralConsent: hasGeneralConsent, + hasPIIConsent: hasPIIConsent)) { skippedRules.add(SkippedRule( rule: rule, reason: 'Consent requirements not met', diff --git a/test/core/event_processor_test.dart b/test/core/event_processor_test.dart index 72aac43..e19e22a 100644 --- a/test/core/event_processor_test.dart +++ b/test/core/event_processor_test.dart @@ -28,6 +28,7 @@ void main() { trackerRegistry: trackerRegistry, routingEngine: routingEngine, ); + eventProcessor.setConsent(general: true, pii: true); }); group('Enable/Disable Functionality', () { diff --git a/test/core/event_processor_transformer_test.dart b/test/core/event_processor_transformer_test.dart index e579066..cad1dcc 100644 --- a/test/core/event_processor_transformer_test.dart +++ b/test/core/event_processor_transformer_test.dart @@ -27,6 +27,7 @@ void main() { trackerRegistry: trackerRegistry, routingEngine: routingEngine, ); + eventProcessor.setConsent(general: true, pii: true); }); test('single transformer enriches event reaching the tracker', () async { diff --git a/test/core/flex_track_client_test.dart b/test/core/flex_track_client_test.dart index daf64df..ee950e2 100644 --- a/test/core/flex_track_client_test.dart +++ b/test/core/flex_track_client_test.dart @@ -39,6 +39,7 @@ void main() { await client.initialize(); expect(client.isInitialized, isTrue); + client.setGeneralConsent(true); await client.track(_TestEvent()); expect(mock.capturedEvents, hasLength(1)); @@ -53,6 +54,7 @@ void main() { final client = await FlexTrackClient.create([mock]); await client.initialize(); await client.initialize(); + client.setGeneralConsent(true); await client.track(_TestEvent()); expect(mock.capturedEvents, hasLength(1)); await client.dispose(); @@ -103,6 +105,7 @@ void main() { final mock = MockTracker(); await FlexTrack.setup([mock]); expect(FlexTrack.instance.client.trackerRegistry.get(mock.id), mock); + FlexTrack.setGeneralConsent(true); await FlexTrack.track(_TestEvent()); expect(mock.capturedEvents, hasLength(1)); await FlexTrack.reset(); @@ -111,6 +114,46 @@ void main() { }); group('consent and processor control', () { + test('new clients deny general and PII consent by default', () async { + final client = await FlexTrackClient.create([MockTracker()]); + + expect(client.getConsentStatus(), { + 'general': false, + 'pii': false, + }); + + await client.dispose(); + }); + + test('ordinary events remain blocked until consent is granted', () async { + final mock = MockTracker(); + final client = await FlexTrackClient.create([mock]); + + expect((await client.track(_TestEvent())).wasTracked, isFalse); + client.setGeneralConsent(true); + expect((await client.track(_TestEvent())).wasTracked, isTrue); + + await client.dispose(); + }); + + test('essential events bypass the default-deny consent state', () async { + final mock = MockTracker(); + final client = await FlexTrackClient.create([mock]); + + expect((await client.track(_EssentialTestEvent())).wasTracked, isTrue); + + await client.dispose(); + }); + + test('disabled consent checking bypasses the consent gate', () async { + final mock = MockTracker(); + final client = await _clientWithRelaxedRouting([mock]); + + expect((await client.track(_TestEvent())).wasTracked, isTrue); + + await client.dispose(); + }); + test( 'events that require consent are not delivered when general consent is denied', () async { @@ -397,6 +440,11 @@ class _NamedTestEvent extends BaseEvent { Map? get properties => const {}; } +class _EssentialTestEvent extends _TestEvent { + @override + bool get isEssential => true; +} + class _BrokenInitTracker extends NoOpTracker { _BrokenInitTracker() : super(id: 'broken', name: 'Broken init'); diff --git a/test/core/flex_track_client_transformer_test.dart b/test/core/flex_track_client_transformer_test.dart index 955b310..3a41fbf 100644 --- a/test/core/flex_track_client_transformer_test.dart +++ b/test/core/flex_track_client_transformer_test.dart @@ -8,6 +8,7 @@ Future<(FlexTrackClient, MockTracker)> _makeClient() async { final client = await FlexTrackClient.create( [mock], routing: RoutingConfiguration( + enableConsentChecking: false, rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], ), ); diff --git a/test/core/flex_track_facade_test.dart b/test/core/flex_track_facade_test.dart index adc68f1..3e375f6 100644 --- a/test/core/flex_track_facade_test.dart +++ b/test/core/flex_track_facade_test.dart @@ -77,4 +77,10 @@ class _FacadeTestEvent extends BaseEvent { @override Map? get properties => const {}; + + @override + bool get requiresConsent => false; + + @override + bool get isEssential => true; } diff --git a/test/core/flex_track_test.dart b/test/core/flex_track_test.dart index 9ea0594..51313c6 100644 --- a/test/core/flex_track_test.dart +++ b/test/core/flex_track_test.dart @@ -12,6 +12,12 @@ class TestEvent extends BaseEvent { @override Map get properties => {'test_property': testProperty}; + + @override + bool get requiresConsent => false; + + @override + bool get isEssential => true; } class PurchaseTestEvent extends BaseEvent { @@ -128,6 +134,7 @@ void main() { test('should track multiple events', () async { await FlexTrack.setup([mockTracker1]); + FlexTrack.setConsent(general: true); final events = [ TestEvent(testProperty: 'value1'), @@ -182,6 +189,8 @@ void main() { return builder; // Return the builder }); + FlexTrack.setConsent(general: true); + // Clear any existing events mockTracker1.clearCapturedData(); mockTracker2.clearCapturedData(); @@ -213,6 +222,8 @@ void main() { return builder; }); + FlexTrack.setConsent(general: true); + // Clear trackers mockTracker1.clearCapturedData(); mockTracker2.clearCapturedData(); @@ -260,7 +271,7 @@ void main() { FlexTrack.setConsent(general: false, pii: false); // Regular event requiring consent should be blocked - await FlexTrack.track(TestEvent(testProperty: 'blocked')); + await FlexTrack.track(DebugTestEvent()); expect(mockTracker1.capturedEvents, hasLength(0)); // Essential event should go through regardless @@ -440,6 +451,8 @@ void main() { return builder; }); + FlexTrack.setConsent(general: true); + final businessEvent = PurchaseTestEvent(amount: 100.0); final debugInfo = FlexTrack.debugEvent(businessEvent); @@ -506,7 +519,7 @@ void main() { // Track multiple events for (int i = 0; i < 10; i++) { - await FlexTrack.track(TestEvent(testProperty: 'sample_test_$i')); + await FlexTrack.track(DebugTestEvent()); } // With 0% sampling, no events should be tracked diff --git a/test/models/event/base_event_test.dart b/test/models/event/base_event_test.dart index 71b33f8..93c6a09 100644 --- a/test/models/event/base_event_test.dart +++ b/test/models/event/base_event_test.dart @@ -18,6 +18,44 @@ void main() { // ignore: deprecated_member_use_from_same_package expect(event.properties, event.properties); }); + + test('captures one immutable occurrence timestamp', () async { + final before = DateTime.now().toUtc(); + final event = _SampleEvent(); + final first = event.timestamp; + await Future.delayed(const Duration(milliseconds: 2)); + + expect(event.timestamp, same(first)); + expect(first.isBefore(before), isFalse); + }); + + test('generates a unique UUID event id', () { + final ids = List.generate(100, (_) => _SampleEvent().eventId); + + expect(ids.toSet(), hasLength(ids.length)); + for (final id in ids) { + expect( + id, + matches( + RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ), + ), + ); + } + }); + + test('accepts explicit identity and occurrence time', () { + final timestamp = DateTime.utc(2024, 1, 2, 3, 4, 5); + final event = _MetadataEvent( + eventId: 'event-for-replay', + timestamp: timestamp, + ); + + expect(event.eventId, 'event-for-replay'); + expect(event.timestamp, same(timestamp)); + expect(event.toMap(), containsPair('eventId', 'event-for-replay')); + }); }); } @@ -28,3 +66,13 @@ class _SampleEvent extends BaseEvent { @override Map? get properties => const {'key': 'value'}; } + +class _MetadataEvent extends BaseEvent { + _MetadataEvent({super.eventId, super.timestamp}); + + @override + String get name => 'metadata'; + + @override + Map? get properties => null; +} diff --git a/test/models/event/enriched_event_test.dart b/test/models/event/enriched_event_test.dart index 45daf9d..88790f5 100644 --- a/test/models/event/enriched_event_test.dart +++ b/test/models/event/enriched_event_test.dart @@ -70,13 +70,23 @@ void main() { }); test('forwards timestamp from original', () { - // Capture once — BaseEvent.timestamp calls DateTime.now() each time + // Capture once to verify the enriched event forwards the same value. final fixedTime = DateTime(2024, 1, 1); final fixedEvent = _FixedTimestampEvent(fixedTime); final enriched = EnrichedEvent(fixedEvent, {}); expect(enriched.timestamp, equals(fixedTime)); }); + test('preserves event id and timestamp through nested enrichment', () { + final first = EnrichedEvent(original, {'layer': 1}); + final second = EnrichedEvent(first, {'layer': 2}); + + expect(first.eventId, original.eventId); + expect(second.eventId, original.eventId); + expect(first.timestamp, same(original.timestamp)); + expect(second.timestamp, same(original.timestamp)); + }); + test('forwards userId from original', () { final enriched = EnrichedEvent(original, {}); expect(enriched.userId, original.userId); diff --git a/test/routing/presets/smart_defaults_test.dart b/test/routing/presets/smart_defaults_test.dart index 0a1eb18..78b8027 100644 --- a/test/routing/presets/smart_defaults_test.dart +++ b/test/routing/presets/smart_defaults_test.dart @@ -432,6 +432,8 @@ void main() { return builder; }); + FlexTrack.setConsent(general: true); + final technicalEvent = TestEvent('debug_test', EventCategory.technical); // In debug mode, technical events should go to development trackers @@ -602,6 +604,9 @@ class TestEvent extends BaseEvent { @override EventCategory? get category => eventCategory; + + @override + bool get requiresConsent => false; } class HighVolumeTestEvent extends BaseEvent { @@ -617,6 +622,9 @@ class HighVolumeTestEvent extends BaseEvent { @override bool get isHighVolume => true; + + @override + bool get isEssential => true; } class EssentialTestEvent extends BaseEvent { diff --git a/test/test_utils/mock_events.dart b/test/test_utils/mock_events.dart index e6fbfeb..2be069a 100644 --- a/test/test_utils/mock_events.dart +++ b/test/test_utils/mock_events.dart @@ -15,7 +15,7 @@ class CustomEvent extends BaseEvent { EventCategory? category, bool containsPII = false, bool isHighVolume = false, - bool isEssential = false, + bool isEssential = true, }) : _properties = properties, _category = category, _containsPII = containsPII, @@ -28,7 +28,7 @@ class CustomEvent extends BaseEvent { EventCategory? category, bool containsPII = false, bool isHighVolume = false, - bool isEssential = false, + bool isEssential = true, }) { return CustomEvent( name, @@ -59,6 +59,10 @@ class CustomEvent extends BaseEvent { @override bool get isEssential => _isEssential; + + // Most tests using this fixture exercise routing or dispatch, not consent. + @override + bool get requiresConsent => false; } class PurchaseEvent extends CustomEvent { diff --git a/test/version_test.dart b/test/version_test.dart new file mode 100644 index 0000000..822b1d3 --- /dev/null +++ b/test/version_test.dart @@ -0,0 +1,15 @@ +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('exported package version matches pubspec', () { + final pubspec = File('pubspec.yaml').readAsStringSync(); + final match = + RegExp(r'^version:\s*(\S+)\s*$', multiLine: true).firstMatch(pubspec); + + expect(match, isNotNull); + expect(flexTrackVersion, match!.group(1)); + }); +} diff --git a/test/widgets/flex_click_track_test.dart b/test/widgets/flex_click_track_test.dart index 50c1e45..b2c9cad 100644 --- a/test/widgets/flex_click_track_test.dart +++ b/test/widgets/flex_click_track_test.dart @@ -186,6 +186,7 @@ void main() { (tester) async { final mock = MockTracker(); final client = await FlexTrackClient.create([mock]); + client.setGeneralConsent(true); addTearDown(() async { await client.dispose(); }); @@ -215,6 +216,7 @@ void main() { final globalMock = await setupFlexTrackForTesting(); final scopedMock = MockTracker(); final scopedClient = await FlexTrackClient.create([scopedMock]); + scopedClient.setGeneralConsent(true); addTearDown(() async { await scopedClient.dispose(); }); diff --git a/test/widgets/flex_impression_track_test.dart b/test/widgets/flex_impression_track_test.dart index c734ae3..5caf7fe 100644 --- a/test/widgets/flex_impression_track_test.dart +++ b/test/widgets/flex_impression_track_test.dart @@ -210,6 +210,7 @@ void main() { (tester) async { final mock = MockTracker(); final client = await FlexTrackClient.create([mock]); + client.setGeneralConsent(true); addTearDown(() async { await client.dispose(); }); @@ -246,6 +247,7 @@ void main() { final globalMock = await setupFlexTrackForTesting(); final scopedMock = MockTracker(); final scopedClient = await FlexTrackClient.create([scopedMock]); + scopedClient.setGeneralConsent(true); addTearDown(() async { await scopedClient.dispose(); }); diff --git a/test/widgets/flex_mount_track_test.dart b/test/widgets/flex_mount_track_test.dart index ca72130..ce279fc 100644 --- a/test/widgets/flex_mount_track_test.dart +++ b/test/widgets/flex_mount_track_test.dart @@ -106,6 +106,7 @@ void main() { (tester) async { final mock = MockTracker(); final client = await FlexTrackClient.create([mock]); + client.setGeneralConsent(true); addTearDown(() async { await client.dispose(); }); @@ -133,6 +134,7 @@ void main() { final globalMock = await setupFlexTrackForTesting(); final scopedMock = MockTracker(); final scopedClient = await FlexTrackClient.create([scopedMock]); + scopedClient.setGeneralConsent(true); addTearDown(() async { await scopedClient.dispose(); }); diff --git a/test/widgets/flex_route_track_test.dart b/test/widgets/flex_route_track_test.dart index b96a12c..fc87e1c 100644 --- a/test/widgets/flex_route_track_test.dart +++ b/test/widgets/flex_route_track_test.dart @@ -289,6 +289,7 @@ void main() { (tester) async { final mock = MockTracker(); final client = await FlexTrackClient.create([mock]); + client.setGeneralConsent(true); addTearDown(() async { await client.dispose(); }); @@ -316,6 +317,7 @@ void main() { final globalMock = await setupFlexTrackForTesting(); final scopedMock = MockTracker(); final scopedClient = await FlexTrackClient.create([scopedMock]); + scopedClient.setGeneralConsent(true); addTearDown(() async { await scopedClient.dispose(); }); diff --git a/test/widgets/transformer_widget_test.dart b/test/widgets/transformer_widget_test.dart index 2346a14..b91f1bb 100644 --- a/test/widgets/transformer_widget_test.dart +++ b/test/widgets/transformer_widget_test.dart @@ -13,6 +13,7 @@ void main() { final client = await FlexTrackClient.create( [mock], routing: RoutingConfiguration( + enableConsentChecking: false, rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], ), ); @@ -53,6 +54,7 @@ void main() { final client = await FlexTrackClient.create( [mock], routing: RoutingConfiguration( + enableConsentChecking: false, rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], ), ); @@ -92,6 +94,7 @@ void main() { final client = await FlexTrackClient.create( [mock], routing: RoutingConfiguration( + enableConsentChecking: false, rules: [RoutingRule(isDefault: true, targetGroup: TrackerGroup.all)], ), ); From d44d6af33591337adfee0f838615a5a462439240 Mon Sep 17 00:00:00 2001 From: Reza Taghizadeh Date: Mon, 17 Aug 2026 20:23:38 +0200 Subject: [PATCH 5/8] Implemented the FlexTrack Core MVP specification. (#30) Changes: - Added the versioned, language-neutral Core 1.0.0 specification. - Defined event, enrichment, tracker, routing, consent, sampling, lifecycle, and debug contracts. - Documented the exact processing and routing evaluation order. - Added worked examples and compatibility/versioning rules. - Explicitly excluded offline queues, persistence, retries, sessions, identity management, and optimized batching from the MVP. - Added a contract test to protect the required specification sections. - Linked the specification from the project documentation. Validation: - All 832 tests pass. - `flutter analyze` passes with no issues. - `git diff --check` passes. --- CHANGELOG.md | 5 + README.md | 3 + docs/README.md | 2 + docs/core-mvp-specification.md | 218 ++++++++++++++++++ .../contract/core_mvp_specification_test.dart | 18 ++ 5 files changed, 246 insertions(+) create mode 100644 docs/core-mvp-specification.md create mode 100644 test/contract/core_mvp_specification_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index bb37cbc..1ad71e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Unreleased +## Added + +- Published the versioned, language-neutral FlexTrack Core MVP specification + shared by the Flutter and Kotlin implementations. + ## Fixed - Type-based routing now matches event subclasses and preserves the original diff --git a/README.md b/README.md index 482241f..c6ead9f 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,9 @@ FlexTrack exists to make that architecture explicit, maintainable, and debuggabl - `FlexTrackClient` for dependency injection patterns - Widget wrappers for click, impression, mount, and route-view tracking +The normative behavior shared by the Flutter and Kotlin SDKs is defined in the +[FlexTrack Core MVP specification](docs/core-mvp-specification.md). + --- ## Examples diff --git a/docs/README.md b/docs/README.md index 292fa5d..30db976 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,4 +7,6 @@ Long-form documentation now lives in the **Docusaurus** site under [`website/doc Historical topic filenames (`trackers.md`, `routing-and-rules.md`, etc.) have corresponding pages in `website/docs/guides/` (when the Docusaurus site is present). +- **[Core MVP specification](core-mvp-specification.md)** — normative, + language-neutral contract shared by Flutter and Kotlin. - **[FlexTrackClient and DI](flex-track-client.md)** — injectable client, Riverpod and Bloc examples, tests without the global singleton. diff --git a/docs/core-mvp-specification.md b/docs/core-mvp-specification.md new file mode 100644 index 0000000..4648e4c --- /dev/null +++ b/docs/core-mvp-specification.md @@ -0,0 +1,218 @@ +# FlexTrack Core MVP Specification + +Status: Normative +Specification version: 1.0.0 +Target SDKs: Flutter 2.1.x and Kotlin 1.0.x + +## 1. Purpose and terminology + +This is the language-neutral contract for a conforming FlexTrack Core. It +defines observable behavior, not internal class structure. **MUST**, **MUST +NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are normative requirements. A +tracker is an adapter for one analytics destination; a dispatch is one attempt +to deliver one processed event to one tracker. + +Example: Kotlin MAY use data classes and Flutter MAY use abstract classes, but +both MUST make the same routing decision from the same inputs. + +## 2. MVP boundary + +Core MVP includes events and enrichment, tracker lifecycle, routing, consent, +PII gates, deterministic sampling, decision records, setup, track, flush, +tracker reset, and disposal. + +Durable/offline queues, persistence, retries/backoff, session management, +SDK-owned identity, and optimized batching are later capabilities. They MUST +NOT be required for Core MVP conformance. An adapter MAY implement them +privately, but Core 1.0 does not promise their semantics. + +Example: a tracker MAY buffer internally, but Core need not restore its buffer +after process death. + +## 3. Event model + +An event MUST expose: + +| Field | Type | Default or requirement | +|---|---|---| +| `eventId` | non-empty string | UUID v4 for a new occurrence | +| `name` | string | supplied by the event | +| `properties` | string-keyed object or null | null | +| `category` | string or null | null | +| `preferredGroup` | group or null | null | +| `containsPII` | boolean | false | +| `requiresConsent` | boolean | true | +| `isHighVolume` | boolean | false | +| `isEssential` | boolean | false | +| `timestamp` | instant | creation time in UTC | +| `userId` | string or null | null | +| `sessionId` | string or null | null | + +`eventId` and `timestamp` MUST be captured once and remain unchanged. +Reconstructed events MUST accept their original values. Generated IDs MUST be +RFC 4122 UUID v4 values. Serialized timestamps MUST be ISO 8601 with an +explicit UTC offset. Core MUST preserve property values without implicit string +conversion. + +Example: + +```json +{ + "eventId": "123e4567-e89b-42d3-a456-426614174000", + "name": "purchase", + "properties": {"amount": 29.99, "currency": "EUR"}, + "category": "business", + "containsPII": false, + "requiresConsent": true, + "isHighVolume": false, + "isEssential": false, + "timestamp": "2026-08-17T12:30:00.000Z", + "userId": null, + "sessionId": null +} +``` + +## 4. Enrichment + +Transformers MUST run in registration order before routing. Each output MUST be +the next input. Added properties MUST win on duplicate keys. Enrichment MUST +preserve ID, timestamp, name, category, group preference, privacy flags, +volume/essential flags, user ID, and session ID. Type matching MUST inspect the +original type through nested wrappers; other conditions MUST inspect the +transformed event. A transformer failure SHOULD be isolated and processing +SHOULD continue from the last valid event. + +Example: `{plan: free}` enriched with `{plan: pro, route: /pay}` becomes +`{plan: pro, route: /pay}` with the original ID and timestamp. + +## 5. Tracker interface and lifecycle + +Each tracker MUST have a unique non-empty stable ID, name, and enabled state. +Setup MUST reject an empty tracker list or duplicate IDs, then register and +initialize every tracker. Repeated client initialization MUST be idempotent. + +Core MUST call `track(event)` on every selected enabled tracker and record each +outcome independently. One failure MUST NOT prevent later tracker attempts. +Disabled selected trackers MUST produce failed tracker results. Unavailable IDs +MUST already have been removed during group resolution. + +`flush()` and tracker reset MUST delegate to all enabled registered trackers. +Disposal MUST flush enabled trackers when the client was initialized and release +client-owned debug resources. It does not guarantee durable delivery. + +Example: if `a` throws and `b` succeeds, Core still calls `b` and returns one +failed plus one successful result. + +## 6. Routing + +### 6.1 Conditions + +A rule matches only when every configured condition matches. MVP conditions are +original event type/subtype, name substring, name regex, category, property +presence and optional equality, PII, high-volume, essential, and environment. +An absent condition MUST NOT restrict matching. + +Example: category `business` plus `currency = EUR` matches a EUR purchase, not +a USD purchase or technical event. + +### 6.2 Priority tiers and merging + +Matching rules MUST be sorted by descending integer priority. Core MUST evaluate +until a tier produces targets. All successful rules at that priority MUST merge +tracker IDs as an ordered, de-duplicated set. Lower tiers MUST NOT run afterward. +A rule blocked by consent, sampled out, or resolving to no tracker does not +establish a winning tier. + +Example: priority-10 targets `[firebase]` and `[api, firebase]` merge to +`[firebase, api]`; priority 0 is ignored. If both are blocked, priority 0 runs. + +### 6.3 Groups and fallback + +A named group MUST resolve to configured IDs. `all` MUST resolve to every +available tracker. Unavailable IDs MUST be removed. An empty resolution MUST be +skipped with a warning. If no configured rule matches, Core MUST use a default +rule, otherwise an equivalent rule for `defaultGroup`; without either it MUST +return no targets. + +Example: `[firebase, missing]` with only `firebase` available resolves to +`[firebase]`. With no match and `defaultGroup = all`, all trackers are targeted. + +## 7. Consent and PII + +New clients MUST start with general and PII consent `false`. With consent +checking enabled, a non-essential rule MUST be skipped when its general consent, +the event's general consent, or its PII consent requirement is unmet. Essential +events MUST bypass both gates. Disabling configuration-level consent checking +MUST bypass all consent gates. Consent changes affect future processing only. + +Example: a purchase is rejected at startup, succeeds after general consent, +and a PII rule still waits for PII consent. An essential crash event is eligible +in every consent state. + +## 8. Deterministic sampling + +Essential events MUST bypass sampling. Rates `<= 0` MUST reject and rates `>= 1` +MUST accept. Otherwise choose the first non-empty `userId`, `sessionId`, then +`name`; hash its UTF-8 bytes with unsigned 32-bit FNV-1a; calculate +`bucket = hash / 4294967296`; accept exactly when `bucket < sampleRate`. +Implementations MUST pass +[`sampling_vectors.json`](../test/fixtures/sampling_vectors.json). Locale +normalization and platform string hashes MUST NOT be used. + +Example: `hello` hashes to `1335831723`, bucket about `0.3110`; it is rejected +at 25% and accepted at 50%. + +## 9. Processing order + +`track(event)` MUST execute in this order: + +1. Stop unsuccessful with no targets when the processor is disabled. +2. Run transformers in registration order. +3. Match rules and sort them by descending priority. +4. Apply consent gates at each eligible tier. +5. Apply deterministic sampling. +6. Resolve groups against available trackers. +7. Merge successful rules in the first successful tier. +8. Attempt every selected tracker independently. +9. Return the processed event, routing decision, and tracker results. +10. In debug builds, emit one decision record after processing. + +Example: an enriched PII event gains `route=/profile`, matches a property rule, +then fails its PII gate. It causes no tracker call, while the result records the +enriched event and skipped rule. + +## 10. Results and debug decisions + +A result MUST contain the processed event, target IDs, applied rules, skipped +rules and reasons, warnings, per-tracker outcomes, and overall success. Success +MUST mean at least one delivery succeeded. `routed` MUST mean at least one target +was selected; `tracked` MUST mean at least one delivery succeeded. + +A tracker outcome MUST contain tracker ID, success, optional error, and attempt +timestamp. A debug decision MUST contain the processed event, selected IDs, and +successful IDs. Debug emission MAY be absent in release builds and MUST NOT +change delivery. + +Example: targets `[a, b]` with only `b` succeeding gives `routed=true`, +`tracked=true`, `successful=true`, successful IDs `[b]`, and one failure. + +## 11. Client operations + +Each client MUST own isolated trackers, routing, consent, and transformers. +Sequential helpers MAY process in input order; parallel helpers MAY deliver +concurrently but MUST return results in input order. Optimized batching remains +outside MVP. Enablement and consent changes affect future processing. Global +facades MAY exist but MUST delegate without changing client semantics. + +Example: a transformer added to client A MUST NOT affect client B. + +## 12. Versioning and compatibility + +The specification uses semantic versioning independently of SDK versions. +Patches clarify wording without behavior changes. Minors add backward-compatible +optional behavior. Majors may change required fields, evaluation order, or +decisions. Every SDK release MUST state its implemented spec version. SDKs on +the same spec major SHOULD interoperate on shared event and vector formats. + +Example: Flutter 2.1.2 and Kotlin 1.0.1 can both implement Core Spec 1.0.0. An +optional debug field can enter 1.1.0; changed priority semantics require 2.0.0. diff --git a/test/contract/core_mvp_specification_test.dart b/test/contract/core_mvp_specification_test.dart new file mode 100644 index 0000000..12e23c6 --- /dev/null +++ b/test/contract/core_mvp_specification_test.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('core MVP specification keeps required contract sections', () { + final specification = + File('docs/core-mvp-specification.md').readAsStringSync(); + + expect(specification, contains('Specification version: 1.0.0')); + expect(specification, contains('## 2. MVP boundary')); + expect(specification, contains('## 9. Processing order')); + expect(specification, contains('## 12. Versioning and compatibility')); + expect(specification, contains('**MUST**')); + expect(specification, contains('Durable/offline queues')); + expect(specification, contains('sampling_vectors.json')); + }); +} From 692344f13f132827c7aa5324737b15db93e8a73e Mon Sep 17 00:00:00 2001 From: Reza Taghizadeh Date: Mon, 17 Aug 2026 20:39:19 +0200 Subject: [PATCH 6/8] test: add shared Flutter Kotlin conformance fixtures (#31) - add versioned JSON schema and deterministic MVP cases - cover routing, consent, sampling, enrichment, and debug behavior - add the Flutter conformance runner and machine-readable report - document the Kotlin runner contract - validate conformance fixtures in CI --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 2 + README.md | 3 +- docs/README.md | 2 + docs/conformance.md | 64 +++++ test/contract/core_mvp_conformance_test.dart | 233 ++++++++++++++++++ .../fixtures/conformance/core_mvp.schema.json | 29 +++ test/fixtures/conformance/core_mvp_cases.json | 99 ++++++++ test/fixtures/conformance/flutter_report.json | 18 ++ 9 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 docs/conformance.md create mode 100644 test/contract/core_mvp_conformance_test.dart create mode 100644 test/fixtures/conformance/core_mvp.schema.json create mode 100644 test/fixtures/conformance/core_mvp_cases.json create mode 100644 test/fixtures/conformance/flutter_report.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad6413f..bbd8afd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: - run: flutter pub get - run: dart format --output=none --set-exit-if-changed lib test example/lib example/integration_test examples/static_app/lib examples/riverpod_app/lib examples/bloc_getit_app/lib - run: flutter analyze + - name: Validate Core contract and conformance fixtures + run: flutter test test/contract - run: flutter test - run: cd example && flutter pub get && flutter analyze # Integration tests need a single device (-d). On ubuntu-latest both linux diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad71e2..0102265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Published the versioned, language-neutral FlexTrack Core MVP specification shared by the Flutter and Kotlin implementations. +- Added versioned JSON conformance fixtures, a Flutter runner and report, and + the Kotlin runner contract for cross-SDK behavior parity. ## Fixed diff --git a/README.md b/README.md index c6ead9f..871bdd7 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,8 @@ FlexTrack exists to make that architecture explicit, maintainable, and debuggabl - Widget wrappers for click, impression, mount, and route-view tracking The normative behavior shared by the Flutter and Kotlin SDKs is defined in the -[FlexTrack Core MVP specification](docs/core-mvp-specification.md). +[FlexTrack Core MVP specification](docs/core-mvp-specification.md) and verified +with [shared conformance fixtures](docs/conformance.md). --- diff --git a/docs/README.md b/docs/README.md index 30db976..0f270e8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,4 +9,6 @@ Historical topic filenames (`trackers.md`, `routing-and-rules.md`, etc.) have co - **[Core MVP specification](core-mvp-specification.md)** — normative, language-neutral contract shared by Flutter and Kotlin. +- **[Cross-SDK conformance](conformance.md)** — shared fixtures, reports, and + the Kotlin runner contract. - **[FlexTrackClient and DI](flex-track-client.md)** — injectable client, Riverpod and Bloc examples, tests without the global singleton. diff --git a/docs/conformance.md b/docs/conformance.md new file mode 100644 index 0000000..55f9bff --- /dev/null +++ b/docs/conformance.md @@ -0,0 +1,64 @@ +# Cross-SDK conformance + +The files in [`test/fixtures/conformance/`](../test/fixtures/conformance/) are +the shared executable contract for Flutter and Kotlin Core MVP implementations. + +## Version 1.0.0 files + +- `core_mvp.schema.json` defines the fixture envelope. +- `core_mvp_cases.json` contains deterministic inputs and expected outputs. +- `flutter_report.json` is the machine-readable Flutter conformance report. +- `sampling_vectors.json` contains the complete Unicode FNV-1a vectors used by + both SDKs. + +Fixture case IDs are stable within a fixture major version. Adding a +backward-compatible case increments the fixture minor version. Changing an +existing input or expected result increments the fixture major version. + +## Kotlin runner contract + +The Android repository MUST copy or consume the fixture files without rewriting +their values. Its runner MUST: + +1. Reject an unsupported `specVersion` or fixture major version. +2. Validate the fixture envelope against `core_mvp.schema.json`. +3. Execute every case according to its `behavior` value. +4. Compare ordered arrays exactly; tracker ordering is observable. +5. Use UTF-8 and unsigned 32-bit FNV-1a for sampling cases. +6. Avoid wall-clock time, random identifiers, network calls, and Android device + state while evaluating fixtures. +7. Emit a JSON report with `specVersion`, `fixtureVersion`, `implementation`, + `total`, `passed`, `failed`, and ordered `caseIds`. +8. Exit unsuccessfully when schema validation or any case fails. + +Example Kotlin report: + +```json +{ + "specVersion": "1.0.0", + "fixtureVersion": "1.0.0", + "implementation": "kotlin", + "total": 8, + "passed": 8, + "failed": 0, + "caseIds": ["routing.priority-overlap"] +} +``` + +The abbreviated `caseIds` above is illustrative; a real passing report MUST +contain every fixture ID in fixture order. + +## Covered behavior + +The MVP suite covers priority overlap, same-tier merging, fallback, missing +general consent, missing PII consent, Unicode sampling, enrichment identity and +property precedence, and the debug dispatch decision. Later capabilities such +as offline queues and retry are intentionally excluded until their contracts +are versioned. + +Run the Flutter suite with: + +```bash +flutter test test/contract +``` + diff --git a/test/contract/core_mvp_conformance_test.dart b/test/contract/core_mvp_conformance_test.dart new file mode 100644 index 0000000..5ac8e75 --- /dev/null +++ b/test/contract/core_mvp_conformance_test.dart @@ -0,0 +1,233 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _fixturePath = 'test/fixtures/conformance/core_mvp_cases.json'; +const _schemaPath = 'test/fixtures/conformance/core_mvp.schema.json'; +const _reportPath = 'test/fixtures/conformance/flutter_report.json'; + +void main() { + final fixture = _readObject(_fixturePath); + final cases = (fixture['cases'] as List).cast>(); + + test('fixture document satisfies the shared schema contract', () { + final schema = _readObject(_schemaPath); + expect(schema[r'$schema'], 'https://json-schema.org/draft/2020-12/schema'); + expect(fixture[r'$schema'], 'core_mvp.schema.json'); + expect(fixture['specVersion'], '1.0.0'); + expect(fixture['fixtureVersion'], matches(r'^1\.[0-9]+\.[0-9]+$')); + expect(cases, isNotEmpty); + + final ids = {}; + for (final fixtureCase in cases) { + expect(fixtureCase.keys.toSet(), + equals({'id', 'behavior', 'input', 'expected'})); + expect(fixtureCase['id'], isA()); + expect((fixtureCase['id'] as String), isNotEmpty); + expect(ids.add(fixtureCase['id'] as String), isTrue, + reason: 'Fixture IDs must be unique'); + expect( + ['routing', 'consent', 'sampling', 'enrichment', 'debug'], + contains(fixtureCase['behavior']), + ); + expect(fixtureCase['input'], isA>()); + expect(fixtureCase['expected'], isA>()); + } + }); + + for (final fixtureCase in cases) { + test('conformance: ${fixtureCase['id']}', () async { + expect( + await _runCase(fixtureCase), + fixtureCase['expected'], + reason: fixtureCase['id'] as String, + ); + }); + } + + test('machine-readable Flutter report covers every passing case', () { + final report = _readObject(_reportPath); + final caseIds = cases.map((value) => value['id']).toList(); + + expect(report['specVersion'], fixture['specVersion']); + expect(report['fixtureVersion'], fixture['fixtureVersion']); + expect(report['implementation'], 'flutter'); + expect(report['total'], cases.length); + expect(report['passed'], cases.length); + expect(report['failed'], 0); + expect(report['caseIds'], caseIds); + }); +} + +Future> _runCase(Map fixtureCase) async { + final input = fixtureCase['input'] as Map; + switch (fixtureCase['behavior']) { + case 'routing': + return _runRouting(input); + case 'consent': + return _runConsent(input); + case 'sampling': + final identity = input['identity'] as String; + final rate = (input['sampleRate'] as num).toDouble(); + return { + 'hash': SamplingUtils.stableHash(identity), + 'accepted': SamplingUtils.shouldSampleDeterministic(identity, rate), + }; + case 'enrichment': + final original = _FixtureEvent.fromJson(input); + final enriched = EnrichedEvent( + original, + _objectProperties(input['extraProperties']), + ); + return { + 'eventId': enriched.eventId, + 'timestamp': enriched.timestamp.toIso8601String(), + 'name': enriched.name, + 'properties': enriched.properties, + }; + case 'debug': + final setup = _routingSetup(input); + final tracker = MockTracker(id: 'analytics', name: 'Analytics'); + final client = await FlexTrackClient.create( + [tracker], + routing: setup.engine.configuration, + ); + client.setGeneralConsent(true); + final recordFuture = client.eventDispatchStream.first; + await client.track(setup.event); + final record = await recordFuture; + await client.dispose(); + return { + 'targetTrackers': record.targetTrackers, + 'successfulTrackerIds': record.successfulTrackerIds, + }; + default: + throw StateError('Unsupported behavior: ${fixtureCase['behavior']}'); + } +} + +Map _runRouting(Map input) { + final setup = _routingSetup(input); + final result = setup.engine.routeEvent( + setup.event, + availableTrackers: setup.availableTrackers, + ); + return { + 'targets': result.targetTrackers, + 'appliedPriorities': + result.appliedRules.map((rule) => rule.priority).toList(), + }; +} + +Map _runConsent(Map input) { + final event = _FixtureEvent.fromJson(input['event'] as Map); + final rule = _ruleFromJson(input['rule'] as Map); + final result = RoutingEngine(RoutingConfiguration(rules: [rule])).routeEvent( + event, + hasGeneralConsent: input['generalConsent'] as bool, + hasPIIConsent: input['piiConsent'] as bool, + availableTrackers: {'analytics'}, + ); + return { + 'targets': result.targetTrackers, + 'skipReasons': result.skippedRules.map((value) => value.reason).toList(), + }; +} + +_RoutingSetup _routingSetup(Map input) { + final event = _FixtureEvent.fromJson(input['event'] as Map); + final rules = (input['rules'] as List) + .cast>() + .map(_ruleFromJson) + .toList(); + final defaultIds = (input['defaultGroup'] as List?)?.cast(); + final configuration = RoutingConfiguration( + rules: rules, + defaultGroup: + defaultIds == null ? null : TrackerGroup('fixture-default', defaultIds), + ); + return _RoutingSetup( + event, + RoutingEngine(configuration), + (input['availableTrackers'] as List).cast().toSet(), + ); +} + +RoutingRule _ruleFromJson(Map json) { + final targets = (json['targets'] as List).cast(); + return RoutingRule( + eventNamePattern: json['nameContains'] as String?, + category: _category(json['category'] as String?), + isDefault: json['default'] as bool? ?? false, + targetGroup: TrackerGroup('fixture', targets), + requireConsent: json['requireConsent'] as bool? ?? false, + requirePIIConsent: json['requirePIIConsent'] as bool? ?? false, + priority: json['priority'] as int? ?? 0, + ); +} + +EventCategory? _category(String? value) => + value == null ? null : EventCategory(value); + +Map _objectProperties(Object? value) => + (value as Map).cast(); + +Map _readObject(String path) => + (jsonDecode(File(path).readAsStringSync()) as Map).cast(); + +class _RoutingSetup { + const _RoutingSetup(this.event, this.engine, this.availableTrackers); + + final BaseEvent event; + final RoutingEngine engine; + final Set availableTrackers; +} + +class _FixtureEvent extends BaseEvent { + _FixtureEvent({ + required this.eventName, + this.eventProperties, + this.eventCategory, + this.eventContainsPII = false, + this.eventRequiresConsent = true, + super.eventId, + super.timestamp, + }); + + factory _FixtureEvent.fromJson(Map json) => _FixtureEvent( + eventName: json['name'] as String, + eventProperties: json['properties'] == null + ? null + : _objectProperties(json['properties']), + eventCategory: _category(json['category'] as String?), + eventContainsPII: json['containsPII'] as bool? ?? false, + eventRequiresConsent: json['requiresConsent'] as bool? ?? true, + eventId: json['eventId'] as String?, + timestamp: json['timestamp'] == null + ? null + : DateTime.parse(json['timestamp'] as String), + ); + + final String eventName; + final Map? eventProperties; + final EventCategory? eventCategory; + final bool eventContainsPII; + final bool eventRequiresConsent; + + @override + String get name => eventName; + + @override + Map? get properties => eventProperties; + + @override + EventCategory? get category => eventCategory; + + @override + bool get containsPII => eventContainsPII; + + @override + bool get requiresConsent => eventRequiresConsent; +} diff --git a/test/fixtures/conformance/core_mvp.schema.json b/test/fixtures/conformance/core_mvp.schema.json new file mode 100644 index 0000000..3e80214 --- /dev/null +++ b/test/fixtures/conformance/core_mvp.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flextrack.taghizadeh.dev/schemas/core-mvp-1.0.0.json", + "title": "FlexTrack Core MVP conformance fixtures", + "type": "object", + "required": ["specVersion", "fixtureVersion", "cases"], + "properties": { + "specVersion": {"const": "1.0.0"}, + "fixtureVersion": {"type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$"}, + "cases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "behavior", "input", "expected"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "behavior": { + "enum": ["routing", "consent", "sampling", "enrichment", "debug"] + }, + "input": {"type": "object"}, + "expected": {"type": "object"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/test/fixtures/conformance/core_mvp_cases.json b/test/fixtures/conformance/core_mvp_cases.json new file mode 100644 index 0000000..4e841a0 --- /dev/null +++ b/test/fixtures/conformance/core_mvp_cases.json @@ -0,0 +1,99 @@ +{ + "$schema": "core_mvp.schema.json", + "specVersion": "1.0.0", + "fixtureVersion": "1.0.0", + "cases": [ + { + "id": "routing.priority-overlap", + "behavior": "routing", + "input": { + "event": {"name": "purchase", "category": "business"}, + "availableTrackers": ["analytics", "archive"], + "rules": [ + {"category": "business", "priority": 10, "targets": ["analytics"]}, + {"default": true, "priority": 0, "targets": ["archive"]} + ] + }, + "expected": {"targets": ["analytics"], "appliedPriorities": [10]} + }, + { + "id": "routing.same-tier-merge", + "behavior": "routing", + "input": { + "event": {"name": "purchase"}, + "availableTrackers": ["analytics", "archive"], + "rules": [ + {"nameContains": "purchase", "priority": 5, "targets": ["analytics"]}, + {"nameContains": "purchase", "priority": 5, "targets": ["archive", "analytics"]} + ] + }, + "expected": {"targets": ["analytics", "archive"], "appliedPriorities": [5, 5]} + }, + { + "id": "routing.default-group-fallback", + "behavior": "routing", + "input": { + "event": {"name": "unmatched"}, + "availableTrackers": ["archive"], + "defaultGroup": ["archive"], + "rules": [{"nameContains": "purchase", "priority": 5, "targets": ["archive"]}] + }, + "expected": {"targets": ["archive"], "appliedPriorities": [0]} + }, + { + "id": "consent.general-missing", + "behavior": "consent", + "input": { + "event": {"name": "view", "requiresConsent": true}, + "generalConsent": false, + "piiConsent": false, + "rule": {"requireConsent": true, "targets": ["analytics"]} + }, + "expected": {"targets": [], "skipReasons": ["Consent requirements not met"]} + }, + { + "id": "consent.pii-missing", + "behavior": "consent", + "input": { + "event": {"name": "profile", "containsPII": true, "requiresConsent": true}, + "generalConsent": true, + "piiConsent": false, + "rule": {"requireConsent": true, "requirePIIConsent": true, "targets": ["analytics"]} + }, + "expected": {"targets": [], "skipReasons": ["Consent requirements not met"]} + }, + { + "id": "sampling.unicode-utf8", + "behavior": "sampling", + "input": {"identity": "नमस्ते", "sampleRate": 0.25}, + "expected": {"hash": 538106393, "accepted": true} + }, + { + "id": "enrichment.identity-and-properties", + "behavior": "enrichment", + "input": { + "eventId": "fixture-event-1", + "timestamp": "2026-08-17T12:30:00.000Z", + "name": "purchase", + "properties": {"plan": "free"}, + "extraProperties": {"plan": "pro", "route": "/pay"} + }, + "expected": { + "eventId": "fixture-event-1", + "timestamp": "2026-08-17T12:30:00.000Z", + "name": "purchase", + "properties": {"plan": "pro", "route": "/pay"} + } + }, + { + "id": "debug.routing-decision", + "behavior": "debug", + "input": { + "event": {"name": "purchase"}, + "availableTrackers": ["analytics"], + "rules": [{"nameContains": "purchase", "priority": 7, "targets": ["analytics"]}] + }, + "expected": {"targetTrackers": ["analytics"], "successfulTrackerIds": ["analytics"]} + } + ] +} diff --git a/test/fixtures/conformance/flutter_report.json b/test/fixtures/conformance/flutter_report.json new file mode 100644 index 0000000..3fca9c3 --- /dev/null +++ b/test/fixtures/conformance/flutter_report.json @@ -0,0 +1,18 @@ +{ + "specVersion": "1.0.0", + "fixtureVersion": "1.0.0", + "implementation": "flutter", + "total": 8, + "passed": 8, + "failed": 0, + "caseIds": [ + "routing.priority-overlap", + "routing.same-tier-merge", + "routing.default-group-fallback", + "consent.general-missing", + "consent.pii-missing", + "sampling.unicode-utf8", + "enrichment.identity-and-properties", + "debug.routing-decision" + ] +} From 809db626cb07f6a595bedf7ff553b3d66860b6d2 Mon Sep 17 00:00:00 2001 From: Reza Taghizadeh Date: Mon, 17 Aug 2026 20:56:39 +0200 Subject: [PATCH 7/8] chore(release): prepare Flutter 2.1.0 (#32) - bump the package and exported versions to 2.1.0 - finalize the 2.1.0 changelog - update README installation instructions - adopt the standard doc directory layout - exclude development-only files from the published package --- .pubignore | 16 ++++++++++++++++ CHANGELOG.md | 6 +++--- README.md | 16 ++++++++-------- {docs => doc}/README.md | 0 {docs => doc}/assets/banner.png | Bin {docs => doc}/assets/ft_logo.png | Bin {docs => doc}/assets/inspector.gif | Bin {docs => doc}/conformance.md | 5 +++-- {docs => doc}/core-mvp-specification.md | 2 +- {docs => doc}/flex-track-client.md | 0 {docs => doc}/privacy-performance-debugging.md | 0 {docs => doc}/routing-and-rules.md | 0 {docs => doc}/testing-and-troubleshooting.md | 0 {docs => doc}/trackers.md | 0 {docs => doc}/widgets.md | 0 lib/flex_track.dart | 4 ++-- pubspec.yaml | 6 +++--- test/contract/core_mvp_specification_test.dart | 2 +- 18 files changed, 37 insertions(+), 20 deletions(-) create mode 100644 .pubignore rename {docs => doc}/README.md (100%) rename {docs => doc}/assets/banner.png (100%) rename {docs => doc}/assets/ft_logo.png (100%) rename {docs => doc}/assets/inspector.gif (100%) rename {docs => doc}/conformance.md (94%) rename {docs => doc}/core-mvp-specification.md (98%) rename {docs => doc}/flex-track-client.md (100%) rename {docs => doc}/privacy-performance-debugging.md (100%) rename {docs => doc}/routing-and-rules.md (100%) rename {docs => doc}/testing-and-troubleshooting.md (100%) rename {docs => doc}/trackers.md (100%) rename {docs => doc}/widgets.md (100%) diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..fdd0123 --- /dev/null +++ b/.pubignore @@ -0,0 +1,16 @@ +.dart_tool/ +.github/ +.idea/ +.vscode/ +*.iml +coverage/ +build/ +test/ +examples/ +plan +website/ +**/.flutter-plugins +**/.flutter-plugins-dependencies +**/.dart_tool/ +**/build/ +**/coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0102265..ad3c27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Unreleased +# 2.1.0 (2026-08-17) ## Added @@ -98,8 +98,8 @@ This release promotes the package to **1.0.0** and focuses on **injectable analy ### Documentation * README: `FlexTrackClient`, `FlexTrackScope`, inspector section, table of contents. -* **`docs/flex-track-client.md`** — injectable client, Riverpod/Bloc, widget scope behavior. -* **`docs/assets/inspector.gif`** — demo of the inspector with the flagship app. +* **`doc/flex-track-client.md`** — injectable client, Riverpod/Bloc, widget scope behavior. +* **`doc/assets/inspector.gif`** — demo of the inspector with the flagship app. --- diff --git a/README.md b/README.md index 871bdd7..79751ea 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![FlexTrack Banner](docs/assets/banner.png) +![FlexTrack Banner](doc/assets/banner.png) # FlexTrack @@ -37,7 +37,7 @@ Instead of spreading analytics policy throughout the app, define it once and app ## Visual Demo -![Inspector Demo](docs/assets/inspector.gif) +![Inspector Demo](doc/assets/inspector.gif) ## Quick Example @@ -113,7 +113,7 @@ One call site, multiple tracker destinations, centralized policy. ```yaml # pubspec.yaml dependencies: - flex_track: ^2.0.0 + flex_track: ^2.1.0 ``` **Step 2 — implement your tracker** (the package ships no vendor SDKs; you write a thin adapter): @@ -208,8 +208,8 @@ FlexTrack exists to make that architecture explicit, maintainable, and debuggabl - Widget wrappers for click, impression, mount, and route-view tracking The normative behavior shared by the Flutter and Kotlin SDKs is defined in the -[FlexTrack Core MVP specification](docs/core-mvp-specification.md) and verified -with [shared conformance fixtures](docs/conformance.md). +[FlexTrack Core MVP specification](doc/core-mvp-specification.md) and verified +with [shared conformance fixtures](doc/conformance.md). --- @@ -305,7 +305,7 @@ class CheckoutCubit extends Cubit { For **strict** clean architecture, wrap `FlexTrackClient` behind your own `Analytics` interface in the domain module and implement the adapter in infrastructure. -More detail: [docs/flex-track-client.md](docs/flex-track-client.md). +More detail: [doc/flex-track-client.md](doc/flex-track-client.md). --- @@ -1008,7 +1008,7 @@ FlexTrack Inspector (open in browser): http://127.0.0.1:7788 Open that address in a browser to inspect the live event list, tracker status, consent snapshot, and per-event JSON. -![FlexTrack Inspector dashboard with the flagship example app](docs/assets/inspector.gif) +![FlexTrack Inspector dashboard with the flagship example app](doc/assets/inspector.gif) ```dart import 'package:flex_track/flex_track_inspector.dart'; @@ -1078,7 +1078,7 @@ await FlexTrack.setup([ **Global singleton** (existing pattern): use `setupFlexTrackForTesting()` and `FlexTrack.reset()` in `tearDown`. -**Injectable client** (no global): create a `FlexTrackClient` with a `MockTracker`, pass it into your class under test, and call `await client.dispose()` in `tearDown`. See [docs/flex-track-client.md](docs/flex-track-client.md). +**Injectable client** (no global): create a `FlexTrackClient` with a `MockTracker`, pass it into your class under test, and call `await client.dispose()` in `tearDown`. See [doc/flex-track-client.md](doc/flex-track-client.md). ```dart import 'package:flutter_test/flutter_test.dart'; diff --git a/docs/README.md b/doc/README.md similarity index 100% rename from docs/README.md rename to doc/README.md diff --git a/docs/assets/banner.png b/doc/assets/banner.png similarity index 100% rename from docs/assets/banner.png rename to doc/assets/banner.png diff --git a/docs/assets/ft_logo.png b/doc/assets/ft_logo.png similarity index 100% rename from docs/assets/ft_logo.png rename to doc/assets/ft_logo.png diff --git a/docs/assets/inspector.gif b/doc/assets/inspector.gif similarity index 100% rename from docs/assets/inspector.gif rename to doc/assets/inspector.gif diff --git a/docs/conformance.md b/doc/conformance.md similarity index 94% rename from docs/conformance.md rename to doc/conformance.md index 55f9bff..8a8449d 100644 --- a/docs/conformance.md +++ b/doc/conformance.md @@ -1,6 +1,8 @@ # Cross-SDK conformance -The files in [`test/fixtures/conformance/`](../test/fixtures/conformance/) are +The files in +[`test/fixtures/conformance/`](https://github.com/alirezat66/flex_track/tree/main/test/fixtures/conformance) +are the shared executable contract for Flutter and Kotlin Core MVP implementations. ## Version 1.0.0 files @@ -61,4 +63,3 @@ Run the Flutter suite with: ```bash flutter test test/contract ``` - diff --git a/docs/core-mvp-specification.md b/doc/core-mvp-specification.md similarity index 98% rename from docs/core-mvp-specification.md rename to doc/core-mvp-specification.md index 4648e4c..fb509b5 100644 --- a/docs/core-mvp-specification.md +++ b/doc/core-mvp-specification.md @@ -156,7 +156,7 @@ MUST accept. Otherwise choose the first non-empty `userId`, `sessionId`, then `name`; hash its UTF-8 bytes with unsigned 32-bit FNV-1a; calculate `bucket = hash / 4294967296`; accept exactly when `bucket < sampleRate`. Implementations MUST pass -[`sampling_vectors.json`](../test/fixtures/sampling_vectors.json). Locale +[`sampling_vectors.json`](https://github.com/alirezat66/flex_track/blob/main/test/fixtures/sampling_vectors.json). Locale normalization and platform string hashes MUST NOT be used. Example: `hello` hashes to `1335831723`, bucket about `0.3110`; it is rejected diff --git a/docs/flex-track-client.md b/doc/flex-track-client.md similarity index 100% rename from docs/flex-track-client.md rename to doc/flex-track-client.md diff --git a/docs/privacy-performance-debugging.md b/doc/privacy-performance-debugging.md similarity index 100% rename from docs/privacy-performance-debugging.md rename to doc/privacy-performance-debugging.md diff --git a/docs/routing-and-rules.md b/doc/routing-and-rules.md similarity index 100% rename from docs/routing-and-rules.md rename to doc/routing-and-rules.md diff --git a/docs/testing-and-troubleshooting.md b/doc/testing-and-troubleshooting.md similarity index 100% rename from docs/testing-and-troubleshooting.md rename to doc/testing-and-troubleshooting.md diff --git a/docs/trackers.md b/doc/trackers.md similarity index 100% rename from docs/trackers.md rename to doc/trackers.md diff --git a/docs/widgets.md b/doc/widgets.md similarity index 100% rename from docs/widgets.md rename to doc/widgets.md diff --git a/lib/flex_track.dart b/lib/flex_track.dart index d0c116c..59edf8c 100644 --- a/lib/flex_track.dart +++ b/lib/flex_track.dart @@ -27,7 +27,7 @@ /// Use [FlexTrackClient.create] when you want a dedicated instance instead of /// the global [FlexTrack.setup] singleton. Wrap subtrees with [FlexTrackScope] /// so [FlexClickTrack] and related widgets use that client automatically. -/// See `docs/flex-track-client.md`. +/// See `doc/flex-track-client.md`. /// /// ## Advanced Setup /// @@ -143,7 +143,7 @@ export 'src/core/flex_track.dart' show FlexTrack; // ============= VERSION INFO ============= /// FlexTrack package version -const String flexTrackVersion = '2.0.0'; +const String flexTrackVersion = '2.1.0'; /// FlexTrack package description const String flexTrackDescription = diff --git a/pubspec.yaml b/pubspec.yaml index 3bcdf39..417c9ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flex_track description: A flexible analytics tracking system for Flutter with intelligent routing, GDPR compliance, and multi-platform support -version: 2.0.0 +version: 2.1.0 homepage: https://flextrack.taghizadeh.dev/ repository: https://github.com/alirezat66/flex_track issue_tracker: https://github.com/alirezat66/flex_track/issues @@ -15,9 +15,9 @@ topics: screenshots: - description: FlexTrack brand logo - path: docs/assets/ft_logo.png + path: doc/assets/ft_logo.png - description: FlexTrack banner - path: docs/assets/banner.png + path: doc/assets/banner.png environment: sdk: '>=3.0.0 <4.0.0' diff --git a/test/contract/core_mvp_specification_test.dart b/test/contract/core_mvp_specification_test.dart index 12e23c6..f11d21b 100644 --- a/test/contract/core_mvp_specification_test.dart +++ b/test/contract/core_mvp_specification_test.dart @@ -5,7 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('core MVP specification keeps required contract sections', () { final specification = - File('docs/core-mvp-specification.md').readAsStringSync(); + File('doc/core-mvp-specification.md').readAsStringSync(); expect(specification, contains('Specification version: 1.0.0')); expect(specification, contains('## 2. MVP boundary')); From 6302520432963a891de4f95534bbdb6585c31b56 Mon Sep 17 00:00:00 2001 From: Reza Taghizadeh Date: Tue, 18 Aug 2026 00:52:03 +0200 Subject: [PATCH 8/8] Feature/runtime contract offline delivery (#34) * feat: introduce event queue system with in-memory and file-based implementations - Added `EventQueue` interface and `InMemoryEventQueue` for in-memory event handling. - Implemented `FileEventQueue` for durable event storage using file system. - Created `QueuedEvent` and `QueuedEventSnapshot` for event representation and serialization. - Enhanced `FlexTrackClient` and `FlexTrack` to support event queue integration. - Introduced `DisposableTrackerStrategy` for trackers managing disposable resources. - Updated `TrackerRegistry` and `TrackerStrategy` to handle initialization and disposal correctly. - Added comprehensive tests for event delivery, queue operations, and tracker lifecycle management. - Updated pubspec version to 2.2.0. * feat: Enhance offline delivery tracking and inspector features - Added a pre-action script to the Xcode scheme for preparing the Flutter framework. - Updated workspace to include Pods project reference. - Updated pubspec.lock with new dependencies: args, code_assets, hooks, jni, jni_flutter, jni_util, logging, objective_c, package_config, path_provider, path_provider_android, path_provider_foundation, pub_semver, record_use, yaml. - Modified pubspec.yaml to include path_provider dependency. - Implemented unit tests for offline delivery screen functionality. - Updated generated_plugins.cmake to include jni plugin. - Enhanced EventDispatchRecord to track queued events and queue size. - Improved FlexTrackClient to notify debug state and handle event processing results. - Updated inspector dashboard to visualize offline queue status. - Enhanced inspector server to provide queue size in status payload. - Updated inspector event buffer to include queued tracker IDs and queue size in records. - Added tests for inspector event buffer to validate offline queue fields. * feat: Implement offline flush behavior to prevent delivery when offline --- CHANGELOG.md | 20 ++ README.md | 34 ++- doc/runtime-delivery-specification.md | 126 +++++++++++ example/README.md | 11 +- .../flex_track_smoke_test.dart | 58 +++++ example/ios/Flutter/AppFrameworkInfo.plist | 2 - example/ios/Podfile.lock | 13 -- example/ios/Runner.xcodeproj/project.pbxproj | 22 ++ .../xcshareddata/xcschemes/Runner.xcscheme | 18 ++ example/ios/Runner/AppDelegate.swift | 7 +- example/ios/Runner/Info.plist | 29 ++- example/lib/main.dart | 1 + .../lib/runtime/offline_delivery_demo.dart | 126 +++++++++++ example/lib/screens/home_screen.dart | 8 + .../lib/screens/offline_delivery_screen.dart | 210 +++++++++++++++++ example/lib/utils/analytics_setup.dart | 35 ++- example/lib/utils/gdpr_manager.dart | 21 +- example/linux/flutter/generated_plugins.cmake | 1 + example/macos/Podfile | 2 +- example/macos/Podfile.lock | 161 +++++++++++++ .../macos/Runner.xcodeproj/project.pbxproj | 128 ++++++++++- .../xcshareddata/xcschemes/Runner.xcscheme | 18 ++ .../contents.xcworkspacedata | 3 + example/pubspec.lock | 126 ++++++++++- example/pubspec.yaml | 3 +- .../test/offline_delivery_screen_test.dart | 103 +++++++++ .../windows/flutter/generated_plugins.cmake | 1 + lib/flex_track.dart | 7 +- lib/src/core/event_dispatch_record.dart | 8 + lib/src/core/event_processor.dart | 206 ++++++++++++----- lib/src/core/flex_track.dart | 16 +- lib/src/core/flex_track_client.dart | 50 ++++- lib/src/core/tracker_registry.dart | 33 ++- lib/src/inspector/dashboard.dart | 12 +- .../inspector/flex_track_inspector_io.dart | 26 ++- lib/src/inspector/inspector_event_buffer.dart | 8 + lib/src/runtime/event_queue.dart | 163 ++++++++++++++ lib/src/runtime/event_queue_io.dart | 83 +++++++ lib/src/runtime/event_queue_stub.dart | 23 ++ lib/src/runtime/file_event_queue.dart | 1 + lib/src/strategies/tracker_strategy.dart | 5 + pubspec.yaml | 2 +- .../runtime_mvp_conformance_test.dart | 212 ++++++++++++++++++ test/core/tracker_registry_test.dart | 6 +- .../conformance/flutter_runtime_report.json | 21 ++ .../conformance/runtime_mvp.schema.json | 29 +++ .../conformance/runtime_mvp_cases.json | 73 ++++++ .../inspector_event_buffer_test.dart | 30 ++- test/runtime/event_delivery_runtime_test.dart | 183 +++++++++++++++ test/runtime/file_event_queue_test.dart | 87 +++++++ .../tracker_lifecycle_runtime_test.dart | 69 ++++++ 51 files changed, 2501 insertions(+), 139 deletions(-) create mode 100644 doc/runtime-delivery-specification.md create mode 100644 example/lib/runtime/offline_delivery_demo.dart create mode 100644 example/lib/screens/offline_delivery_screen.dart create mode 100644 example/macos/Podfile.lock create mode 100644 example/test/offline_delivery_screen_test.dart create mode 100644 lib/src/runtime/event_queue.dart create mode 100644 lib/src/runtime/event_queue_io.dart create mode 100644 lib/src/runtime/event_queue_stub.dart create mode 100644 lib/src/runtime/file_event_queue.dart create mode 100644 test/contract/runtime_mvp_conformance_test.dart create mode 100644 test/fixtures/conformance/flutter_runtime_report.json create mode 100644 test/fixtures/conformance/runtime_mvp.schema.json create mode 100644 test/fixtures/conformance/runtime_mvp_cases.json create mode 100644 test/runtime/event_delivery_runtime_test.dart create mode 100644 test/runtime/file_event_queue_test.dart create mode 100644 test/runtime/tracker_lifecycle_runtime_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3c27d..7c28b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# 2.2.0 (2026-08-17) + +## Added + +- Added the language-neutral Runtime Delivery Specification 1.0.0, shared JSON + schema, 11 deterministic fixtures, and a machine-readable Flutter report. +- Added injectable `EventQueue`, `InMemoryEventQueue`, and IO-only atomic + `FileEventQueue` implementations. +- Added offline dispatch, durable event snapshots, selective per-tracker retry, + `queuedTrackerIds`, `QueueFlushResult`, and queued-event diagnostics. + +## Changed + +- Tracker destinations are attempted concurrently while result order remains + deterministic and failures stay isolated. +- `flush()` now replays the offline queue before flushing tracker-owned buffers. +- Concurrent flush calls are serialized to prevent duplicate delivery. +- Retry uses the stored processed event and destinations without rerunning + transformers, routing, sampling, or already-successful destinations. + # 2.1.0 (2026-08-17) ## Added diff --git a/README.md b/README.md index 79751ea..a0c4885 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ One call site, multiple tracker destinations, centralized policy. - [Features](#features) - [Examples](#examples) - [FlexTrackClient and dependency injection](#flextrackclient-and-dependency-injection) + - [Offline delivery and selective retry](#offline-delivery-and-selective-retry) - [Riverpod](#riverpod) - [Bloc / Cubit](#bloc--cubit) - [Design philosophy](#design-philosophy) @@ -113,7 +114,7 @@ One call site, multiple tracker destinations, centralized policy. ```yaml # pubspec.yaml dependencies: - flex_track: ^2.1.0 + flex_track: ^2.2.0 ``` **Step 2 — implement your tracker** (the package ships no vendor SDKs; you write a thin adapter): @@ -305,6 +306,37 @@ class CheckoutCubit extends Cubit { For **strict** clean architecture, wrap `FlexTrackClient` behind your own `Analytics` interface in the domain module and implement the adapter in infrastructure. +## Offline delivery and selective retry + +Provide an application-owned queue file and connectivity signal when creating +the client. The file queue uses atomic replacement and restores the processed +event with its original ID and UTC timestamp after an app restart. + +```dart +var online = true; +final client = await FlexTrackClient.create( + [FirebaseTracker(), InternalApiTracker()], + queue: FileEventQueue(File('/app-private/flextrack-queue.json')), + onlineProvider: () => online, +); + +final result = await client.track(PurchaseEvent()); +print(result.queuedTrackerIds); // only destinations still awaiting delivery + +final flush = await client.flush(limit: 100); +print(flush.remainingEvents); +``` + +When offline, no tracker is called and every routed destination is queued. When +one tracker fails, successful trackers are never retried. Flush operations are +FIFO, bounded, and serialized; retries use the stored processed event without +rerunning transformers, routing, consent, or sampling. `FileEventQueue` is +available on `dart:io` platforms. Web clients should inject a platform-specific +`EventQueue` or use `InMemoryEventQueue`. + +The normative cross-SDK behavior is documented in +[Runtime Delivery Specification 1.0.0](doc/runtime-delivery-specification.md). + More detail: [doc/flex-track-client.md](doc/flex-track-client.md). --- diff --git a/doc/runtime-delivery-specification.md b/doc/runtime-delivery-specification.md new file mode 100644 index 0000000..e68ccec --- /dev/null +++ b/doc/runtime-delivery-specification.md @@ -0,0 +1,126 @@ +# FlexTrack Runtime Delivery Specification 1.0.0 + +Status: normative, language-neutral contract for Flutter, Kotlin, Swift, and +TypeScript implementations. RFC 2119 terms MUST, SHOULD, and MAY are normative. + +## 1. Scope + +Core Specification 1.0 decides which destinations should receive an event. +Runtime Delivery 1.0 defines tracker lifecycle, dispatch attempts, durable +queuing, replay, concurrency, failure isolation, and observable results. It does +not define vendor payload formats, network reachability detection, encryption at +rest, exponential backoff scheduling, or server acknowledgement protocols. + +## 2. Terms and identity + +- An event occurrence is identified by its non-empty `eventId`. +- A destination is identified by a non-empty tracker ID. +- A queued item is identified by the event occurrence ID and contains an + ordered, duplicate-free list of destinations still awaiting delivery. +- An attempt is one invocation of one tracker for one event occurrence. +- A dispatch is the initial attempt set produced by one `track` operation. +- A flush is one bounded FIFO replay pass over queued items. + +Event identity and timestamp MUST survive transformation, queue serialization, +process restart, and every retry. A retry MUST NOT create a new occurrence. + +## 3. Lifecycle + +Tracker initialization MUST be idempotent per client lifecycle. A client MUST +attempt every registered tracker even if another tracker fails initialization, +and MUST expose initialization failure. Initialization is transactional: after +any failure the client remains uninitialized, successfully initialized peers +are disposed, and a later initialize call MUST attempt every tracker again. +Tracking before successful client +initialization is SDK-defined for Core 1 compatibility; new APIs SHOULD reject +it. Disposal MUST be idempotent, flush tracker-owned buffers, and release +client-owned streams/resources. Queue persistence MUST survive disposal. + +## 4. Initial dispatch + +The runtime MUST transform and route an event exactly once. When online, all +resolved destinations MUST be attempted independently. One synchronous or +asynchronous tracker failure MUST NOT cancel or prevent other attempts. Results +MUST preserve routing destination order even when attempts execute concurrently. + +When offline, no tracker may be invoked. If routing produced destinations, the +processed event and every destination MUST be queued. An event rejected before +routing completion (disabled processor, consent, sampling, or no target) MUST +NOT be queued. + +## 5. Partial success and queue admission + +After an online dispatch, only unsuccessful destinations MUST be queued. +Successful destinations MUST never be included in that queued item. Queue +admission is idempotent by event ID: enqueueing an already-present occurrence +MUST NOT duplicate or reorder it. Callers MUST use unique event IDs for distinct +occurrences. + +## 6. Queue model + +The queue MUST provide FIFO read, enqueue, replace, remove, size, and clear. +Reads MUST require a positive limit and MUST NOT mutate state. Replace and remove +of an absent ID MUST be no-ops. Returned collections MUST not permit callers to +mutate queue state. A durable implementation MUST serialize all mutating +operations and use atomic file replacement or equivalent transactional storage. + +Queued properties MUST be JSON-compatible: null, boolean, finite number, +string, list, and string-keyed map composed recursively. Unsupported values MUST +fail queue persistence visibly; they MUST NOT be stringified or silently lost. +Malformed persisted data MUST raise a corruption/format error and MUST NOT be +silently discarded or partially replayed. + +## 7. Flush and selective retry + +A flush MUST process at most `limit` queued items in FIFO order. A non-positive +limit MUST fail before reading or mutating the queue. An offline flush MUST be a +no-op with zero attempted and delivered events. + +Queued events MUST NOT be transformed or routed again. The stored processed +event and pending destinations are authoritative. Every pending destination is +attempted independently. If all succeed, remove the item. Otherwise replace it +in the same FIFO position with only failed destination IDs and increment +`attempts` exactly once for that flush pass. Successful destinations MUST NOT be +retried by a later flush. + +Items added after a flush begins are outside that pass. Concurrent queue +mutations MUST be serialized. Concurrent flush calls MUST be serialized so one +occurrence/destination cannot be attempted twice concurrently. + +## 8. Results + +Initial dispatch results MUST expose the processed event, routing result, +ordered successful and failed per-tracker outcomes, and queued destination IDs. +Overall success means at least one tracker succeeded; queued-only is not success. + +Flush results MUST expose: + +- `attemptedEvents`: queued items selected at the beginning of the pass; +- `deliveredEvents`: selected items removed because every pending destination + succeeded; +- `remainingEvents`: total queue size after the pass. + +## 9. Error and cancellation semantics + +Tracker errors are data and MUST be isolated into per-tracker results. Runtime, +programmer, queue corruption, serialization, and cancellation errors MUST remain +errors; they MUST NOT be mislabeled as tracker failures. Cancellation MUST stop +the caller's operation and MUST NOT enqueue an artificial failure. SDKs without +structured cancellation MUST document their closest equivalent. + +## 10. Privacy + +Core consent is evaluated before initial queue admission. Runtime 1.0 preserves +that routing decision during replay to guarantee deterministic delivery and to +avoid policy drift. Applications requiring revocation to purge queued data MUST +call queue `clear`; SDKs SHOULD provide a higher-level purge helper. Runtime 1.1 +will define consent-revocation retention policy explicitly. + +## 11. Conformance + +Implementations claiming Runtime 1.0 MUST pass `runtime_mvp_cases.json`, validate +its envelope against `runtime_mvp.schema.json`, and publish a machine-readable +report containing implementation, spec version, fixture version, pass/fail +counts, and case IDs. Platform-specific persistence tests MUST additionally +cover restart recovery, atomic replacement, malformed JSON, invalid event +shape, concurrent mutations, and unsupported property values. diff --git a/example/README.md b/example/README.md index 7e7fed5..37db4b8 100644 --- a/example/README.md +++ b/example/README.md @@ -1,6 +1,6 @@ # flex_track flagship example -This app demonstrates **routing**, **consent**, **multiple trackers** (implemented as **mocks** in this repo—no real Firebase/Mixpanel/Amplitude keys required), and **widget wrappers** (`FlexClickTrack`, `FlexMountTrack`, `FlexTrackRouteObserver` + `FlexTrackRouteViewMixin` on the home shell). +This app demonstrates **routing**, **consent**, **multiple trackers** (implemented as **mocks** in this repo—no real Firebase/Mixpanel/Amplitude keys required), **offline delivery with selective retry**, and **widget wrappers** (`FlexClickTrack`, `FlexMountTrack`, `FlexTrackRouteObserver` + `FlexTrackRouteViewMixin` on the home shell). ## Run locally @@ -16,6 +16,15 @@ On first launch you will see a **privacy / consent** dialog (backed by an in-mem In **debug** mode on mobile or desktop, the app starts the **FlexTrack Inspector** (local HTTP dashboard). Watch the console for `FlexTrack Inspector (open in browser): http://127.0.0.1:7788` and open that URL to see live events and tracker state. (Not available on Flutter Web.) +Open the **Delivery** tab to force the demo offline, queue events, simulate one +failing destination, and flush again. The screen and Inspector both show which +destinations succeeded, which were retained for retry, and the pending count. +The queue is stored under the platform application-support directory. Pending +events survive a full process restart and are replayed automatically on the +next online launch. The Lab's simulated network state is also persisted: an +app closed while Offline starts Offline and retains its queue until you switch +it Online and flush. Consent choices use the platform SharedPreferences store. + ## Integration test Integration tests must target **one** device. If several devices are available (e.g. Linux desktop and Chrome), pick one explicitly: diff --git a/example/integration_test/flex_track_smoke_test.dart b/example/integration_test/flex_track_smoke_test.dart index cff36d5..5f6eb0f 100644 --- a/example/integration_test/flex_track_smoke_test.dart +++ b/example/integration_test/flex_track_smoke_test.dart @@ -1,16 +1,51 @@ +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; import 'package:flex_track_example/main.dart' as app; +import 'package:flex_track_example/runtime/offline_delivery_demo.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:path_provider/path_provider.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('flagship example starts after consent flow', (tester) async { + SharedPreferences.setMockInitialValues({ + 'flex_track_demo_network_available': true, + }); + final supportDirectory = await getApplicationSupportDirectory(); + final queueFile = + File('${supportDirectory.path}/flex_track/event_queue.json'); + if (await queueFile.exists()) await queueFile.delete(); + final persistedQueue = FileEventQueue(queueFile); + final restoredEvent = OfflineDeliveryDemoEvent(sequence: 0); + await persistedQueue.enqueue(QueuedEvent( + event: restoredEvent, + trackerIds: const [ + 'demo_delivery_success', + 'demo_delivery_retry', + ], + )); + await app.main(); await tester.pump(); await tester.pump(const Duration(seconds: 1)); await tester.pumpAndSettle(const Duration(seconds: 5)); + expect(await FlexTrack.queuedEventCount, 0, + reason: 'the event persisted before startup must be replayed'); + expect( + OfflineDeliveryDemo.instance.successTracker.deliveredEventIds, + contains(restoredEvent.eventId), + ); + expect( + OfflineDeliveryDemo.instance.retryTracker.deliveredEventIds, + contains(restoredEvent.eventId), + ); + await tester.tap(find.text('Accept All')); await tester.pumpAndSettle(); @@ -19,5 +54,28 @@ void main() { expect(find.text('FlexTrack demo'), findsOneWidget); expect(find.textContaining('Widget wrappers'), findsOneWidget); + + await tester.tap(find.text('Delivery')); + await tester.pumpAndSettle(); + expect(find.text('Offline Delivery Lab'), findsOneWidget); + + await tester.tap(find.byKey(const Key('network-toggle'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('track-delivery-event'))); + await tester.pumpAndSettle(); + expect(find.text('Pending events: 1'), findsOneWidget); + + await tester.tap(find.byKey(const Key('network-toggle'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('flush-delivery-queue'))); + await tester.pumpAndSettle(); + expect(find.text('Pending events: 0'), findsOneWidget); + await tester.scrollUntilVisible( + find.byKey(const Key('flush-result')), + 200, + scrollable: find.byType(Scrollable), + ); + expect(find.textContaining('1 attempted · 1 delivered · 0 remaining'), + findsOneWidget); }); } diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 681a9d0..7d69230 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -100,8 +100,6 @@ PODS: - GoogleUtilities/UserDefaults (7.13.3): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - integration_test (0.0.1): - - Flutter - Mixpanel-swift (4.3.0): - Mixpanel-swift/Complete (= 4.3.0) - Mixpanel-swift/Complete (4.3.0) @@ -114,18 +112,13 @@ PODS: - nanopb/decode (2.30910.0) - nanopb/encode (2.30910.0) - PromisesObjC (2.4.0) - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS DEPENDENCIES: - amplitude_flutter (from `.symlinks/plugins/amplitude_flutter/darwin`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - Flutter (from `Flutter`) - - integration_test (from `.symlinks/plugins/integration_test/ios`) - mixpanel_flutter (from `.symlinks/plugins/mixpanel_flutter/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) SPEC REPOS: trunk: @@ -152,12 +145,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/firebase_core/ios" Flutter: :path: Flutter - integration_test: - :path: ".symlinks/plugins/integration_test/ios" mixpanel_flutter: :path: ".symlinks/plugins/mixpanel_flutter/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: amplitude_flutter: fd9bf76a1885fe760055877777da18ca4d087088 @@ -174,12 +163,10 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 GoogleAppMeasurement: 9abf64b682732fed36da827aa2a68f0221fd2356 GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 - integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 Mixpanel-swift: 2192b9a24cf41b870749e4e0e10fbc9822bb2b4a mixpanel_flutter: 950da37cf4deb5e346c3f568de41e6d06a4067ad nanopb: 438bc412db1928dac798aa6fd75726007be04262 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 PODFILE CHECKSUM: 251cb053df7158f337c0712f2ab29f4e0fa474ce diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 9e040ad..b91471c 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; EFEDDCE1595DDB2F63E3D061 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8ED52A12575922D0A394C21D /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -65,6 +66,7 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; F49B1FD76CA1DE6DAAF940FA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; F518F9F194CBD609D7E0989F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -72,6 +74,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 01220E6154BA0B2E522EECAE /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -121,6 +124,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -188,6 +192,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -213,6 +220,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -726,6 +736,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index f13e5d2..e63b794 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/example/lib/main.dart b/example/lib/main.dart index ec7315a..673a354 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -15,6 +15,7 @@ Future main() async { await AnalyticsSetup.initialize(); await GDPRManager.initialize(); + await AnalyticsSetup.flushRecoveredEvents(); runApp(const MyApp()); } diff --git a/example/lib/runtime/offline_delivery_demo.dart b/example/lib/runtime/offline_delivery_demo.dart new file mode 100644 index 0000000..f9721cc --- /dev/null +++ b/example/lib/runtime/offline_delivery_demo.dart @@ -0,0 +1,126 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Mutable, deterministic network and tracker failure controls used only by +/// the example application's Offline Delivery Lab. +class OfflineDeliveryDemo extends ChangeNotifier { + OfflineDeliveryDemo(); + + static final OfflineDeliveryDemo instance = OfflineDeliveryDemo(); + static const _networkAvailableKey = 'flex_track_demo_network_available'; + + bool _isOnline = true; + bool _retryTrackerFails = false; + int _queueSize = 0; + EventProcessingResult? _lastDispatch; + QueueFlushResult? _lastFlush; + SharedPreferences? _preferences; + + final DemoDeliveryTracker successTracker = DemoDeliveryTracker( + id: 'demo_delivery_success', + name: 'Demo reliable destination', + ); + late final DemoDeliveryTracker retryTracker = DemoDeliveryTracker( + id: 'demo_delivery_retry', + name: 'Demo retry destination', + shouldFail: () => _retryTrackerFails, + ); + + bool get isOnline => _isOnline; + bool get retryTrackerFails => _retryTrackerFails; + int get queueSize => _queueSize; + EventProcessingResult? get lastDispatch => _lastDispatch; + QueueFlushResult? get lastFlush => _lastFlush; + + bool get onlineProvider => _isOnline; + + /// Restores the simulated connectivity before FlexTrack starts. This makes + /// process-restart behavior deterministic for the Delivery Lab. + Future initialize() async { + _preferences ??= await SharedPreferences.getInstance(); + _isOnline = _preferences!.getBool(_networkAvailableKey) ?? true; + } + + Future setOnline(bool value) async { + if (_isOnline == value) return; + _isOnline = value; + notifyListeners(); + _preferences ??= await SharedPreferences.getInstance(); + await _preferences!.setBool(_networkAvailableKey, value); + } + + void setRetryTrackerFails(bool value) { + if (_retryTrackerFails == value) return; + _retryTrackerFails = value; + notifyListeners(); + } + + Future track() async { + _lastDispatch = await FlexTrack.track( + OfflineDeliveryDemoEvent( + sequence: successTracker.attemptCount + retryTracker.attemptCount + 1, + ), + ); + await refreshQueueSize(); + return _lastDispatch!; + } + + Future flush() async { + _lastFlush = await FlexTrack.flush(); + await refreshQueueSize(); + return _lastFlush!; + } + + Future refreshQueueSize() async { + _queueSize = await FlexTrack.queuedEventCount; + notifyListeners(); + } +} + +class OfflineDeliveryDemoEvent extends BaseEvent { + OfflineDeliveryDemoEvent({required this.sequence}); + + final int sequence; + + @override + String get name => 'demo_offline_delivery'; + + @override + Map get properties => { + 'sequence': sequence, + 'source': 'offline_delivery_lab', + }; + + @override + bool get requiresConsent => false; +} + +class DemoDeliveryTracker extends BaseTrackerStrategy { + DemoDeliveryTracker({ + required super.id, + required super.name, + this.shouldFail, + }); + + final bool Function()? shouldFail; + int attemptCount = 0; + int successCount = 0; + final List deliveredEventIds = []; + + @override + Future doInitialize() async {} + + @override + Future doTrack(BaseEvent event) async { + // These trackers are registered globally so broad demo rules can resolve + // to them. Keep Lab metrics and intentional failures scoped to its event. + if (event.name != 'demo_offline_delivery') return; + attemptCount++; + if (shouldFail?.call() ?? false) { + throw StateError('Intentional demo failure from $id'); + } + successCount++; + deliveredEventIds.add(event.eventId); + } +} diff --git a/example/lib/screens/home_screen.dart b/example/lib/screens/home_screen.dart index d8d30f0..9b9e18b 100644 --- a/example/lib/screens/home_screen.dart +++ b/example/lib/screens/home_screen.dart @@ -9,6 +9,7 @@ import '../events/user_events.dart'; import '../utils/gdpr_manager.dart'; import 'ecommerce_screen.dart'; import 'event_enrichment_screen.dart'; +import 'offline_delivery_screen.dart'; import 'setting_screen.dart'; import 'user_journey_screen.dart'; @@ -37,6 +38,7 @@ class HomeScreenState extends State with FlexTrackRouteViewMixin { const UserJourneyScreen(), const SettingsScreen(), const EventEnrichmentScreen(), + const OfflineDeliveryScreen(), ]; @override @@ -105,6 +107,10 @@ class HomeScreenState extends State with FlexTrackRouteViewMixin { icon: Icon(Icons.auto_awesome), label: 'Enrichment', ), + BottomNavigationBarItem( + icon: Icon(Icons.cloud_queue), + label: 'Delivery', + ), ], ), ); @@ -122,6 +128,8 @@ class HomeScreenState extends State with FlexTrackRouteViewMixin { return 'Settings'; case 4: return 'Enrichment'; + case 5: + return 'Delivery'; default: return 'Unknown'; } diff --git a/example/lib/screens/offline_delivery_screen.dart b/example/lib/screens/offline_delivery_screen.dart new file mode 100644 index 0000000..a7d48da --- /dev/null +++ b/example/lib/screens/offline_delivery_screen.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; + +import '../runtime/offline_delivery_demo.dart'; + +class OfflineDeliveryScreen extends StatefulWidget { + const OfflineDeliveryScreen({ + super.key, + this.demo, + }); + + final OfflineDeliveryDemo? demo; + + @override + State createState() => _OfflineDeliveryScreenState(); +} + +class _OfflineDeliveryScreenState extends State { + late final OfflineDeliveryDemo _demo = + widget.demo ?? OfflineDeliveryDemo.instance; + bool _busy = false; + + @override + void initState() { + super.initState(); + _demo.addListener(_refresh); + _demo.refreshQueueSize(); + } + + @override + void dispose() { + _demo.removeListener(_refresh); + super.dispose(); + } + + void _refresh() { + if (mounted) setState(() {}); + } + + Future _run(Future Function() action) async { + setState(() => _busy = true); + try { + await action(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final dispatch = _demo.lastDispatch; + final delivered = dispatch?.trackingResults + .where((result) => result.successful) + .map((result) => result.trackerId) + .toList() ?? + const []; + final failed = dispatch?.trackingResults + .where((result) => !result.successful) + .map((result) => result.trackerId) + .toList() ?? + const []; + + return ListView( + key: const Key('offline-delivery-lab'), + padding: const EdgeInsets.all(16), + children: [ + Text('Offline Delivery Lab', + style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + const Text( + 'Control connectivity and one failing destination, then inspect ' + 'queueing and selective retry here or in FlexTrack Inspector.', + ), + const SizedBox(height: 16), + Card( + child: Column( + children: [ + SwitchListTile( + key: const Key('network-toggle'), + title: const Text('Network available'), + subtitle: Text(_demo.isOnline ? 'Online' : 'Offline'), + value: _demo.isOnline, + onChanged: _busy + ? null + : (value) => _run(() => _demo.setOnline(value)), + ), + SwitchListTile( + key: const Key('failure-toggle'), + title: const Text('Retry destination succeeds'), + subtitle: Text(_demo.retryTrackerFails + ? 'Intentional failure enabled' + : 'Healthy'), + value: !_demo.retryTrackerFails, + onChanged: _busy + ? null + : (value) => _demo.setRetryTrackerFails(!value), + ), + ], + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Pending events: ${_demo.queueSize}', + key: const Key('queue-count'), + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Text('Reliable destination: ' + '${_demo.successTracker.successCount} delivered / ' + '${_demo.successTracker.attemptCount} attempts'), + Text('Retry destination: ' + '${_demo.retryTracker.successCount} delivered / ' + '${_demo.retryTracker.attemptCount} attempts'), + ], + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton.icon( + key: const Key('track-delivery-event'), + onPressed: _busy + ? null + : () => _run(() async { + await _demo.track(); + }), + icon: const Icon(Icons.send), + label: const Text('Track event'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + key: const Key('flush-delivery-queue'), + onPressed: _busy + ? null + : () => _run(() async { + await _demo.flush(); + }), + icon: const Icon(Icons.sync), + label: const Text('Flush queue'), + ), + ), + ], + ), + if (_busy) ...[ + const SizedBox(height: 12), + const LinearProgressIndicator(), + ], + if (dispatch != null) ...[ + const SizedBox(height: 16), + _ResultCard( + title: 'Last dispatch', + lines: { + 'Delivered': delivered, + 'Failed': failed, + 'Queued': dispatch.queuedTrackerIds, + }, + ), + ], + if (_demo.lastFlush case final flush?) ...[ + const SizedBox(height: 12), + Card( + key: const Key('flush-result'), + child: ListTile( + title: const Text('Last flush'), + subtitle: Text( + '${flush.attemptedEvents} attempted · ' + '${flush.deliveredEvents} delivered · ' + '${flush.remainingEvents} remaining', + ), + ), + ), + ], + ], + ); + } +} + +class _ResultCard extends StatelessWidget { + const _ResultCard({required this.title, required this.lines}); + + final String title; + final Map> lines; + + @override + Widget build(BuildContext context) { + return Card( + key: const Key('dispatch-result'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + for (final entry in lines.entries) + Text('${entry.key}: ' + '${entry.value.isEmpty ? 'none' : entry.value.join(', ')}'), + ], + ), + ), + ); + } +} diff --git a/example/lib/utils/analytics_setup.dart b/example/lib/utils/analytics_setup.dart index d7835f0..0ef56a7 100644 --- a/example/lib/utils/analytics_setup.dart +++ b/example/lib/utils/analytics_setup.dart @@ -1,21 +1,33 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flex_track/flex_track.dart'; import 'package:flex_track/flex_track_inspector.dart'; +import 'package:path_provider/path_provider.dart'; import '../trackers/firebase_tracker.dart'; import '../trackers/mixpanel_tracker.dart'; import '../trackers/amplitude_tracker.dart'; import '../trackers/custom_api_tracker.dart'; import '../events/app_events.dart'; +import '../runtime/offline_delivery_demo.dart'; class AnalyticsSetup { static Future initialize() async { // Create trackers final trackers = await _createTrackers(); + final deliveryDemo = OfflineDeliveryDemo.instance; + await deliveryDemo.initialize(); + final supportDirectory = await getApplicationSupportDirectory(); + final queue = FileEventQueue( + File('${supportDirectory.path}/flex_track/event_queue.json'), + ); // Set up FlexTrack with advanced routing await FlexTrack.setupWithRouting(trackers, (builder) { return _configureRouting(builder); - }); + }, queue: queue, onlineProvider: () => deliveryDemo.onlineProvider); + + await deliveryDemo.refreshQueueSize(); // Track app startup await FlexTrack.track(AppStartEvent()); @@ -28,6 +40,15 @@ class AnalyticsSetup { } } + /// Replays events restored from disk after consent has been loaded. + /// If the demo is still offline, [FlexTrack.flush] is a safe no-op and the + /// durable queue remains intact for a later manual or startup retry. + static Future flushRecoveredEvents() async { + final result = await FlexTrack.flush(); + await OfflineDeliveryDemo.instance.refreshQueueSize(); + return result; + } + static Future> _createTrackers() async { final trackers = []; @@ -37,6 +58,9 @@ class AnalyticsSetup { showTimestamps: true, colorOutput: true, )); + trackers + ..add(OfflineDeliveryDemo.instance.successTracker) + ..add(OfflineDeliveryDemo.instance.retryTracker); // Add production trackers based on environment if (!kDebugMode) { @@ -86,6 +110,15 @@ class AnalyticsSetup { // Apply GDPR defaults first (highest priority) GDPRDefaults.apply(builder, compliantTrackers: ['firebase', 'custom_api']); + builder + .routeExact('demo_offline_delivery') + .to(['demo_delivery_success', 'demo_delivery_retry']) + .skipConsent() + .noSampling() + .withPriority(100) + .withDescription('Offline Delivery Lab destinations') + .and(); + // Example: same app, different destinations — explicit tracker lists builder .routeExact('demo_free_tier_only') diff --git a/example/lib/utils/gdpr_manager.dart b/example/lib/utils/gdpr_manager.dart index eb387d7..101e6e5 100644 --- a/example/lib/utils/gdpr_manager.dart +++ b/example/lib/utils/gdpr_manager.dart @@ -1,33 +1,18 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flex_track/flex_track.dart'; - -/// Mock SharedPreferences since we don't want to add dependencies -class MockSharedPreferences { - static final Map _storage = {}; - - String? getString(String key) => _storage[key]; - - Future setString(String key, String value) async { - _storage[key] = value; - return true; - } - - static Future getInstance() async { - return MockSharedPreferences(); - } -} +import 'package:shared_preferences/shared_preferences.dart'; class GDPRManager { static const String _consentKey = 'gdpr_consent'; static const String _consentVersionKey = 'gdpr_consent_version'; static const String _currentConsentVersion = '2.0'; - static late MockSharedPreferences _prefs; + static late SharedPreferences _prefs; static ConsentStatus? _currentConsent; static Future initialize() async { - _prefs = await MockSharedPreferences.getInstance(); + _prefs = await SharedPreferences.getInstance(); await _loadConsent(); _applyConsentToFlexTrack(); } diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake index 2e1de87..be1ee3e 100644 --- a/example/linux/flutter/generated_plugins.cmake +++ b/example/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/example/macos/Podfile b/example/macos/Podfile index 29c8eb3..ff5ddb3 100644 --- a/example/macos/Podfile +++ b/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock new file mode 100644 index 0000000..44e16ee --- /dev/null +++ b/example/macos/Podfile.lock @@ -0,0 +1,161 @@ +PODS: + - amplitude_flutter (0.0.1): + - AmplitudeSwift (~> 1.11) + - Flutter + - FlutterMacOS + - AmplitudeCore (1.4.6) + - AmplitudeSwift (1.18.5): + - AmplitudeCore (< 2.0.0, >= 1.4.6) + - AnalyticsConnector (~> 1.3.0) + - AnalyticsConnector (1.3.1) + - Firebase/Analytics (10.25.0): + - Firebase/Core + - Firebase/Core (10.25.0): + - Firebase/CoreOnly + - FirebaseAnalytics (~> 10.25.0) + - Firebase/CoreOnly (10.25.0): + - FirebaseCore (= 10.25.0) + - firebase_analytics (10.10.7): + - Firebase/Analytics (= 10.25.0) + - firebase_core + - FlutterMacOS + - firebase_core (2.32.0): + - Firebase/CoreOnly (~> 10.25.0) + - FlutterMacOS + - FirebaseAnalytics (10.25.0): + - FirebaseAnalytics/AdIdSupport (= 10.25.0) + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - FirebaseAnalytics/AdIdSupport (10.25.0): + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleAppMeasurement (= 10.25.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - FirebaseCore (10.25.0): + - FirebaseCoreInternal (~> 10.0) + - GoogleUtilities/Environment (~> 7.12) + - GoogleUtilities/Logger (~> 7.12) + - FirebaseCoreInternal (10.29.0): + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseInstallations (10.29.0): + - FirebaseCore (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) + - PromisesObjC (~> 2.1) + - FlutterMacOS (1.0.0) + - GoogleAppMeasurement (10.25.0): + - GoogleAppMeasurement/AdIdSupport (= 10.25.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleAppMeasurement/AdIdSupport (10.25.0): + - GoogleAppMeasurement/WithoutAdIdSupport (= 10.25.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleAppMeasurement/WithoutAdIdSupport (10.25.0): + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleUtilities/AppDelegateSwizzler (7.13.3): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (7.13.3): + - GoogleUtilities/Privacy + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.13.3): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/Network (7.13.3): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (7.13.3)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (7.13.3) + - GoogleUtilities/Reachability (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - nanopb (2.30910.0): + - nanopb/decode (= 2.30910.0) + - nanopb/encode (= 2.30910.0) + - nanopb/decode (2.30910.0) + - nanopb/encode (2.30910.0) + - PromisesObjC (2.4.1) + +DEPENDENCIES: + - amplitude_flutter (from `Flutter/ephemeral/.symlinks/plugins/amplitude_flutter/darwin`) + - firebase_analytics (from `Flutter/ephemeral/.symlinks/plugins/firebase_analytics/macos`) + - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + +SPEC REPOS: + trunk: + - AmplitudeCore + - AmplitudeSwift + - AnalyticsConnector + - Firebase + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - GoogleAppMeasurement + - GoogleUtilities + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + amplitude_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/amplitude_flutter/darwin + firebase_analytics: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_analytics/macos + firebase_core: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos + FlutterMacOS: + :path: Flutter/ephemeral + +SPEC CHECKSUMS: + amplitude_flutter: fd9bf76a1885fe760055877777da18ca4d087088 + AmplitudeCore: e384984131ec515b92396801ab65b53e8fd73fa7 + AmplitudeSwift: 5a51527b615163c0dbb7bfeb5cb8ced2e7c1a906 + AnalyticsConnector: 3def11199b4ddcad7202c778bde982ec5da0ebb3 + Firebase: 0312a2352584f782ea56f66d91606891d4607f06 + firebase_analytics: 47e1f6453c222417b323ca53b1793f1a9ef395f6 + firebase_core: b5b8b60dad71f93132bbaa21e8d1379367d824f0 + FirebaseAnalytics: ec00fe8b93b41dc6fe4a28784b8e51da0647a248 + FirebaseCore: 7ec4d0484817f12c3373955bc87762d96842d483 + FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934 + FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + GoogleAppMeasurement: 9abf64b682732fed36da827aa2a68f0221fd2356 + GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 + nanopb: 438bc412db1928dac798aa6fd75726007be04262 + PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index a282ca7..505207f 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -21,12 +21,15 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 2E0B6255401500F469C320DF /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A8504EB24CA57C17CA6E7B3 /* Pods_Runner.framework */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 743B8247EA1394F746368817 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79A50F70AA12D19D61366B1C /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +63,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 28D3F100515D27A763601B4D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* flex_track_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "flex_track_example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* flex_track_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flex_track_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +80,16 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 37572643629FBA2AD4320515 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 37C5B34FA1F4F56D9DDC1A79 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 6617E97E59646DB7B441D92A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 79A50F70AA12D19D61366B1C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9A8504EB24CA57C17CA6E7B3 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D4B6747C3157B51DCB057A55 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + EADBFED99CE867507CBBE26C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +97,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 743B8247EA1394F746368817 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +105,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 2E0B6255401500F469C320DF /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +140,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + E01E037B6B8CE3648AD33D78 /* Pods */, ); sourceTree = ""; }; @@ -151,6 +167,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -175,10 +192,26 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 9A8504EB24CA57C17CA6E7B3 /* Pods_Runner.framework */, + 79A50F70AA12D19D61366B1C /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; }; + E01E037B6B8CE3648AD33D78 /* Pods */ = { + isa = PBXGroup; + children = ( + D4B6747C3157B51DCB057A55 /* Pods-Runner.debug.xcconfig */, + 37C5B34FA1F4F56D9DDC1A79 /* Pods-Runner.release.xcconfig */, + 37572643629FBA2AD4320515 /* Pods-Runner.profile.xcconfig */, + EADBFED99CE867507CBBE26C /* Pods-RunnerTests.debug.xcconfig */, + 28D3F100515D27A763601B4D /* Pods-RunnerTests.release.xcconfig */, + 6617E97E59646DB7B441D92A /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -186,6 +219,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 99F61EE377A06D393C727B2E /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +238,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 1B9B4EC8E3458C543F910C00 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 9A49F2C0A1FD0482EA46EA16 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -216,6 +252,9 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* flex_track_example.app */; productType = "com.apple.product-type.application"; @@ -260,6 +299,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -291,6 +333,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 1B9B4EC8E3458C543F910C00 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -329,6 +393,45 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 99F61EE377A06D393C727B2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9A49F2C0A1FD0482EA46EA16 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +483,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = EADBFED99CE867507CBBE26C /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +498,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 28D3F100515D27A763601B4D /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +513,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 6617E97E59646DB7B441D92A /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -461,7 +567,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -543,7 +649,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -593,7 +699,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -700,6 +806,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 2ed52d4..1a833b2 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + + + diff --git a/example/pubspec.lock b/example/pubspec.lock index 817f11f..d22bfb3 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -49,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -151,7 +167,7 @@ packages: path: ".." relative: true source: path - version: "1.0.1" + version: "2.2.0" flutter: dependency: "direct main" description: flutter @@ -185,6 +201,14 @@ packages: description: flutter source: sdk version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http_methods: dependency: transitive description: @@ -206,6 +230,30 @@ packages: description: flutter source: sdk version: "0.0.0" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" js: dependency: transitive description: @@ -246,6 +294,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -278,6 +334,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.4" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -286,6 +358,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -334,6 +430,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" shared_preferences: dependency: "direct main" description: @@ -539,6 +651,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.10.0-0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.4" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index db3279b..c03b3fc 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: mixpanel_flutter: ^2.3.4 cupertino_icons: ^1.0.2 shared_preferences: ^2.5.3 + path_provider: ^2.1.5 amplitude_flutter: ^4.3.1 dev_dependencies: @@ -29,4 +30,4 @@ dev_dependencies: sdk: flutter flutter: - uses-material-design: true \ No newline at end of file + uses-material-design: true diff --git a/example/test/offline_delivery_screen_test.dart b/example/test/offline_delivery_screen_test.dart new file mode 100644 index 0000000..c494a3c --- /dev/null +++ b/example/test/offline_delivery_screen_test.dart @@ -0,0 +1,103 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flex_track_example/runtime/offline_delivery_demo.dart'; +import 'package:flex_track_example/screens/offline_delivery_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late OfflineDeliveryDemo demo; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await FlexTrack.reset(); + demo = OfflineDeliveryDemo(); + await demo.initialize(); + await FlexTrack.setupWithRouting( + [demo.successTracker, demo.retryTracker], + (builder) => builder + .routeExact('demo_offline_delivery') + .to(['demo_delivery_success', 'demo_delivery_retry']) + .skipConsent() + .noSampling() + .withPriority(100) + .and(), + onlineProvider: () => demo.onlineProvider, + ); + }); + + tearDown(() => FlexTrack.reset()); + + Future pumpLab(WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: OfflineDeliveryScreen(demo: demo)), + )); + await tester.pumpAndSettle(); + } + + test('restores simulated network state across controller instances', + () async { + await demo.setOnline(false); + + final restored = OfflineDeliveryDemo(); + await restored.initialize(); + + expect(restored.isOnline, isFalse); + restored.dispose(); + }); + + testWidgets('visualizes offline enqueue and successful flush', + (tester) async { + await demo.setOnline(false); + await pumpLab(tester); + + await tester.tap(find.byKey(const Key('track-delivery-event'))); + await tester.pumpAndSettle(); + + expect(find.text('Pending events: 1'), findsOneWidget); + expect( + find.text('Queued: demo_delivery_success, demo_delivery_retry'), + findsOneWidget, + ); + expect(demo.successTracker.attemptCount, 0); + expect(demo.retryTracker.attemptCount, 0); + + await demo.setOnline(true); + await tester.runAsync(demo.flush); + await tester.pump(); + + expect(find.text('Pending events: 0'), findsOneWidget); + await tester.scrollUntilVisible( + find.byKey(const Key('flush-result')), + 200, + scrollable: find.byType(Scrollable), + ); + expect(find.textContaining('1 attempted · 1 delivered · 0 remaining'), + findsOneWidget); + expect(demo.successTracker.successCount, 1); + expect(demo.retryTracker.successCount, 1); + }); + + testWidgets('visualizes partial failure and selective retry', (tester) async { + demo.setRetryTrackerFails(true); + await pumpLab(tester); + + await tester.tap(find.byKey(const Key('track-delivery-event'))); + await tester.pumpAndSettle(); + + expect(find.text('Pending events: 1'), findsOneWidget); + expect(find.text('Delivered: demo_delivery_success'), findsOneWidget); + expect(find.text('Failed: demo_delivery_retry'), findsOneWidget); + expect(find.text('Queued: demo_delivery_retry'), findsOneWidget); + expect(demo.successTracker.attemptCount, 1); + + demo.setRetryTrackerFails(false); + await tester.runAsync(demo.flush); + await tester.pump(); + + expect(demo.successTracker.attemptCount, 1, + reason: 'successful destinations must never be retried'); + expect(demo.retryTracker.attemptCount, 2); + expect(find.text('Pending events: 0'), findsOneWidget); + }); +} diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index fa8a39b..b854f96 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/lib/flex_track.dart b/lib/flex_track.dart index 59edf8c..8310881 100644 --- a/lib/flex_track.dart +++ b/lib/flex_track.dart @@ -53,8 +53,11 @@ export 'src/core/event_dispatch_record.dart'; export 'src/core/flex_track.dart'; export 'src/core/flex_track_client.dart' show FlexTrackClient; export 'src/core/event_processor.dart' - show EventProcessingResult, TrackingResult; + show EventProcessingResult, TrackingResult, QueueFlushResult; export 'src/core/tracker_registry.dart' show TrackerRegistry; +export 'src/runtime/event_queue.dart' + show EventQueue, InMemoryEventQueue, QueuedEvent, QueuedEventSnapshot; +export 'src/runtime/file_event_queue.dart' show FileEventQueue; // ============= EVENT MODELS ============= @@ -143,7 +146,7 @@ export 'src/core/flex_track.dart' show FlexTrack; // ============= VERSION INFO ============= /// FlexTrack package version -const String flexTrackVersion = '2.1.0'; +const String flexTrackVersion = '2.2.0'; /// FlexTrack package description const String flexTrackDescription = diff --git a/lib/src/core/event_dispatch_record.dart b/lib/src/core/event_dispatch_record.dart index 5c6b544..7a1a05c 100644 --- a/lib/src/core/event_dispatch_record.dart +++ b/lib/src/core/event_dispatch_record.dart @@ -9,6 +9,8 @@ class EventDispatchRecord { required this.event, this.targetTrackers = const [], this.successfulTrackerIds = const [], + this.queuedTrackerIds = const [], + this.queueSize = 0, }); final BaseEvent event; @@ -18,4 +20,10 @@ class EventDispatchRecord { /// Subset of [targetTrackers] where `doTrack` completed successfully. final List successfulTrackerIds; + + /// Subset of [targetTrackers] retained for a later delivery attempt. + final List queuedTrackerIds; + + /// Total number of events in the runtime queue after this dispatch. + final int queueSize; } diff --git a/lib/src/core/event_processor.dart b/lib/src/core/event_processor.dart index 58191fb..6c0f043 100644 --- a/lib/src/core/event_processor.dart +++ b/lib/src/core/event_processor.dart @@ -1,9 +1,12 @@ +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 '../routing/routing_engine.dart'; import '../exceptions/tracker_exception.dart'; +import '../runtime/event_queue.dart'; import 'tracker_registry.dart'; /// Processes events through the routing system and sends them to appropriate trackers @@ -11,6 +14,9 @@ class EventProcessor { final TrackerRegistry _trackerRegistry; final RoutingEngine _routingEngine; final List _transformers = []; + final EventQueue _queue; + final bool Function() _onlineProvider; + Future _flushTail = Future.value(); bool _hasGeneralConsent = false; bool _hasPIIConsent = false; @@ -19,8 +25,14 @@ class EventProcessor { EventProcessor({ required TrackerRegistry trackerRegistry, required RoutingEngine routingEngine, + EventQueue? queue, + bool Function()? onlineProvider, }) : _trackerRegistry = trackerRegistry, - _routingEngine = routingEngine; + _routingEngine = routingEngine, + _queue = queue ?? InMemoryEventQueue(), + _onlineProvider = onlineProvider ?? _alwaysOnline; + + EventQueue get queue => _queue; /// Get the routing engine (for debugging) RoutingEngine get routingEngine => _routingEngine; @@ -28,6 +40,9 @@ class EventProcessor { /// Whether the processor is enabled bool get isEnabled => _isEnabled; + /// Current connectivity decision supplied by the host application. + bool get isOnline => _onlineProvider(); + /// Current general consent status bool get hasGeneralConsent => _hasGeneralConsent; @@ -130,70 +145,134 @@ class EventProcessor { ); } - // Send event to target trackers - final trackingResults = []; - bool anySuccessful = false; + if (!_onlineProvider()) { + await _queue.enqueue(QueuedEvent( + event: processedEvent, + trackerIds: routingResult.targetTrackers, + )); + return EventProcessingResult( + event: processedEvent, + routingResult: routingResult, + trackingResults: const [], + successful: false, + queuedTrackerIds: routingResult.targetTrackers, + ); + } - for (final trackerId in routingResult.targetTrackers) { - final tracker = _trackerRegistry.get(trackerId); + final trackingResults = await Future.wait( + routingResult.targetTrackers.map( + (trackerId) => _deliver(processedEvent, trackerId), + ), + ); + final failedTrackerIds = [ + for (final result in trackingResults) + if (!result.successful) result.trackerId, + ]; + if (failedTrackerIds.isNotEmpty) { + await _queue.enqueue(QueuedEvent( + event: processedEvent, + trackerIds: failedTrackerIds, + )); + } - if (tracker == null) { - trackingResults.add(TrackingResult( + return EventProcessingResult( + event: processedEvent, + routingResult: routingResult, + trackingResults: trackingResults, + successful: trackingResults.any((result) => result.successful), + queuedTrackerIds: failedTrackerIds, + ); + } + + Future _deliver(BaseEvent event, String trackerId) async { + final tracker = _trackerRegistry.get(trackerId); + + if (tracker == null) { + return TrackingResult( + trackerId: trackerId, + successful: false, + error: TrackerException( + 'Tracker not found: $trackerId', trackerId: trackerId, - successful: false, - error: TrackerException( - 'Tracker not found: $trackerId', - trackerId: trackerId, - eventName: processedEvent.name, - code: 'NOT_FOUND', - ), - )); - continue; - } + eventName: event.name, + code: 'NOT_FOUND', + ), + ); + } - if (!tracker.isEnabled) { - trackingResults.add(TrackingResult( + if (!tracker.isEnabled) { + return TrackingResult( + trackerId: trackerId, + successful: false, + error: TrackerException( + 'Tracker is disabled: $trackerId', trackerId: trackerId, - successful: false, - error: TrackerException( - 'Tracker is disabled: $trackerId', - trackerId: trackerId, - eventName: processedEvent.name, - code: 'DISABLED', - ), - )); - continue; - } + eventName: event.name, + code: 'DISABLED', + ), + ); + } + + try { + await tracker.track(event); + return TrackingResult( + trackerId: trackerId, + successful: true, + ); + } catch (e) { + return TrackingResult( + trackerId: trackerId, + successful: false, + error: e is TrackerException + ? e + : TrackerException( + 'Failed to track event: $e', + trackerId: trackerId, + eventName: event.name, + originalError: e, + ), + ); + } + } + Future flushQueue({int limit = 100}) async { + if (limit <= 0) throw ArgumentError.value(limit, 'limit'); + final completer = Completer(); + _flushTail = _flushTail.then((_) async { try { - await tracker.track(processedEvent); - trackingResults.add(TrackingResult( - trackerId: trackerId, - successful: true, - )); - anySuccessful = true; - } catch (e) { - trackingResults.add(TrackingResult( - trackerId: trackerId, - successful: false, - error: e is TrackerException - ? e - : TrackerException( - 'Failed to track event: $e', - trackerId: trackerId, - eventName: processedEvent.name, - originalError: e, - ), + completer.complete(await _flushQueuePass(limit)); + } catch (error, stack) { + completer.completeError(error, stack); + } + }); + return completer.future; + } + + Future _flushQueuePass(int limit) async { + if (!_onlineProvider()) { + return QueueFlushResult(0, 0, await _queue.size()); + } + final items = await _queue.read(limit: limit); + var delivered = 0; + for (final item in items) { + final results = await Future.wait( + item.trackerIds.map((id) => _deliver(item.event, id)), + ); + final failures = [ + for (final result in results) + if (!result.successful) result.trackerId, + ]; + if (failures.isEmpty) { + await _queue.remove(item.id); + delivered++; + } else { + await _queue.replace(item.copyWith( + trackerIds: failures, + attempts: item.attempts + 1, )); } } - - return EventProcessingResult( - event: processedEvent, - routingResult: routingResult, - trackingResults: trackingResults, - successful: anySuccessful, - ); + return QueueFlushResult(items.length, delivered, await _queue.size()); } /// Process multiple events as a batch @@ -250,12 +329,14 @@ class EventProcessingResult { final RoutingResult routingResult; final List trackingResults; final bool successful; + final List queuedTrackerIds; const EventProcessingResult({ required this.event, required this.routingResult, required this.trackingResults, required this.successful, + this.queuedTrackerIds = const [], }); /// Returns true if the event was routed to at least one tracker @@ -291,6 +372,7 @@ class EventProcessingResult { 'successfulTrackingCount': successfulTrackingCount, 'failedTrackingCount': failedTrackingCount, 'hasErrors': trackingErrors.isNotEmpty, + 'queuedTrackerIds': queuedTrackerIds, }; } @@ -305,6 +387,20 @@ class EventProcessingResult { } } +class QueueFlushResult { + const QueueFlushResult( + this.attemptedEvents, + this.deliveredEvents, + this.remainingEvents, + ); + + final int attemptedEvents; + final int deliveredEvents; + final int remainingEvents; +} + +bool _alwaysOnline() => true; + /// Result of tracking an event with a specific tracker class TrackingResult { final String trackerId; diff --git a/lib/src/core/flex_track.dart b/lib/src/core/flex_track.dart index d0fa1e6..58f33b8 100644 --- a/lib/src/core/flex_track.dart +++ b/lib/src/core/flex_track.dart @@ -6,6 +6,7 @@ import '../models/routing/routing_config.dart'; import '../routing/routing_builder.dart'; import '../routing/routing_engine.dart' show RoutingDebugInfo; import '../strategies/tracker_strategy.dart'; +import '../runtime/event_queue.dart'; import '../exceptions/configuration_exception.dart'; import 'event_dispatch_record.dart'; import 'flex_track_client.dart'; @@ -81,6 +82,8 @@ class FlexTrack { List trackers, { RoutingConfiguration? routing, bool autoInitialize = true, + EventQueue? queue, + bool Function()? onlineProvider, }) async { if (_instance != null) { throw ConfigurationException( @@ -93,6 +96,8 @@ class FlexTrack { trackers, routing: routing, autoInitialize: autoInitialize, + queue: queue, + onlineProvider: onlineProvider, ); _instance = FlexTrack._(client); return _instance!; @@ -103,11 +108,15 @@ class FlexTrack { List trackers, RoutingBuilder Function(RoutingBuilder) configureRouting, { bool autoInitialize = true, + EventQueue? queue, + bool Function()? onlineProvider, }) async { return setup( trackers, routing: _routingFromBuilder(configureRouting), autoInitialize: autoInitialize, + queue: queue, + onlineProvider: onlineProvider, ); } @@ -231,9 +240,10 @@ class FlexTrack { } /// Flush all pending events - static Future flush() async { - await instance._client.flush(); - } + static Future flush({int limit = 100}) => + instance._client.flush(limit: limit); + + static Future get queuedEventCount => instance._client.queuedEventCount; // ========== TRANSFORMERS ========== diff --git a/lib/src/core/flex_track_client.dart b/lib/src/core/flex_track_client.dart index 0131997..c367de4 100644 --- a/lib/src/core/flex_track_client.dart +++ b/lib/src/core/flex_track_client.dart @@ -9,6 +9,7 @@ import '../models/routing/routing_config.dart'; import '../routing/routing_builder.dart'; import '../routing/routing_engine.dart'; import '../strategies/tracker_strategy.dart'; +import '../runtime/event_queue.dart'; import 'event_dispatch_record.dart'; import 'event_processor.dart'; import 'tracker_registry.dart'; @@ -27,6 +28,7 @@ class FlexTrackClient { final TrackerRegistry _trackerRegistry; final EventProcessor _eventProcessor; bool _isInitialized = false; + bool _isDisposed = false; final StreamController _dispatchStreamController = StreamController.broadcast(sync: true); @@ -63,6 +65,8 @@ class FlexTrackClient { List trackers, { RoutingConfiguration? routing, bool autoInitialize = true, + EventQueue? queue, + bool Function()? onlineProvider, }) async { if (trackers.isEmpty) { throw ConfigurationException( @@ -77,6 +81,8 @@ class FlexTrackClient { final eventProcessor = EventProcessor( trackerRegistry: trackerRegistry, routingEngine: routingEngine, + queue: queue, + onlineProvider: onlineProvider, ); final client = FlexTrackClient._( @@ -95,6 +101,8 @@ class FlexTrackClient { List trackers, RoutingBuilder Function(RoutingBuilder) configureRouting, { bool autoInitialize = true, + EventQueue? queue, + bool Function()? onlineProvider, }) async { final routingBuilder = RoutingBuilder(); final configuredBuilder = configureRouting(routingBuilder); @@ -104,6 +112,8 @@ class FlexTrackClient { trackers, routing: routingConfig, autoInitialize: autoInitialize, + queue: queue, + onlineProvider: onlineProvider, ); } @@ -127,7 +137,8 @@ class FlexTrackClient { Future track(BaseEvent event) async { final result = await _eventProcessor.processEvent(event); - _emitDispatchIfDebug(_recordFromResult(result)); + _emitDispatchIfDebug(await _recordFromResult(result)); + _notifyDebugStateIfDebug(); return result; } @@ -135,9 +146,10 @@ class FlexTrackClient { final results = await _eventProcessor.processEvents(events); if (kDebugMode) { for (final result in results) { - _emitDispatchIfDebug(_recordFromResult(result)); + _emitDispatchIfDebug(await _recordFromResult(result)); } } + _notifyDebugStateIfDebug(); return results; } @@ -146,9 +158,10 @@ class FlexTrackClient { final results = await _eventProcessor.processEventsParallel(events); if (kDebugMode) { for (final result in results) { - _emitDispatchIfDebug(_recordFromResult(result)); + _emitDispatchIfDebug(await _recordFromResult(result)); } } + _notifyDebugStateIfDebug(); return results; } @@ -206,7 +219,24 @@ class FlexTrackClient { Future resetTrackers() => _trackerRegistry.reset(); - Future flush() => _trackerRegistry.flush(); + Future flush({int limit = 100}) async { + final result = await _eventProcessor.flushQueue(limit: limit); + // Tracker SDK flush methods may perform network I/O of their own. Keep a + // host-declared offline flush a complete no-op across both queue layers. + if (_eventProcessor.isOnline) { + await _trackerRegistry.flush(); + } + _notifyDebugStateIfDebug(); + return result; + } + + Future flushQueue({int limit = 100}) async { + final result = await _eventProcessor.flushQueue(limit: limit); + _notifyDebugStateIfDebug(); + return result; + } + + Future get queuedEventCount => _eventProcessor.queue.size(); void addTransformer(EventTransformer transformer) => _eventProcessor.addTransformer(transformer); @@ -259,7 +289,8 @@ class FlexTrackClient { } } - static EventDispatchRecord _recordFromResult(EventProcessingResult result) { + Future _recordFromResult( + EventProcessingResult result) async { final targets = List.from(result.routingResult.targetTrackers); final ok = [ for (final t in result.trackingResults) @@ -269,6 +300,8 @@ class FlexTrackClient { event: result.event, targetTrackers: targets, successfulTrackerIds: ok, + queuedTrackerIds: List.from(result.queuedTrackerIds), + queueSize: await _eventProcessor.queue.size(), ); } @@ -285,9 +318,10 @@ class FlexTrackClient { } Future dispose() async { - if (_isInitialized) { - await _trackerRegistry.flush(); - } + if (_isDisposed) return; + _isDisposed = true; + if (_isInitialized) await _trackerRegistry.dispose(); + _isInitialized = false; await _dispatchStreamController.close(); await _debugStateController.close(); } diff --git a/lib/src/core/tracker_registry.dart b/lib/src/core/tracker_registry.dart index 481ed8b..102aefc 100644 --- a/lib/src/core/tracker_registry.dart +++ b/lib/src/core/tracker_registry.dart @@ -8,6 +8,7 @@ class TrackerRegistry { final Map _trackers = {}; final Map _initializationStatus = {}; bool _isInitialized = false; + bool _isDisposed = false; /// Returns true if the registry has been initialized bool get isInitialized => _isInitialized; @@ -126,10 +127,18 @@ class TrackerRegistry { failures[trackerId] = e is Exception ? e : Exception(e.toString()); } } - _isInitialized = true; - // Report any failures if (failures.isNotEmpty) { + for (final entry in _trackers.entries) { + if (_initializationStatus[entry.key] ?? false) { + try { + await _disposeTracker(entry.value); + } catch (_) { + // Initialization failure remains the primary error. + } + _initializationStatus[entry.key] = false; + } + } final failureMessage = failures.entries.map((e) => '${e.key}: ${e.value}').join(', '); @@ -138,6 +147,7 @@ class TrackerRegistry { code: 'INITIALIZATION_FAILURES', ); } + _isInitialized = true; } /// Enable a tracker @@ -233,6 +243,20 @@ class TrackerRegistry { await Future.wait(futures); } + /// Disposes every initialized tracker once, without short-circuiting peers. + Future dispose() async { + if (_isDisposed) return; + _isDisposed = true; + final futures = >[]; + for (final entry in _trackers.entries) { + if (_initializationStatus[entry.key] ?? false) { + futures.add(_disposeTracker(entry.value)); + } + } + await Future.wait(futures, eagerError: false); + _isInitialized = false; + } + /// Track a single event on all enabled trackers Future track(BaseEvent event) async { final futures = []; @@ -340,3 +364,8 @@ class TrackerRegistry { return 'TrackerRegistry(${_trackers.length} trackers, initialized: $_isInitialized)'; } } + +Future _disposeTracker(TrackerStrategy tracker) => + tracker is DisposableTrackerStrategy + ? (tracker as DisposableTrackerStrategy).dispose() + : tracker.flush(); diff --git a/lib/src/inspector/dashboard.dart b/lib/src/inspector/dashboard.dart index baba7ce..1ff51ab 100644 --- a/lib/src/inspector/dashboard.dart +++ b/lib/src/inspector/dashboard.dart @@ -306,6 +306,7 @@ select { cursor: pointer; }

Trackers

+

Offline queue

Consent

Validation

@@ -359,9 +360,11 @@ select { cursor: pointer; } var tb = document.getElementById('trackers'); var cs = document.getElementById('consent'); var vl = document.getElementById('validation'); + var qu = document.getElementById('queue'); tb.innerHTML = ''; cs.innerHTML = ''; vl.innerHTML = ''; + qu.innerHTML = ''; if (!data || data.isSetUp === false) { tb.innerHTML = '
FlexTrack not set up
'; return; @@ -395,6 +398,7 @@ select { cursor: pointer; } applyTrackerRowHighlight(); var con = data.consent || {}; cs.innerHTML = '
general: ' + !!con.general + '
pii: ' + !!con.pii + '
'; + qu.innerHTML = '
pending events: ' + Number(data.queueSize || 0) + '
'; var issues = data.validation || []; if (!issues.length) vl.innerHTML = '
No issues
'; else issues.forEach(function(w){ var d=document.createElement('div'); d.className='warn'; d.textContent=w; vl.appendChild(d); }); @@ -470,7 +474,8 @@ select { cursor: pointer; } if (tid) { var targets = normIds(ev.targetTrackers); var ok = normIds(ev.successfulTrackerIds); - if (targets.indexOf(tid) < 0 && ok.indexOf(tid) < 0) return false; + var queued = normIds(ev.queuedTrackerIds); + if (targets.indexOf(tid) < 0 && ok.indexOf(tid) < 0 && queued.indexOf(tid) < 0) return false; } return true; } @@ -519,7 +524,10 @@ select { cursor: pointer; } '
Routed to (rule targets)
' + '
' + pillsHtml(ev.targetTrackers, '') + '
' + '
Delivered (track succeeded)
' + - '
' + pillsHtml(ev.successfulTrackerIds, 'ok') + '
' + + '
' + pillsHtml(ev.successfulTrackerIds, 'ok') + '
' + + '
Queued for retry
' + + '
' + pillsHtml(ev.queuedTrackerIds, '') + '
' + + '
Queue size after dispatch
' + Number(ev.queueSize || 0) + '
' + '

Properties

' + escapeHtml(JSON.stringify(ev.properties || {}, null, 2)) + '
'; applyTrackerRowHighlight(); renderFeed(); diff --git a/lib/src/inspector/flex_track_inspector_io.dart b/lib/src/inspector/flex_track_inspector_io.dart index 05cee53..7fb62da 100644 --- a/lib/src/inspector/flex_track_inspector_io.dart +++ b/lib/src/inspector/flex_track_inspector_io.dart @@ -156,8 +156,8 @@ class _FlexTrackInspectorServer { ); } - Response _handleStatus(Request request) { - final payload = _buildStatusPayload(); + Future _handleStatus(Request request) async { + final payload = await _buildStatusPayload(); return Response.ok( jsonEncode(payload), headers: {'content-type': 'application/json; charset=utf-8'}, @@ -183,9 +183,7 @@ class _FlexTrackInspectorServer { void _handleWebSocket(WebSocketChannel channel) { _channels.add(channel); - try { - channel.sink.add(jsonEncode(_statusMessageMap())); - } catch (_) {} + _sendInitialStatus(channel); channel.stream.listen( (_) {}, @@ -195,7 +193,13 @@ class _FlexTrackInspectorServer { ); } - Map _buildStatusPayload() { + Future _sendInitialStatus(WebSocketChannel channel) async { + try { + channel.sink.add(jsonEncode(await _statusMessageMap())); + } catch (_) {} + } + + Future> _buildStatusPayload() async { if (!FlexTrack.isSetUp) { return { 'isSetUp': false, @@ -203,6 +207,7 @@ class _FlexTrackInspectorServer { 'trackers': [], 'consent': {}, 'validation': ['FlexTrack is not set up'], + 'queueSize': 0, }; } @@ -221,16 +226,17 @@ class _FlexTrackInspectorServer { 'trackers': trackers, 'consent': FlexTrack.getConsentStatus(), 'validation': FlexTrack.validate(), + 'queueSize': await FlexTrack.queuedEventCount, }; } - Map _statusMessageMap() => { + Future> _statusMessageMap() async => { 'type': 'status', - 'data': _buildStatusPayload(), + 'data': await _buildStatusPayload(), }; - void _broadcastStatus() { - _broadcastWs(jsonEncode(_statusMessageMap())); + Future _broadcastStatus() async { + _broadcastWs(jsonEncode(await _statusMessageMap())); } void _broadcastWs(String message) { diff --git a/lib/src/inspector/inspector_event_buffer.dart b/lib/src/inspector/inspector_event_buffer.dart index f161ee7..0681566 100644 --- a/lib/src/inspector/inspector_event_buffer.dart +++ b/lib/src/inspector/inspector_event_buffer.dart @@ -41,6 +41,8 @@ class InspectorEventRecord { required this.flags, required this.targetTrackers, required this.successfulTrackerIds, + required this.queuedTrackerIds, + required this.queueSize, }); final String id; @@ -51,6 +53,8 @@ class InspectorEventRecord { final Map flags; final List targetTrackers; final List successfulTrackerIds; + final List queuedTrackerIds; + final int queueSize; factory InspectorEventRecord.fromDispatch({ required String id, @@ -71,6 +75,8 @@ class InspectorEventRecord { }, targetTrackers: List.from(dispatch.targetTrackers), successfulTrackerIds: List.from(dispatch.successfulTrackerIds), + queuedTrackerIds: List.from(dispatch.queuedTrackerIds), + queueSize: dispatch.queueSize, ); } @@ -84,6 +90,8 @@ class InspectorEventRecord { 'flags': flags, 'targetTrackers': targetTrackers, 'successfulTrackerIds': successfulTrackerIds, + 'queuedTrackerIds': queuedTrackerIds, + 'queueSize': queueSize, }; } diff --git a/lib/src/runtime/event_queue.dart b/lib/src/runtime/event_queue.dart new file mode 100644 index 0000000..7ea660a --- /dev/null +++ b/lib/src/runtime/event_queue.dart @@ -0,0 +1,163 @@ +import 'dart:collection'; + +import '../models/event/base_event.dart'; +import '../models/routing/event_category.dart'; + +/// A routed event waiting for delivery to one or more tracker destinations. +class QueuedEvent { + QueuedEvent({ + required this.event, + required Iterable trackerIds, + this.attempts = 0, + DateTime? queuedAt, + }) : trackerIds = List.unmodifiable(LinkedHashSet.of(trackerIds)), + queuedAt = (queuedAt ?? DateTime.now()).toUtc() { + if (this.trackerIds.isEmpty) { + throw ArgumentError.value(trackerIds, 'trackerIds', 'Cannot be empty'); + } + if (attempts < 0) { + throw ArgumentError.value(attempts, 'attempts', 'Cannot be negative'); + } + } + + final BaseEvent event; + final List trackerIds; + final int attempts; + final DateTime queuedAt; + + String get id => event.eventId; + + QueuedEvent copyWith({List? trackerIds, int? attempts}) => + QueuedEvent( + event: event, + trackerIds: trackerIds ?? this.trackerIds, + attempts: attempts ?? this.attempts, + queuedAt: queuedAt, + ); + + Map toJson() => { + 'id': id, + 'trackerIds': trackerIds, + 'attempts': attempts, + 'queuedAt': queuedAt.toIso8601String(), + 'event': event.toMap(), + }; + + factory QueuedEvent.fromJson(Map json) => QueuedEvent( + event: QueuedEventSnapshot.fromJson( + (json['event'] as Map).cast(), + ), + trackerIds: (json['trackerIds'] as List).cast(), + attempts: json['attempts'] as int, + queuedAt: DateTime.parse(json['queuedAt'] as String), + ); +} + +/// Storage boundary used by the delivery runtime. +abstract interface class EventQueue { + Future enqueue(QueuedEvent item); + Future> read({required int limit}); + Future replace(QueuedEvent item); + Future remove(String eventId); + Future size(); + Future clear(); +} + +/// FIFO, process-local queue intended for tests and non-durable clients. +class InMemoryEventQueue implements EventQueue { + final Map _items = {}; + + @override + Future enqueue(QueuedEvent item) async { + _items.putIfAbsent(item.id, () => item); + } + + @override + Future> read({required int limit}) async { + _checkLimit(limit); + return List.unmodifiable(_items.values.take(limit)); + } + + @override + Future replace(QueuedEvent item) async { + if (_items.containsKey(item.id)) _items[item.id] = item; + } + + @override + Future remove(String eventId) async => _items.remove(eventId); + + @override + Future size() async => _items.length; + + @override + Future clear() async => _items.clear(); +} + +/// Immutable event representation used for retry and process restoration. +class QueuedEventSnapshot extends BaseEvent { + QueuedEventSnapshot({ + required this.eventName, + required this.eventProperties, + required this.eventCategory, + required this.eventContainsPII, + required this.eventRequiresConsent, + required this.eventIsHighVolume, + required this.eventIsEssential, + required this.eventUserId, + required this.eventSessionId, + required super.eventId, + required super.timestamp, + }); + + factory QueuedEventSnapshot.fromJson(Map json) => + QueuedEventSnapshot( + eventName: json['name'] as String, + eventProperties: (json['properties'] as Map?)?.cast(), + eventCategory: json['category'] == null + ? null + : EventCategory(json['category'] as String), + eventContainsPII: json['containsPII'] as bool? ?? false, + eventRequiresConsent: json['requiresConsent'] as bool? ?? true, + eventIsHighVolume: json['isHighVolume'] as bool? ?? false, + eventIsEssential: json['isEssential'] as bool? ?? false, + eventUserId: json['userId'] as String?, + eventSessionId: json['sessionId'] as String?, + eventId: json['eventId'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + ); + + final String eventName; + final Map? eventProperties; + final EventCategory? eventCategory; + final bool eventContainsPII; + final bool eventRequiresConsent; + final bool eventIsHighVolume; + final bool eventIsEssential; + final String? eventUserId; + final String? eventSessionId; + + @override + String get name => eventName; + @override + Map? get properties => eventProperties; + @override + EventCategory? get category => eventCategory; + @override + bool get containsPII => eventContainsPII; + @override + bool get requiresConsent => eventRequiresConsent; + @override + bool get isHighVolume => eventIsHighVolume; + @override + bool get isEssential => eventIsEssential; + @override + String? get userId => eventUserId; + @override + String? get sessionId => eventSessionId; +} + +void _checkLimit(int limit) { + if (limit <= 0) { + throw ArgumentError.value(limit, 'limit', 'Must be positive'); + } +} diff --git a/lib/src/runtime/event_queue_io.dart b/lib/src/runtime/event_queue_io.dart new file mode 100644 index 0000000..9d73896 --- /dev/null +++ b/lib/src/runtime/event_queue_io.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'event_queue.dart'; + +/// Durable FIFO queue using atomic replacement in an application-owned file. +class FileEventQueue implements EventQueue { + FileEventQueue(this.file); + + final File file; + Future _tail = Future.value(); + + Future _locked(Future Function() operation) { + final completer = Completer(); + _tail = _tail.then((_) async { + try { + completer.complete(await operation()); + } catch (error, stack) { + completer.completeError(error, stack); + } + }); + return completer.future; + } + + @override + Future enqueue(QueuedEvent item) => _mutate((items) { + if (!items.any((value) => value.id == item.id)) items.add(item); + }); + + @override + Future> read({required int limit}) => _locked(() async { + if (limit <= 0) throw ArgumentError.value(limit, 'limit'); + return List.unmodifiable((await _load()).take(limit)); + }); + + @override + Future replace(QueuedEvent item) => _mutate((items) { + final index = items.indexWhere((value) => value.id == item.id); + if (index >= 0) items[index] = item; + }); + + @override + Future remove(String eventId) => + _mutate((items) => items.removeWhere((value) => value.id == eventId)); + + @override + Future size() => _locked(() async => (await _load()).length); + + @override + Future clear() => _locked(() async { + if (await file.exists()) await file.delete(); + }); + + Future _mutate(void Function(List) change) => + _locked(() async { + final items = await _load(); + change(items); + await _persist(items); + }); + + Future> _load() async { + if (!await file.exists() || await file.length() == 0) return []; + final decoded = jsonDecode(await file.readAsString()); + if (decoded is! List) { + throw const FormatException('FlexTrack queue root must be a JSON array'); + } + return decoded + .map((value) => + QueuedEvent.fromJson((value as Map).cast())) + .toList(); + } + + Future _persist(List items) async { + await file.parent.create(recursive: true); + final temporary = File('${file.path}.tmp'); + await temporary.writeAsString( + jsonEncode(items.map((value) => value.toJson()).toList()), + flush: true, + ); + await temporary.rename(file.path); + } +} diff --git a/lib/src/runtime/event_queue_stub.dart b/lib/src/runtime/event_queue_stub.dart new file mode 100644 index 0000000..ff3b89b --- /dev/null +++ b/lib/src/runtime/event_queue_stub.dart @@ -0,0 +1,23 @@ +import 'event_queue.dart'; + +/// File persistence is unavailable on this platform. +class FileEventQueue implements EventQueue { + FileEventQueue(Object file) { + throw UnsupportedError('FileEventQueue requires dart:io'); + } + + Never _unsupported() => + throw UnsupportedError('FileEventQueue requires dart:io'); + @override + Future clear() => _unsupported(); + @override + Future enqueue(QueuedEvent item) => _unsupported(); + @override + Future> read({required int limit}) => _unsupported(); + @override + Future remove(String eventId) => _unsupported(); + @override + Future replace(QueuedEvent item) => _unsupported(); + @override + Future size() => _unsupported(); +} diff --git a/lib/src/runtime/file_event_queue.dart b/lib/src/runtime/file_event_queue.dart new file mode 100644 index 0000000..d817f14 --- /dev/null +++ b/lib/src/runtime/file_event_queue.dart @@ -0,0 +1 @@ +export 'event_queue_stub.dart' if (dart.library.io) 'event_queue_io.dart'; diff --git a/lib/src/strategies/tracker_strategy.dart b/lib/src/strategies/tracker_strategy.dart index 9f14329..8d2a33d 100644 --- a/lib/src/strategies/tracker_strategy.dart +++ b/lib/src/strategies/tracker_strategy.dart @@ -87,3 +87,8 @@ abstract class TrackerStrategy { }; } } + +/// Optional lifecycle capability for trackers that own disposable resources. +abstract interface class DisposableTrackerStrategy { + Future dispose(); +} diff --git a/pubspec.yaml b/pubspec.yaml index 417c9ba..d4eaa09 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flex_track description: A flexible analytics tracking system for Flutter with intelligent routing, GDPR compliance, and multi-platform support -version: 2.1.0 +version: 2.2.0 homepage: https://flextrack.taghizadeh.dev/ repository: https://github.com/alirezat66/flex_track issue_tracker: https://github.com/alirezat66/flex_track/issues diff --git a/test/contract/runtime_mvp_conformance_test.dart b/test/contract/runtime_mvp_conformance_test.dart new file mode 100644 index 0000000..caf432b --- /dev/null +++ b/test/contract/runtime_mvp_conformance_test.dart @@ -0,0 +1,212 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _casesPath = 'test/fixtures/conformance/runtime_mvp_cases.json'; +const _schemaPath = 'test/fixtures/conformance/runtime_mvp.schema.json'; +const _reportPath = 'test/fixtures/conformance/flutter_runtime_report.json'; + +void main() { + final fixture = _json(_casesPath); + final cases = (fixture['cases'] as List).cast>(); + + test('runtime fixture envelope and case identities are valid', () { + final schema = _json(_schemaPath); + expect(schema[r'$schema'], 'https://json-schema.org/draft/2020-12/schema'); + expect(fixture[r'$schema'], 'runtime_mvp.schema.json'); + expect(fixture['specVersion'], '1.0.0'); + expect(fixture['fixtureVersion'], matches(r'^1\.[0-9]+\.[0-9]+$')); + expect(cases.map((value) => value['id']).toSet().length, cases.length); + for (final value in cases) { + expect(value.keys.toSet(), {'id', 'behavior', 'input', 'expected'}); + expect(['offline', 'partialFailure', 'flush', 'queue', 'lifecycle'], + contains(value['behavior'])); + } + }); + + for (final value in cases) { + test('runtime conformance: ${value['id']}', () async { + expect(await _run(value), value['expected'], + reason: value['id'] as String); + }); + } + + test('runtime report covers every conformance case', () { + final report = _json(_reportPath); + expect(report['implementation'], 'flutter'); + expect(report['specVersion'], fixture['specVersion']); + expect(report['fixtureVersion'], fixture['fixtureVersion']); + expect(report['total'], cases.length); + expect(report['passed'], cases.length); + expect(report['failed'], 0); + expect(report['caseIds'], cases.map((value) => value['id']).toList()); + }); +} + +Future> _run(Map value) async { + final input = value['input'] as Map; + switch (value['behavior']) { + case 'offline': + final setup = await _client( + targets: (input['targets'] as List).cast(), + online: false, + ); + final result = await setup.client.track(_Event('event-1')); + return { + 'attempted': setup.trackers.expand((value) => value.events).toList(), + 'queued': result.queuedTrackerIds, + 'queueSize': await setup.queue.size(), + }; + case 'partialFailure': + final setup = await _client( + targets: (input['targets'] as List).cast(), + failing: (input['failing'] as List).cast().toSet(), + ); + final result = await setup.client.track(_Event('event-1')); + return { + 'successful': [ + for (final item in result.trackingResults) + if (item.successful) item.trackerId, + ], + 'queued': result.queuedTrackerIds, + 'queueSize': await setup.queue.size(), + }; + case 'flush': + final pending = (input['pending'] as List).cast(); + final online = input['online'] as bool; + final onlineState = _OnlineState(online); + final setup = await _client( + targets: pending, + failing: (input['failing'] as List).cast().toSet(), + onlineState: onlineState, + ); + await setup.queue.enqueue(QueuedEvent( + event: _Event('event-1'), + trackerIds: pending, + )); + final result = await setup.client.flushQueue(); + final remaining = await setup.queue.read(limit: 10); + return { + 'attemptedEvents': result.attemptedEvents, + 'deliveredEvents': result.deliveredEvents, + 'remainingEvents': result.remainingEvents, + 'pending': remaining.isEmpty ? [] : remaining.single.trackerIds, + if (remaining.isNotEmpty || !online) + 'attempts': remaining.isEmpty ? 0 : remaining.single.attempts, + }; + case 'queue': + return _queueCase(input); + case 'lifecycle': + final tracker = _Tracker('analytics'); + final client = + await FlexTrackClient.create([tracker], autoInitialize: false); + for (var i = 0; i < (input['initializeCalls'] as int); i++) { + await client.initialize(); + } + return {'trackerInitializeCalls': tracker.initializeCalls}; + default: + throw StateError('Unsupported behavior ${value['behavior']}'); + } +} + +Future> _queueCase(Map input) async { + final queue = InMemoryEventQueue(); + final ids = (input['eventIds'] as List).cast(); + for (final id in ids) { + await queue + .enqueue(QueuedEvent(event: _Event(id), trackerIds: const ['a'])); + } + if (input['operation'] == 'replace') { + final first = (await queue.read(limit: 10)).first; + await queue.replace(first.copyWith(attempts: 1)); + } + final values = await queue.read(limit: input['limit'] as int? ?? 10); + return { + 'eventIds': values.map((value) => value.id).toList(), + 'queueSize': await queue.size(), + if (input['operation'] == 'replace') + 'attempts': values.map((value) => value.attempts).toList(), + }; +} + +Future<_Setup> _client({ + required List targets, + Set failing = const {}, + bool online = true, + _OnlineState? onlineState, +}) async { + final queue = InMemoryEventQueue(); + final trackers = + targets.map((id) => _Tracker(id, failing: failing.contains(id))).toList(); + final config = RoutingConfiguration( + rules: targets.isEmpty + ? const [] + : [ + RoutingRule( + targetGroup: TrackerGroup('fixture', targets), + requireConsent: false, + ), + ], + ); + final state = onlineState ?? _OnlineState(online); + final client = await FlexTrackClient.create( + trackers.isEmpty ? [_Tracker('unused')] : trackers, + routing: config, + queue: queue, + onlineProvider: () => state.value, + ); + return _Setup(client, queue, trackers); +} + +Map _json(String path) => + (jsonDecode(File(path).readAsStringSync()) as Map).cast(); + +class _Event extends BaseEvent { + _Event(String id) : super(eventId: id, timestamp: DateTime.utc(2026, 8, 17)); + @override + String get name => 'purchase'; + @override + Map get properties => const {'plan': 'pro'}; + @override + bool get requiresConsent => false; +} + +class _Tracker extends TrackerStrategy { + _Tracker(this.id, {this.failing = false}); + @override + final String id; + final bool failing; + final List events = []; + int initializeCalls = 0; + bool _enabled = true; + @override + String get name => id; + @override + bool get isEnabled => _enabled; + @override + Future initialize() async => initializeCalls++; + @override + Future track(BaseEvent event) async { + events.add(id); + if (failing) throw StateError('failure'); + } + + @override + void enable() => _enabled = true; + @override + void disable() => _enabled = false; +} + +class _OnlineState { + _OnlineState(this.value); + bool value; +} + +class _Setup { + const _Setup(this.client, this.queue, this.trackers); + final FlexTrackClient client; + final InMemoryEventQueue queue; + final List<_Tracker> trackers; +} diff --git a/test/core/tracker_registry_test.dart b/test/core/tracker_registry_test.dart index 2afc051..d00c2d0 100644 --- a/test/core/tracker_registry_test.dart +++ b/test/core/tracker_registry_test.dart @@ -176,11 +176,9 @@ void main() { } catch (e) { print(e); } - expect(registry.isInitialized, - isTrue); // Registry is marked as initialized even with failures + expect(registry.isInitialized, isFalse); expect(registry.isTrackerInitialized('tracker1'), isFalse); - expect(registry.isTrackerInitialized('tracker2'), - isTrue); // Other trackers should still initialize + expect(registry.isTrackerInitialized('tracker2'), isFalse); }); test('should enable a tracker', () { diff --git a/test/fixtures/conformance/flutter_runtime_report.json b/test/fixtures/conformance/flutter_runtime_report.json new file mode 100644 index 0000000..e8bcec4 --- /dev/null +++ b/test/fixtures/conformance/flutter_runtime_report.json @@ -0,0 +1,21 @@ +{ + "implementation": "flutter", + "specVersion": "1.0.0", + "fixtureVersion": "1.0.0", + "total": 11, + "passed": 11, + "failed": 0, + "caseIds": [ + "offline.queue-all-targets", + "offline.no-target-no-queue", + "delivery.partial-failure", + "delivery.all-success", + "flush.selective-retry-success", + "flush.retain-only-failures", + "flush.offline-noop", + "queue.duplicate-id-idempotent", + "queue.fifo-limit", + "queue.replace-preserves-position", + "lifecycle.initialize-idempotent" + ] +} diff --git a/test/fixtures/conformance/runtime_mvp.schema.json b/test/fixtures/conformance/runtime_mvp.schema.json new file mode 100644 index 0000000..a00ac28 --- /dev/null +++ b/test/fixtures/conformance/runtime_mvp.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flextrack.taghizadeh.dev/schemas/runtime-mvp-1.0.0.json", + "title": "FlexTrack Runtime MVP fixtures", + "type": "object", + "required": ["specVersion", "fixtureVersion", "cases"], + "additionalProperties": false, + "properties": { + "specVersion": {"const": "1.0.0"}, + "fixtureVersion": {"type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$"}, + "cases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "behavior", "input", "expected"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "behavior": { + "enum": ["offline", "partialFailure", "flush", "queue", "lifecycle"] + }, + "input": {"type": "object"}, + "expected": {"type": "object"} + } + } + } + } +} diff --git a/test/fixtures/conformance/runtime_mvp_cases.json b/test/fixtures/conformance/runtime_mvp_cases.json new file mode 100644 index 0000000..babcca2 --- /dev/null +++ b/test/fixtures/conformance/runtime_mvp_cases.json @@ -0,0 +1,73 @@ +{ + "$schema": "runtime_mvp.schema.json", + "specVersion": "1.0.0", + "fixtureVersion": "1.0.0", + "cases": [ + { + "id": "offline.queue-all-targets", + "behavior": "offline", + "input": {"targets": ["analytics", "archive"]}, + "expected": {"attempted": [], "queued": ["analytics", "archive"], "queueSize": 1} + }, + { + "id": "offline.no-target-no-queue", + "behavior": "offline", + "input": {"targets": []}, + "expected": {"attempted": [], "queued": [], "queueSize": 0} + }, + { + "id": "delivery.partial-failure", + "behavior": "partialFailure", + "input": {"targets": ["analytics", "archive"], "failing": ["archive"]}, + "expected": {"successful": ["analytics"], "queued": ["archive"], "queueSize": 1} + }, + { + "id": "delivery.all-success", + "behavior": "partialFailure", + "input": {"targets": ["analytics", "archive"], "failing": []}, + "expected": {"successful": ["analytics", "archive"], "queued": [], "queueSize": 0} + }, + { + "id": "flush.selective-retry-success", + "behavior": "flush", + "input": {"pending": ["archive"], "failing": [], "online": true}, + "expected": {"attemptedEvents": 1, "deliveredEvents": 1, "remainingEvents": 0, "pending": []} + }, + { + "id": "flush.retain-only-failures", + "behavior": "flush", + "input": {"pending": ["analytics", "archive"], "failing": ["archive"], "online": true}, + "expected": {"attemptedEvents": 1, "deliveredEvents": 0, "remainingEvents": 1, "pending": ["archive"], "attempts": 1} + }, + { + "id": "flush.offline-noop", + "behavior": "flush", + "input": {"pending": ["analytics"], "failing": [], "online": false}, + "expected": {"attemptedEvents": 0, "deliveredEvents": 0, "remainingEvents": 1, "pending": ["analytics"], "attempts": 0} + }, + { + "id": "queue.duplicate-id-idempotent", + "behavior": "queue", + "input": {"operation": "duplicate", "eventIds": ["event-1", "event-1"]}, + "expected": {"eventIds": ["event-1"], "queueSize": 1} + }, + { + "id": "queue.fifo-limit", + "behavior": "queue", + "input": {"operation": "read", "eventIds": ["event-1", "event-2", "event-3"], "limit": 2}, + "expected": {"eventIds": ["event-1", "event-2"], "queueSize": 3} + }, + { + "id": "queue.replace-preserves-position", + "behavior": "queue", + "input": {"operation": "replace", "eventIds": ["event-1", "event-2"]}, + "expected": {"eventIds": ["event-1", "event-2"], "queueSize": 2, "attempts": [1, 0]} + }, + { + "id": "lifecycle.initialize-idempotent", + "behavior": "lifecycle", + "input": {"initializeCalls": 2}, + "expected": {"trackerInitializeCalls": 1} + } + ] +} diff --git a/test/inspector/inspector_event_buffer_test.dart b/test/inspector/inspector_event_buffer_test.dart index 8015254..1c7ca87 100644 --- a/test/inspector/inspector_event_buffer_test.dart +++ b/test/inspector/inspector_event_buffer_test.dart @@ -23,11 +23,19 @@ class _BufEvent extends BaseEvent { bool get isEssential => true; } -EventDispatchRecord _rec(BaseEvent e, {List? t, List? ok}) { +EventDispatchRecord _rec( + BaseEvent e, { + List? t, + List? ok, + List? queued, + int queueSize = 0, +}) { return EventDispatchRecord( event: e, targetTrackers: t ?? const [], successfulTrackerIds: ok ?? const [], + queuedTrackerIds: queued ?? const [], + queueSize: queueSize, ); } @@ -66,10 +74,30 @@ void main() { expect(p['category'], 'business'); expect(p['targetTrackers'], ['console', 'firebase']); expect(p['successfulTrackerIds'], ['console']); + expect(p['queuedTrackerIds'], isEmpty); + expect(p['queueSize'], 0); expect(p['flags'], { 'essential': true, 'highVolume': false, 'containsPII': false, }); }); + + test('toEventPayload includes offline queue fields', () { + final buf = InspectorEventBuffer(); + final rec = buf.append( + _rec( + _BufEvent('offline'), + t: const ['reliable', 'retry'], + ok: const ['reliable'], + queued: const ['retry'], + queueSize: 3, + ), + 'uuid', + '12:00:00.001', + ); + + expect(rec.toEventPayload()['queuedTrackerIds'], ['retry']); + expect(rec.toEventPayload()['queueSize'], 3); + }); } diff --git a/test/runtime/event_delivery_runtime_test.dart b/test/runtime/event_delivery_runtime_test.dart new file mode 100644 index 0000000..80b55b3 --- /dev/null +++ b/test/runtime/event_delivery_runtime_test.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('retry never reruns transformers or redelivers successful targets', + () async { + var transforms = 0; + final success = _Tracker('success'); + final retry = _Tracker('retry', failCount: 1); + final client = await _client([success, retry]); + client.addTransformer((event) { + transforms++; + return EnrichedEvent(event, const {'transform': 'once'}); + }); + + final first = await client.track(_Event('event-1')); + expect(first.queuedTrackerIds, ['retry']); + await client.flushQueue(); + + expect(transforms, 1); + expect(success.events, hasLength(1)); + expect(retry.events, hasLength(2)); + expect(retry.events.last.properties, containsPair('transform', 'once')); + expect(await client.queuedEventCount, 0); + }); + + test('concurrent flush calls cannot duplicate a queued delivery', () async { + final gate = Completer(); + final tracker = _Tracker('analytics', gate: gate.future); + final queue = InMemoryEventQueue(); + final client = await _client([tracker], queue: queue); + await queue.enqueue(QueuedEvent( + event: _Event('event-1'), + trackerIds: const ['analytics'], + )); + + final first = client.flushQueue(); + final second = client.flushQueue(); + await Future.delayed(Duration.zero); + expect(tracker.events, hasLength(1)); + gate.complete(); + + expect((await first).deliveredEvents, 1); + expect((await second).attemptedEvents, 0); + expect(tracker.events, hasLength(1)); + }); + + test('offline flush does not deliver queue or flush tracker SDKs', () async { + final tracker = _Tracker('analytics'); + final queue = InMemoryEventQueue(); + await queue.enqueue(QueuedEvent( + event: _Event('event-1'), + trackerIds: const ['analytics'], + )); + final client = await _client( + [tracker], + queue: queue, + onlineProvider: () => false, + ); + + final result = await client.flush(); + + expect(result.attemptedEvents, 0); + expect(result.deliveredEvents, 0); + expect(result.remainingEvents, 1); + expect(tracker.events, isEmpty); + expect(tracker.flushCount, 0); + expect(await client.queuedEventCount, 1); + }); + + test('queue read result cannot mutate in-memory queue state', () async { + final queue = InMemoryEventQueue(); + await queue.enqueue(QueuedEvent( + event: _Event('event-1'), + trackerIds: const ['analytics'], + )); + final result = await queue.read(limit: 10); + + expect(() => result.clear(), throwsUnsupportedError); + expect(() => result.single.trackerIds.clear(), throwsUnsupportedError); + expect(await queue.size(), 1); + }); + + test('serialization failure never replaces a valid durable queue', () async { + final directory = + await Directory.systemTemp.createTemp('flextrack-atomic-'); + addTearDown(() => directory.delete(recursive: true)); + final file = File('${directory.path}/queue.json'); + final queue = FileEventQueue(file); + await queue.enqueue(QueuedEvent( + event: _Event('valid'), + trackerIds: const ['analytics'], + )); + final originalBytes = await file.readAsBytes(); + + await expectLater( + queue.enqueue(QueuedEvent( + event: _UnsupportedEvent('invalid'), + trackerIds: const ['analytics'], + )), + throwsA(anything), + ); + expect(await file.readAsBytes(), originalBytes); + expect((await queue.read(limit: 10)).single.id, 'valid'); + }); +} + +Future _client( + List<_Tracker> trackers, { + EventQueue? queue, + bool Function()? onlineProvider, +}) => + FlexTrackClient.create( + trackers, + queue: queue, + onlineProvider: onlineProvider, + routing: RoutingConfiguration( + rules: [ + RoutingRule( + targetGroup: TrackerGroup( + 'all', + trackers.map((value) => value.id).toList(), + ), + requireConsent: false, + ), + ], + ), + ); + +class _Event extends BaseEvent { + _Event(String id) : super(eventId: id, timestamp: DateTime.utc(2026, 8, 17)); + @override + String get name => 'purchase'; + @override + Map get properties => const {'plan': 'pro'}; + @override + bool get requiresConsent => false; +} + +class _UnsupportedEvent extends _Event { + _UnsupportedEvent(super.id); + @override + Map get properties => {'unsupported': Object()}; +} + +class _Tracker extends TrackerStrategy { + _Tracker(this.id, {this.failCount = 0, this.gate}); + @override + final String id; + int failCount; + final Future? gate; + final List events = []; + int flushCount = 0; + bool _enabled = true; + @override + String get name => id; + @override + bool get isEnabled => _enabled; + @override + Future initialize() async {} + @override + Future track(BaseEvent event) async { + events.add(event); + if (gate != null) await gate; + if (failCount > 0) { + failCount--; + throw StateError('failure'); + } + } + + @override + Future flush() async { + flushCount++; + } + + @override + void enable() => _enabled = true; + @override + void disable() => _enabled = false; +} diff --git a/test/runtime/file_event_queue_test.dart b/test/runtime/file_event_queue_test.dart new file mode 100644 index 0000000..ef42188 --- /dev/null +++ b/test/runtime/file_event_queue_test.dart @@ -0,0 +1,87 @@ +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory directory; + late File file; + + setUp(() async { + directory = await Directory.systemTemp.createTemp('flextrack-queue-'); + file = File('${directory.path}/queue.json'); + }); + + tearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + + test('survives recreation and preserves event identity and metadata', + () async { + final original = _Event('stable-id'); + await FileEventQueue(file).enqueue( + QueuedEvent(event: original, trackerIds: const ['a', 'b']), + ); + + final restored = (await FileEventQueue(file).read(limit: 10)).single; + expect(restored.id, original.eventId); + expect(restored.event.timestamp, original.timestamp); + expect(restored.event.name, original.name); + expect(restored.event.properties, original.properties); + expect(restored.trackerIds, ['a', 'b']); + }); + + test('malformed JSON fails visibly without deleting persisted bytes', + () async { + await file.writeAsString('{broken'); + final queue = FileEventQueue(file); + + await expectLater(queue.read(limit: 10), throwsFormatException); + expect(await file.readAsString(), '{broken'); + }); + + test('invalid persisted shape fails visibly', () async { + await file.writeAsString('{}'); + await expectLater(FileEventQueue(file).size(), throwsFormatException); + }); + + test('concurrent enqueue operations are serialized without loss', () async { + final queue = FileEventQueue(file); + await Future.wait([ + for (var i = 0; i < 50; i++) + queue.enqueue(QueuedEvent( + event: _Event('event-$i'), + trackerIds: const ['analytics'], + )), + ]); + + expect(await queue.size(), 50); + expect( + (await queue.read(limit: 50)).map((value) => value.id), + [for (var i = 0; i < 50; i++) 'event-$i'], + ); + }); + + test('non-positive read limit does not mutate storage', () async { + final queue = FileEventQueue(file); + await queue + .enqueue(QueuedEvent(event: _Event('one'), trackerIds: const ['a'])); + await expectLater(queue.read(limit: 0), throwsArgumentError); + expect(await queue.size(), 1); + }); +} + +class _Event extends BaseEvent { + _Event(String id) + : super(eventId: id, timestamp: DateTime.utc(2026, 8, 17, 12, 30)); + @override + String get name => 'purchase'; + @override + Map get properties => const { + 'plan': 'pro', + 'nested': {'enabled': true}, + 'items': [1, 'two'], + }; + @override + bool get requiresConsent => false; +} diff --git a/test/runtime/tracker_lifecycle_runtime_test.dart b/test/runtime/tracker_lifecycle_runtime_test.dart new file mode 100644 index 0000000..33ecb7d --- /dev/null +++ b/test/runtime/tracker_lifecycle_runtime_test.dart @@ -0,0 +1,69 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'partial initialization failure rolls back and retry attempts all trackers', + () async { + final healthy = _LifecycleTracker('healthy'); + final flaky = _LifecycleTracker('flaky', initializeFailures: 1); + final client = await FlexTrackClient.create( + [healthy, flaky], + autoInitialize: false, + ); + + await expectLater( + client.initialize(), throwsA(isA())); + expect(client.isInitialized, isFalse); + expect(healthy.initializeCalls, 1); + expect(healthy.disposeCalls, 1); + + await client.initialize(); + expect(client.isInitialized, isTrue); + expect(healthy.initializeCalls, 2); + expect(flaky.initializeCalls, 2); + }); + + test('client disposal releases initialized trackers exactly once', () async { + final tracker = _LifecycleTracker('tracker'); + final client = await FlexTrackClient.create([tracker]); + + await client.dispose(); + await client.dispose(); + + expect(tracker.disposeCalls, 1); + expect(client.isInitialized, isFalse); + }); +} + +class _LifecycleTracker extends TrackerStrategy + implements DisposableTrackerStrategy { + _LifecycleTracker(this.id, {this.initializeFailures = 0}); + @override + final String id; + int initializeFailures; + int initializeCalls = 0; + int disposeCalls = 0; + bool _enabled = true; + @override + String get name => id; + @override + bool get isEnabled => _enabled; + @override + Future initialize() async { + initializeCalls++; + if (initializeFailures > 0) { + initializeFailures--; + throw StateError('initialize failed'); + } + } + + @override + Future track(BaseEvent event) async {} + @override + Future dispose() async => disposeCalls++; + @override + void enable() => _enabled = true; + @override + void disable() => _enabled = false; +}