Files
sxchnge/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart
T
Stephan D f1cbf64282 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.
2026-09-12 08:36:45 +02:00

246 lines
6.5 KiB
Dart

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();
}
}