From 219f48ba698c8d04dd4f80293925ce9b0349b5b4 Mon Sep 17 00:00:00 2001 From: vahidlazio <692343+vahidlazio@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:13:08 +0200 Subject: [PATCH] fix: stop publishing unsupported tracking values as 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dart wraps a value it has no Confidence type for — a DateTime, a null, a custom object — as {'type': 'unknown', 'value': value.toString()}, so the stringified value does reach native. Both native sides then threw it away. iOS convertValue's default branch returned ConfidenceValue(integer: 0), so track('my_event', {'ts': DateTime.now()}) published ts = 0. The event was accepted, so nothing surfaced the corruption; the data was simply wrong. Android convert() threw IllegalArgumentException("Unknown type unknown") instead. Since Dart's track is fire-and-forget, that surfaced only as a logged handler error while the event was lost — a different failure from iOS for the same input. Both now publish the string Dart already computed, so the two platforms agree and the caller's value survives. Stringifying is preferred over rejecting (track must not throw at the app) and over omitting the key (silent loss is the failure being fixed). The unrecognised-type branches are hardened the same way rather than left to coerce. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ConfidenceFlutterSdkPlugin.kt | 5 +++ .../ConfidenceFlutterSdkPlugin.swift | 12 +++++- ...confidence_flutter_sdk_method_channel.dart | 8 ++++ ...dence_flutter_sdk_method_channel_test.dart | 43 +++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/android/src/main/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.kt b/android/src/main/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.kt index 0ddfc95..e4e19bb 100644 --- a/android/src/main/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.kt +++ b/android/src/main/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.kt @@ -187,6 +187,11 @@ private fun Map.convert(): ConfidenceValue { "double" -> return ConfidenceValue.Double(this["value"] as Double) "bool" -> return ConfidenceValue.Boolean(this["value"] as Boolean) "int" -> return ConfidenceValue.Integer(this["value"] as Int) + // Dart could not map this type (a DateTime, a null, a custom object) and + // has already sent `value.toString()`. Keep that string rather than + // throwing: `track` is fire-and-forget from Dart, so an exception here + // would surface only as a logged handler error while the event is lost. + "unknown" -> return ConfidenceValue.String(this["value"]?.toString() ?: "") "list" -> { val list = (this["value"] as List>).map { it.convert() } return ConfidenceValue.List(list) diff --git a/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift b/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift index 85346c4..e5a8c0b 100644 --- a/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift +++ b/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift @@ -259,7 +259,17 @@ func convertValue(_ type: String, _ value: Any) -> ConfidenceValue { }) case "string": return ConfidenceValue.init(string: value as! String) + case "unknown": + // Dart could not map this type (a DateTime, a null, a custom object) + // and has already sent `value.toString()`. Keep that string: coercing + // it to a number would publish a wrong value with nothing to show the + // caller their data was discarded. + return ConfidenceValue.init(string: value as? String ?? String(describing: value)) default: - return ConfidenceValue.init(integer: 0) + // An unrecognised type marker means the Dart and native sides have + // drifted. Preserve the value as a string and make the mismatch + // visible rather than silently publishing a number. + NSLog("%@", "Confidence SDK: unsupported value type '\(type)', publishing it as a string") + return ConfidenceValue.init(string: value as? String ?? String(describing: value)) } } diff --git a/lib/confidence_flutter_sdk_method_channel.dart b/lib/confidence_flutter_sdk_method_channel.dart index 6555388..8c1216b 100644 --- a/lib/confidence_flutter_sdk_method_channel.dart +++ b/lib/confidence_flutter_sdk_method_channel.dart @@ -160,6 +160,14 @@ class MethodChannelConfidenceFlutterSdk extends ConfidenceFlutterSdkPlatform { return value!; } + /// Wraps [value] in the `{'type': ..., 'value': ...}` envelope the native + /// plugins decode. + /// + /// Anything Confidence has no type for — a `DateTime`, a `null`, a custom + /// object — is sent as `type: 'unknown'` carrying `value.toString()`. Both + /// native sides publish that as a string. They must not coerce it to a + /// number or drop it: the caller's data would be silently wrong with + /// nothing to indicate it. Map toTypedValue(dynamic value) { if (value is int) { return {'type': 'int', 'value': value}; diff --git a/test/confidence_flutter_sdk_method_channel_test.dart b/test/confidence_flutter_sdk_method_channel_test.dart index ac16352..3f128a2 100644 --- a/test/confidence_flutter_sdk_method_channel_test.dart +++ b/test/confidence_flutter_sdk_method_channel_test.dart @@ -111,4 +111,47 @@ void main() { expect(methodCalls, hasLength(1)); expect(methodCalls.single.method, 'track'); }); + + // Values Confidence has no type for must reach native as a stringified + // 'unknown', never as a number and never dropped. The native halves of this + // contract (iOS convertValue, Android convert) cannot be exercised from + // Dart, so what is pinned here is the wire format they rely on. + group('unsupported tracking values keep their value', () { + Map trackedData() { + final args = methodCalls.single.arguments as Map; + return (args['data'] as Map) + .map((k, v) => MapEntry(k as String, v)); + } + + test('a DateTime is stringified, not coerced to a number', () { + final now = DateTime.utc(2026, 9, 7, 12, 34, 56); + platform.track('my_event', {'ts': now}); + + final ts = trackedData()['ts'] as Map; + expect(ts['type'], 'unknown'); + expect(ts['value'], now.toString()); + expect(ts['value'], isNot(0)); + }); + + test('a null keeps a value and the key is not dropped', () { + platform.track('my_event', {'maybe': null}); + + final data = trackedData(); + expect(data.containsKey('maybe'), isTrue, + reason: 'the key must survive so the caller can see what was sent'); + final maybe = data['maybe'] as Map; + expect(maybe['type'], 'unknown'); + expect(maybe['value'], isNot(0)); + }); + + test('supported types are unaffected', () { + platform.track('my_event', {'n': 7, 's': 'x', 'b': true, 'd': 1.5}); + + final data = trackedData(); + expect((data['n'] as Map)['type'], 'int'); + expect((data['s'] as Map)['type'], 'string'); + expect((data['b'] as Map)['type'], 'bool'); + expect((data['d'] as Map)['type'], 'double'); + }); + }); }