From b15521ec4775f623b296e1fca558ffac46447bb8 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 28 Mar 2024 10:22:55 +0100 Subject: [PATCH] feat(metrics): Add BeforeEmitMetrics callback (#3799) Add the BeforeEmitMetrics to the options, as described in develop docs https://develop.sentry.dev/sdk/metrics/#hooks. --- CHANGELOG.md | 2 +- Sources/Sentry/Public/SentryDefines.h | 10 +++++ Sources/Sentry/Public/SentryOptions.h | 5 +++ Sources/Sentry/SentryHub.m | 11 +++--- Sources/Sentry/SentryOptions.m | 4 ++ .../Metrics/BucketsMetricsAggregator.swift | 9 +++++ Sources/Swift/Metrics/SentryMetricsAPI.swift | 8 +++- Tests/SentryTests/SentryOptionsTest.m | 21 ++++++++++ Tests/SentryTests/SentrySDKTests.swift | 16 ++++++++ .../BucketMetricsAggregatorTests.swift | 38 +++++++++++++++++++ .../Swift/Metrics/SentryMetricsAPITests.swift | 10 ++--- 11 files changed, 121 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9606a51ccc9..44d0d44e39b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Add Metrics API (#3791): Read our [docs](https://docs.sentry.io/platforms/apple/metrics/) to learn +- Add Metrics API (#3791, #3799): Read our [docs](https://docs.sentry.io/platforms/apple/metrics/) to learn more about how to use the Metrics API. - Pre-main profiling data is now attached to the app start transaction (#3736) - Release framework without UIKit/AppKit (#3793) diff --git a/Sources/Sentry/Public/SentryDefines.h b/Sources/Sentry/Public/SentryDefines.h index 9f877a5985d..961660faf50 100644 --- a/Sources/Sentry/Public/SentryDefines.h +++ b/Sources/Sentry/Public/SentryDefines.h @@ -110,6 +110,16 @@ typedef NSNumber *_Nullable (^SentryTracesSamplerCallback)( */ typedef void (^SentrySpanCallback)(id _Nullable span); +/** + * A callback block which gets called right before a metric is about to be emitted. + + * @param key The key of the metric. + * @param tags A dictionary of key-value pairs associated with the metric. + * @return BOOL YES if the metric should be emitted, NO otherwise. + */ +typedef BOOL (^SentryBeforeEmitMetricCallback)( + NSString *_Nonnull key, NSDictionary *_Nonnull tags); + /** * Log level. */ diff --git a/Sources/Sentry/Public/SentryOptions.h b/Sources/Sentry/Public/SentryOptions.h index 0a7f20cd191..963a436bfc0 100644 --- a/Sources/Sentry/Public/SentryOptions.h +++ b/Sources/Sentry/Public/SentryOptions.h @@ -584,6 +584,11 @@ NS_SWIFT_NAME(Options) */ @property (nonatomic, assign) BOOL enableSpanLocalMetricAggregation; +/** + * This block can be used to modify the event before it will be serialized and sent. + */ +@property (nullable, nonatomic, copy) SentryBeforeEmitMetricCallback beforeEmitMetric; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index a1a916c94a7..ea177d0c7ea 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -62,11 +62,12 @@ - (instancetype)initWithClient:(nullable SentryClient *)client SentryMetricsClient *metricsClient = [[SentryMetricsClient alloc] initWithClient:statsdClient]; _metrics = [[SentryMetricsAPI alloc] - initWithEnabled:client.options.enableMetrics - client:metricsClient - currentDate:SentryDependencyContainer.sharedInstance.dateProvider - dispatchQueue:SentryDependencyContainer.sharedInstance.dispatchQueueWrapper - random:SentryDependencyContainer.sharedInstance.random]; + initWithEnabled:client.options.enableMetrics + client:metricsClient + currentDate:SentryDependencyContainer.sharedInstance.dateProvider + dispatchQueue:SentryDependencyContainer.sharedInstance.dispatchQueueWrapper + random:SentryDependencyContainer.sharedInstance.random + beforeEmitMetric:client.options.beforeEmitMetric]; [_metrics setDelegate:self]; _sessionLock = [[NSObject alloc] init]; diff --git a/Sources/Sentry/SentryOptions.m b/Sources/Sentry/SentryOptions.m index a8b76f99acb..5b6020f2913 100644 --- a/Sources/Sentry/SentryOptions.m +++ b/Sources/Sentry/SentryOptions.m @@ -508,6 +508,10 @@ - (BOOL)validateOptions:(NSDictionary *)options [self setBool:options[@"enableSpanLocalMetricAggregation"] block:^(BOOL value) { self->_enableSpanLocalMetricAggregation = value; }]; + if ([self isBlock:options[@"beforeEmitMetric"]]) { + self.beforeEmitMetric = options[@"beforeEmitMetric"]; + } + return YES; } diff --git a/Sources/Swift/Metrics/BucketsMetricsAggregator.swift b/Sources/Swift/Metrics/BucketsMetricsAggregator.swift index 4b65a978e10..beff3a4e499 100644 --- a/Sources/Swift/Metrics/BucketsMetricsAggregator.swift +++ b/Sources/Swift/Metrics/BucketsMetricsAggregator.swift @@ -20,6 +20,7 @@ class BucketMetricsAggregator: MetricsAggregator { private let currentDate: SentryCurrentDateProvider private let dispatchQueue: SentryDispatchQueueWrapper private let random: SentryRandomProtocol + private let beforeEmitMetric: BeforeEmitMetricCallback? private let totalMaxWeight: UInt private let flushShift: TimeInterval private let flushInterval: TimeInterval @@ -35,6 +36,7 @@ class BucketMetricsAggregator: MetricsAggregator { currentDate: SentryCurrentDateProvider, dispatchQueue: SentryDispatchQueueWrapper, random: SentryRandomProtocol, + beforeEmitMetric: BeforeEmitMetricCallback? = nil, totalMaxWeight: UInt = 1_000, flushInterval: TimeInterval = 10.0, flushTolerance: TimeInterval = 0.5 @@ -43,6 +45,7 @@ class BucketMetricsAggregator: MetricsAggregator { self.currentDate = currentDate self.dispatchQueue = dispatchQueue self.random = random + self.beforeEmitMetric = beforeEmitMetric // The aggregator shifts its flushing by up to an entire rollup window to // avoid multiple clients trampling on end of a 10 second window as all the @@ -133,6 +136,12 @@ class BucketMetricsAggregator: MetricsAggregator { } private func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String], localMetricsAggregator: LocalMetricsAggregator?, initMetric: () -> Metric, addValueToMetric: (Metric) -> Void) { + + if let beforeEmitMetric = self.beforeEmitMetric { + if !beforeEmitMetric(key, tags) { + return + } + } let tagsKey = tags.getMetricsTagsKey() let bucketKey = "\(type)_\(key)_\(unit.unit)_\(tagsKey)" diff --git a/Sources/Swift/Metrics/SentryMetricsAPI.swift b/Sources/Swift/Metrics/SentryMetricsAPI.swift index 1729c264ec8..f2d5b574b1a 100644 --- a/Sources/Swift/Metrics/SentryMetricsAPI.swift +++ b/Sources/Swift/Metrics/SentryMetricsAPI.swift @@ -7,16 +7,20 @@ import Foundation func getLocalMetricsAggregator() -> LocalMetricsAggregator? } +/// Using SentryBeforeEmitMetricCallback of SentryDefines.h leads to compiler errors because of +/// Swift to ObjC interoperability. Defining the callback again in Swift with the same signature is a workaround. +typealias BeforeEmitMetricCallback = (String, [String: String]) -> Bool + @objc public class SentryMetricsAPI: NSObject { private let aggregator: MetricsAggregator private weak var delegate: SentryMetricsAPIDelegate? - @objc init(enabled: Bool, client: SentryMetricsClient, currentDate: SentryCurrentDateProvider, dispatchQueue: SentryDispatchQueueWrapper, random: SentryRandomProtocol) { + @objc init(enabled: Bool, client: SentryMetricsClient, currentDate: SentryCurrentDateProvider, dispatchQueue: SentryDispatchQueueWrapper, random: SentryRandomProtocol, beforeEmitMetric: BeforeEmitMetricCallback?) { if enabled { - self.aggregator = BucketMetricsAggregator(client: client, currentDate: currentDate, dispatchQueue: dispatchQueue, random: random) + self.aggregator = BucketMetricsAggregator(client: client, currentDate: currentDate, dispatchQueue: dispatchQueue, random: random, beforeEmitMetric: beforeEmitMetric ?? { _, _ in true }) } else { self.aggregator = NoOpMetricsAggregator() } diff --git a/Tests/SentryTests/SentryOptionsTest.m b/Tests/SentryTests/SentryOptionsTest.m index d3fdd52e1e9..4df2b2cb684 100644 --- a/Tests/SentryTests/SentryOptionsTest.m +++ b/Tests/SentryTests/SentryOptionsTest.m @@ -1291,6 +1291,27 @@ - (void)testEnableSpanLocalMetricAggregation [self testBooleanField:@"enableSpanLocalMetricAggregation" defaultValue:YES]; } +- (void)testBeforeEmitMetric +{ + SentryBeforeEmitMetricCallback callback + = ^(NSString *_Nonnull key, NSDictionary *_Nonnull tags) { + // Use tags and key to silence unused compiler error + XCTAssertNotNil(key); + XCTAssertNotNil(tags); + return YES; + }; + SentryOptions *options = [self getValidOptions:@{ @"beforeEmitMetric" : callback }]; + + XCTAssertEqual(callback, options.beforeEmitMetric); +} + +- (void)testDefaultBeforeEmitMetric +{ + SentryOptions *options = [self getValidOptions:@{}]; + + XCTAssertNil(options.beforeEmitMetric); +} + #pragma mark - Private - (void)assertArrayEquals:(NSArray *)expected actual:(NSArray *)actual diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index 0932df1b021..50dec90fff0 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -748,6 +748,22 @@ class SentrySDKTests: XCTestCase { expect(envelopeItem.header.type) == SentryEnvelopeItemTypeStatsd expect(envelopeItem.header.contentType) == "application/octet-stream" } + + func testMetrics_BeforeEmitMetricCallback_DiscardEveryThing() throws { + let options = fixture.options + options.enableMetrics = true + options.beforeEmitMetric = { _, _ in false } + + SentrySDK.start(options: options) + let client = try XCTUnwrap(TestClient(options: options)) + let hub = SentryHub(client: client, andScope: nil) + SentrySDK.setCurrentHub(hub) + + SentrySDK.metrics.increment(key: "key") + SentrySDK.flush(timeout: 1.0) + + expect(client.captureEnvelopeInvocations.count) == 0 + } #if SENTRY_HAS_UIKIT diff --git a/Tests/SentryTests/Swift/Metrics/BucketMetricsAggregatorTests.swift b/Tests/SentryTests/Swift/Metrics/BucketMetricsAggregatorTests.swift index 78fb97df0be..5fa1ed14c5e 100644 --- a/Tests/SentryTests/Swift/Metrics/BucketMetricsAggregatorTests.swift +++ b/Tests/SentryTests/Swift/Metrics/BucketMetricsAggregatorTests.swift @@ -406,5 +406,43 @@ final class BucketMetricsAggregatorTests: XCTestCase { expect(metric["count"] as? Int) == 2 expect(metric["sum"] as? Double) == 1.0 } + + func testBeforeEmitMetricCallback() throws { + let currentDate = TestCurrentDateProvider() + let metricsClient = try TestMetricsClient() + + let sut = BucketMetricsAggregator(client: metricsClient, currentDate: currentDate, dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: { key, tags in + if key == "key" { + return false + } + + if tags == ["my": "tag"] { + return false + } + + return true + }, totalMaxWeight: 1_000) + + // removed + sut.distribution( key: "key", value: 1.0, unit: MeasurementUnitDuration.day, tags: [:]) + + // kept + sut.distribution(key: "key1", value: 1.0, unit: MeasurementUnitDuration.day, tags: [:]) + + // removed + sut.distribution(key: "key1", value: 1.0, unit: MeasurementUnitDuration.day, tags: ["my": "tag"]) + + sut.flush(force: true) + + expect(metricsClient.captureInvocations.count) == 1 + let buckets = try XCTUnwrap(metricsClient.captureInvocations.first) + + let bucket = try XCTUnwrap(buckets[currentDate.bucketTimestamp]) + expect(bucket.count) == 1 + let metric = try XCTUnwrap(bucket.first as? DistributionMetric) + + expect(metric.key) == "key1" + expect(metric.tags.isEmpty) == true + } } diff --git a/Tests/SentryTests/Swift/Metrics/SentryMetricsAPITests.swift b/Tests/SentryTests/Swift/Metrics/SentryMetricsAPITests.swift index 71d6f780c1e..33713aea3da 100644 --- a/Tests/SentryTests/Swift/Metrics/SentryMetricsAPITests.swift +++ b/Tests/SentryTests/Swift/Metrics/SentryMetricsAPITests.swift @@ -8,7 +8,7 @@ final class SentryMetricsAPITests: XCTestCase, SentryMetricsAPIDelegate { func testInitWithDisabled_AllOperationsAreNoOps() throws { let metricsClient = try TestMetricsClient() - let sut = SentryMetricsAPI(enabled: false, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom()) + let sut = SentryMetricsAPI(enabled: false, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: { _, _ in true }) sut.increment(key: "some", value: 1.0, unit: .none, tags: ["yeah": "sentry"]) sut.gauge(key: "some", value: 1.0, unit: .none, tags: ["yeah": "sentry"]) @@ -22,7 +22,7 @@ final class SentryMetricsAPITests: XCTestCase, SentryMetricsAPIDelegate { func testIncrement_EmitsIncrementMetric() throws { let metricsClient = try TestMetricsClient() - let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom()) + let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: { _, _ in return true }) sut.setDelegate(self) sut.increment(key: "key", value: 1.0, unit: MeasurementUnitFraction.percent, tags: ["yeah": "sentry"]) @@ -44,7 +44,7 @@ final class SentryMetricsAPITests: XCTestCase, SentryMetricsAPIDelegate { func testGauge_EmitsGaugeMetric() throws { let metricsClient = try TestMetricsClient() - let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom()) + let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: { _, _ in return true }) sut.setDelegate(self) sut.gauge(key: "key", value: 1.0, unit: MeasurementUnitFraction.percent, tags: ["yeah": "sentry"]) @@ -66,7 +66,7 @@ final class SentryMetricsAPITests: XCTestCase, SentryMetricsAPIDelegate { func testDistribution_EmitsDistributionMetric() throws { let metricsClient = try TestMetricsClient() - let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom()) + let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: { _, _ in return true }) sut.setDelegate(self) sut.distribution(key: "key", value: 1.0, unit: MeasurementUnitFraction.percent, tags: ["yeah": "sentry"]) @@ -89,7 +89,7 @@ final class SentryMetricsAPITests: XCTestCase, SentryMetricsAPIDelegate { func testSet_EmitsSetMetric() throws { let metricsClient = try TestMetricsClient() - let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom()) + let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: { _, _ in return true }) sut.setDelegate(self) sut.set(key: "key", value: "value1", unit: MeasurementUnitFraction.percent, tags: ["yeah": "sentry"])