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/web-injected-release-id.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>;

Expand Down Expand Up @@ -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 = <String, dynamic>{
'\$exception_level': 'error', // Never crashes, so always error
'\$exception_list': exceptionList,
if (releaseId != null) '\$release_id': releaseId,
if (properties != null) ...properties,
};

Expand Down
1 change: 1 addition & 0 deletions posthog_flutter/lib/src/error_tracking/release_id.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'release_id_io.dart' if (dart.library.js_interop) 'release_id_web.dart';
6 changes: 6 additions & 0 deletions posthog_flutter/lib/src/error_tracking/release_id_io.dart
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions posthog_flutter/lib/src/error_tracking/release_id_web.dart
Original file line number Diff line number Diff line change
@@ -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;
}
53 changes: 53 additions & 0 deletions posthog_flutter/test/dart_exception_processor_web_test.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> 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);
});
}
});
}
Loading