Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ One call site, multiple tracker destinations, centralized policy.
- [FlexTrackClient and dependency injection](#flextrackclient-and-dependency-injection)
- [Riverpod](#riverpod)
- [Bloc / Cubit](#bloc--cubit)
- [Offline delivery and selective retry](#offline-delivery-and-selective-retry)
- [Design philosophy](#design-philosophy)
- [Creating events](#creating-events)
- [Event flags](#event-flags)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -305,6 +306,37 @@ class CheckoutCubit extends Cubit<CheckoutState> {

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).

---
Expand Down
126 changes: 126 additions & 0 deletions doc/runtime-delivery-specification.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 10 additions & 1 deletion example/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:
Expand Down
58 changes: 58 additions & 0 deletions example/integration_test/flex_track_smoke_test.dart
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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);
});
}
2 changes: 0 additions & 2 deletions example/ios/Flutter/AppFrameworkInfo.plist
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>
13 changes: 0 additions & 13 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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

Expand Down
Loading
Loading