implemented a new telemetry heartbeat and client-side error reporting system for MarianumConnect; replaced legacy mhsl.eu user index updates with a POST me/telemetry heartbeat using stable, securely-stored device identifiers; integrated global Flutter and platform error handlers to report uncaught exceptions with session-based deduplication and rate limiting.

This commit is contained in:
2026-07-06 22:11:23 +02:00
parent 2be5051d12
commit ddbb3fbc19
9 changed files with 238 additions and 97 deletions
@@ -0,0 +1,82 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'report_client_error.dart';
/// Fire-and-forget bridge from the app's global error handlers to the
/// MarianumConnect `client-errors` endpoint.
///
/// A Flutter layout error (e.g. a RenderFlex overflow) re-fires on every frame,
/// so reports are deduplicated per session by a digit-normalized key — matching
/// the server-side fingerprint — and the number of distinct reports per session
/// is capped, so a broken screen produces one report, not thousands.
class ClientErrorReporter {
static const int _maxReportsPerSession = 50;
static final Set<String> _seen = {};
static String? _appVersion;
static void reportFlutterError(FlutterErrorDetails details) {
_report(
errorType: 'flutter',
message: details.exceptionAsString(),
stacktrace: details.stack?.toString(),
context: details.library ?? details.context?.toString(),
);
}
static void reportPlatformError(Object error, StackTrace stack) {
_report(
errorType: 'platform',
message: error.toString(),
stacktrace: stack.toString(),
);
}
static void _report({
required String errorType,
required String message,
String? stacktrace,
String? context,
}) {
final key = _dedupKey(errorType, message, context);
if (_seen.contains(key) || _seen.length >= _maxReportsPerSession) return;
_seen.add(key);
unawaited(_send(errorType, message, stacktrace, context));
}
static Future<void> _send(
String errorType,
String message,
String? stacktrace,
String? context,
) async {
try {
_appVersion ??= (await PackageInfo.fromPlatform()).version;
await ReportClientError().run(
errorType: errorType,
message: message,
stacktrace: stacktrace,
context: context,
platform: _platform(),
appVersion: _appVersion,
);
} catch (e) {
log('Client error report failed: $e');
}
}
static String _dedupKey(String errorType, String message, String? context) {
final normalized = message.toLowerCase().replaceAll(RegExp(r'\d+'), '#');
return '$errorType|$normalized|${context ?? ''}';
}
static String? _platform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
}
@@ -0,0 +1,42 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
/// Sends a single client-side error report to MarianumConnect
/// (`POST client-errors`). The endpoint is public, so reports that happen
/// before login are still captured; when a bearer token is present the shared
/// dio interceptor attaches it and the server attributes the report to that user.
class ReportClientError {
final Dio _dio;
ReportClientError({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<void> run({
required String errorType,
String? message,
String? stacktrace,
String? context,
String? platform,
String? appVersion,
String? deviceModel,
}) async {
try {
await _dio.post<void>(
MarianumConnectEndpoint.resolve('client-errors'),
data: {
'errorType': errorType,
'message': ?message,
'stacktrace': ?stacktrace,
'context': ?context,
'platform': ?platform,
'appVersion': ?appVersion,
'deviceModel': ?deviceModel,
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,35 @@
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// A stable, anonymous per-install identifier for telemetry. Generated once on
/// first use (128 bits from a cryptographic RNG) and persisted in the secure
/// keystore, so a device stays a single row across password rotations and FCM
/// token refreshes — unlike the legacy device id which was derived from both.
/// Contains no user secret. A fresh install (or clearing app data) mints a new
/// id, which is intended: that is a new install.
class TelemetryDeviceId {
static const String _key = 'telemetry_device_id';
static const FlutterSecureStorage _storage = FlutterSecureStorage();
static String? _cached;
static Future<String> resolve() async {
if (_cached != null) return _cached!;
final existing = await _storage.read(key: _key);
if (existing != null && existing.isNotEmpty) {
_cached = existing;
return existing;
}
final generated = _generate();
await _storage.write(key: _key, value: generated);
_cached = generated;
return generated;
}
static String _generate() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
}
@@ -0,0 +1,74 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../../push/push_registration_store.dart';
import '../../../../push/push_registration_type.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'telemetry_device_id.dart';
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) — one
/// upsert per app start carrying the stable install id, platform, app version
/// and device info. Bearer-authenticated via the shared dio interceptor.
/// Replaces the legacy mhsl.eu `server/userIndex/update` call.
class TelemetryHeartbeat {
final Dio _dio;
TelemetryHeartbeat({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
/// send never disrupts app start. Used from the app shell's initState.
static void report() {
unawaited(TelemetryHeartbeat().send().catchError((Object _) {}));
}
Future<void> send() async {
try {
final info = DeviceInfoPlugin();
final package = await PackageInfo.fromPlatform();
final deviceIdentifier = await TelemetryDeviceId.resolve();
final pushDeviceIdentifier = await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general);
var platform = 'unknown';
String? deviceModel;
String? osVersion;
var raw = <String, dynamic>{};
if (Platform.isAndroid) {
platform = 'android';
final androidInfo = await info.androidInfo;
deviceModel = androidInfo.model;
osVersion = androidInfo.version.release;
raw = androidInfo.data;
} else if (Platform.isIOS) {
platform = 'ios';
final appleInfo = await info.iosInfo;
deviceModel = appleInfo.utsname.machine;
osVersion = appleInfo.systemVersion;
raw = appleInfo.data;
}
await _dio.post<void>(
MarianumConnectEndpoint.resolve('me/telemetry'),
data: {
'deviceIdentifier': deviceIdentifier,
'pushDeviceIdentifier': ?pushDeviceIdentifier,
'platform': platform,
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
'deviceModel': deviceModel,
'osVersion': osVersion,
'deviceInfo': jsonEncode(raw),
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}