Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Enhancements

- Offload `captureEnvelope` to background isolate for iOS and Android ([#3232](https://github.com/getsentry/sentry-dart/pull/3232))

## 9.7.0-beta.2

### Features
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import 'package:meta/meta.dart';

import '../../sentry_flutter.dart';
import '../isolate_helper.dart';
import '../isolate/isolate_helper.dart';

/// Integration for adding thread information to spans.
///
Expand Down
66 changes: 66 additions & 0 deletions packages/flutter/lib/src/isolate/isolate_logger.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import 'dart:developer' as developer;

import '../../sentry_flutter.dart';

/// Isolate-local logger that writes diagnostic messages to `dart:developer.log`.
///
/// Intended for worker/background isolates where a `SentryOptions` instance
/// or hub may not be available. Because Dart statics are isolate-local,
/// you must call [configure] once per isolate before using [log].
class IsolateLogger {
IsolateLogger._();

static late final bool _debug;
static late final SentryLevel _level;
static late final String _loggerName;
static bool _isConfigured = false;

/// Configures this logger for the current isolate.
///
/// Must be called once per isolate before invoking [log].
///
/// - [debug]: when false, suppresses all logs except [SentryLevel.fatal].
/// - [level]: minimum severity threshold (inclusive) when [debug] is true.
/// - [loggerName]: logger name for the call sites
static void configure(
{required bool debug,
required SentryLevel level,
required String loggerName}) {
_debug = debug;
_level = level;
_loggerName = loggerName;
_isConfigured = true;
}

/// Emits a log entry if enabled for this isolate.
///
/// Messages are forwarded to [developer.log]. The provided [level] is
/// mapped via [SentryLevel.toDartLogLevel] to a `developer.log` numeric level.
/// If logging is disabled or [level] is below the configured threshold,
/// nothing is emitted. [SentryLevel.fatal] is always emitted.
static void log(
SentryLevel level,
String message, {
String? logger,
Object? exception,
StackTrace? stackTrace,
}) {
assert(
_isConfigured, 'IsolateLogger.configure must be called before logging');
if (_isEnabled(level)) {
developer.log(
'[${level.name}] $message',
level: level.toDartLogLevel(),
name: logger ?? _loggerName,
time: DateTime.now(),
error: exception,
stackTrace: stackTrace,
);
}
}

static bool _isEnabled(SentryLevel level) {
return _debug && level.ordinal >= _level.ordinal ||
level == SentryLevel.fatal;
}
}
172 changes: 172 additions & 0 deletions packages/flutter/lib/src/isolate/isolate_worker.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import 'dart:async';
import 'dart:isolate';

import '../../sentry_flutter.dart';
import 'isolate_logger.dart';

const _shutdownCommand = '_shutdown_';

// -------------------------------------------
// HOST-SIDE API (runs on the main isolate)
// -------------------------------------------

/// Minimal config passed to isolates. Extend as needed.
class WorkerConfig {
final bool debug;
final SentryLevel diagnosticLevel;
final String debugName;

const WorkerConfig({
required this.debug,
required this.diagnosticLevel,
required this.debugName,
});
}

/// Host-side helper for workers to perform minimal request/response.
/// Adapted from https://dart.dev/language/isolates#robust-ports-example
class Worker {
Worker(this._workerPort, this._responses) {
_responses.listen(_handleResponse);
}

final SendPort _workerPort;
SendPort get port => _workerPort;
final ReceivePort _responses;
final Map<int, Completer<Object?>> _pending = {};
int _idCounter = 0;
bool _closed = false;

/// Fire-and-forget send to the worker.
void send(Object? message) {
_workerPort.send(message);
}

/// Send a request to the worker and await a response.
Future<Object?> request(Object? payload) {
if (_closed) throw StateError('Worker is closed');
final id = _idCounter++;
final completer = Completer<Object?>.sync();
_pending[id] = completer;
_workerPort.send((id, payload));
return completer.future;
}

void close() {
if (_closed) return;
_workerPort.send(_shutdownCommand);
_closed = true;
if (_pending.isEmpty) {
_responses.close();
}
}

void _handleResponse(dynamic message) {
final (int id, Object? response) = message as (int, Object?);
final completer = _pending.remove(id);
if (completer == null) return;

if (response is RemoteError) {
completer.completeError(response);
} else {
completer.complete(response);
}

if (_closed && _pending.isEmpty) {
_responses.close();
}
}
}

/// Worker (isolate) entry-point signature.
typedef WorkerEntry = void Function((SendPort, WorkerConfig));

/// Spawn a worker isolate and handshake to obtain its SendPort.
Future<Worker> spawnWorker(
WorkerConfig config,
WorkerEntry entry,
) async {
final initPort = RawReceivePort();
final connection = Completer<(ReceivePort, SendPort)>.sync();
initPort.handler = (SendPort commandPort) {
connection.complete((
ReceivePort.fromRawReceivePort(initPort),
commandPort,
));
};

await Isolate.spawn<(SendPort, WorkerConfig)>(
entry,
(initPort.sendPort, config),
debugName: config.debugName,
);

final (ReceivePort receivePort, SendPort sendPort) = await connection.future;
return Worker(sendPort, receivePort);
}

// -------------------------------------------
// ISOLATE-SIDE API (runs inside the worker isolate)
// -------------------------------------------

/// Message/request handler that runs inside the worker isolate.
///
/// This does not represent the isolate lifecycle; it only defines how
/// the worker processes incoming messages and optional request/response.
abstract class WorkerHandler {
/// Handle fire-and-forget messages sent from the host.
FutureOr<void> onMessage(Object? message);

/// Handle request/response payloads sent from the host.
/// Return value is sent back to the host. Default: no-op.
FutureOr<Object?> onRequest(Object? payload) => {};
}

/// Runs the Sentry worker loop inside a background isolate.
///
/// Call this only from the worker isolate entry-point spawned via
/// [spawnWorker]. It configures logging, handshakes with the host, and routes
/// messages
void runWorker(
WorkerConfig config,
SendPort host,
WorkerHandler handler,
) {
IsolateLogger.configure(
debug: config.debug,
level: config.diagnosticLevel,
loggerName: config.debugName,
);

final inbox = ReceivePort();
host.send(inbox.sendPort);

inbox.listen((msg) async {
if (msg == _shutdownCommand) {
IsolateLogger.log(SentryLevel.debug, 'Isolate received shutdown');
inbox.close();
IsolateLogger.log(SentryLevel.debug, 'Isolate closed');
return;
}

// RPC: (id, payload)
if (msg is (int, Object?)) {
final (id, payload) = msg;
try {
final result = await handler.onRequest(payload);
host.send((id, result));
} catch (e, st) {
host.send((id, RemoteError(e.toString(), st.toString())));
}
return;
}

// Fire-and-forget
try {
await handler.onMessage(msg);
} catch (exception, stackTrace) {
IsolateLogger.log(SentryLevel.error, 'Isolate failed to handle message',
exception: exception, stackTrace: stackTrace);
}
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import 'dart:async';
import 'dart:isolate';
import 'dart:typed_data';

import 'package:meta/meta.dart';
import 'package:objective_c/objective_c.dart';

import '../../../sentry_flutter.dart';
import '../../isolate/isolate_worker.dart';
import '../../isolate/isolate_logger.dart';
import 'binding.dart' as cocoa;

typedef SpawnWorkerFn = Future<Worker> Function(WorkerConfig, WorkerEntry);

class CocoaEnvelopeSender {
final SentryFlutterOptions _options;
final WorkerConfig _config;
final SpawnWorkerFn _spawn;
Worker? _worker;

CocoaEnvelopeSender(this._options, {SpawnWorkerFn? spawn})
: _config = WorkerConfig(
debugName: 'SentryCocoaEnvelopeSender',
debug: _options.debug,
diagnosticLevel: _options.diagnosticLevel,
),
_spawn = spawn ?? spawnWorker;

@internal // visible for testing/mocking
static CocoaEnvelopeSender Function(SentryFlutterOptions) factory =
CocoaEnvelopeSender.new;

FutureOr<void> start() async {
if (_worker != null) return;
_worker = await _spawn(_config, _entryPoint);
}

FutureOr<void> close() {
_worker?.close();
_worker = null;
}

/// Fire-and-forget send of envelope bytes to the worker.
void captureEnvelope(Uint8List envelopeData) {
final client = _worker;
if (client == null) {
_options.log(
SentryLevel.warning,
'captureEnvelope called before start; dropping',
);
return;
}
client.send(TransferableTypedData.fromList([envelopeData]));
}

static void _entryPoint((SendPort, WorkerConfig) init) {
final (host, config) = init;
runWorker(config, host, _CocoaEnvelopeHandler());
}
}

class _CocoaEnvelopeHandler extends WorkerHandler {
@override
FutureOr<void> onMessage(Object? msg) {
if (msg is TransferableTypedData) {
final data = msg.materialize().asUint8List();
_captureEnvelope(data);
} else {
IsolateLogger.log(SentryLevel.warning, 'Unexpected message type: $msg');
}
}

void _captureEnvelope(Uint8List envelopeData) {
try {
final nsData = envelopeData.toNSData();
final envelope = cocoa.PrivateSentrySDKOnly.envelopeWithData(nsData);
if (envelope != null) {
cocoa.PrivateSentrySDKOnly.captureEnvelope(envelope);
} else {
IsolateLogger.log(SentryLevel.error,
'Native Cocoa SDK returned null when capturing envelope');
}
} catch (exception, stackTrace) {
IsolateLogger.log(SentryLevel.error, 'Failed to capture envelope',
exception: exception, stackTrace: stackTrace);
}
}
}
23 changes: 6 additions & 17 deletions packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import '../sentry_native_channel.dart';
import '../utils/utf8_json.dart';
import 'binding.dart' as cocoa;
import 'cocoa_replay_recorder.dart';
import 'cocoa_envelope_sender.dart';

@internal
class SentryNativeCocoa extends SentryNativeChannel {
CocoaReplayRecorder? _replayRecorder;
CocoaEnvelopeSender? _envelopeSender;
SentryId? _replayId;

SentryNativeCocoa(super.options);
Expand Down Expand Up @@ -49,29 +51,16 @@ class SentryNativeCocoa extends SentryNativeChannel {
});
}

_envelopeSender = CocoaEnvelopeSender(options);
await _envelopeSender?.start();

return super.init(hub);
}

@override
FutureOr<void> captureEnvelope(
Uint8List envelopeData, bool containsUnhandledException) {
try {
final nsData = envelopeData.toNSData();
final envelope = cocoa.PrivateSentrySDKOnly.envelopeWithData(nsData);
if (envelope != null) {
cocoa.PrivateSentrySDKOnly.captureEnvelope(envelope);
} else {
options.log(
SentryLevel.error, 'Failed to capture envelope: envelope is null');
}
} catch (exception, stackTrace) {
options.log(SentryLevel.error, 'Failed to capture envelope',
exception: exception, stackTrace: stackTrace);

if (options.automatedTestMode) {
rethrow;
}
}
_envelopeSender?.captureEnvelope(envelopeData);
}

@override
Expand Down
Loading
Loading