import 'dart:async'; import 'dart:collection'; import 'dart:math'; enum JourneyKind { login, signup, verification, kyc, topUp, exchange, payout, security, } enum JourneyStep { login, signup, emailDelivery, emailConfirmation, kycPreparation, kycPassport, kycSelfie, kycSubmission, kycStatus, transferDraft, transferQuote, transferSubmission, settlement, factorChallenge, factorDelivery, factorVerification, factorSettings, } enum JourneySignal { cancelled, started, requested, succeeded, simulated, failed, abandoned, } enum JourneyFailure { insufficientFunds, limitExceeded, declined, document, 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 { final area = switch (kind) { JourneyKind.kyc => 'kyc', JourneyKind.topUp => 'top_up', JourneyKind.exchange => 'exchange', JourneyKind.payout => 'payout', JourneyKind.security => 'security', _ => 'auth', }; var stage = step == null ? 'journey' : _snake(step!.name); if (kind == JourneyKind.kyc) stage = stage.replaceFirst('kyc_', ''); return '${area}_${stage}_${signal.name}'; } Map 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 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 _events = Queue(); List get events => List.unmodifiable(_events); void clear() => _events.clear(); @override Future 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, bool cancelled = false, }) { final journey = operation._journey; if (_disposed || operation._finished || journey.closed) return; operation._finished = true; journey.pending--; journey.simulated |= simulated; final complete = !cancelled && terminal; _emit( journey, cancelled ? JourneySignal.cancelled : 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.sync(() => service.capture(event)).catchError((Object _) {}), ); } void dispose({bool abandonActive = true}) { if (abandonActive) abandon(JourneyExit.disposed); _disposed = true; _idle?.cancel(); } }