Skip to content

Commit

Permalink
chore(metrics): Add BucketAggregator (#3762)
Browse files Browse the repository at this point in the history
Add BucketMetricsAggregator to aggregate metrics in timestamp buckets to
avoid sending multiple HTTP requests for every added metric.
  • Loading branch information
philipphofmann authored Mar 20, 2024
1 parent d9cd5f1 commit 1656cf6
Show file tree
Hide file tree
Showing 6 changed files with 570 additions and 7 deletions.
20 changes: 18 additions & 2 deletions Sentry.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@
622C08DB29E554B9002571D4 /* SentrySpanContext+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 622C08D929E554B9002571D4 /* SentrySpanContext+Private.h */; };
62375FB92B47F9F000CC55F1 /* SentryDependencyContainerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62375FB82B47F9F000CC55F1 /* SentryDependencyContainerTests.swift */; };
623C45B02A651D8200D9E88B /* SentryCoreDataTracker+Test.m in Sources */ = {isa = PBXBuildFile; fileRef = 623C45AF2A651D8200D9E88B /* SentryCoreDataTracker+Test.m */; };
626866722BA89641006995EA /* MetricsAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626866712BA89641006995EA /* MetricsAggregator.swift */; };
626866742BA89683006995EA /* BucketMetricsAggregatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626866732BA89683006995EA /* BucketMetricsAggregatorTests.swift */; };
626866762BA896AD006995EA /* TestMetricsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626866752BA896AD006995EA /* TestMetricsClient.swift */; };
626866782BA89928006995EA /* BucketsMetricsAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626866772BA89928006995EA /* BucketsMetricsAggregator.swift */; };
6271ADF32BA06D9B0098D2E9 /* SentryInternalSerializable.h in Headers */ = {isa = PBXBuildFile; fileRef = 6271ADF22BA06D9B0098D2E9 /* SentryInternalSerializable.h */; };
627E7589299F6FE40085504D /* SentryInternalDefines.h in Headers */ = {isa = PBXBuildFile; fileRef = 627E7588299F6FE40085504D /* SentryInternalDefines.h */; };
62862B1C2B1DDBC8009B16E3 /* SentryDelayedFrame.h in Headers */ = {isa = PBXBuildFile; fileRef = 62862B1B2B1DDBC8009B16E3 /* SentryDelayedFrame.h */; };
Expand Down Expand Up @@ -988,6 +992,10 @@
62375FB82B47F9F000CC55F1 /* SentryDependencyContainerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryDependencyContainerTests.swift; sourceTree = "<group>"; };
623C45AE2A651C4500D9E88B /* SentryCoreDataTracker+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SentryCoreDataTracker+Test.h"; sourceTree = "<group>"; };
623C45AF2A651D8200D9E88B /* SentryCoreDataTracker+Test.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SentryCoreDataTracker+Test.m"; sourceTree = "<group>"; };
626866712BA89641006995EA /* MetricsAggregator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsAggregator.swift; sourceTree = "<group>"; };
626866732BA89683006995EA /* BucketMetricsAggregatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketMetricsAggregatorTests.swift; sourceTree = "<group>"; };
626866752BA896AD006995EA /* TestMetricsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMetricsClient.swift; sourceTree = "<group>"; };
626866772BA89928006995EA /* BucketsMetricsAggregator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketsMetricsAggregator.swift; sourceTree = "<group>"; };
6271ADF22BA06D9B0098D2E9 /* SentryInternalSerializable.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryInternalSerializable.h; path = include/SentryInternalSerializable.h; sourceTree = "<group>"; };
627E7588299F6FE40085504D /* SentryInternalDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryInternalDefines.h; path = include/SentryInternalDefines.h; sourceTree = "<group>"; };
62862B1B2B1DDBC8009B16E3 /* SentryDelayedFrame.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryDelayedFrame.h; path = include/SentryDelayedFrame.h; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1927,10 +1935,12 @@
62262B892BA1C4B0004DA3DD /* Metrics */ = {
isa = PBXGroup;
children = (
62262B8A2BA1C4C1004DA3DD /* EncodeMetrics.swift */,
62BAD7552BA202C300EBAAFC /* SentryMetricsClient.swift */,
62262B8C2BA1C4DB004DA3DD /* Metric.swift */,
62262B902BA1C520004DA3DD /* CounterMetric.swift */,
626866712BA89641006995EA /* MetricsAggregator.swift */,
626866772BA89928006995EA /* BucketsMetricsAggregator.swift */,
62262B8A2BA1C4C1004DA3DD /* EncodeMetrics.swift */,
62BAD7552BA202C300EBAAFC /* SentryMetricsClient.swift */,
);
path = Metrics;
sourceTree = "<group>";
Expand All @@ -1940,6 +1950,8 @@
children = (
62262B952BA1C564004DA3DD /* EncodeMetricTests.swift */,
62BAD74F2BA1C5AF00EBAAFC /* SentryMetricsClientTests.swift */,
626866732BA89683006995EA /* BucketMetricsAggregatorTests.swift */,
626866752BA896AD006995EA /* TestMetricsClient.swift */,
);
path = Metrics;
sourceTree = "<group>";
Expand Down Expand Up @@ -4348,6 +4360,8 @@
D8CAC0412BA0984500E38F34 /* SentryIntegrationProtocol.swift in Sources */,
63FE710F20DA4C1000CDBAE8 /* NSError+SentrySimpleConstructor.m in Sources */,
8ECC674925C23A20000E2BF6 /* SentrySpanId.m in Sources */,
626866722BA89641006995EA /* MetricsAggregator.swift in Sources */,
626866782BA89928006995EA /* BucketsMetricsAggregator.swift in Sources */,
6344DDB51EC309E000D9160D /* SentryCrashReportSink.m in Sources */,
8EAE9806261E87120073B6B3 /* SentryUIViewControllerPerformanceTracker.m in Sources */,
D88817D826D7149100BF2251 /* SentryTraceContext.m in Sources */,
Expand Down Expand Up @@ -4439,6 +4453,7 @@
8EE017A126704CD500470616 /* SentryUIViewControllerPerformanceTrackerTests.swift in Sources */,
7B18DE4428D9F8F6004845C6 /* TestNSNotificationCenterWrapper.swift in Sources */,
7B5B94352657AD21002E474B /* SentryFramesTrackingIntegrationTests.swift in Sources */,
626866762BA896AD006995EA /* TestMetricsClient.swift in Sources */,
8431EE5B29ADB8EA00D8DC56 /* SentryTimeTests.m in Sources */,
7B0A54562523178700A71716 /* SentryScopeSwiftTests.swift in Sources */,
7B5B94332657A816002E474B /* SentryAppStartTrackingIntegrationTests.swift in Sources */,
Expand Down Expand Up @@ -4606,6 +4621,7 @@
7BA61CAF247BBF3C00C130A8 /* SentryDebugImageProviderTests.swift in Sources */,
7BB7E7C729267A28004BF96B /* EmptyIntegration.swift in Sources */,
7B965728268321CD00C66E25 /* SentryCrashScopeObserverTests.swift in Sources */,
626866742BA89683006995EA /* BucketMetricsAggregatorTests.swift in Sources */,
7BD86ECB264A6DB5005439DB /* TestSysctl.swift in Sources */,
7B0DC73428869BF40039995F /* NSMutableDictionarySentryTests.swift in Sources */,
7B6ADFCF26A02CAE0076C206 /* SentryCrashReportTests.swift in Sources */,
Expand Down
163 changes: 163 additions & 0 deletions Sources/Swift/Metrics/BucketsMetricsAggregator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
@_implementationOnly import _SentryPrivate

/// The bucket timestamp is calculated:
/// ( timeIntervalSince1970 / ROLLUP_IN_SECONDS ) * ROLLUP_IN_SECONDS
typealias BucketTimestamp = UInt64
let ROLLUP_IN_SECONDS: TimeInterval = 10

extension SentryCurrentDateProvider {
var bucketTimestamp: BucketTimestamp {
let now = self.date()
let seconds = now.timeIntervalSince1970

return (UInt64(seconds) / UInt64(ROLLUP_IN_SECONDS)) * UInt64(ROLLUP_IN_SECONDS)
}
}

class BucketMetricsAggregator: MetricsAggregator {

private let client: SentryMetricsClient
private let currentDate: SentryCurrentDateProvider
private let dispatchQueue: SentryDispatchQueueWrapper
private let random: SentryRandomProtocol
private let totalMaxWeight: UInt
private let flushShift: TimeInterval
private let flushInterval: TimeInterval
private let flushTolerance: TimeInterval

private var timer: DispatchSourceTimer?
private var totalBucketsWeight: UInt = 0
private var buckets: [BucketTimestamp: [String: Metric]] = [:]
private let lock = NSLock()

init(
client: SentryMetricsClient,
currentDate: SentryCurrentDateProvider,
dispatchQueue: SentryDispatchQueueWrapper,
random: SentryRandomProtocol,
totalMaxWeight: UInt = METRICS_AGGREGATOR_TOTAL_MAX_WEIGHT,
flushInterval: TimeInterval = METRICS_AGGREGATOR_FLUSH_INTERVAL,
flushTolerance: TimeInterval = METRICS_AGGREGATOR_FLUSH_TOLERANCE
) {
self.client = client
self.currentDate = currentDate
self.dispatchQueue = dispatchQueue
self.random = random

// 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
// buckets are anchored to multiples of ROLLUP seconds. We randomize this
// number once per aggregator boot to achieve some level of offsetting
// across a fleet of deployed SDKs.
let flushShift = random.nextNumber() * ROLLUP_IN_SECONDS
self.totalMaxWeight = totalMaxWeight
self.flushInterval = flushInterval
self.flushShift = flushShift
self.flushTolerance = flushTolerance

startTimer()
}

private func startTimer() {
let timer = DispatchSource.makeTimerSource(flags: [], queue: dispatchQueue.queue)

// Set leeway to reduce energy impact
let leewayInMilliseconds: Int = Int(flushTolerance * 1_000)
timer.schedule(deadline: .now() + flushInterval, repeating: self.flushInterval, leeway: .milliseconds(leewayInMilliseconds))
timer.setEventHandler { [weak self] in
self?.flush(force: false)
}
timer.activate()
self.timer = timer
}

func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String]) {

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

let bucketTimestamp = currentDate.bucketTimestamp

var isOverWeight = false

lock.synchronized {
var bucket = buckets[bucketTimestamp] ?? [:]

let metric = bucket[bucketKey] ?? CounterMetric(key: key, unit: unit, tags: tags)
let oldWeight = bucket[bucketKey]?.weight ?? 0

metric.add(value: value)
let addedWeight = metric.weight - oldWeight

bucket[bucketKey] = metric
totalBucketsWeight += addedWeight

buckets[bucketTimestamp] = bucket

let totalWeight = UInt(buckets.count) + totalBucketsWeight
isOverWeight = totalWeight >= totalMaxWeight
}

if isOverWeight {
dispatchQueue.dispatchAsync({ [weak self] in
self?.flush(force: true)
})
}
}

func flush(force: Bool) {
var flushableBuckets: [BucketTimestamp: [Metric]] = [:]

if force {
lock.synchronized {
for (timestamp, metrics) in buckets {
flushableBuckets[timestamp] = Array(metrics.values)
}

buckets.removeAll()
totalBucketsWeight = 0
}
} else {
let cutoff = BucketTimestamp(currentDate.date().timeIntervalSince1970 - ROLLUP_IN_SECONDS - flushShift)

lock.synchronized {
for (bucketTimestamp, bucket) in buckets {
if bucketTimestamp <= cutoff {
flushableBuckets[bucketTimestamp] = Array(bucket.values)
}
}

var weightToRemove: UInt = 0
for (bucketTimestamp, metrics) in flushableBuckets {
for metric in metrics {
weightToRemove += metric.weight
}
buckets.removeValue(forKey: bucketTimestamp)
}

totalBucketsWeight -= weightToRemove
}
}

if !flushableBuckets.isEmpty {
client.capture(flushableBuckets: flushableBuckets)
}
}

func close() {
self.flush(force: true)

cancelTimer()
}

deinit {
cancelTimer()
}

private func cancelTimer() {
self.timer?.cancel()
self.timer = nil
}
}
5 changes: 0 additions & 5 deletions Sources/Swift/Metrics/Metric.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import Foundation

/// The bucket timestamp is calculated:
/// ( timeIntervalSince1970 / ROLLUP_IN_SECONDS ) * ROLLUP_IN_SECONDS
typealias BucketTimestamp = UInt64
private let ROLLUP_IN_SECONDS: TimeInterval = 10

typealias Metric = MetricBase & MetricProtocol

protocol MetricProtocol {
Expand Down
27 changes: 27 additions & 0 deletions Sources/Swift/Metrics/MetricsAggregator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation

let METRICS_AGGREGATOR_TOTAL_MAX_WEIGHT: UInt = 1_000
let METRICS_AGGREGATOR_FLUSH_INTERVAL: TimeInterval = 10.0
let METRICS_AGGREGATOR_FLUSH_TOLERANCE: TimeInterval = 0.5

protocol MetricsAggregator {
func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String])

func flush(force: Bool)
func close()
}

class NoOpMetricsAggregator: MetricsAggregator {

func add(type: MetricType, key: String, value: Double, unit: MeasurementUnit, tags: [String: String]) {
// empty on purpose
}

func flush(force: Bool) {
// empty on purpose
}

func close() {
// empty on purpose
}
}
Loading

0 comments on commit 1656cf6

Please sign in to comment.