Skip to content

Commit 79df88f

Browse files
buenaflorcodexcursoragent
authored
perf(flutter): Optimize Android scope sync (#3708)
* perf(flutter): Optimize Android scope sync Send large Android scope payloads as JSON bytes instead of recursively constructing Java maps and lists through JNI. This keeps nested user data structured while reducing per-entry JNI object churn. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(flutter): Document JNI payload guidance Clarify that large or arbitrary Dart collection payloads should cross JNI as JSON bytes, while primitives and small controlled payloads can use direct conversion. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * style(flutter): Wrap JNI JSON reader helper Keep the Android JSON reader helper within ktlint formatting limits after adding the scope sync byte-array bridge. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * style(flutter): Apply ktlint when-branch formatting Wrap all native JSON conversion when branches consistently so ktlint accepts the multiline Kotlin helper added for Android scope sync. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * ref(flutter): Reuse native data normalizer Use the existing native boundary normalizer before encoding Android scope payloads as JSON bytes instead of maintaining a second normalization helper. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(flutter): Preserve nulls in Android JSON bridge Keep null values when converting JSON object and array payloads on Android so the bridge remains lossless before native model deserialization. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * perf(flutter): Reuse Android JSON deserializers Cache JSON deserializers for scope sync payloads and replace the broad Any extension with a private helper function for Kotlin JSON conversion. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * ref(flutter): Use SDK JSON object reader Use the Java SDK JSON reader for context payload parsing instead of a custom recursive org.json conversion helper. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * docs(flutter): Clarify JSON reader parsing Document that the Android JSON reader accepts root-level primitives so future changes do not replace it with object-only parsing. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(flutter): Wrap Android primitive contexts Route Android context values through sentry-java's typed overloads so primitive Dart context values are serialized as valid context objects. Regenerate JNI bindings after removing the unused object overload entrypoint. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * test(flutter): Expect wrapped Android contexts Update the native context sync test to match the Android bridge's valid serialized shape for primitive context values. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2e4277e commit 79df88f

7 files changed

Lines changed: 363 additions & 78 deletions

File tree

packages/flutter/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ Flutter SDK with native integrations across all platforms.
2020
- Release all native memory (JNI local refs, malloc allocations)
2121
- Handle native exceptions gracefully — never crash the host app
2222
- JNI bindings use `package:jni`; FFI bindings use `dart:ffi`
23+
- JNI should pass primitives directly. Small, controlled `Map`/`List` payloads may use direct conversion; arbitrary or large payloads should cross as UTF-8 JSON bytes and deserialize on Kotlin/Java.
2324
- JNI and FFI can currently only be tested through integration test since they cannot be injected / mocked or faked.
2425

25-
2626
## Key Directories
2727

2828
- `lib/src/integrations/` — Integration implementations (reference for new integrations)

packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
1515
import io.flutter.plugin.common.MethodChannel.Result
1616
import io.sentry.Breadcrumb
1717
import io.sentry.DateUtils
18+
import io.sentry.IScope
19+
import io.sentry.JsonObjectReader
1820
import io.sentry.ScopesAdapter
1921
import io.sentry.Sentry
2022
import io.sentry.SentryOptions
@@ -31,8 +33,10 @@ import io.sentry.protocol.DebugImage
3133
import io.sentry.protocol.SdkVersion
3234
import io.sentry.protocol.User
3335
import io.sentry.transport.CurrentDateProvider
34-
import org.json.JSONObject
3536
import org.json.JSONArray
37+
import org.json.JSONObject
38+
import java.io.ByteArrayInputStream
39+
import java.io.InputStreamReader
3640
import java.lang.ref.WeakReference
3741
import java.net.Proxy.Type
3842
import kotlin.math.roundToInt
@@ -113,6 +117,14 @@ class SentryFlutterPlugin :
113117

114118
private const val NATIVE_CRASH_WAIT_TIME = 500L
115119

120+
private val breadcrumbDeserializer by lazy {
121+
Breadcrumb.Deserializer()
122+
}
123+
124+
private val userDeserializer by lazy {
125+
User.Deserializer()
126+
}
127+
116128
/**
117129
* Tears down the current ReplayIntegration to avoid invoking callbacks from a stale
118130
* Flutter isolate after hot restart.
@@ -161,14 +173,54 @@ class SentryFlutterPlugin :
161173
}
162174
}
163175

164-
@Suppress("unused") // Used by native/jni bindings
176+
@Suppress("unused", "TooGenericExceptionCaught") // Used by native/jni bindings
177+
@JvmStatic
178+
fun addBreadcrumbFromJsonBytes(bytes: ByteArray) {
179+
try {
180+
val options = ScopesAdapter.getInstance().options
181+
val breadcrumb =
182+
jsonObjectReader(bytes).use { reader ->
183+
breadcrumbDeserializer.deserialize(reader, options.logger)
184+
}
185+
Sentry.addBreadcrumb(breadcrumb)
186+
} catch (e: Exception) {
187+
Log.e("Sentry", "Failed to add breadcrumb from JSON bytes", e)
188+
}
189+
}
190+
191+
@Suppress("unused", "TooGenericExceptionCaught") // Used by native/jni bindings
192+
@JvmStatic
193+
fun setUserFromJsonBytes(bytes: ByteArray?) {
194+
try {
195+
if (bytes == null) {
196+
Sentry.setUser(null)
197+
return
198+
}
199+
200+
val options = ScopesAdapter.getInstance().options
201+
val user =
202+
jsonObjectReader(bytes).use { reader ->
203+
userDeserializer.deserialize(reader, options.logger)
204+
}
205+
Sentry.setUser(user)
206+
} catch (e: Exception) {
207+
Log.e("Sentry", "Failed to set user from JSON bytes", e)
208+
}
209+
}
210+
211+
@Suppress("unused", "TooGenericExceptionCaught") // Used by native/jni bindings
165212
@JvmStatic
166-
fun setContext(
213+
fun setContextFromJsonBytes(
167214
key: String,
168-
value: Any?,
215+
bytes: ByteArray,
169216
) {
170-
Sentry.configureScope { scope ->
171-
scope.setContexts(key, value)
217+
try {
218+
val value = parseJsonBytes(bytes)
219+
Sentry.configureScope { scope ->
220+
setContextValue(scope, key, value)
221+
}
222+
} catch (e: Exception) {
223+
Log.e("Sentry", "Failed to set context from JSON bytes", e)
172224
}
173225
}
174226

@@ -395,6 +447,37 @@ class SentryFlutterPlugin :
395447
"debug_file" to debugFile,
396448
)
397449

450+
private fun setContextValue(
451+
scope: IScope,
452+
key: String,
453+
value: Any?,
454+
) {
455+
// Force sentry-java's typed overloads so primitive contexts are wrapped
456+
// as {"value": ...} instead of being stored as invalid raw values.
457+
when (value) {
458+
null -> scope.setContexts(key, null as Any?)
459+
is Boolean -> scope.setContexts(key, value)
460+
is String -> scope.setContexts(key, value)
461+
is Number -> scope.setContexts(key, value)
462+
is Collection<*> -> scope.setContexts(key, value)
463+
is Array<*> -> scope.setContexts(key, value)
464+
is Char -> scope.setContexts(key, value)
465+
else -> scope.setContexts(key, value)
466+
}
467+
}
468+
469+
private fun parseJsonBytes(bytes: ByteArray): Any? =
470+
jsonObjectReader(bytes).use { reader ->
471+
// Despite the name, sentry-java's JsonObjectReader accepts
472+
// primitives here as well as objects and arrays.
473+
reader.nextObjectOrNull()
474+
}
475+
476+
private fun jsonObjectReader(bytes: ByteArray): JsonObjectReader =
477+
JsonObjectReader(
478+
InputStreamReader(ByteArrayInputStream(bytes), Charsets.UTF_8),
479+
)
480+
398481
private fun Double.adjustReplaySizeToBlockSize(): Double {
399482
val remainder = this % VIDEO_BLOCK_SIZE
400483
return if (remainder <= VIDEO_BLOCK_SIZE / 2) {

packages/flutter/example/integration_test/integration_test.dart

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,75 @@ void main() {
151151
});
152152
});
153153

154+
testWidgets('syncs large scope maps to native on Android', (tester) async {
155+
await restoreFlutterOnErrorAfter(() async {
156+
await setupSentryAndApp(tester);
157+
});
158+
159+
final largeItems = List.generate(
160+
64,
161+
(index) => {
162+
'index': index,
163+
'label': 'item-$index',
164+
'nested': {
165+
'enabled': index.isEven,
166+
'value': index * 1.5,
167+
'nullEntry': null,
168+
},
169+
},
170+
);
171+
172+
await Sentry.configureScope((scope) async {
173+
await scope.setContexts('large_context', {
174+
'items': largeItems,
175+
'customObject': CustomObject(),
176+
'nullEntry': null,
177+
'sparseList': ['a', null, 'c'],
178+
});
179+
await scope.setUser(SentryUser(
180+
id: 'large-user',
181+
data: {
182+
'items': largeItems,
183+
'customObject': CustomObject(),
184+
'nullEntry': null,
185+
'sparseList': ['a', null, 'c'],
186+
},
187+
));
188+
await scope.addBreadcrumb(Breadcrumb(
189+
message: 'large-breadcrumb',
190+
data: {
191+
'items': largeItems,
192+
'customObject': CustomObject(),
193+
'nullEntry': null,
194+
'sparseList': ['a', null, 'c'],
195+
},
196+
));
197+
});
198+
199+
final nativeContexts = await SentryFlutter.native?.loadContexts();
200+
final contextData = nativeContexts?['contexts'] as Map?;
201+
final largeContext = contextData?['large_context'] as Map?;
202+
final nativeUser = nativeContexts?['user'] as Map?;
203+
final breadcrumbs = nativeContexts?['breadcrumbs'] as List?;
204+
final nativeBreadcrumb = breadcrumbs?.cast<Map>().firstWhere(
205+
(breadcrumb) => breadcrumb['message'] == 'large-breadcrumb',
206+
);
207+
208+
expect((largeContext?['items'] as List?)?.length, 64);
209+
expect(largeContext?['customObject'], CustomObject().toString());
210+
expect(largeContext?.containsKey('nullEntry'), isTrue);
211+
expect(largeContext?['nullEntry'], isNull);
212+
expect(largeContext?['sparseList'], ['a', null, 'c']);
213+
expect(nativeUser?['id'], 'large-user');
214+
final userData = nativeUser?['data'] as Map?;
215+
expect((userData?['items'] as List?)?.length, 64);
216+
expect(userData?['sparseList'], ['a', null, 'c']);
217+
218+
final breadcrumbData = nativeBreadcrumb?['data'] as Map?;
219+
expect((breadcrumbData?['items'] as List?)?.length, 64);
220+
expect(breadcrumbData?['sparseList'], ['a', null, 'c']);
221+
}, skip: !Platform.isAndroid);
222+
154223
testWidgets('setup sentry and start transaction', (tester) async {
155224
await setupSentryAndApp(tester);
156225

@@ -806,23 +875,12 @@ void main() {
806875
expect(user['data']['map'], isNotNull);
807876
expect(user['data']['list'], isNotNull);
808877
expect(user['data']['custom object'], equals(customObject.toString()));
809-
810-
if (Platform.isAndroid) {
811-
// On Android, the Java SDK's User.data field only supports Map<String, String>.
812-
// Nested Maps and Lists are converted to Java's HashMap/ArrayList toString()
813-
// format (e.g., {key=value} instead of {"key":"value"}).
814-
expect(user['data']['map'],
815-
equals('{nested=data, custom object=${customObject.toString()}}'));
816-
expect(
817-
user['data']['list'], equals('[1, ${customObject.toString()}, 3]'));
818-
} else {
819-
expect(user['data']['map']['nested'], equals('data'));
820-
expect(user['data']['map']['custom object'],
821-
equals(customObject.toString()));
822-
expect(user['data']['list'][0], equals(1));
823-
expect(user['data']['list'][1], equals(customObject.toString()));
824-
expect(user['data']['list'][2], equals(3));
825-
}
878+
expect(user['data']['map']['nested'], equals('data'));
879+
expect(
880+
user['data']['map']['custom object'], equals(customObject.toString()));
881+
expect(user['data']['list'][0], equals(1));
882+
expect(user['data']['list'][1], equals(customObject.toString()));
883+
expect(user['data']['list'][2], equals(3));
826884

827885
// 3. Clear user (after clearing the id should remain)
828886
await Sentry.configureScope((scope) async {
@@ -954,13 +1012,13 @@ void main() {
9541012
expect(values['key4'], {'value': 12}, reason: 'key4 mismatch');
9551013
expect(values['key5'], {'value': 12.3}, reason: 'key5 mismatch');
9561014
} else if (Platform.isAndroid) {
957-
expect(values['key1'], 'randomValue', reason: 'key1 mismatch');
1015+
expect(values['key1'], {'value': 'randomValue'}, reason: 'key1 mismatch');
9581016
expect(values['key2'],
9591017
{'String': 'Value', 'Bool': true, 'Int': 123, 'Double': 12.3},
9601018
reason: 'key2 mismatch');
961-
expect(values['key3'], true, reason: 'key3 mismatch');
962-
expect(values['key4'], 12, reason: 'key4 mismatch');
963-
expect(values['key5'], 12.3, reason: 'key5 mismatch');
1019+
expect(values['key3'], {'value': true}, reason: 'key3 mismatch');
1020+
expect(values['key4'], {'value': 12}, reason: 'key4 mismatch');
1021+
expect(values['key5'], {'value': 12.3}, reason: 'key5 mismatch');
9641022
}
9651023

9661024
await Sentry.configureScope((scope) async {

packages/flutter/example/integration_test/native_jni_utils_test.dart

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
// ignore_for_file: depend_on_referenced_packages
1+
// ignore_for_file: depend_on_referenced_packages, invalid_use_of_internal_member
22
@TestOn('vm')
33

44
import 'dart:io';
55

66
import 'package:test/test.dart';
77
import 'package:jni/jni.dart';
88
import 'package:sentry_flutter/src/native/java/sentry_native_java.dart';
9+
import 'package:sentry_flutter/src/native/utils/data_normalizer.dart';
910

1011
import 'utils.dart';
1112

@@ -47,6 +48,11 @@ void main() {
4748
'innerList': [1, 2],
4849
'innerNull': null,
4950
};
51+
final expectedNormalizedNestedMap = {
52+
'innerString': 'nested',
53+
'innerList': [1, null, 2],
54+
'innerNull': null,
55+
};
5056
final expectedList = [
5157
'value',
5258
1,
@@ -66,6 +72,21 @@ void main() {
6672
'list': expectedList,
6773
'nestedMap': expectedNestedMap,
6874
};
75+
final expectedNormalizedMap = {
76+
...expectedMap,
77+
'nullEntry': null,
78+
'list': [
79+
'value',
80+
1,
81+
1.1,
82+
true,
83+
customObject.toString(),
84+
['nestedList', 2],
85+
expectedNormalizedNestedMap,
86+
null,
87+
],
88+
'nestedMap': expectedNormalizedNestedMap,
89+
};
6990

7091
group('JNI (Android)', () {
7192
test('dartToJObject converts primitives', () {
@@ -112,6 +133,23 @@ void main() {
112133
_expectJniMap(javaMap, expectedMap, arena);
113134
});
114135
});
136+
137+
test('normalize normalizes values for JSON bytes', () {
138+
final actual = normalize(inputMap);
139+
140+
expect(actual, expectedNormalizedMap);
141+
});
142+
143+
test('jsonToJByteArray converts normalized JSON to bytes', () {
144+
using((arena) {
145+
final javaBytes = jsonToJByteArray(inputMap)..releasedBy(arena);
146+
final byteRange = javaBytes.getRange(0, javaBytes.length);
147+
final bytes = byteRange.buffer
148+
.asUint8List(byteRange.offsetInBytes, byteRange.length);
149+
150+
expect(bytes, isNotEmpty);
151+
});
152+
});
115153
}, skip: !Platform.isAndroid);
116154
}
117155

0 commit comments

Comments
 (0)