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..11e72f2c 100644 --- a/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart +++ b/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart @@ -43,20 +43,20 @@ class DartExceptionProcessor { StackTrace? effectiveStackTrace = stackTrace; bool isGeneratedStackTrace = false; - // If it's an Error, try to use its built-in stackTrace - if (currentError is Error) { - effectiveStackTrace ??= currentError.stackTrace; + // If no usable trace was supplied, try the error's own stackTrace + if (effectiveStackTrace == null || + effectiveStackTrace.toString().trim().isEmpty) { + effectiveStackTrace = _extractOwnStackTrace(currentError); } - // If still null or empty, get current stack trace - if (effectiveStackTrace == null || - effectiveStackTrace == StackTrace.empty) { + // If still null, get current stack trace + if (effectiveStackTrace == null) { effectiveStackTrace = stackTraceProvider?.call() ?? StackTrace.current; isGeneratedStackTrace = true; // Flag to remove top PostHog frames } // Check if we still have an empty stack trace - final hasValidStackTrace = effectiveStackTrace != StackTrace.empty; + final hasValidStackTrace = effectiveStackTrace.toString().trim().isNotEmpty; // Process primary exception final frames = hasValidStackTrace @@ -268,7 +268,7 @@ class DartExceptionProcessor { stackTrace = error.stackTrace; } - if (stackTrace == null || stackTrace == StackTrace.empty) { + if (stackTrace == null || stackTrace.toString().trim().isEmpty) { return null; } return stackTrace; diff --git a/posthog_flutter/test/empty_stack_fallback_test.dart b/posthog_flutter/test/empty_stack_fallback_test.dart new file mode 100644 index 00000000..a244128f --- /dev/null +++ b/posthog_flutter/test/empty_stack_fallback_test.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/error_tracking/dart_exception_processor.dart'; +import 'package:posthog_flutter/src/error_tracking/posthog_exception.dart'; +import 'package:stack_trace/stack_trace.dart'; + +Never _throwOriginalError() => throw StateError('original failure'); + +void main() { + final traces = { + 'null': null, + 'StackTrace.empty': StackTrace.empty, + 'StackTrace.fromString(empty)': StackTrace.fromString(''), + 'StackTrace.fromString(whitespace)': StackTrace.fromString(' \n\t'), + 'Trace.parse(empty)': Trace.parse(''), + 'Chain.parse(empty)': Chain.parse(''), + }; + final captureStack = StackTrace.fromString( + '#0 captureSite (package:my_app/capture.dart:10:1)', + ); + + for (final wrapped in [false, true]) { + for (final entry in traces.entries) { + test( + '${wrapped ? 'wrapped' : 'unwrapped'} ${entry.key} preserves the original Error stack', + () { + late Error error; + try { + _throwOriginalError(); + } catch (caught) { + error = caught as Error; + } + expect(error.stackTrace, isNotNull); + + var generated = false; + // The test runner's Chain.capture zone appends frames to empty traces. + final result = + Zone.root.run(() => DartExceptionProcessor.processException( + error: wrapped + ? PostHogException( + source: error, mechanism: 'FlutterError') + : error, + stackTrace: entry.value, + stackTraceProvider: () { + generated = true; + return captureStack; + }, + )); + final exception = + (result['\$exception_list'] as List>).first; + final stack = exception['stacktrace'] as Map?; + final frames = stack?['frames'] as List>? ?? []; + + expect( + frames.map((frame) => frame['function']), + contains('_throwOriginalError'), + reason: 'generated=$generated; mechanism=${exception['mechanism']}', + ); + expect(generated, isFalse); + expect(exception['type'], 'StateError'); + expect(exception['mechanism'], containsPair('synthetic', false)); + expect(exception['mechanism'], containsPair('handled', !wrapped)); + expect(exception['mechanism'], + containsPair('type', wrapped ? 'FlutterError' : 'generic')); + }); + } + } + + for (final entry in traces.entries) { + test('${entry.key} generates a fallback when no original exists', () { + final result = + Zone.root.run(() => DartExceptionProcessor.processException( + error: Exception('no original stack'), + stackTrace: entry.value, + stackTraceProvider: () => captureStack, + )); + final exception = + (result['\$exception_list'] as List>).first; + final stack = exception['stacktrace'] as Map?; + final frames = stack?['frames'] as List>? ?? []; + + expect(frames.map((frame) => frame['function']), contains('captureSite')); + expect(exception['mechanism'], containsPair('synthetic', true)); + }); + } + + test('a usable supplied stack takes precedence over the original', () { + try { + _throwOriginalError(); + } catch (error) { + final result = + Zone.root.run(() => DartExceptionProcessor.processException( + error: error, + stackTrace: captureStack, + stackTraceProvider: () => + throw StateError('must use supplied stack'), + )); + final exception = + (result['\$exception_list'] as List>).first; + final stack = exception['stacktrace'] as Map; + final frames = stack['frames'] as List>; + + expect(frames.map((frame) => frame['function']), ['captureSite']); + expect(exception['mechanism'], containsPair('synthetic', false)); + } + }); +}