Skip to content

Commit c83ffd9

Browse files
feat(metrics): Add timing API (#3812)
Add the timing API to measure the duration of a closure.
1 parent 5e769dd commit c83ffd9

File tree

5 files changed

+225
-34
lines changed

5 files changed

+225
-34
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- Add timing API for Metrics (#3812):
8+
39
## 8.23.0
410

511
### Features

Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ class ErrorsViewController: UIViewController {
1313
SentrySDK.reportFullyDisplayed()
1414

1515
SentrySDK.metrics.increment(key: "load.errors.view.controller")
16+
17+
SentrySDK.metrics.timing(key: "timing.some.delayed") {
18+
Thread.sleep(forTimeInterval: 0.01)
19+
}
1620
}
1721

1822
@IBAction func useAfterFree(_ sender: UIButton) {

Sources/Sentry/SentryHub.m

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -701,19 +701,7 @@ - (void)close
701701
SENTRY_LOG_DEBUG(@"Closed the Hub.");
702702
}
703703

704-
- (LocalMetricsAggregator *_Nullable)getLocalMetricsAggregator
705-
{
706-
id<SentrySpan> currentSpan = _scope.span;
707-
708-
// We don't want to add them LocalMetricsAggregator to the SentrySpan protocol and make it
709-
// public. Instead, we check if the span responds to the getLocalMetricsAggregator which, every
710-
// span should do.
711-
if ([currentSpan isKindOfClass:SentrySpan.class]) {
712-
return [(SentrySpan *)currentSpan getLocalMetricsAggregator];
713-
}
714-
715-
return nil;
716-
}
704+
#pragma mark - SentryMetricsAPIDelegate
717705

718706
- (NSDictionary<NSString *, NSString *> *)getDefaultTagsForMetrics
719707
{
@@ -733,6 +721,22 @@ - (LocalMetricsAggregator *_Nullable)getLocalMetricsAggregator
733721
return defaultTags;
734722
}
735723

724+
- (id<SentrySpan> _Nullable)getCurrentSpan
725+
{
726+
return _scope.span;
727+
}
728+
729+
- (LocalMetricsAggregator *_Nullable)getLocalMetricsAggregatorWithSpan:(id<SentrySpan>)span
730+
{
731+
// We don't want to add them LocalMetricsAggregator to the SentrySpan protocol and make it
732+
// public. Instead, we check if the span responds to the getLocalMetricsAggregator which, every
733+
// span should do.
734+
if ([span isKindOfClass:SentrySpan.class]) {
735+
return [(SentrySpan *)span getLocalMetricsAggregator];
736+
}
737+
return nil;
738+
}
739+
736740
#pragma mark - Protected
737741

738742
- (NSMutableArray<NSString *> *)trimmedInstalledIntegrationNames

Sources/Swift/Metrics/SentryMetricsAPI.swift

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@
22
import Foundation
33

44
@objc protocol SentryMetricsAPIDelegate: AnyObject {
5+
56
func getDefaultTagsForMetrics() -> [String: String]
67

7-
func getLocalMetricsAggregator() -> LocalMetricsAggregator?
8+
func getCurrentSpan() -> Span?
9+
10+
// We don't want to add the LocalMetricsAggregator to the SpanProtocol
11+
// because it would be public then. Exposing the LocalMetricsAggregator
12+
// on the Span internally in Swift is a bit tricky, so we ask the
13+
// delegate written in ObjC for it.
14+
func getLocalMetricsAggregator(span: Span) -> LocalMetricsAggregator?
815
}
916

1017
/// Using SentryBeforeEmitMetricCallback of SentryDefines.h leads to compiler errors because of
@@ -14,11 +21,14 @@ typealias BeforeEmitMetricCallback = (String, [String: String]) -> Bool
1421
@objc public class SentryMetricsAPI: NSObject {
1522

1623
private let aggregator: MetricsAggregator
24+
private let currentDate: SentryCurrentDateProvider
1725

1826
private weak var delegate: SentryMetricsAPIDelegate?
1927

2028
@objc init(enabled: Bool, client: SentryMetricsClient, currentDate: SentryCurrentDateProvider, dispatchQueue: SentryDispatchQueueWrapper, random: SentryRandomProtocol, beforeEmitMetric: BeforeEmitMetricCallback?) {
2129

30+
self.currentDate = currentDate
31+
2232
if enabled {
2333
self.aggregator = BucketMetricsAggregator(client: client, currentDate: currentDate, dispatchQueue: dispatchQueue, random: random, beforeEmitMetric: beforeEmitMetric ?? { _, _ in true })
2434
} else {
@@ -38,7 +48,8 @@ typealias BeforeEmitMetricCallback = (String, [String: String]) -> Bool
3848
/// - Parameter tags: Tags to associate with the metric.
3949
@objc public func increment(key: String, value: Double = 1.0, unit: MeasurementUnit = .none, tags: [String: String] = [:]) {
4050
let mergedTags = mergeDefaultTagsInto(tags: tags)
41-
aggregator.increment(key: key, value: value, unit: unit, tags: mergedTags, localMetricsAggregator: delegate?.getLocalMetricsAggregator())
51+
52+
aggregator.increment(key: key, value: value, unit: unit, tags: mergedTags, localMetricsAggregator: getLocalMetricsAggregator())
4253
}
4354

4455
/// Emits a Gauge metric.
@@ -50,7 +61,7 @@ typealias BeforeEmitMetricCallback = (String, [String: String]) -> Bool
5061
@objc
5162
public func gauge(key: String, value: Double, unit: MeasurementUnit = .none, tags: [String: String] = [:]) {
5263
let mergedTags = mergeDefaultTagsInto(tags: tags)
53-
aggregator.gauge(key: key, value: value, unit: unit, tags: mergedTags, localMetricsAggregator: delegate?.getLocalMetricsAggregator())
64+
aggregator.gauge(key: key, value: value, unit: unit, tags: mergedTags, localMetricsAggregator: getLocalMetricsAggregator())
5465
}
5566

5667
/// Emits a Distribution metric.
@@ -62,7 +73,7 @@ typealias BeforeEmitMetricCallback = (String, [String: String]) -> Bool
6273
@objc
6374
public func distribution(key: String, value: Double, unit: MeasurementUnit = .none, tags: [String: String] = [:]) {
6475
let mergedTags = mergeDefaultTagsInto(tags: tags)
65-
aggregator.distribution(key: key, value: value, unit: unit, tags: mergedTags, localMetricsAggregator: delegate?.getLocalMetricsAggregator())
76+
aggregator.distribution(key: key, value: value, unit: unit, tags: mergedTags, localMetricsAggregator: getLocalMetricsAggregator())
6677
}
6778

6879
/// Emits a Set metric.
@@ -76,7 +87,40 @@ typealias BeforeEmitMetricCallback = (String, [String: String]) -> Bool
7687
let mergedTags = mergeDefaultTagsInto(tags: tags)
7788
let crc32 = sentry_crc32ofString(value)
7889

79-
aggregator.set(key: key, value: crc32, unit: unit, tags: mergedTags, localMetricsAggregator: delegate?.getLocalMetricsAggregator())
90+
aggregator.set(key: key, value: crc32, unit: unit, tags: mergedTags, localMetricsAggregator: getLocalMetricsAggregator())
91+
}
92+
93+
/// Measures how long it takes to run the given closure by emitting a distribution metric in seconds.
94+
///
95+
/// - Note: This method also creates a child span with the operation `metric.timing` and the
96+
/// description `key` if a span is bound to the scope.
97+
///
98+
/// - Parameter key: A unique key identifying the metric.
99+
/// - Parameter tags: Tags to associate with the metric.
100+
public func timing<T>(key: String, tags: [String: String] = [:], _ closure: () throws -> T) rethrows -> T {
101+
102+
guard let currentSpan = delegate?.getCurrentSpan() else {
103+
return try closure()
104+
}
105+
106+
let span = currentSpan.startChild(operation: "metric.timing", description: key)
107+
let aggregator = delegate?.getLocalMetricsAggregator(span: span)
108+
109+
let mergedTags = mergeDefaultTagsInto(tags: tags)
110+
for tag in mergedTags {
111+
span.setTag(value: tag.value, key: tag.key)
112+
}
113+
114+
defer {
115+
span.finish()
116+
if let timestamp = span.timestamp, let startTimestamp = span.startTimestamp {
117+
let duration = timestamp.timeIntervalSince(startTimestamp)
118+
119+
self.aggregator.distribution(key: key, value: duration, unit: MeasurementUnitDuration.second, tags: mergedTags, localMetricsAggregator: aggregator)
120+
}
121+
}
122+
123+
return try closure()
80124
}
81125

82126
@objc public func close() {
@@ -93,5 +137,12 @@ typealias BeforeEmitMetricCallback = (String, [String: String]) -> Bool
93137
let defaultTags = delegate?.getDefaultTagsForMetrics() ?? [:]
94138
return tags.merging(defaultTags) { (tagValue, _) in tagValue }
95139
}
140+
141+
private func getLocalMetricsAggregator() -> LocalMetricsAggregator? {
142+
if let currentSpan = delegate?.getCurrentSpan() {
143+
return delegate?.getLocalMetricsAggregator(span: currentSpan)
144+
}
145+
return nil
146+
}
96147

97148
}

Tests/SentryTests/Swift/Metrics/SentryMetricsAPITests.swift

Lines changed: 142 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,34 @@ import Nimble
44
import SentryTestUtils
55
import XCTest
66

7-
final class SentryMetricsAPITests: XCTestCase, SentryMetricsAPIDelegate {
7+
final class SentryMetricsAPITests: XCTestCase {
88

9-
func testInitWithDisabled_AllOperationsAreNoOps() throws {
9+
override func tearDown() {
10+
super.tearDown()
11+
clearTestState()
12+
}
13+
14+
private func getSut(enabled: Bool = true) throws -> (SentryMetricsAPI, TestMetricsClient, TestCurrentDateProvider, TestHub, Options) {
15+
1016
let metricsClient = try TestMetricsClient()
11-
let sut = SentryMetricsAPI(enabled: false, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: { _, _ in true })
17+
let currentDate = TestCurrentDateProvider()
18+
let sut = SentryMetricsAPI(enabled: enabled, client: metricsClient, currentDate: currentDate, dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: nil)
19+
20+
SentryDependencyContainer.sharedInstance().dateProvider = currentDate
21+
22+
let options = Options()
23+
options.enableMetrics = true
24+
25+
let testClient = try XCTUnwrap(TestClient(options: options))
26+
let testHub = TestHub(client: testClient, andScope: Scope())
27+
28+
sut.setDelegate(testHub as? SentryMetricsAPIDelegate)
29+
30+
return (sut, metricsClient, currentDate, testHub, options)
31+
}
32+
33+
func testInitWithDisabled_AllOperationsAreNoOps() throws {
34+
let (sut, metricsClient, _, _, _) = try getSut(enabled: false)
1235

1336
sut.increment(key: "some", value: 1.0, unit: .none, tags: ["yeah": "sentry"])
1437
sut.gauge(key: "some", value: 1.0, unit: .none, tags: ["yeah": "sentry"])
@@ -21,9 +44,9 @@ final class SentryMetricsAPITests: XCTestCase, SentryMetricsAPIDelegate {
2144
}
2245

2346
func testIncrement_EmitsIncrementMetric() throws {
24-
let metricsClient = try TestMetricsClient()
25-
let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: { _, _ in return true })
26-
sut.setDelegate(self)
47+
let (sut, metricsClient, _, _, _) = try getSut()
48+
let delegate = TestSentryMetricsAPIDelegate()
49+
sut.setDelegate(delegate)
2750

2851
sut.increment(key: "key", value: 1.0, unit: MeasurementUnitFraction.percent, tags: ["yeah": "sentry"])
2952

@@ -43,9 +66,9 @@ final class SentryMetricsAPITests: XCTestCase, SentryMetricsAPIDelegate {
4366
}
4467

4568
func testGauge_EmitsGaugeMetric() throws {
46-
let metricsClient = try TestMetricsClient()
47-
let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: { _, _ in return true })
48-
sut.setDelegate(self)
69+
let (sut, metricsClient, _, _, _) = try getSut()
70+
let delegate = TestSentryMetricsAPIDelegate()
71+
sut.setDelegate(delegate)
4972

5073
sut.gauge(key: "key", value: 1.0, unit: MeasurementUnitFraction.percent, tags: ["yeah": "sentry"])
5174

@@ -65,9 +88,9 @@ final class SentryMetricsAPITests: XCTestCase, SentryMetricsAPIDelegate {
6588
}
6689

6790
func testDistribution_EmitsDistributionMetric() throws {
68-
let metricsClient = try TestMetricsClient()
69-
let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: { _, _ in return true })
70-
sut.setDelegate(self)
91+
let (sut, metricsClient, _, _, _) = try getSut()
92+
let delegate = TestSentryMetricsAPIDelegate()
93+
sut.setDelegate(delegate)
7194

7295
sut.distribution(key: "key", value: 1.0, unit: MeasurementUnitFraction.percent, tags: ["yeah": "sentry"])
7396
sut.distribution(key: "key", value: 12.0, unit: MeasurementUnitFraction.percent, tags: ["yeah": "sentry"])
@@ -88,9 +111,9 @@ final class SentryMetricsAPITests: XCTestCase, SentryMetricsAPIDelegate {
88111
}
89112

90113
func testSet_EmitsSetMetric() throws {
91-
let metricsClient = try TestMetricsClient()
92-
let sut = SentryMetricsAPI(enabled: true, client: metricsClient, currentDate: SentryCurrentDateProvider(), dispatchQueue: SentryDispatchQueueWrapper(), random: SentryRandom(), beforeEmitMetric: { _, _ in return true })
93-
sut.setDelegate(self)
114+
let (sut, metricsClient, _, _, _) = try getSut()
115+
let delegate = TestSentryMetricsAPIDelegate()
116+
sut.setDelegate(delegate)
94117

95118
sut.set(key: "key", value: "value1", unit: MeasurementUnitFraction.percent, tags: ["yeah": "sentry"])
96119
sut.set(key: "key", value: "value1", unit: MeasurementUnitFraction.percent, tags: ["yeah": "sentry"])
@@ -111,11 +134,114 @@ final class SentryMetricsAPITests: XCTestCase, SentryMetricsAPIDelegate {
111134
expect(metric.tags) == ["yeah": "sentry", "some": "tag"]
112135
}
113136

137+
func testTiming_WhenNoCurrentSpan_NoSpanCreatedAndNoMetricEmitted() throws {
138+
let (sut, metricsClient, _, _, _) = try getSut()
139+
let delegate = TestSentryMetricsAPIDelegate()
140+
sut.setDelegate(delegate)
141+
142+
let errorMessage = "It's broken"
143+
do {
144+
try sut.timing(key: "key") {
145+
throw MetricsAPIError.runtimeError(errorMessage)
146+
}
147+
} catch MetricsAPIError.runtimeError(let actualErrorMessage) {
148+
expect(actualErrorMessage) == errorMessage
149+
}
150+
151+
expect(metricsClient.captureInvocations.count) == 0
152+
}
153+
154+
func testTiming_WithCurrentSpan_CapturesGaugeMetric() throws {
155+
let (sut, metricsClient, currentDate, testHub, options) = try getSut()
156+
157+
let transaction = testHub.startTransaction(name: "hello", operation: "operation", bindToScope: true)
158+
159+
let errorMessage = "It's broken"
160+
do {
161+
try sut.timing(key: "key", tags: ["some": "tag"]) {
162+
currentDate.setDate(date: currentDate.date().addingTimeInterval(1.0))
163+
throw MetricsAPIError.runtimeError(errorMessage)
164+
}
165+
} catch MetricsAPIError.runtimeError(let actualErrorMessage) {
166+
expect(actualErrorMessage) == errorMessage
167+
}
168+
169+
sut.flush()
170+
transaction.finish()
171+
172+
expect(metricsClient.captureInvocations.count) == 1
173+
let buckets = try XCTUnwrap(metricsClient.captureInvocations.first)
174+
175+
let bucket = try XCTUnwrap(buckets.first?.value)
176+
expect(bucket.count) == 1
177+
let metric = try XCTUnwrap(bucket.first as? DistributionMetric)
178+
179+
expect(metric.key) == "key"
180+
expect(metric.serialize()) == ["1.0"]
181+
expect(metric.unit.unit) == MeasurementUnitDuration.second.unit
182+
expect(metric.tags) == ["some": "tag", "release": options.releaseName, "environment": options.environment]
183+
}
184+
185+
func testTiming_WithCurrentSpan_CreatesSpanWithMetricsSummary() throws {
186+
let (sut, _, currentDate, testHub, options) = try getSut()
187+
188+
let transaction = testHub.startTransaction(name: "hello", operation: "operation", bindToScope: true)
189+
190+
sut.timing(key: "key", tags: ["some": "tag"]) {
191+
currentDate.setDate(date: currentDate.date().addingTimeInterval(1.0))
192+
}
193+
194+
transaction.finish()
195+
196+
expect(testHub.capturedTransactionsWithScope.count) == 1
197+
let serializedTransaction = try XCTUnwrap(testHub.capturedTransactionsWithScope.first?.transaction)
198+
expect(serializedTransaction.count) != 0
199+
200+
let spans = try XCTUnwrap(serializedTransaction["spans"] as? [[String: Any]])
201+
expect(spans.count) == 1
202+
let span = try XCTUnwrap(spans.first)
203+
204+
expect(span["op"] as? String) == "metric.timing"
205+
expect(span["description"] as? String) == "key"
206+
expect(span["origin"] as? String) == "manual"
207+
expect(span["start_timestamp"] as? Int) == 978_307_200
208+
expect(span["timestamp"] as? Int) == 978_307_201
209+
expect(span["parent_span_id"] as? String) == transaction.spanId.sentrySpanIdString
210+
expect(span["tags"] as? [String: String]) == ["some": "tag", "release": options.releaseName, "environment": options.environment]
211+
212+
let metricsSummary = try XCTUnwrap(span["_metrics_summary"] as? [String: [[String: Any]]])
213+
expect(metricsSummary.count) == 1
214+
215+
let bucket = try XCTUnwrap(metricsSummary["d:key@second"] )
216+
expect(bucket.count) == 1
217+
let metric = try XCTUnwrap(bucket.first)
218+
expect(metric["min"] as? Double) == 1.0
219+
expect(metric["max"] as? Double) == 1.0
220+
expect(metric["count"] as? Int) == 1
221+
expect(metric["sum"] as? Double) == 1.0
222+
}
223+
224+
enum MetricsAPIError: Error {
225+
case runtimeError(String)
226+
}
227+
}
228+
229+
class TestSentryMetricsAPIDelegate: SentryMetricsAPIDelegate {
230+
var currentSpan: SentrySpan?
231+
114232
func getDefaultTagsForMetrics() -> [String: String] {
115233
return ["some": "tag", "yeah": "not-taken"]
116234
}
117235

118236
func getLocalMetricsAggregator() -> Sentry.LocalMetricsAggregator? {
119-
return nil
237+
return currentSpan?.getLocalMetricsAggregator()
238+
}
239+
240+
func getCurrentSpan() -> Span? {
241+
return currentSpan
242+
}
243+
244+
func getLocalMetricsAggregator(span: Span) -> Sentry.LocalMetricsAggregator? {
245+
return (span as? SentrySpan)?.getLocalMetricsAggregator()
120246
}
121247
}

0 commit comments

Comments
 (0)