Files
sxchnge/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart
T

266 lines
6.9 KiB
Dart

import 'dart:async';
import 'dart:collection';
import 'dart:math';
enum JourneyKind { login, signup, verification, kyc }
enum JourneyStep {
login,
signup,
emailDelivery,
emailConfirmation,
kycPreparation,
kycPassport,
kycSelfie,
kycSubmission,
kycStatus,
}
enum JourneySignal {
cancelled,
started,
requested,
succeeded,
simulated,
failed,
abandoned,
}
enum JourneyFailure {
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 = kind == JourneyKind.kyc ? 'kyc' : '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 (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,
}) {
final journey = operation._journey;
if (_disposed || operation._finished || journey.closed) return;
operation._finished = true;
journey.pending--;
journey.simulated |= simulated;
final complete = failure == null && !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<void>.sync(() => service.capture(event)).catchError((Object _) {}),
);
}
void dispose() {
abandon(JourneyExit.disposed);
_disposed = true;
_idle?.cancel();
}
}