diff --git a/.changeset/fresh-error-hooks-sync.md b/.changeset/fresh-error-hooks-sync.md new file mode 100644 index 00000000..eb1147eb --- /dev/null +++ b/.changeset/fresh-error-hooks-sync.md @@ -0,0 +1,5 @@ +--- +'posthog_flutter': patch +--- + +Reapply Dart automatic error-capture hooks when setup is called again with changed flags. diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 35e8a56b..8ae0234f 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -43,6 +43,10 @@ class Posthog { /// To listen for feature flag load events, provide an `onFeatureFlags` /// callback in the [PostHogConfig]. /// + /// Repeated setup reapplies Dart-side settings such as the error-tracking + /// hooks. Most native settings still require [close] before setup because + /// the native SDKs ignore repeated setup. + /// /// Returns a [Future] that completes when platform setup has finished. /// /// **Example:** @@ -83,7 +87,8 @@ class Posthog { } void _installFlutterIntegrations(PostHogConfig config) { - // Install exception autocapture if enabled + _uninstallFlutterIntegrations(); + if (config.errorTrackingConfig.captureFlutterErrors || config.errorTrackingConfig.capturePlatformDispatcherErrors || config.errorTrackingConfig.captureIsolateErrors) { diff --git a/posthog_flutter/test/repeated_setup_hooks_test.dart b/posthog_flutter/test/repeated_setup_hooks_test.dart new file mode 100644 index 00000000..906e3036 --- /dev/null +++ b/posthog_flutter/test/repeated_setup_hooks_test.dart @@ -0,0 +1,209 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; + +import 'posthog_flutter_platform_interface_fake.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late PosthogFlutterPlatformFake platform; + FlutterExceptionHandler? originalFlutterHandler; + ErrorCallback? originalPlatformHandler; + + setUp(() async { + platform = PosthogFlutterPlatformFake(); + PosthogFlutterPlatformInterface.instance = platform; + await Posthog().close(); + originalFlutterHandler = FlutterError.onError; + FlutterError.onError = (_) {}; + if (!kIsWeb) { + originalPlatformHandler = PlatformDispatcher.instance.onError; + PlatformDispatcher.instance.onError = (_, __) => true; + } + }); + + tearDown(() async { + await Posthog().close(); + FlutterError.onError = originalFlutterHandler; + if (!kIsWeb) { + PlatformDispatcher.instance.onError = originalPlatformHandler; + } + }); + + void reportFlutterError({bool silent = false}) { + FlutterError.reportError(FlutterErrorDetails( + exception: StateError('repeated setup repro'), + stack: StackTrace.current, + silent: silent, + )); + } + + for (final reuseConfig in [false, true]) { + test('disables Flutter hook on repeated setup (reuse=$reuseConfig)', + () async { + final upstream = FlutterError.onError; + final first = PostHogConfig('test-token') + ..errorTrackingConfig.captureFlutterErrors = true; + await Posthog().setup(first); + reportFlutterError(); + expect(platform.capturedExceptions, hasLength(1)); + platform.capturedExceptions.clear(); + + final second = reuseConfig ? first : PostHogConfig('test-token'); + second.errorTrackingConfig.captureFlutterErrors = false; + await Posthog().setup(second); + expect(Posthog().config, same(second)); + + reportFlutterError(); + expect(platform.capturedExceptions, isEmpty); + expect(FlutterError.onError, same(upstream)); + }); + } + + test('disables PlatformDispatcher hook on repeated setup', () async { + final upstream = PlatformDispatcher.instance.onError; + final first = PostHogConfig('test-token') + ..errorTrackingConfig.capturePlatformDispatcherErrors = true; + await Posthog().setup(first); + PlatformDispatcher.instance.onError!( + StateError('before'), StackTrace.current); + expect(platform.capturedExceptions, hasLength(1)); + platform.capturedExceptions.clear(); + + await Posthog().setup(PostHogConfig('test-token')); + PlatformDispatcher.instance.onError!( + StateError('after'), StackTrace.current); + expect(platform.capturedExceptions, isEmpty); + expect(PlatformDispatcher.instance.onError, same(upstream)); + }, skip: kIsWeb); + + test('adds PlatformDispatcher hook while Flutter hook is installed', + () async { + final first = PostHogConfig('test-token') + ..errorTrackingConfig.captureFlutterErrors = true; + await Posthog().setup(first); + + final second = PostHogConfig('test-token') + ..errorTrackingConfig.captureFlutterErrors = true + ..errorTrackingConfig.capturePlatformDispatcherErrors = true; + await Posthog().setup(second); + expect(Posthog().config, same(second)); + + PlatformDispatcher.instance.onError!( + StateError('after'), StackTrace.current); + expect(platform.capturedExceptions, hasLength(1)); + }, skip: kIsWeb); + + test('replaces silent-error policy on repeated setup', () async { + final first = PostHogConfig('test-token') + ..errorTrackingConfig.captureFlutterErrors = true; + await Posthog().setup(first); + + final second = PostHogConfig('test-token') + ..errorTrackingConfig.captureFlutterErrors = true + ..errorTrackingConfig.captureSilentFlutterErrors = true; + await Posthog().setup(second); + + reportFlutterError(silent: true); + expect(platform.capturedExceptions, hasLength(1)); + }); + + test('mutating the original silent-error policy takes effect', () async { + final config = PostHogConfig('test-token') + ..errorTrackingConfig.captureFlutterErrors = true; + await Posthog().setup(config); + config.errorTrackingConfig.captureSilentFlutterErrors = true; + await Posthog().setup(config); + + reportFlutterError(silent: true); + expect(platform.capturedExceptions, hasLength(1)); + }); + + test('first installation can happen on second setup', () async { + await Posthog().setup(PostHogConfig('test-token')); + await Posthog().setup(PostHogConfig('test-token') + ..errorTrackingConfig.captureFlutterErrors = true); + + reportFlutterError(); + expect(platform.capturedExceptions, hasLength(1)); + }); + + test('identical repeated setup does not duplicate capture', () async { + final config = PostHogConfig('test-token') + ..errorTrackingConfig.captureFlutterErrors = true; + await Posthog().setup(config); + await Posthog().setup(config); + + reportFlutterError(); + expect(platform.capturedExceptions, hasLength(1)); + }); + + for (final platformHandler in [false, true]) { + test( + 'repeated setup preserves wrapped handlers (platform=$platformHandler)', + () async { + var upstreamCalls = 0; + var wrapperCalls = 0; + final config = PostHogConfig('test-token') + ..errorTrackingConfig.captureFlutterErrors = !platformHandler + ..errorTrackingConfig.capturePlatformDispatcherErrors = platformHandler; + + if (platformHandler) { + PlatformDispatcher.instance.onError = (_, __) { + upstreamCalls++; + return true; + }; + } else { + FlutterError.onError = (_) => upstreamCalls++; + } + await Posthog().setup(config); + if (platformHandler) { + final delegate = PlatformDispatcher.instance.onError!; + PlatformDispatcher.instance.onError = (error, stack) { + wrapperCalls++; + return delegate(error, stack); + }; + } else { + final delegate = FlutterError.onError!; + FlutterError.onError = (details) { + wrapperCalls++; + delegate(details); + }; + } + + for (var i = 0; i < 3; i++) { + await Posthog().setup(config); + } + if (platformHandler) { + expect( + PlatformDispatcher.instance.onError!( + StateError('after'), StackTrace.current), + isTrue, + ); + } else { + reportFlutterError(); + } + expect(platform.capturedExceptions, hasLength(1)); + expect(upstreamCalls, 1); + expect(wrapperCalls, 1); + }, skip: kIsWeb && platformHandler); + } + + test('close then setup applies changed hook flags', () async { + await Posthog().setup(PostHogConfig('test-token') + ..errorTrackingConfig.captureFlutterErrors = true); + await Posthog().close(); + await Posthog().setup(PostHogConfig('test-token') + ..errorTrackingConfig.capturePlatformDispatcherErrors = true); + + reportFlutterError(); + expect(platform.capturedExceptions, isEmpty); + PlatformDispatcher.instance.onError!( + StateError('after'), StackTrace.current); + expect(platform.capturedExceptions, hasLength(1)); + }, skip: kIsWeb); +} diff --git a/posthog_flutter/test/repeated_setup_isolate_hooks_test.dart b/posthog_flutter/test/repeated_setup_isolate_hooks_test.dart new file mode 100644 index 00000000..f955eccd --- /dev/null +++ b/posthog_flutter/test/repeated_setup_isolate_hooks_test.dart @@ -0,0 +1,59 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; + +import 'posthog_flutter_platform_interface_fake.dart'; + +void main() { + test('repeated setup turns the isolate hook off and back on', () async { + final results = ReceivePort(); + final isolate = await Isolate.spawn( + _exerciseIsolateHook, + results.sendPort, + errorsAreFatal: false, + onExit: results.sendPort, + ); + addTearDown(() { + isolate.kill(priority: Isolate.immediate); + results.close(); + }); + + expect(await results.first, [1, 0, 1]); + }); +} + +Future _exerciseIsolateHook(SendPort results) async { + final observedErrors = ReceivePort(); + final errors = StreamIterator(observedErrors); + Isolate.current.addErrorListener(observedErrors.sendPort); + final platform = PosthogFlutterPlatformFake(); + PosthogFlutterPlatformInterface.instance = platform; + + try { + final counts = []; + for (final enabled in [true, false, true]) { + await Posthog().setup(PostHogConfig('test-token') + ..errorTrackingConfig.captureIsolateErrors = enabled); + platform.capturedExceptions.clear(); + Timer.run(() => throw StateError('isolate hook enabled=$enabled')); + await errors.moveNext(); + // Error listeners use separate ports; let the SDK's queued callback run. + await Future.delayed(Duration.zero); + counts.add(platform.capturedExceptions.length); + } + results.send(counts); + } catch (error, stack) { + results.send('$error\n$stack'); + } finally { + await Posthog().close(); + Isolate.current.removeErrorListener(observedErrors.sendPort); + await errors.cancel(); + observedErrors.close(); + } +}