Skip to content

Commit da70ee1

Browse files
authored
Feat: Client Reports (#829)
1 parent 3af1dc8 commit da70ee1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+952
-182
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
# Changelog
22

3-
## Unreleased:
3+
## Unreleased
44

5+
* Feat: Client Reports (#829)
56
* Fix: Add missing iOS contexts (#761)
67

8+
Starting with version `6.6.0` of `sentry`, [Sentry's version >= v21.9.0](https://github.com/getsentry/self-hosted/releases) is required or you have to manually disable sending client reports via the `sendClientReports` option. This only applies to self-hosted Sentry. If you are using [sentry.io](https://sentry.io), no action is needed.
9+
710
## 6.5.1
811

912
- Update event contexts (#838)

dart/lib/sentry.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export 'src/protocol.dart';
1414
export 'src/scope.dart';
1515
export 'src/sentry.dart';
1616
export 'src/sentry_envelope.dart';
17+
export 'src/sentry_envelope_item.dart';
1718
export 'src/sentry_client.dart';
1819
export 'src/sentry_options.dart';
1920
// useful for integrations
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import 'package:meta/meta.dart';
2+
3+
import 'discarded_event.dart';
4+
import '../utils.dart';
5+
6+
@internal
7+
class ClientReport {
8+
ClientReport(this.timestamp, this.discardedEvents);
9+
10+
final DateTime? timestamp;
11+
final List<DiscardedEvent> discardedEvents;
12+
13+
Map<String, dynamic> toJson() {
14+
final json = <String, dynamic>{};
15+
16+
if (timestamp != null) {
17+
json['timestamp'] = formatDateAsIso8601WithMillisPrecision(timestamp!);
18+
}
19+
20+
final eventsJson = discardedEvents
21+
.map((e) => e.toJson())
22+
.where((e) => e.isNotEmpty)
23+
.toList(growable: false);
24+
if (eventsJson.isNotEmpty) {
25+
json['discarded_events'] = eventsJson;
26+
}
27+
28+
return json;
29+
}
30+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import 'package:meta/meta.dart';
2+
3+
import '../sentry_options.dart';
4+
import 'client_report.dart';
5+
import 'discarded_event.dart';
6+
import 'discard_reason.dart';
7+
import '../transport/data_category.dart';
8+
9+
@internal
10+
class ClientReportRecorder {
11+
ClientReportRecorder(this._clock);
12+
13+
final ClockProvider _clock;
14+
final Map<_QuantityKey, int> _quantities = {};
15+
16+
void recordLostEvent(
17+
final DiscardReason reason, final DataCategory category) {
18+
final key = _QuantityKey(reason, category);
19+
var current = _quantities[key] ?? 0;
20+
_quantities[key] = current + 1;
21+
}
22+
23+
ClientReport? flush() {
24+
if (_quantities.isEmpty) {
25+
return null;
26+
}
27+
28+
final events = _quantities.keys.map((key) {
29+
final quantity = _quantities[key] ?? 0;
30+
return DiscardedEvent(key.reason, key.category, quantity);
31+
}).toList(growable: false);
32+
33+
_quantities.clear();
34+
35+
return ClientReport(_clock(), events);
36+
}
37+
}
38+
39+
class _QuantityKey {
40+
_QuantityKey(this.reason, this.category);
41+
42+
final DiscardReason reason;
43+
final DataCategory category;
44+
45+
@override
46+
int get hashCode => Object.hash(reason, category);
47+
48+
@override
49+
bool operator ==(dynamic other) {
50+
return other is _QuantityKey &&
51+
other.reason == reason &&
52+
other.category == category;
53+
}
54+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import 'package:meta/meta.dart';
2+
3+
/// A reason that defines why events were lost, see
4+
/// https://develop.sentry.dev/sdk/client-reports/#envelope-item-payload.
5+
@internal
6+
enum DiscardReason {
7+
beforeSend,
8+
eventProcessor,
9+
sampleRate,
10+
networkError,
11+
queueOverflow,
12+
cacheOverflow,
13+
rateLimitBackoff,
14+
}
15+
16+
extension OutcomeExtension on DiscardReason {
17+
String toStringValue() {
18+
switch (this) {
19+
case DiscardReason.beforeSend:
20+
return 'before_send';
21+
case DiscardReason.eventProcessor:
22+
return 'event_processor';
23+
case DiscardReason.sampleRate:
24+
return 'sample_rate';
25+
case DiscardReason.networkError:
26+
return 'network_error';
27+
case DiscardReason.queueOverflow:
28+
return 'queue_overflow';
29+
case DiscardReason.cacheOverflow:
30+
return 'cache_overflow';
31+
case DiscardReason.rateLimitBackoff:
32+
return 'ratelimit_backoff';
33+
}
34+
}
35+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import 'package:meta/meta.dart';
2+
3+
import 'discard_reason.dart';
4+
import '../transport/data_category.dart';
5+
6+
@internal
7+
class DiscardedEvent {
8+
DiscardedEvent(this.reason, this.category, this.quantity);
9+
10+
final DiscardReason reason;
11+
final DataCategory category;
12+
final int quantity;
13+
14+
Map<String, dynamic> toJson() {
15+
return {
16+
'reason': reason.toStringValue(),
17+
'category': category.toStringValue(),
18+
'quantity': quantity,
19+
};
20+
}
21+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import 'package:meta/meta.dart';
2+
3+
import '../transport/data_category.dart';
4+
import 'client_report.dart';
5+
import 'client_report_recorder.dart';
6+
import 'discard_reason.dart';
7+
8+
@internal
9+
class NoOpClientReportRecorder implements ClientReportRecorder {
10+
const NoOpClientReportRecorder();
11+
12+
@override
13+
ClientReport? flush() {
14+
return null;
15+
}
16+
17+
@override
18+
void recordLostEvent(DiscardReason reason, DataCategory category) {}
19+
}

dart/lib/src/hub.dart

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@ import 'dart:async';
22
import 'dart:collection';
33

44
import 'package:meta/meta.dart';
5+
import 'transport/data_category.dart';
56

6-
import 'protocol.dart';
7-
import 'scope.dart';
8-
import 'sentry_client.dart';
9-
import 'sentry_options.dart';
7+
import '../sentry.dart';
8+
import 'client_reports/discard_reason.dart';
109
import 'sentry_tracer.dart';
1110
import 'sentry_traces_sampler.dart';
12-
import 'sentry_user_feedback.dart';
13-
import 'tracing.dart';
1411

1512
/// Configures the scope through the callback.
1613
typedef ScopeCallback = void Function(Scope);
@@ -462,14 +459,18 @@ class Hub {
462459
'Capturing unfinished transaction: ${transaction.eventId}',
463460
);
464461
} else {
462+
final item = _peek();
463+
465464
if (!transaction.sampled) {
465+
_options.recorder.recordLostEvent(
466+
DiscardReason.sampleRate,
467+
DataCategory.transaction,
468+
);
466469
_options.logger(
467470
SentryLevel.warning,
468471
'Transaction ${transaction.eventId} was dropped due to sampling decision.',
469472
);
470473
} else {
471-
final item = _peek();
472-
473474
try {
474475
sentryId = await item.client.captureTransaction(
475476
transaction,

dart/lib/src/sentry_client.dart

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import 'transport/http_transport.dart';
1414
import 'transport/noop_transport.dart';
1515
import 'version.dart';
1616
import 'sentry_envelope.dart';
17+
import 'client_reports/client_report_recorder.dart';
18+
import 'client_reports/discard_reason.dart';
19+
import 'transport/data_category.dart';
1720

1821
/// Default value for [User.ipAddress]. It gets set when an event does not have
1922
/// a user and IP address. Only applies if [SentryOptions.sendDefaultPii] is set
@@ -34,10 +37,13 @@ class SentryClient {
3437

3538
/// Instantiates a client using [SentryOptions]
3639
factory SentryClient(SentryOptions options) {
40+
if (options.sendClientReports) {
41+
options.recorder = ClientReportRecorder(options.clock);
42+
}
3743
if (options.transport is NoOpTransport) {
38-
options.transport = HttpTransport(options, RateLimiter(options.clock));
44+
final rateLimiter = RateLimiter(options);
45+
options.transport = HttpTransport(options, rateLimiter);
3946
}
40-
4147
return SentryClient._(options);
4248
}
4349

@@ -53,6 +59,7 @@ class SentryClient {
5359
dynamic hint,
5460
}) async {
5561
if (_sampleRate()) {
62+
_recordLostEvent(event, DiscardReason.sampleRate);
5663
_options.logger(
5764
SentryLevel.debug,
5865
'Event ${event.eventId.toString()} was dropped due to sampling decision.',
@@ -87,6 +94,7 @@ class SentryClient {
8794

8895
final beforeSend = _options.beforeSend;
8996
if (beforeSend != null) {
97+
final beforeSendEvent = preparedEvent;
9098
try {
9199
preparedEvent = await beforeSend(preparedEvent, hint: hint);
92100
} catch (exception, stackTrace) {
@@ -98,6 +106,7 @@ class SentryClient {
98106
);
99107
}
100108
if (preparedEvent == null) {
109+
_recordLostEvent(beforeSendEvent, DiscardReason.beforeSend);
101110
_options.logger(
102111
SentryLevel.debug,
103112
'Event was dropped by BeforeSend callback',
@@ -264,7 +273,7 @@ class SentryClient {
264273

265274
/// Reports the [envelope] to Sentry.io.
266275
Future<SentryId?> captureEnvelope(SentryEnvelope envelope) {
267-
return _options.transport.send(envelope);
276+
return _attachClientReportsAndSend(envelope);
268277
}
269278

270279
/// Reports the [userFeedback] to Sentry.io.
@@ -273,7 +282,7 @@ class SentryClient {
273282
userFeedback,
274283
_options.sdk,
275284
);
276-
return _options.transport.send(envelope);
285+
return _attachClientReportsAndSend(envelope);
277286
}
278287

279288
void close() => _options.httpClient.close();
@@ -296,6 +305,7 @@ class SentryClient {
296305
);
297306
}
298307
if (processedEvent == null) {
308+
_recordLostEvent(event, DiscardReason.eventProcessor);
299309
_options.logger(SentryLevel.debug, 'Event was dropped by a processor');
300310
break;
301311
}
@@ -309,4 +319,20 @@ class SentryClient {
309319
}
310320
return false;
311321
}
322+
323+
void _recordLostEvent(SentryEvent event, DiscardReason reason) {
324+
DataCategory category;
325+
if (event is SentryTransaction) {
326+
category = DataCategory.transaction;
327+
} else {
328+
category = DataCategory.error;
329+
}
330+
_options.recorder.recordLostEvent(reason, category);
331+
}
332+
333+
Future<SentryId?> _attachClientReportsAndSend(SentryEnvelope envelope) {
334+
final clientReport = _options.recorder.flush();
335+
envelope.addClientReport(clientReport);
336+
return _options.transport.send(envelope);
337+
}
312338
}

dart/lib/src/sentry_envelope.dart

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:convert';
2+
import 'client_reports/client_report.dart';
23
import 'protocol.dart';
34
import 'sentry_item_type.dart';
45
import 'sentry_options.dart';
@@ -12,7 +13,7 @@ import 'sentry_user_feedback.dart';
1213
class SentryEnvelope {
1314
SentryEnvelope(this.header, this.items);
1415

15-
/// Header descriping envelope content.
16+
/// Header describing envelope content.
1617
final SentryEnvelopeHeader header;
1718

1819
/// All items contained in the envelope.
@@ -74,7 +75,7 @@ class SentryEnvelope {
7475
if (length < 0) {
7576
continue;
7677
}
77-
// Olny attachments should be filtered according to
78+
// Only attachments should be filtered according to
7879
// SentryOptions.maxAttachmentSize
7980
if (item.header.type == SentryItemType.attachment) {
8081
if (await item.header.length() > options.maxAttachmentSize) {
@@ -88,4 +89,12 @@ class SentryEnvelope {
8889
}
8990
}
9091
}
92+
93+
/// Add an envelope item containing client report data.
94+
void addClientReport(ClientReport? clientReport) {
95+
if (clientReport != null) {
96+
final envelopeItem = SentryEnvelopeItem.fromClientReport(clientReport);
97+
items.add(envelopeItem);
98+
}
99+
}
91100
}

0 commit comments

Comments
 (0)