diff --git a/dart/lib/src/metrics/local_metrics_aggregator.dart b/dart/lib/src/metrics/local_metrics_aggregator.dart new file mode 100644 index 0000000000..92076ef807 --- /dev/null +++ b/dart/lib/src/metrics/local_metrics_aggregator.dart @@ -0,0 +1,37 @@ +import 'dart:core'; +import 'package:meta/meta.dart'; +import '../protocol/metric_summary.dart'; +import 'metric.dart'; + +@internal +class LocalMetricsAggregator { + // format: > + final Map> _buckets = {}; + + void add(final Metric metric, final num value) { + final bucket = + _buckets.putIfAbsent(metric.getSpanAggregationKey(), () => {}); + + bucket.update(metric.getCompositeKey(), (m) => m..add(value), + ifAbsent: () => Metric.fromType( + type: MetricType.gauge, + key: metric.key, + value: value, + unit: metric.unit, + tags: metric.tags) as GaugeMetric); + } + + Map> getSummaries() { + final Map> summaries = {}; + for (final entry in _buckets.entries) { + final String exportKey = entry.key; + + final metricSummaries = entry.value.values + .map((gauge) => MetricSummary.fromGauge(gauge)) + .toList(); + + summaries[exportKey] = metricSummaries; + } + return summaries; + } +} diff --git a/dart/lib/src/metrics/metric.dart b/dart/lib/src/metrics/metric.dart index ff9f5e3e06..52831e73ce 100644 --- a/dart/lib/src/metrics/metric.dart +++ b/dart/lib/src/metrics/metric.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:meta/meta.dart'; import '../../sentry.dart'; @@ -26,8 +28,31 @@ abstract class Metric { required this.tags, }); + factory Metric.fromType({ + required final MetricType type, + required final String key, + required final num value, + required final SentryMeasurementUnit unit, + required final Map tags, + }) { + switch (type) { + case MetricType.counter: + return CounterMetric._(value: value, key: key, unit: unit, tags: tags); + case MetricType.gauge: + return GaugeMetric._(value: value, key: key, unit: unit, tags: tags); + case MetricType.set: + return SetMetric._(value: value, key: key, unit: unit, tags: tags); + case MetricType.distribution: + return DistributionMetric._( + value: value, key: key, unit: unit, tags: tags); + } + } + /// Add a value to the metric. - add(double value); + add(num value); + + /// Return the weight of the current metric. + int getWeight(); /// Serialize the value into a list of Objects to be converted into a String. Iterable _serializeValue(); @@ -87,6 +112,10 @@ abstract class Metric { return ('${type.statsdType}_${key}_${unit.name}_$serializedTags'); } + /// Return a key created by [key], [type] and [unit]. + /// This key should be used to aggregate the metric locally in a span. + String getSpanAggregationKey() => '${type.statsdType}:$key@${unit.name}'; + /// Remove forbidden characters from the metric key and tag key. String _normalizeKey(String input) => input.replaceAll(forbiddenKeyCharsRegex, '_'); @@ -100,13 +129,12 @@ abstract class Metric { input.replaceAll(forbiddenUnitCharsRegex, '_'); } -@internal - /// Metric [MetricType.counter] that tracks a value that can only be incremented. +@internal class CounterMetric extends Metric { - double value; + num value; - CounterMetric({ + CounterMetric._({ required this.value, required super.key, required super.unit, @@ -114,17 +142,122 @@ class CounterMetric extends Metric { }) : super(type: MetricType.counter); @override - add(double value) => this.value += value; + add(num value) => this.value += value; @override Iterable _serializeValue() => [value]; + + @override + int getWeight() => 1; } +/// Metric [MetricType.gauge] that tracks a value that can go up and down. @internal +class GaugeMetric extends Metric { + num _last; + num _minimum; + num _maximum; + num _sum; + int _count; + + GaugeMetric._({ + required num value, + required super.key, + required super.unit, + required super.tags, + }) : _last = value, + _minimum = value, + _maximum = value, + _sum = value, + _count = 1, + super(type: MetricType.gauge); + + @override + add(num value) { + _last = value; + _minimum = min(_minimum, value); + _maximum = max(_maximum, value); + _sum += value; + _count++; + } + + @override + Iterable _serializeValue() => + [_last, _minimum, _maximum, _sum, _count]; + + @override + int getWeight() => 5; + + @visibleForTesting + num get last => _last; + num get minimum => _minimum; + num get maximum => _maximum; + num get sum => _sum; + int get count => _count; +} + +/// Metric [MetricType.set] that tracks a set of values on which you can perform +/// aggregations such as count_unique. +@internal +class SetMetric extends Metric { + final Set _values = {}; + + SetMetric._( + {required num value, + required super.key, + required super.unit, + required super.tags}) + : super(type: MetricType.set) { + add(value); + } + + @override + add(num value) => _values.add(value.toInt()); + + @override + Iterable _serializeValue() => _values; + + @override + int getWeight() => _values.length; + + @visibleForTesting + Set get values => _values; +} + +/// Metric [MetricType.distribution] that tracks a list of values. +@internal +class DistributionMetric extends Metric { + final List _values = []; + + DistributionMetric._( + {required num value, + required super.key, + required super.unit, + required super.tags}) + : super(type: MetricType.distribution) { + add(value); + } + + @override + add(num value) => _values.add(value); + + @override + Iterable _serializeValue() => _values; + + @override + int getWeight() => _values.length; + + @visibleForTesting + List get values => _values; +} /// The metric type and its associated statsd encoded value. +@internal enum MetricType { - counter('c'); + counter('c'), + gauge('g'), + distribution('d'), + set('s'); final String statsdType; diff --git a/dart/lib/src/metrics/metrics_aggregator.dart b/dart/lib/src/metrics/metrics_aggregator.dart index e2e77e9fa4..2dd9cb2c91 100644 --- a/dart/lib/src/metrics/metrics_aggregator.dart +++ b/dart/lib/src/metrics/metrics_aggregator.dart @@ -10,14 +10,21 @@ import 'metric.dart'; /// Class that aggregates all metrics into time buckets and sends them. @internal class MetricsAggregator { + static final _defaultFlushShiftMs = + (Random().nextDouble() * (_rollupInSeconds * 1000)).toInt(); + static const _defaultFlushInterval = Duration(seconds: 5); + static const _defaultMaxWeight = 100000; static const int _rollupInSeconds = 10; + final Duration _flushInterval; final int _flushShiftMs; final SentryOptions _options; final Hub _hub; + final int _maxWeight; + int _totalWeight = 0; bool _isClosed = false; - @visibleForTesting - Completer? flushCompleter; + Completer? _flushCompleter; + Timer? _flushTimer; /// The key for this map is the timestamp of the bucket, rounded down to the /// nearest RollupInSeconds. So it aggregates all the metrics over a certain @@ -29,20 +36,22 @@ class MetricsAggregator { MetricsAggregator({ required SentryOptions options, Hub? hub, - @visibleForTesting Duration flushInterval = const Duration(seconds: 5), + @visibleForTesting Duration? flushInterval, @visibleForTesting int? flushShiftMs, + @visibleForTesting int? maxWeight, }) : _options = options, _hub = hub ?? HubAdapter(), - _flushInterval = flushInterval, - _flushShiftMs = flushShiftMs ?? - (Random().nextDouble() * (_rollupInSeconds * 1000)).toInt(); + _flushInterval = flushInterval ?? _defaultFlushInterval, + _flushShiftMs = flushShiftMs ?? _defaultFlushShiftMs, + _maxWeight = maxWeight ?? _defaultMaxWeight; /// Creates or update an existing Counter metric with [value]. /// The metric to update is identified using [key], [unit] and [tags]. /// The [timestamp] represents when the metric was emitted. - void increment( + void emit( + MetricType metricType, String key, - double value, + num value, SentryMeasurementUnit unit, Map tags, ) { @@ -50,10 +59,38 @@ class MetricsAggregator { return; } + // run before metric callback if set + if (_options.beforeMetricCallback != null) { + try { + final shouldEmit = _options.beforeMetricCallback!(key, tags: tags); + if (!shouldEmit) { + _options.logger( + SentryLevel.info, + 'Metric was dropped by beforeMetric', + ); + return; + } + } catch (exception, stackTrace) { + _options.logger( + SentryLevel.error, + 'The BeforeMetric callback threw an exception', + exception: exception, + stackTrace: stackTrace, + ); + if (_options.automatedTestMode) { + rethrow; + } + } + } + final bucketKey = _getBucketKey(_options.clock()); final bucket = _buckets.putIfAbsent(bucketKey, () => {}); - final metric = - CounterMetric(value: value, key: key, unit: unit, tags: tags); + final metric = Metric.fromType( + type: metricType, key: key, value: value, unit: unit, tags: tags); + + final oldWeight = bucket[metric.getCompositeKey()]?.getWeight() ?? 0; + final addedWeight = metric.getWeight(); + _totalWeight += addedWeight - oldWeight; // Update the existing metric in the bucket. // If absent, add the newly created metric to the bucket. @@ -63,60 +100,78 @@ class MetricsAggregator { ifAbsent: () => metric, ); + // For sets, we only record that a value has been added to the set but not which one. + // See develop docs: https://develop.sentry.dev/sdk/metrics/#sets + _hub + .getSpan() + ?.localMetricsAggregator + ?.add(metric, metricType == MetricType.set ? addedWeight : value); + // Schedule the metrics flushing. _scheduleFlush(); } - Future _scheduleFlush() async { - if (!_isClosed && - _buckets.isNotEmpty && - flushCompleter?.isCompleted != false) { - flushCompleter = Completer(); - - await flushCompleter?.future - .timeout(_flushInterval, onTimeout: _flushMetrics); + void _scheduleFlush() { + if (!_isClosed && _buckets.isNotEmpty) { + if (_isOverWeight()) { + _flushTimer?.cancel(); + _flush(false); + return; + } + if (_flushTimer?.isActive != true) { + _flushCompleter = Completer(); + _flushTimer = Timer(_flushInterval, () => _flush(false)); + } } } - /// Flush the metrics, then schedule next flush again. - void _flushMetrics() async { - await _flush(); + bool _isOverWeight() => _totalWeight >= _maxWeight; - flushCompleter?.complete(null); - flushCompleter = null; - await _scheduleFlush(); + int getBucketWeight(final Map bucket) { + int weight = 0; + for (final metric in bucket.values) { + weight += metric.getWeight(); + } + return weight; } - /// Flush and sends metrics. - Future _flush() async { - final flushableBucketKeys = _getFlushableBucketKeys(); - if (flushableBucketKeys.isEmpty) { - _options.logger(SentryLevel.debug, 'Metrics: nothing to flush'); - return; + /// Flush the metrics, then schedule next flush again. + void _flush(bool force) async { + if (!force && _isOverWeight()) { + _options.logger(SentryLevel.info, + "Metrics: total weight exceeded, flushing all buckets"); + force = true; } - final Map> bucketsToFlush = {}; - int numMetrics = 0; - - for (int flushableBucketKey in flushableBucketKeys) { - final bucket = _buckets.remove(flushableBucketKey); - if (bucket != null) { - numMetrics += bucket.length; - bucketsToFlush[flushableBucketKey] = bucket.values; + final flushableBucketKeys = _getFlushableBucketKeys(force); + if (flushableBucketKeys.isEmpty) { + _options.logger(SentryLevel.debug, 'Metrics: nothing to flush'); + } else { + final Map> bucketsToFlush = {}; + + for (final flushableBucketKey in flushableBucketKeys) { + final bucket = _buckets.remove(flushableBucketKey); + if (bucket != null && bucket.isNotEmpty) { + _totalWeight -= getBucketWeight(bucket); + bucketsToFlush[flushableBucketKey] = bucket.values; + } } + await _hub.captureMetrics(bucketsToFlush); } - if (numMetrics == 0) { - _options.logger(SentryLevel.debug, 'Metrics: only empty buckets found'); - return; - } - - _options.logger(SentryLevel.debug, 'Metrics: capture $numMetrics metrics'); - await _hub.captureMetrics(bucketsToFlush); + // Notify flush completed and reschedule flushing + _flushTimer?.cancel(); + _flushTimer = null; + flushCompleter?.complete(null); + _flushCompleter = null; + _scheduleFlush(); } /// Return a list of bucket keys to flush. - List _getFlushableBucketKeys() { + List _getFlushableBucketKeys(bool force) { + if (force) { + return buckets.keys.toList(); + } // Flushable buckets are all buckets with timestamp lower than the current // one (so now - rollupInSeconds), minus a random duration (flushShiftMs). final maxTimestampToFlush = _options.clock().subtract(Duration( @@ -140,7 +195,11 @@ class MetricsAggregator { @visibleForTesting SplayTreeMap> get buckets => _buckets; + @visibleForTesting + Completer? get flushCompleter => _flushCompleter; + void close() { + _flush(true); _isClosed = true; } } diff --git a/dart/lib/src/metrics/metrics_api.dart b/dart/lib/src/metrics/metrics_api.dart index cefbd52138..1ef8df77c3 100644 --- a/dart/lib/src/metrics/metrics_api.dart +++ b/dart/lib/src/metrics/metrics_api.dart @@ -1,4 +1,8 @@ +import 'dart:async'; +import 'dart:convert'; import '../../sentry.dart'; +import '../utils/crc32_utils.dart'; +import 'metric.dart'; /// Public APIs to emit Sentry metrics. class MetricsApi { @@ -7,12 +11,14 @@ class MetricsApi { final Hub _hub; /// Emits a Counter metric, identified by [key], increasing it by [value]. + /// Counters track a value that can only be incremented. /// You can set the [unit] and the optional [tags] to associate to the metric. void increment(final String key, {final double value = 1.0, final SentryMeasurementUnit? unit, final Map? tags}) { - _hub.metricsAggregator?.increment( + _hub.metricsAggregator?.emit( + MetricType.counter, key, value, unit ?? SentryMeasurementUnit.none, @@ -20,6 +26,74 @@ class MetricsApi { ); } + /// Emits a Gauge metric, identified by [key], adding [value] to it. + /// Gauges track a value that can go up and down. + /// You can set the [unit] and the optional [tags] to associate to the metric. + void gauge(final String key, + {required final double value, + final SentryMeasurementUnit? unit, + final Map? tags}) { + _hub.metricsAggregator?.emit( + MetricType.gauge, + key, + value, + unit ?? SentryMeasurementUnit.none, + _enrichWithDefaultTags(tags), + ); + } + + /// Emits a Distribution metric, identified by [key], adding [value] to it. + /// Distributions track a list of values. + /// You can set the [unit] and the optional [tags] to associate to the metric. + void distribution(final String key, + {required final double value, + final SentryMeasurementUnit? unit, + final Map? tags}) { + _hub.metricsAggregator?.emit( + MetricType.distribution, + key, + value, + unit ?? SentryMeasurementUnit.none, + _enrichWithDefaultTags(tags), + ); + } + + /// Emits a Set metric, identified by [key], adding [value] or the CRC32 + /// checksum of [stringValue] to it. + /// Providing both [value] and [stringValue] adds both values to the metric. + /// Sets track a set of values to perform aggregations such as count_unique. + /// You can set the [unit] and the optional [tags] to associate to the metric. + void set(final String key, + {final int? value, + final String? stringValue, + final SentryMeasurementUnit? unit, + final Map? tags}) { + if (value != null) { + _hub.metricsAggregator?.emit( + MetricType.set, + key, + value, + unit ?? SentryMeasurementUnit.none, + _enrichWithDefaultTags(tags), + ); + } + if (stringValue != null && stringValue.isNotEmpty) { + final intValue = Crc32Utils.getCrc32(utf8.encode(stringValue)); + + _hub.metricsAggregator?.emit( + MetricType.set, + key, + intValue, + unit ?? SentryMeasurementUnit.none, + _enrichWithDefaultTags(tags), + ); + } + if (value == null && (stringValue == null || stringValue.isEmpty)) { + _hub.options.logger( + SentryLevel.info, 'No value provided. No metric will be emitted.'); + } + } + /// Enrich user tags adding default tags /// /// Currently adds release, environment and transaction. @@ -42,4 +116,65 @@ class MetricsApi { map.putIfAbsent(key, () => value); } } + + /// Emits a Distribution metric, identified by [key], with the time it takes + /// to run [function]. + /// You can set the [unit] and the optional [tags] to associate to the metric. + void timing(final String key, + {required FutureOr Function() function, + final DurationSentryMeasurementUnit unit = + DurationSentryMeasurementUnit.second, + final Map? tags}) async { + // Start a span for the metric + final span = _hub.getSpan()?.startChild('metric.timing', description: key); + // Set the user tags to the span as well + if (span != null && tags != null) { + for (final entry in tags.entries) { + span.setTag(entry.key, entry.value); + } + } + final before = _hub.options.clock(); + try { + if (function is Future Function()) { + await function(); + } else { + function(); + } + } finally { + final after = _hub.options.clock(); + Duration duration = after.difference(before); + // If we have a span, we use its duration as value for the emitted metric + if (span != null) { + await span.finish(); + duration = + span.endTimestamp?.difference(span.startTimestamp) ?? duration; + } + final value = _convertMicrosTo(unit, duration.inMicroseconds); + + _hub.metricsAggregator?.emit(MetricType.distribution, key, value, unit, + _enrichWithDefaultTags(tags)); + } + } + + double _convertMicrosTo( + final DurationSentryMeasurementUnit unit, final int micros) { + switch (unit) { + case DurationSentryMeasurementUnit.nanoSecond: + return micros * 1000; + case DurationSentryMeasurementUnit.microSecond: + return micros.toDouble(); + case DurationSentryMeasurementUnit.milliSecond: + return micros / 1000.0; + case DurationSentryMeasurementUnit.second: + return micros / 1000000.0; + case DurationSentryMeasurementUnit.minute: + return micros / 60000000.0; + case DurationSentryMeasurementUnit.hour: + return micros / 3600000000.0; + case DurationSentryMeasurementUnit.day: + return micros / 86400000000.0; + case DurationSentryMeasurementUnit.week: + return micros / 86400000000.0 / 7.0; + } + } } diff --git a/dart/lib/src/noop_sentry_span.dart b/dart/lib/src/noop_sentry_span.dart index 2156aeb678..45d72b94c9 100644 --- a/dart/lib/src/noop_sentry_span.dart +++ b/dart/lib/src/noop_sentry_span.dart @@ -1,3 +1,4 @@ +import 'metrics/local_metrics_aggregator.dart'; import 'protocol.dart'; import 'tracing.dart'; import 'utils.dart'; @@ -95,4 +96,7 @@ class NoOpSentrySpan extends ISentrySpan { @override void scheduleFinish() {} + + @override + LocalMetricsAggregator? get localMetricsAggregator => null; } diff --git a/dart/lib/src/protocol.dart b/dart/lib/src/protocol.dart index 3dac1a6f3c..6d89823721 100644 --- a/dart/lib/src/protocol.dart +++ b/dart/lib/src/protocol.dart @@ -8,6 +8,7 @@ export 'protocol/sentry_device.dart'; export 'protocol/dsn.dart'; export 'protocol/sentry_gpu.dart'; export 'protocol/mechanism.dart'; +export 'protocol/metric_summary.dart'; export 'protocol/sentry_message.dart'; export 'protocol/sentry_operating_system.dart'; export 'protocol/sentry_request.dart'; diff --git a/dart/lib/src/protocol/metric_summary.dart b/dart/lib/src/protocol/metric_summary.dart new file mode 100644 index 0000000000..b0c617fb30 --- /dev/null +++ b/dart/lib/src/protocol/metric_summary.dart @@ -0,0 +1,43 @@ +import '../metrics/metric.dart'; + +class MetricSummary { + final num min; + final num max; + final num sum; + final int count; + final Map? tags; + + MetricSummary.fromGauge(GaugeMetric gauge) + : min = gauge.minimum, + max = gauge.maximum, + sum = gauge.sum, + count = gauge.count, + tags = gauge.tags; + + const MetricSummary( + {required this.min, + required this.max, + required this.sum, + required this.count, + required this.tags}); + + /// Deserializes a [MetricSummary] from JSON [Map]. + factory MetricSummary.fromJson(Map data) => MetricSummary( + min: data['min'], + max: data['max'], + count: data['count'], + sum: data['sum'], + tags: data['tags']?.cast(), + ); + + /// Produces a [Map] that can be serialized to JSON. + Map toJson() { + return { + 'min': min, + 'max': max, + 'count': count, + 'sum': sum, + if (tags?.isNotEmpty ?? false) 'tags': tags, + }; + } +} diff --git a/dart/lib/src/protocol/sentry_span.dart b/dart/lib/src/protocol/sentry_span.dart index e03410d715..780578d182 100644 --- a/dart/lib/src/protocol/sentry_span.dart +++ b/dart/lib/src/protocol/sentry_span.dart @@ -1,6 +1,7 @@ import 'dart:async'; import '../hub.dart'; +import '../metrics/local_metrics_aggregator.dart'; import '../protocol.dart'; import '../sentry_tracer.dart'; @@ -12,6 +13,7 @@ typedef OnFinishedCallback = Future Function({DateTime? endTimestamp}); class SentrySpan extends ISentrySpan { final SentrySpanContext _context; DateTime? _endTimestamp; + Map>? _metricSummaries; late final DateTime _startTimestamp; final Hub _hub; @@ -22,6 +24,7 @@ class SentrySpan extends ISentrySpan { SpanStatus? _status; final Map _tags = {}; OnFinishedCallback? _finishedCallback; + late final LocalMetricsAggregator? _localMetricsAggregator; @override final SentryTracesSamplingDecision? samplingDecision; @@ -37,6 +40,9 @@ class SentrySpan extends ISentrySpan { _startTimestamp = startTimestamp?.toUtc() ?? _hub.options.clock(); _finishedCallback = finishedCallback; _origin = _context.origin; + _localMetricsAggregator = _hub.options.enableSpanLocalMetricAggregation + ? LocalMetricsAggregator() + : null; } @override @@ -65,6 +71,7 @@ class SentrySpan extends ISentrySpan { if (_throwable != null) { _hub.setSpanContext(_throwable, this, _tracer.name); } + _metricSummaries = _localMetricsAggregator?.getSummaries(); await _finishedCallback?.call(endTimestamp: _endTimestamp); return super.finish(status: status, endTimestamp: _endTimestamp); } @@ -154,6 +161,9 @@ class SentrySpan extends ISentrySpan { @override set origin(String? origin) => _origin = origin; + @override + LocalMetricsAggregator? get localMetricsAggregator => _localMetricsAggregator; + Map toJson() { final json = _context.toJson(); json['start_timestamp'] = @@ -174,6 +184,16 @@ class SentrySpan extends ISentrySpan { if (_origin != null) { json['origin'] = _origin; } + + final metricSummariesMap = _metricSummaries?.entries ?? Iterable.empty(); + if (metricSummariesMap.isNotEmpty) { + final map = {}; + for (final entry in metricSummariesMap) { + final summary = entry.value.map((e) => e.toJson()); + map[entry.key] = summary.toList(growable: false); + } + json['_metrics_summary'] = map; + } return json; } diff --git a/dart/lib/src/protocol/sentry_transaction.dart b/dart/lib/src/protocol/sentry_transaction.dart index e00fa23355..eea319aa41 100644 --- a/dart/lib/src/protocol/sentry_transaction.dart +++ b/dart/lib/src/protocol/sentry_transaction.dart @@ -13,6 +13,7 @@ class SentryTransaction extends SentryEvent { @internal final SentryTracer tracer; late final Map measurements; + late final Map>? metricSummaries; late final SentryTransactionInfo? transactionInfo; SentryTransaction( @@ -37,6 +38,7 @@ class SentryTransaction extends SentryEvent { super.request, String? type, Map? measurements, + Map>? metricSummaries, SentryTransactionInfo? transactionInfo, }) : super( timestamp: timestamp ?? tracer.endTimestamp, @@ -52,6 +54,8 @@ class SentryTransaction extends SentryEvent { final spanContext = tracer.context; spans = tracer.children; this.measurements = measurements ?? {}; + this.metricSummaries = + metricSummaries ?? tracer.localMetricsAggregator?.getSummaries(); contexts.trace = spanContext.toTraceContext( sampled: tracer.samplingDecision?.sampled, @@ -85,6 +89,16 @@ class SentryTransaction extends SentryEvent { json['transaction_info'] = transactionInfo.toJson(); } + final metricSummariesMap = metricSummaries?.entries ?? Iterable.empty(); + if (metricSummariesMap.isNotEmpty) { + final map = {}; + for (final entry in metricSummariesMap) { + final summary = entry.value.map((e) => e.toJson()); + map[entry.key] = summary.toList(growable: false); + } + json['_metrics_summary'] = map; + } + return json; } @@ -123,6 +137,7 @@ class SentryTransaction extends SentryEvent { List? threads, String? type, Map? measurements, + Map>? metricSummaries, SentryTransactionInfo? transactionInfo, }) => SentryTransaction( @@ -148,6 +163,9 @@ class SentryTransaction extends SentryEvent { type: type ?? this.type, measurements: (measurements != null ? Map.from(measurements) : null) ?? this.measurements, + metricSummaries: + (metricSummaries != null ? Map.from(metricSummaries) : null) ?? + this.metricSummaries, transactionInfo: transactionInfo ?? this.transactionInfo, ); } diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index a08d67ca37..eea642ae48 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -165,6 +165,10 @@ class SentryOptions { /// to the scope. When nothing is returned from the function, the breadcrumb is dropped BeforeBreadcrumbCallback? beforeBreadcrumb; + /// This function is called right before a metric is about to be emitted. + /// Can return true to emit the metric, or false to drop it. + BeforeMetricCallback? beforeMetricCallback; + /// 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; @@ -390,7 +394,7 @@ class SentryOptions { bool enableMetrics = false; @experimental - bool _enableDefaultTagsForMetrics = false; + bool _enableDefaultTagsForMetrics = true; /// Enables enriching metrics with default tags. Requires [enableMetrics]. /// More on https://develop.sentry.dev/delightful-developer-metrics/sending-metrics-sdk/#automatic-tags-extraction @@ -406,6 +410,22 @@ class SentryOptions { set enableDefaultTagsForMetrics(final bool enableDefaultTagsForMetrics) => _enableDefaultTagsForMetrics = enableDefaultTagsForMetrics; + @experimental + bool _enableSpanLocalMetricAggregation = true; + + /// Enables span metrics aggregation. Requires [enableMetrics]. + /// More on https://develop.sentry.dev/sdk/metrics/#span-aggregation + @experimental + bool get enableSpanLocalMetricAggregation => + enableMetrics && _enableSpanLocalMetricAggregation; + + /// Enables span metrics aggregation. Requires [enableMetrics]. + /// More on https://develop.sentry.dev/sdk/metrics/#span-aggregation + @experimental + set enableSpanLocalMetricAggregation( + final bool enableSpanLocalMetricAggregation) => + _enableSpanLocalMetricAggregation = enableSpanLocalMetricAggregation; + /// Only for internal use. Changed SDK behaviour when set to true: /// - Rethrow exceptions that occur in user provided closures @internal @@ -526,6 +546,13 @@ typedef BeforeBreadcrumbCallback = Breadcrumb? Function( Hint? hint, }); +/// This function is called right before a metric is about to be emitted. +/// Can return true to emit the metric, or false to drop it. +typedef BeforeMetricCallback = bool Function( + String key, { + Map? tags, +}); + /// Used to provide timestamp for logging. typedef ClockProvider = DateTime Function(); diff --git a/dart/lib/src/sentry_span_interface.dart b/dart/lib/src/sentry_span_interface.dart index cdc121f849..1d142c45b9 100644 --- a/dart/lib/src/sentry_span_interface.dart +++ b/dart/lib/src/sentry_span_interface.dart @@ -1,5 +1,6 @@ import 'package:meta/meta.dart'; +import 'metrics/local_metrics_aggregator.dart'; import 'protocol.dart'; import 'tracing.dart'; @@ -46,6 +47,9 @@ abstract class ISentrySpan { /// See https://develop.sentry.dev/sdk/performance/trace-origin set origin(String? origin); + @internal + LocalMetricsAggregator? get localMetricsAggregator; + /// Returns the end timestamp if finished DateTime? get endTimestamp; diff --git a/dart/lib/src/sentry_tracer.dart b/dart/lib/src/sentry_tracer.dart index c7f6993ad3..d9ea75d256 100644 --- a/dart/lib/src/sentry_tracer.dart +++ b/dart/lib/src/sentry_tracer.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import '../sentry.dart'; +import 'metrics/local_metrics_aggregator.dart'; import 'profiling.dart'; import 'sentry_tracer_finish_status.dart'; import 'utils/sample_rate_format.dart'; @@ -413,4 +414,8 @@ class SentryTracer extends ISentrySpan { }); } } + + @override + LocalMetricsAggregator? get localMetricsAggregator => + _rootSpan.localMetricsAggregator; } diff --git a/dart/lib/src/utils/crc32_utils.dart b/dart/lib/src/utils/crc32_utils.dart new file mode 100644 index 0000000000..8e6f63fd53 --- /dev/null +++ b/dart/lib/src/utils/crc32_utils.dart @@ -0,0 +1,313 @@ +// Adapted from the archive library (https://pub.dev/packages/archive) +// https://github.com/brendan-duncan/archive/blob/21c864efe0df2b7fd962b59ff0a714c96732bf7d/lib/src/util/crc32.dart +// +// The MIT License +// +// Copyright (c) 2013-2021 Brendan Duncan. +// All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +/// Util class to compute the CRC-32 checksum of a given array. +class Crc32Utils { + /// Get the CRC-32 checksum of the given array. You can append bytes to an + /// already computed crc by specifying the previous [crc] value. + static int getCrc32(List array, [int crc = 0]) { + var len = array.length; + crc = crc ^ 0xffffffff; + var ip = 0; + while (len >= 8) { + crc = _crc32Table[(crc ^ array[ip++]) & 0xff] ^ (crc >> 8); + crc = _crc32Table[(crc ^ array[ip++]) & 0xff] ^ (crc >> 8); + crc = _crc32Table[(crc ^ array[ip++]) & 0xff] ^ (crc >> 8); + crc = _crc32Table[(crc ^ array[ip++]) & 0xff] ^ (crc >> 8); + crc = _crc32Table[(crc ^ array[ip++]) & 0xff] ^ (crc >> 8); + crc = _crc32Table[(crc ^ array[ip++]) & 0xff] ^ (crc >> 8); + crc = _crc32Table[(crc ^ array[ip++]) & 0xff] ^ (crc >> 8); + crc = _crc32Table[(crc ^ array[ip++]) & 0xff] ^ (crc >> 8); + len -= 8; + } + if (len > 0) { + do { + crc = _crc32Table[(crc ^ array[ip++]) & 0xff] ^ (crc >> 8); + } while (--len > 0); + } + return crc ^ 0xffffffff; + } +} + +// Precomputed CRC table for faster calculations. +const List _crc32Table = [ + 0, + 1996959894, + 3993919788, + 2567524794, + 124634137, + 1886057615, + 3915621685, + 2657392035, + 249268274, + 2044508324, + 3772115230, + 2547177864, + 162941995, + 2125561021, + 3887607047, + 2428444049, + 498536548, + 1789927666, + 4089016648, + 2227061214, + 450548861, + 1843258603, + 4107580753, + 2211677639, + 325883990, + 1684777152, + 4251122042, + 2321926636, + 335633487, + 1661365465, + 4195302755, + 2366115317, + 997073096, + 1281953886, + 3579855332, + 2724688242, + 1006888145, + 1258607687, + 3524101629, + 2768942443, + 901097722, + 1119000684, + 3686517206, + 2898065728, + 853044451, + 1172266101, + 3705015759, + 2882616665, + 651767980, + 1373503546, + 3369554304, + 3218104598, + 565507253, + 1454621731, + 3485111705, + 3099436303, + 671266974, + 1594198024, + 3322730930, + 2970347812, + 795835527, + 1483230225, + 3244367275, + 3060149565, + 1994146192, + 31158534, + 2563907772, + 4023717930, + 1907459465, + 112637215, + 2680153253, + 3904427059, + 2013776290, + 251722036, + 2517215374, + 3775830040, + 2137656763, + 141376813, + 2439277719, + 3865271297, + 1802195444, + 476864866, + 2238001368, + 4066508878, + 1812370925, + 453092731, + 2181625025, + 4111451223, + 1706088902, + 314042704, + 2344532202, + 4240017532, + 1658658271, + 366619977, + 2362670323, + 4224994405, + 1303535960, + 984961486, + 2747007092, + 3569037538, + 1256170817, + 1037604311, + 2765210733, + 3554079995, + 1131014506, + 879679996, + 2909243462, + 3663771856, + 1141124467, + 855842277, + 2852801631, + 3708648649, + 1342533948, + 654459306, + 3188396048, + 3373015174, + 1466479909, + 544179635, + 3110523913, + 3462522015, + 1591671054, + 702138776, + 2966460450, + 3352799412, + 1504918807, + 783551873, + 3082640443, + 3233442989, + 3988292384, + 2596254646, + 62317068, + 1957810842, + 3939845945, + 2647816111, + 81470997, + 1943803523, + 3814918930, + 2489596804, + 225274430, + 2053790376, + 3826175755, + 2466906013, + 167816743, + 2097651377, + 4027552580, + 2265490386, + 503444072, + 1762050814, + 4150417245, + 2154129355, + 426522225, + 1852507879, + 4275313526, + 2312317920, + 282753626, + 1742555852, + 4189708143, + 2394877945, + 397917763, + 1622183637, + 3604390888, + 2714866558, + 953729732, + 1340076626, + 3518719985, + 2797360999, + 1068828381, + 1219638859, + 3624741850, + 2936675148, + 906185462, + 1090812512, + 3747672003, + 2825379669, + 829329135, + 1181335161, + 3412177804, + 3160834842, + 628085408, + 1382605366, + 3423369109, + 3138078467, + 570562233, + 1426400815, + 3317316542, + 2998733608, + 733239954, + 1555261956, + 3268935591, + 3050360625, + 752459403, + 1541320221, + 2607071920, + 3965973030, + 1969922972, + 40735498, + 2617837225, + 3943577151, + 1913087877, + 83908371, + 2512341634, + 3803740692, + 2075208622, + 213261112, + 2463272603, + 3855990285, + 2094854071, + 198958881, + 2262029012, + 4057260610, + 1759359992, + 534414190, + 2176718541, + 4139329115, + 1873836001, + 414664567, + 2282248934, + 4279200368, + 1711684554, + 285281116, + 2405801727, + 4167216745, + 1634467795, + 376229701, + 2685067896, + 3608007406, + 1308918612, + 956543938, + 2808555105, + 3495958263, + 1231636301, + 1047427035, + 2932959818, + 3654703836, + 1088359270, + 936918000, + 2847714899, + 3736837829, + 1202900863, + 817233897, + 3183342108, + 3401237130, + 1404277552, + 615818150, + 3134207493, + 3453421203, + 1423857449, + 601450431, + 3009837614, + 3294710456, + 1567103746, + 711928724, + 3020668471, + 3272380065, + 1510334235, + 755167117 +]; diff --git a/dart/test/metrics/local_metrics_aggregator_test.dart b/dart/test/metrics/local_metrics_aggregator_test.dart new file mode 100644 index 0000000000..42648fdaf2 --- /dev/null +++ b/dart/test/metrics/local_metrics_aggregator_test.dart @@ -0,0 +1,40 @@ +import 'package:sentry/src/metrics/local_metrics_aggregator.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +import '../mocks.dart'; + +void main() { + group('add', () { + late LocalMetricsAggregator aggregator; + + setUp(() { + aggregator = LocalMetricsAggregator(); + }); + + test('same metric multiple times aggregates them', () async { + aggregator.add(fakeMetric, 1); + aggregator.add(fakeMetric, 2); + final summaries = aggregator.getSummaries(); + expect(summaries.length, 1); + final summary = summaries.values.first; + expect(summary.length, 1); + }); + + test('same metric different tags aggregates summary bucket', () async { + aggregator.add(fakeMetric, 1); + aggregator.add(fakeMetric..tags.clear(), 2); + final summaries = aggregator.getSummaries(); + expect(summaries.length, 1); + final summary = summaries.values.first; + expect(summary.length, 2); + }); + + test('different metrics does not aggregate them', () async { + aggregator.add(fakeMetric, 1); + aggregator.add(fakeMetric2, 2); + final summaries = aggregator.getSummaries(); + expect(summaries.length, 2); + }); + }); +} diff --git a/dart/test/metrics/metric_test.dart b/dart/test/metrics/metric_test.dart index a218988b48..d123916eea 100644 --- a/dart/test/metrics/metric_test.dart +++ b/dart/test/metrics/metric_test.dart @@ -3,7 +3,51 @@ import 'package:sentry/src/metrics/metric.dart'; import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; +import 'metrics_aggregator_test.dart'; + void main() { + group('fromType', () { + test('counter creates a CounterMetric', () async { + final Metric metric = Metric.fromType( + type: MetricType.counter, + key: mockKey, + value: 1, + unit: mockUnit, + tags: mockTags); + expect(metric, isA()); + }); + + test('gauge creates a GaugeMetric', () async { + final Metric metric = Metric.fromType( + type: MetricType.gauge, + key: mockKey, + value: 1, + unit: mockUnit, + tags: mockTags); + expect(metric, isA()); + }); + + test('distribution creates a DistributionMetric', () async { + final Metric metric = Metric.fromType( + type: MetricType.distribution, + key: mockKey, + value: 1, + unit: mockUnit, + tags: mockTags); + expect(metric, isA()); + }); + + test('set creates a SetMetric', () async { + final Metric metric = Metric.fromType( + type: MetricType.set, + key: mockKey, + value: 1, + unit: mockUnit, + tags: mockTags); + expect(metric, isA()); + }); + }); + group('Encode to statsd', () { late Fixture fixture; @@ -13,10 +57,9 @@ void main() { test('encode CounterMetric', () async { final int bucketKey = 10; - final String expectedStatsd = + final expectedStatsd = 'key_metric_@hour:2.1|c|#tag1:tag value 1,key_2:@13/-d_s|T10'; - final String actualStatsd = - fixture.counterMetric.encodeToStatsd(bucketKey); + final actualStatsd = fixture.counterMetric.encodeToStatsd(bucketKey); expect(actualStatsd, expectedStatsd); }); }); @@ -28,12 +71,12 @@ void main() { fixture = Fixture(); }); - test('getCompositeKey escapes commas from tags', () async { + test('escapes commas from tags', () async { final Iterable tags = fixture.counterMetric.tags.values; - final String joinedTags = tags.join(); + final joinedTags = tags.join(); final Iterable expectedTags = tags.map((e) => e.replaceAll(',', '\\,')); - final String actualKey = fixture.counterMetric.getCompositeKey(); + final actualKey = fixture.counterMetric.getCompositeKey(); expect(joinedTags.contains(','), true); expect(joinedTags.contains('\\,'), false); @@ -43,22 +86,190 @@ void main() { } }); - test('getCompositeKey CounterMetric', () async { - final String expectedKey = + test('CounterMetric', () async { + final expectedKey = 'c_key metric!_hour_tag1=tag\\, value 1,key 2=&@"13/-d_s'; - final String actualKey = fixture.counterMetric.getCompositeKey(); + final actualKey = fixture.counterMetric.getCompositeKey(); + expect(actualKey, expectedKey); + }); + + test('GaugeMetric', () async { + final expectedKey = + 'g_key metric!_hour_tag1=tag\\, value 1,key 2=&@"13/-d_s'; + final actualKey = fixture.gaugeMetric.getCompositeKey(); + expect(actualKey, expectedKey); + }); + + test('DistributionMetric', () async { + final expectedKey = + 'd_key metric!_hour_tag1=tag\\, value 1,key 2=&@"13/-d_s'; + final actualKey = fixture.distributionMetric.getCompositeKey(); + expect(actualKey, expectedKey); + }); + + test('SetMetric', () async { + final expectedKey = + 's_key metric!_hour_tag1=tag\\, value 1,key 2=&@"13/-d_s'; + final actualKey = fixture.setMetric.getCompositeKey(); + expect(actualKey, expectedKey); + }); + }); + + group('getSpanAggregationKey', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('CounterMetric', () async { + final expectedKey = 'c:key metric!@hour'; + final actualKey = fixture.counterMetric.getSpanAggregationKey(); + expect(actualKey, expectedKey); + }); + + test('GaugeMetric', () async { + final expectedKey = 'g:key metric!@hour'; + final actualKey = fixture.gaugeMetric.getSpanAggregationKey(); + expect(actualKey, expectedKey); + }); + + test('DistributionMetric', () async { + final expectedKey = 'd:key metric!@hour'; + final actualKey = fixture.distributionMetric.getSpanAggregationKey(); + expect(actualKey, expectedKey); + }); + + test('SetMetric', () async { + final expectedKey = 's:key metric!@hour'; + final actualKey = fixture.setMetric.getSpanAggregationKey(); expect(actualKey, expectedKey); }); }); + + group('getWeight', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('counter always returns 1', () async { + final CounterMetric metric = fixture.counterMetric; + expect(metric.getWeight(), 1); + metric.add(5); + metric.add(2); + expect(metric.getWeight(), 1); + }); + + test('gauge always returns 5', () async { + final GaugeMetric metric = fixture.gaugeMetric; + expect(metric.getWeight(), 5); + metric.add(5); + metric.add(2); + expect(metric.getWeight(), 5); + }); + + test('distribution returns number of values', () async { + final DistributionMetric metric = fixture.distributionMetric; + expect(metric.getWeight(), 1); + metric.add(5); + // Repeated values are counted + metric.add(5); + expect(metric.getWeight(), 3); + }); + + test('set returns number of unique values', () async { + final SetMetric metric = fixture.setMetric; + expect(metric.getWeight(), 1); + metric.add(5); + // Repeated values are not counted + metric.add(5); + expect(metric.getWeight(), 2); + }); + }); + + group('add', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('counter increments', () async { + final CounterMetric metric = fixture.counterMetric; + expect(metric.value, 2.1); + metric.add(5); + metric.add(2); + expect(metric.value, 9.1); + }); + + test('gauge stores min, max, last, sum and count', () async { + final GaugeMetric metric = fixture.gaugeMetric; + expect(metric.minimum, 2.1); + expect(metric.maximum, 2.1); + expect(metric.last, 2.1); + expect(metric.sum, 2.1); + expect(metric.count, 1); + metric.add(1.4); + metric.add(5.4); + expect(metric.minimum, 1.4); + expect(metric.maximum, 5.4); + expect(metric.last, 5.4); + expect(metric.sum, 8.9); + expect(metric.count, 3); + }); + + test('distribution stores all values', () async { + final DistributionMetric metric = fixture.distributionMetric; + metric.add(2); + metric.add(4); + metric.add(4); + expect(metric.values, [2.1, 2, 4, 4]); + }); + + test('set stores unique int values', () async { + final SetMetric metric = fixture.setMetric; + metric.add(5); + // Repeated values are not counted + metric.add(5); + expect(metric.values, {2, 5}); + }); + }); } class Fixture { // We use a fractional number because on some platforms converting '2' to // string return '2', while others '2.0', and we'd have issues testing. - final CounterMetric counterMetric = CounterMetric( + final CounterMetric counterMetric = Metric.fromType( + type: MetricType.counter, + value: 2.1, + key: 'key metric!', + unit: DurationSentryMeasurementUnit.hour, + tags: {'tag1': 'tag, value 1', 'key 2': '&@"13/-d_s'}, + ) as CounterMetric; + + final GaugeMetric gaugeMetric = Metric.fromType( + type: MetricType.gauge, + value: 2.1, + key: 'key metric!', + unit: DurationSentryMeasurementUnit.hour, + tags: {'tag1': 'tag, value 1', 'key 2': '&@"13/-d_s'}, + ) as GaugeMetric; + + final DistributionMetric distributionMetric = Metric.fromType( + type: MetricType.distribution, + value: 2.1, + key: 'key metric!', + unit: DurationSentryMeasurementUnit.hour, + tags: {'tag1': 'tag, value 1', 'key 2': '&@"13/-d_s'}, + ) as DistributionMetric; + + final SetMetric setMetric = Metric.fromType( + type: MetricType.set, value: 2.1, key: 'key metric!', unit: DurationSentryMeasurementUnit.hour, tags: {'tag1': 'tag, value 1', 'key 2': '&@"13/-d_s'}, - ); + ) as SetMetric; } diff --git a/dart/test/metrics/metrics_aggregator_test.dart b/dart/test/metrics/metrics_aggregator_test.dart index 16a7afa9ec..5636e7ebf6 100644 --- a/dart/test/metrics/metrics_aggregator_test.dart +++ b/dart/test/metrics/metrics_aggregator_test.dart @@ -7,29 +7,142 @@ import '../mocks.dart'; import '../mocks/mock_hub.dart'; void main() { - group('apis', () { + group('emit', () { late Fixture fixture; setUp(() { fixture = Fixture(); }); - test('increment emits counter metric', () async { + test('counter', () async { final MetricsAggregator sut = fixture.getSut(); final String key = 'metric key'; final double value = 5; final SentryMeasurementUnit unit = DurationSentryMeasurementUnit.minute; final Map tags = {'tag1': 'val1', 'tag2': 'val2'}; - sut.increment(key, value, unit, tags); + sut.emit(MetricType.counter, key, value, unit, tags); final metricsCaptured = sut.buckets.values.first.values; expect(metricsCaptured.length, 1); + expect(metricsCaptured.first, isA()); expect(metricsCaptured.first.type, MetricType.counter); expect(metricsCaptured.first.key, key); expect((metricsCaptured.first as CounterMetric).value, value); expect(metricsCaptured.first.unit, unit); expect(metricsCaptured.first.tags, tags); }); + + test('gauge', () async { + final MetricsAggregator sut = fixture.getSut(); + sut.testEmit(type: MetricType.gauge); + + final metricsCaptured = sut.buckets.values.first.values; + expect(metricsCaptured.first, isA()); + expect(metricsCaptured.first.type, MetricType.gauge); + }); + + test('distribution', () async { + final MetricsAggregator sut = fixture.getSut(); + sut.testEmit(type: MetricType.distribution); + + final metricsCaptured = sut.buckets.values.first.values; + expect(metricsCaptured.first, isA()); + expect(metricsCaptured.first.type, MetricType.distribution); + }); + + test('set', () async { + final MetricsAggregator sut = fixture.getSut(); + sut.testEmit(type: MetricType.set); + + final metricsCaptured = sut.buckets.values.first.values; + expect(metricsCaptured.first, isA()); + expect(metricsCaptured.first.type, MetricType.set); + }); + }); + + group('span local aggregation', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('emit calls add', () async { + final MetricsAggregator sut = fixture.getSut(hub: fixture.hub); + final t = fixture.hub.startTransaction('test', 'op', bindToScope: true); + + var spanSummary = t.localMetricsAggregator?.getSummaries().values; + expect(spanSummary, isEmpty); + + sut.testEmit(); + + spanSummary = t.localMetricsAggregator?.getSummaries().values; + expect(spanSummary, isNotEmpty); + }); + + test('emit counter', () async { + final MetricsAggregator sut = fixture.getSut(hub: fixture.hub); + final t = fixture.hub.startTransaction('test', 'op', bindToScope: true); + + sut.testEmit(type: MetricType.counter, value: 1); + sut.testEmit(type: MetricType.counter, value: 4); + + final spanSummary = t.localMetricsAggregator?.getSummaries().values.first; + expect(spanSummary!.length, 1); + expect(spanSummary.first.sum, 5); + expect(spanSummary.first.min, 1); + expect(spanSummary.first.max, 4); + expect(spanSummary.first.count, 2); + expect(spanSummary.first.tags, mockTags); + }); + + test('emit distribution', () async { + final MetricsAggregator sut = fixture.getSut(hub: fixture.hub); + final t = fixture.hub.startTransaction('test', 'op', bindToScope: true); + + sut.testEmit(type: MetricType.distribution, value: 1); + sut.testEmit(type: MetricType.distribution, value: 4); + + final spanSummary = t.localMetricsAggregator?.getSummaries().values.first; + expect(spanSummary!.length, 1); + expect(spanSummary.first.sum, 5); + expect(spanSummary.first.min, 1); + expect(spanSummary.first.max, 4); + expect(spanSummary.first.count, 2); + expect(spanSummary.first.tags, mockTags); + }); + + test('emit gauge', () async { + final MetricsAggregator sut = fixture.getSut(hub: fixture.hub); + final t = fixture.hub.startTransaction('test', 'op', bindToScope: true); + + sut.testEmit(type: MetricType.gauge, value: 1); + sut.testEmit(type: MetricType.gauge, value: 4); + + final spanSummary = t.localMetricsAggregator?.getSummaries().values.first; + expect(spanSummary!.length, 1); + expect(spanSummary.first.sum, 5); + expect(spanSummary.first.min, 1); + expect(spanSummary.first.max, 4); + expect(spanSummary.first.count, 2); + expect(spanSummary.first.tags, mockTags); + }); + + test('emit set', () async { + final MetricsAggregator sut = fixture.getSut(hub: fixture.hub); + final t = fixture.hub.startTransaction('test', 'op', bindToScope: true); + + sut.testEmit(type: MetricType.set, value: 1); + sut.testEmit(type: MetricType.set, value: 4); + + final spanSummary = t.localMetricsAggregator?.getSummaries().values.first; + expect(spanSummary!.length, 1); + expect(spanSummary.first.sum, 2); + expect(spanSummary.first.min, 1); + expect(spanSummary.first.max, 1); + expect(spanSummary.first.count, 2); + expect(spanSummary.first.tags, mockTags); + }); }); group('emit in same time bucket', () { @@ -41,8 +154,8 @@ void main() { test('same metric with different keys emit different metrics', () async { final MetricsAggregator sut = fixture.getSut(); - sut.testIncrement(key: mockKey); - sut.testIncrement(key: mockKey2); + sut.testEmit(key: mockKey); + sut.testEmit(key: mockKey2); final timeBuckets = sut.buckets; final bucket = timeBuckets.values.first; @@ -54,8 +167,8 @@ void main() { test('same metric with different units emit different metrics', () async { final MetricsAggregator sut = fixture.getSut(); - sut.testIncrement(unit: mockUnit); - sut.testIncrement(unit: mockUnit2); + sut.testEmit(unit: mockUnit); + sut.testEmit(unit: mockUnit2); final timeBuckets = sut.buckets; final bucket = timeBuckets.values.first; @@ -67,8 +180,8 @@ void main() { test('same metric with different tags emit different metrics', () async { final MetricsAggregator sut = fixture.getSut(); - sut.testIncrement(tags: mockTags); - sut.testIncrement(tags: mockTags2); + sut.testEmit(tags: mockTags); + sut.testEmit(tags: mockTags2); final timeBuckets = sut.buckets; final bucket = timeBuckets.values.first; @@ -80,8 +193,8 @@ void main() { test('increment same metric emit only one counter', () async { final MetricsAggregator sut = fixture.getSut(); - sut.testIncrement(value: 1); - sut.testIncrement(value: 2); + sut.testEmit(type: MetricType.counter, value: 1); + sut.testEmit(type: MetricType.counter, value: 2); final timeBuckets = sut.buckets; final bucket = timeBuckets.values.first; @@ -101,9 +214,9 @@ void main() { test('same metric in < 10 seconds interval emit only one metric', () async { final MetricsAggregator sut = fixture.getSut(); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(0); - sut.testIncrement(); + sut.testEmit(); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(9999); - sut.testIncrement(); + sut.testEmit(); final timeBuckets = sut.buckets; expect(timeBuckets.length, 1); @@ -112,11 +225,11 @@ void main() { test('same metric in >= 10 seconds interval emit two metrics', () async { final MetricsAggregator sut = fixture.getSut(); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(0); - sut.testIncrement(); + sut.testEmit(); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); - sut.testIncrement(); + sut.testEmit(); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(20000); - sut.testIncrement(); + sut.testEmit(); final timeBuckets = sut.buckets; expect(timeBuckets.length, 3); @@ -134,16 +247,16 @@ void main() { final MetricsAggregator sut = fixture.getSut(); expect(sut.flushCompleter, isNull); - sut.testIncrement(); + sut.testEmit(); expect(sut.flushCompleter, isNotNull); }); test('flush calls hub captureMetrics', () async { final MetricsAggregator sut = fixture.getSut(); - // emit a counter metric + // emit a metric fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(0); - sut.testIncrement(); + sut.testEmit(); expect(fixture.mockHub.captureMetricsCalls, isEmpty); // mock clock to allow metric time aggregation @@ -162,7 +275,7 @@ void main() { final MetricsAggregator sut = fixture.getSut(); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(0); - sut.testIncrement(); + sut.testEmit(); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); expect(sut.flushCompleter, isNotNull); await sut.flushCompleter!.future; @@ -173,9 +286,9 @@ void main() { final MetricsAggregator sut = fixture.getSut(); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(0); - sut.testIncrement(); + sut.testEmit(); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); - sut.testIncrement(); + sut.testEmit(); expect(sut.flushCompleter, isNotNull); await sut.flushCompleter!.future; // we expect the aggregator flushed metrics and schedules flushing again @@ -188,7 +301,7 @@ void main() { fixture.getSut(flushInterval: Duration(milliseconds: 100)); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); - sut.testIncrement(); + sut.testEmit(); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10050); expect(sut.flushCompleter, isNotNull); @@ -202,7 +315,7 @@ void main() { final MetricsAggregator sut = fixture.getSut(); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); - sut.testIncrement(); + sut.testEmit(); // The 10 second bucket is not finished, so it shouldn't capture anything fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(19999); @@ -219,7 +332,7 @@ void main() { final MetricsAggregator sut = fixture.getSut(flushShiftMs: 4000); fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); - sut.testIncrement(); + sut.testEmit(); // The 10 second bucket is not finished, so it shouldn't capture anything fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(19999); @@ -236,6 +349,99 @@ void main() { await sut.flushCompleter!.future; expect(fixture.mockHub.captureMetricsCalls, isNotEmpty); }); + + test('close flushes everything', () async { + final MetricsAggregator sut = fixture.getSut(); + sut.testEmit(); + sut.testEmit(type: MetricType.gauge); + // We have some metrics, but we don't flush them, yet + await sut.flushCompleter!.future; + expect(fixture.mockHub.captureMetricsCalls, isEmpty); + + // Closing the aggregator. Flush everything + sut.close(); + expect(fixture.mockHub.captureMetricsCalls, isNotEmpty); + expect(sut.buckets, isEmpty); + }); + }); + + group('beforeMetric', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('emits if not set', () async { + final MetricsAggregator sut = fixture.getSut(maxWeight: 4); + sut.testEmit(key: 'key1'); + final metricsCaptured = sut.buckets.values.first.values; + expect(metricsCaptured.length, 1); + expect(metricsCaptured.first.key, 'key1'); + }); + + test('drops if it return false', () async { + final MetricsAggregator sut = fixture.getSut(maxWeight: 4); + fixture.options.beforeMetricCallback = (key, {tags}) => key != 'key2'; + sut.testEmit(key: 'key1'); + sut.testEmit(key: 'key2'); + final metricsCaptured = sut.buckets.values.first.values; + expect(metricsCaptured.length, 1); + expect(metricsCaptured.first.key, 'key1'); + }); + + test('emits if it return true', () async { + final MetricsAggregator sut = fixture.getSut(maxWeight: 4); + fixture.options.beforeMetricCallback = (key, {tags}) => true; + sut.testEmit(key: 'key1'); + sut.testEmit(key: 'key2'); + final metricsCaptured = sut.buckets.values.first.values; + expect(metricsCaptured.length, 2); + expect(metricsCaptured.first.key, 'key1'); + expect(metricsCaptured.last.key, 'key2'); + }); + + test('emits if it throws', () async { + final MetricsAggregator sut = fixture.getSut(maxWeight: 4); + fixture.options.beforeMetricCallback = (key, {tags}) => throw Exception(); + sut.testEmit(key: 'key1'); + sut.testEmit(key: 'key2'); + final metricsCaptured = sut.buckets.values.first.values; + expect(metricsCaptured.length, 2); + expect(metricsCaptured.first.key, 'key1'); + expect(metricsCaptured.last.key, 'key2'); + }); + }); + + group('overweight', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('flush if exceeds maxWeight', () async { + final MetricsAggregator sut = fixture.getSut(maxWeight: 4); + sut.testEmit(type: MetricType.counter, key: 'key1'); + sut.testEmit(type: MetricType.counter, key: 'key2'); + sut.testEmit(type: MetricType.counter, key: 'key3'); + await sut.flushCompleter!.future; + expect(fixture.mockHub.captureMetricsCalls, isEmpty); + // After the 4th metric is emitted, the aggregator flushes immediately + sut.testEmit(type: MetricType.counter, key: 'key4'); + expect(fixture.mockHub.captureMetricsCalls, isNotEmpty); + }); + + test('does not flush if not exceeds maxWeight', () async { + final MetricsAggregator sut = fixture.getSut(maxWeight: 2); + // We are emitting the same metric, so no weight is added + sut.testEmit(type: MetricType.counter); + sut.testEmit(type: MetricType.counter); + sut.testEmit(type: MetricType.counter); + sut.testEmit(type: MetricType.counter); + await sut.flushCompleter!.future; + expect(fixture.mockHub.captureMetricsCalls, isEmpty); + }); }); } @@ -251,26 +457,37 @@ final DateTime mockTimestamp = DateTime.fromMillisecondsSinceEpoch(1); class Fixture { final options = SentryOptions(dsn: fakeDsn); final mockHub = MockHub(); + late final hub = Hub(options); + + Fixture() { + options.tracesSampleRate = 1; + options.enableMetrics = true; + options.enableSpanLocalMetricAggregation = true; + } MetricsAggregator getSut({ + Hub? hub, Duration flushInterval = const Duration(milliseconds: 1), int flushShiftMs = 0, + int maxWeight = 100000, }) { return MetricsAggregator( - hub: mockHub, + hub: hub ?? mockHub, options: options, flushInterval: flushInterval, - flushShiftMs: flushShiftMs); + flushShiftMs: flushShiftMs, + maxWeight: maxWeight); } } extension _MetricsAggregatorUtils on MetricsAggregator { - testIncrement({ + testEmit({ + MetricType type = MetricType.counter, String key = mockKey, double value = mockValue, SentryMeasurementUnit unit = mockUnit, Map tags = mockTags, }) { - increment(key, value, unit, tags); + emit(type, key, value, unit, tags); } } diff --git a/dart/test/metrics/metrics_api_test.dart b/dart/test/metrics/metrics_api_test.dart new file mode 100644 index 0000000000..9345720102 --- /dev/null +++ b/dart/test/metrics/metrics_api_test.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/metrics/metric.dart'; +import 'package:sentry/src/metrics/metrics_api.dart'; +import 'package:sentry/src/sentry_tracer.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; +import '../mocks/mock_hub.dart'; + +void main() { + group('api', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('counter', () async { + MetricsApi api = fixture.getSut(); + api.increment('key'); + api.increment('key', value: 2.4); + + Iterable sentMetrics = + fixture.mockHub.metricsAggregator!.buckets.values.first.values; + expect(sentMetrics.first.type, MetricType.counter); + expect((sentMetrics.first as CounterMetric).value, 3.4); + }); + + test('gauge', () async { + MetricsApi api = fixture.getSut(); + api.gauge('key', value: 1.5); + api.gauge('key', value: 2.4); + + Iterable sentMetrics = + fixture.mockHub.metricsAggregator!.buckets.values.first.values; + expect(sentMetrics.first.type, MetricType.gauge); + expect((sentMetrics.first as GaugeMetric).minimum, 1.5); + expect((sentMetrics.first as GaugeMetric).maximum, 2.4); + expect((sentMetrics.first as GaugeMetric).last, 2.4); + expect((sentMetrics.first as GaugeMetric).sum, 3.9); + expect((sentMetrics.first as GaugeMetric).count, 2); + }); + + test('distribution', () async { + MetricsApi api = fixture.getSut(); + api.distribution('key', value: 1.5); + api.distribution('key', value: 2.4); + + Iterable sentMetrics = + fixture.mockHub.metricsAggregator!.buckets.values.first.values; + expect(sentMetrics.first.type, MetricType.distribution); + expect((sentMetrics.first as DistributionMetric).values, [1.5, 2.4]); + }); + + test('set', () async { + MetricsApi api = fixture.getSut(); + api.set('key', value: 1); + api.set('key', value: 2); + // This is ignored as it's a repeated value + api.set('key', value: 2); + // This adds both an int and a crc32 of the string to the metric + api.set('key', value: 4, stringValue: 'value'); + // No value provided. This does nothing + api.set('key'); + // Empty String provided. This does nothing + api.set('key', stringValue: ''); + + Iterable sentMetrics = + fixture.mockHub.metricsAggregator!.buckets.values.first.values; + expect(sentMetrics.first.type, MetricType.set); + expect((sentMetrics.first as SetMetric).values, {1, 2, 4, 494360628}); + }); + + test('timing emits distribution', () async { + final delay = Duration(milliseconds: 100); + final completer = Completer(); + MetricsApi api = fixture.getSut(); + + // The timing API tries to start a child span + expect(fixture.mockHub.getSpanCalls, 0); + api.timing('key', + function: () => Future.delayed(delay, () => completer.complete())); + expect(fixture.mockHub.getSpanCalls, 1); + + await completer.future; + Iterable sentMetrics = + fixture.mockHub.metricsAggregator!.buckets.values.first.values; + + // The timing API emits a distribution metric + expect(sentMetrics.first.type, MetricType.distribution); + // The default unit is second + expect(sentMetrics.first.unit, DurationSentryMeasurementUnit.second); + // It awaits for the function completion, which means 100 milliseconds in + // this case. Since the unit is second, its value (duration) is >= 0.1 + expect( + (sentMetrics.first as DistributionMetric).values.first >= 0.1, true); + }); + + test('timing starts a span', () async { + final delay = Duration(milliseconds: 100); + final completer = Completer(); + fixture._options.tracesSampleRate = 1; + fixture._options.enableMetrics = true; + MetricsApi api = fixture.getSut(hub: fixture.hub); + + // Start a transaction so that timing api can start a child span + final transaction = fixture.hub.startTransaction( + 'name', + 'operation', + bindToScope: true, + ) as SentryTracer; + expect(transaction.children, isEmpty); + + // Timing starts a span + api.timing('my key', + unit: DurationSentryMeasurementUnit.milliSecond, + function: () => Future.delayed(delay, () => completer.complete())); + final span = transaction.children.first; + expect(span.finished, false); + expect(span.context.operation, 'metric.timing'); + expect(span.context.description, 'my key'); + + // Timing finishes the span when the function is finished, which takes 100 milliseconds + await completer.future; + expect(span.finished, true); + final spanDuration = span.endTimestamp!.difference(span.startTimestamp); + expect(spanDuration.inMilliseconds >= 100, true); + await Future.delayed(Duration()); + + Iterable sentMetrics = + fixture.hub.metricsAggregator!.buckets.values.first.values; + + // The emitted metric value should match the span duration + expect(sentMetrics.first.unit, DurationSentryMeasurementUnit.milliSecond); + // Duration.inMilliseconds returns an int, so we have to assert it + expect((sentMetrics.first as DistributionMetric).values.first.toInt(), + spanDuration.inMilliseconds); + }); + }); +} + +class Fixture { + final _options = SentryOptions(dsn: fakeDsn); + final mockHub = MockHub(); + late final hub = Hub(_options); + + MetricsApi getSut({Hub? hub}) => MetricsApi(hub: hub ?? mockHub); +} diff --git a/dart/test/mocks.dart b/dart/test/mocks.dart index 9b3e7e440f..1c79599a6b 100644 --- a/dart/test/mocks.dart +++ b/dart/test/mocks.dart @@ -98,22 +98,26 @@ final fakeEvent = SentryEvent( ), ); -final fakeMetric = CounterMetric( +final fakeMetric = Metric.fromType( + type: MetricType.counter, value: 4, key: 'key', unit: DurationSentryMeasurementUnit.hour, tags: {'tag1': 'value1', 'tag2': 'value2'}); -final fakeMetric2 = CounterMetric( +final fakeMetric2 = Metric.fromType( + type: MetricType.counter, value: 2, key: 'key', unit: SentryMeasurementUnit.none, tags: {'tag1': 'value1', 'tag2': 'value2'}); -final fakeMetric3 = CounterMetric( +final fakeMetric3 = Metric.fromType( + type: MetricType.counter, value: 2, key: 'key', unit: SentryMeasurementUnit.none, tags: {'tag1': 'value1'}); -final fakeMetric4 = CounterMetric( +final fakeMetric4 = Metric.fromType( + type: MetricType.counter, value: 2, key: 'key2', unit: SentryMeasurementUnit.none, diff --git a/dart/test/mocks/mock_hub.dart b/dart/test/mocks/mock_hub.dart index 0e3e40299f..c076251736 100644 --- a/dart/test/mocks/mock_hub.dart +++ b/dart/test/mocks/mock_hub.dart @@ -1,6 +1,7 @@ import 'package:meta/meta.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/metrics/metric.dart'; +import 'package:sentry/src/metrics/metrics_aggregator.dart'; import '../mocks.dart'; import 'mock_sentry_client.dart'; @@ -21,11 +22,16 @@ class MockHub with NoSuchMethodProvider implements Hub { int getSpanCalls = 0; final _options = SentryOptions(dsn: fakeDsn); + late final MetricsAggregator _metricsAggregator = + MetricsAggregator(options: _options, hub: this); @override @internal SentryOptions get options => _options; + @override + MetricsAggregator? get metricsAggregator => _metricsAggregator; + /// Useful for tests. void reset() { captureEventCalls = []; diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 9939b36185..ca7718ba32 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -8,6 +8,7 @@ import 'package:sentry/src/client_reports/client_report.dart'; import 'package:sentry/src/client_reports/discard_reason.dart'; import 'package:sentry/src/client_reports/discarded_event.dart'; import 'package:sentry/src/client_reports/noop_client_report_recorder.dart'; +import 'package:sentry/src/metrics/metric.dart'; import 'package:sentry/src/sentry_item_type.dart'; import 'package:sentry/src/sentry_stack_trace_factory.dart'; import 'package:sentry/src/sentry_tracer.dart'; @@ -1726,7 +1727,7 @@ void main() { client.close(); expect(client.metricsAggregator, isNotNull); client.metricsAggregator! - .increment('key', 1, SentryMeasurementUnit.none, {}); + .emit(MetricType.counter, 'key', 1, SentryMeasurementUnit.none, {}); // metricsAggregator is closed, so no metrics should be recorded expect(client.metricsAggregator!.buckets, isEmpty); }); diff --git a/dart/test/sentry_options_test.dart b/dart/test/sentry_options_test.dart index 438913ed10..273366e442 100644 --- a/dart/test/sentry_options_test.dart +++ b/dart/test/sentry_options_test.dart @@ -140,17 +140,16 @@ void main() { expect(options.enableMetrics, false); }); - test('default tags for metrics are disabled by default', () { + test('default tags for metrics are enabled by default', () { final options = SentryOptions(dsn: fakeDsn); options.enableMetrics = true; - expect(options.enableDefaultTagsForMetrics, false); + expect(options.enableDefaultTagsForMetrics, true); }); test('default tags for metrics are disabled if metrics are disabled', () { final options = SentryOptions(dsn: fakeDsn); options.enableMetrics = false; - options.enableDefaultTagsForMetrics = true; expect(options.enableDefaultTagsForMetrics, false); }); @@ -162,4 +161,27 @@ void main() { expect(options.enableDefaultTagsForMetrics, true); }); + + test('span local metric aggregation is enabled by default', () { + final options = SentryOptions(dsn: fakeDsn); + options.enableMetrics = true; + + expect(options.enableSpanLocalMetricAggregation, true); + }); + + test('span local metric aggregation is disabled if metrics are disabled', () { + final options = SentryOptions(dsn: fakeDsn); + options.enableMetrics = false; + + expect(options.enableSpanLocalMetricAggregation, false); + }); + + test('span local metric aggregation is enabled if metrics are enabled, too', + () { + final options = SentryOptions(dsn: fakeDsn); + options.enableMetrics = true; + options.enableSpanLocalMetricAggregation = true; + + expect(options.enableSpanLocalMetricAggregation, true); + }); } diff --git a/dart/test/sentry_span_test.dart b/dart/test/sentry_span_test.dart index e878be5cf2..e161ceee2f 100644 --- a/dart/test/sentry_span_test.dart +++ b/dart/test/sentry_span_test.dart @@ -2,6 +2,7 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; import 'mocks/mock_hub.dart'; void main() { @@ -125,11 +126,14 @@ void main() { }); test('span serializes', () async { + fixture.hub.options.enableMetrics = true; + fixture.hub.options.enableSpanLocalMetricAggregation = true; final sut = fixture.getSut(); sut.setTag('test', 'test'); sut.setData('test', 'test'); sut.origin = 'manual'; + sut.localMetricsAggregator?.add(fakeMetric, 0); await sut.finish(status: SpanStatus.aborted()); @@ -141,6 +145,26 @@ void main() { expect(map['tags']['test'], 'test'); expect(map['status'], 'aborted'); expect(map['origin'], 'manual'); + expect(map['_metrics_summary'], isNotNull); + }); + + test('adding a metric after span finish does not serialize', () async { + fixture.hub.options.enableMetrics = true; + fixture.hub.options.enableSpanLocalMetricAggregation = true; + final sut = fixture.getSut(); + await sut.finish(status: SpanStatus.aborted()); + sut.localMetricsAggregator?.add(fakeMetric, 0); + + expect(sut.toJson()['_metrics_summary'], isNull); + }); + + test('adding a metric when option is disabled does not serialize', () async { + fixture.hub.options.enableMetrics = false; + final sut = fixture.getSut(); + sut.localMetricsAggregator?.add(fakeMetric, 0); + await sut.finish(status: SpanStatus.aborted()); + + expect(sut.toJson()['_metrics_summary'], isNull); }); test('finished returns false if not yet', () { @@ -271,6 +295,21 @@ void main() { final sut = fixture.getSut(); expect(sut.origin, 'manual'); }); + + test('localMetricsAggregator is set when option is enabled', () async { + fixture.hub.options.enableMetrics = true; + fixture.hub.options.enableSpanLocalMetricAggregation = true; + final sut = fixture.getSut(); + expect(fixture.hub.options.enableSpanLocalMetricAggregation, true); + expect(sut.localMetricsAggregator, isNotNull); + }); + + test('localMetricsAggregator is null when option is disabled', () async { + fixture.hub.options.enableSpanLocalMetricAggregation = false; + final sut = fixture.getSut(); + expect(fixture.hub.options.enableSpanLocalMetricAggregation, false); + expect(sut.localMetricsAggregator, null); + }); } class Fixture { diff --git a/dart/test/sentry_tracer_test.dart b/dart/test/sentry_tracer_test.dart index e57cb415ea..77914e66d3 100644 --- a/dart/test/sentry_tracer_test.dart +++ b/dart/test/sentry_tracer_test.dart @@ -468,6 +468,21 @@ void main() { expect(sut.measurements.isEmpty, true); }); + + test('localMetricsAggregator is set when option is enabled', () async { + fixture.hub.options.enableMetrics = true; + fixture.hub.options.enableSpanLocalMetricAggregation = true; + final sut = fixture.getSut(); + expect(fixture.hub.options.enableSpanLocalMetricAggregation, true); + expect(sut.localMetricsAggregator, isNotNull); + }); + + test('localMetricsAggregator is null when option is disabled', () async { + fixture.hub.options.enableMetrics = false; + final sut = fixture.getSut(); + expect(fixture.hub.options.enableSpanLocalMetricAggregation, false); + expect(sut.localMetricsAggregator, null); + }); }); group('$SentryBaggageHeader', () { diff --git a/dart/test/sentry_transaction_test.dart b/dart/test/sentry_transaction_test.dart index fb784abd6d..7de0549a13 100644 --- a/dart/test/sentry_transaction_test.dart +++ b/dart/test/sentry_transaction_test.dart @@ -2,6 +2,7 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; import 'mocks/mock_hub.dart'; void main() { @@ -9,6 +10,7 @@ void main() { SentryTracer _createTracer({ bool? sampled = true, + Hub? hub, }) { final context = SentryTransactionContext( 'name', @@ -16,11 +18,16 @@ void main() { samplingDecision: SentryTracesSamplingDecision(sampled!), transactionNameSource: SentryTransactionNameSource.component, ); - return SentryTracer(context, MockHub()); + return SentryTracer(context, hub ?? MockHub()); } test('toJson serializes', () async { - final tracer = _createTracer(); + fixture.options.enableSpanLocalMetricAggregation = true; + fixture.options.enableMetrics = true; + + final tracer = _createTracer(hub: fixture.hub); + tracer.localMetricsAggregator?.add(fakeMetric, 0); + final child = tracer.startChild('child'); await child.finish(); await tracer.finish(); @@ -32,6 +39,7 @@ void main() { expect(map['start_timestamp'], isNotNull); expect(map['spans'], isNotNull); expect(map['transaction_info']['source'], 'component'); + expect(map['_metrics_summary'], isNotNull); }); test('returns finished if it is', () async { @@ -66,9 +74,43 @@ void main() { expect(sut.sampled, false); }); + + test('add a metric to localAggregator adds it to metricSummary', () async { + fixture.options.enableSpanLocalMetricAggregation = true; + fixture.options.enableMetrics = true; + + final tracer = _createTracer(hub: fixture.hub) + ..localMetricsAggregator?.add(fakeMetric, 0); + await tracer.finish(); + + final sut = fixture.getSut(tracer); + expect(sut.metricSummaries, isNotEmpty); + }); + + test('add metric after creation does not add it to metricSummary', () async { + fixture.options.enableSpanLocalMetricAggregation = true; + fixture.options.enableMetrics = true; + + final tracer = _createTracer(hub: fixture.hub); + await tracer.finish(); + final sut = fixture.getSut(tracer); + tracer.localMetricsAggregator?.add(fakeMetric, 0); + + expect(sut.metricSummaries, isEmpty); + }); + + test('metricSummary is null by default', () async { + final tracer = _createTracer(); + await tracer.finish(); + final sut = fixture.getSut(tracer); + expect(sut.metricSummaries, null); + }); } class Fixture { + final SentryOptions options = SentryOptions(dsn: fakeDsn); + late final Hub hub = Hub(options); + SentryTransaction getSut(SentryTracer tracer) { return SentryTransaction(tracer); } diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 73fc7da8c2..5d52ab6080 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -530,15 +531,32 @@ class MainScaffold extends StatelessWidget { ), TooltipButton( onPressed: () async { - Sentry.metrics().increment('key1'); - Sentry.metrics().increment('key3', - unit: DurationSentryMeasurementUnit.minute); - Sentry.metrics().increment('key1', - value: 2, tags: {'myTag': 'myValue', 'myTag2': 'myValue2'}); + final span = Sentry.getSpan() ?? + Sentry.startTransaction( + 'testMetrics', 'span summary example', + bindToScope: true); + Sentry.metrics().increment('increment key', + unit: DurationSentryMeasurementUnit.day); + Sentry.metrics().distribution('distribution key', + value: Random().nextDouble() * 10); + Sentry.metrics().set('set int key', + value: Random().nextInt(100), + tags: {'myTag': 'myValue', 'myTag2': 'myValue2'}); + Sentry.metrics().set('set string key', + stringValue: 'Random n ${Random().nextInt(100)}'); + Sentry.metrics() + .gauge('gauge key', value: Random().nextDouble() * 10); + Sentry.metrics().timing( + 'timing key', + function: () async => await Future.delayed( + Duration(milliseconds: Random().nextInt(100)), + () => span.finish()), + unit: DurationSentryMeasurementUnit.milliSecond, + ); }, text: - 'Demonstrates the metrics. It creates 3 counter metrics and send them to Sentry.', - buttonTitle: 'Counter Metric', + 'Demonstrates the metrics. It creates several metrics and send them to Sentry.', + buttonTitle: 'Metrics', ), if (UniversalPlatform.isIOS || UniversalPlatform.isMacOS) const CocoaExample(), diff --git a/flutter/test/mocks.mocks.dart b/flutter/test/mocks.mocks.dart index d747a3889f..ee81d44430 100644 --- a/flutter/test/mocks.mocks.dart +++ b/flutter/test/mocks.mocks.dart @@ -640,6 +640,7 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { List<_i3.SentryThread>? threads, String? type, Map? measurements, + Map>? metricSummaries, _i3.SentryTransactionInfo? transactionInfo, }) => (super.noSuchMethod( @@ -674,6 +675,7 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { #threads: threads, #type: type, #measurements: measurements, + #metricSummaries: metricSummaries, #transactionInfo: transactionInfo, }, ),