diff --git a/.changeset/web-injected-release-id.md b/.changeset/web-injected-release-id.md new file mode 100644 index 00000000..d020b007 --- /dev/null +++ b/.changeset/web-injected-release-id.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +Report the release id `posthog-cli sourcemap inject --release-mode=event` writes into a Flutter web bundle as `$release_id` on `$exception` events, so PostHog resolves the release per event instead of joining through the uploaded symbol set. The SDK reads the injected global itself, so the release lands whatever posthog-js version the page loaded. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 302c3722..43028dd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,7 +169,7 @@ jobs: if: needs.detect-markdown-only.outputs.markdown_only != 'true' timeout-minutes: 5 working-directory: ./posthog_flutter - run: flutter test --platform chrome test/posthog_flutter_web_handler_test.dart test/posthog_widget_web_test.dart test/web_canvas_mask_provider_test.dart + run: flutter test --platform chrome test/posthog_flutter_web_handler_test.dart test/posthog_widget_web_test.dart test/web_canvas_mask_provider_test.dart test/dart_exception_processor_web_test.dart # dart2js resolves the isolate-handler conditional import differently; # only a wasm compile exercises the dart2wasm selection this test guards. 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..2ea8f49f 100644 --- a/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart +++ b/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart @@ -11,6 +11,7 @@ import 'utils/isolate_utils.dart' as isolate_utils; import 'posthog_exception.dart'; import 'origin.dart'; import 'chunk_ids.dart'; +import 'release_id.dart'; typedef ChunkIdMapType = Map; @@ -118,10 +119,16 @@ class DartExceptionProcessor { inAppByDefault: inAppByDefault, ); + // The release id posthog-cli injected into the chunk. posthog-js reads the + // same global from 1.409.0 on, but the page picks its own posthog-js, so + // reading it here keeps the release on the event whatever version loaded. + final releaseId = getPosthogReleaseId(); + // Final result, merging system properties with user properties (user properties take precedence) final result = { '\$exception_level': 'error', // Never crashes, so always error '\$exception_list': exceptionList, + if (releaseId != null) '\$release_id': releaseId, if (properties != null) ...properties, }; diff --git a/posthog_flutter/lib/src/error_tracking/release_id.dart b/posthog_flutter/lib/src/error_tracking/release_id.dart new file mode 100644 index 00000000..70067eb1 --- /dev/null +++ b/posthog_flutter/lib/src/error_tracking/release_id.dart @@ -0,0 +1 @@ +export 'release_id_io.dart' if (dart.library.js_interop) 'release_id_web.dart'; diff --git a/posthog_flutter/lib/src/error_tracking/release_id_io.dart b/posthog_flutter/lib/src/error_tracking/release_id_io.dart new file mode 100644 index 00000000..a7898f68 --- /dev/null +++ b/posthog_flutter/lib/src/error_tracking/release_id_io.dart @@ -0,0 +1,6 @@ +/// No chunk carries a release id outside the web build, so there is nothing to +/// read. Apple and Android exceptions resolve their release from the app +/// metadata the native SDK reports. +String? getPosthogReleaseId() { + return null; +} diff --git a/posthog_flutter/lib/src/error_tracking/release_id_web.dart b/posthog_flutter/lib/src/error_tracking/release_id_web.dart new file mode 100644 index 00000000..8d6a95e5 --- /dev/null +++ b/posthog_flutter/lib/src/error_tracking/release_id_web.dart @@ -0,0 +1,21 @@ +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +@JS('globalThis') +external JSObject get globalThis; + +/// Reads the release id `posthog-cli sourcemap inject --release-mode=event` +/// writes into each chunk. +/// +/// The CLI prepends a snippet that sets `globalThis._posthogReleaseId` to the +/// release row's id, first write wins, so the first loaded chunk pins the +/// release for the runtime. Returns null when nothing was injected or the value +/// is not a non-empty string. +String? getPosthogReleaseId() { + final releaseIdJS = globalThis['_posthogReleaseId']; + final releaseId = releaseIdJS?.dartify(); + if (releaseId is! String || releaseId.isEmpty) { + return null; + } + return releaseId; +} diff --git a/posthog_flutter/test/dart_exception_processor_web_test.dart b/posthog_flutter/test/dart_exception_processor_web_test.dart new file mode 100644 index 00000000..4ce23667 --- /dev/null +++ b/posthog_flutter/test/dart_exception_processor_web_test.dart @@ -0,0 +1,53 @@ +@TestOn('browser') +library; + +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/error_tracking/dart_exception_processor.dart'; + +@JS('globalThis') +external JSObject get globalThis; + +void setReleaseId(JSAny? value) { + if (value == null) { + globalThis.delete('_posthogReleaseId'.toJS); + return; + } + globalThis['_posthogReleaseId'] = value; +} + +Map process() { + return DartExceptionProcessor.processException( + error: StateError('boom'), + stackTrace: StackTrace.current, + ); +} + +void main() { + group('DartExceptionProcessor release id', () { + tearDown(() => setReleaseId(null)); + + test('reports the release id posthog-cli injected into the chunk', () { + setReleaseId('01a047ca-108d-0000-7487-e086f76d4aaf'.toJS); + + expect(process()[r'$release_id'], '01a047ca-108d-0000-7487-e086f76d4aaf'); + }); + + // An absent or malformed global has to leave the property off entirely. An + // empty or non-string value reaches the server as a release that resolves + // to nothing, which is worse than no release at all. + for (final (description, value) in <(String, JSAny?)>[ + ('nothing is injected', null), + ('the global is an empty string', ''.toJS), + ('the global is not a string', 42.toJS), + ]) { + test('omits the release id when $description', () { + setReleaseId(value); + + expect(process().containsKey(r'$release_id'), isFalse); + }); + } + }); +}