From f521dd17e0921d7be74cf92420e5df45314c734d Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Wed, 26 Aug 2026 10:21:17 +0200 Subject: [PATCH] feat(error-tracking): standardize exception metadata --- .changeset/canonical-exception-metadata.md | 5 + .../dart_exception_processor.dart | 122 +++++++++++++----- ...rror_tracking_autocapture_integration.dart | 9 +- .../src/error_tracking/posthog_exception.dart | 4 + posthog_flutter/lib/src/posthog.dart | 3 +- .../lib/src/posthog_flutter_web_handler.dart | 44 +------ .../test/dart_exception_processor_test.dart | 25 ++-- 7 files changed, 129 insertions(+), 83 deletions(-) create mode 100644 .changeset/canonical-exception-metadata.md diff --git a/.changeset/canonical-exception-metadata.md b/.changeset/canonical-exception-metadata.md new file mode 100644 index 00000000..9b14c44a --- /dev/null +++ b/.changeset/canonical-exception-metadata.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +Standardize exception capture metadata, including severity, capture source, mechanism semantics, deterministic cause and aggregate linkage, and reserved property ownership. diff --git a/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart b/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart index 99802662..716c1581 100644 --- a/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart +++ b/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart @@ -17,7 +17,7 @@ typedef ChunkIdMapType = Map; class DartExceptionProcessor { /// Maximum number of exception items in `$exception_list` when walking an /// error's cause chain (outermost error plus its causes). - static const maxExceptionChainLength = 10; + static const maxExceptionChainLength = 50; /// Converts Dart error/exception and stack trace to PostHog exception format static Map processException({ @@ -32,11 +32,15 @@ class DartExceptionProcessor { // Extract PostHog metadata if error is wrapped in PostHogException var mechanismType = 'generic'; var handled = true; + var level = 'error'; + String? captureSource; var currentError = error; if (error is PostHogException) { handled = error.handled; mechanismType = error.mechanism; + level = _normalizeLevel(error.level) ?? 'error'; + captureSource = error.captureSource; currentError = error.source; } @@ -75,8 +79,10 @@ class DartExceptionProcessor { // - runtimeType.toString() returned empty/null (fallback to 'Error' type) // - Stack trace was generated by PostHog (not from original exception) // - No valid stack trace is available - final isSynthetic = - errorType == null || isGeneratedStackTrace || !hasValidStackTrace; + final isSynthetic = !_isRuntimeException(currentError) || + errorType == null || + isGeneratedStackTrace || + !hasValidStackTrace; final exceptionData = { 'type': errorType ?? 'Error', @@ -84,14 +90,12 @@ class DartExceptionProcessor { 'handled': handled, 'synthetic': isSynthetic, 'type': mechanismType, + 'exception_id': 0, }, }; // Add exception message, if available - final errorMessage = currentError.toString(); - if (errorMessage.isNotEmpty) { - exceptionData['value'] = errorMessage; - } + exceptionData['value'] = currentError.toString(); // Add stacktrace, if any frames are available if (frames.isNotEmpty) { @@ -110,19 +114,25 @@ class DartExceptionProcessor { _appendCauses( exceptionList, currentError, - handled: handled, - mechanismType: mechanismType, + parentId: 0, threadId: threadId, inAppIncludes: inAppIncludes, inAppExcludes: inAppExcludes, inAppByDefault: inAppByDefault, ); - // Final result, merging system properties with user properties (user properties take precedence) + final safeProperties = properties == null + ? {} + : Map.fromEntries( + properties.entries.where( + (entry) => !_reservedExceptionProperties.contains(entry.key), + ), + ); final result = { - '\$exception_level': 'error', // Never crashes, so always error + ...safeProperties, + '\$exception_level': level, '\$exception_list': exceptionList, - if (properties != null) ...properties, + if (captureSource != null) '\$exception_source': captureSource, }; return result; @@ -134,8 +144,7 @@ class DartExceptionProcessor { static void _appendCauses( List> exceptionList, Object error, { - required bool handled, - required String mechanismType, + required int parentId, required int? threadId, List? inAppIncludes, List? inAppExcludes, @@ -146,8 +155,7 @@ class DartExceptionProcessor { exceptionList, _getCauses(error), seen, - handled: handled, - mechanismType: mechanismType, + parentId: parentId, threadId: threadId, inAppIncludes: inAppIncludes, inAppExcludes: inAppExcludes, @@ -157,16 +165,15 @@ class DartExceptionProcessor { static void _appendCauseItems( List> exceptionList, - Iterable causes, + Iterable<(Object, String)> causes, Set seen, { - required bool handled, - required String mechanismType, + required int parentId, required int? threadId, List? inAppIncludes, List? inAppExcludes, bool inAppByDefault = true, }) { - for (final cause in causes) { + for (final (cause, relationship) in causes) { if (exceptionList.length >= maxExceptionChainLength || !seen.add(cause)) { continue; } @@ -175,17 +182,15 @@ class DartExceptionProcessor { final causeData = { 'type': causeType ?? 'Error', 'mechanism': { - 'handled': handled, - 'synthetic': causeType == null, - 'type': mechanismType, + 'synthetic': !_isRuntimeException(cause), + 'type': 'chained', + 'source': relationship, + 'exception_id': exceptionList.length, + 'parent_id': parentId, }, + 'value': cause.toString(), }; - final causeMessage = cause.toString(); - if (causeMessage.isNotEmpty) { - causeData['value'] = causeMessage; - } - // Only use the cause's own stack trace; never generate one for causes final causeStackTrace = _extractOwnStackTrace(cause); if (causeStackTrace != null) { @@ -204,13 +209,13 @@ class DartExceptionProcessor { causeData['thread_id'] = threadId; } + final causeId = exceptionList.length; exceptionList.add(causeData); _appendCauseItems( exceptionList, _getCauses(cause), seen, - handled: handled, - mechanismType: mechanismType, + parentId: causeId, threadId: threadId, inAppIncludes: inAppIncludes, inAppExcludes: inAppExcludes, @@ -224,14 +229,16 @@ class DartExceptionProcessor { /// Supports [AsyncError] (unwraps to the original error), /// [ParallelWaitError] (walks all non-null errors) and the common duck-typed /// `cause` getter convention used by custom exceptions. - static Iterable _getCauses(Object error) sync* { + static Iterable<(Object, String)> _getCauses(Object error) sync* { if (error is AsyncError) { - yield error.error; + yield (error.error, 'unwrap'); return; } if (error is ParallelWaitError) { - yield* _parallelErrors(error.errors); + for (final member in _parallelErrors(error.errors)) { + yield (member, 'member'); + } return; } @@ -239,13 +246,60 @@ class DartExceptionProcessor { // ignore: avoid_dynamic_calls final cause = (error as dynamic).cause; if (cause is Object && !identical(cause, error)) { - yield cause; + yield (cause, 'cause'); } } catch (_) { // Error type doesn't expose a `cause` getter } } + static const _reservedExceptionProperties = { + '\$exception_list', + '\$exception_level', + '\$exception_source', + '\$debug_images', + '\$exception_handled', + '\$exception_types', + '\$exception_values', + '\$exception_sources', + '\$exception_functions', + '\$exception_fingerprint_version', + '\$exception_fingerprint_record', + '\$exception_issue_id', + '\$exception_release', + '\$cymbal_errors', + }; + + static String? _normalizeLevel(String level) { + switch (level.toLowerCase()) { + case 'fatal': + case 'critical': + case 'alert': + case 'emergency': + return 'fatal'; + case 'error': + case 'log': + return level.toLowerCase(); + case 'warning': + case 'warn': + return 'warning'; + case 'notice': + case 'info': + return 'info'; + case 'trace': + case 'debug': + return 'debug'; + default: + return null; + } + } + + static bool _isRuntimeException(Object error) => + error is Exception || + error is Error || + error is AsyncError || + error is ParallelWaitError; + /// Returns all non-null errors from a [ParallelWaitError.errors] collection, /// if it is enumerable (e.g. `List.wait` produces a /// `List`; record-based `wait` produces a record, skipped here) diff --git a/posthog_flutter/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart b/posthog_flutter/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart index fa5d826d..64de22cd 100644 --- a/posthog_flutter/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart +++ b/posthog_flutter/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart @@ -153,8 +153,9 @@ class PostHogErrorTrackingAutoCaptureIntegration { final wrappedError = PostHogException( source: details.exception, - mechanism: 'FlutterError', + mechanism: 'onuncaughtexception', handled: false, + captureSource: 'flutter.flutter_error', ); _captureException( @@ -192,8 +193,9 @@ class PostHogErrorTrackingAutoCaptureIntegration { bool _posthogPlatformErrorHandler(Object error, StackTrace stackTrace) { final wrappedError = PostHogException( source: error, - mechanism: 'PlatformDispatcher', + mechanism: 'onuncaughtexception', handled: false, + captureSource: 'flutter.platform_dispatcher', ); _captureException(error: wrappedError, stackTrace: stackTrace); @@ -230,8 +232,9 @@ class PostHogErrorTrackingAutoCaptureIntegration { final wrappedError = PostHogException( source: errorString, - mechanism: 'isolateError', + mechanism: 'task', handled: false, + captureSource: 'flutter.isolate_error', ); _captureException( diff --git a/posthog_flutter/lib/src/error_tracking/posthog_exception.dart b/posthog_flutter/lib/src/error_tracking/posthog_exception.dart index dee023fb..f4ec3c33 100644 --- a/posthog_flutter/lib/src/error_tracking/posthog_exception.dart +++ b/posthog_flutter/lib/src/error_tracking/posthog_exception.dart @@ -8,10 +8,14 @@ class PostHogException implements Exception { final Object source; final String mechanism; final bool handled; + final String? captureSource; + final String level; const PostHogException({ required this.source, required this.mechanism, this.handled = false, + this.captureSource, + this.level = 'error', }); } diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 305a7815..e2145e15 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -780,8 +780,9 @@ class Posthog { }) async { final wrappedError = PostHogException( source: error, - mechanism: 'runZonedGuarded', + mechanism: 'task', handled: false, + captureSource: 'flutter.run_zoned_guarded', ); await _posthog.captureException( error: wrappedError, diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index e4c6c4ba..596b7043 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -145,23 +145,6 @@ Map _getLocationProperties() { } } -// The human-readable message used only as the posthog-js captureException -// trigger; its parse is overridden by the Dart-built properties we pass as -// additionalProperties. Read from the exception list the Dart side builds. -String _exceptionMessage(Map properties) { - final exceptionList = properties[r'$exception_list']; - if (exceptionList is List && exceptionList.isNotEmpty) { - final first = exceptionList.first; - if (first is Map) { - final value = first['value']; - if (value is String && value.isNotEmpty) { - return value; - } - } - } - return 'Exception'; -} - Future handleWebMethodCall(MethodCall call) async { _maybeOverrideSDKInfo(); @@ -427,26 +410,13 @@ Future handleWebMethodCall(MethodCall call) async { final properties = safeMapConversion(args['properties']); properties.addAll(_getLocationProperties()); - // Route through posthog-js's captureException so it attaches required - // metadata and any buffered $exception_steps. posthog-js spreads the - // additionalProperties last, so our Dart-built $exception_list (frames, - // mechanism.handled, level) overrides its synthetic parse of `message`. - try { - posthog?.captureException( - stringToJSAny(_exceptionMessage(properties)), - mapToJSAny(properties), - ); - } catch (error) { - // Very old posthog-js lacks captureException; still record the event. - printIfDebug( - '[PostHog] captureException via posthog-js failed; falling back to capture: $error', - ); - posthog?.capture( - stringToJSAny('\$exception'), - mapToJSAny(properties), - null, - ); - } + // Dart has already assembled the canonical exception event. Send it directly so + // posthog-js's manual-capture defaults cannot replace trusted Dart metadata. + posthog?.capture( + stringToJSAny('\$exception'), + mapToJSAny(properties), + null, + ); break; default: throw PlatformException( diff --git a/posthog_flutter/test/dart_exception_processor_test.dart b/posthog_flutter/test/dart_exception_processor_test.dart index b77756d4..4de0f243 100644 --- a/posthog_flutter/test/dart_exception_processor_test.dart +++ b/posthog_flutter/test/dart_exception_processor_test.dart @@ -74,6 +74,7 @@ void main() { expect(mechanism['handled'], isTrue); expect(mechanism['synthetic'], isFalse); expect(mechanism['type'], equals('generic')); + expect(mechanism['exception_id'], equals(0)); // Verify stack trace structure final stackTraceData = @@ -491,7 +492,7 @@ void main() { }, ); - test('allows user properties to override system properties', () { + test('protects system properties from generic user properties', () { final exception = Exception('Test exception'); final stackTrace = StackTrace.fromString('#0 test (test.dart:1:1)'); @@ -507,8 +508,7 @@ void main() { properties: overrideProperties, ); - // Verify that user properties take precedence - expect(result['\$exception_level'], equals('warning')); + expect(result['\$exception_level'], equals('error')); expect(result['custom_property'], equals('custom_value')); }); @@ -703,9 +703,12 @@ void main() { isTrue, ); - // Causes reuse the outer mechanism - expect(exceptionList[1]['mechanism']['handled'], isTrue); - expect(exceptionList[1]['mechanism']['type'], equals('generic')); + expect( + exceptionList[1]['mechanism'].containsKey('handled'), isFalse); + expect(exceptionList[1]['mechanism']['type'], equals('chained')); + expect(exceptionList[1]['mechanism']['source'], equals('unwrap')); + expect(exceptionList[1]['mechanism']['exception_id'], equals(1)); + expect(exceptionList[1]['mechanism']['parent_id'], equals(0)); expect(exceptionList[1]['mechanism']['synthetic'], isFalse); }, ), @@ -789,6 +792,12 @@ void main() { exceptionList[4]['value'], equals('FormatException: second parallel failure'), ); + expect(exceptionList[1]['mechanism']['source'], equals('member')); + expect(exceptionList[1]['mechanism']['parent_id'], equals(0)); + expect(exceptionList[2]['mechanism']['source'], equals('unwrap')); + expect(exceptionList[2]['mechanism']['parent_id'], equals(1)); + expect(exceptionList[3]['mechanism']['source'], equals('member')); + expect(exceptionList[3]['mechanism']['parent_id'], equals(0)); }); test('guards against cause cycles', () { @@ -812,7 +821,7 @@ void main() { test('caps the cause chain length', () { Object error = FormatException('root'); - for (var i = 0; i < 20; i++) { + for (var i = 0; i < 60; i++) { error = _ChainedException('wrapper $i', error); } @@ -828,7 +837,7 @@ void main() { exceptionList, hasLength(DartExceptionProcessor.maxExceptionChainLength), ); - expect(exceptionList.first['value'], equals('wrapper 19')); + expect(exceptionList.first['value'], equals('wrapper 59')); }); }); });