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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> json) =>
|
||||
_$UpdateUserIndexParamsFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$UpdateUserIndexParamsToJson(this);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'update_user_index_params.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
UpdateUserIndexParams _$UpdateUserIndexParamsFromJson(
|
||||
Map<String, dynamic> 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<String, dynamic> _$UpdateUserIndexParamsToJson(
|
||||
UpdateUserIndexParams instance,
|
||||
) => <String, dynamic>{
|
||||
'user': instance.user,
|
||||
'username': instance.username,
|
||||
'device': instance.device,
|
||||
'appVersion': instance.appVersion,
|
||||
'deviceInfo': instance.deviceInfo,
|
||||
};
|
||||
@@ -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<void> {
|
||||
UpdateUserIndexParams params;
|
||||
UpdateUserIndex(this.params) : super('server/userIndex/update');
|
||||
|
||||
@override
|
||||
void assemble(String raw) {}
|
||||
|
||||
@override
|
||||
Future<http.Response> request(Uri uri) {
|
||||
var data = jsonEncode(params.toJson());
|
||||
log('Updating userindex: ${data.length}');
|
||||
return http.post(uri, body: data);
|
||||
}
|
||||
|
||||
static Future<void> 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user