From a76bf466ccf5ec724f5480753165be7c27938cc6 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Thu, 14 Nov 2024 13:23:54 -0500 Subject: [PATCH] Telemetry module for Swift (#369) --- Sources/Segment/Analytics.swift | 9 + Sources/Segment/Errors.swift | 10 + Sources/Segment/Plugins/StartupQueue.swift | 3 + Sources/Segment/Timeline.swift | 15 + Sources/Segment/Utilities/Telemetry.swift | 313 ++++++++++++++++++ Tests/Segment-Tests/Analytics_Tests.swift | 3 + Tests/Segment-Tests/Atomic_Tests.swift | 3 + .../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 | 166 ++++++++++ Tests/Segment-Tests/Timeline_Tests.swift | 1 + Tests/Segment-Tests/UserAgentTests.swift | 1 + .../WindowsVendorSystem_Tests.swift | 5 + Tests/Segment-Tests/iOSLifecycle_Tests.swift | 5 +- 21 files changed, 542 insertions(+), 1 deletion(-) 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 1e16a7d8..1e68e796 100644 --- a/Sources/Segment/Analytics.swift +++ b/Sources/Segment/Analytics.swift @@ -87,6 +87,15 @@ 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)" + it["config"] = "seg:\(configuration.values.autoAddSegmentDestination) ua:\(configuration.values.userAgent ?? "N/A")" + } } deinit { diff --git a/Sources/Segment/Errors.swift b/Sources/Segment/Errors.swift index 34f1b377..b0ec0576 100644 --- a/Sources/Segment/Errors.swift +++ b/Sources/Segment/Errors.swift @@ -73,6 +73,12 @@ 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 + it["caller"] = Thread.callStackSymbols[3] + } } static public func reportInternalError(_ error: Error, fatal: Bool = false) { @@ -83,5 +89,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 6e7a3479..0a5e9d37 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 1585d1a6..2f8febed 100644 --- a/Sources/Segment/Timeline.swift +++ b/Sources/Segment/Timeline.swift @@ -65,10 +65,20 @@ 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) { 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 } } @@ -86,6 +96,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 00000000..031d4a34 --- /dev/null +++ b/Sources/Segment/Utilities/Telemetry.swift @@ -0,0 +1,313 @@ +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) +} + +/// 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: 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" + 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 + } + + /// A Boolean value indicating whether to enable telemetry. + #if DEBUG + public var enable: Bool = false { // Don't collect data in debug mode (i.e. test environments) + didSet { + if enable { + start() + } + } + } + #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 + + 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 + var maxQueueBytes: Int = MAX_QUEUE_BYTES { + didSet { + maxQueueBytes = min(maxQueueBytes, Telemetry.MAX_QUEUE_BYTES) + } + } + + internal var queue = [RemoteMetric]() + private var queueBytes = 0 + private var queueSizeExceeded = false + private var seenErrors = [String: Int]() + 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 + + 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() + } + } + + /// Resets the telemetry state, including the queue and seen errors. + func reset() { + telemetryTimer?.invalidate() + resetQueue() + seenErrors.removeAll() + started = false + 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) + + 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) + } + + /// 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) + + guard enable, sampleRate > 0.0 && sampleRate <= 1.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) + } + } + + /// Flushes the telemetry queue, sending the metrics to the server. + internal 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 && sampleRate <= 1.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 + } + } + } + + /// 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, + 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 under = false + telemetryQueue.sync { + under = queue.count < maxQueueSize + } + return under + } + + private func resetQueue() { + telemetryQueue.sync { + queue.removeAll() + queueBytes = 0 + queueSizeExceeded = false + } + } +} diff --git a/Tests/Segment-Tests/Analytics_Tests.swift b/Tests/Segment-Tests/Analytics_Tests.swift index 31137874..4585bac9 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 d6b420b0..f40bab55 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 a57fd82c..f233cf69 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 636a5792..0f866e76 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 6fe317ba..0d19a533 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 43f13cf9..01479e5f 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 263f99f2..fa08df12 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 7a8ba984..1c1be91a 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 8198946c..d2f765b8 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 4d6cb7e7..116fdc7c 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 70168a01..6c7a61bf 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 new file mode 100644 index 00000000..8b85e197 --- /dev/null +++ b/Tests/Segment-Tests/Telemetry_Tests.swift @@ -0,0 +1,166 @@ +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, @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 04a2ea92..13e4ec84 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 072c6d79..6c1f7b3e 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 ac79b995..86627f8c 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 44fff33f..fe8cfe7a 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()