Fix onboarding UX and add replaceable journey analytics
Make recipient changes explicit, prioritize verification retry, and label simulated outcomes prominently. Add shared typed analytics with bounded dummy capture, correlated service events and documented PostHog adapter contract. Validate privacy, retries, abandonment, keyboard recovery and accessibility. Related to #12.
This commit is contained in:
1 parent
bf7778f312
commit
f1cbf64282
28 files changed
+1158
-64
No files matched your search
@@ -0,0 +1,3 @@
|
||||
.dart_tool/
|
||||
build/
|
||||
pubspec.lock
|
||||
@@ -0,0 +1,21 @@
|
||||
# SXCHNGE analytics
|
||||
|
||||
Shared pure-Dart analytics contract, semantic journey recorder and bounded
|
||||
in-memory dummy service. No PostHog SDK, network, persistence or logging.
|
||||
|
||||
```dart
|
||||
final analytics = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(analytics);
|
||||
final operation = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.finish(operation, simulated: true, terminal: true);
|
||||
final captured = analytics.events; // immutable snapshot
|
||||
recorder.dispose();
|
||||
```
|
||||
|
||||
Inject an `AnalyticsService` implementation to replace the sink. Consumers do not
|
||||
await analytics. See [the auth journey contract](../../../docs/ux/auth-journeys.md)
|
||||
for schema, metric definitions, simulation rules, privacy, retry/abandonment
|
||||
semantics, PostHog adapter guidance and measurement limitations.
|
||||
|
||||
Run `flutter test` and `flutter analyze` here. The production library imports
|
||||
only Dart SDK libraries and can be reused by web and mobile.
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
enum JourneyKind { login, signup, verification }
|
||||
|
||||
enum JourneyStep { login, signup, emailDelivery, emailConfirmation }
|
||||
|
||||
enum JourneySignal {
|
||||
started,
|
||||
requested,
|
||||
succeeded,
|
||||
simulated,
|
||||
failed,
|
||||
abandoned,
|
||||
}
|
||||
|
||||
enum JourneyFailure {
|
||||
invalidCredentials,
|
||||
emailInUse,
|
||||
unavailable,
|
||||
unexpected,
|
||||
expired,
|
||||
invalid,
|
||||
internal,
|
||||
}
|
||||
|
||||
enum JourneyExit { navigation, modeChange, inactivity, disposed }
|
||||
|
||||
String _snake(String value) =>
|
||||
value.replaceAllMapped(RegExp('[A-Z]'), (m) => '_${m[0]!.toLowerCase()}');
|
||||
String _id() {
|
||||
final random = Random.secure();
|
||||
return List.generate(
|
||||
16,
|
||||
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
}
|
||||
|
||||
/// Only typed, allowlisted fields; never accepts form data, URLs or exceptions.
|
||||
class AnalyticsEvent {
|
||||
AnalyticsEvent({
|
||||
required this.journeyId,
|
||||
required this.kind,
|
||||
required this.signal,
|
||||
required this.sequence,
|
||||
required this.elapsedMs,
|
||||
this.step,
|
||||
this.operation,
|
||||
this.failure,
|
||||
this.exit,
|
||||
this.terminal = false,
|
||||
this.simulated = false,
|
||||
}) : eventId = _id(),
|
||||
occurredAt = DateTime.now().toUtc();
|
||||
final String eventId;
|
||||
final String journeyId;
|
||||
final JourneyKind kind;
|
||||
final JourneySignal signal;
|
||||
final JourneyStep? step;
|
||||
final JourneyFailure? failure;
|
||||
final JourneyExit? exit;
|
||||
final int sequence;
|
||||
final int? operation;
|
||||
final int elapsedMs;
|
||||
final bool terminal;
|
||||
final bool simulated;
|
||||
final DateTime occurredAt;
|
||||
String get name =>
|
||||
'auth_${step == null ? 'journey' : _snake(step!.name)}_${signal.name}';
|
||||
Map<String, Object> get properties => Map.unmodifiable({
|
||||
'schema_version': 1,
|
||||
'event_id': eventId,
|
||||
'journey_id': journeyId,
|
||||
'journey_kind': kind.name,
|
||||
'sequence': sequence,
|
||||
'elapsed_ms': elapsedMs,
|
||||
'occurred_at': occurredAt.toIso8601String(),
|
||||
'terminal': terminal,
|
||||
'simulated': simulated,
|
||||
if (step != null) 'step': _snake(step!.name),
|
||||
if (operation != null) 'operation': operation!,
|
||||
if (failure != null) 'failure': _snake(failure!.name),
|
||||
if (exit != null) 'exit': _snake(exit!.name),
|
||||
});
|
||||
}
|
||||
|
||||
abstract interface class AnalyticsService {
|
||||
Future<void> capture(AnalyticsEvent event);
|
||||
}
|
||||
|
||||
/// Bounded, process-local inspection only. No disk, logs, network or SDK.
|
||||
class DummyAnalyticsService implements AnalyticsService {
|
||||
DummyAnalyticsService({this.capacity = 500}) : assert(capacity > 0);
|
||||
final int capacity;
|
||||
final Queue<AnalyticsEvent> _events = Queue();
|
||||
List<AnalyticsEvent> get events => List.unmodifiable(_events);
|
||||
void clear() => _events.clear();
|
||||
@override
|
||||
Future<void> capture(AnalyticsEvent event) async {
|
||||
if (_events.any((item) => item.eventId == event.eventId)) return;
|
||||
if (_events.length == capacity) _events.removeFirst();
|
||||
_events.add(event);
|
||||
}
|
||||
}
|
||||
|
||||
class _Journey {
|
||||
_Journey(this.kind);
|
||||
final JourneyKind kind;
|
||||
final String id = _id();
|
||||
final Stopwatch time = Stopwatch()..start();
|
||||
int sequence = 0;
|
||||
int operations = 0;
|
||||
int pending = 0;
|
||||
bool closed = false;
|
||||
bool simulated = false;
|
||||
}
|
||||
|
||||
class JourneyOperation {
|
||||
JourneyOperation._(this._journey, this.step, this.number);
|
||||
final _Journey _journey;
|
||||
final JourneyStep step;
|
||||
final int number;
|
||||
bool _finished = false;
|
||||
}
|
||||
|
||||
/// One foreground auth journey. Retried operations share a journey, not outcomes.
|
||||
class JourneyRecorder {
|
||||
JourneyRecorder(
|
||||
this.service, {
|
||||
this.idleWindow = const Duration(minutes: 30),
|
||||
});
|
||||
final AnalyticsService service;
|
||||
final Duration idleWindow;
|
||||
_Journey? _active;
|
||||
Timer? _idle;
|
||||
bool _disposed = false;
|
||||
|
||||
JourneyOperation begin(
|
||||
JourneyKind kind,
|
||||
JourneyStep step, {
|
||||
bool onboarding = false,
|
||||
bool simulated = false,
|
||||
}) {
|
||||
var journey = _active;
|
||||
if (journey == null ||
|
||||
journey.closed ||
|
||||
(journey.kind != kind &&
|
||||
!(onboarding && journey.kind == JourneyKind.signup))) {
|
||||
abandon(JourneyExit.navigation);
|
||||
journey = _Journey(kind)..simulated = simulated;
|
||||
_active = journey;
|
||||
_emit(journey, JourneySignal.started);
|
||||
}
|
||||
journey.simulated |= simulated;
|
||||
_idle?.cancel();
|
||||
journey.pending++;
|
||||
final operation = JourneyOperation._(journey, step, ++journey.operations);
|
||||
_emit(journey, JourneySignal.requested, operation: operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
void finish(
|
||||
JourneyOperation operation, {
|
||||
bool simulated = false,
|
||||
JourneyFailure? failure,
|
||||
bool terminal = false,
|
||||
}) {
|
||||
final journey = operation._journey;
|
||||
if (_disposed || operation._finished || journey.closed) return;
|
||||
operation._finished = true;
|
||||
journey.pending--;
|
||||
journey.simulated |= simulated;
|
||||
final complete = failure == null && terminal;
|
||||
_emit(
|
||||
journey,
|
||||
failure != null
|
||||
? JourneySignal.failed
|
||||
: (simulated || (complete && journey.simulated))
|
||||
? JourneySignal.simulated
|
||||
: JourneySignal.succeeded,
|
||||
operation: operation,
|
||||
failure: failure,
|
||||
terminal: complete,
|
||||
simulated: simulated || (complete && journey.simulated),
|
||||
);
|
||||
if (complete) {
|
||||
journey.closed = true;
|
||||
_idle?.cancel();
|
||||
} else if (journey.pending == 0) {
|
||||
_idle?.cancel();
|
||||
_idle = Timer(idleWindow, () => abandon(JourneyExit.inactivity));
|
||||
}
|
||||
}
|
||||
|
||||
void abandon(JourneyExit reason) {
|
||||
_idle?.cancel();
|
||||
final journey = _active;
|
||||
if (_disposed || journey == null || journey.closed) return;
|
||||
_emit(
|
||||
journey,
|
||||
JourneySignal.abandoned,
|
||||
terminal: true,
|
||||
exit: reason,
|
||||
simulated: journey.simulated,
|
||||
);
|
||||
journey.closed = true;
|
||||
}
|
||||
|
||||
void _emit(
|
||||
_Journey journey,
|
||||
JourneySignal signal, {
|
||||
JourneyOperation? operation,
|
||||
JourneyFailure? failure,
|
||||
JourneyExit? exit,
|
||||
bool terminal = false,
|
||||
bool simulated = false,
|
||||
}) {
|
||||
if (_disposed) return;
|
||||
final event = AnalyticsEvent(
|
||||
journeyId: journey.id,
|
||||
kind: journey.kind,
|
||||
signal: signal,
|
||||
sequence: ++journey.sequence,
|
||||
elapsedMs: journey.time.elapsedMilliseconds,
|
||||
step: operation?.step,
|
||||
operation: operation?.number,
|
||||
failure: failure,
|
||||
exit: exit,
|
||||
terminal: terminal,
|
||||
simulated: simulated || journey.simulated,
|
||||
);
|
||||
// Adapters may throw synchronously, fail asynchronously or never finish.
|
||||
// None may delay or change the authentication operation.
|
||||
unawaited(
|
||||
Future<void>.sync(() => service.capture(event)).catchError((Object _) {}),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
abandon(JourneyExit.disposed);
|
||||
_disposed = true;
|
||||
_idle?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
name: sxchnge_analytics
|
||||
description: Privacy-minimal journey analytics contracts and in-memory dummy service.
|
||||
publish_to: none
|
||||
version: 0.1.0
|
||||
environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
class BrokenService implements AnalyticsService {
|
||||
BrokenService(this.mode);
|
||||
final int mode;
|
||||
@override
|
||||
Future<void> capture(AnalyticsEvent event) {
|
||||
if (mode == 0) throw StateError('offline');
|
||||
if (mode == 1) return Future.error(StateError('offline'));
|
||||
return Completer<void>().future;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('signup through verification shares ID; retries and duplicates do not inflate outcomes', () {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final signup = recorder.begin(JourneyKind.signup, JourneyStep.signup);
|
||||
recorder.finish(signup, simulated: true);
|
||||
final send = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailDelivery,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(send, simulated: true);
|
||||
final failed = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailConfirmation,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(failed, failure: JourneyFailure.expired);
|
||||
final retry = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailConfirmation,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(retry, simulated: true, terminal: true);
|
||||
recorder.finish(retry, simulated: true, terminal: true);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
expect(sink.events.last.signal, JourneySignal.simulated);
|
||||
expect(sink.events.last.operation, 4);
|
||||
expect(
|
||||
sink.events.map((e) => e.sequence),
|
||||
orderedEquals(List.generate(9, (i) => i + 1)),
|
||||
);
|
||||
expect(
|
||||
sink.events.any((e) => e.properties.toString().contains('email_address')),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
test(
|
||||
'explicit exit records once and suppresses late operation completion',
|
||||
() {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.abandon(JourneyExit.modeChange);
|
||||
recorder.abandon(JourneyExit.navigation);
|
||||
recorder.finish(op, terminal: true);
|
||||
expect(sink.events.last.signal, JourneySignal.abandoned);
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
final newOp = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.finish(newOp, terminal: true);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 2);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'inactivity waits for pending operations and starts after settled failure',
|
||||
() async {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(
|
||||
sink,
|
||||
idleWindow: const Duration(milliseconds: 10),
|
||||
);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
expect(sink.events.any((e) => e.terminal), isFalse);
|
||||
recorder.finish(op, failure: JourneyFailure.unavailable);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
expect(sink.events.last.exit, JourneyExit.inactivity);
|
||||
},
|
||||
);
|
||||
for (final mode in [0, 1, 2]) {
|
||||
test('analytics failure mode $mode never blocks outcomes', () async {
|
||||
final recorder = JourneyRecorder(BrokenService(mode));
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.finish(op, terminal: true);
|
||||
recorder.dispose();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
});
|
||||
}
|
||||
test(
|
||||
'dummy is bounded, immutable and deduplicates repeated event IDs',
|
||||
() async {
|
||||
final sink = DummyAnalyticsService(capacity: 2);
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
final event = sink.events.last;
|
||||
await sink.capture(event);
|
||||
expect(sink.events.length, 2);
|
||||
recorder.finish(op, terminal: true);
|
||||
expect(sink.events.length, 2);
|
||||
expect(() => sink.events.clear(), throwsUnsupportedError);
|
||||
expect(() => event.properties['password'] = 'no', throwsUnsupportedError);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user