From 84bc635491698e6b3f3cfe8b60c67ca9b489b6b6 Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 9 Apr 2024 17:12:01 +0200 Subject: [PATCH] Metrics Part 1 - Add Counter Metric API (#1940) Add Counter Metric API (part 1) (#1940) * added Counter Metric API * added Metric Aggregation, with hub/client integration and close * added metric tag normalization * added send of statsd envelope type Add other metric types and weight (part 2) (#1949) * added crc32_utils.dart, taken from archive library * added Gauge, Distribution and Set metrics * added weight to Metrics and auto flush when weight is too much Add timing metric and beforeMetric callback (part 3) (#1954) * added SentryOptions.beforeMetricCallback * added beforeMetricCallback logic in metrics_aggregator.dart * added timing metric api with span auto start * timing api uses span duration as value for the emitted metric if possible Add metrics span summary (part 4) (#1958) * added local_metrics_aggregator.dart to spans * metrics_aggregator.dart now adds to current span's localMetricsAggregator * added metric_summary.dart * added metricSummary to spans and transaction JSONs Add rate limit (part 5) (#1973) * added metric_bucket data category for rate limits * updated metric normalization rules * added rate limit for metrics --- CHANGELOG.md | 15 + dart/lib/src/hub.dart | 48 ++ dart/lib/src/hub_adapter.dart | 15 + .../src/metrics/local_metrics_aggregator.dart | 37 ++ dart/lib/src/metrics/metric.dart | 291 +++++++++++ dart/lib/src/metrics/metrics_aggregator.dart | 205 ++++++++ dart/lib/src/metrics/metrics_api.dart | 180 +++++++ dart/lib/src/noop_hub.dart | 23 +- dart/lib/src/noop_sentry_client.dart | 14 + dart/lib/src/noop_sentry_span.dart | 4 + dart/lib/src/protocol.dart | 1 + dart/lib/src/protocol/metric_summary.dart | 43 ++ dart/lib/src/protocol/sentry_span.dart | 20 + dart/lib/src/protocol/sentry_transaction.dart | 18 + dart/lib/src/sentry.dart | 4 + dart/lib/src/sentry_client.dart | 29 +- dart/lib/src/sentry_envelope.dart | 21 +- dart/lib/src/sentry_envelope_item.dart | 42 +- dart/lib/src/sentry_item_type.dart | 1 + dart/lib/src/sentry_options.dart | 53 ++ dart/lib/src/sentry_span_interface.dart | 4 + dart/lib/src/sentry_tracer.dart | 5 + dart/lib/src/transport/data_category.dart | 5 + dart/lib/src/transport/rate_limit.dart | 4 +- dart/lib/src/transport/rate_limit_parser.dart | 13 +- dart/lib/src/transport/rate_limiter.dart | 9 + dart/lib/src/utils/crc32_utils.dart | 313 +++++++++++ dart/test/hub_test.dart | 80 ++- .../local_metrics_aggregator_test.dart | 40 ++ dart/test/metrics/metric_test.dart | 315 +++++++++++ .../test/metrics/metrics_aggregator_test.dart | 493 ++++++++++++++++++ dart/test/metrics/metrics_api_test.dart | 150 ++++++ dart/test/mocks.dart | 32 ++ dart/test/mocks/mock_hub.dart | 16 + dart/test/mocks/mock_sentry_client.dart | 14 + dart/test/mocks/mock_transport.dart | 15 +- .../test/protocol/rate_limit_parser_test.dart | 39 ++ dart/test/protocol/rate_limiter_test.dart | 112 ++++ dart/test/sentry_client_test.dart | 64 ++- dart/test/sentry_envelope_item_test.dart | 46 ++ dart/test/sentry_envelope_test.dart | 57 ++ dart/test/sentry_options_test.dart | 51 ++ dart/test/sentry_span_test.dart | 39 ++ dart/test/sentry_test.dart | 28 +- dart/test/sentry_tracer_test.dart | 15 + dart/test/sentry_transaction_test.dart | 46 +- flutter/example/lib/main.dart | 31 ++ flutter/test/mocks.mocks.dart | 2 + 48 files changed, 3021 insertions(+), 81 deletions(-) create mode 100644 dart/lib/src/metrics/local_metrics_aggregator.dart create mode 100644 dart/lib/src/metrics/metric.dart create mode 100644 dart/lib/src/metrics/metrics_aggregator.dart create mode 100644 dart/lib/src/metrics/metrics_api.dart create mode 100644 dart/lib/src/protocol/metric_summary.dart create mode 100644 dart/lib/src/utils/crc32_utils.dart create mode 100644 dart/test/metrics/local_metrics_aggregator_test.dart create mode 100644 dart/test/metrics/metric_test.dart create mode 100644 dart/test/metrics/metrics_aggregator_test.dart create mode 100644 dart/test/metrics/metrics_api_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1930e71df6..57c0ba848b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +### Features + +- Experimental: Add support for Sentry Developer Metrics ([#1940](https://github.com/getsentry/sentry-dart/pull/1940), [#1949](https://github.com/getsentry/sentry-dart/pull/1949), [#1954](https://github.com/getsentry/sentry-dart/pull/1954), [#1958](https://github.com/getsentry/sentry-dart/pull/1958)) + Use the Metrics API to track processing time, download sizes, user signups, and conversion rates and correlate them back to tracing data in order to get deeper insights and solve issues faster. Our API supports counters, distributions, sets, gauges and timers, and it's easy to get started: + ```dart + Sentry.metrics() + .increment( + 'button_login_click', // key + value: 1.0, + unit: null, + tags: {"provider": "e-mail"} + ); + ``` + To learn more about Sentry Developer Metrics, head over to our [Dart](https://docs.sentry.io/platforms/dart/metrics/) and [Flutter](https://docs.sentry.io/platforms/flutter/metrics/) docs page. + ### Dependencies - Expand `package_info_plus` version range to `6.0.0` ([#1948](https://github.com/getsentry/sentry-dart/pull/1948)) diff --git a/dart/lib/src/hub.dart b/dart/lib/src/hub.dart index 42291d1c87..a8e06e28ed 100644 --- a/dart/lib/src/hub.dart +++ b/dart/lib/src/hub.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'dart:collection'; import 'package:meta/meta.dart'; +import 'metrics/metric.dart'; +import 'metrics/metrics_aggregator.dart'; +import 'metrics/metrics_api.dart'; import 'profiling.dart'; import 'propagation_context.dart'; import 'transport/data_category.dart'; @@ -38,6 +41,14 @@ class Hub { late final _WeakMap _throwableToSpan; + late final MetricsApi _metricsApi; + + @internal + MetricsApi get metricsApi => _metricsApi; + + @internal + MetricsAggregator? get metricsAggregator => _peek().client.metricsAggregator; + factory Hub(SentryOptions options) { _validateOptions(options); @@ -49,6 +60,7 @@ class Hub { _stack.add(_StackItem(_getClient(_options), Scope(_options))); _isEnabled = true; _throwableToSpan = _WeakMap(_options); + _metricsApi = MetricsApi(hub: this); } static void _validateOptions(SentryOptions options) { @@ -554,6 +566,42 @@ class Hub { return sentryId; } + @internal + Future captureMetrics( + Map> metricsBuckets) async { + var sentryId = SentryId.empty(); + + if (!_isEnabled) { + _options.logger( + SentryLevel.warning, + "Instance is disabled and this 'captureMetrics' call is a no-op.", + ); + } else if (!_options.enableMetrics) { + _options.logger( + SentryLevel.info, + "Metrics are disabled and this 'captureMetrics' call is a no-op.", + ); + } else if (metricsBuckets.isEmpty) { + _options.logger( + SentryLevel.info, + "Metrics are empty and this 'captureMetrics' call is a no-op.", + ); + } else { + final item = _peek(); + try { + sentryId = await item.client.captureMetrics(metricsBuckets); + } catch (exception, stackTrace) { + _options.logger( + SentryLevel.error, + 'Error while capturing metrics.', + exception: exception, + stackTrace: stackTrace, + ); + } + } + return sentryId; + } + @internal void setSpanContext( dynamic throwable, diff --git a/dart/lib/src/hub_adapter.dart b/dart/lib/src/hub_adapter.dart index 8a9107ae54..6b2ece3c53 100644 --- a/dart/lib/src/hub_adapter.dart +++ b/dart/lib/src/hub_adapter.dart @@ -4,6 +4,9 @@ import 'package:meta/meta.dart'; import 'hint.dart'; import 'hub.dart'; +import 'metrics/metric.dart'; +import 'metrics/metrics_aggregator.dart'; +import 'metrics/metrics_api.dart'; import 'profiling.dart'; import 'protocol.dart'; import 'scope.dart'; @@ -23,6 +26,10 @@ class HubAdapter implements Hub { @internal SentryOptions get options => Sentry.currentHub.options; + @override + @internal + MetricsApi get metricsApi => Sentry.currentHub.metricsApi; + factory HubAdapter() { return _instance; } @@ -181,4 +188,12 @@ class HubAdapter implements Hub { @override Scope get scope => Sentry.currentHub.scope; + + @override + Future captureMetrics(Map> metricsBuckets) => + Sentry.currentHub.captureMetrics(metricsBuckets); + + @override + MetricsAggregator? get metricsAggregator => + Sentry.currentHub.metricsAggregator; } 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 new file mode 100644 index 0000000000..fdea81cbf4 --- /dev/null +++ b/dart/lib/src/metrics/metric.dart @@ -0,0 +1,291 @@ +import 'dart:math'; + +import 'package:meta/meta.dart'; + +import '../../sentry.dart'; + +final RegExp unitRegex = RegExp('[^\\w]+'); +final RegExp nameRegex = RegExp('[^\\w-.]+'); +final RegExp tagKeyRegex = RegExp('[^\\w-./]+'); + +/// Base class for metrics. +/// Each metric is identified by a [key]. Its [type] describes its behaviour. +/// A [unit] (defaults to [SentryMeasurementUnit.none]) describes the values +/// being tracked. Optional [tags] can be added. The [timestamp] is the time +/// when the metric was emitted. +@internal +abstract class Metric { + final MetricType type; + final String key; + final SentryMeasurementUnit unit; + final Map tags; + + Metric({ + required this.type, + required this.key, + required this.unit, + 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(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(); + + /// Encodes the metric in the statsd format + /// See github.com/statsd/statsd#usage and + /// getsentry.github.io/relay/relay_metrics/index.html + /// for more details about the format. + /// + /// Example format: key@none:1|c|#myTag:myValue|T1710844170 + /// key@unit:value1:value2|type|#tagKey1:tagValue1,tagKey2:tagValue2,|TbucketKey + /// + /// [bucketKey] is the key of the metric bucket that will be sent to Sentry, + /// and it's appended at the end of the encoded metric. + String encodeToStatsd(int bucketKey) { + final buffer = StringBuffer(); + buffer.write(_sanitizeName(key)); + buffer.write("@"); + + final sanitizeUnitName = _sanitizeUnit(unit.name); + buffer.write(sanitizeUnitName); + + for (final value in _serializeValue()) { + buffer.write(":"); + buffer.write(value.toString()); + } + + buffer.write("|"); + buffer.write(type.statsdType); + + if (tags.isNotEmpty) { + buffer.write("|#"); + final serializedTags = tags.entries + .map((tag) => + '${_sanitizeTagKey(tag.key)}:${_sanitizeTagValue(tag.value)}') + .join(','); + buffer.write(serializedTags); + } + + buffer.write("|T"); + buffer.write(bucketKey); + + return buffer.toString(); + } + + /// Return a key created by [key], [type], [unit] and [tags]. + /// This key should be used to retrieve the metric to update in aggregation. + String getCompositeKey() { + final String serializedTags = tags.entries.map((e) { + // We escape the ',' from the key and the value, as we will join the tags + // with a ',' to create the composite key. + String escapedKey = e.key.replaceAll(',', '\\,'); + String escapedValue = e.value.replaceAll(',', '\\,'); + return '$escapedKey=$escapedValue'; + }).join(','); + + 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 _sanitizeName(String input) => input.replaceAll(nameRegex, '_'); + + /// Remove forbidden characters from the tag value. + String _sanitizeTagKey(String input) => input.replaceAll(tagKeyRegex, ''); + + /// Remove forbidden characters from the metric unit. + String _sanitizeUnit(String input) => input.replaceAll(unitRegex, ''); + + String _sanitizeTagValue(String input) { + // see https://develop.sentry.dev/sdk/metrics/#tag-values-replacement-map + // Line feed -> \n + // Carriage return -> \r + // Tab -> \t + // Backslash -> \\ + // Pipe -> \\u{7c} + // Comma -> \\u{2c} + final buffer = StringBuffer(); + for (int i = 0; i < input.length; i++) { + final ch = input[i]; + if (ch == '\n') { + buffer.write("\\n"); + } else if (ch == '\r') { + buffer.write("\\r"); + } else if (ch == '\t') { + buffer.write("\\t"); + } else if (ch == '\\') { + buffer.write("\\\\"); + } else if (ch == '|') { + buffer.write("\\u{7c}"); + } else if (ch == ',') { + buffer.write("\\u{2c}"); + } else { + buffer.write(ch); + } + } + return buffer.toString(); + } +} + +/// Metric [MetricType.counter] that tracks a value that can only be incremented. +@internal +class CounterMetric extends Metric { + num value; + + CounterMetric._({ + required this.value, + required super.key, + required super.unit, + required super.tags, + }) : super(type: MetricType.counter); + + @override + 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'), + gauge('g'), + distribution('d'), + set('s'); + + final String statsdType; + + const MetricType(this.statsdType); +} diff --git a/dart/lib/src/metrics/metrics_aggregator.dart b/dart/lib/src/metrics/metrics_aggregator.dart new file mode 100644 index 0000000000..2dd9cb2c91 --- /dev/null +++ b/dart/lib/src/metrics/metrics_aggregator.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:math'; + +import 'package:meta/meta.dart'; + +import '../../sentry.dart'; +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; + 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 + /// time period. The Value is a map of the metrics, each of which has a key + /// that uniquely identifies it within the time period. + /// The [SplayTreeMap] is used so that bucket keys are ordered. + final SplayTreeMap> _buckets = SplayTreeMap(); + + MetricsAggregator({ + required SentryOptions options, + Hub? hub, + @visibleForTesting Duration? flushInterval, + @visibleForTesting int? flushShiftMs, + @visibleForTesting int? maxWeight, + }) : _options = options, + _hub = hub ?? HubAdapter(), + _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 emit( + MetricType metricType, + String key, + num value, + SentryMeasurementUnit unit, + Map tags, + ) { + if (_isClosed) { + 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 = 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. + bucket.update( + metric.getCompositeKey(), + (m) => m..add(value), + 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(); + } + + void _scheduleFlush() { + if (!_isClosed && _buckets.isNotEmpty) { + if (_isOverWeight()) { + _flushTimer?.cancel(); + _flush(false); + return; + } + if (_flushTimer?.isActive != true) { + _flushCompleter = Completer(); + _flushTimer = Timer(_flushInterval, () => _flush(false)); + } + } + } + + bool _isOverWeight() => _totalWeight >= _maxWeight; + + int getBucketWeight(final Map bucket) { + int weight = 0; + for (final metric in bucket.values) { + weight += metric.getWeight(); + } + return weight; + } + + /// 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 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); + } + + // 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(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( + seconds: _rollupInSeconds, + milliseconds: _flushShiftMs, + )); + final maxKeyToFlush = _getBucketKey(maxTimestampToFlush); + + // takeWhile works because we use a SplayTreeMap and keys are ordered. + // toList() is needed because takeWhile is lazy and we want to remove items + // from the buckets with these keys. + return _buckets.keys.takeWhile((value) => value <= maxKeyToFlush).toList(); + } + + /// The timestamp of the bucket, rounded down to the nearest RollupInSeconds. + int _getBucketKey(DateTime timestamp) { + final seconds = timestamp.millisecondsSinceEpoch ~/ 1000; + return (seconds ~/ _rollupInSeconds) * _rollupInSeconds; + } + + @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 new file mode 100644 index 0000000000..1ef8df77c3 --- /dev/null +++ b/dart/lib/src/metrics/metrics_api.dart @@ -0,0 +1,180 @@ +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 { + MetricsApi({Hub? hub}) : _hub = hub ?? HubAdapter(); + + 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?.emit( + MetricType.counter, + key, + value, + unit ?? SentryMeasurementUnit.none, + _enrichWithDefaultTags(tags), + ); + } + + /// 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. + Map _enrichWithDefaultTags(Map? userTags) { + // We create another map, in case the userTags is unmodifiable. + final Map tags = Map.from(userTags ?? {}); + if (!_hub.options.enableDefaultTagsForMetrics) { + return tags; + } + // Enrich tags with default values (without overwriting user values) + _putIfAbsentIfNotNull(tags, 'release', _hub.options.release); + _putIfAbsentIfNotNull(tags, 'environment', _hub.options.environment); + _putIfAbsentIfNotNull(tags, 'transaction', _hub.scope.transaction); + return tags; + } + + /// Call [map.putIfAbsent] with [key] and [value] if [value] is not null. + _putIfAbsentIfNotNull(Map map, K key, V? value) { + if (value != null) { + 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_hub.dart b/dart/lib/src/noop_hub.dart index 06d31e7da2..30f68ca895 100644 --- a/dart/lib/src/noop_hub.dart +++ b/dart/lib/src/noop_hub.dart @@ -4,6 +4,9 @@ import 'package:meta/meta.dart'; import 'hint.dart'; import 'hub.dart'; +import 'metrics/metric.dart'; +import 'metrics/metrics_aggregator.dart'; +import 'metrics/metrics_api.dart'; import 'profiling.dart'; import 'protocol.dart'; import 'scope.dart'; @@ -13,16 +16,24 @@ import 'sentry_user_feedback.dart'; import 'tracing.dart'; class NoOpHub implements Hub { - NoOpHub._(); + NoOpHub._() { + _metricsApi = MetricsApi(hub: this); + } static final NoOpHub _instance = NoOpHub._(); final _options = SentryOptions.empty(); + late final MetricsApi _metricsApi; + @override @internal SentryOptions get options => _options; + @override + @internal + MetricsApi get metricsApi => _metricsApi; + factory NoOpHub() { return _instance; } @@ -131,4 +142,14 @@ class NoOpHub implements Hub { @override Scope get scope => Scope(_options); + + @override + @internal + Future captureMetrics( + Map> metricsBuckets) async => + SentryId.empty(); + + @override + @internal + MetricsAggregator? get metricsAggregator => null; } diff --git a/dart/lib/src/noop_sentry_client.dart b/dart/lib/src/noop_sentry_client.dart index 593d63f74b..5a8c04bbae 100644 --- a/dart/lib/src/noop_sentry_client.dart +++ b/dart/lib/src/noop_sentry_client.dart @@ -1,6 +1,10 @@ import 'dart:async'; +import 'package:meta/meta.dart'; + import 'hint.dart'; +import 'metrics/metric.dart'; +import 'metrics/metrics_aggregator.dart'; import 'protocol.dart'; import 'scope.dart'; import 'sentry_client.dart'; @@ -63,4 +67,14 @@ class NoOpSentryClient implements SentryClient { SentryTraceContextHeader? traceContext, }) async => SentryId.empty(); + + @override + @internal + Future captureMetrics( + Map> metricsBuckets) async => + SentryId.empty(); + + @override + @internal + MetricsAggregator? get metricsAggregator => null; } 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.dart b/dart/lib/src/sentry.dart index 88fdd42996..1873cd6308 100644 --- a/dart/lib/src/sentry.dart +++ b/dart/lib/src/sentry.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'metrics/metrics_api.dart'; import 'run_zoned_guarded_integration.dart'; import 'event_processor/enricher/enricher_event_processor.dart'; import 'environment/environment_variables.dart'; @@ -306,6 +307,9 @@ class Sentry { /// Gets the current active transaction or span bound to the scope. static ISentrySpan? getSpan() => _hub.getSpan(); + /// Gets access to the metrics API for the current hub. + static MetricsApi metrics() => _hub.metricsApi; + @internal static Hub get currentHub => _hub; } diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 9761a4b349..47c6fc0d8b 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:math'; import 'package:meta/meta.dart'; +import 'metrics/metric.dart'; +import 'metrics/metrics_aggregator.dart'; import 'sentry_baggage.dart'; import 'sentry_attachment/sentry_attachment.dart'; @@ -40,6 +42,8 @@ class SentryClient { final Random? _random; + late final MetricsAggregator? _metricsAggregator; + static final _sentryId = Future.value(SentryId.empty()); SentryExceptionFactory get _exceptionFactory => _options.exceptionFactory; @@ -63,7 +67,13 @@ class SentryClient { /// Instantiates a client using [SentryOptions] SentryClient._(this._options) - : _random = _options.sampleRate == null ? null : Random(); + : _random = _options.sampleRate == null ? null : Random(), + _metricsAggregator = _options.enableMetrics + ? MetricsAggregator(options: _options) + : null; + + @internal + MetricsAggregator? get metricsAggregator => _metricsAggregator; /// Reports an [event] to Sentry.io. Future captureEvent( @@ -379,7 +389,22 @@ class SentryClient { return _attachClientReportsAndSend(envelope); } - void close() => _options.httpClient.close(); + /// Reports the [metricsBuckets] to Sentry.io. + Future captureMetrics( + Map> metricsBuckets) async { + final envelope = SentryEnvelope.fromMetrics( + metricsBuckets, + _options.sdk, + dsn: _options.dsn, + ); + final id = await _attachClientReportsAndSend(envelope); + return id ?? SentryId.empty(); + } + + void close() { + _metricsAggregator?.close(); + _options.httpClient.close(); + } Future _runBeforeSend( SentryEvent event, { diff --git a/dart/lib/src/sentry_envelope.dart b/dart/lib/src/sentry_envelope.dart index eb62aeec47..7835b7859e 100644 --- a/dart/lib/src/sentry_envelope.dart +++ b/dart/lib/src/sentry_envelope.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'client_reports/client_report.dart'; +import 'metrics/metric.dart'; import 'protocol.dart'; import 'sentry_item_type.dart'; import 'sentry_options.dart'; @@ -20,7 +21,7 @@ class SentryEnvelope { /// All items contained in the envelope. final List items; - /// Create an [SentryEnvelope] with containing one [SentryEnvelopeItem] which holds the [SentryEvent] data. + /// Create a [SentryEnvelope] containing one [SentryEnvelopeItem] which holds the [SentryEvent] data. factory SentryEnvelope.fromEvent( SentryEvent event, SdkVersion sdkVersion, { @@ -59,7 +60,7 @@ class SentryEnvelope { ); } - /// Create an [SentryEnvelope] with containing one [SentryEnvelopeItem] which holds the [SentryTransaction] data. + /// Create a [SentryEnvelope] containing one [SentryEnvelopeItem] which holds the [SentryTransaction] data. factory SentryEnvelope.fromTransaction( SentryTransaction transaction, SdkVersion sdkVersion, { @@ -82,6 +83,22 @@ class SentryEnvelope { ); } + /// Create a [SentryEnvelope] containing one [SentryEnvelopeItem] which holds the [Metric] data. + factory SentryEnvelope.fromMetrics( + Map> metricsBuckets, + SdkVersion sdkVersion, { + String? dsn, + }) { + return SentryEnvelope( + SentryEnvelopeHeader( + SentryId.newId(), + sdkVersion, + dsn: dsn, + ), + [SentryEnvelopeItem.fromMetrics(metricsBuckets)], + ); + } + /// Stream binary data representation of `Envelope` file encoded. Stream> envelopeStream(SentryOptions options) async* { yield utf8JsonEncoder.convert(header.toJson()); diff --git a/dart/lib/src/sentry_envelope_item.dart b/dart/lib/src/sentry_envelope_item.dart index 5e38a7123d..019e1e0a08 100644 --- a/dart/lib/src/sentry_envelope_item.dart +++ b/dart/lib/src/sentry_envelope_item.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'client_reports/client_report.dart'; +import 'metrics/metric.dart'; import 'protocol.dart'; import 'utils.dart'; import 'sentry_attachment/sentry_attachment.dart'; @@ -13,7 +14,7 @@ import 'sentry_user_feedback.dart'; class SentryEnvelopeItem { SentryEnvelopeItem(this.header, this.dataFactory); - /// Creates an [SentryEnvelopeItem] which sends [SentryTransaction]. + /// Creates a [SentryEnvelopeItem] which sends [SentryTransaction]. factory SentryEnvelopeItem.fromTransaction(SentryTransaction transaction) { final cachedItem = _CachedItem(() async => utf8JsonEncoder.convert(transaction.toJson())); @@ -27,7 +28,7 @@ class SentryEnvelopeItem { } factory SentryEnvelopeItem.fromAttachment(SentryAttachment attachment) { - final cachedItem = _CachedItem(() => attachment.bytes); + final cachedItem = _CachedItem(() async => attachment.bytes); final header = SentryEnvelopeItemHeader( SentryItemType.attachment, @@ -39,7 +40,7 @@ class SentryEnvelopeItem { return SentryEnvelopeItem(header, cachedItem.getData); } - /// Create an [SentryEnvelopeItem] which sends [SentryUserFeedback]. + /// Create a [SentryEnvelopeItem] which sends [SentryUserFeedback]. factory SentryEnvelopeItem.fromUserFeedback(SentryUserFeedback feedback) { final cachedItem = _CachedItem(() async => utf8JsonEncoder.convert(feedback.toJson())); @@ -52,7 +53,7 @@ class SentryEnvelopeItem { return SentryEnvelopeItem(header, cachedItem.getData); } - /// Create an [SentryEnvelopeItem] which holds the [SentryEvent] data. + /// Create a [SentryEnvelopeItem] which holds the [SentryEvent] data. factory SentryEnvelopeItem.fromEvent(SentryEvent event) { final cachedItem = _CachedItem(() async => utf8JsonEncoder.convert(event.toJson())); @@ -67,7 +68,7 @@ class SentryEnvelopeItem { ); } - /// Create an [SentryEnvelopeItem] which holds the [ClientReport] data. + /// Create a [SentryEnvelopeItem] which holds the [ClientReport] data. factory SentryEnvelopeItem.fromClientReport(ClientReport clientReport) { final cachedItem = _CachedItem(() async => utf8JsonEncoder.convert(clientReport.toJson())); @@ -82,6 +83,28 @@ class SentryEnvelopeItem { ); } + /// Creates a [SentryEnvelopeItem] which holds several [Metric] data. + factory SentryEnvelopeItem.fromMetrics(Map> buckets) { + final cachedItem = _CachedItem(() async { + final statsd = StringBuffer(); + // Encode all metrics of a bucket in statsd format, using the bucket key, + // which is the timestamp of the bucket. + for (final bucket in buckets.entries) { + final encodedMetrics = + bucket.value.map((metric) => metric.encodeToStatsd(bucket.key)); + statsd.write(encodedMetrics.join('\n')); + } + return utf8.encode(statsd.toString()); + }); + + final header = SentryEnvelopeItemHeader( + SentryItemType.statsd, + cachedItem.getDataLength, + contentType: 'application/octet-stream', + ); + return SentryEnvelopeItem(header, cachedItem.getData); + } + /// Header with info about type and length of data in bytes. final SentryEnvelopeItemHeader header; @@ -109,16 +132,11 @@ class SentryEnvelopeItem { class _CachedItem { _CachedItem(this._dataFactory); - final FutureOr> Function() _dataFactory; + final Future> Function() _dataFactory; List? _data; Future> getData() async { - final data = _dataFactory(); - if (data is Future>) { - _data ??= await data; - } else { - _data ??= data; - } + _data ??= await _dataFactory(); return _data!; } diff --git a/dart/lib/src/sentry_item_type.dart b/dart/lib/src/sentry_item_type.dart index 6215cbb78f..18dbc8f4ad 100644 --- a/dart/lib/src/sentry_item_type.dart +++ b/dart/lib/src/sentry_item_type.dart @@ -5,5 +5,6 @@ class SentryItemType { static const String transaction = 'transaction'; static const String clientReport = 'client_report'; static const String profile = 'profile'; + static const String statsd = 'statsd'; static const String unknown = '__unknown__'; } diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 1de3a49d0f..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; @@ -380,6 +384,48 @@ class SentryOptions { /// are set. bool? enableTracing; + /// Enables sending developer metrics to Sentry. + /// More on https://develop.sentry.dev/delightful-developer-metrics/. + /// Example: + /// ```dart + /// Sentry.metrics.counter('myMetric'); + /// ``` + @experimental + bool enableMetrics = false; + + @experimental + 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 + /// Currently adds release, environment and transaction name. + @experimental + bool get enableDefaultTagsForMetrics => + enableMetrics && _enableDefaultTagsForMetrics; + + /// Enables enriching metrics with default tags. Requires [enableMetrics]. + /// More on https://develop.sentry.dev/delightful-developer-metrics/sending-metrics-sdk/#automatic-tags-extraction + /// Currently adds release, environment and transaction name. + @experimental + 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 @@ -500,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/transport/data_category.dart b/dart/lib/src/transport/data_category.dart index 1843acfacb..f21da18ed7 100644 --- a/dart/lib/src/transport/data_category.dart +++ b/dart/lib/src/transport/data_category.dart @@ -7,6 +7,7 @@ enum DataCategory { transaction, attachment, security, + metricBucket, unknown } @@ -27,6 +28,8 @@ extension DataCategoryExtension on DataCategory { return DataCategory.attachment; case 'security': return DataCategory.security; + case 'metric_bucket': + return DataCategory.metricBucket; } return DataCategory.unknown; } @@ -47,6 +50,8 @@ extension DataCategoryExtension on DataCategory { return 'attachment'; case DataCategory.security: return 'security'; + case DataCategory.metricBucket: + return 'metric_bucket'; case DataCategory.unknown: return 'unknown'; } diff --git a/dart/lib/src/transport/rate_limit.dart b/dart/lib/src/transport/rate_limit.dart index 8f41b91d81..00284a3ba7 100644 --- a/dart/lib/src/transport/rate_limit.dart +++ b/dart/lib/src/transport/rate_limit.dart @@ -2,8 +2,10 @@ import 'data_category.dart'; /// `RateLimit` containing limited `DataCategory` and duration in milliseconds. class RateLimit { - RateLimit(this.category, this.duration); + RateLimit(this.category, this.duration, {List? namespaces}) + : namespaces = (namespaces?..removeWhere((e) => e.isEmpty)) ?? []; final DataCategory category; final Duration duration; + final List namespaces; } diff --git a/dart/lib/src/transport/rate_limit_parser.dart b/dart/lib/src/transport/rate_limit_parser.dart index 63f4f179d1..03391e0210 100644 --- a/dart/lib/src/transport/rate_limit_parser.dart +++ b/dart/lib/src/transport/rate_limit_parser.dart @@ -14,6 +14,7 @@ class RateLimitParser { if (rateLimitHeader == null) { return []; } + // example: 2700:metric_bucket:organization:quota_exceeded:custom,... final rateLimits = []; final rateLimitValues = rateLimitHeader.toLowerCase().split(','); for (final rateLimitValue in rateLimitValues) { @@ -30,7 +31,17 @@ class RateLimitParser { final categoryValues = allCategories.split(';'); for (final categoryValue in categoryValues) { final category = DataCategoryExtension.fromStringValue(categoryValue); - if (category != DataCategory.unknown) { + // Metric buckets rate limit can have namespaces + if (category == DataCategory.metricBucket) { + final namespaces = durationAndCategories.length > 4 + ? durationAndCategories[4] + : null; + rateLimits.add(RateLimit( + category, + duration, + namespaces: namespaces?.trim().split(','), + )); + } else if (category != DataCategory.unknown) { rateLimits.add(RateLimit(category, duration)); } } diff --git a/dart/lib/src/transport/rate_limiter.dart b/dart/lib/src/transport/rate_limiter.dart index 6d4d3c3e9a..ef9b168edd 100644 --- a/dart/lib/src/transport/rate_limiter.dart +++ b/dart/lib/src/transport/rate_limiter.dart @@ -64,6 +64,11 @@ class RateLimiter { } for (final rateLimit in rateLimits) { + if (rateLimit.category == DataCategory.metricBucket && + rateLimit.namespaces.isNotEmpty && + !rateLimit.namespaces.contains('custom')) { + continue; + } _applyRetryAfterOnlyIfLonger( rateLimit.category, DateTime.fromMillisecondsSinceEpoch( @@ -111,6 +116,10 @@ class RateLimiter { return DataCategory.attachment; case 'transaction': return DataCategory.transaction; + // The envelope item type used for metrics is statsd, + // whereas the client report category is metric_bucket + case 'statsd': + return DataCategory.metricBucket; default: return DataCategory.unknown; } 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/hub_test.dart b/dart/test/hub_test.dart index 31698329d6..8dcc622654 100644 --- a/dart/test/hub_test.dart +++ b/dart/test/hub_test.dart @@ -81,6 +81,21 @@ void main() { expect(fixture.client.captureMessageCalls.first.scope, isNotNull); }); + test('should capture metrics', () async { + final hub = fixture.getSut(); + await hub.captureMetrics(fakeMetrics); + + expect(fixture.client.captureMetricsCalls.length, 1); + expect( + fixture.client.captureMetricsCalls.first.values, + [ + [fakeMetric], + [fakeMetric, fakeMetric2], + [fakeMetric, fakeMetric3, fakeMetric4], + ], + ); + }); + test('should save the lastEventId', () async { final hub = fixture.getSut(); final event = SentryEvent(); @@ -155,7 +170,7 @@ void main() { }); }); - group('Hub captures', () { + group('Hub transactions', () { late Fixture fixture; setUp(() { @@ -376,6 +391,14 @@ void main() { expect( fixture.client.captureTransactionCalls.first.traceContext, context); }); + }); + + group('Hub profiles', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); test('profiler is not started by default', () async { final hub = fixture.getSut(); @@ -432,14 +455,6 @@ void main() { verify(profiler.dispose()).called(1); verifyNever(profiler.finishFor(any)); }); - - test('returns scope', () async { - final hub = fixture.getSut(); - - final scope = hub.scope; - - expect(scope, isNotNull); - }); }); group('Hub scope', () { @@ -452,6 +467,11 @@ void main() { hub.bindClient(client); }); + test('returns scope', () async { + final scope = hub.scope; + expect(scope, isNotNull); + }); + test('should configure its scope', () async { await hub.configureScope((Scope scope) { scope @@ -681,6 +701,44 @@ void main() { expect(fixture.recorder.category, DataCategory.transaction); }); }); + + group('Metrics', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('should not capture metrics if enableMetric is false', () async { + final hub = fixture.getSut(enableMetrics: false, debug: true); + await hub.captureMetrics(fakeMetrics); + + expect(fixture.client.captureMetricsCalls, isEmpty); + expect(fixture.loggedMessage, + 'Metrics are disabled and this \'captureMetrics\' call is a no-op.'); + }); + + test('should not capture metrics if hub is closed', () async { + final hub = fixture.getSut(debug: true); + await hub.close(); + + expect(hub.isEnabled, false); + await hub.captureMetrics(fakeMetrics); + expect(fixture.loggedMessage, + 'Instance is disabled and this \'captureMetrics\' call is a no-op.'); + + expect(fixture.client.captureMetricsCalls, isEmpty); + }); + + test('should not capture metrics if metrics are empty', () async { + final hub = fixture.getSut(debug: true); + await hub.captureMetrics({}); + expect(fixture.loggedMessage, + 'Metrics are empty and this \'captureMetrics\' call is a no-op.'); + + expect(fixture.client.captureMetricsCalls, isEmpty); + }); + }); } class Fixture { @@ -692,17 +750,20 @@ class Fixture { late SentryTracer tracer; SentryLevel? loggedLevel; + String? loggedMessage; Object? loggedException; Hub getSut({ double? tracesSampleRate = 1.0, TracesSamplerCallback? tracesSampler, bool? sampled = true, + bool enableMetrics = true, bool debug = false, }) { options.tracesSampleRate = tracesSampleRate; options.tracesSampler = tracesSampler; options.debug = debug; + options.enableMetrics = enableMetrics; options.logger = mockLogger; // Enable logging in DiagnosticsLogger final hub = Hub(options); @@ -731,6 +792,7 @@ class Fixture { StackTrace? stackTrace, }) { loggedLevel = level; + loggedMessage = message; loggedException = exception; } } 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 new file mode 100644 index 0000000000..f3edc0486b --- /dev/null +++ b/dart/test/metrics/metric_test.dart @@ -0,0 +1,315 @@ +import 'package:sentry/sentry.dart'; +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; + + setUp(() { + fixture = Fixture(); + }); + + test('encode CounterMetric', () async { + final int bucketKey = 10; + final expectedStatsd = + 'key_metric_@hour:2.1|c|#tag1:tag\\u{2c} value 1,key2:&@"13/-d_s|T10'; + final actualStatsd = fixture.counterMetric.encodeToStatsd(bucketKey); + expect(actualStatsd, expectedStatsd); + }); + + test('sanitize name', () async { + final metric = Metric.fromType( + type: MetricType.counter, + value: 2.1, + key: 'key£ - @# metric!', + unit: DurationSentryMeasurementUnit.day, + tags: {}, + ); + + final expectedStatsd = 'key_-_metric_@day:2.1|c|T10'; + expect(metric.encodeToStatsd(10), expectedStatsd); + }); + + test('sanitize unit', () async { + final metric = Metric.fromType( + type: MetricType.counter, + value: 2.1, + key: 'key', + unit: CustomSentryMeasurementUnit('weird-measurement name!'), + tags: {}, + ); + + final expectedStatsd = 'key@weirdmeasurementname:2.1|c|T10'; + expect(metric.encodeToStatsd(10), expectedStatsd); + }); + + test('sanitize tags', () async { + final metric = Metric.fromType( + type: MetricType.counter, + value: 2.1, + key: 'key', + unit: DurationSentryMeasurementUnit.day, + tags: {'tag1': 'tag, value 1', 'key 2': '&@"13/-d_s'}, + ); + + final expectedStatsd = + 'key@day:2.1|c|#tag1:tag\\u{2c} value 1,key2:&@"13/-d_s|T10'; + expect(metric.encodeToStatsd(10), expectedStatsd); + }); + }); + + group('getCompositeKey', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('escapes commas from tags', () async { + final Iterable tags = fixture.counterMetric.tags.values; + final joinedTags = tags.join(); + final Iterable expectedTags = + tags.map((e) => e.replaceAll(',', '\\,')); + final actualKey = fixture.counterMetric.getCompositeKey(); + + expect(joinedTags.contains(','), true); + expect(joinedTags.contains('\\,'), false); + expect(actualKey.contains('\\,'), true); + for (var tag in expectedTags) { + expect(actualKey.contains(tag), true); + } + }); + + test('CounterMetric', () async { + final expectedKey = + 'c_key metric!_hour_tag1=tag\\, value 1,key 2=&@"13/-d_s'; + 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 = 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 new file mode 100644 index 0000000000..5636e7ebf6 --- /dev/null +++ b/dart/test/metrics/metrics_aggregator_test.dart @@ -0,0 +1,493 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/metrics/metric.dart'; +import 'package:sentry/src/metrics/metrics_aggregator.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; +import '../mocks/mock_hub.dart'; + +void main() { + group('emit', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + 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.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', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('same metric with different keys emit different metrics', () async { + final MetricsAggregator sut = fixture.getSut(); + sut.testEmit(key: mockKey); + sut.testEmit(key: mockKey2); + + final timeBuckets = sut.buckets; + final bucket = timeBuckets.values.first; + + expect(bucket.length, 2); + expect(bucket.values.firstWhere((e) => e.key == mockKey), isNotNull); + expect(bucket.values.firstWhere((e) => e.key == mockKey2), isNotNull); + }); + + test('same metric with different units emit different metrics', () async { + final MetricsAggregator sut = fixture.getSut(); + sut.testEmit(unit: mockUnit); + sut.testEmit(unit: mockUnit2); + + final timeBuckets = sut.buckets; + final bucket = timeBuckets.values.first; + + expect(bucket.length, 2); + expect(bucket.values.firstWhere((e) => e.unit == mockUnit), isNotNull); + expect(bucket.values.firstWhere((e) => e.unit == mockUnit2), isNotNull); + }); + + test('same metric with different tags emit different metrics', () async { + final MetricsAggregator sut = fixture.getSut(); + sut.testEmit(tags: mockTags); + sut.testEmit(tags: mockTags2); + + final timeBuckets = sut.buckets; + final bucket = timeBuckets.values.first; + + expect(bucket.length, 2); + expect(bucket.values.firstWhere((e) => e.tags == mockTags), isNotNull); + expect(bucket.values.firstWhere((e) => e.tags == mockTags2), isNotNull); + }); + + test('increment same metric emit only one counter', () async { + final MetricsAggregator sut = fixture.getSut(); + sut.testEmit(type: MetricType.counter, value: 1); + sut.testEmit(type: MetricType.counter, value: 2); + + final timeBuckets = sut.buckets; + final bucket = timeBuckets.values.first; + + expect(bucket.length, 1); + expect((bucket.values.first as CounterMetric).value, 3); + }); + }); + + group('time buckets', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('same metric in < 10 seconds interval emit only one metric', () async { + final MetricsAggregator sut = fixture.getSut(); + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(0); + sut.testEmit(); + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(9999); + sut.testEmit(); + + final timeBuckets = sut.buckets; + expect(timeBuckets.length, 1); + }); + + test('same metric in >= 10 seconds interval emit two metrics', () async { + final MetricsAggregator sut = fixture.getSut(); + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(0); + sut.testEmit(); + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); + sut.testEmit(); + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(20000); + sut.testEmit(); + + final timeBuckets = sut.buckets; + expect(timeBuckets.length, 3); + }); + }); + + group('flush metrics', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('emitting a metric schedules flushing', () async { + final MetricsAggregator sut = fixture.getSut(); + + expect(sut.flushCompleter, isNull); + sut.testEmit(); + expect(sut.flushCompleter, isNotNull); + }); + + test('flush calls hub captureMetrics', () async { + final MetricsAggregator sut = fixture.getSut(); + + // emit a metric + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(0); + sut.testEmit(); + expect(fixture.mockHub.captureMetricsCalls, isEmpty); + + // mock clock to allow metric time aggregation + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); + // wait for flush + await sut.flushCompleter!.future; + expect(fixture.mockHub.captureMetricsCalls, isNotEmpty); + + Map> capturedMetrics = + fixture.mockHub.captureMetricsCalls.first.metricsBuckets; + Metric capturedMetric = capturedMetrics.values.first.first; + expect(capturedMetric.key, mockKey); + }); + + test('flush don\'t schedules flushing if no other metrics', () async { + final MetricsAggregator sut = fixture.getSut(); + + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(0); + sut.testEmit(); + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); + expect(sut.flushCompleter, isNotNull); + await sut.flushCompleter!.future; + expect(sut.flushCompleter, isNull); + }); + + test('flush schedules flushing if there are other metrics', () async { + final MetricsAggregator sut = fixture.getSut(); + + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(0); + sut.testEmit(); + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); + sut.testEmit(); + expect(sut.flushCompleter, isNotNull); + await sut.flushCompleter!.future; + // we expect the aggregator flushed metrics and schedules flushing again + expect(fixture.mockHub.captureMetricsCalls, isNotEmpty); + expect(sut.flushCompleter, isNotNull); + }); + + test('flush schedules flushing if no metric was captured', () async { + final MetricsAggregator sut = + fixture.getSut(flushInterval: Duration(milliseconds: 100)); + + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); + sut.testEmit(); + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10050); + + expect(sut.flushCompleter, isNotNull); + await sut.flushCompleter!.future; + // we expect the aggregator didn't flush anything and schedules flushing + expect(fixture.mockHub.captureMetricsCalls, isEmpty); + expect(sut.flushCompleter, isNotNull); + }); + + test('flush ignores last 10 seconds', () async { + final MetricsAggregator sut = fixture.getSut(); + + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); + sut.testEmit(); + + // The 10 second bucket is not finished, so it shouldn't capture anything + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(19999); + await sut.flushCompleter!.future; + expect(fixture.mockHub.captureMetricsCalls, isEmpty); + + // The 10 second bucket finished, so it should capture metrics + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(20000); + await sut.flushCompleter!.future; + expect(fixture.mockHub.captureMetricsCalls, isNotEmpty); + }); + + test('flush ignores last flushShiftMs milliseconds', () async { + final MetricsAggregator sut = fixture.getSut(flushShiftMs: 4000); + + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(10000); + sut.testEmit(); + + // The 10 second bucket is not finished, so it shouldn't capture anything + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(19999); + await sut.flushCompleter!.future; + expect(fixture.mockHub.captureMetricsCalls, isEmpty); + + // The 10 second bucket finished, but flushShiftMs didn't pass, so it shouldn't capture anything + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(23999); + await sut.flushCompleter!.future; + expect(fixture.mockHub.captureMetricsCalls, isEmpty); + + // The 10 second bucket finished and flushShiftMs passed, so it should capture metrics + fixture.options.clock = () => DateTime.fromMillisecondsSinceEpoch(24000); + 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); + }); + }); +} + +const String mockKey = 'metric key'; +const String mockKey2 = 'metric key 2'; +const double mockValue = 5; +const SentryMeasurementUnit mockUnit = DurationSentryMeasurementUnit.minute; +const SentryMeasurementUnit mockUnit2 = DurationSentryMeasurementUnit.second; +const Map mockTags = {'tag1': 'val1', 'tag2': 'val2'}; +const Map mockTags2 = {'tag1': 'val1'}; +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: hub ?? mockHub, + options: options, + flushInterval: flushInterval, + flushShiftMs: flushShiftMs, + maxWeight: maxWeight); + } +} + +extension _MetricsAggregatorUtils on MetricsAggregator { + testEmit({ + MetricType type = MetricType.counter, + String key = mockKey, + double value = mockValue, + SentryMeasurementUnit unit = mockUnit, + Map tags = mockTags, + }) { + 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 b5fdd59aa9..1c79599a6b 100644 --- a/dart/test/mocks.dart +++ b/dart/test/mocks.dart @@ -1,5 +1,6 @@ import 'package:mockito/annotations.dart'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/metrics/metric.dart'; import 'package:sentry/src/profiling.dart'; import 'package:sentry/src/transport/rate_limiter.dart'; @@ -97,6 +98,37 @@ final fakeEvent = SentryEvent( ), ); +final fakeMetric = Metric.fromType( + type: MetricType.counter, + value: 4, + key: 'key', + unit: DurationSentryMeasurementUnit.hour, + tags: {'tag1': 'value1', 'tag2': 'value2'}); +final fakeMetric2 = Metric.fromType( + type: MetricType.counter, + value: 2, + key: 'key', + unit: SentryMeasurementUnit.none, + tags: {'tag1': 'value1', 'tag2': 'value2'}); +final fakeMetric3 = Metric.fromType( + type: MetricType.counter, + value: 2, + key: 'key', + unit: SentryMeasurementUnit.none, + tags: {'tag1': 'value1'}); +final fakeMetric4 = Metric.fromType( + type: MetricType.counter, + value: 2, + key: 'key2', + unit: SentryMeasurementUnit.none, + tags: {'tag1': 'value1'}); + +final Map> fakeMetrics = { + 10: [fakeMetric], + 20: [fakeMetric, fakeMetric2], + 30: [fakeMetric, fakeMetric3, fakeMetric4], +}; + /// Always returns null and thus drops all events class DropAllEventProcessor implements EventProcessor { @override diff --git a/dart/test/mocks/mock_hub.dart b/dart/test/mocks/mock_hub.dart index 351ad70672..c076251736 100644 --- a/dart/test/mocks/mock_hub.dart +++ b/dart/test/mocks/mock_hub.dart @@ -1,5 +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'; @@ -13,17 +15,23 @@ class MockHub with NoSuchMethodProvider implements Hub { List bindClientCalls = []; List userFeedbackCalls = []; List captureTransactionCalls = []; + List captureMetricsCalls = []; int closeCalls = 0; bool _isEnabled = true; int spanContextCals = 0; 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 = []; @@ -35,6 +43,7 @@ class MockHub with NoSuchMethodProvider implements Hub { _isEnabled = true; spanContextCals = 0; captureTransactionCalls = []; + captureMetricsCalls = []; getSpanCalls = 0; } @@ -116,6 +125,13 @@ class MockHub with NoSuchMethodProvider implements Hub { return transaction.eventId; } + @override + Future captureMetrics( + Map> metricsBuckets) async { + captureMetricsCalls.add(CaptureMetricsCall(metricsBuckets)); + return SentryId.newId(); + } + @override Future captureUserFeedback(SentryUserFeedback userFeedback) async { userFeedbackCalls.add(userFeedback); diff --git a/dart/test/mocks/mock_sentry_client.dart b/dart/test/mocks/mock_sentry_client.dart index 7b08250193..248ea19032 100644 --- a/dart/test/mocks/mock_sentry_client.dart +++ b/dart/test/mocks/mock_sentry_client.dart @@ -1,4 +1,5 @@ import 'package:sentry/sentry.dart'; +import 'package:sentry/src/metrics/metric.dart'; import 'no_such_method_provider.dart'; @@ -9,6 +10,7 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { List captureEnvelopeCalls = []; List captureTransactionCalls = []; List userFeedbackCalls = []; + List>> captureMetricsCalls = []; int closeCalls = 0; @override @@ -74,6 +76,12 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { userFeedbackCalls.add(userFeedback); } + @override + Future captureMetrics(Map> metrics) async { + captureMetricsCalls.add(metrics); + return SentryId.newId(); + } + @override void close() { closeCalls = closeCalls + 1; @@ -149,3 +157,9 @@ class CaptureTransactionCall { CaptureTransactionCall(this.transaction, this.traceContext); } + +class CaptureMetricsCall { + final Map> metricsBuckets; + + CaptureMetricsCall(this.metricsBuckets); +} diff --git a/dart/test/mocks/mock_transport.dart b/dart/test/mocks/mock_transport.dart index 0ce6c9d896..fad9f43696 100644 --- a/dart/test/mocks/mock_transport.dart +++ b/dart/test/mocks/mock_transport.dart @@ -6,6 +6,7 @@ import 'package:test/expect.dart'; class MockTransport implements Transport { List envelopes = []; List events = []; + List statsdItems = []; int _calls = 0; String _exceptions = ''; @@ -27,8 +28,7 @@ class MockTransport implements Transport { // failure causes. Instead, we log them and check on access to [calls]. try { envelopes.add(envelope); - final event = await _eventFromEnvelope(envelope); - events.add(event); + await _eventFromEnvelope(envelope); } catch (e, stack) { _exceptions += '$e\n$stack\n\n'; rethrow; @@ -37,13 +37,18 @@ class MockTransport implements Transport { return envelope.header.eventId ?? SentryId.empty(); } - Future _eventFromEnvelope(SentryEnvelope envelope) async { + Future _eventFromEnvelope(SentryEnvelope envelope) async { final envelopeItemData = []; + final RegExp statSdRegex = RegExp('^(?!{).+@.+:.+\\|.+', multiLine: true); envelopeItemData.addAll(await envelope.items.first.envelopeItemStream()); final envelopeItem = utf8.decode(envelopeItemData).split('\n').last; - final envelopeItemJson = jsonDecode(envelopeItem) as Map; - return SentryEvent.fromJson(envelopeItemJson); + if (statSdRegex.hasMatch(envelopeItem)) { + statsdItems.add(envelopeItem); + } else { + final envelopeItemJson = jsonDecode(envelopeItem) as Map; + events.add(SentryEvent.fromJson(envelopeItemJson)); + } } void reset() { diff --git a/dart/test/protocol/rate_limit_parser_test.dart b/dart/test/protocol/rate_limit_parser_test.dart index c898915e04..567dec34f0 100644 --- a/dart/test/protocol/rate_limit_parser_test.dart +++ b/dart/test/protocol/rate_limit_parser_test.dart @@ -75,6 +75,45 @@ void main() { expect(sut[0].duration.inMilliseconds, RateLimitParser.httpRetryAfterDefaultDelay.inMilliseconds); }); + + test('do not parse namespaces if not metric_bucket', () { + final sut = + RateLimitParser('1:transaction:organization:quota_exceeded:custom') + .parseRateLimitHeader(); + + expect(sut.length, 1); + expect(sut[0].category, DataCategory.transaction); + expect(sut[0].namespaces, isEmpty); + }); + + test('parse namespaces on metric_bucket', () { + final sut = + RateLimitParser('1:metric_bucket:organization:quota_exceeded:custom') + .parseRateLimitHeader(); + + expect(sut.length, 1); + expect(sut[0].category, DataCategory.metricBucket); + expect(sut[0].namespaces, isNotEmpty); + expect(sut[0].namespaces.first, 'custom'); + }); + + test('parse empty namespaces on metric_bucket', () { + final sut = + RateLimitParser('1:metric_bucket:organization:quota_exceeded:') + .parseRateLimitHeader(); + + expect(sut.length, 1); + expect(sut[0].category, DataCategory.metricBucket); + expect(sut[0].namespaces, isEmpty); + }); + + test('parse missing namespaces on metric_bucket', () { + final sut = RateLimitParser('1:metric_bucket').parseRateLimitHeader(); + + expect(sut.length, 1); + expect(sut[0].category, DataCategory.metricBucket); + expect(sut[0].namespaces, isEmpty); + }); }); group('parseRetryAfterHeader', () { diff --git a/dart/test/protocol/rate_limiter_test.dart b/dart/test/protocol/rate_limiter_test.dart index cc931d5b66..1f52a60003 100644 --- a/dart/test/protocol/rate_limiter_test.dart +++ b/dart/test/protocol/rate_limiter_test.dart @@ -228,6 +228,118 @@ void main() { expect(fixture.mockRecorder.category, DataCategory.transaction); expect(fixture.mockRecorder.reason, DiscardReason.rateLimitBackoff); }); + + test('dropping of metrics recorded', () { + final rateLimiter = fixture.getSut(); + + final metricsItem = SentryEnvelopeItem.fromMetrics({}); + final eventEnvelope = SentryEnvelope( + SentryEnvelopeHeader.newEventId(), + [metricsItem], + ); + + rateLimiter.updateRetryAfterLimits( + '1:metric_bucket:key, 5:metric_bucket:organization', null, 1); + + final result = rateLimiter.filter(eventEnvelope); + expect(result, isNull); + + expect(fixture.mockRecorder.category, DataCategory.metricBucket); + expect(fixture.mockRecorder.reason, DiscardReason.rateLimitBackoff); + }); + + group('apply rateLimit', () { + test('error', () { + final rateLimiter = fixture.getSut(); + fixture.dateTimeToReturn = 0; + + final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); + final envelope = SentryEnvelope( + SentryEnvelopeHeader.newEventId(), + [eventItem], + ); + + rateLimiter.updateRetryAfterLimits( + '1:error:key, 5:error:organization', null, 1); + + expect(rateLimiter.filter(envelope), isNull); + }); + + test('transaction', () { + final rateLimiter = fixture.getSut(); + fixture.dateTimeToReturn = 0; + + final transaction = fixture.getTransaction(); + final eventItem = SentryEnvelopeItem.fromTransaction(transaction); + final envelope = SentryEnvelope( + SentryEnvelopeHeader.newEventId(), + [eventItem], + ); + + rateLimiter.updateRetryAfterLimits( + '1:transaction:key, 5:transaction:organization', null, 1); + + final result = rateLimiter.filter(envelope); + expect(result, isNull); + }); + + test('metrics', () { + final rateLimiter = fixture.getSut(); + fixture.dateTimeToReturn = 0; + + final metricsItem = SentryEnvelopeItem.fromMetrics({}); + final envelope = SentryEnvelope( + SentryEnvelopeHeader.newEventId(), + [metricsItem], + ); + + rateLimiter.updateRetryAfterLimits( + '1:metric_bucket:key, 5:metric_bucket:organization', null, 1); + + final result = rateLimiter.filter(envelope); + expect(result, isNull); + }); + + test('metrics with empty namespaces', () { + final rateLimiter = fixture.getSut(); + fixture.dateTimeToReturn = 0; + + final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); + final metricsItem = SentryEnvelopeItem.fromMetrics({}); + final envelope = SentryEnvelope( + SentryEnvelopeHeader.newEventId(), + [eventItem, metricsItem], + ); + + rateLimiter.updateRetryAfterLimits( + '10:metric_bucket:key:quota_exceeded:', null, 1); + + final result = rateLimiter.filter(envelope); + expect(result, isNotNull); + expect(result!.items.length, 1); + expect(result.items.first.header.type, 'event'); + }); + + test('metrics with custom namespace', () { + final rateLimiter = fixture.getSut(); + fixture.dateTimeToReturn = 0; + + final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); + final metricsItem = SentryEnvelopeItem.fromMetrics({}); + final envelope = SentryEnvelope( + SentryEnvelopeHeader.newEventId(), + [eventItem, metricsItem], + ); + + rateLimiter.updateRetryAfterLimits( + '10:metric_bucket:key:quota_exceeded:custom', null, 1); + + final result = rateLimiter.filter(envelope); + expect(result, isNotNull); + expect(result!.items.length, 1); + expect(result.items.first.header.type, 'event'); + }); + }); } class Fixture { diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 63c83a321f..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'; @@ -405,6 +406,8 @@ void main() { Error error; + dynamic exception; + final stacktrace = ''' #0 baz (file:///pathto/test.dart:50:3) @@ -436,16 +439,6 @@ void main() { capturedEvent.exceptions?.first.stackTrace!.frames.first.lineNo, 46); expect(capturedEvent.exceptions?.first.stackTrace!.frames.first.colNo, 9); }); - }); - - group('SentryClient captures exception and stacktrace', () { - late Fixture fixture; - - dynamic exception; - - setUp(() { - fixture = Fixture(); - }); test('should capture exception', () async { try { @@ -454,12 +447,6 @@ void main() { exception = err; } - final stacktrace = ''' -#0 baz (file:///pathto/test.dart:50:3) - -#1 bar (file:///pathto/test.dart:46:9) - '''; - final client = fixture.getSut(); await client.captureException(exception, stackTrace: stacktrace); @@ -513,13 +500,6 @@ void main() { exception = err; } - final stacktrace = ''' -#0 init (package:sentry/sentry.dart:46:9) -#1 bar (file:///pathto/test.dart:46:9) - -#2 capture (package:sentry/sentry.dart:46:9) - '''; - final client = fixture.getSut(); await client.captureException(exception, stackTrace: stacktrace); @@ -1716,6 +1696,42 @@ void main() { expect(fixture.options.transport is SpotlightHttpTransport, true); }); }); + + group('Capture metrics', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('metricsAggregator is set if metrics are enabled', () async { + final client = fixture.getSut(enableMetrics: true); + expect(client.metricsAggregator, isNotNull); + }); + + test('metricsAggregator is null if metrics are disabled', () async { + final client = fixture.getSut(enableMetrics: false); + expect(client.metricsAggregator, isNull); + }); + + test('captureMetrics send statsd envelope', () async { + final client = fixture.getSut(); + await client.captureMetrics(fakeMetrics); + + final capturedStatsd = (fixture.transport).statsdItems.first; + expect(capturedStatsd, isNotNull); + }); + + test('close closes metricsAggregator', () async { + final client = fixture.getSut(); + client.close(); + expect(client.metricsAggregator, isNotNull); + client.metricsAggregator! + .emit(MetricType.counter, 'key', 1, SentryMeasurementUnit.none, {}); + // metricsAggregator is closed, so no metrics should be recorded + expect(client.metricsAggregator!.buckets, isEmpty); + }); + }); } Future eventFromEnvelope(SentryEnvelope envelope) async { @@ -1802,6 +1818,7 @@ class Fixture { bool sendDefaultPii = false, bool attachStacktrace = true, bool attachThreads = false, + bool enableMetrics = true, double? sampleRate, BeforeSendCallback? beforeSend, BeforeSendTransactionCallback? beforeSendTransaction, @@ -1818,6 +1835,7 @@ class Fixture { options.tracesSampleRate = 1.0; options.sendDefaultPii = sendDefaultPii; + options.enableMetrics = enableMetrics; options.attachStacktrace = attachStacktrace; options.attachThreads = attachThreads; options.sampleRate = sampleRate; diff --git a/dart/test/sentry_envelope_item_test.dart b/dart/test/sentry_envelope_item_test.dart index 673f679740..c5a205c945 100644 --- a/dart/test/sentry_envelope_item_test.dart +++ b/dart/test/sentry_envelope_item_test.dart @@ -4,12 +4,14 @@ import 'package:sentry/sentry.dart'; 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/metrics/metric.dart'; import 'package:sentry/src/sentry_envelope_item_header.dart'; import 'package:sentry/src/sentry_item_type.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/src/transport/data_category.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; import 'mocks/mock_hub.dart'; void main() { @@ -109,5 +111,49 @@ void main() { expect(actualLength, expectedLength); expect(actualData, expectedData); }); + + test('fromUserFeedback', () async { + final userFeedback = SentryUserFeedback( + eventId: SentryId.newId(), + name: 'name', + comments: 'comments', + email: 'email'); + final sut = SentryEnvelopeItem.fromUserFeedback(userFeedback); + + final expectedData = utf8.encode(jsonEncode( + userFeedback.toJson(), + toEncodable: jsonSerializationFallback, + )); + final actualData = await sut.dataFactory(); + + final expectedLength = expectedData.length; + final actualLength = await sut.header.length(); + + expect(sut.header.contentType, 'application/json'); + expect(sut.header.type, SentryItemType.userFeedback); + expect(actualLength, expectedLength); + expect(actualData, expectedData); + }); + + test('fromMetrics', () async { + final sut = SentryEnvelopeItem.fromMetrics(fakeMetrics); + + final StringBuffer statsd = StringBuffer(); + for (MapEntry> bucket in fakeMetrics.entries) { + final Iterable encodedMetrics = + bucket.value.map((metric) => metric.encodeToStatsd(bucket.key)); + statsd.write(encodedMetrics.join('\n')); + } + final expectedData = utf8.encode(statsd.toString()); + final actualData = await sut.dataFactory(); + + final expectedLength = expectedData.length; + final actualLength = await sut.header.length(); + + expect(sut.header.contentType, 'application/octet-stream'); + expect(sut.header.type, SentryItemType.statsd); + expect(actualLength, expectedLength); + expect(actualData, expectedData); + }); }); } diff --git a/dart/test/sentry_envelope_test.dart b/dart/test/sentry_envelope_test.dart index 375f62846e..a24cab20c7 100644 --- a/dart/test/sentry_envelope_test.dart +++ b/dart/test/sentry_envelope_test.dart @@ -130,6 +130,63 @@ void main() { expect(actualItem, expectedItem); }); + test('fromUserFeedback', () async { + final eventId = SentryId.newId(); + final userFeedback = SentryUserFeedback( + eventId: eventId, name: 'name', email: 'email', comments: 'comments'); + final sdkVersion = + SdkVersion(name: 'fixture-name', version: 'fixture-version'); + final sut = SentryEnvelope.fromUserFeedback( + userFeedback, + sdkVersion, + dsn: fakeDsn, + ); + + final expectedEnvelopeItem = + SentryEnvelopeItem.fromUserFeedback(userFeedback); + + expect(sut.header.eventId, eventId); + expect(sut.header.sdkVersion, sdkVersion); + expect(sut.header.dsn, fakeDsn); + expect(sut.items[0].header.contentType, + expectedEnvelopeItem.header.contentType); + expect(sut.items[0].header.type, expectedEnvelopeItem.header.type); + expect(await sut.items[0].header.length(), + await expectedEnvelopeItem.header.length()); + + final actualItem = await sut.items[0].envelopeItemStream(); + + final expectedItem = await expectedEnvelopeItem.envelopeItemStream(); + + expect(actualItem, expectedItem); + }); + + test('fromMetrics', () async { + final sdkVersion = + SdkVersion(name: 'fixture-name', version: 'fixture-version'); + final sut = SentryEnvelope.fromMetrics( + fakeMetrics, + sdkVersion, + dsn: fakeDsn, + ); + + final expectedEnvelopeItem = SentryEnvelopeItem.fromMetrics(fakeMetrics); + + expect(sut.header.sdkVersion, sdkVersion); + expect(sut.header.dsn, fakeDsn); + expect(sut.items[0].header.contentType, + expectedEnvelopeItem.header.contentType); + expect(sut.items[0].header.type, expectedEnvelopeItem.header.type); + expect(await sut.items[0].header.length(), + await expectedEnvelopeItem.header.length()); + + final actualItem = await sut.items[0].envelopeItemStream(); + + final expectedItem = await expectedEnvelopeItem.envelopeItemStream(); + + expect(actualItem, expectedItem); + }); + test('max attachment size', () async { final attachment = SentryAttachment.fromLoader( loader: () => Uint8List.fromList([1, 2, 3, 4]), diff --git a/dart/test/sentry_options_test.dart b/dart/test/sentry_options_test.dart index e921c87b32..273366e442 100644 --- a/dart/test/sentry_options_test.dart +++ b/dart/test/sentry_options_test.dart @@ -133,4 +133,55 @@ void main() { expect(options.spotlight.enabled, false); }); + + test('metrics are disabled by default', () { + final options = SentryOptions(dsn: fakeDsn); + + expect(options.enableMetrics, false); + }); + + test('default tags for metrics are enabled by default', () { + final options = SentryOptions(dsn: fakeDsn); + options.enableMetrics = true; + + expect(options.enableDefaultTagsForMetrics, true); + }); + + test('default tags for metrics are disabled if metrics are disabled', () { + final options = SentryOptions(dsn: fakeDsn); + options.enableMetrics = false; + + expect(options.enableDefaultTagsForMetrics, false); + }); + + test('default tags for metrics are enabled if metrics are enabled, too', () { + final options = SentryOptions(dsn: fakeDsn); + options.enableMetrics = true; + options.enableDefaultTagsForMetrics = true; + + 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_test.dart b/dart/test/sentry_test.dart index a361c0bc4f..0a8dd5db27 100644 --- a/dart/test/sentry_test.dart +++ b/dart/test/sentry_test.dart @@ -129,6 +129,10 @@ void main() { expect(Sentry.getSpan(), isNull); }); + + test('should provide metrics API', () async { + expect(Sentry.metrics(), Sentry.currentHub.metricsApi); + }); }); group('Sentry is enabled or disabled', () { @@ -412,29 +416,7 @@ void main() { expect(sentryOptions.logger, isNot(dartLogger)); }); - group("Sentry init optionsConfiguration", () { - final fixture = Fixture(); - - test('throw is handled and logged', () async { - final sentryOptions = SentryOptions(dsn: fakeDsn) - ..automatedTestMode = false - ..debug = true - ..logger = fixture.mockLogger; - - final exception = Exception("Exception in options callback"); - await Sentry.init( - (options) async { - throw exception; - }, - options: sentryOptions, - ); - - expect(fixture.loggedException, exception); - expect(fixture.loggedLevel, SentryLevel.error); - }); - }); - - group("Sentry init optionsConfiguration", () { + group('Sentry init optionsConfiguration', () { final fixture = Fixture(); test('throw is handled and logged', () async { 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 2a68648ee0..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'; @@ -83,6 +84,7 @@ Future setupSentry( options.debug = true; options.spotlight = Spotlight(enabled: true); options.enableTimeToFullDisplayTracing = true; + options.enableMetrics = true; options.maxRequestBodySize = MaxRequestBodySize.always; options.maxResponseBodySize = MaxResponseBodySize.always; @@ -527,6 +529,35 @@ class MainScaffold extends StatelessWidget { 'Demonstrates the logging integration. log.info() will create an info event send it to Sentry.', buttonTitle: 'Logging', ), + TooltipButton( + onPressed: () async { + 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 several metrics and send them to Sentry.', + buttonTitle: 'Metrics', + ), if (UniversalPlatform.isIOS || UniversalPlatform.isMacOS) const CocoaExample(), if (UniversalPlatform.isAndroid) const AndroidExample(), 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, }, ),