From 15290dc8d0daff45b4bfaf29b0d172d137a306a1 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 13:37:05 +0200 Subject: [PATCH 1/2] fix: serialize non-finite desktop event properties as null --- .changeset/large-horses-invent.md | 5 ++ posthog_flutter/lib/src/core/utils/utils.dart | 5 +- .../test/core/posthog_core_test.dart | 71 ++++++++++++++++++- .../posthog_desktop_exception_steps_test.dart | 10 ++- .../test/posthog_flutter_desktop_test.dart | 21 ++++-- 5 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 .changeset/large-horses-invent.md diff --git a/.changeset/large-horses-invent.md b/.changeset/large-horses-invent.md new file mode 100644 index 00000000..21a51965 --- /dev/null +++ b/.changeset/large-horses-invent.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": patch +--- + +Convert numeric NaN and infinities in Windows/Linux event properties to JSON null instead of strings, preserving array positions and valid neighboring properties. diff --git a/posthog_flutter/lib/src/core/utils/utils.dart b/posthog_flutter/lib/src/core/utils/utils.dart index 9f9dd50b..a17e423f 100644 --- a/posthog_flutter/lib/src/core/utils/utils.dart +++ b/posthog_flutter/lib/src/core/utils/utils.dart @@ -40,7 +40,8 @@ Future retriable( /// Returns [value] as a new structure that `jsonEncode` accepts. /// -/// A [DateTime] becomes its ISO 8601 string in UTC and a [Uri] its string. +/// Non-finite doubles become null. A [DateTime] becomes its ISO 8601 string +/// in UTC and a [Uri] its string. /// Other values JSON cannot represent are converted like `jsonEncode` does, /// through their `toJson()`, or else sent as their `toString()` with a /// warning to [logger]. Map keys become strings. With [dropNullMembers], @@ -56,6 +57,8 @@ Object? toJsonValue(Object? value, CoreLogger logger, switch (value) { case null || bool() || String() || int() || double(isFinite: true): return value; + case double(): + return null; case final DateTime date: return date.toUtc().toIso8601String(); case final Uri uri: diff --git a/posthog_flutter/test/core/posthog_core_test.dart b/posthog_flutter/test/core/posthog_core_test.dart index 401dd6c6..4e6d9f11 100644 --- a/posthog_flutter/test/core/posthog_core_test.dart +++ b/posthog_flutter/test/core/posthog_core_test.dart @@ -568,11 +568,80 @@ void main() { expect(props['link'], 'https://posthog.com/docs'); expect(props['plan'], {'name': 'pro'}, reason: 'jsonEncode supports toJson(), so the event must too'); - expect(props['ratio'], 'NaN'); + expect(props, containsPair('ratio', null)); expect(props['counts'], {'1': 'one'}); expect(() => jsonEncode(getQueue(storage)), returnsNormally); }); + for (final (label, value, literal) in [ + ('NaN', double.nan, 'NaN'), + ('positive infinity', double.infinity, 'Infinity'), + ('negative infinity', double.negativeInfinity, '-Infinity'), + ]) { + test('captures and sends numeric $label as null without changing input', + () async { + final nested = {'ratio': value, 'finite': 42.5}; + final inArray = {'ratio': value, 'enabled': false}; + final items = [0, value, inArray, null, 'after']; + final properties = { + 'ratio': value, + 'nested': nested, + 'items': items, + 'finite': 42.5, + 'enabled': true, + 'literal': literal, + 'drop': null, + }; + final expected = { + 'ratio': null, + 'nested': {'ratio': null, 'finite': 42.5}, + 'items': [ + 0, + null, + {'ratio': null, 'enabled': false}, + null, + 'after' + ], + 'finite': 42.5, + 'enabled': true, + 'literal': literal, + }; + + client.capture('non_finite', properties: properties); + client + .capture('healthy', properties: {'finite': 7, 'literal': literal}); + + expect(queuedEvents(storage), ['non_finite', 'healthy']); + final queued = queuedProps(storage, 0); + for (final entry in expected.entries) { + expect(queued, containsPair(entry.key, entry.value)); + } + expect(queued, isNot(contains('drop'))); + + await client.flush(); + + final batch = server.batchRequests.single; + expect(batch.eventNames, ['non_finite', 'healthy']); + final sent = batch.events.first['properties'] as Map; + for (final entry in expected.entries) { + expect(sent, containsPair(entry.key, entry.value)); + } + expect(sent, isNot(contains('drop'))); + expect(batch.events.last['properties'], + allOf(containsPair('finite', 7), containsPair('literal', literal))); + expect(getQueue(storage), isEmpty); + + final originalNumber = value.isNaN ? isNaN : equals(value); + expect(properties['ratio'], originalNumber); + expect(properties['nested'], same(nested)); + expect(properties['items'], same(items)); + expect(nested['ratio'], originalNumber); + expect(items, [0, originalNumber, same(inArray), null, 'after']); + expect(inArray['ratio'], originalNumber); + expect(properties, containsPair('drop', null)); + }); + } + test('sends a value without a JSON form as its string, with a warning', () { final lines = printedLines(() => client .capture('evt', properties: {'timeout': const Duration(seconds: 3)})); diff --git a/posthog_flutter/test/posthog_desktop_exception_steps_test.dart b/posthog_flutter/test/posthog_desktop_exception_steps_test.dart index 0789ef9c..5059a3df 100644 --- a/posthog_flutter/test/posthog_desktop_exception_steps_test.dart +++ b/posthog_flutter/test/posthog_desktop_exception_steps_test.dart @@ -66,17 +66,21 @@ void main() { expect(step['nested'], {'kept': 1}); }); - test('keeps a step with a number JSON cannot represent, as its string', () { + test('keeps a step with non-finite numbers as null', () { final buffer = ExceptionStepsBuffer(maxBytes: 32768); buffer.add('step', properties: { 'ratio': double.nan, 'limits': [double.infinity, double.negativeInfinity], + 'finite': 42.5, + 'literal': 'NaN', }); final step = buffer.steps.single; - expect(step['ratio'], 'NaN'); - expect(step['limits'], ['Infinity', '-Infinity']); + expect(step, containsPair('ratio', null)); + expect(step['limits'], [null, null]); + expect(step['finite'], 42.5); + expect(step['literal'], 'NaN'); }); test('evicts the oldest steps to stay within the budget', () { diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index c251bc9d..1289c36a 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -836,22 +836,31 @@ void main() { ['before capture', 'during beforeSend']); }); - test('send their values as event properties send them', () async { + test('omit null-valued buffered step members when capturing the exception', + () async { final server = await LocalPostHogServer.start(); final platform = await setUpPlatform(configFor(server)); - await platform - .addExceptionStep('step', properties: {'ratio': double.nan}); + final input = { + 'ratio': double.nan, + 'finite': 42.5, + 'literal': 'NaN', + }; + await platform.addExceptionStep('step', properties: input); await platform.captureException( error: StateError('not a number'), - properties: {'ratio': double.nan}, + properties: input, ); final properties = _propertiesOf(await server.waitForEvent(r'$exception')); final step = (properties[r'$exception_steps']! as List).single as Map; - expect(properties['ratio'], 'NaN'); - expect(step['ratio'], 'NaN'); + expect(properties, containsPair('ratio', null)); + expect(step, isNot(contains('ratio'))); + for (final sent in [properties, step]) { + expect(sent['finite'], 42.5); + expect(sent['literal'], 'NaN'); + } }); test(r'attach to an $exception captured by name', () async { From e9a2ca832536007b89aeb5955c326b6fe1482c4f Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 22 Oct 2026 16:30:02 +0200 Subject: [PATCH 2/2] fix: budget desktop exception steps using their wire form --- .changeset/large-horses-invent.md | 2 +- .../lib/src/core/posthog_core_stateless.dart | 6 +- posthog_flutter/lib/src/core/utils/utils.dart | 8 ++- .../test/core/posthog_core_test.dart | 42 +++++++++++-- .../posthog_desktop_exception_steps_test.dart | 59 ++++++++++++++++++- .../test/posthog_flutter_desktop_test.dart | 4 +- 6 files changed, 106 insertions(+), 15 deletions(-) diff --git a/.changeset/large-horses-invent.md b/.changeset/large-horses-invent.md index 21a51965..e08621c4 100644 --- a/.changeset/large-horses-invent.md +++ b/.changeset/large-horses-invent.md @@ -2,4 +2,4 @@ "posthog_flutter": patch --- -Convert numeric NaN and infinities in Windows/Linux event properties to JSON null instead of strings, preserving array positions and valid neighboring properties. +Omit numeric NaN and infinities from Windows/Linux event object properties and send them as null in arrays instead of strings. Preserve array positions and valid neighboring properties, and budget exception steps using their final wire form. diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index d9dbe26b..9e97bae3 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -435,8 +435,10 @@ abstract class PostHogCoreStateless { Map properties) => { for (final MapEntry(:key, :value) in properties.entries) - if (value != null || key == r'$feature_flag_response') - '$key': toJsonValue(value, logger, dropNullMembers: true), + if (toJsonValue(value, logger, dropNullMembers: true) + case final converted + when converted != null || key == r'$feature_flag_response') + '$key': converted, }; void _scheduleFlush() { diff --git a/posthog_flutter/lib/src/core/utils/utils.dart b/posthog_flutter/lib/src/core/utils/utils.dart index a17e423f..473d5ab1 100644 --- a/posthog_flutter/lib/src/core/utils/utils.dart +++ b/posthog_flutter/lib/src/core/utils/utils.dart @@ -45,8 +45,8 @@ Future retriable( /// Other values JSON cannot represent are converted like `jsonEncode` does, /// through their `toJson()`, or else sent as their `toString()` with a /// warning to [logger]. Map keys become strings. With [dropNullMembers], -/// null-valued map entries are left out at every level, while list -/// elements keep their positions. +/// map entries whose converted value is null are left out at every level, +/// while list elements keep their positions. Object? toJsonValue(Object? value, CoreLogger logger, {bool dropNullMembers = false}) { // The maps and lists being converted: one that contains itself goes to @@ -68,7 +68,9 @@ Object? toJsonValue(Object? value, CoreLogger logger, try { return { for (final MapEntry(:key, value: member) in map.entries) - if (member != null || !dropNullMembers) '$key': convert(member), + if (convert(member) case final converted + when converted != null || !dropNullMembers) + '$key': converted, }; } finally { converting.remove(map); diff --git a/posthog_flutter/test/core/posthog_core_test.dart b/posthog_flutter/test/core/posthog_core_test.dart index 4e6d9f11..dae2cb47 100644 --- a/posthog_flutter/test/core/posthog_core_test.dart +++ b/posthog_flutter/test/core/posthog_core_test.dart @@ -5,6 +5,7 @@ import 'package:posthog_flutter/src/core/file_storage.dart'; import 'package:posthog_flutter/src/core/persistence.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_desktop_client.dart'; +import 'package:posthog_flutter/src/posthog_desktop_exception_steps.dart'; import 'package:posthog_flutter/src/posthog_flutter_version.dart'; import '../posthog_api_fake.dart'; @@ -568,7 +569,7 @@ void main() { expect(props['link'], 'https://posthog.com/docs'); expect(props['plan'], {'name': 'pro'}, reason: 'jsonEncode supports toJson(), so the event must too'); - expect(props, containsPair('ratio', null)); + expect(props, isNot(contains('ratio'))); expect(props['counts'], {'1': 'one'}); expect(() => jsonEncode(getQueue(storage)), returnsNormally); }); @@ -578,7 +579,7 @@ void main() { ('positive infinity', double.infinity, 'Infinity'), ('negative infinity', double.negativeInfinity, '-Infinity'), ]) { - test('captures and sends numeric $label as null without changing input', + test('captures and sends numeric $label without changing input', () async { final nested = {'ratio': value, 'finite': 42.5}; final inArray = {'ratio': value, 'enabled': false}; @@ -593,12 +594,11 @@ void main() { 'drop': null, }; final expected = { - 'ratio': null, - 'nested': {'ratio': null, 'finite': 42.5}, + 'nested': {'finite': 42.5}, 'items': [ 0, null, - {'ratio': null, 'enabled': false}, + {'enabled': false}, null, 'after' ], @@ -617,6 +617,7 @@ void main() { expect(queued, containsPair(entry.key, entry.value)); } expect(queued, isNot(contains('drop'))); + expect(queued, isNot(contains('ratio'))); await client.flush(); @@ -627,6 +628,7 @@ void main() { expect(sent, containsPair(entry.key, entry.value)); } expect(sent, isNot(contains('drop'))); + expect(sent, isNot(contains('ratio'))); expect(batch.events.last['properties'], allOf(containsPair('finite', 7), containsPair('literal', literal))); expect(getQueue(storage), isEmpty); @@ -642,6 +644,36 @@ void main() { }); } + test( + 'sends buffered non-finite exception steps without changing their form', + () async { + final buffer = ExceptionStepsBuffer(maxBytes: 32768); + buffer.add('step', properties: { + 'ratio': double.nan, + 'nested': {'ratio': double.infinity, 'finite': 42.5}, + 'items': [ + 0, + double.negativeInfinity, + {'ratio': double.nan}, + 'NaN' + ], + }); + final buffered = jsonDecode(jsonEncode(buffer.steps)); + + client.capture(r'$exception', properties: { + r'$exception_steps': buffer.steps, + }); + client.capture('healthy', properties: {'finite': 7}); + await client.flush(); + + final batch = server.batchRequests.single; + expect(batch.eventNames, [r'$exception', 'healthy']); + final sent = batch.events.first['properties'] as Map; + expect(sent[r'$exception_steps'], buffered); + expect(buffer.steps, buffered); + expect(batch.events.last['properties'], containsPair('finite', 7)); + }); + test('sends a value without a JSON form as its string, with a warning', () { final lines = printedLines(() => client .capture('evt', properties: {'timeout': const Duration(seconds: 3)})); diff --git a/posthog_flutter/test/posthog_desktop_exception_steps_test.dart b/posthog_flutter/test/posthog_desktop_exception_steps_test.dart index 5059a3df..73cefc4a 100644 --- a/posthog_flutter/test/posthog_desktop_exception_steps_test.dart +++ b/posthog_flutter/test/posthog_desktop_exception_steps_test.dart @@ -66,7 +66,8 @@ void main() { expect(step['nested'], {'kept': 1}); }); - test('keeps a step with non-finite numbers as null', () { + test('omits non-finite object members and preserves null array positions', + () { final buffer = ExceptionStepsBuffer(maxBytes: 32768); buffer.add('step', properties: { @@ -77,12 +78,66 @@ void main() { }); final step = buffer.steps.single; - expect(step, containsPair('ratio', null)); + expect(step, isNot(contains('ratio'))); expect(step['limits'], [null, null]); expect(step['finite'], 42.5); expect(step['literal'], 'NaN'); }); + for (final capacity in [1, 2]) { + test('retains $capacity non-finite steps whose wire forms fit exactly', + () { + final expectedStep = { + 'nested': {'finite': 42.5}, + 'items': [ + 0, + null, + {'literal': 'NaN'}, + null, + 'after' + ], + 'enabled': false, + 'literal': 'Infinity', + r'$message': 'step 1', + r'$timestamp': '2026-01-02T00:00:00.000Z', + }; + final stepBytes = bytesOf(expectedStep); + final buffer = ExceptionStepsBuffer(maxBytes: stepBytes * capacity); + final nested = {'ratio': double.infinity, 'finite': 42.5}; + final inArray = {'ratio': double.negativeInfinity, 'literal': 'NaN'}; + final properties = { + 'ratio': double.nan, + 'nested': nested, + 'items': [0, double.infinity, inArray, null, 'after'], + 'enabled': false, + 'literal': 'Infinity', + }; + + for (var i = 1; i <= capacity; i++) { + buffer.add('step $i', properties: properties); + } + + expect(messagesOf(buffer), + [for (var i = 1; i <= capacity; i++) 'step $i']); + for (final step in buffer.steps) { + expect(bytesOf(step), stepBytes); + expect({ + ...step, + r'$message': 'step 1', + r'$timestamp': '2026-01-02T00:00:00.000Z' + }, expectedStep); + } + expect(properties['ratio'], isNaN); + expect(nested['ratio'], double.infinity); + expect(inArray['ratio'], double.negativeInfinity); + + buffer.add('step ${capacity + 1}', properties: properties); + + expect(messagesOf(buffer), + [for (var i = 2; i <= capacity + 1; i++) 'step $i']); + }); + } + test('evicts the oldest steps to stay within the budget', () { final probe = ExceptionStepsBuffer(maxBytes: 32768)..add('step 1'); final buffer = ExceptionStepsBuffer( diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 1289c36a..bc5d451c 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -836,7 +836,7 @@ void main() { ['before capture', 'during beforeSend']); }); - test('omit null-valued buffered step members when capturing the exception', + test('normalize non-finite step and event properties identically', () async { final server = await LocalPostHogServer.start(); final platform = await setUpPlatform(configFor(server)); @@ -855,7 +855,7 @@ void main() { final properties = _propertiesOf(await server.waitForEvent(r'$exception')); final step = (properties[r'$exception_steps']! as List).single as Map; - expect(properties, containsPair('ratio', null)); + expect(properties, isNot(contains('ratio'))); expect(step, isNot(contains('ratio'))); for (final sent in [properties, step]) { expect(sent['finite'], 42.5);