Skip to content

Commit a10aff4

Browse files
authored
enh: offload captureEnvelope to background isolate for iOS and Android (#3232)
* Update * Update * Update * Update * Update * Update * Configure diagnostic log * Update log messages * Update * Update * Update * Update * Update * Update * Update * Update * Update * Update * Fix test * Update * Update * Update * Add automatedTestMode option * Update * Fix web tests * Update * Update * Add close * Review * Review
1 parent f18bc8f commit a10aff4

20 files changed

+1167
-48
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 9.7.0
44

5+
56
### Features
67

78
- Add W3C `traceparent` header support ([#3246](https://github.com/getsentry/sentry-dart/pull/3246))

packages/flutter/example/pubspec_overrides.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,3 @@ dependency_overrides:
2121
isar_flutter_libs:
2222
git:
2323
url: https://github.com/MrLittleWhite/isar_flutter_libs.git
24-

packages/flutter/lib/src/integrations/thread_info_integration.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import 'package:meta/meta.dart';
44

55
import '../../sentry_flutter.dart';
6-
import '../isolate_helper.dart';
6+
import '../isolate/isolate_helper.dart';
77

88
/// Integration for adding thread information to spans.
99
///
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import 'dart:developer' as developer;
2+
3+
import 'package:meta/meta.dart';
4+
5+
import '../../sentry_flutter.dart';
6+
7+
/// Static logger for Isolates that writes diagnostic messages to `dart:developer.log`.
8+
///
9+
/// Intended for worker/background isolates where a `SentryOptions` instance
10+
/// or hub may not be available. Because Dart statics are isolate-local,
11+
/// you must call [configure] once per isolate before using [log].
12+
class IsolateLogger {
13+
IsolateLogger._();
14+
15+
static late bool _debug;
16+
static late SentryLevel _level;
17+
static late String _loggerName;
18+
static bool _isConfigured = false;
19+
20+
/// Configures this logger for the current isolate.
21+
///
22+
/// Must be called once per isolate before invoking [log].
23+
/// Throws [StateError] if called more than once without calling [reset] first.
24+
///
25+
/// - [debug]: when false, suppresses all logs except [SentryLevel.fatal].
26+
/// - [level]: minimum severity threshold (inclusive) when [debug] is true.
27+
/// - [loggerName]: logger name for the call sites
28+
static void configure(
29+
{required bool debug,
30+
required SentryLevel level,
31+
required String loggerName}) {
32+
if (_isConfigured) {
33+
throw StateError(
34+
'IsolateLogger.configure has already been called. It can only be configured once per isolate.');
35+
}
36+
_debug = debug;
37+
_level = level;
38+
_loggerName = loggerName;
39+
_isConfigured = true;
40+
}
41+
42+
/// Resets the logger state to allow reconfiguration.
43+
///
44+
/// This is intended for testing purposes only.
45+
@visibleForTesting
46+
static void reset() {
47+
_isConfigured = false;
48+
}
49+
50+
/// Emits a log entry if enabled.
51+
///
52+
/// Messages are forwarded to [developer.log]. The provided [level] is
53+
/// mapped via [SentryLevel.toDartLogLevel] to a `developer.log` numeric level.
54+
/// If logging is disabled or [level] is below the configured threshold,
55+
/// nothing is emitted. [SentryLevel.fatal] is always emitted.
56+
static void log(
57+
SentryLevel level,
58+
String message, {
59+
String? logger,
60+
Object? exception,
61+
StackTrace? stackTrace,
62+
}) {
63+
assert(
64+
_isConfigured, 'IsolateLogger.configure must be called before logging');
65+
if (_isEnabled(level)) {
66+
developer.log(
67+
'[${level.name}] $message',
68+
level: level.toDartLogLevel(),
69+
name: logger ?? _loggerName,
70+
time: DateTime.now(),
71+
error: exception,
72+
stackTrace: stackTrace,
73+
);
74+
}
75+
}
76+
77+
static bool _isEnabled(SentryLevel level) {
78+
return (_debug && level.ordinal >= _level.ordinal) ||
79+
level == SentryLevel.fatal;
80+
}
81+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import 'dart:async';
2+
import 'dart:isolate';
3+
4+
import '../../sentry_flutter.dart';
5+
import 'isolate_logger.dart';
6+
7+
const _shutdownCommand = '_shutdown_';
8+
9+
// -------------------------------------------
10+
// HOST-SIDE API (runs on the main isolate)
11+
// -------------------------------------------
12+
13+
/// Minimal config passed to isolates - extend as needed.
14+
class WorkerConfig {
15+
final bool debug;
16+
final SentryLevel diagnosticLevel;
17+
final String debugName;
18+
final bool automatedTestMode;
19+
20+
const WorkerConfig({
21+
required this.debug,
22+
required this.diagnosticLevel,
23+
required this.debugName,
24+
this.automatedTestMode = false,
25+
});
26+
}
27+
28+
/// Host-side helper for workers to perform minimal request/response.
29+
/// Adapted from https://dart.dev/language/isolates#robust-ports-example
30+
class Worker {
31+
Worker(this._workerPort, this._responses) {
32+
_responses.listen(_handleResponse);
33+
}
34+
35+
final SendPort _workerPort;
36+
SendPort get port => _workerPort;
37+
final ReceivePort _responses;
38+
final Map<int, Completer<Object?>> _pending = {};
39+
int _idCounter = 0;
40+
bool _closed = false;
41+
42+
/// Fire-and-forget send to the worker.
43+
void send(Object? message) {
44+
_workerPort.send(message);
45+
}
46+
47+
/// Send a request to the worker and await a response.
48+
Future<Object?> request(Object? payload) async {
49+
if (_closed) throw StateError('Worker is closed');
50+
final id = _idCounter++;
51+
final completer = Completer<Object?>.sync();
52+
_pending[id] = completer;
53+
_workerPort.send((id, payload));
54+
return await completer.future;
55+
}
56+
57+
void close() {
58+
if (_closed) return;
59+
_closed = true;
60+
_workerPort.send(_shutdownCommand);
61+
if (_pending.isEmpty) {
62+
_responses.close();
63+
}
64+
}
65+
66+
void _handleResponse(dynamic message) {
67+
final (int id, Object? response) = message as (int, Object?);
68+
final completer = _pending.remove(id);
69+
if (completer == null) return;
70+
71+
if (response is RemoteError) {
72+
completer.completeError(response);
73+
} else {
74+
completer.complete(response);
75+
}
76+
77+
if (_closed && _pending.isEmpty) {
78+
_responses.close();
79+
}
80+
}
81+
}
82+
83+
/// Worker (isolate) entry-point signature.
84+
typedef WorkerEntry = void Function((SendPort, WorkerConfig));
85+
86+
/// Spawn a worker isolate and handshake to obtain its SendPort.
87+
Future<Worker> spawnWorker(
88+
WorkerConfig config,
89+
WorkerEntry entry,
90+
) async {
91+
final initPort = RawReceivePort();
92+
final connection = Completer<(ReceivePort, SendPort)>.sync();
93+
initPort.handler = (SendPort commandPort) {
94+
connection.complete((
95+
ReceivePort.fromRawReceivePort(initPort),
96+
commandPort,
97+
));
98+
};
99+
100+
try {
101+
await Isolate.spawn<(SendPort, WorkerConfig)>(
102+
entry,
103+
(initPort.sendPort, config),
104+
debugName: config.debugName,
105+
);
106+
} on Object {
107+
initPort.close();
108+
rethrow;
109+
}
110+
111+
final (ReceivePort receivePort, SendPort sendPort) = await connection.future;
112+
return Worker(sendPort, receivePort);
113+
}
114+
115+
// -------------------------------------------
116+
// ISOLATE-SIDE API (runs inside the worker isolate)
117+
// -------------------------------------------
118+
119+
/// Message/request handler that runs inside the worker isolate.
120+
///
121+
/// This does not represent the isolate lifecycle; it only defines how
122+
/// the worker processes incoming messages and optional request/response.
123+
abstract class WorkerHandler {
124+
/// Handle fire-and-forget messages sent from the host.
125+
FutureOr<void> onMessage(Object? message);
126+
127+
/// Handle request/response payloads sent from the host.
128+
/// Return value is sent back to the host. Default: no-op.
129+
FutureOr<Object?> onRequest(Object? payload) => {};
130+
}
131+
132+
/// Runs the Sentry worker loop inside a background isolate.
133+
///
134+
/// Call this only from the worker isolate entry-point spawned via
135+
/// [spawnWorker]. It configures logging, handshakes with the host, and routes
136+
/// messages
137+
void runWorker(
138+
WorkerConfig config,
139+
SendPort host,
140+
WorkerHandler handler,
141+
) {
142+
IsolateLogger.configure(
143+
debug: config.debug,
144+
level: config.diagnosticLevel,
145+
loggerName: config.debugName,
146+
);
147+
148+
final inbox = ReceivePort();
149+
host.send(inbox.sendPort);
150+
151+
inbox.listen((msg) async {
152+
if (msg == _shutdownCommand) {
153+
IsolateLogger.log(SentryLevel.debug, 'Isolate received shutdown');
154+
inbox.close();
155+
IsolateLogger.log(SentryLevel.debug, 'Isolate closed');
156+
return;
157+
}
158+
159+
if (msg is (int, Object?)) {
160+
final (id, payload) = msg;
161+
try {
162+
final result = await handler.onRequest(payload);
163+
host.send((id, result));
164+
} catch (e, st) {
165+
host.send((id, RemoteError(e.toString(), st.toString())));
166+
}
167+
return;
168+
}
169+
170+
try {
171+
await handler.onMessage(msg);
172+
} catch (exception, stackTrace) {
173+
IsolateLogger.log(SentryLevel.error, 'Isolate failed to handle message',
174+
exception: exception, stackTrace: stackTrace);
175+
}
176+
});
177+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import 'dart:async';
2+
import 'dart:isolate';
3+
import 'dart:typed_data';
4+
5+
import 'package:meta/meta.dart';
6+
import 'package:objective_c/objective_c.dart';
7+
8+
import '../../../sentry_flutter.dart';
9+
import '../../isolate/isolate_worker.dart';
10+
import '../../isolate/isolate_logger.dart';
11+
import 'binding.dart' as cocoa;
12+
13+
typedef SpawnWorkerFn = Future<Worker> Function(WorkerConfig, WorkerEntry);
14+
15+
class CocoaEnvelopeSender {
16+
final SentryFlutterOptions _options;
17+
final WorkerConfig _config;
18+
final SpawnWorkerFn _spawn;
19+
Worker? _worker;
20+
21+
CocoaEnvelopeSender(this._options, {SpawnWorkerFn? spawn})
22+
: _config = WorkerConfig(
23+
debugName: 'SentryCocoaEnvelopeSender',
24+
debug: _options.debug,
25+
diagnosticLevel: _options.diagnosticLevel,
26+
automatedTestMode: _options.automatedTestMode,
27+
),
28+
_spawn = spawn ?? spawnWorker;
29+
30+
@internal
31+
static CocoaEnvelopeSender Function(SentryFlutterOptions) factory =
32+
CocoaEnvelopeSender.new;
33+
34+
FutureOr<void> start() async {
35+
if (_worker != null) return;
36+
_worker = await _spawn(_config, _entryPoint);
37+
}
38+
39+
FutureOr<void> close() {
40+
_worker?.close();
41+
_worker = null;
42+
}
43+
44+
/// Fire-and-forget send of envelope bytes to the worker.
45+
void captureEnvelope(Uint8List envelopeData) {
46+
final client = _worker;
47+
if (client == null) {
48+
_options.log(
49+
SentryLevel.warning,
50+
'captureEnvelope called before start; dropping',
51+
);
52+
return;
53+
}
54+
client.send(TransferableTypedData.fromList([envelopeData]));
55+
}
56+
57+
static void _entryPoint((SendPort, WorkerConfig) init) {
58+
final (host, config) = init;
59+
runWorker(config, host, _CocoaEnvelopeHandler(config));
60+
}
61+
}
62+
63+
class _CocoaEnvelopeHandler extends WorkerHandler {
64+
final WorkerConfig _config;
65+
66+
_CocoaEnvelopeHandler(this._config);
67+
68+
@override
69+
FutureOr<void> onMessage(Object? msg) {
70+
if (msg is TransferableTypedData) {
71+
final data = msg.materialize().asUint8List();
72+
_captureEnvelope(data);
73+
} else {
74+
IsolateLogger.log(SentryLevel.warning, 'Unexpected message type: $msg');
75+
}
76+
}
77+
78+
void _captureEnvelope(Uint8List envelopeData) {
79+
try {
80+
final nsData = envelopeData.toNSData();
81+
final envelope = cocoa.PrivateSentrySDKOnly.envelopeWithData(nsData);
82+
if (envelope != null) {
83+
cocoa.PrivateSentrySDKOnly.captureEnvelope(envelope);
84+
} else {
85+
IsolateLogger.log(SentryLevel.error,
86+
'Native Cocoa SDK returned null when capturing envelope');
87+
}
88+
} catch (exception, stackTrace) {
89+
IsolateLogger.log(SentryLevel.error, 'Failed to capture envelope',
90+
exception: exception, stackTrace: stackTrace);
91+
if (_config.automatedTestMode) {
92+
rethrow;
93+
}
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)