308 lines
7.8 KiB
Dart
308 lines
7.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
import 'dart:math';
|
|
|
|
enum JourneyKind {
|
|
login,
|
|
signup,
|
|
verification,
|
|
kyc,
|
|
topUp,
|
|
exchange,
|
|
payout,
|
|
security,
|
|
destination,
|
|
}
|
|
|
|
enum DestinationResolution { created, duplicate }
|
|
|
|
enum JourneyStep {
|
|
login,
|
|
signup,
|
|
emailDelivery,
|
|
emailConfirmation,
|
|
kycPreparation,
|
|
kycPassport,
|
|
kycSelfie,
|
|
kycSubmission,
|
|
kycStatus,
|
|
destinationDraft,
|
|
destinationSave,
|
|
destinationReconciliation,
|
|
destinationBinding,
|
|
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.resolution,
|
|
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 DestinationResolution? resolution;
|
|
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',
|
|
JourneyKind.destination => 'destination',
|
|
_ => 'auth',
|
|
};
|
|
var stage = step == null ? 'journey' : _snake(step!.name);
|
|
if (kind == JourneyKind.kyc) stage = stage.replaceFirst('kyc_', '');
|
|
return '${area}_${stage}_${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 (resolution != null) 'destination_resolution': resolution!.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,
|
|
bool cancelled = false,
|
|
DestinationResolution? resolution,
|
|
}) {
|
|
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,
|
|
resolution: resolution,
|
|
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,
|
|
DestinationResolution? resolution,
|
|
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,
|
|
resolution: resolution,
|
|
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({bool abandonActive = true}) {
|
|
if (abandonActive) abandon(JourneyExit.disposed);
|
|
_disposed = true;
|
|
_idle?.cancel();
|
|
}
|
|
}
|