Files
m8chat-app2/lib/core/analytics/session_journey.dart
T
help4bisandClaude Sonnet 5 7c9e730cf0 fix(analytics): resolve sessionId before assigning trackScreen state (v1.12.1+17)
The sessionId getter mints a fresh session on cold start or after a
30-min lapse, and as a side effect nulls _currentScreenName/_currentStepUid.
trackScreen() set those fields before calling the sessionId getter, so the
mint-on-cold-start side effect could immediately null the fields it had
just set, breaking the same-screen idempotency check and firing a
duplicate session_enter right after cold start.

Fix: resolve visitorId and sessionId into local variables before
assigning _currentScreenName/_currentStepUid, and use the locals in the
_enqueue() payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 14:58:56 +10:00

686 lines
24 KiB
Dart

/// Session / screen-journey / friction tracker for the Flutter estate.
///
/// Ports the WordPress sites' SessionJourney module (help4bis-siteanalytics)
/// to Flutter, per plan `ancient-petting-hejlsberg.md` §3. One file, written
/// once and copied into each in-scope app's `lib/core/analytics/` with the
/// [sessionJourneyAppKey] differing per copy (no shared pub workspace exists
/// across these repos, so a copy-with-version-header is the right-sized
/// solution). This copy is wired for M8Chat (`m8chat.au-app`), rolled out
/// after the GardenBuddy pilot (Phase 2) per the plan's §5 rollout order.
///
/// Talks to `stats.help4bis.com/track.php` v2 via its `action`-based JSON
/// actions, NOT the legacy `{s,u,w,r}` pageview beacon (M8Chat currently has
/// no such beacon in `web/index.html` at all — out of scope here, per the
/// plan this task covers SessionJourney only).
///
/// ## Where this copy differs from GardenBuddy's (read before copying again)
///
/// **Persistence.** GardenBuddy's copy depends on `core/offline/offline_cache.dart`,
/// a shared module this repo does not have (verified by reading this repo
/// before writing this file — no `shared_preferences`, no existing
/// localStorage abstraction; only `flutter_secure_storage`, async-only and
/// reserved for the one real secret this app persists, the Matrix device
/// id). This copy uses a small sibling module, `sj_local_store.dart`
/// (+ `_stub`/`_web` platform split), hand-rolled the same way GardenBuddy's
/// own store is, scoped to SessionJourney's own keys only. See that file's
/// header for the full reasoning.
///
/// **Routing / screen naming.** M8Chat uses GoRouter (`MaterialApp.router`,
/// `lib/app/router.dart`), not GardenBuddy's Navigator 1.0 + IndexedStack
/// tabs, and its routes are plain `path:`-based `GoRoute`s with no explicit
/// `name:`. Unlike GardenBuddy's copy (which had to document an
/// `"unnamed_route"` gap), M8Chat gets real per-screen names for free:
/// go_router 17.2.0's page builder (`builder.dart`) sets
/// `name: state.name ?? state.path`, so `route.settings.name` resolves to
/// the route's path (`/rooms`, `/rooms/:roomId`, `/profile`, `/spaces`,
/// `/jitsi`, `/welcome`, `/login`, `/calls/:roomId`) — verified by reading
/// the installed go_router source, not assumed. So
/// [SessionJourneyNavigatorObserver] alone (registered on `GoRouter`'s
/// `observers:`) covers every screen in this app; there is no per-tab
/// `trackScreen` call to add anywhere, unlike GardenBuddy's `HomeShell`.
///
/// **Error-handler chaining.** `lib/main.dart` already sets both
/// `FlutterError.onError` and `PlatformDispatcher.instance.onError` to
/// `debugPrint`-only handlers. [SessionJourney.init] chains to whatever is
/// already assigned at the point it runs (unconditionally, same as
/// GardenBuddy's copy) — the caller in `main()` must call [SessionJourney.init]
/// *after* those two assignments so this module captures them as
/// `previousFlutterOnError`/`previousPlatformOnError` rather than being
/// captured itself and silently dropped.
///
/// Everything else (identity, sliding session window, heartbeat cadence,
/// rage-tap clustering, queue/flush shape, server contract) is unchanged
/// from the GardenBuddy copy — see that file's header for the full design
/// rationale, including the documented `flutter_visitors_v2.session_count`
/// accounting gap in track.php v2 (re-sending `session_enter` per screen
/// change is the only way the deployed server creates a
/// `flutter_screen_views` row per screen; this is a Phase-1 backend gap,
/// not a client bug, and applies identically here).
///
/// Version: 1.0.1 | Created: 2026-09-25 | Updated: 2026-09-25
/// v1.0.1: trackScreen() resolved visitorId/sessionId into locals
/// (resolvedVisitorId/resolvedSessionId) BEFORE assigning
/// _currentScreenName/_currentStepUid, so the sessionId getter's
/// mint-on-cold-start side effect (which nulls those fields) can no
/// longer race the same-screen idempotency check and cause a duplicate
/// session_enter right after cold start.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'sj_local_store.dart';
/// The one line that differs (besides the header comment) between per-app
/// copies of this file. Must match an entry in track.php v2's
/// `$allowed_sites`, confirmed by reading
/// `sites/stats.help4bis.com/track.php` on brisbane01 directly before this
/// file was written: `m8chat.au-app` / origin `https://app2.m8chat.au`.
const String sessionJourneyAppKey = 'm8chat.au-app';
const String _trackOrigin = 'https://stats.help4bis.com';
const String _trackPath = '/track.php';
/// Sliding session window: a gap shorter than this while backgrounded
/// resumes the same session; longer, or no persisted timestamp at all
/// (cold start with nothing recent), starts a fresh one.
const Duration sessionTimeout = Duration(minutes: 30);
/// Flush triggers: whichever comes first.
const int flushBatchSize = 25;
const Duration flushInterval = Duration(seconds: 10);
/// How often an open screen sends a heartbeat while it stays the active one.
const Duration heartbeatInterval = Duration(seconds: 15);
/// Oldest-dropped cap on the durable outbox, generous for a normal session.
const int maxQueueLength = 300;
const Duration _requestTimeout = Duration(seconds: 6);
/// Rage-tap clustering: >= this many PointerDownEvents within [rageTapWindow]
/// and [rageTapProximityPx] of each other counts as one rage-tap cluster.
const int rageTapThreshold = 4;
const Duration rageTapWindow = Duration(milliseconds: 1500);
const double rageTapProximityPx = 40;
/// Engagement event kinds the server allowlists (see track.php's
/// `$kind_allowlist`). Kept here so a bad call fails fast, client side.
enum EngagementKind { rageTap, error, elementSeen, custom }
extension on EngagementKind {
String get wireValue => switch (this) {
EngagementKind.rageTap => 'rage_tap',
EngagementKind.error => 'error',
EngagementKind.elementSeen => 'element_seen',
EngagementKind.custom => 'custom',
};
}
/// A fresh UUIDv4 (RFC 4122 §4.4), generated locally. No `uuid` package
/// dependency, and track.php's `is_uuid_v4()` regex requires the real shape
/// (version nibble `4`, variant nibble `8-b`).
String newUuidV4({Random? random}) {
final rnd = random ?? Random.secure();
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-'
'${hex.substring(20)}';
}
/// One queued request to track.php: an action name plus its JSON body
/// (already including `action`/`appKey`/etc.), persisted verbatim.
@immutable
class _QueuedRequest {
const _QueuedRequest({required this.body, required this.queuedAt});
final Map<String, Object?> body;
final DateTime queuedAt;
Map<String, Object?> toJson() => {
'body': body,
'queued_at': queuedAt.toIso8601String(),
};
static _QueuedRequest? fromJson(Object? json) {
if (json is! Map) {
return null;
}
final body = json['body'];
if (body is! Map) {
return null;
}
final queuedAt = DateTime.tryParse('${json['queued_at']}');
if (queuedAt == null) {
return null;
}
return _QueuedRequest(
body: Map<String, Object?>.from(body),
queuedAt: queuedAt,
);
}
}
/// One pending pointer-down, kept only long enough to cluster it.
class _PendingTap {
_PendingTap(this.at, this.position);
final DateTime at;
final Offset position;
}
/// Session / journey / friction tracker. One instance per app run, reached
/// through [SessionJourney.instance] after [SessionJourney.init] runs once
/// in `main()`, before `runApp`.
class SessionJourney extends WidgetsBindingObserver {
SessionJourney._({
required this.appVersion,
http.Client? client,
SjLocalStore? cache,
DateTime Function()? now,
Duration flushEvery = flushInterval,
Duration heartbeatEvery = heartbeatInterval,
}) : _client = client ?? http.Client(),
_cache = cache ?? SjLocalStore(),
_now = now ?? DateTime.now,
_flushEvery = flushEvery,
_heartbeatEvery = heartbeatEvery;
/// The app's own version string (from `pubspec.yaml`, passed in by
/// `main()` and kept in sync by hand, same manual-sync convention this
/// repo already accepts for version-header comments).
final String appVersion;
final http.Client _client;
final SjLocalStore _cache;
final DateTime Function() _now;
final Duration _flushEvery;
final Duration _heartbeatEvery;
static SessionJourney? _instance;
/// The running instance. Throws if read before [init] has run; every
/// call site in this app runs after `main()`'s [init] call, so that is
/// a programming error, not a runtime condition to handle gracefully.
static SessionJourney get instance {
final i = _instance;
if (i == null) {
throw StateError('SessionJourney.init() has not run yet.');
}
return i;
}
/// The running instance, or `null` if [init] has not run — for wiring
/// code exercised outside `main()`'s normal boot order (widget tests).
static SessionJourney? get instanceOrNull => _instance;
/// Boots the singleton, chains the global error handlers, and starts the
/// flush loop. Call once from `main()`, before `runApp` — and, in this
/// app specifically, *after* `main()`'s existing `FlutterError.onError`
/// and `PlatformDispatcher.instance.onError` assignments, so this chains
/// to them rather than being silently overwritten itself.
static SessionJourney init({
required String appVersion,
http.Client? client,
SjLocalStore? cache,
DateTime Function()? now,
}) {
final journey = SessionJourney._(
appVersion: appVersion,
client: client,
cache: cache,
now: now,
);
_instance = journey;
final previousFlutterOnError = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
journey.recordError(details.exceptionAsString());
previousFlutterOnError?.call(details);
};
final previousPlatformOnError = PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
journey.recordError(error.toString());
return previousPlatformOnError?.call(error, stack) ?? false;
};
journey.start();
return journey;
}
Timer? _flushTimer;
Timer? _heartbeatTimer;
Future<void> _inFlightFlush = Future<void>.value();
bool _flushPending = false;
// --- Identity ------------------------------------------------------------
String? _visitorId;
/// Persistent, app-scoped visitor id: generated once, read back every
/// launch. Never rotates.
String get visitorId {
final cached = _visitorId;
if (cached != null) {
return cached;
}
final stored = _cache.readJson(SjStoreKeys.visitorId);
if (stored is String && stored.isNotEmpty) {
_visitorId = stored;
return stored;
}
final fresh = newUuidV4();
_cache.writeJson(SjStoreKeys.visitorId, fresh);
_visitorId = fresh;
return fresh;
}
String? _sessionId;
DateTime? _sessionLastActive;
bool _sessionIdentityLoaded = false;
void _loadSessionIdentityOnce() {
if (_sessionIdentityLoaded) {
return;
}
_sessionIdentityLoaded = true;
final storedId = _cache.readJson(SjStoreKeys.sessionId);
final storedAt = _cache.readJson(SjStoreKeys.sessionLastActive);
if (storedId is String && storedId.isNotEmpty && storedAt is String) {
final parsed = DateTime.tryParse(storedAt);
if (parsed != null && _now().difference(parsed) <= sessionTimeout) {
_sessionId = storedId;
_sessionLastActive = parsed;
}
}
}
/// The current session id, minting (and persisting) a fresh one when the
/// sliding 30-minute window has lapsed, or on a cold start with no usable
/// persisted timestamp. Touching this always refreshes the "last active"
/// timestamp, so a session only actually expires after real inactivity.
String get sessionId {
_loadSessionIdentityOnce();
final last = _sessionLastActive;
final now = _now();
if (_sessionId == null ||
last == null ||
now.difference(last) > sessionTimeout) {
_sessionId = newUuidV4();
_cache.writeJson(SjStoreKeys.sessionId, _sessionId);
// A brand new session means whatever screen is "current" belongs to
// the old session; the next trackScreen() call re-enters it fresh.
_currentScreenName = null;
_currentStepUid = null;
}
_sessionLastActive = now;
_cache.writeJson(SjStoreKeys.sessionLastActive, now.toIso8601String());
return _sessionId!;
}
// --- Lifecycle -------------------------------------------------------------
/// Idempotent: replays any leftover outbox from a previous run before
/// anything new queues, then starts the periodic flush timer.
void start() {
WidgetsBinding.instance.addObserver(this);
unawaited(flush());
_flushTimer ??= Timer.periodic(_flushEvery, (_) => unawaited(flush()));
}
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_flushTimer?.cancel();
_flushTimer = null;
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_leaveCurrentScreen();
unawaited(flush());
}
}
// --- Screen tracking -------------------------------------------------------
String? _currentScreenName;
String? _currentStepUid;
DateTime? _currentScreenEnteredAt;
/// Enters [screenName] as the current screen: finalises the previous
/// screen (a `screen_leave` with its accumulated dwell/active seconds, if
/// one was open) and sends `session_enter` for the new one — the only
/// action the deployed track.php actually inserts a `flutter_screen_views`
/// row from.
///
/// Idempotent for repeated calls with the same [screenName].
void trackScreen(String screenName) {
if (screenName.isEmpty || screenName == _currentScreenName) {
return;
}
_leaveCurrentScreen();
// Resolve visitorId/sessionId BEFORE assigning _currentScreenName:
// sessionId's getter mints (and persists) a fresh session on a lapsed
// or cold window, and as a documented side effect of that mint it
// nulls _currentScreenName/_currentStepUid so the next trackScreen()
// re-enters cleanly under the new session. Reading it after already
// setting _currentScreenName below would immediately undo that
// assignment on every cold start, breaking this method's own
// same-screen idempotency and causing a duplicate session_enter on
// the very next call for the same screen. Found and fixed 2026-09-25.
final resolvedVisitorId = visitorId;
final resolvedSessionId = sessionId;
final stepUid = newUuidV4();
_currentScreenName = screenName;
_currentStepUid = stepUid;
_currentScreenEnteredAt = _now();
_startHeartbeatTimer();
_enqueue({
'action': 'session_enter',
'appKey': sessionJourneyAppKey,
'visitorId': resolvedVisitorId,
'sessionId': resolvedSessionId,
'stepUid': stepUid,
'platform': kIsWeb ? 'web' : 'unknown',
'appVersion': appVersion,
'referrer': null,
'screen': screenName,
});
}
void _startHeartbeatTimer() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(_heartbeatEvery, (_) => _heartbeat());
}
/// No idle/focus detection wired in v1: `activeSeconds` is treated as
/// equal to elapsed dwell time on the screen, a deliberate simplification.
void _heartbeat() {
final stepUid = _currentStepUid;
final enteredAt = _currentScreenEnteredAt;
if (stepUid == null || enteredAt == null) {
return;
}
final elapsed = _now().difference(enteredAt).inSeconds;
_enqueue({
'action': 'heartbeat',
'appKey': sessionJourneyAppKey,
'sessionId': sessionId,
'stepUid': stepUid,
'dwellSeconds': elapsed,
'activeSeconds': elapsed,
'scrollPct': null,
});
}
void _leaveCurrentScreen() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
final stepUid = _currentStepUid;
final enteredAt = _currentScreenEnteredAt;
if (stepUid == null || enteredAt == null) {
return;
}
final elapsed = _now().difference(enteredAt).inSeconds;
_enqueue({
'action': 'screen_leave',
'appKey': sessionJourneyAppKey,
'sessionId': sessionId,
'stepUid': stepUid,
'dwellSeconds': elapsed,
'activeSeconds': elapsed,
'scrollPct': null,
});
_currentStepUid = null;
_currentScreenEnteredAt = null;
}
// --- Engagement ------------------------------------------------------------
/// Queues one `engagement` event. Each call is its own request (`events`
/// as a single-item array) rather than coalescing several into one POST.
void _recordEngagement(
EngagementKind kind, {
String? label,
String? context,
String? value,
}) {
_enqueue({
'action': 'engagement',
'appKey': sessionJourneyAppKey,
'sessionId': sessionId,
'visitorId': visitorId,
'events': [
{
'stepUid': _currentStepUid,
'kind': kind.wireValue,
'label': label,
'context': context,
'value': value,
'occurredAt': _now().toIso8601String(),
},
],
});
}
/// One-off business events an app instruments itself
/// (`SessionJourney.instance.trackCustomEvent(...)`).
void trackCustomEvent(String label, String context, {num? value}) {
if (label.isEmpty) {
return;
}
_recordEngagement(
EngagementKind.custom,
label: label,
context: context,
value: value?.toString(),
);
}
/// Reported by [FlutterError.onError] / `PlatformDispatcher`'s handler
/// (wired in [init], chained after `main.dart`'s existing debugPrint
/// handlers), and available for a manual `try`/`catch` to call directly.
/// Truncated: never send a huge stack dump as `context`.
void recordError(String message) {
const maxLength = 300;
final truncated = message.length > maxLength
? '${message.substring(0, maxLength)}…'
: message;
_recordEngagement(
EngagementKind.error,
label: _currentScreenName ?? 'unknown_screen',
context: truncated,
);
}
// --- Rage-tap detection ------------------------------------------------
final List<_PendingTap> _recentTaps = [];
/// Called from [SessionJourneyGestureLayer]'s root `Listener` for every
/// `PointerDownEvent`. Clusters by time ([rageTapWindow]) and proximity
/// ([rageTapProximityPx]); firing a `rage_tap` engagement event resets the
/// cluster so the same burst cannot fire twice.
void recordTap(Offset position) {
final now = _now();
_recentTaps.removeWhere((t) => now.difference(t.at) > rageTapWindow);
_recentTaps.add(_PendingTap(now, position));
final cluster = _recentTaps
.where((t) => (t.position - position).distance <= rageTapProximityPx)
.toList();
if (cluster.length >= rageTapThreshold) {
final screen = _currentScreenName ?? 'unknown_screen';
_recordEngagement(
EngagementKind.rageTap,
label: screen,
context:
'${cluster.length} taps near '
'(${position.dx.round()},${position.dy.round()}) on $screen',
);
_recentTaps.clear();
}
}
// --- Queue / flush -----------------------------------------------------
Uri get _trackUri => Uri.parse('$_trackOrigin$_trackPath');
List<_QueuedRequest> _readQueue() {
final data = _cache.readJson(SjStoreKeys.queue);
if (data is! List) {
return [];
}
return data
.map(_QueuedRequest.fromJson)
.whereType<_QueuedRequest>()
.toList();
}
void _writeQueue(List<_QueuedRequest> queue) {
_cache.writeJson(SjStoreKeys.queue, queue.map((q) => q.toJson()).toList());
}
/// The queue as it stands right now. Visible for tests.
List<Map<String, Object?>> get queue =>
_readQueue().map((q) => q.body).toList();
void _enqueue(Map<String, Object?> body) {
final queue = _readQueue()
..add(_QueuedRequest(body: body, queuedAt: _now()));
while (queue.length > maxQueueLength) {
queue.removeAt(0); // Oldest dropped first.
}
_writeQueue(queue);
if (queue.length >= flushBatchSize) {
unawaited(flush());
} else {
_flushPending = true;
unawaited(flush());
}
}
/// Drains the persisted queue, one request at a time. Reentrant-safe: a
/// call while a sweep is already running shares that sweep's future
/// rather than racing it.
Future<void> flush() {
if (_flushPending == false && _readQueue().isEmpty) {
return Future<void>.value();
}
_flushPending = false;
final chained = _inFlightFlush.then((_) => _runFlush());
_inFlightFlush = chained;
return chained;
}
Future<void> _runFlush() async {
while (true) {
final current = _readQueue();
if (current.isEmpty) {
return;
}
final request = current.first;
final sent = await _post(request.body);
if (!sent) {
return; // Network failure: stop this sweep, retry next timer tick.
}
final remaining = _readQueue();
if (remaining.isEmpty) {
return;
}
_writeQueue(remaining.sublist(1));
}
}
/// True on any HTTP response (track.php always answers 200, even for a
/// payload it silently drops). False only on a network-level failure,
/// which is the one case worth retrying.
Future<bool> _post(Map<String, Object?> body) async {
try {
final response = await _client
.post(
_trackUri,
headers: const {'Content-Type': 'application/json'},
body: jsonEncode(body),
)
.timeout(_requestTimeout);
return response.statusCode == 200;
} on Object {
return false;
}
}
}
/// `NavigatorObserver` for GoRouter navigation. Registered on `GoRouter`'s
/// `observers: []` in `lib/app/router.dart`. See this file's header for why
/// M8Chat's routes resolve to real path-based names here (unlike
/// GardenBuddy's `"unnamed_route"` gap).
class SessionJourneyNavigatorObserver extends NavigatorObserver {
SessionJourneyNavigatorObserver([SessionJourney? journey])
: _journey = journey;
final SessionJourney? _journey;
/// `null` when this observer fires before [SessionJourney.init] has run
/// (widget tests). See [SessionJourney.instanceOrNull].
SessionJourney? get _instance => _journey ?? SessionJourney.instanceOrNull;
String _nameOf(Route<Object?> route) =>
route.settings.name ?? 'unnamed_route';
@override
void didPush(Route<Object?> route, Route<Object?>? previousRoute) {
if (route is PageRoute) {
_instance?.trackScreen(_nameOf(route));
}
}
@override
void didPop(Route<Object?> route, Route<Object?>? previousRoute) {
if (previousRoute is PageRoute) {
_instance?.trackScreen(_nameOf(previousRoute));
}
}
@override
void didReplace({Route<Object?>? newRoute, Route<Object?>? oldRoute}) {
if (newRoute is PageRoute) {
_instance?.trackScreen(_nameOf(newRoute));
}
}
}
/// Root-level rage-tap detector. Wrap the whole app with this once, above
/// `MaterialApp.router`, in `main()`. A bare `Listener`, not a
/// `GestureDetector`: this must see every `PointerDownEvent` in the tree
/// without competing for gesture-arena ownership with the app's own gestures
/// (chat swipe-to-reply, panic hold-press, etc.), so it only reads
/// pointer-down positions and never claims or consumes the event.
class SessionJourneyGestureLayer extends StatelessWidget {
const SessionJourneyGestureLayer({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) {
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (PointerDownEvent event) {
SessionJourney.instanceOrNull?.recordTap(event.position);
},
child: child,
);
}
}