Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 9.7.0


### Features

- Add W3C `traceparent` header support ([#3246](https://github.com/getsentry/sentry-dart/pull/3246))
Expand Down
1 change: 0 additions & 1 deletion packages/flutter/example/pubspec_overrides.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,3 @@ dependency_overrides:
isar_flutter_libs:
git:
url: https://github.com/MrLittleWhite/isar_flutter_libs.git

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
81 changes: 81 additions & 0 deletions packages/flutter/lib/src/isolate/isolate_logger.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import 'dart:developer' as developer;

import 'package:meta/meta.dart';

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

/// Static logger for Isolates 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 bool _debug;
static late SentryLevel _level;
static late String _loggerName;
static bool _isConfigured = false;

/// Configures this logger for the current isolate.
///
/// Must be called once per isolate before invoking [log].
/// Throws [StateError] if called more than once without calling [reset] first.
///
/// - [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}) {
if (_isConfigured) {
throw StateError(
'IsolateLogger.configure has already been called. It can only be configured once per isolate.');
}
Comment on lines +7 to +35
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We gotta use a static logger since we cannot pass SentryFlutterOptions to another isolate as it's not serializable

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the alternative would be not to log in isolates at all

_debug = debug;
_level = level;
_loggerName = loggerName;
_isConfigured = true;
}

/// Resets the logger state to allow reconfiguration.
///
/// This is intended for testing purposes only.
@visibleForTesting
static void reset() {
_isConfigured = false;
}

/// Emits a log entry if enabled.
///
/// 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;
}
}
177 changes: 177 additions & 0 deletions packages/flutter/lib/src/isolate/isolate_worker.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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;
final bool automatedTestMode;

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

/// 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) async {
if (_closed) throw StateError('Worker is closed');
final id = _idCounter++;
final completer = Completer<Object?>.sync();
_pending[id] = completer;
_workerPort.send((id, payload));
return await completer.future;
}

void close() {
if (_closed) return;
_closed = true;
_workerPort.send(_shutdownCommand);
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,
));
};

try {
await Isolate.spawn<(SendPort, WorkerConfig)>(
entry,
(initPort.sendPort, config),
debugName: config.debugName,
);
} on Object {
initPort.close();
rethrow;
}

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;
}

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;
}

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,96 @@
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,
automatedTestMode: _options.automatedTestMode,
),
_spawn = spawn ?? spawnWorker;

@internal
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(config));
}
}

class _CocoaEnvelopeHandler extends WorkerHandler {
final WorkerConfig _config;

_CocoaEnvelopeHandler(this._config);

@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);
Copy link
Collaborator

@denrase denrase Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great to do some performance tests if using the background isolate here is really bringing more performance improvements than the overhead introduce by isolate communication.

Alternatively, if calling the plugin code or a new helper we add from here is possible, we could also do the cocoa.PrivateSentrySDKOnly.envelopeWithData(nsData); call on the plugin side and dispatch to the background there if this indeed is a bottleneck.

If possible, this would be way less code, making it more maintainable and we'd have no sending overhead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cocoa.PrivateSentrySDKOnly.envelopeWithData(nsData); call on the plugin side and dispatch to the background there if this indeed is a bottleneck.

the whole _captureEnvelope function is running in the bg isolate in this case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overhead introduce by isolate communication

Isolate communication overhead is essentially 0 since we use https://api.flutter.dev/flutter/dart-isolate/TransferableTypedData-class.html

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all in all I'd expect there to be almost no difference in speed but less load on the main isolate which is kind of hard to benchmark, I guess the best way would be to compare frame data

Copy link
Contributor Author

@buenaflor buenaflor Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have benchmarked this via a ~36mb envelope and

  • without bg isolate: significant UI jank that freezes the app for around 1.5 seconds
  • with bg isolate: no jank or freezing at all

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);
if (_config.automatedTestMode) {
rethrow;
}
}
}
}
Loading
Loading