Skip to content

Logs: Integrate in Sentry Client #2920

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 32 commits into from
May 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2f4f785
add log models
denrase May 7, 2025
20506d4
Log in envelope
denrase May 7, 2025
c5f5d6e
add type annotation
denrase May 7, 2025
1a2c038
add cl entry
denrase May 7, 2025
16c4b42
remove unused imports
denrase May 7, 2025
f4ec20b
capture log in client
denrase May 7, 2025
9bf5514
add severity number
denrase May 7, 2025
9cb7821
remove parent SnetryLog, Rename SentryLogItem to SentryLog
denrase May 7, 2025
f50a6ce
Merge branch 'feat/logs-envelope' into feat/logs-client
denrase May 7, 2025
3dee73c
update from model feedback
denrase May 7, 2025
6c74691
add comment to attrib
denrase May 7, 2025
2b9b62e
Merge branch 'feat/logs-envelope' into feat/logs-client
denrase May 7, 2025
ea79978
infer severity number from level
denrase May 7, 2025
39f4130
make default constructor provate for attribute
denrase May 7, 2025
302d22a
Merge branch 'feat/logs-envelope' into feat/logs-client
denrase May 7, 2025
9163b43
add sdk name & version to log attributes
denrase May 7, 2025
58bbb6a
set sentry attributes to log
denrase May 7, 2025
bd95978
Merge branch 'feat/logs' into feat/logs-client
denrase May 7, 2025
5050c90
change client api to take single log
denrase May 7, 2025
a58b17d
update comment with correct dart types
denrase May 7, 2025
874a4ce
add todo for android
denrase May 7, 2025
8ed3a83
set trace id
denrase May 8, 2025
de3a856
disable logs per default
denrase May 8, 2025
3ad3ff0
add BeforeSendLogCallback
denrase May 8, 2025
345d4f6
add cl entry
denrase May 8, 2025
74e69d9
update test text to match api
denrase May 8, 2025
87d52e6
rename var
denrase May 8, 2025
b58cd37
always use propagation context for trace id
denrase May 12, 2025
969dd3c
add comment for empty trace id
denrase May 12, 2025
1a206f4
update comment
denrase May 13, 2025
58535ae
remove experimental, add comments
denrase May 13, 2025
b7f74b3
[Structured Logs]: Buffering and Flushing of Logs (#2930)
denrase May 13, 2025
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### Features

- Logs: Models & Envelopes ([#2916](https://github.com/getsentry/sentry-dart/pull/2916))
- Logs: Integrate in Sentry Client ([#2920](https://github.com/getsentry/sentry-dart/pull/2920))
- [Structured Logs]: Buffering and Flushing of Logs ([#2930](https://github.com/getsentry/sentry-dart/pull/2930))

## 9.0.0-beta.2

Expand Down
12 changes: 12 additions & 0 deletions dart/lib/src/noop_log_batcher.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'dart:async';

import 'sentry_log_batcher.dart';
import 'protocol/sentry_log.dart';

class NoopLogBatcher implements SentryLogBatcher {
@override

Check warning on line 7 in dart/lib/src/noop_log_batcher.dart

View check run for this annotation

Codecov / codecov/patch

dart/lib/src/noop_log_batcher.dart#L7

Added line #L7 was not covered by tests
FutureOr<void> addLog(SentryLog log) {}

@override

Check warning on line 10 in dart/lib/src/noop_log_batcher.dart

View check run for this annotation

Codecov / codecov/patch

dart/lib/src/noop_log_batcher.dart#L10

Added line #L10 was not covered by tests
Future<void> flush() async {}
}
3 changes: 3 additions & 0 deletions dart/lib/src/noop_sentry_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,7 @@ class NoOpSentryClient implements SentryClient {
Future<SentryId> captureFeedback(SentryFeedback feedback,
{Scope? scope, Hint? hint}) async =>
SentryId.empty();

@override
Future<void> captureLog(SentryLog log, {Scope? scope}) async {}
}
6 changes: 4 additions & 2 deletions dart/lib/src/protocol/sentry_log.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ class SentryLog {
Map<String, SentryLogAttribute> attributes;
int? severityNumber;

/// The traceId is initially an empty default value and is populated during event processing;
/// by the time processing completes, it is guaranteed to be a valid non-empty trace id.
SentryLog({
required this.timestamp,
required this.traceId,
SentryId? traceId,
required this.level,
required this.body,
required this.attributes,
this.severityNumber,
});
}) : traceId = traceId ?? SentryId.empty();

Map<String, dynamic> toJson() {
return {
Expand Down
6 changes: 3 additions & 3 deletions dart/lib/src/protocol/sentry_log_attribute.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,19 @@ class SentryLogAttribute {
return SentryLogAttribute._(value, 'string');
}

factory SentryLogAttribute.boolean(bool value) {
factory SentryLogAttribute.bool(bool value) {
return SentryLogAttribute._(value, 'boolean');
}

factory SentryLogAttribute.integer(int value) {
factory SentryLogAttribute.int(int value) {
return SentryLogAttribute._(value, 'integer');
}

factory SentryLogAttribute.double(double value) {
return SentryLogAttribute._(value, 'double');
}

// In the future the SDK will also support string[], boolean[], integer[], double[] values.
// In the future the SDK will also support List<String>, List<bool>, List<int>, List<double> values.
Map<String, dynamic> toJson() {
return {
'value': value,
Expand Down
71 changes: 71 additions & 0 deletions dart/lib/src/sentry_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import 'type_check_hint.dart';
import 'utils/isolate_utils.dart';
import 'utils/regex_utils.dart';
import 'utils/stacktrace_utils.dart';
import 'sentry_log_batcher.dart';
import 'version.dart';

/// Default value for [SentryUser.ipAddress]. It gets set when an event does not have
Expand Down Expand Up @@ -75,6 +76,9 @@ class SentryClient {
if (enableFlutterSpotlight) {
options.transport = SpotlightHttpTransport(options, options.transport);
}
if (options.enableLogs) {
options.logBatcher = SentryLogBatcher(options);
}
return SentryClient._(options);
}

Expand Down Expand Up @@ -485,6 +489,73 @@ class SentryClient {
);
}

@internal
Future<void> captureLog(
SentryLog log, {
Scope? scope,
}) async {
if (!_options.enableLogs) {
return;
}

log.attributes['sentry.sdk.name'] = SentryLogAttribute.string(
_options.sdk.name,
);
log.attributes['sentry.sdk.version'] = SentryLogAttribute.string(
_options.sdk.version,
);
final environment = _options.environment;
if (environment != null) {
log.attributes['sentry.environment'] = SentryLogAttribute.string(
environment,
);
}
final release = _options.release;
if (release != null) {
log.attributes['sentry.release'] = SentryLogAttribute.string(
release,
);
}

final propagationContext = scope?.propagationContext;
if (propagationContext != null) {
log.traceId = propagationContext.traceId;
}
final span = scope?.span;
if (span != null) {
log.attributes['sentry.trace.parent_span_id'] = SentryLogAttribute.string(
span.context.spanId.toString(),
);
}

final beforeSendLog = _options.beforeSendLog;
SentryLog? processedLog = log;
if (beforeSendLog != null) {
try {
final callbackResult = beforeSendLog(log);

if (callbackResult is Future<SentryLog?>) {
processedLog = await callbackResult;
} else {
processedLog = callbackResult;
}
} catch (exception, stackTrace) {
_options.logger(
SentryLevel.error,
'The beforeSendLog callback threw an exception',
exception: exception,
stackTrace: stackTrace,
);
if (_options.automatedTestMode) {
rethrow;
}
}
}
if (processedLog != null) {
_options.logBatcher.addLog(processedLog);
}
Copy link
Member

Choose a reason for hiding this comment

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

should we report a dropped log if null after the beforeSendLog?

}

void close() {
_options.httpClient.close();
}
Expand Down
52 changes: 52 additions & 0 deletions dart/lib/src/sentry_log_batcher.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'dart:async';
import 'sentry_envelope.dart';
import 'sentry_options.dart';
import 'protocol/sentry_log.dart';
import 'package:meta/meta.dart';

@internal
class SentryLogBatcher {
SentryLogBatcher(this._options, {Duration? flushTimeout, int? maxBufferSize})
: _flushTimeout = flushTimeout ?? Duration(seconds: 5),
_maxBufferSize = maxBufferSize ?? 100;

final SentryOptions _options;
final Duration _flushTimeout;
final int _maxBufferSize;

final _logBuffer = <SentryLog>[];

Timer? _flushTimer;

void addLog(SentryLog log) {
_logBuffer.add(log);

_flushTimer?.cancel();
Copy link
Member

Choose a reason for hiding this comment

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

canceling the timer here means that, in theory, if a log is added every 4 seconds, we will not flush them for several minutes
Is this the expectation?
Just fyi, Java sends every 5 seconds, regardless of how many logs are there

Copy link
Member

Choose a reason for hiding this comment

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

i guess it's fine as per docs, just ensuring it's wanted


if (_logBuffer.length >= _maxBufferSize) {
return flush();
} else {
_flushTimer = Timer(_flushTimeout, flush);
}
}

void flush() {
_flushTimer?.cancel();
_flushTimer = null;

final logs = List<SentryLog>.from(_logBuffer);
_logBuffer.clear();

if (logs.isEmpty) {
return;
}

final envelope = SentryEnvelope.fromLogs(
logs,
_options.sdk,
);

// TODO: Make sure the Android SDK understands the log envelope type.
_options.transport.send(envelope);
}
}
18 changes: 18 additions & 0 deletions dart/lib/src/sentry_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import 'sentry_exception_factory.dart';
import 'sentry_stack_trace_factory.dart';
import 'transport/noop_transport.dart';
import 'version.dart';
import 'sentry_log_batcher.dart';
import 'noop_log_batcher.dart';

// TODO: shutdownTimeout, flushTimeoutMillis
// https://api.dart.dev/stable/2.10.2/dart-io/HttpClient/close.html doesn't have a timeout param, we'd need to implement manually
Expand Down Expand Up @@ -198,6 +200,10 @@ class SentryOptions {
/// Can return true to emit the metric, or false to drop it.
BeforeMetricCallback? beforeMetricCallback;

/// This function is called right before a log is about to be sent.
/// Can return a modified log or null to drop the log.
BeforeSendLogCallback? beforeSendLog;

/// Sets the release. SDK will try to automatically configure a release out of the box
/// See [docs for further information](https://docs.sentry.io/platforms/flutter/configuration/releases/)
String? release;
Expand Down Expand Up @@ -531,6 +537,14 @@ class SentryOptions {
/// This is opt-in, as it can lead to existing exception beeing grouped as new ones.
bool groupExceptions = false;

/// Enable to capture and send logs to Sentry.
///
/// Disabled by default.
bool enableLogs = false;

@internal
SentryLogBatcher logBatcher = NoopLogBatcher();

SentryOptions({String? dsn, Platform? platform, RuntimeChecker? checker}) {
this.dsn = dsn;
if (platform != null) {
Expand Down Expand Up @@ -660,6 +674,10 @@ typedef BeforeMetricCallback = bool Function(
Map<String, String>? tags,
});

/// This function is called right before a log is about to be sent.
/// Can return a modified log or null to drop the log.
typedef BeforeSendLogCallback = FutureOr<SentryLog?> Function(SentryLog log);

/// Used to provide timestamp for logging.
typedef ClockProvider = DateTime Function();

Expand Down
19 changes: 19 additions & 0 deletions dart/test/mocks/mock_log_batcher.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import 'dart:async';

import 'package:sentry/src/protocol/sentry_log.dart';
import 'package:sentry/src/sentry_log_batcher.dart';

class MockLogBatcher implements SentryLogBatcher {
final addLogCalls = <SentryLog>[];
final flushCalls = <void>[];

@override
FutureOr<void> addLog(SentryLog log) {
addLogCalls.add(log);
}

@override
Future<void> flush() async {
flushCalls.add(null);
}
}
14 changes: 13 additions & 1 deletion dart/test/mocks/mock_sentry_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient {
List<CaptureMessageCall> captureMessageCalls = [];
List<CaptureEnvelopeCall> captureEnvelopeCalls = [];
List<CaptureTransactionCall> captureTransactionCalls = [];

List<CaptureFeedbackCall> captureFeedbackCalls = [];
List<CaptureLogCall> captureLogCalls = [];
int closeCalls = 0;

@override
Expand Down Expand Up @@ -84,6 +84,11 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient {
return SentryId.newId();
}

@override
Future<void> captureLog(SentryLog log, {Scope? scope}) async {
captureLogCalls.add(CaptureLogCall(log, scope));
}

@override
void close() {
closeCalls = closeCalls + 1;
Expand Down Expand Up @@ -173,3 +178,10 @@ class CaptureTransactionCall {

CaptureTransactionCall(this.transaction, this.traceContext, this.hint);
}

class CaptureLogCall {
final SentryLog log;
final Scope? scope;

CaptureLogCall(this.log, this.scope);
}
12 changes: 10 additions & 2 deletions dart/test/mocks/mock_transport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class MockTransport implements Transport {
List<SentryEnvelope> envelopes = [];
List<SentryEvent> events = [];
List<String> statsdItems = [];
List<Map<String, dynamic>> logs = [];

int _calls = 0;
String _exceptions = '';
Expand All @@ -31,7 +32,7 @@ class MockTransport implements Transport {
try {
envelopes.add(envelope);
if (parseFromEnvelope) {
await _eventFromEnvelope(envelope);
await _parseEnvelope(envelope);
}
} catch (e, stack) {
_exceptions += '$e\n$stack\n\n';
Expand All @@ -41,14 +42,21 @@ class MockTransport implements Transport {
return envelope.header.eventId ?? SentryId.empty();
}

Future<void> _eventFromEnvelope(SentryEnvelope envelope) async {
Future<void> _parseEnvelope(SentryEnvelope envelope) async {
final RegExp statSdRegex = RegExp('^(?!{).+@.+:.+\\|.+', multiLine: true);

final envelopeItemData = await envelope.items.first.dataFactory();
final envelopeItem = utf8.decode(envelopeItemData);

if (statSdRegex.hasMatch(envelopeItem)) {
statsdItems.add(envelopeItem);
} else if (envelopeItem.contains('items') &&
envelopeItem.contains('timestamp') &&
envelopeItem.contains('trace_id') &&
envelopeItem.contains('level') &&
envelopeItem.contains('body')) {
final envelopeItemJson = jsonDecode(envelopeItem) as Map<String, dynamic>;
logs.add(envelopeItemJson);
} else {
final envelopeItemJson = jsonDecode(envelopeItem) as Map<String, dynamic>;
events.add(SentryEvent.fromJson(envelopeItemJson));
Expand Down
8 changes: 4 additions & 4 deletions dart/test/protocol/sentry_log_attribute_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@ void main() {
});
});

test('$SentryLogAttribute boolean to json', () {
final attribute = SentryLogAttribute.boolean(true);
test('$SentryLogAttribute bool to json', () {
final attribute = SentryLogAttribute.bool(true);
final json = attribute.toJson();
expect(json, {
'value': true,
'type': 'boolean',
});
});

test('$SentryLogAttribute integer to json', () {
final attribute = SentryLogAttribute.integer(1);
test('$SentryLogAttribute int to json', () {
final attribute = SentryLogAttribute.int(1);
final json = attribute.toJson();

expect(json, {
Expand Down
4 changes: 2 additions & 2 deletions dart/test/protocol/sentry_log_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ void main() {
body: 'fixture-body',
attributes: {
'test': SentryLogAttribute.string('fixture-test'),
'test2': SentryLogAttribute.boolean(true),
'test3': SentryLogAttribute.integer(9001),
'test2': SentryLogAttribute.bool(true),
'test3': SentryLogAttribute.int(9001),
'test4': SentryLogAttribute.double(9000.1),
},
severityNumber: 1,
Expand Down
Loading
Loading