From ddbb3fbc1997078ceb51062f84ce07e33c7ab729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Mon, 6 Jul 2026 22:11:23 +0200 Subject: [PATCH] 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. --- .../client_error_reporter.dart | 82 +++++++++++++++++++ .../report_client_error.dart | 42 ++++++++++ .../telemetry_device_id.dart | 35 ++++++++ .../telemetry_heartbeat.dart | 74 +++++++++++++++++ .../update/update_user_index_params.dart | 24 ------ .../update/update_user_index_params.g.dart | 27 ------ .../user_index/update/update_userindex.dart | 42 ---------- lib/app.dart | 4 +- lib/main.dart | 5 +- 9 files changed, 238 insertions(+), 97 deletions(-) create mode 100644 lib/api/marianumconnect/queries/report_client_error/client_error_reporter.dart create mode 100644 lib/api/marianumconnect/queries/report_client_error/report_client_error.dart create mode 100644 lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart create mode 100644 lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart delete mode 100644 lib/api/mhsl/server/user_index/update/update_user_index_params.dart delete mode 100644 lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart delete mode 100644 lib/api/mhsl/server/user_index/update/update_userindex.dart diff --git a/lib/api/marianumconnect/queries/report_client_error/client_error_reporter.dart b/lib/api/marianumconnect/queries/report_client_error/client_error_reporter.dart new file mode 100644 index 0000000..5c8aa35 --- /dev/null +++ b/lib/api/marianumconnect/queries/report_client_error/client_error_reporter.dart @@ -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 _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 _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; + } +} diff --git a/lib/api/marianumconnect/queries/report_client_error/report_client_error.dart b/lib/api/marianumconnect/queries/report_client_error/report_client_error.dart new file mode 100644 index 0000000..d8ee4e4 --- /dev/null +++ b/lib/api/marianumconnect/queries/report_client_error/report_client_error.dart @@ -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 run({ + required String errorType, + String? message, + String? stacktrace, + String? context, + String? platform, + String? appVersion, + String? deviceModel, + }) async { + try { + await _dio.post( + 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); + } + } +} diff --git a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart new file mode 100644 index 0000000..f4504d3 --- /dev/null +++ b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart @@ -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 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.generate(16, (_) => random.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } +} diff --git a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart new file mode 100644 index 0000000..74f5bd8 --- /dev/null +++ b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart @@ -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 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 = {}; + 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( + 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); + } + } +} diff --git a/lib/api/mhsl/server/user_index/update/update_user_index_params.dart b/lib/api/mhsl/server/user_index/update/update_user_index_params.dart deleted file mode 100644 index 9fead47..0000000 --- a/lib/api/mhsl/server/user_index/update/update_user_index_params.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'update_user_index_params.g.dart'; - -@JsonSerializable() -class UpdateUserIndexParams { - String user; - String username; - String device; - int appVersion; - String deviceInfo; - - UpdateUserIndexParams({ - required this.user, - required this.username, - required this.device, - required this.appVersion, - required this.deviceInfo, - }); - - factory UpdateUserIndexParams.fromJson(Map json) => - _$UpdateUserIndexParamsFromJson(json); - Map toJson() => _$UpdateUserIndexParamsToJson(this); -} diff --git a/lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart b/lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart deleted file mode 100644 index 4d8775a..0000000 --- a/lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'update_user_index_params.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -UpdateUserIndexParams _$UpdateUserIndexParamsFromJson( - Map json, -) => UpdateUserIndexParams( - user: json['user'] as String, - username: json['username'] as String, - device: json['device'] as String, - appVersion: (json['appVersion'] as num).toInt(), - deviceInfo: json['deviceInfo'] as String, -); - -Map _$UpdateUserIndexParamsToJson( - UpdateUserIndexParams instance, -) => { - 'user': instance.user, - 'username': instance.username, - 'device': instance.device, - 'appVersion': instance.appVersion, - 'deviceInfo': instance.deviceInfo, -}; diff --git a/lib/api/mhsl/server/user_index/update/update_userindex.dart b/lib/api/mhsl/server/user_index/update/update_userindex.dart deleted file mode 100644 index ed4776f..0000000 --- a/lib/api/mhsl/server/user_index/update/update_userindex.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; - -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:http/http.dart' as http; -import 'package:package_info_plus/package_info_plus.dart'; - -import '../../../../../model/account_data.dart'; -import '../../../mhsl_api.dart'; -import 'update_user_index_params.dart'; - -class UpdateUserIndex extends MhslApi { - UpdateUserIndexParams params; - UpdateUserIndex(this.params) : super('server/userIndex/update'); - - @override - void assemble(String raw) {} - - @override - Future request(Uri uri) { - var data = jsonEncode(params.toJson()); - log('Updating userindex: ${data.length}'); - return http.post(uri, body: data); - } - - static Future index() async { - unawaited( - UpdateUserIndex( - UpdateUserIndexParams( - username: AccountData().getUsername(), - user: AccountData().getUserSecret(), - device: await AccountData().getDeviceId(), - appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), - deviceInfo: jsonEncode( - (await DeviceInfoPlugin().deviceInfo).data, - ).toString(), - ), - ).run(), - ); - } -} diff --git a/lib/app.dart b/lib/app.dart index fabb0f8..c1c4f35 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -7,7 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; -import 'api/mhsl/server/user_index/update/update_userindex.dart'; +import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'main.dart'; import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; @@ -171,7 +171,7 @@ class _AppState extends State with WidgetsBindingObserver { if (mounted) setState(() {}); }); - UpdateUserIndex.index(); + TelemetryHeartbeat.report(); // A refreshed FCM token invalidates the existing push subscription — the // NC device identifier stays stable, so we simply re-register (NC first, diff --git a/lib/main.dart b/lib/main.dart index 8ae7455..7066918 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/marianumconnect/auth/session_validator.dart'; import 'api/marianumconnect/marianumconnect_endpoint.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; +import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart'; import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; @@ -157,17 +158,17 @@ Future main() async { ); } - // Capture uncaught Flutter and platform errors so they show up in logs - // instead of being silently swallowed. FlutterError.onError = (details) { log( 'Uncaught Flutter error: ${details.exception}', stackTrace: details.stack, ); + ClientErrorReporter.reportFlutterError(details); FlutterError.presentError(details); }; PlatformDispatcher.instance.onError = (error, stack) { log('Uncaught platform error: $error', stackTrace: stack); + ClientErrorReporter.reportPlatformError(error, stack); return true; };