diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 847de960..ccb480a4 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -143,7 +143,7 @@ jobs: run: | eval "$(~/.local/bin/mise activate bash)" >> ~/.bashrc echo "$PATH" - mise doctor + # mise doctor ./bin/build_xcframeworks.sh # TODO: Finish this step diff --git a/CHANGELOG.md b/CHANGELOG.md index 66726655..0a7b5863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## 6.6.0 +*Dec 12th, 2024* +* Features + * Added new instrumentation for the `ViewCaptureService`. Can be enabled through `ViewCaptureService.Options.instrumentFirstRender`. + * Added url blacklist for the `URLSessionCaptureService`. Can be configured through `URLSessionCaptureService.Options.ignoredURLs`. + * Added the ability to auto terminate spans if the session ends while the span is still open. + * Updated the OpenTelemetry dependency to v1.12.1 which fixes some concurrency related crashes. + * Improved logic around Embrace data uploads and retries. + * Deprecated `Span.markAsKeySpan()`. +* Fixes + * Fixed the remote config parse sometimes failing. + * Fixed the remote config cache not working properly. + * Fixed crash logs sometimes not containing the session properties. + * Fixed keychain related crash/hang during startup. + * Fixed issues with the `WebViewCaptureService` that could lead to a crash. + * Fixed issue with the `URLSessionCaptureService` when dealing with `URLSessionDelegate` objects in Objective-C responding to methods without conforming to specific protocols. + ## 6.5.2 *Nov 14th, 2024* * Features diff --git a/EmbraceIO.podspec b/EmbraceIO.podspec index 92787035..66f96f24 100644 --- a/EmbraceIO.podspec +++ b/EmbraceIO.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "EmbraceIO" - spec.version = "6.5.2" + spec.version = "6.6.0" spec.summary = "Visibility into your users that you didn't have before." spec.description = <<-DESC Embrace is the only performance monitoring platform focused solely on mobile. We are built diff --git a/Examples/BrandGame/BrandGame/Embrace/App+Embrace.swift b/Examples/BrandGame/BrandGame/Embrace/App+Embrace.swift index c76a2907..c938dfd1 100644 --- a/Examples/BrandGame/BrandGame/Embrace/App+Embrace.swift +++ b/Examples/BrandGame/BrandGame/Embrace/App+Embrace.swift @@ -21,7 +21,7 @@ extension BrandGameApp { private var otelExport: OpenTelemetryExport { OpenTelemetryExport( - spanExporter: StdoutExporter(isDebug: true), + spanExporter: StdoutSpanExporter(isDebug: true), logExporter: StdoutLogExporter(isDebug: true) ) } diff --git a/Sources/EmbraceCommonInternal/EmbraceMeta.swift b/Sources/EmbraceCommonInternal/EmbraceMeta.swift index d3e42350..c4f02c3d 100644 --- a/Sources/EmbraceCommonInternal/EmbraceMeta.swift +++ b/Sources/EmbraceCommonInternal/EmbraceMeta.swift @@ -6,5 +6,5 @@ // Do not edit this file manually public class EmbraceMeta { - public static let sdkVersion = "6.5.2" + public static let sdkVersion = "6.6.0" } diff --git a/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig.swift b/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig.swift index be74d59d..5a0fc386 100644 --- a/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig.swift +++ b/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig.swift @@ -9,6 +9,8 @@ import EmbraceConfiguration /// Remote config uses the Embrace Config Service to request config values public class RemoteConfig { + let logger: InternalLogger + // config requests @ThreadSafe var payload: RemoteConfigPayload let fetcher: RemoteConfigFetcher @@ -19,6 +21,8 @@ public class RemoteConfig { @ThreadSafe private(set) var updating = false + let cacheURL: URL? + public convenience init( options: RemoteConfig.Options, payload: RemoteConfigPayload = RemoteConfigPayload(), @@ -38,6 +42,42 @@ public class RemoteConfig { self.payload = payload self.fetcher = fetcher self.deviceIdHexValue = options.deviceId.intValue(digitCount: Self.deviceIdUsedDigits) + self.logger = logger + + if let url = options.cacheLocation { + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + self.cacheURL = options.cacheLocation?.appendingPathComponent("cache") + loadFromCache() + } else { + self.cacheURL = nil + } + } + + func loadFromCache() { + guard let url = cacheURL, + FileManager.default.fileExists(atPath: url.path) else { + return + } + + do { + let data = try Data(contentsOf: url) + payload = try JSONDecoder().decode(RemoteConfigPayload.self, from: data) + } catch { + logger.error("Error loading cached remote config!") + } + } + + func saveToCache(_ data: Data?) { + guard let url = cacheURL, + let data = data else { + return + } + + do { + try data.write(to: url, options: .atomic) + } catch { + logger.warning("Error saving remote config cache!") + } } } @@ -67,7 +107,7 @@ extension RemoteConfig: EmbraceConfigurable { } updating = true - fetcher.fetch { [weak self] newPayload in + fetcher.fetch { [weak self] newPayload, data in defer { self?.updating = false } guard let strongSelf = self else { completion(false, nil) @@ -82,6 +122,8 @@ extension RemoteConfig: EmbraceConfigurable { let didUpdate = strongSelf.payload != newPayload strongSelf.payload = newPayload + strongSelf.saveToCache(data) + completion(didUpdate, nil) } } diff --git a/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig/RemoteConfig+Options.swift b/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig/RemoteConfig+Options.swift index 6961bcab..14bf43c8 100644 --- a/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig/RemoteConfig+Options.swift +++ b/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig/RemoteConfig+Options.swift @@ -17,6 +17,8 @@ public extension RemoteConfig { let appVersion: String let userAgent: String + let cacheLocation: URL? + let urlSessionConfiguration: URLSessionConfiguration public init( @@ -28,6 +30,7 @@ public extension RemoteConfig { sdkVersion: String, appVersion: String, userAgent: String, + cacheLocation: URL?, urlSessionConfiguration: URLSessionConfiguration = URLSessionConfiguration.default ) { self.apiBaseUrl = apiBaseUrl @@ -38,6 +41,7 @@ public extension RemoteConfig { self.sdkVersion = sdkVersion self.appVersion = appVersion self.userAgent = userAgent + self.cacheLocation = cacheLocation self.urlSessionConfiguration = urlSessionConfiguration } } diff --git a/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig/RemoteConfigFetcher.swift b/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig/RemoteConfigFetcher.swift index abfc27d6..d7b532ac 100644 --- a/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig/RemoteConfigFetcher.swift +++ b/Sources/EmbraceConfigInternal/EmbraceConfigurable/RemoteConfig/RemoteConfigFetcher.swift @@ -27,9 +27,9 @@ class RemoteConfigFetcher { ) } - func fetch(completion: @escaping (RemoteConfigPayload?) -> Void) { + func fetch(completion: @escaping (RemoteConfigPayload?, Data?) -> Void) { guard let request = newRequest() else { - completion(nil) + completion(nil, nil) return } @@ -38,19 +38,19 @@ class RemoteConfigFetcher { guard let data = data, error == nil else { self?.logger.error("Error fetching remote config:\n\(String(describing: error?.localizedDescription))") - completion(nil) + completion(nil, nil) return } guard let httpResponse = response as? HTTPURLResponse else { self?.logger.error("Error fetching remote config - Invalid response:\n\(String(describing: response?.description))") - completion(nil) + completion(nil, nil) return } guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { self?.logger.error("Error fetching remote config - Invalid response:\n\(httpResponse.description))") - completion(nil) + completion(nil, nil) return } @@ -59,11 +59,11 @@ class RemoteConfigFetcher { let payload = try JSONDecoder().decode(RemoteConfigPayload.self, from: data) self?.logger.info("Successfully fetched remote config") - completion(payload) + completion(payload, data) } catch { self?.logger.error("Error decoding remote config:\n\(error.localizedDescription)") // if a decoding issue happens, instead of returning `nil`, we provide a default `RemoteConfigPayload` - completion(RemoteConfigPayload()) + completion(RemoteConfigPayload(), nil) } } diff --git a/Sources/EmbraceCore/Capture/UX/View/UIViewControllerHandler.swift b/Sources/EmbraceCore/Capture/UX/View/UIViewControllerHandler.swift index 9334bcff..88849e36 100644 --- a/Sources/EmbraceCore/Capture/UX/View/UIViewControllerHandler.swift +++ b/Sources/EmbraceCore/Capture/UX/View/UIViewControllerHandler.swift @@ -33,16 +33,16 @@ class UIViewControllerHandler { @ThreadSafe var alreadyFinishedUiReadyIds: Set = [] init() { - NotificationCenter.default.addObserver( + Embrace.notificationCenter.addObserver( self, - selector: #selector(appDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, + selector: #selector(foregroundSessionDidEnd), + name: .embraceForegroundSessionDidEnd, object: nil ) } deinit { - NotificationCenter.default.removeObserver(self) + Embrace.notificationCenter.removeObserver(self) } func parentSpan(for vc: UIViewController) -> Span? { @@ -53,16 +53,18 @@ class UIViewControllerHandler { return parentSpans[id] } - @objc func appDidEnterBackground() { + @objc func foregroundSessionDidEnd(_ notification: Notification? = nil) { + let now = notification?.object as? Date ?? Date() + // end all parent spans and visibility spans if the app enters the background // also clear all the cached spans queue.async { for span in self.visibilitySpans.values { - span.end() + span.end(time: now) } for id in self.parentSpans.keys { - self.forcefullyEndSpans(id: id) + self.forcefullyEndSpans(id: id, time: now) } self.parentSpans.removeAll() @@ -206,8 +208,10 @@ class UIViewControllerHandler { } // end view did appear span + let now = Date() + if let span = self.viewDidAppearSpans.removeValue(forKey: id) { - span.end() + span.end(time: now) } guard let parentSpan = self.parentSpans[id] else { @@ -216,7 +220,7 @@ class UIViewControllerHandler { // end time to first render span if parentSpan.isTimeToFirstRender { - parentSpan.end() + parentSpan.end(time: now) self.clear(id: id, vc: vc) // generate ui ready span @@ -231,8 +235,8 @@ class UIViewControllerHandler { // if the view controller was already flagged as ready to interact // we end the spans right away if self.alreadyFinishedUiReadyIds.contains(id) { - span.end() - parentSpan.end() + span.end(time: now) + parentSpan.end(time: now) self.clear(id: id, vc: vc) @@ -250,13 +254,16 @@ class UIViewControllerHandler { return } + let now = Date() + // end visibility span if let span = self.visibilitySpans[id] { - span.end() + span.end(time: now) + self.visibilitySpans[id] = nil } // force end all spans - self.forcefullyEndSpans(id: id) + self.forcefullyEndSpans(id: id, time: now) } } @@ -271,8 +278,9 @@ class UIViewControllerHandler { // if we have a ui ready span it means that viewDidAppear already happened // in this case we close the spans if let span = self.uiReadySpans[id] { - span.end() - parentSpan.end() + let now = Date() + span.end(time: now) + parentSpan.end(time: now) self.clear(id: id, vc: vc) // otherwise it means the view is still loading, in this case we flag @@ -284,26 +292,26 @@ class UIViewControllerHandler { } } - private func forcefullyEndSpans(id: String) { + private func forcefullyEndSpans(id: String, time: Date) { if let viewDidLoadSpan = self.viewDidLoadSpans[id] { - viewDidLoadSpan.end(errorCode: .userAbandon) + viewDidLoadSpan.end(errorCode: .userAbandon, time: time) } if let viewWillAppearSpan = self.viewWillAppearSpans[id] { - viewWillAppearSpan.end(errorCode: .userAbandon) + viewWillAppearSpan.end(errorCode: .userAbandon, time: time) } if let viewDidAppearSpan = self.viewDidAppearSpans[id] { - viewDidAppearSpan.end(errorCode: .userAbandon) + viewDidAppearSpan.end(errorCode: .userAbandon, time: time) } if let uiReadySpan = self.uiReadySpans[id] { - uiReadySpan.end(errorCode: .userAbandon) + uiReadySpan.end(errorCode: .userAbandon, time: time) } if let parentSpan = self.parentSpans[id] { - parentSpan.end(errorCode: .userAbandon) + parentSpan.end(errorCode: .userAbandon, time: time) } self.clear(id: id) @@ -338,7 +346,6 @@ class UIViewControllerHandler { self.viewDidLoadSpans[id] = nil self.viewWillAppearSpans[id] = nil self.viewDidAppearSpans[id] = nil - self.visibilitySpans[id] = nil self.uiReadySpans[id] = nil self.alreadyFinishedUiReadyIds.remove(id) diff --git a/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService+Options.swift b/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService+Options.swift index 97f7886b..78d0ccd3 100644 --- a/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService+Options.swift +++ b/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService+Options.swift @@ -32,7 +32,7 @@ extension ViewCaptureService { } @objc public convenience override init() { - self.init(instrumentVisibility: true, instrumentFirstRender: true) + self.init(instrumentVisibility: true, instrumentFirstRender: false) } } } diff --git a/Sources/EmbraceCore/Embrace.swift b/Sources/EmbraceCore/Embrace.swift index d31b1862..f15724c9 100644 --- a/Sources/EmbraceCore/Embrace.swift +++ b/Sources/EmbraceCore/Embrace.swift @@ -60,6 +60,13 @@ To start the SDK you first need to configure it using an `Embrace.Options` insta /// Returns the current `MetadataHandler` used to store resources and session properties. @objc public let metadata: MetadataHandler + var isSDKEnabled: Bool { + if let config = config { + return config.isSDKEnabled + } + return true + } + let config: EmbraceConfig? let storage: EmbraceStorage let upload: EmbraceUpload? @@ -149,7 +156,8 @@ To start the SDK you first need to configure it using an `Embrace.Options` insta self.logController = logControllable ?? LogController( storage: storage, upload: upload, - controller: sessionController + controller: sessionController, + config: config ) super.init() @@ -258,6 +266,10 @@ To start the SDK you first need to configure it using an `Embrace.Options` insta @objc private func onConfigUpdated() { if let config = config { Embrace.logger.limits = config.internalLogLimits + if !config.isSDKEnabled { + Embrace.logger.debug("SDK was disabled") + captureServices.stop() + } } } } diff --git a/Sources/EmbraceCore/FileSystem/EmbraceFileSystem.swift b/Sources/EmbraceCore/FileSystem/EmbraceFileSystem.swift index 593eec75..4c89612b 100644 --- a/Sources/EmbraceCore/FileSystem/EmbraceFileSystem.swift +++ b/Sources/EmbraceCore/FileSystem/EmbraceFileSystem.swift @@ -11,6 +11,7 @@ public struct EmbraceFileSystem { static let uploadsDirectoryName = "uploads" static let crashesDirectoryName = "crashes" static let captureDirectoryName = "capture" + static let configDirectoryName = "config" static let defaultPartitionId = "default" @@ -53,8 +54,7 @@ public struct EmbraceFileSystem { /// ``` /// - Parameters: /// - name: The name of the subdirectory - /// - partitionIdentifier: The main partition identifier to use - /// identifier to use + /// - partitionId: The main partition identifier to use /// - appGroupId: The app group identifier if using an app group container. static func directoryURL(name: String, partitionId: String, appGroupId: String? = nil) -> URL? { guard let baseURL = systemDirectory(appGroupId: appGroupId) else { @@ -104,4 +104,16 @@ public struct EmbraceFileSystem { appGroupId: appGroupId ) } + + /// Returns the subdirectory for config cache + /// ``` + /// io.embrace.data///config + /// ``` + static func configDirectoryURL(partitionIdentifier: String, appGroupId: String? = nil) -> URL? { + return directoryURL( + name: configDirectoryName, + partitionId: partitionIdentifier, + appGroupId: appGroupId + ) + } } diff --git a/Sources/EmbraceCore/Internal/Embrace+Config.swift b/Sources/EmbraceCore/Internal/Embrace+Config.swift index 41501dfa..e71f691c 100644 --- a/Sources/EmbraceCore/Internal/Embrace+Config.swift +++ b/Sources/EmbraceCore/Internal/Embrace+Config.swift @@ -47,7 +47,8 @@ extension Embrace { osVersion: EMBDevice.appVersion ?? "", sdkVersion: EmbraceMeta.sdkVersion, appVersion: EMBDevice.operatingSystemVersion, - userAgent: EmbraceMeta.userAgent + userAgent: EmbraceMeta.userAgent, + cacheLocation: EmbraceFileSystem.configDirectoryURL(partitionIdentifier: appId, appGroupId: options.appGroupId) ) return RemoteConfig( diff --git a/Sources/EmbraceCore/Internal/Logs/LogController.swift b/Sources/EmbraceCore/Internal/Logs/LogController.swift index ffb0a7e2..0b537945 100644 --- a/Sources/EmbraceCore/Internal/Logs/LogController.swift +++ b/Sources/EmbraceCore/Internal/Logs/LogController.swift @@ -7,6 +7,7 @@ import EmbraceStorageInternal import EmbraceUploadInternal import EmbraceCommonInternal import EmbraceSemantics +import EmbraceConfigInternal protocol LogControllable: LogBatcherDelegate { func uploadAllPersistedLogs() @@ -16,16 +17,26 @@ class LogController: LogControllable { private(set) weak var sessionController: SessionControllable? private weak var storage: Storage? private weak var upload: EmbraceLogUploader? + private weak var config: EmbraceConfig? /// This will probably be injected eventually. /// For consistency, I created a constant static let maxLogsPerBatch: Int = 20 + private var isSDKEnabled: Bool { + guard let config = config else { + return true + } + return config.isSDKEnabled + } + init(storage: Storage?, upload: EmbraceLogUploader?, - controller: SessionControllable) { + controller: SessionControllable, + config: EmbraceConfig?) { self.storage = storage self.upload = upload self.sessionController = controller + self.config = config } func uploadAllPersistedLogs() { @@ -46,6 +57,10 @@ class LogController: LogControllable { extension LogController { func batchFinished(withLogs logs: [LogRecord]) { + guard isSDKEnabled else { + return + } + do { guard let sessionId = sessionController?.currentSession?.id, logs.count > 0 else { return @@ -61,6 +76,10 @@ extension LogController { private extension LogController { func send(batches: [LogsBatch]) { + guard isSDKEnabled else { + return + } + guard batches.count > 0 else { return } diff --git a/Sources/EmbraceCore/Public/Embrace+OTel.swift b/Sources/EmbraceCore/Public/Embrace+OTel.swift index 060810aa..fb0e9f20 100644 --- a/Sources/EmbraceCore/Public/Embrace+OTel.swift +++ b/Sources/EmbraceCore/Public/Embrace+OTel.swift @@ -39,7 +39,6 @@ extension Embrace: EmbraceOpenTelemetry { attributes: [String: String] = [:], autoTerminationCode: SpanErrorCode? = nil ) -> SpanBuilder { - if let autoTerminationCode = autoTerminationCode { var attributes = attributes attributes[SpanSemantics.keyAutoTerminationCode] = autoTerminationCode.rawValue diff --git a/Sources/EmbraceCore/Session/SessionController.swift b/Sources/EmbraceCore/Session/SessionController.swift index c8cd8f3e..54e040ac 100644 --- a/Sources/EmbraceCore/Session/SessionController.swift +++ b/Sources/EmbraceCore/Session/SessionController.swift @@ -46,8 +46,6 @@ class SessionController: SessionControllable { let queue: DispatchQueue var firstSession = true - internal var notificationCenter = NotificationCenter.default - init( storage: EmbraceStorage, upload: EmbraceUpload?, @@ -86,6 +84,10 @@ class SessionController: SessionControllable { endSession() } + guard isSDKEnabled else { + return nil + } + // detect cold start let isColdStart = firstSession @@ -133,7 +135,7 @@ class SessionController: SessionControllable { heartbeat.start() // post notification - notificationCenter.post(name: .embraceSessionDidStart, object: session) + NotificationCenter.default.post(name: .embraceSessionDidStart, object: session) firstSession = false @@ -151,6 +153,11 @@ class SessionController: SessionControllable { heartbeat.stop() let now = Date() + guard isSDKEnabled else { + delete() + return now + } + // If the session is a background session and background sessions // are disabled in the config, we drop the session! // + @@ -165,8 +172,8 @@ class SessionController: SessionControllable { // auto terminate spans EmbraceOTel.processor?.autoTerminateSpans() - // post notification - notificationCenter.post(name: .embraceSessionWillEnd, object: currentSession) + // post public notification + NotificationCenter.default.post(name: .embraceSessionWillEnd, object: currentSession) currentSessionSpan?.end(time: now) SessionSpanUtils.setCleanExit(span: currentSessionSpan, cleanExit: true) @@ -174,6 +181,11 @@ class SessionController: SessionControllable { currentSession?.endTime = now currentSession?.cleanExit = true + // post internal notification + if currentSession?.state == SessionState.foreground.rawValue { + Embrace.notificationCenter.post(name: .embraceForegroundSessionDidEnd, object: now) + } + // save session record save() @@ -241,4 +253,16 @@ extension SessionController { currentSession = nil currentSessionSpan = nil } + + private var isSDKEnabled: Bool { + guard let config = config else { + return true + } + return config.isSDKEnabled + } +} + +// internal use +extension Notification.Name { + static let embraceForegroundSessionDidEnd = Notification.Name("embrace.session.foreground.end") } diff --git a/Sources/EmbraceIO/Capture/CaptureService+Helpers.swift b/Sources/EmbraceIO/Capture/CaptureService+Helpers.swift index b595fa2e..9c6bf6f5 100644 --- a/Sources/EmbraceIO/Capture/CaptureService+Helpers.swift +++ b/Sources/EmbraceIO/Capture/CaptureService+Helpers.swift @@ -24,8 +24,10 @@ import EmbraceCaptureService } /// Returns a `ViewCaptureService`. - static func view() -> ViewCaptureService { - return ViewCaptureService() + static func view( + options: ViewCaptureService.Options = ViewCaptureService.Options() + ) -> ViewCaptureService { + return ViewCaptureService(options: options) } #endif diff --git a/Tests/EmbraceConfigInternalTests/EmbraceConfigurable/RemoteConfig/RemoteConfigFetcherTests.swift b/Tests/EmbraceConfigInternalTests/EmbraceConfigurable/RemoteConfig/RemoteConfigFetcherTests.swift index a16a271c..07f86164 100644 --- a/Tests/EmbraceConfigInternalTests/EmbraceConfigurable/RemoteConfig/RemoteConfigFetcherTests.swift +++ b/Tests/EmbraceConfigInternalTests/EmbraceConfigurable/RemoteConfig/RemoteConfigFetcherTests.swift @@ -40,6 +40,7 @@ class RemoteConfigFetcherTests: XCTestCase { sdkVersion: sdkVersion, appVersion: appVersion, userAgent: userAgent, + cacheLocation: nil, urlSessionConfiguration: Self.urlSessionConfig ) } @@ -145,7 +146,7 @@ class RemoteConfigFetcherTests: XCTestCase { let fetcher = RemoteConfigFetcher(options: options, logger: logger) let expectation = expectation(description: "URL request") - fetcher.fetch { payload in + fetcher.fetch { payload, data in XCTAssertNotNil(payload) expectation.fulfill() } @@ -163,7 +164,7 @@ class RemoteConfigFetcherTests: XCTestCase { let fetcher = RemoteConfigFetcher(options: options, logger: logger) let expectation = expectation(description: "URL request") - fetcher.fetch { payload in + fetcher.fetch { payload, data in XCTAssertNil(payload) expectation.fulfill() } diff --git a/Tests/EmbraceConfigInternalTests/EmbraceConfigurable/RemoteConfigTests.swift b/Tests/EmbraceConfigInternalTests/EmbraceConfigurable/RemoteConfigTests.swift index f0e16297..557fb00d 100644 --- a/Tests/EmbraceConfigInternalTests/EmbraceConfigurable/RemoteConfigTests.swift +++ b/Tests/EmbraceConfigInternalTests/EmbraceConfigurable/RemoteConfigTests.swift @@ -21,33 +21,10 @@ final class RemoteConfigTests: XCTestCase { sdkVersion: TestConstants.sdkVersion, appVersion: TestConstants.appVersion, userAgent: TestConstants.userAgent, + cacheLocation: nil, urlSessionConfiguration: URLSessionConfiguration.default ) - func mockSuccessfulResponse() throws { - var url = try XCTUnwrap(URL(string: "\(options.apiBaseUrl)/v2/config")) - - if #available(iOS 16.0, watchOS 9.0, *) { - url.append(queryItems: [ - .init(name: "appId", value: options.appId), - .init(name: "osVersion", value: options.osVersion), - .init(name: "appVersion", value: options.appVersion), - .init(name: "deviceId", value: options.deviceId.hex), - .init(name: "sdkVersion", value: options.sdkVersion) - ]) - } else { - XCTFail("This will fail on versions prior to iOS 16.0") - } - - let path = Bundle.module.path( - forResource: "remote_config", - ofType: "json", - inDirectory: "Fixtures" - )! - let data = try Data(contentsOf: URL(fileURLWithPath: path)) - EmbraceHTTPMock.mock(url: url, response: .withData(data, statusCode: 200)) - } - // MARK: Tests func test_isEnabled_returnsCorrectValues() { diff --git a/Tests/EmbraceCoreTests/Capture/UX/View/UIViewControllerHandlerTests.swift b/Tests/EmbraceCoreTests/Capture/UX/View/UIViewControllerHandlerTests.swift index 32b6d27f..df98d04f 100644 --- a/Tests/EmbraceCoreTests/Capture/UX/View/UIViewControllerHandlerTests.swift +++ b/Tests/EmbraceCoreTests/Capture/UX/View/UIViewControllerHandlerTests.swift @@ -54,7 +54,7 @@ class UIViewControllerHandlerTests: XCTestCase { XCTAssertNil(parent) } - func test_appDidEnterBackground_clearsCache() { + func test_foregroundSessionDidEnd_clearsCache() { // given a handler with cached spans let id = "test" let span = createSpan() @@ -66,16 +66,16 @@ class UIViewControllerHandlerTests: XCTestCase { handler.uiReadySpans[id] = span handler.alreadyFinishedUiReadyIds.insert(id) - // when appDidEnterBackground is called - handler.appDidEnterBackground() + // when foregroundSessionDidEnd is called + handler.foregroundSessionDidEnd() // then the cache is cleared wait { - return self.cacheIsEmpty() + return self.cacheIsEmpty(true) } } - func test_appDidEnterBackground_endsSpans() { + func test_foregroundSessionDidEnd_endsSpans() { // given a handler with cached spans let id = "test" @@ -97,8 +97,8 @@ class UIViewControllerHandlerTests: XCTestCase { let uiReadySpan = createUiReadySpan() handler.uiReadySpans[id] = uiReadySpan - // when appDidEnterBackgroundz is called - handler.appDidEnterBackground() + // when appDidEnterBackground is called + handler.foregroundSessionDidEnd() // then all spans are ended wait { @@ -206,7 +206,7 @@ class UIViewControllerHandlerTests: XCTestCase { validateViewDidLoadSpans(vc: vc, parentName: parentName) validateViewWillAppearSpans(vc: vc, parentName: parentName) - handler.appDidEnterBackground() + handler.foregroundSessionDidEnd() wait(timeout: .longTimeout) { let parent = self.otel.spanProcessor.endedSpans.first(where: { $0.name.contains(parentName) }) @@ -242,7 +242,7 @@ class UIViewControllerHandlerTests: XCTestCase { let parent = self.otel.spanProcessor.endedSpans.first(where: { $0.name.contains(parentName) }) let uiReady = self.otel.spanProcessor.endedSpans.first(where: { $0.name == "ui-ready"}) - return parent != nil && uiReady != nil && self.cacheIsEmpty() + return parent != nil && uiReady != nil && parent!.endTime == uiReady!.endTime && self.cacheIsEmpty() } } @@ -264,7 +264,7 @@ class UIViewControllerHandlerTests: XCTestCase { let parent = self.otel.spanProcessor.endedSpans.first(where: { $0.name.contains(parentName) }) let uiReady = self.otel.spanProcessor.endedSpans.first(where: { $0.name == "ui-ready"}) - return parent != nil && uiReady != nil && self.cacheIsEmpty() + return parent != nil && uiReady != nil && parent!.endTime == uiReady!.endTime && self.cacheIsEmpty() } } @@ -296,7 +296,7 @@ class UIViewControllerHandlerTests: XCTestCase { validateViewDidLoadSpans(vc: vc, parentName: parentName) validateViewWillAppearSpans(vc: vc, parentName: parentName) - handler.appDidEnterBackground() + handler.foregroundSessionDidEnd() // then the spans are ended wait(timeout: .longTimeout) { @@ -375,12 +375,12 @@ class UIViewControllerHandlerTests: XCTestCase { } } - func cacheIsEmpty() -> Bool { + func cacheIsEmpty(_ checkVisibilitySpans: Bool = false) -> Bool { return handler.parentSpans.count == 0 && handler.viewDidLoadSpans.count == 0 && handler.viewWillAppearSpans.count == 0 && handler.viewDidAppearSpans.count == 0 && - handler.visibilitySpans.count == 0 && + (!checkVisibilitySpans || handler.visibilitySpans.count == 0) && handler.uiReadySpans.count == 0 && handler.alreadyFinishedUiReadyIds.count == 0 } diff --git a/Tests/EmbraceCoreTests/Internal/Logs/LogControllerTests.swift b/Tests/EmbraceCoreTests/Internal/Logs/LogControllerTests.swift index c798688d..c4308325 100644 --- a/Tests/EmbraceCoreTests/Internal/Logs/LogControllerTests.swift +++ b/Tests/EmbraceCoreTests/Internal/Logs/LogControllerTests.swift @@ -8,15 +8,18 @@ import XCTest import EmbraceStorageInternal import EmbraceUploadInternal import EmbraceCommonInternal +import EmbraceConfigInternal class LogControllerTests: XCTestCase { private var sut: LogController! private var storage: SpyStorage? private var sessionController: MockSessionController! private var upload: SpyEmbraceLogUploader! + private var config: EmbraceConfig! override func setUp() { givenEmbraceLogUploader() + givenConfig() givenSessionControllerWithSession() givenStorage() } @@ -91,6 +94,15 @@ class LogControllerTests: XCTestCase { thenLogUploadShouldUpload(times: 1) } + func testSDKDisabledHavingLogsForLessThanABatch_onSetup_logUploaderShouldntSendASingleBatch() throws { + givenStorage(withLogs: [randomLogRecord(), randomLogRecord()]) + givenConfig(sdkEnabled: false) + givenLogController() + whenInvokingSetup() + thenLogUploadShouldUpload(times: 0) + try thenStorageShouldntCallRemoveLogs() + } + func testHavingLogsForMoreThanABatch_onSetup_logUploaderShouldSendTwoBatches() { givenStorage(withLogs: logsForMoreThanASingleBatch()) givenLogController() @@ -150,6 +162,13 @@ class LogControllerTests: XCTestCase { try thenFetchesMetadataFromStorage(sessionId: sessionController.currentSession?.id) } + func testSDKDisabledHavingLogs_onBatchFinished_ontTryToUploadAnything() throws { + givenConfig(sdkEnabled: false) + givenLogController() + whenInvokingBatchFinished(withLogs: [randomLogRecord()]) + thenDoesntTryToUploadAnything() + } + func testHavingThrowingStorage_onBatchFinished_wontTryToUploadAnything() { givenStorageThatThrowsException() givenLogController() @@ -184,11 +203,21 @@ class LogControllerTests: XCTestCase { private extension LogControllerTests { func givenLogControllerWithNoStorage() { - sut = .init(storage: nil, upload: upload, controller: sessionController) + sut = .init( + storage: nil, + upload: upload, + controller: sessionController, + config: config + ) } func givenLogController() { - sut = .init(storage: storage, upload: upload, controller: sessionController) + sut = .init( + storage: storage, + upload: upload, + controller: sessionController, + config: config + ) } func givenEmbraceLogUploader() { @@ -201,6 +230,10 @@ private extension LogControllerTests { upload.stubbedCompletion = .failure(RandomError()) } + func givenConfig(sdkEnabled: Bool = true) { + config = EmbraceConfigMock.default(sdkEnabled: sdkEnabled) + } + func givenSessionControllerWithoutSession() { sessionController = .init() } @@ -250,7 +283,6 @@ private extension LogControllerTests { } func thenLogUploadShouldUpload(times: Int) { - XCTAssertTrue(upload.didCallUploadLog) XCTAssertEqual(upload.didCallUploadLogCount, times) } diff --git a/Tests/EmbraceCoreTests/Session/SessionControllerTests.swift b/Tests/EmbraceCoreTests/Session/SessionControllerTests.swift index e33dc394..9c5bbddd 100644 --- a/Tests/EmbraceCoreTests/Session/SessionControllerTests.swift +++ b/Tests/EmbraceCoreTests/Session/SessionControllerTests.swift @@ -16,6 +16,7 @@ final class SessionControllerTests: XCTestCase { var storage: EmbraceStorage! var controller: SessionController! + var config: EmbraceConfig! var upload: EmbraceUpload! static let testMetadataOptions = EmbraceUpload.MetadataOptions( @@ -67,6 +68,22 @@ final class SessionControllerTests: XCTestCase { XCTAssertNotEqual(b!.id, c!.id) } + func testSDKDisabled_startSession_doesntCreateASession() throws { + config = EmbraceConfigMock.default(sdkEnabled: false) + + controller = SessionController( + storage: storage, + upload: upload, + config: config + ) + + let session = controller.startSession(state: .foreground) + + XCTAssertNil(session) + XCTAssertNil(controller.currentSessionSpan) + } + + func test_startSession_setsForegroundState() throws { let a = controller.startSession(state: .foreground) XCTAssertEqual(a!.state, "foreground") diff --git a/Tests/EmbraceCoreTests/Session/UnsentDataHandlerTests.swift b/Tests/EmbraceCoreTests/Session/UnsentDataHandlerTests.swift index d9958120..e64414be 100644 --- a/Tests/EmbraceCoreTests/Session/UnsentDataHandlerTests.swift +++ b/Tests/EmbraceCoreTests/Session/UnsentDataHandlerTests.swift @@ -730,7 +730,12 @@ class UnsentDataHandlerTests: XCTestCase { defer { try? storage.teardown() } let upload = try EmbraceUpload(options: uploadOptions, logger: logger, queue: queue, semaphore: .init(value: .max)) - let logController = LogController(storage: storage, upload: upload, controller: MockSessionController()) + let logController = LogController( + storage: storage, + upload: upload, + controller: MockSessionController(), + config: EmbraceConfigMock.default() + ) let otel = MockEmbraceOpenTelemetry() // given logs in storage diff --git a/Tests/EmbraceCoreTests/TestSupport/TestDoubles/EmbraceConfigMock.swift b/Tests/EmbraceCoreTests/TestSupport/TestDoubles/EmbraceConfigMock.swift new file mode 100644 index 00000000..d8f43e1a --- /dev/null +++ b/Tests/EmbraceCoreTests/TestSupport/TestDoubles/EmbraceConfigMock.swift @@ -0,0 +1,17 @@ +// +// Copyright © 2024 Embrace Mobile, Inc. All rights reserved. +// + +@testable import EmbraceConfigInternal +import TestSupport + +class EmbraceConfigMock { + static func `default`(sdkEnabled: Bool = true) -> EmbraceConfig { + EmbraceConfig( + configurable: MockEmbraceConfigurable(isSDKEnabled: sdkEnabled), + options: .init(minimumUpdateInterval: .infinity), + notificationCenter: .default, + logger: MockLogger() + ) + } +}