Skip to content

Commit 1656cf6

Browse files
chore(metrics): Add BucketAggregator (#3762)
Add BucketMetricsAggregator to aggregate metrics in timestamp buckets to avoid sending multiple HTTP requests for every added metric.
1 parent d9cd5f1 commit 1656cf6

6 files changed

Lines changed: 570 additions & 7 deletions

File tree

Sentry.xcodeproj/project.pbxproj

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@
8585
622C08DB29E554B9002571D4 /* SentrySpanContext+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 622C08D929E554B9002571D4 /* SentrySpanContext+Private.h */; };
8686
62375FB92B47F9F000CC55F1 /* SentryDependencyContainerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62375FB82B47F9F000CC55F1 /* SentryDependencyContainerTests.swift */; };
8787
623C45B02A651D8200D9E88B /* SentryCoreDataTracker+Test.m in Sources */ = {isa = PBXBuildFile; fileRef = 623C45AF2A651D8200D9E88B /* SentryCoreDataTracker+Test.m */; };
88+
626866722BA89641006995EA /* MetricsAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626866712BA89641006995EA /* MetricsAggregator.swift */; };
89+
626866742BA89683006995EA /* BucketMetricsAggregatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626866732BA89683006995EA /* BucketMetricsAggregatorTests.swift */; };
90+
626866762BA896AD006995EA /* TestMetricsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626866752BA896AD006995EA /* TestMetricsClient.swift */; };
91+
626866782BA89928006995EA /* BucketsMetricsAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626866772BA89928006995EA /* BucketsMetricsAggregator.swift */; };
8892
6271ADF32BA06D9B0098D2E9 /* SentryInternalSerializable.h in Headers */ = {isa = PBXBuildFile; fileRef = 6271ADF22BA06D9B0098D2E9 /* SentryInternalSerializable.h */; };
8993
627E7589299F6FE40085504D /* SentryInternalDefines.h in Headers */ = {isa = PBXBuildFile; fileRef = 627E7588299F6FE40085504D /* SentryInternalDefines.h */; };
9094
62862B1C2B1DDBC8009B16E3 /* SentryDelayedFrame.h in Headers */ = {isa = PBXBuildFile; fileRef = 62862B1B2B1DDBC8009B16E3 /* SentryDelayedFrame.h */; };
@@ -988,6 +992,10 @@
988992
62375FB82B47F9F000CC55F1 /* SentryDependencyContainerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryDependencyContainerTests.swift; sourceTree = "<group>"; };
989993
623C45AE2A651C4500D9E88B /* SentryCoreDataTracker+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SentryCoreDataTracker+Test.h"; sourceTree = "<group>"; };
990994
623C45AF2A651D8200D9E88B /* SentryCoreDataTracker+Test.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SentryCoreDataTracker+Test.m"; sourceTree = "<group>"; };
995+
626866712BA89641006995EA /* MetricsAggregator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsAggregator.swift; sourceTree = "<group>"; };
996+
626866732BA89683006995EA /* BucketMetricsAggregatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketMetricsAggregatorTests.swift; sourceTree = "<group>"; };
997+
626866752BA896AD006995EA /* TestMetricsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMetricsClient.swift; sourceTree = "<group>"; };
998+
626866772BA89928006995EA /* BucketsMetricsAggregator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketsMetricsAggregator.swift; sourceTree = "<group>"; };
991999
6271ADF22BA06D9B0098D2E9 /* SentryInternalSerializable.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryInternalSerializable.h; path = include/SentryInternalSerializable.h; sourceTree = "<group>"; };
9921000
627E7588299F6FE40085504D /* SentryInternalDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryInternalDefines.h; path = include/SentryInternalDefines.h; sourceTree = "<group>"; };
9931001
62862B1B2B1DDBC8009B16E3 /* SentryDelayedFrame.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryDelayedFrame.h; path = include/SentryDelayedFrame.h; sourceTree = "<group>"; };
@@ -1927,10 +1935,12 @@
19271935
62262B892BA1C4B0004DA3DD /* Metrics */ = {
19281936
isa = PBXGroup;
19291937
children = (
1930-
62262B8A2BA1C4C1004DA3DD /* EncodeMetrics.swift */,
1931-
62BAD7552BA202C300EBAAFC /* SentryMetricsClient.swift */,
19321938
62262B8C2BA1C4DB004DA3DD /* Metric.swift */,
19331939
62262B902BA1C520004DA3DD /* CounterMetric.swift */,
1940+
626866712BA89641006995EA /* MetricsAggregator.swift */,
1941+
626866772BA89928006995EA /* BucketsMetricsAggregator.swift */,
1942+
62262B8A2BA1C4C1004DA3DD /* EncodeMetrics.swift */,
1943+
62BAD7552BA202C300EBAAFC /* SentryMetricsClient.swift */,
19341944
);
19351945
path = Metrics;
19361946
sourceTree = "<group>";
@@ -1940,6 +1950,8 @@
19401950
children = (
19411951
62262B952BA1C564004DA3DD /* EncodeMetricTests.swift */,
19421952
62BAD74F2BA1C5AF00EBAAFC /* SentryMetricsClientTests.swift */,
1953+
626866732BA89683006995EA /* BucketMetricsAggregatorTests.swift */,
1954+
626866752BA896AD006995EA /* TestMetricsClient.swift */,
19431955
);
19441956
path = Metrics;
19451957
sourceTree = "<group>";
@@ -4348,6 +4360,8 @@
43484360
D8CAC0412BA0984500E38F34 /* SentryIntegrationProtocol.swift in Sources */,
43494361
63FE710F20DA4C1000CDBAE8 /* NSError+SentrySimpleConstructor.m in Sources */,
43504362
8ECC674925C23A20000E2BF6 /* SentrySpanId.m in Sources */,
4363+
626866722BA89641006995EA /* MetricsAggregator.swift in Sources */,
4364+
626866782BA89928006995EA /* BucketsMetricsAggregator.swift in Sources */,
43514365
6344DDB51EC309E000D9160D /* SentryCrashReportSink.m in Sources */,
43524366
8EAE9806261E87120073B6B3 /* SentryUIViewControllerPerformanceTracker.m in Sources */,
43534367
D88817D826D7149100BF2251 /* SentryTraceContext.m in Sources */,
@@ -4439,6 +4453,7 @@
44394453
8EE017A126704CD500470616 /* SentryUIViewControllerPerformanceTrackerTests.swift in Sources */,
44404454
7B18DE4428D9F8F6004845C6 /* TestNSNotificationCenterWrapper.swift in Sources */,
44414455
7B5B94352657AD21002E474B /* SentryFramesTrackingIntegrationTests.swift in Sources */,
4456+
626866762BA896AD006995EA /* TestMetricsClient.swift in Sources */,
44424457
8431EE5B29ADB8EA00D8DC56 /* SentryTimeTests.m in Sources */,
44434458
7B0A54562523178700A71716 /* SentryScopeSwiftTests.swift in Sources */,
44444459
7B5B94332657A816002E474B /* SentryAppStartTrackingIntegrationTests.swift in Sources */,
@@ -4606,6 +4621,7 @@
46064621
7BA61CAF247BBF3C00C130A8 /* SentryDebugImageProviderTests.swift in Sources */,
46074622
7BB7E7C729267A28004BF96B /* EmptyIntegration.swift in Sources */,
46084623
7B965728268321CD00C66E25 /* SentryCrashScopeObserverTests.swift in Sources */,
4624+
626866742BA89683006995EA /* BucketMetricsAggregatorTests.swift in Sources */,
46094625
7BD86ECB264A6DB5005439DB /* TestSysctl.swift in Sources */,
46104626
7B0DC73428869BF40039995F /* NSMutableDictionarySentryTests.swift in Sources */,
46114627
7B6ADFCF26A02CAE0076C206 /* SentryCrashReportTests.swift in Sources */,
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
@_implementationOnly import _SentryPrivate
2+
3+
/// The bucket timestamp is calculated:
4+
/// ( timeIntervalSince1970 / ROLLUP_IN_SECONDS ) * ROLLUP_IN_SECONDS
5+
typealias BucketTimestamp = UInt64
6+
let ROLLUP_IN_SECONDS: TimeInterval = 10
7+
8+
extension SentryCurrentDateProvider {
9+
var bucketTimestamp: BucketTimestamp {
10+
let now = self.date()
11+
let seconds = now.timeIntervalSince1970
12+
13+
return (UInt64(seconds) / UInt64(ROLLUP_IN_SECONDS)) * UInt64(ROLLUP_IN_SECONDS)
14+
}
15+
}
16+
17+
class BucketMetricsAggregator: MetricsAggregator {
18+
19+
private let client: SentryMetricsClient
20+
private let currentDate: SentryCurrentDateProvider
21+
private let dispatchQueue: SentryDispatchQueueWrapper
22+
private let random: SentryRandomProtocol
23+
private let totalMaxWeight: UInt
24+
private let flushShift: TimeInterval
25+
private let flushInterval: TimeInterval
26+
private let flushTolerance: TimeInterval
27+
28+
private var timer: DispatchSourceTimer?
29+
private var totalBucketsWeight: UInt = 0
30+
private var buckets: [BucketTimestamp: [String: Metric]] = [:]
31+
private let lock = NSLock()
32+
33+
init(
34+
client: SentryMetricsClient,
35+
currentDate: SentryCurrentDateProvider,
36+
dispatchQueue: SentryDispatchQueueWrapper,
37+
random: SentryRandomProtocol,
38+
totalMaxWeight: UInt = METRICS_AGGREGATOR_TOTAL_MAX_WEIGHT,
39+
flushInterval: TimeInterval = METRICS_AGGREGATOR_FLUSH_INTERVAL,
40+
flushTolerance: TimeInterval = METRICS_AGGREGATOR_FLUSH_TOLERANCE
41+
) {
42+
self.client = client
43+
self.currentDate = currentDate
44+
self.dispatchQueue = dispatchQueue
45+
self.random = random
46+
47+
// The aggregator shifts its flushing by up to an entire rollup window to
48+
// avoid multiple clients trampling on end of a 10 second window as all the
49+
// buckets are anchored to multiples of ROLLUP seconds. We randomize this
50+
// number once per aggregator boot to achieve some level of offsetting
51+
// across a fleet of deployed SDKs.
52+
let flushShift = random.nextNumber() * ROLLUP_IN_SECONDS
53+
self.totalMaxWeight = totalMaxWeight
54+
self.flushInterval = flushInterval
55+
self.flushShift = flushShift
56+
self.flushTolerance = flushTolerance
57+
58+
startTimer()
59+
}
60+
61+
private func startTimer() {
62+
let timer = DispatchSource.makeTimerSource(flags: [], queue: dispatchQueue.queue)
63+
64+
// Set leeway to reduce energy impact
65+
let leewayInMilliseconds: Int = Int(flushTolerance * 1_000)
66+
timer.schedule(deadline: .now() + flushInterval, repeating: self.flushInterval, leeway: .milliseconds(leewayInMilliseconds))
67+
timer.setEventHandler { [weak self] in
68+
self?.flush(force: false)
69+
}
70+
timer.activate()
71+
self.timer = timer
72+
}
73+
74+
func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String]) {
75+
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: ",")
79+
let bucketKey = "\(type)_\(key)_\(unit.unit)_\(tagsKey)"
80+
81+
let bucketTimestamp = currentDate.bucketTimestamp
82+
83+
var isOverWeight = false
84+
85+
lock.synchronized {
86+
var bucket = buckets[bucketTimestamp] ?? [:]
87+
88+
let metric = bucket[bucketKey] ?? CounterMetric(key: key, unit: unit, tags: tags)
89+
let oldWeight = bucket[bucketKey]?.weight ?? 0
90+
91+
metric.add(value: value)
92+
let addedWeight = metric.weight - oldWeight
93+
94+
bucket[bucketKey] = metric
95+
totalBucketsWeight += addedWeight
96+
97+
buckets[bucketTimestamp] = bucket
98+
99+
let totalWeight = UInt(buckets.count) + totalBucketsWeight
100+
isOverWeight = totalWeight >= totalMaxWeight
101+
}
102+
103+
if isOverWeight {
104+
dispatchQueue.dispatchAsync({ [weak self] in
105+
self?.flush(force: true)
106+
})
107+
}
108+
}
109+
110+
func flush(force: Bool) {
111+
var flushableBuckets: [BucketTimestamp: [Metric]] = [:]
112+
113+
if force {
114+
lock.synchronized {
115+
for (timestamp, metrics) in buckets {
116+
flushableBuckets[timestamp] = Array(metrics.values)
117+
}
118+
119+
buckets.removeAll()
120+
totalBucketsWeight = 0
121+
}
122+
} else {
123+
let cutoff = BucketTimestamp(currentDate.date().timeIntervalSince1970 - ROLLUP_IN_SECONDS - flushShift)
124+
125+
lock.synchronized {
126+
for (bucketTimestamp, bucket) in buckets {
127+
if bucketTimestamp <= cutoff {
128+
flushableBuckets[bucketTimestamp] = Array(bucket.values)
129+
}
130+
}
131+
132+
var weightToRemove: UInt = 0
133+
for (bucketTimestamp, metrics) in flushableBuckets {
134+
for metric in metrics {
135+
weightToRemove += metric.weight
136+
}
137+
buckets.removeValue(forKey: bucketTimestamp)
138+
}
139+
140+
totalBucketsWeight -= weightToRemove
141+
}
142+
}
143+
144+
if !flushableBuckets.isEmpty {
145+
client.capture(flushableBuckets: flushableBuckets)
146+
}
147+
}
148+
149+
func close() {
150+
self.flush(force: true)
151+
152+
cancelTimer()
153+
}
154+
155+
deinit {
156+
cancelTimer()
157+
}
158+
159+
private func cancelTimer() {
160+
self.timer?.cancel()
161+
self.timer = nil
162+
}
163+
}

Sources/Swift/Metrics/Metric.swift

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

3-
/// The bucket timestamp is calculated:
4-
/// ( timeIntervalSince1970 / ROLLUP_IN_SECONDS ) * ROLLUP_IN_SECONDS
5-
typealias BucketTimestamp = UInt64
6-
private let ROLLUP_IN_SECONDS: TimeInterval = 10
7-
83
typealias Metric = MetricBase & MetricProtocol
94

105
protocol MetricProtocol {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Foundation
2+
3+
let METRICS_AGGREGATOR_TOTAL_MAX_WEIGHT: UInt = 1_000
4+
let METRICS_AGGREGATOR_FLUSH_INTERVAL: TimeInterval = 10.0
5+
let METRICS_AGGREGATOR_FLUSH_TOLERANCE: TimeInterval = 0.5
6+
7+
protocol MetricsAggregator {
8+
func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String])
9+
10+
func flush(force: Bool)
11+
func close()
12+
}
13+
14+
class NoOpMetricsAggregator: MetricsAggregator {
15+
16+
func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String]) {
17+
// empty on purpose
18+
}
19+
20+
func flush(force: Bool) {
21+
// empty on purpose
22+
}
23+
24+
func close() {
25+
// empty on purpose
26+
}
27+
}

0 commit comments

Comments
 (0)