From 21b6725ad609d576bae5c49a7030780fe3f05dc0 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Tue, 15 Oct 2024 16:09:14 -0400 Subject: [PATCH 1/6] Telemetry module for Swift --- Sources/Segment/Analytics.swift | 8 + Sources/Segment/Errors.swift | 9 + Sources/Segment/Plugins/StartupQueue.swift | 3 + Sources/Segment/Timeline.swift | 10 + Sources/Segment/Utilities/Telemetry.swift | 278 +++++++++++++++++++++ Tests/Segment-Tests/Telemetry_Tests.swift | 167 +++++++++++++ 6 files changed, 475 insertions(+) create mode 100644 Sources/Segment/Utilities/Telemetry.swift create mode 100644 Tests/Segment-Tests/Telemetry_Tests.swift diff --git a/Sources/Segment/Analytics.swift b/Sources/Segment/Analytics.swift index 99d0c83..9013e9d 100644 --- a/Sources/Segment/Analytics.swift +++ b/Sources/Segment/Analytics.swift @@ -87,6 +87,14 @@ public class Analytics { // Get everything running platformStartup() + + Telemetry.shared.increment(metric: Telemetry.INVOKE_METRIC) {it in + it["message"] = "configured" + it["apihost"] = configuration.values.apiHost + it["cdnhost"] = configuration.values.cdnHost + it["flush"] = + "at:\(configuration.values.flushAt) int:\(configuration.values.flushInterval) pol:\(configuration.values.flushPolicies.count)" + } } deinit { diff --git a/Sources/Segment/Errors.swift b/Sources/Segment/Errors.swift index 35f9ed0..5fc6d8b 100644 --- a/Sources/Segment/Errors.swift +++ b/Sources/Segment/Errors.swift @@ -70,6 +70,11 @@ extension Analytics { if fatal { exceptionFailure("A critical error occurred: \(translatedError)") } + Telemetry.shared.error(metric: Telemetry.INVOKE_ERROR_METRIC, log: Thread.callStackSymbols.joined(separator: "\n")) { + (_ it: inout [String: String]) in + it["error"] = "\(translatedError)" + it["writekey"] = configuration.values.writeKey + } } static public func reportInternalError(_ error: Error, fatal: Bool = false) { @@ -80,5 +85,9 @@ extension Analytics { if fatal { exceptionFailure("A critical error occurred: \(translatedError)") } + Telemetry.shared.error(metric: Telemetry.INVOKE_ERROR_METRIC, log: Thread.callStackSymbols.joined(separator: "\n")) { + (_ it: inout [String: String]) in + it["error"] = "\(translatedError)" + } } } diff --git a/Sources/Segment/Plugins/StartupQueue.swift b/Sources/Segment/Plugins/StartupQueue.swift index 6e7a347..0a5e9d3 100644 --- a/Sources/Segment/Plugins/StartupQueue.swift +++ b/Sources/Segment/Plugins/StartupQueue.swift @@ -20,6 +20,9 @@ public class StartupQueue: Plugin, Subscriber { analytics?.store.subscribe(self) { [weak self] (state: System) in self?.runningUpdate(state: state) } + if let store = analytics?.store { + Telemetry.shared.subscribe(store) + } } } diff --git a/Sources/Segment/Timeline.swift b/Sources/Segment/Timeline.swift index a61e199..e76f1f5 100644 --- a/Sources/Segment/Timeline.swift +++ b/Sources/Segment/Timeline.swift @@ -65,6 +65,11 @@ public class Timeline { internal class Mediator { internal func add(plugin: Plugin) { plugins.append(plugin) + Telemetry.shared.increment(metric: Telemetry.INTEGRATION_METRIC) { + (_ it: inout [String: String]) in + it["message"] = "added" + it["plugin"] = "\(plugin.type)-\(String(describing: plugin))" + } } internal func remove(plugin: Plugin) { @@ -86,6 +91,11 @@ internal class Mediator { } else { result = plugin.execute(event: r) } + Telemetry.shared.increment(metric: Telemetry.INTEGRATION_METRIC) { + (_ it: inout [String: String]) in + it["message"] = "event-\(r.type ?? "unknown")" + it["plugin"] = "\(plugin.type)-\(String(describing: plugin))" + } } } diff --git a/Sources/Segment/Utilities/Telemetry.swift b/Sources/Segment/Utilities/Telemetry.swift new file mode 100644 index 0000000..30cf422 --- /dev/null +++ b/Sources/Segment/Utilities/Telemetry.swift @@ -0,0 +1,278 @@ +import Foundation +import Sovran + +public struct RemoteMetric: Codable { + let type: String + let metric: String + var value: Int + let tags: [String: String] + let log: [String: String]? + + init(type: String, metric: String, value: Int, tags: [String: String], log: [String: String]? = nil) { + self.type = type + self.metric = metric + self.value = value + self.tags = tags + self.log = log + } +} + +private let METRIC_TYPE = "Counter" + +func logError(_ error: Error) { + Analytics.reportInternalError(error) +} + +public class Telemetry: Subscriber { + public static let shared = Telemetry(session: URLSession.shared) + private static let METRICS_BASE_TAG = "analytics_mobile" + public static let INVOKE_METRIC = "\(METRICS_BASE_TAG).invoke" + public static let INVOKE_ERROR_METRIC = "\(METRICS_BASE_TAG).invoke.error" + public static let INTEGRATION_METRIC = "\(METRICS_BASE_TAG).integration.invoke" + public static let INTEGRATION_ERROR_METRIC = "\(METRICS_BASE_TAG).integration.invoke.error" + + init(session: any HTTPSession) { + self.session = session + } + + public var enable: Bool = false { + didSet { + if enable { + start() + } + } + } + internal var session: any HTTPSession + public var host: String = HTTPClient.getDefaultAPIHost() + var sampleRate: Double = 0.10 + public var flushTimer: Int = 30 * 1000 + public var sendWriteKeyOnError: Bool = true + public var sendErrorLogData: Bool = false + public var errorHandler: ((Error) -> Void)? = logError + public var maxQueueSize: Int = 20 + var errorLogSizeMax: Int = 4000 + + static private let MAX_QUEUE_BYTES = 28000 + var maxQueueBytes: Int = MAX_QUEUE_BYTES { + didSet { + maxQueueBytes = min(maxQueueBytes, Telemetry.MAX_QUEUE_BYTES) + } + } + + public var queue = [RemoteMetric]() + private var queueBytes = 0 + private var queueSizeExceeded = false + private var seenErrors = [String: Int]() + public var started = false + private var rateLimitEndTime: TimeInterval = 0 + private var telemetryQueue = DispatchQueue(label: "telemetryQueue") + private var telemetryTimer: Timer? + + func start() { + guard enable, !started, sampleRate != 0.0 else { return } + started = true + + if Double.random(in: 0...1) > sampleRate { + resetQueue() + } + + telemetryTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(flushTimer) / 1000.0, repeats: true) { [weak self] _ in + if (!(self?.enable ?? false)) { + self?.started = false + self?.telemetryTimer?.invalidate() + } + self?.flush() + } + } + + func reset() { + telemetryTimer?.invalidate() + resetQueue() + seenErrors.removeAll() + started = false + rateLimitEndTime = 0 + } + + func increment(metric: String, buildTags: (inout [String: String]) -> Void) { + var tags = [String: String]() + buildTags(&tags) + + guard enable, sampleRate != 0.0, metric.hasPrefix(Telemetry.METRICS_BASE_TAG), !tags.isEmpty, queueHasSpace() else { return } + if Double.random(in: 0...1) > sampleRate { return } + + addRemoteMetric(metric: metric, tags: tags) + } + + func error(metric: String, log: String, buildTags: (inout [String: String]) -> Void) { + var tags = [String: String]() + buildTags(&tags) + + guard enable, sampleRate != 0.0, metric.hasPrefix(Telemetry.METRICS_BASE_TAG), !tags.isEmpty, queueHasSpace() else { return } + + var filteredTags = tags + if !sendWriteKeyOnError { + filteredTags = tags.filter { $0.key.lowercased() != "writekey" } + } + + var logData: String? = nil + if sendErrorLogData { + logData = String(log.prefix(errorLogSizeMax)) + } + + if let errorKey = tags["error"] { + if let count = seenErrors[errorKey] { + seenErrors[errorKey] = count + 1 + if Double.random(in: 0...1) > sampleRate { return } + addRemoteMetric(metric: metric, tags: filteredTags, value: Int(Double(count) * sampleRate), log: logData) + seenErrors[errorKey] = 0 + } else { + addRemoteMetric(metric: metric, tags: filteredTags, log: logData) + flush() + seenErrors[errorKey] = 0 + } + } else { + addRemoteMetric(metric: metric, tags: filteredTags, log: logData) + } + } + + @objc func flush() { + guard enable else { return } + + telemetryQueue.sync { + guard !queue.isEmpty else { return } + if rateLimitEndTime > Date().timeIntervalSince1970 { + return + } + rateLimitEndTime = 0 + + do { + try send() + queueBytes = 0 + } catch { + errorHandler?(error) + sampleRate = 0.0 + } + } + } + +private func send() throws { + guard sampleRate != 0.0 else { return } + + var sendQueue = [RemoteMetric]() + while !queue.isEmpty { + var metric = queue.removeFirst() + metric.value = Int(Double(metric.value) / sampleRate) + sendQueue.append(metric) + } + queueBytes = 0 + queueSizeExceeded = false + + let payload = try JSONEncoder().encode(["series": sendQueue]) + var request = upload(apiHost: host) + request.httpBody = payload + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + self.errorHandler?(error) + return + } + + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 429 { + if let retryAfter = httpResponse.allHeaderFields["Retry-After"] as? String, let retryAfterSeconds = TimeInterval(retryAfter) { + self.rateLimitEndTime = retryAfterSeconds + Date().timeIntervalSince1970 + } + } + } + task.resume() + } + + private var additionalTags: [String: String] { + var osVersion = ProcessInfo.processInfo.operatingSystemVersionString + let osRegex = try! NSRegularExpression(pattern: "[0-9]+", options: []) + if let match = osRegex.firstMatch(in: osVersion, options: [], range: NSRange(location: 0, length: osVersion.utf16.count)) { + osVersion = (osVersion as NSString).substring(with: match.range) + } + #if os(iOS) + osVersion = "iOS-\(osVersion)" + #elseif os(macOS) + osVersion = "macOS-\(osVersion)" + #elseif os(tvOS) + osVersion = "tvOS-\(osVersion)" + #elseif os(watchOS) + osVersion = "watchOS-\(osVersion)" + #else + osVersion = "unknown-\(osVersion)" + #endif + + return [ + "os": "\(osVersion)", + "library": "analytics.swift", + "library_version": __segment_version + ] + } + +private func addRemoteMetric(metric: String, tags: [String: String], value: Int = 1, log: String? = nil) { + let fullTags = tags.merging(additionalTags) { (_, new) in new } + + telemetryQueue.sync { + if var found = queue.first(where: { $0.metric == metric && $0.tags == fullTags }) { + found.value += value + return + } + + let newMetric = RemoteMetric( + type: METRIC_TYPE, + metric: metric, + value: value, + tags: fullTags, + log: log != nil ? ["timestamp": Date().iso8601() , "trace": log!] : nil + ) + let newMetricSize = String(describing: newMetric).data(using: .utf8)?.count ?? 0 + if queueBytes + newMetricSize <= maxQueueBytes { + queue.append(newMetric) + queueBytes += newMetricSize + } else { + queueSizeExceeded = true + } + } + } + + func subscribe(_ store: Store) { + store.subscribe(self, + initialState: true, + queue: telemetryQueue, + handler: systemUpdate + ) + } + + private func systemUpdate(system: System) { + if let settings = system.settings, let sampleRate = settings.metrics?["sampleRate"]?.doubleValue { + self.sampleRate = sampleRate + start() + } + } + + private func upload(apiHost: String) -> URLRequest { + var request = URLRequest(url: URL(string: "https://\(apiHost)/m")!) + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + return request + } + + private func queueHasSpace() -> Bool { + var over = false + telemetryQueue.sync { + over = queue.count < maxQueueSize + } + return over + } + + private func resetQueue() { + telemetryQueue.sync { + queue.removeAll() + queueBytes = 0 + queueSizeExceeded = false + } + } +} diff --git a/Tests/Segment-Tests/Telemetry_Tests.swift b/Tests/Segment-Tests/Telemetry_Tests.swift new file mode 100644 index 0000000..bf54dcc --- /dev/null +++ b/Tests/Segment-Tests/Telemetry_Tests.swift @@ -0,0 +1,167 @@ +import XCTest +@testable import Segment + +class TelemetryTests: XCTestCase { + + var errors: [String] = [] + + override func setUpWithError() throws { + Telemetry.shared.reset() + Telemetry.shared.errorHandler = { error in + self.errors.append("\(error)") + } + errors.removeAll() + Telemetry.shared.sampleRate = 1.0 + mockTelemetryHTTPClient() + } + + override func tearDownWithError() throws { + Telemetry.shared.reset() + } + + func mockTelemetryHTTPClient(telemetryHost: String = Telemetry.shared.host, shouldThrow: Bool = false) { + let sessionMock = URLSessionMock() + if shouldThrow { + sessionMock.shouldThrow = true + } + Telemetry.shared.session = sessionMock + } + + func testTelemetryStart() { + Telemetry.shared.sampleRate = 0.0 + Telemetry.shared.enable = true + Telemetry.shared.start() + XCTAssertFalse(Telemetry.shared.started) + + Telemetry.shared.sampleRate = 1.0 + Telemetry.shared.start() + XCTAssertTrue(Telemetry.shared.started) + XCTAssertTrue(errors.isEmpty) + } + + func testRollingUpDuplicateMetrics() { + Telemetry.shared.enable = true + Telemetry.shared.start() + for _ in 1...3 { + Telemetry.shared.increment(metric: Telemetry.INVOKE_METRIC) { $0["test"] = "test" } + Telemetry.shared.error(metric: Telemetry.INVOKE_ERROR_METRIC, log: "log") { $0["test"] = "test2" } + } + XCTAssertEqual(Telemetry.shared.queue.count, 2) + } + + func testIncrementWhenTelemetryIsDisabled() { + Telemetry.shared.enable = false + Telemetry.shared.start() + Telemetry.shared.increment(metric: Telemetry.INVOKE_METRIC) { $0["test"] = "test" } + XCTAssertEqual(Telemetry.shared.queue.count, 0) + XCTAssertTrue(errors.isEmpty) + } + + func testIncrementWithWrongMetric() { + Telemetry.shared.enable = true + Telemetry.shared.start() + Telemetry.shared.increment(metric: "wrong_metric") { $0["test"] = "test" } + XCTAssertEqual(Telemetry.shared.queue.count, 0) + XCTAssertTrue(errors.isEmpty) + } + + func testIncrementWithNoTags() { + Telemetry.shared.enable = true + Telemetry.shared.start() + Telemetry.shared.increment(metric: Telemetry.INVOKE_METRIC) { $0.removeAll() } + XCTAssertEqual(Telemetry.shared.queue.count, 0) + XCTAssertTrue(errors.isEmpty) + } + + func testErrorWhenTelemetryIsDisabled() { + Telemetry.shared.enable = false + Telemetry.shared.start() + Telemetry.shared.error(metric: Telemetry.INVOKE_ERROR_METRIC, log: "error") { $0["test"] = "test" } + XCTAssertEqual(Telemetry.shared.queue.count, 0) + XCTAssertTrue(errors.isEmpty) + } + + func testErrorWithNoTags() { + Telemetry.shared.enable = true + Telemetry.shared.start() + Telemetry.shared.error(metric: Telemetry.INVOKE_ERROR_METRIC, log: "error") { $0.removeAll() } + XCTAssertEqual(Telemetry.shared.queue.count, 0) + XCTAssertTrue(errors.isEmpty) + } + + func testFlushWorksEvenWhenTelemetryIsNotStarted() { + Telemetry.shared.increment(metric: Telemetry.INVOKE_METRIC) { $0["test"] = "test" } + Telemetry.shared.flush() + XCTAssertEqual(Telemetry.shared.queue.count, 0) + XCTAssertTrue(errors.isEmpty) + } + + func testFlushWhenTelemetryIsDisabled() { + Telemetry.shared.enable = true + Telemetry.shared.start() + Telemetry.shared.enable = false + Telemetry.shared.increment(metric: Telemetry.INVOKE_METRIC) { $0["test"] = "test" } + XCTAssertEqual(Telemetry.shared.queue.count, 0) + XCTAssertTrue(errors.isEmpty) + } + + func testFlushWithEmptyQueue() { + Telemetry.shared.enable = true + Telemetry.shared.start() + Telemetry.shared.flush() + XCTAssertEqual(Telemetry.shared.queue.count, 0) + XCTAssertTrue(errors.isEmpty) + } + + func testHTTPException() { + mockTelemetryHTTPClient(shouldThrow: true) + Telemetry.shared.enable = true + Telemetry.shared.start() + Telemetry.shared.error(metric: Telemetry.INVOKE_METRIC, log: "log") { $0["error"] = "test" } + XCTAssertEqual(Telemetry.shared.queue.count, 0) + XCTAssertEqual(errors.count, 1) + } + + func testIncrementAndErrorMethodsWhenQueueIsFull() { + Telemetry.shared.enable = true + Telemetry.shared.start() + for i in 1...Telemetry.shared.maxQueueSize + 1 { + Telemetry.shared.increment(metric: Telemetry.INVOKE_METRIC) { $0["test"] = "test\(i)" } + Telemetry.shared.error(metric: Telemetry.INVOKE_ERROR_METRIC, log: "error") { $0["test"] = "test\(i)" } + } + XCTAssertEqual(Telemetry.shared.queue.count, Telemetry.shared.maxQueueSize) + } + + func testErrorMethodWithDifferentFlagSettings() { + let longString = String(repeating: "a", count: 1000) + Telemetry.shared.enable = true + Telemetry.shared.start() + Telemetry.shared.sendWriteKeyOnError = false + Telemetry.shared.sendErrorLogData = false + Telemetry.shared.error(metric: Telemetry.INVOKE_ERROR_METRIC, log: longString) { $0["writekey"] = longString } + XCTAssertTrue(Telemetry.shared.queue.count < 1000) + } +} + +// Mock URLSession +class URLSessionMock: RestrictedHTTPSession { + typealias DataTaskType = DataTask + + typealias UploadTaskType = UploadTask + + var shouldThrow = false + + override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { + if shouldThrow { + completionHandler(nil, nil, NSError(domain: "Test", code: 1, userInfo: nil)) + } else { + completionHandler(nil, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil), nil) + } + return URLSessionDataTaskMock() + } +} + +// Mock URLSessionDataTask +class URLSessionDataTaskMock: URLSessionDataTask { + override func resume() {} +} \ No newline at end of file From e37d234077a004fcbcd536b0c2526c2c109fc8e3 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Wed, 16 Oct 2024 17:41:25 -0400 Subject: [PATCH 2/6] Addressing a few PR comments and ensuring telemetry is disabled for most tests --- Sources/Segment/Utilities/Telemetry.swift | 14 +++++++------- Tests/Segment-Tests/Analytics_Tests.swift | 3 +++ Tests/Segment-Tests/Atomic_Tests.swift | 3 +++ Tests/Segment-Tests/CompletionGroup_Tests.swift | 1 + Tests/Segment-Tests/FlushPolicy_Tests.swift | 1 + Tests/Segment-Tests/HTTPClient_Tests.swift | 1 + Tests/Segment-Tests/JSON_Tests.swift | 1 + Tests/Segment-Tests/KeyPath_Tests.swift | 1 + Tests/Segment-Tests/MemoryLeak_Tests.swift | 1 + Tests/Segment-Tests/ObjC_Tests.swift | 1 + Tests/Segment-Tests/Storage_Tests.swift | 1 + Tests/Segment-Tests/StressTests.swift | 1 + Tests/Segment-Tests/Telemetry_Tests.swift | 3 +-- Tests/Segment-Tests/Timeline_Tests.swift | 1 + Tests/Segment-Tests/UserAgentTests.swift | 1 + .../Segment-Tests/WindowsVendorSystem_Tests.swift | 5 +++++ Tests/Segment-Tests/iOSLifecycle_Tests.swift | 5 ++++- 17 files changed, 34 insertions(+), 10 deletions(-) diff --git a/Sources/Segment/Utilities/Telemetry.swift b/Sources/Segment/Utilities/Telemetry.swift index 30cf422..ddde71c 100644 --- a/Sources/Segment/Utilities/Telemetry.swift +++ b/Sources/Segment/Utilities/Telemetry.swift @@ -69,7 +69,7 @@ public class Telemetry: Subscriber { private var telemetryTimer: Timer? func start() { - guard enable, !started, sampleRate != 0.0 else { return } + guard enable, !started, sampleRate > 0.0 && sampleRate <= 1.0 else { return } started = true if Double.random(in: 0...1) > sampleRate { @@ -97,7 +97,7 @@ public class Telemetry: Subscriber { var tags = [String: String]() buildTags(&tags) - guard enable, sampleRate != 0.0, metric.hasPrefix(Telemetry.METRICS_BASE_TAG), !tags.isEmpty, queueHasSpace() else { return } + guard enable, sampleRate > 0.0 && sampleRate <= 1.0, metric.hasPrefix(Telemetry.METRICS_BASE_TAG), !tags.isEmpty, queueHasSpace() else { return } if Double.random(in: 0...1) > sampleRate { return } addRemoteMetric(metric: metric, tags: tags) @@ -107,7 +107,7 @@ public class Telemetry: Subscriber { var tags = [String: String]() buildTags(&tags) - guard enable, sampleRate != 0.0, metric.hasPrefix(Telemetry.METRICS_BASE_TAG), !tags.isEmpty, queueHasSpace() else { return } + guard enable, sampleRate > 0.0 && sampleRate <= 1.0, metric.hasPrefix(Telemetry.METRICS_BASE_TAG), !tags.isEmpty, queueHasSpace() else { return } var filteredTags = tags if !sendWriteKeyOnError { @@ -156,7 +156,7 @@ public class Telemetry: Subscriber { } private func send() throws { - guard sampleRate != 0.0 else { return } + guard sampleRate > 0.0 && sampleRate <= 1.0 else { return } var sendQueue = [RemoteMetric]() while !queue.isEmpty { @@ -261,11 +261,11 @@ private func addRemoteMetric(metric: String, tags: [String: String], value: Int } private func queueHasSpace() -> Bool { - var over = false + var under = false telemetryQueue.sync { - over = queue.count < maxQueueSize + under = queue.count < maxQueueSize } - return over + return under } private func resetQueue() { diff --git a/Tests/Segment-Tests/Analytics_Tests.swift b/Tests/Segment-Tests/Analytics_Tests.swift index 22ca5f5..601a0ec 100644 --- a/Tests/Segment-Tests/Analytics_Tests.swift +++ b/Tests/Segment-Tests/Analytics_Tests.swift @@ -2,6 +2,9 @@ import XCTest @testable import Segment final class Analytics_Tests: XCTestCase { + override func setUpWithError() throws { + Telemetry.shared.enable = false + } func testBaseEventCreation() { let analytics = Analytics(configuration: Configuration(writeKey: "test")) diff --git a/Tests/Segment-Tests/Atomic_Tests.swift b/Tests/Segment-Tests/Atomic_Tests.swift index d6b420b..f40bab5 100644 --- a/Tests/Segment-Tests/Atomic_Tests.swift +++ b/Tests/Segment-Tests/Atomic_Tests.swift @@ -2,6 +2,9 @@ import XCTest @testable import Segment final class Atomic_Tests: XCTestCase { + override func setUpWithError() throws { + Telemetry.shared.enable = false + } func testAtomicIncrement() { diff --git a/Tests/Segment-Tests/CompletionGroup_Tests.swift b/Tests/Segment-Tests/CompletionGroup_Tests.swift index a57fd82..f233cf6 100644 --- a/Tests/Segment-Tests/CompletionGroup_Tests.swift +++ b/Tests/Segment-Tests/CompletionGroup_Tests.swift @@ -12,6 +12,7 @@ final class CompletionGroup_Tests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false } override func tearDownWithError() throws { diff --git a/Tests/Segment-Tests/FlushPolicy_Tests.swift b/Tests/Segment-Tests/FlushPolicy_Tests.swift index 636a579..0f866e7 100644 --- a/Tests/Segment-Tests/FlushPolicy_Tests.swift +++ b/Tests/Segment-Tests/FlushPolicy_Tests.swift @@ -32,6 +32,7 @@ class FlushPolicyTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false } override func tearDownWithError() throws { diff --git a/Tests/Segment-Tests/HTTPClient_Tests.swift b/Tests/Segment-Tests/HTTPClient_Tests.swift index 6fe317b..0d19a53 100644 --- a/Tests/Segment-Tests/HTTPClient_Tests.swift +++ b/Tests/Segment-Tests/HTTPClient_Tests.swift @@ -14,6 +14,7 @@ class HTTPClientTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false RestrictedHTTPSession.reset() } diff --git a/Tests/Segment-Tests/JSON_Tests.swift b/Tests/Segment-Tests/JSON_Tests.swift index 43f13cf..01479e5 100644 --- a/Tests/Segment-Tests/JSON_Tests.swift +++ b/Tests/Segment-Tests/JSON_Tests.swift @@ -30,6 +30,7 @@ class JSONTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false } override func tearDownWithError() throws { diff --git a/Tests/Segment-Tests/KeyPath_Tests.swift b/Tests/Segment-Tests/KeyPath_Tests.swift index 263f99f..fa08df1 100644 --- a/Tests/Segment-Tests/KeyPath_Tests.swift +++ b/Tests/Segment-Tests/KeyPath_Tests.swift @@ -69,6 +69,7 @@ class KeyPath_Tests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false } override func tearDownWithError() throws { diff --git a/Tests/Segment-Tests/MemoryLeak_Tests.swift b/Tests/Segment-Tests/MemoryLeak_Tests.swift index 7a8ba98..1c1be91 100644 --- a/Tests/Segment-Tests/MemoryLeak_Tests.swift +++ b/Tests/Segment-Tests/MemoryLeak_Tests.swift @@ -12,6 +12,7 @@ final class MemoryLeak_Tests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false } override func tearDownWithError() throws { diff --git a/Tests/Segment-Tests/ObjC_Tests.swift b/Tests/Segment-Tests/ObjC_Tests.swift index 8198946..d2f765b 100644 --- a/Tests/Segment-Tests/ObjC_Tests.swift +++ b/Tests/Segment-Tests/ObjC_Tests.swift @@ -14,6 +14,7 @@ class ObjC_Tests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false } override func tearDownWithError() throws { diff --git a/Tests/Segment-Tests/Storage_Tests.swift b/Tests/Segment-Tests/Storage_Tests.swift index 4d6cb7e..116fdc7 100644 --- a/Tests/Segment-Tests/Storage_Tests.swift +++ b/Tests/Segment-Tests/Storage_Tests.swift @@ -12,6 +12,7 @@ class StorageTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false } override func tearDownWithError() throws { diff --git a/Tests/Segment-Tests/StressTests.swift b/Tests/Segment-Tests/StressTests.swift index 70168a0..6c7a61b 100644 --- a/Tests/Segment-Tests/StressTests.swift +++ b/Tests/Segment-Tests/StressTests.swift @@ -14,6 +14,7 @@ class StressTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false RestrictedHTTPSession.reset() } diff --git a/Tests/Segment-Tests/Telemetry_Tests.swift b/Tests/Segment-Tests/Telemetry_Tests.swift index bf54dcc..8b85e19 100644 --- a/Tests/Segment-Tests/Telemetry_Tests.swift +++ b/Tests/Segment-Tests/Telemetry_Tests.swift @@ -2,7 +2,6 @@ import XCTest @testable import Segment class TelemetryTests: XCTestCase { - var errors: [String] = [] override func setUpWithError() throws { @@ -162,6 +161,6 @@ class URLSessionMock: RestrictedHTTPSession { } // Mock URLSessionDataTask -class URLSessionDataTaskMock: URLSessionDataTask { +class URLSessionDataTaskMock: URLSessionDataTask, @unchecked Sendable { override func resume() {} } \ No newline at end of file diff --git a/Tests/Segment-Tests/Timeline_Tests.swift b/Tests/Segment-Tests/Timeline_Tests.swift index 04a2ea9..13e4ec8 100644 --- a/Tests/Segment-Tests/Timeline_Tests.swift +++ b/Tests/Segment-Tests/Timeline_Tests.swift @@ -12,6 +12,7 @@ class Timeline_Tests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false } override func tearDownWithError() throws { diff --git a/Tests/Segment-Tests/UserAgentTests.swift b/Tests/Segment-Tests/UserAgentTests.swift index 072c6d7..6c1f7b3 100644 --- a/Tests/Segment-Tests/UserAgentTests.swift +++ b/Tests/Segment-Tests/UserAgentTests.swift @@ -15,6 +15,7 @@ final class UserAgentTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false } override func tearDownWithError() throws { diff --git a/Tests/Segment-Tests/WindowsVendorSystem_Tests.swift b/Tests/Segment-Tests/WindowsVendorSystem_Tests.swift index ac79b99..86627f8 100644 --- a/Tests/Segment-Tests/WindowsVendorSystem_Tests.swift +++ b/Tests/Segment-Tests/WindowsVendorSystem_Tests.swift @@ -4,6 +4,11 @@ import XCTest #if os(Windows) final class WindowsVendorSystem_Tests: XCTestCase { + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + Telemetry.shared.enable = false + } + func testScreenSizeReturnsNonEmpty() { let system = WindowsVendorSystem() diff --git a/Tests/Segment-Tests/iOSLifecycle_Tests.swift b/Tests/Segment-Tests/iOSLifecycle_Tests.swift index 44fff33..fe8cfe7 100644 --- a/Tests/Segment-Tests/iOSLifecycle_Tests.swift +++ b/Tests/Segment-Tests/iOSLifecycle_Tests.swift @@ -3,7 +3,10 @@ import XCTest #if os(iOS) || os(tvOS) || os(visionOS) final class iOSLifecycle_Tests: XCTestCase { - + override func setUpWithError() throws { + Telemetry.shared.enable = false + } + func testInstallEventCreation() { let analytics = Analytics(configuration: Configuration(writeKey: "test")) let outputReader = OutputReaderPlugin() From acccc3445d7f92eb7e1e5278e87ea8cf20199331 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Tue, 22 Oct 2024 19:39:06 -0400 Subject: [PATCH 3/6] Additional telemetry --- Sources/Segment/Analytics.swift | 1 + Sources/Segment/Timeline.swift | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/Sources/Segment/Analytics.swift b/Sources/Segment/Analytics.swift index 9013e9d..a200829 100644 --- a/Sources/Segment/Analytics.swift +++ b/Sources/Segment/Analytics.swift @@ -94,6 +94,7 @@ public class Analytics { it["cdnhost"] = configuration.values.cdnHost it["flush"] = "at:\(configuration.values.flushAt) int:\(configuration.values.flushInterval) pol:\(configuration.values.flushPolicies.count)" + it["config"] = "seg:\(configuration.values.autoAddSegmentDestination) ua:\(configuration.values.userAgent ?? "N/A")" } } diff --git a/Sources/Segment/Timeline.swift b/Sources/Segment/Timeline.swift index e76f1f5..c62294a 100644 --- a/Sources/Segment/Timeline.swift +++ b/Sources/Segment/Timeline.swift @@ -74,6 +74,11 @@ internal class Mediator { internal func remove(plugin: Plugin) { plugins.removeAll { (storedPlugin) -> Bool in + Telemetry.shared.increment(metric: Telemetry.INTEGRATION_METRIC) { + (_ it: inout [String: String]) in + it["message"] = "removed" + it["plugin"] = "\(plugin.type)-\(String(describing: plugin))" + } return plugin === storedPlugin } } From 7cd8b97f4b00daad4cb8cf92dd1a44c89138a799 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Wed, 23 Oct 2024 18:34:01 -0400 Subject: [PATCH 4/6] Adding caller tag for error metric --- Sources/Segment/Errors.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Segment/Errors.swift b/Sources/Segment/Errors.swift index 5fc6d8b..cfd1464 100644 --- a/Sources/Segment/Errors.swift +++ b/Sources/Segment/Errors.swift @@ -74,6 +74,7 @@ extension Analytics { (_ it: inout [String: String]) in it["error"] = "\(translatedError)" it["writekey"] = configuration.values.writeKey + it["caller"] = Thread.callStackSymbols[3] } } From b5dc6f52d7201466896ca64e89e54a3b14af1c7b Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Mon, 28 Oct 2024 18:40:37 -0400 Subject: [PATCH 5/6] Fixing visibility, formatting, comments --- Sources/Segment/Utilities/Telemetry.swift | 56 +++++++++++++++++------ 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/Sources/Segment/Utilities/Telemetry.swift b/Sources/Segment/Utilities/Telemetry.swift index ddde71c..9886afe 100644 --- a/Sources/Segment/Utilities/Telemetry.swift +++ b/Sources/Segment/Utilities/Telemetry.swift @@ -35,6 +35,8 @@ public class Telemetry: Subscriber { self.session = session } + /// A Boolean value indicating whether to enable telemetry. + #if DEBUG public var enable: Bool = false { didSet { if enable { @@ -42,14 +44,28 @@ public class Telemetry: Subscriber { } } } - internal var session: any HTTPSession - public var host: String = HTTPClient.getDefaultAPIHost() - var sampleRate: Double = 0.10 - public var flushTimer: Int = 30 * 1000 + #else + public var enable: Bool = true { + didSet { + if enable { + start() + } + } + } + #endif + + /// A Boolean value indicating whether to send the write key with error metrics. public var sendWriteKeyOnError: Bool = true + /// A Boolean value indicating whether to send the error log data with error metrics. public var sendErrorLogData: Bool = false + /// A Callback for reporting errors that occur during telemetry. public var errorHandler: ((Error) -> Void)? = logError - public var maxQueueSize: Int = 20 + + internal var session: any HTTPSession + internal var host: String = HTTPClient.getDefaultAPIHost() + var sampleRate: Double = 0.10 + private var flushTimer: Int = 30 * 1000 + internal var maxQueueSize: Int = 20 var errorLogSizeMax: Int = 4000 static private let MAX_QUEUE_BYTES = 28000 @@ -59,15 +75,16 @@ public class Telemetry: Subscriber { } } - public var queue = [RemoteMetric]() + internal var queue = [RemoteMetric]() private var queueBytes = 0 private var queueSizeExceeded = false private var seenErrors = [String: Int]() - public var started = false + internal var started = false private var rateLimitEndTime: TimeInterval = 0 private var telemetryQueue = DispatchQueue(label: "telemetryQueue") private var telemetryTimer: Timer? + /// Starts the Telemetry send loop. Requires both `enable` to be set and a configuration to be retrieved from Segment. func start() { guard enable, !started, sampleRate > 0.0 && sampleRate <= 1.0 else { return } started = true @@ -85,6 +102,7 @@ public class Telemetry: Subscriber { } } + /// Resets the telemetry state, including the queue and seen errors. func reset() { telemetryTimer?.invalidate() resetQueue() @@ -93,6 +111,10 @@ public class Telemetry: Subscriber { rateLimitEndTime = 0 } + /// Increments a metric with the provided tags. + /// - Parameters: + /// - metric: The metric name. + /// - buildTags: A closure to build the tags dictionary. func increment(metric: String, buildTags: (inout [String: String]) -> Void) { var tags = [String: String]() buildTags(&tags) @@ -103,6 +125,11 @@ public class Telemetry: Subscriber { addRemoteMetric(metric: metric, tags: tags) } + /// Logs an error metric with the provided log data and tags. + /// - Parameters: + /// - metric: The metric name. + /// - log: The log data. + /// - buildTags: A closure to build the tags dictionary. func error(metric: String, log: String, buildTags: (inout [String: String]) -> Void) { var tags = [String: String]() buildTags(&tags) @@ -110,12 +137,12 @@ public class Telemetry: Subscriber { guard enable, sampleRate > 0.0 && sampleRate <= 1.0, metric.hasPrefix(Telemetry.METRICS_BASE_TAG), !tags.isEmpty, queueHasSpace() else { return } var filteredTags = tags - if !sendWriteKeyOnError { + if (!sendWriteKeyOnError) { filteredTags = tags.filter { $0.key.lowercased() != "writekey" } } var logData: String? = nil - if sendErrorLogData { + if (sendErrorLogData) { logData = String(log.prefix(errorLogSizeMax)) } @@ -135,7 +162,8 @@ public class Telemetry: Subscriber { } } - @objc func flush() { + /// Flushes the telemetry queue, sending the metrics to the server. + internal func flush() { guard enable else { return } telemetryQueue.sync { @@ -155,7 +183,7 @@ public class Telemetry: Subscriber { } } -private func send() throws { + private func send() throws { guard sampleRate > 0.0 && sampleRate <= 1.0 else { return } var sendQueue = [RemoteMetric]() @@ -211,7 +239,7 @@ private func send() throws { ] } -private func addRemoteMetric(metric: String, tags: [String: String], value: Int = 1, log: String? = nil) { + private func addRemoteMetric(metric: String, tags: [String: String], value: Int = 1, log: String? = nil) { let fullTags = tags.merging(additionalTags) { (_, new) in new } telemetryQueue.sync { @@ -237,7 +265,9 @@ private func addRemoteMetric(metric: String, tags: [String: String], value: Int } } - func subscribe(_ store: Store) { + /// Subscribes to the given store to receive system updates. + /// - Parameter store: The store on which a sampleRate setting is expected. + public func subscribe(_ store: Store) { store.subscribe(self, initialState: true, queue: telemetryQueue, From b393f0599df70457e942e94e4fa9ca02c43852b3 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Wed, 30 Oct 2024 08:57:41 -0400 Subject: [PATCH 6/6] Fixing linux build error and class comment --- Sources/Segment/Utilities/Telemetry.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/Segment/Utilities/Telemetry.swift b/Sources/Segment/Utilities/Telemetry.swift index 9886afe..031d4a3 100644 --- a/Sources/Segment/Utilities/Telemetry.swift +++ b/Sources/Segment/Utilities/Telemetry.swift @@ -23,8 +23,13 @@ func logError(_ error: Error) { Analytics.reportInternalError(error) } +/// A class for sending telemetry data to Segment. +/// This system is used to gather usage and error data from the SDK for the purpose of improving the SDK. +/// It can be disabled at any time by setting Telemetry.shared.enable to false. +/// Errors are sent with a write key, which can be disabled by setting Telemetry.shared.sendWriteKeyOnError to false. +/// All data is downsampled and no PII is collected. public class Telemetry: Subscriber { - public static let shared = Telemetry(session: URLSession.shared) + public static let shared = Telemetry(session: HTTPSessions.urlSession()) private static let METRICS_BASE_TAG = "analytics_mobile" public static let INVOKE_METRIC = "\(METRICS_BASE_TAG).invoke" public static let INVOKE_ERROR_METRIC = "\(METRICS_BASE_TAG).invoke.error" @@ -37,7 +42,7 @@ public class Telemetry: Subscriber { /// A Boolean value indicating whether to enable telemetry. #if DEBUG - public var enable: Bool = false { + public var enable: Bool = false { // Don't collect data in debug mode (i.e. test environments) didSet { if enable { start()