Skip to content
Merged
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/large-horses-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog_flutter": patch
---

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.
6 changes: 4 additions & 2 deletions posthog_flutter/lib/src/core/posthog_core_stateless.dart
Original file line number Diff line number Diff line change
Expand Up @@ -435,8 +435,10 @@ abstract class PostHogCoreStateless {
Map<Object?, Object?> 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() {
Expand Down
13 changes: 9 additions & 4 deletions posthog_flutter/lib/src/core/utils/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@ Future<T> retriable<T>(

/// 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],
/// 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
Expand All @@ -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;
Comment thread
marandaneto marked this conversation as resolved.
Resolved
case final DateTime date:
return date.toUtc().toIso8601String();
case final Uri uri:
Expand All @@ -65,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);
Expand Down
103 changes: 102 additions & 1 deletion posthog_flutter/test/core/posthog_core_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -568,11 +569,111 @@ 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, isNot(contains('ratio')));
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 without changing input',
() async {
final nested = <String, Object?>{'ratio': value, 'finite': 42.5};
final inArray = <String, Object?>{'ratio': value, 'enabled': false};
final items = <Object?>[0, value, inArray, null, 'after'];
final properties = <String, Object?>{
'ratio': value,
'nested': nested,
'items': items,
'finite': 42.5,
'enabled': true,
'literal': literal,
'drop': null,
};
final expected = <String, Object?>{
'nested': {'finite': 42.5},
'items': [
0,
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')));
expect(queued, isNot(contains('ratio')));

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(sent, isNot(contains('ratio')));
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 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)}));
Expand Down
65 changes: 62 additions & 3 deletions posthog_flutter/test/posthog_desktop_exception_steps_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,78 @@ void main() {
expect(step['nested'], {'kept': 1});
});

test('keeps a step with a number JSON cannot represent, as its string', () {
test('omits non-finite object members and preserves null array positions',
() {
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, 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 = <String, Object?>{
'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(
Expand Down
21 changes: 15 additions & 6 deletions posthog_flutter/test/posthog_flutter_desktop_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -836,22 +836,31 @@ void main() {
['before capture', 'during beforeSend']);
});

test('send their values as event properties send them', () async {
test('normalize non-finite step and event properties identically',
() 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, isNot(contains('ratio')));
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 {
Expand Down
Loading