Skip to content

Commit 59c1b97

Browse files
chore(metrics): Add LocaleMetricsAggregator (#3773)
Add LocalMetricsAggregator, which is the foundation for correlating metrics to spans.
1 parent db533ee commit 59c1b97

7 files changed

Lines changed: 250 additions & 7 deletions

File tree

.swiftlint.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ only_rules:
3535
- force_try
3636
- force_unwrapping
3737
- function_body_length
38-
- function_parameter_count
3938
- generic_type_name
4039
- implicit_getter
4140
- large_tuple

Sentry.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@
121121
62C3168B2B1F865A000D7031 /* SentryTimeSwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C3168A2B1F865A000D7031 /* SentryTimeSwiftTests.swift */; };
122122
62E081A929ED4260000F69FC /* SentryBreadcrumbDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 62E081A829ED4260000F69FC /* SentryBreadcrumbDelegate.h */; };
123123
62E081AB29ED4322000F69FC /* SentryBreadcrumbTestDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E081AA29ED4322000F69FC /* SentryBreadcrumbTestDelegate.swift */; };
124+
62E146D02BAAE47600ED34FD /* LocalMetricsAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E146CF2BAAE47600ED34FD /* LocalMetricsAggregator.swift */; };
125+
62E146D22BAAF55B00ED34FD /* LocalMetricsAggregatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E146D12BAAF55B00ED34FD /* LocalMetricsAggregatorTests.swift */; };
124126
62F226B729A37C120038080D /* SentryBooleanSerialization.m in Sources */ = {isa = PBXBuildFile; fileRef = 62F226B629A37C120038080D /* SentryBooleanSerialization.m */; };
125127
630435FE1EBCA9D900C4D3FA /* SentryNSURLRequest.h in Headers */ = {isa = PBXBuildFile; fileRef = 630435FC1EBCA9D900C4D3FA /* SentryNSURLRequest.h */; };
126128
630435FF1EBCA9D900C4D3FA /* SentryNSURLRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = 630435FD1EBCA9D900C4D3FA /* SentryNSURLRequest.m */; };
@@ -1034,6 +1036,8 @@
10341036
62C3168A2B1F865A000D7031 /* SentryTimeSwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTimeSwiftTests.swift; sourceTree = "<group>"; };
10351037
62E081A829ED4260000F69FC /* SentryBreadcrumbDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryBreadcrumbDelegate.h; path = include/SentryBreadcrumbDelegate.h; sourceTree = "<group>"; };
10361038
62E081AA29ED4322000F69FC /* SentryBreadcrumbTestDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryBreadcrumbTestDelegate.swift; sourceTree = "<group>"; };
1039+
62E146CF2BAAE47600ED34FD /* LocalMetricsAggregator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMetricsAggregator.swift; sourceTree = "<group>"; };
1040+
62E146D12BAAF55B00ED34FD /* LocalMetricsAggregatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMetricsAggregatorTests.swift; sourceTree = "<group>"; };
10371041
62F226B629A37C120038080D /* SentryBooleanSerialization.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryBooleanSerialization.m; sourceTree = "<group>"; };
10381042
62F226B829A37C270038080D /* SentryBooleanSerialization.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SentryBooleanSerialization.h; sourceTree = "<group>"; };
10391043
62F605422B9A099100582E47 /* SentryCurrentDateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCurrentDateProvider.swift; sourceTree = "<group>"; };
@@ -1956,6 +1960,7 @@
19561960
62A2F4412BA9AE12000C9FDD /* SetMetric.swift */,
19571961
626866712BA89641006995EA /* MetricsAggregator.swift */,
19581962
626866772BA89928006995EA /* BucketsMetricsAggregator.swift */,
1963+
62E146CF2BAAE47600ED34FD /* LocalMetricsAggregator.swift */,
19591964
62262B8A2BA1C4C1004DA3DD /* EncodeMetrics.swift */,
19601965
62BAD7552BA202C300EBAAFC /* SentryMetricsClient.swift */,
19611966
);
@@ -1968,6 +1973,7 @@
19681973
62262B952BA1C564004DA3DD /* EncodeMetricTests.swift */,
19691974
62BAD74F2BA1C5AF00EBAAFC /* SentryMetricsClientTests.swift */,
19701975
626866732BA89683006995EA /* BucketMetricsAggregatorTests.swift */,
1976+
62E146D12BAAF55B00ED34FD /* LocalMetricsAggregatorTests.swift */,
19711977
626866752BA896AD006995EA /* TestMetricsClient.swift */,
19721978
62B0C30C2BA9D39600648D59 /* CounterMetricTests.swift */,
19731979
62B0C30E2BA9D74800648D59 /* DistributionMetricTests.swift */,
@@ -4304,6 +4310,7 @@
43044310
7B8ECBFC26498958005FE2EF /* SentryAppStateManager.m in Sources */,
43054311
62262B8D2BA1C4DB004DA3DD /* Metric.swift in Sources */,
43064312
7B2A70DD27D6083D008B0D15 /* SentryThreadWrapper.m in Sources */,
4313+
62E146D02BAAE47600ED34FD /* LocalMetricsAggregator.swift in Sources */,
43074314
D8ACE3C72762187200F5A213 /* SentryNSDataSwizzling.m in Sources */,
43084315
638DC9A11EBC6B6400A66E41 /* SentryRequestOperation.m in Sources */,
43094316
63AA767A1EB8D20500D153DE /* SentryLog.m in Sources */,
@@ -4448,6 +4455,7 @@
44484455
7B68345128F7EB3D00FB7064 /* SentryMeasurementUnitTests.swift in Sources */,
44494456
7B14089A248791660035403D /* SentryCrashStackEntryMapperTests.swift in Sources */,
44504457
D85790292976A69F00C6AC1F /* TestDebugImageProvider.swift in Sources */,
4458+
62E146D22BAAF55B00ED34FD /* LocalMetricsAggregatorTests.swift in Sources */,
44514459
7B869EBC249B91D8004F4FDB /* SentryDebugMetaEquality.swift in Sources */,
44524460
7B01CE3D271993AC00B5AF31 /* SentryTransportFactoryTests.swift in Sources */,
44534461
7B30B68026527C3C006B2752 /* SentryFramesTrackerTests.swift in Sources */,

Sources/Swift/Metrics/BucketsMetricsAggregator.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,9 @@ class BucketMetricsAggregator: MetricsAggregator {
7171
self.timer = timer
7272
}
7373

74-
func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String]) {
74+
func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String], localMetricsAggregator: LocalMetricsAggregator? = nil) {
7575

76-
// It's important to sort the tags in order to
77-
// obtain the same bucket key.
78-
let tagsKey = tags.sorted(by: { $0.key < $1.key }).map({ "\($0.key)=\($0.value)" }).joined(separator: ",")
76+
let tagsKey = tags.getMetricsTagsKey()
7977
let bucketKey = "\(type)_\(key)_\(unit.unit)_\(tagsKey)"
8078

8179
let bucketTimestamp = currentDate.bucketTimestamp
@@ -101,6 +99,12 @@ class BucketMetricsAggregator: MetricsAggregator {
10199

102100
let totalWeight = UInt(buckets.count) + totalBucketsWeight
103101
isOverWeight = totalWeight >= totalMaxWeight
102+
103+
// 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
104+
if localMetricsAggregator != nil {
105+
let localValue = type == .set ? Double(addedWeight) : value
106+
localMetricsAggregator?.add(type: type, key: key, value: localValue, unit: unit, tags: tags)
107+
}
104108
}
105109

106110
if isOverWeight {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import Foundation
2+
3+
/// Used for correlating metrics to spans. See https://github.com/getsentry/rfcs/blob/main/text/0123-metrics-correlation.md
4+
///
5+
class LocalMetricsAggregator {
6+
7+
private struct Metric {
8+
let min: Double
9+
let max: Double
10+
let count: Int
11+
let sum: Double
12+
let tags: [String: String]
13+
}
14+
15+
private var metricBuckets: [String: [String: Metric]] = [:]
16+
private let lock = NSLock()
17+
18+
func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String]) {
19+
let exportKey = "\(type.rawValue):\(key)@\(unit.unit)"
20+
let tagsKey = tags.getMetricsTagsKey()
21+
22+
lock.synchronized {
23+
var bucket = metricBuckets[exportKey] ?? [:]
24+
25+
var metric = bucket[tagsKey] ?? Metric(min: value, max: value, count: 1, sum: value, tags: tags)
26+
27+
if bucket[tagsKey] != nil {
28+
let newMin = min(metric.min, value)
29+
let newMax = max(metric.max, value)
30+
let newSum = metric.sum + value
31+
let count = metric.count + 1
32+
33+
metric = Metric(min: newMin, max: newMax, count: count, sum: newSum, tags: tags)
34+
}
35+
36+
bucket[tagsKey] = metric
37+
metricBuckets[exportKey] = bucket
38+
}
39+
}
40+
41+
func serialize() -> [String: [[String: Any]]] {
42+
var returnValue: [String: [[String: Any]]] = [:]
43+
44+
lock.synchronized {
45+
46+
for (exportKey, bucket) in metricBuckets {
47+
48+
var metrics: [[String: Any]] = []
49+
for (_, metric) in bucket {
50+
var dict: [String: Any] = [
51+
"min": metric.min,
52+
"max": metric.max,
53+
"count": metric.count,
54+
"sum": metric.sum
55+
]
56+
if !metric.tags.isEmpty {
57+
dict["tags"] = metric.tags
58+
}
59+
60+
metrics.append(dict)
61+
}
62+
63+
returnValue[exportKey] = metrics
64+
}
65+
}
66+
67+
return returnValue
68+
}
69+
}

Sources/Swift/Metrics/MetricsAggregator.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
import Foundation
22

33
protocol MetricsAggregator {
4-
func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String])
4+
func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String], localMetricsAggregator: LocalMetricsAggregator?)
55

66
func flush(force: Bool)
77
func close()
88
}
99

10+
extension Dictionary where Key == String, Value == String {
11+
func getMetricsTagsKey() -> String {
12+
// It's important to sort the tags in order to
13+
// obtain the same bucket key.
14+
return self.sorted(by: { $0.key < $1.key }).map({ "\($0.key)=\($0.value)" }).joined(separator: ",")
15+
}
16+
}
17+
1018
class NoOpMetricsAggregator: MetricsAggregator {
1119

12-
func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String]) {
20+
func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String], localMetricsAggregator: LocalMetricsAggregator?) {
1321
// empty on purpose
1422
}
1523

Tests/SentryTests/Swift/Metrics/BucketMetricsAggregatorTests.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,4 +340,48 @@ final class BucketMetricsAggregatorTests: XCTestCase {
340340
sut.add(type: .set, key: "key\(i)", value: 1.1, unit: .none, tags: ["some": "tag"])
341341
})
342342
}
343+
344+
func testCounterMetricGetsForwardedToLocalAggregator() throws {
345+
let localMetricsAggregator = LocalMetricsAggregator()
346+
let (sut, _, _) = try getSut()
347+
348+
sut.add(type: .counter, key: "key1", value: 1.0, unit: MeasurementUnitDuration.day, tags: ["some": "tag"], localMetricsAggregator: localMetricsAggregator)
349+
350+
let serialized = localMetricsAggregator.serialize()
351+
expect(serialized.count) == 1
352+
let bucket = try XCTUnwrap(serialized["c:key1@day"])
353+
354+
expect(bucket.count) == 1
355+
let metric = try XCTUnwrap(bucket.first)
356+
357+
expect(metric["tags"] as? [String: String]) == ["some": "tag"]
358+
expect(metric["min"] as? Double) == 1.0
359+
expect(metric["max"] as? Double) == 1.0
360+
expect(metric["count"] as? Int) == 1
361+
expect(metric["sum"] as? Double) == 1.0
362+
}
363+
364+
func testSetMetricOnlyForwardsAddedWeight() throws {
365+
let localMetricsAggregator = LocalMetricsAggregator()
366+
let (sut, _, _) = try getSut()
367+
368+
sut.add(type: .set, key: "key1", value: 1.0, unit: MeasurementUnitDuration.day, tags: ["some": "tag"], localMetricsAggregator: localMetricsAggregator)
369+
// This one doesn't add new weight
370+
sut.add(type: .set, key: "key1", value: 1.0, unit: MeasurementUnitDuration.day, tags: ["some": "tag"], localMetricsAggregator: localMetricsAggregator)
371+
372+
let serialized = localMetricsAggregator.serialize()
373+
expect(serialized.count) == 1
374+
let bucket = try XCTUnwrap(serialized["s:key1@day"])
375+
376+
expect(bucket.count) == 1
377+
let metric = try XCTUnwrap(bucket.first)
378+
379+
expect(metric["tags"] as? [String: String]) == ["some": "tag"]
380+
// When no weight added the value is 0.0
381+
expect(metric["min"] as? Double) == 0.0
382+
expect(metric["max"] as? Double) == 1.0
383+
expect(metric["count"] as? Int) == 2
384+
expect(metric["sum"] as? Double) == 1.0
385+
}
386+
343387
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import Nimble
2+
@testable import Sentry
3+
import XCTest
4+
5+
final class LocalMetricsAggregatorTests: XCTestCase {
6+
7+
func testAddOneCounterMetric() throws {
8+
let sut = LocalMetricsAggregator()
9+
10+
sut.add(type: .counter, key: "key", value: 1.0, unit: MeasurementUnitDuration.second, tags: [:])
11+
12+
let serialized = sut.serialize()
13+
expect(serialized.count) == 1
14+
let bucket = try XCTUnwrap(serialized["c:key@second"])
15+
16+
expect(bucket.count) == 1
17+
let metric = try XCTUnwrap(bucket.first)
18+
19+
expect(metric["tags"]) == nil
20+
expect(metric["min"] as? Double) == 1.0
21+
expect(metric["max"] as? Double) == 1.0
22+
expect(metric["count"] as? Int) == 1
23+
expect(metric["sum"] as? Double) == 1.0
24+
}
25+
26+
func testAddTwoSameDistributionMetrics() throws {
27+
let sut = LocalMetricsAggregator()
28+
29+
sut.add(type: .distribution, key: "key", value: 1.0, unit: MeasurementUnitDuration.second, tags: [:])
30+
sut.add(type: .distribution, key: "key", value: 1.1, unit: MeasurementUnitDuration.second, tags: [:])
31+
32+
let serialized = sut.serialize()
33+
expect(serialized.count) == 1
34+
let bucket = try XCTUnwrap(serialized["d:key@second"])
35+
36+
expect(bucket.count) == 1
37+
let metric = try XCTUnwrap(bucket.first)
38+
39+
expect(metric["tags"]) == nil
40+
expect(metric["min"] as? Double) == 1.0
41+
expect(metric["max"] as? Double) == 1.1
42+
expect(metric["count"] as? Int) == 2
43+
expect(metric["sum"] as? Double) == 2.1
44+
}
45+
46+
func testAddTwoGaugeMetrics_WithDifferentTags() throws {
47+
let sut = LocalMetricsAggregator()
48+
49+
sut.add(type: .gauge, key: "key", value: 1.0, unit: MeasurementUnitDuration.second, tags: ["some0": "tag0"])
50+
sut.add(type: .gauge, key: "key", value: 10.0, unit: MeasurementUnitDuration.second, tags: ["some1": "tag1"])
51+
52+
let serialized = sut.serialize()
53+
expect(serialized.count) == 1
54+
let bucket = try XCTUnwrap(serialized["g:key@second"])
55+
56+
expect(bucket.count) == 2
57+
let metric0 = try XCTUnwrap(bucket.first { $0.contains { $0.value as? [String: String] == ["some0": "tag0"] } })
58+
59+
expect(metric0["min"] as? Double) == 1.0
60+
expect(metric0["max"] as? Double) == 1.0
61+
expect(metric0["count"] as? Int) == 1
62+
expect(metric0["sum"] as? Double) == 1.0
63+
64+
let metric1 = try XCTUnwrap(bucket.first { $0.contains { $0.value as? [String: String] == ["some1": "tag1"] } })
65+
66+
expect(metric1["min"] as? Double) == 10.0
67+
expect(metric1["max"] as? Double) == 10.0
68+
expect(metric1["count"] as? Int) == 1
69+
expect(metric1["sum"] as? Double) == 10.0
70+
}
71+
72+
func testAddTwoDifferentMetrics() throws {
73+
let sut = LocalMetricsAggregator()
74+
75+
sut.add(type: .gauge, key: "key", value: 1.0, unit: MeasurementUnitDuration.day, tags: ["some0": "tag0"])
76+
sut.add(type: .gauge, key: "key", value: 10.0, unit: MeasurementUnitDuration.second, tags: ["some1": "tag1"])
77+
sut.add(type: .gauge, key: "key", value: -10.0, unit: MeasurementUnitDuration.second, tags: ["some1": "tag1"])
78+
79+
let serialized = sut.serialize()
80+
expect(serialized.count) == 2
81+
let dayBucket = try XCTUnwrap(serialized["g:key@day"])
82+
83+
expect(dayBucket.count) == 1
84+
let dayMetric = try XCTUnwrap(dayBucket.first)
85+
expect(dayMetric["min"] as? Double) == 1.0
86+
expect(dayMetric["max"] as? Double) == 1.0
87+
expect(dayMetric["count"] as? Int) == 1
88+
expect(dayMetric["sum"] as? Double) == 1.0
89+
90+
let secondBucket = try XCTUnwrap(serialized["g:key@second"])
91+
expect(secondBucket.count) == 1
92+
let secondMetric = try XCTUnwrap(secondBucket.first)
93+
expect(secondMetric["min"] as? Double) == -10.0
94+
expect(secondMetric["max"] as? Double) == 10.0
95+
expect(secondMetric["count"] as? Int) == 2
96+
expect(secondMetric["sum"] as? Double) == 0.0
97+
}
98+
99+
func testWriteMultipleMetricsInParallel_DoesNotCrash() throws {
100+
let sut = LocalMetricsAggregator()
101+
102+
testConcurrentModifications(asyncWorkItems: 10, writeLoopCount: 100, writeWork: { i in
103+
sut.add(type: .counter, key: "key\(i)", value: 1.1, unit: .none, tags: ["some": "tag"])
104+
sut.add(type: .gauge, key: "key\(i)", value: 1.1, unit: .none, tags: ["some": "tag"])
105+
sut.add(type: .distribution, key: "key\(i)", value: 1.1, unit: .none, tags: ["some": "tag"])
106+
sut.add(type: .set, key: "key\(i)", value: 1.1, unit: .none, tags: ["some": "tag"])
107+
}, readWork: {
108+
expect(sut.serialize()) != nil
109+
})
110+
}
111+
}

0 commit comments

Comments
 (0)