diff --git a/.changeset/flutter-version-property.md b/.changeset/flutter-version-property.md new file mode 100644 index 00000000..a8acebcd --- /dev/null +++ b/.changeset/flutter-version-property.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +Add `$flutter_version` (for example `3.32.0`) to events captured through `capture`, `screen` and `captureException`, including the event `beforeSend` receives. Requires apps built with Flutter 3.32 or later; it's omitted on older Flutter versions and on events the native iOS/Android SDKs or posthog-js capture on their own, such as lifecycle events. diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 1b6d2f44..698c9585 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -19,6 +19,7 @@ import 'src/posthog_flutter_web_handler.dart'; import 'src/utils/before_send.dart'; import 'src/replay/web/web_canvas_mask_provider.dart'; import 'src/utils/capture_utils.dart'; +import 'src/utils/flutter_version.dart'; /// A web implementation of the PosthogFlutterPlatform of the PosthogFlutter plugin. class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @@ -168,6 +169,22 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + }) { + return _capture( + eventName: eventName, + properties: withFlutterVersion(properties), + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ); + } + + /// Captures [properties] as given. Callers add `$flutter_version` first; + /// renamed screen/exception events skip it so a beforeSend removal sticks. + Future _capture({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, }) async { final processedEvent = await _runBeforeSend( eventName, @@ -252,7 +269,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final processedEvent = await _runBeforeSend( PostHogEventName.screen, - propsWithScreenName, + withFlutterVersion(propsWithScreenName), ); if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); @@ -260,7 +277,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { } if (processedEvent.event != PostHogEventName.screen) { - await capture( + await _capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), ); @@ -491,7 +508,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final processedEvent = await _runBeforeSend( PostHogEventName.exception, - exceptionData.cast(), + withFlutterVersion(exceptionData.cast()), ); if (processedEvent == null) { printIfDebug( @@ -501,7 +518,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { } if (processedEvent.event != PostHogEventName.exception) { - await capture( + await _capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), ); diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 6915f76c..4ed438be 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -12,6 +12,7 @@ import 'surveys/models/survey_callbacks.dart'; import 'error_tracking/dart_exception_processor.dart'; import 'utils/before_send.dart'; import 'utils/capture_utils.dart'; +import 'utils/flutter_version.dart'; import 'utils/property_normalizer.dart'; import 'feature_flag_result.dart'; @@ -289,6 +290,22 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + }) { + return _capture( + eventName: eventName, + properties: withFlutterVersion(properties), + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ); + } + + /// Captures [properties] as given. Callers add `$flutter_version` first; + /// renamed screen/exception events skip it so a beforeSend removal sticks. + Future _capture({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, }) async { if (!isSupportedPlatform()) { return; @@ -362,7 +379,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { // Apply beforeSend callback - screen events are captured as $screen final processedEvent = await _runBeforeSend( PostHogEventName.screen, - propsWithScreenName, + withFlutterVersion(propsWithScreenName), ); if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); @@ -371,7 +388,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { // If event name was changed, use regular capture() instead if (processedEvent.event != PostHogEventName.screen) { - await capture( + await _capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), ); @@ -386,10 +403,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { processedEvent.properties?.remove(PostHogPropertyName.screenName); try { - final normalizedProperties = processedEvent.properties?.isNotEmpty == true - ? PropertyNormalizer.normalize( - processedEvent.properties!.cast(), - ) + final eventProperties = processedEvent.properties?.cast(); + final normalizedProperties = eventProperties?.isNotEmpty == true + ? PropertyNormalizer.normalize(eventProperties!) : null; await _methodChannel.invokeMethod('screen', { @@ -771,7 +787,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { // Apply beforeSend callback - exception events are captured as $exception final processedEvent = await _runBeforeSend( PostHogEventName.exception, - exceptionProps.cast(), + withFlutterVersion(exceptionProps.cast()), ); if (processedEvent == null) { printIfDebug( @@ -782,7 +798,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { // If event name was changed, use capture() instead if (processedEvent.event != PostHogEventName.exception) { - await capture( + await _capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), ); @@ -791,10 +807,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { // Add timestamp from Flutter side (will be used and removed from native plugins) final timestamp = DateTime.now().millisecondsSinceEpoch; - final normalizedData = processedEvent.properties != null - ? PropertyNormalizer.normalize( - processedEvent.properties!.cast(), - ) + final eventProperties = processedEvent.properties?.cast(); + final normalizedData = eventProperties != null + ? PropertyNormalizer.normalize(eventProperties) : {}; await _methodChannel.invokeMethod('captureException', { diff --git a/posthog_flutter/lib/src/utils/flutter_version.dart b/posthog_flutter/lib/src/utils/flutter_version.dart new file mode 100644 index 00000000..2632f3be --- /dev/null +++ b/posthog_flutter/lib/src/utils/flutter_version.dart @@ -0,0 +1,22 @@ +// Flutter 3.32+ compiles its own version into every build as this define. It +// backs `FlutterVersion.version`, which can't be referenced directly because +// that class is missing from the older Flutter versions this package supports. +const _flutterVersion = String.fromEnvironment('FLUTTER_VERSION'); + +/// Returns the `$flutter_version` property, or an empty map when the build +/// doesn't report a Flutter version (Flutter < 3.32). +Map flutterVersionProperties({ + String flutterVersion = _flutterVersion, +}) { + return { + if (flutterVersion.isNotEmpty) r'$flutter_version': flutterVersion, + }; +} + +/// Adds the `$flutter_version` property to [properties], replacing any value +/// the caller set. `beforeSend` runs afterwards and can still change it. +Map? withFlutterVersion(Map? properties) { + final versionProperties = flutterVersionProperties(); + if (versionProperties.isEmpty) return properties; + return {...?properties, ...versionProperties}; +} diff --git a/posthog_flutter/test/event_shape_snapshot_test.dart b/posthog_flutter/test/event_shape_snapshot_test.dart index 6b85bd91..d2245a3e 100644 --- a/posthog_flutter/test/event_shape_snapshot_test.dart +++ b/posthog_flutter/test/event_shape_snapshot_test.dart @@ -261,13 +261,26 @@ void main() { }); } +// The Flutter version depends on the SDK running the tests, and Flutter < 3.32 +// doesn't report one, so it's checked here and left out of the snapshots. +Object? _withoutFlutterVersion(Object? arguments) { + if (arguments is! Map || arguments['properties'] is! Map) return arguments; + final properties = Map.from(arguments['properties'] as Map); + if (!properties.containsKey(r'$flutter_version')) return arguments; + expect( + properties.remove(r'$flutter_version'), + const String.fromEnvironment('FLUTTER_VERSION'), + ); + return {...arguments, 'properties': properties}; +} + Future _expectSnapshot(String name, List calls) async { final snapshotFile = File('test/snapshots/$name'); final actual = calls .map( (call) => { 'method': call.method, - 'arguments': call.arguments, + 'arguments': _withoutFlutterVersion(call.arguments), }, ) .toList(); diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index af13ba0e..b2469903 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -7,6 +7,7 @@ import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_event.dart'; import 'package:posthog_flutter/src/posthog_flutter_io.dart'; import 'package:posthog_flutter/src/posthog_internal_events.dart'; +import 'package:posthog_flutter/src/utils/flutter_version.dart'; // Simplified void callback for feature flags void emptyCallback() {} @@ -361,6 +362,160 @@ void main() { }); }); + const flutterVersion = String.fromEnvironment('FLUTTER_VERSION'); + + group('PosthogFlutterIO \$flutter_version', + skip: flutterVersion.isEmpty + ? 'Flutter < 3.32 does not report its version' + : false, () { + setUp(() async { + await posthogFlutterIO.setup(PostHogConfig('test_project_token')); + }); + + Map propertiesOf(String method) { + final call = log.lastWhere((c) => c.method == method); + return Map.from( + (call.arguments as Map)['properties'] as Map, + ); + } + + test('capture attaches the compile-time Flutter version', () async { + await posthogFlutterIO.capture(eventName: 'checkout'); + + expect(propertiesOf('capture'), {r'$flutter_version': flutterVersion}); + }); + + test('screen attaches the compile-time Flutter version', () async { + await posthogFlutterIO.screen(screenName: 'Home'); + + expect(propertiesOf('screen'), {r'$flutter_version': flutterVersion}); + }); + + test('captureException attaches the compile-time Flutter version', + () async { + await posthogFlutterIO.captureException(error: StateError('boom')); + + expect( + propertiesOf('captureException'), + containsPair(r'$flutter_version', flutterVersion), + ); + }); + + test('replaces a Flutter version the caller set', () async { + await posthogFlutterIO.capture( + eventName: 'checkout', + properties: {r'$flutter_version': 'custom'}, + ); + + expect(propertiesOf('capture'), {r'$flutter_version': flutterVersion}); + }); + + test('omits the property when the build reports no Flutter version', () { + expect(flutterVersionProperties(flutterVersion: ''), isEmpty); + }); + }); + + group('PosthogFlutterIO \$flutter_version and beforeSend', + skip: flutterVersion.isEmpty + ? 'Flutter < 3.32 does not report its version' + : false, () { + final seen = {}; + + Future setUpRemovingFlutterVersion() async { + seen.clear(); + await posthogFlutterIO.setup(PostHogConfig( + 'test_project_token', + beforeSend: [ + (event) { + seen[event.event] = event.properties?[r'$flutter_version']; + event.properties?.remove(r'$flutter_version'); + return event; + }, + ], + )); + } + + Map propertiesOf(String method) { + final call = log.lastWhere((c) => c.method == method); + return Map.from( + ((call.arguments as Map)['properties'] as Map?) ?? {}, + ); + } + + test('capture: beforeSend sees and can remove it', () async { + await setUpRemovingFlutterVersion(); + await posthogFlutterIO.capture(eventName: 'checkout'); + + expect(seen['checkout'], flutterVersion); + expect(propertiesOf('capture'), isNot(contains(r'$flutter_version'))); + }); + + test('screen: beforeSend sees and can remove it', () async { + await setUpRemovingFlutterVersion(); + await posthogFlutterIO.screen(screenName: 'Home'); + + expect(seen[r'$screen'], flutterVersion); + expect(propertiesOf('screen'), isNot(contains(r'$flutter_version'))); + }); + + test('captureException: beforeSend sees and can remove it', () async { + await setUpRemovingFlutterVersion(); + await posthogFlutterIO.captureException(error: StateError('boom')); + + expect(seen[r'$exception'], flutterVersion); + expect(propertiesOf('captureException'), + isNot(contains(r'$flutter_version'))); + }); + + // Renamed screen/exception events fall back to capture, which must not + // add the version back after beforeSend removed it. + Future setUpRemoveAndRename() async { + await posthogFlutterIO.setup(PostHogConfig( + 'test_project_token', + beforeSend: [ + (event) { + if (event.event == r'$screen' || event.event == r'$exception') { + event.properties?.remove(r'$flutter_version'); + event.event = 'redacted_event'; + } + return event; + }, + ], + )); + } + + for (final callerValue in [null, 'caller']) { + final props = {if (callerValue != null) r'$flutter_version': callerValue}; + final label = callerValue == null ? 'automatic' : 'caller-set'; + + test('screen renamed by beforeSend keeps the $label version removed', + () async { + await setUpRemoveAndRename(); + await posthogFlutterIO.screen(screenName: 'Home', properties: props); + + final captures = log.where((c) => c.method == 'capture').toList(); + expect(captures, hasLength(1)); + expect( + (captures.single.arguments as Map)['eventName'], 'redacted_event'); + expect(propertiesOf('capture'), isNot(contains(r'$flutter_version'))); + }); + + test( + 'captureException renamed by beforeSend keeps the $label version removed', + () async { + await setUpRemoveAndRename(); + await posthogFlutterIO.captureException( + error: StateError('boom'), properties: props); + + final captures = log.where((c) => c.method == 'capture').toList(); + expect(captures, hasLength(1)); + expect( + (captures.single.arguments as Map)['eventName'], 'redacted_event'); + expect(propertiesOf('capture'), isNot(contains(r'$flutter_version'))); + }); + } + }); + group('PosthogFlutterIO beforeSend callback', () { test( 'capture sends event unchanged when no beforeSend registered', @@ -376,7 +531,8 @@ void main() { final captureCall = log.firstWhere((c) => c.method == 'capture'); final args = Map.from(captureCall.arguments as Map); expect(args['eventName'], 'test_event'); - expect(args['properties'], {'key': 'value'}); + expect(args['properties'], + {...flutterVersionProperties(), 'key': 'value'}); }, ); @@ -418,6 +574,7 @@ void main() { final captureCall = log.firstWhere((c) => c.method == 'capture'); final args = Map.from(captureCall.arguments as Map); + // beforeSend replaced the properties, so the SDK's $flutter_version is gone too. expect(args['properties'], {'modified': true}); }); @@ -599,7 +756,8 @@ void main() { expect(capturedEvent, isNotNull); expect(capturedEvent!.event, 'test_event'); - expect(capturedEvent!.properties, {'prop': 'value'}); + expect(capturedEvent!.properties, + {...flutterVersionProperties(), 'prop': 'value'}); expect(capturedEvent!.userProperties, {'user_prop': 'user_value'}); expect(capturedEvent!.userPropertiesSetOnce, { 'set_once_prop': 'set_once_value', @@ -631,7 +789,8 @@ void main() { final captureCall = log.firstWhere((c) => c.method == 'capture'); final args = Map.from(captureCall.arguments as Map); - expect(args['properties'], {'event_prop': 'value'}); + expect(args['properties'], + {...flutterVersionProperties(), 'event_prop': 'value'}); expect(args['userProperties'], {'developer_name': 'John'}); }, ); @@ -660,7 +819,8 @@ void main() { final captureCall = log.firstWhere((c) => c.method == 'capture'); final args = Map.from(captureCall.arguments as Map); - expect(args['properties'], {'event_prop': 'value'}); + expect(args['properties'], + {...flutterVersionProperties(), 'event_prop': 'value'}); expect(args['userPropertiesSetOnce'], {'first_seen': '2025-01-01'}); }, ); @@ -688,7 +848,8 @@ void main() { final captureCall = log.firstWhere((c) => c.method == 'capture'); final args = Map.from(captureCall.arguments as Map); - expect(args['properties'], {'event_prop': 'value'}); + expect(args['properties'], + {...flutterVersionProperties(), 'event_prop': 'value'}); expect(args['userProperties'], { 'from_legacy': 'legacy_value', 'from_direct': 'direct_value', diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index 493fea90..f5972a88 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -85,4 +85,102 @@ void main() { expect(captures, isEmpty); }); + + group(r'$flutter_version', + skip: const String.fromEnvironment('FLUTTER_VERSION').isEmpty + ? 'Flutter < 3.32 does not report its version' + : false, () { + const flutterVersion = String.fromEnvironment('FLUTTER_VERSION'); + + Map propertiesOf(String event) => + captures.lastWhere((c) => c['event'] == event)['properties'] as Map; + + Future captureAll(PosthogFlutterWeb web, + [Map? props]) async { + await web.capture(eventName: 'checkout', properties: props); + await web.screen(screenName: 'Home', properties: props); + await web.captureException(error: StateError('boom'), properties: props); + } + + test('is sent and visible to beforeSend', () async { + final seen = {}; + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) { + seen[event.event] = event.properties?[r'$flutter_version']; + return event; + }, + ])); + + await captureAll(web); + + expect(seen, { + 'checkout': flutterVersion, + r'$screen': flutterVersion, + r'$exception': flutterVersion, + }); + for (final event in ['checkout', r'$screen', r'$exception']) { + expect(propertiesOf(event)[r'$flutter_version'], flutterVersion); + } + }); + + test('replaces a caller-set value', () async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token')); + + await captureAll(web, {r'$flutter_version': 'custom'}); + + for (final event in ['checkout', r'$screen', r'$exception']) { + expect(propertiesOf(event)[r'$flutter_version'], flutterVersion); + } + }); + + for (final props in [ + {}, + {r'$flutter_version': 'caller'}, + ]) { + test('stays removed when beforeSend removes it (caller props: $props)', + () async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) { + event.properties?.remove(r'$flutter_version'); + return event; + }, + ])); + + await captureAll(web, props); + + expect(captures, hasLength(3)); + for (final capture in captures) { + expect(capture['properties'] as Map, + isNot(contains(r'$flutter_version'))); + } + }); + } + + test('stays removed when beforeSend removes it and renames the event', + () async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) { + if (event.event == r'$screen' || event.event == r'$exception') { + event.properties?.remove(r'$flutter_version'); + event.event = 'redacted_event'; + } + return event; + }, + ])); + + await web.screen(screenName: 'Home'); + await web.captureException(error: StateError('boom')); + + expect(captures.map((c) => c['event']), + ['redacted_event', 'redacted_event']); + for (final capture in captures) { + expect( + capture['properties'] as Map, isNot(contains(r'$flutter_version'))); + } + }); + }); }