Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canonical-exception-metadata.md
Original file line number Diff line number Diff line change
@@ -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.
122 changes: 88 additions & 34 deletions posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ typedef ChunkIdMapType = Map<String, String>;
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<String, dynamic> processException({
Expand All @@ -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;
}

Expand Down Expand Up @@ -75,23 +79,23 @@ 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 = <String, dynamic>{
'type': errorType ?? 'Error',
'mechanism': {
'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) {
Expand All @@ -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
? <String, Object>{}
: Map<String, Object>.fromEntries(
properties.entries.where(
(entry) => !_reservedExceptionProperties.contains(entry.key),
),
);
final result = <String, dynamic>{
'\$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;
Expand All @@ -134,8 +144,7 @@ class DartExceptionProcessor {
static void _appendCauses(
List<Map<String, dynamic>> exceptionList,
Object error, {
required bool handled,
required String mechanismType,
required int parentId,
required int? threadId,
List<String>? inAppIncludes,
List<String>? inAppExcludes,
Expand All @@ -146,8 +155,7 @@ class DartExceptionProcessor {
exceptionList,
_getCauses(error),
seen,
handled: handled,
mechanismType: mechanismType,
parentId: parentId,
threadId: threadId,
inAppIncludes: inAppIncludes,
inAppExcludes: inAppExcludes,
Expand All @@ -157,16 +165,15 @@ class DartExceptionProcessor {

static void _appendCauseItems(
List<Map<String, dynamic>> exceptionList,
Iterable<Object> causes,
Iterable<(Object, String)> causes,
Set<Object> seen, {
required bool handled,
required String mechanismType,
required int parentId,
required int? threadId,
List<String>? inAppIncludes,
List<String>? inAppExcludes,
bool inAppByDefault = true,
}) {
for (final cause in causes) {
for (final (cause, relationship) in causes) {
if (exceptionList.length >= maxExceptionChainLength || !seen.add(cause)) {
continue;
}
Expand All @@ -175,17 +182,15 @@ class DartExceptionProcessor {
final causeData = <String, dynamic>{
'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) {
Expand All @@ -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,
Expand All @@ -224,28 +229,77 @@ 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<Object> _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;
}

try {
// 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<Future>.wait` produces a
/// `List<AsyncError?>`; record-based `wait` produces a record, skipped here)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,9 @@ class PostHogErrorTrackingAutoCaptureIntegration {

final wrappedError = PostHogException(
source: details.exception,
mechanism: 'FlutterError',
mechanism: 'onuncaughtexception',
handled: false,
captureSource: 'flutter.flutter_error',
);

_captureException(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -230,8 +232,9 @@ class PostHogErrorTrackingAutoCaptureIntegration {

final wrappedError = PostHogException(
source: errorString,
mechanism: 'isolateError',
mechanism: 'task',
handled: false,
captureSource: 'flutter.isolate_error',
);

_captureException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
}
3 changes: 2 additions & 1 deletion posthog_flutter/lib/src/posthog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 7 additions & 37 deletions posthog_flutter/lib/src/posthog_flutter_web_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,23 +145,6 @@ Map<String, String> _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<String, Object?> 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<dynamic> handleWebMethodCall(MethodCall call) async {
_maybeOverrideSDKInfo();

Expand Down Expand Up @@ -427,26 +410,13 @@ Future<dynamic> 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(
Expand Down
Loading
Loading