diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index bfc679a4c..5461b697b 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit bfc679a4c7445cc13f092f9ad1b58af4b3ab8c2c +Subproject commit 5461b697bcff33fd2082bcbd8e4ab7f0ea7691b1 diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 6077cdd24..4fcfefb03 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -75,6 +75,7 @@ 3C14E3B32FAE54C006ED053 /* OSLoggerPlatformProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */; }; 3C14E3B42FAE54C006ED053 /* KotlinByteArray+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AD2FAE54C006ED053 /* KotlinByteArray+Data.swift */; }; 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */; }; + 5156E0E0A1B2C3D4E5F60004 /* OSObservabilityEventRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */; }; A5048F01A1B2C3D4E5F60002 /* OSFeatureFlagsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60001 /* OSFeatureFlagsStore.swift */; }; A5048F01A1B2C3D4E5F60004 /* OSFeatureFlagsBackendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60003 /* OSFeatureFlagsBackendService.swift */; }; A5048F01A1B2C3D4E5F60006 /* OSFeatureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60005 /* OSFeatureManager.swift */; }; @@ -92,6 +93,7 @@ C73D4E5F60718293A4B5C6D7 /* OSRemoteLoggingConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */; }; ACE2175908241BB46C9F1829 /* OSRemoteLoggingControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */; }; 7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */; }; + 5156E0E0A1B2C3D4E5F60002 /* OSObservabilityEventRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5156E0E0A1B2C3D4E5F60001 /* OSObservabilityEventRecorder.swift */; }; 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */; }; 3C19C6322E919F0C00D6731E /* OSRequestLiveActivityClicked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */; }; 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */; }; @@ -1809,6 +1811,7 @@ 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLoggerPlatformProvider.swift; sourceTree = ""; }; 3C14E3AD2FAE54C006ED053 /* KotlinByteArray+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinByteArray+Data.swift"; sourceTree = ""; }; 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLoggerAdaptersTests.swift; sourceTree = ""; }; + 5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSObservabilityEventRecorderTests.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F60001 /* OSFeatureFlagsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsStore.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F60003 /* OSFeatureFlagsBackendService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsBackendService.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F60005 /* OSFeatureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureManager.swift; sourceTree = ""; }; @@ -1823,6 +1826,7 @@ C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSRemoteLoggingController.h; sourceTree = ""; }; 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingControllerTests.swift; sourceTree = ""; }; 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLogger.swift; sourceTree = ""; }; + 5156E0E0A1B2C3D4E5F60001 /* OSObservabilityEventRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSObservabilityEventRecorder.swift; sourceTree = ""; }; ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandler.swift; sourceTree = ""; }; DEF5CCF12539321A0003E9CC /* UnitTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UnitTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; DEF5CCF32539321A0003E9CC /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; @@ -2323,6 +2327,7 @@ ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */, 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */, 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */, + 5156E0E0A1B2C3D4E5F60001 /* OSObservabilityEventRecorder.swift */, ); path = Logging; sourceTree = ""; @@ -2607,6 +2612,7 @@ 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */, 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, + 5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, @@ -4493,6 +4499,7 @@ 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */, 7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */, + 5156E0E0A1B2C3D4E5F60002 /* OSObservabilityEventRecorder.swift in Sources */, 3C11518B289ADEEB00565C41 /* OSEventProducer.swift in Sources */, 3C115165289A259500565C41 /* OneSignalOSCore.docc in Sources */, 5BC1DE5E2C90B80E00CA8807 /* OSCondition.swift in Sources */, @@ -4655,6 +4662,7 @@ 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */, 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, + 5156E0E0A1B2C3D4E5F60004 /* OSObservabilityEventRecorderTests.swift in Sources */, A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift index 492e52fc6..8b59731a9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift @@ -74,8 +74,8 @@ final class FileLogStore: ILogFileStore { ) { self.rootURL = URL(fileURLWithPath: rootPath, isDirectory: true) self.fileManager = fileManager - let crashLogger = OSCrashLogger() - self.crashWarn = crashWarn ?? { crashLogger.warn(message: $0) } + let consoleLogger = OSConsoleLogger() + self.crashWarn = crashWarn ?? { consoleLogger.warn(message: $0) } self.attributeLookup = attributeLookup try? createRootDirectory() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift index b3e00c70a..a406fb41d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift @@ -41,9 +41,14 @@ struct OSResolvedStackFrame: Equatable { let symbolName: String? } -/// Prints KMP crash-reporter diagnostics with `NSLog`. Honors the console log level -/// without going through `OneSignalLog` (listeners, alert UI, remote sink). -final class OSCrashLogger: ILogger { +/// Prints KMP diagnostics with `NSLog`, honoring the console log level without going through +/// `OneSignalLog` (listeners, alert UI, remote telemetry). Each caller documents why it cannot +/// afford that path. +final class OSConsoleLogger: ILogger { + /// Marks the lines that skipped `OneSignalLog`. Every message arrives already naming its + /// own component, so nothing finer is needed here. + private static let prefix = "[OSConsoleLogger]" + private let consoleLogLevel: () -> ONE_S_LOG_LEVEL private let write: (String) -> Void @@ -75,7 +80,7 @@ final class OSCrashLogger: ILogger { guard level.rawValue <= consoleLogLevel().rawValue else { return } - write("[OneSignal crash] \(label): \(message)") + write("\(Self.prefix) \(label): \(message)") } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSObservabilityEventRecorder.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSObservabilityEventRecorder.swift new file mode 100644 index 000000000..6c2bce16c --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSObservabilityEventRecorder.swift @@ -0,0 +1,127 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +@_implementationOnly import OneSignalKMP + +/// Mirrors the KMP `ObservabilityEvent` catalog so a call site can name an event without seeing KMP +/// types, which this module imports implementation-only. +@_spi(OneSignalInternal) +public enum OSObservabilityEvent: CaseIterable { + /// Temporary: comes out once its usage question is answered. + case deviceGesture + + /// The `event.name` of the KMP entry, so a test can check the mirror against the catalog. + var eventName: String { + kmpEvent.eventName + } +} + +/// The producer-facing contract, so a call site can take a spy in tests. +@_spi(OneSignalInternal) +public protocol OSObservabilityEventRecorderProtocol: AnyObject { + /// Never throws or blocks. Drops when the event's flag is off or the per-process cap is + /// reached; queues, bounded, until remote telemetry is attached. + func record(event: OSObservabilityEvent, attributes: [String: String]) +} + +/// The attach side, driven by `OSRemoteLogger`. Internal because the telemetry is a KMP type. +protocol OSObservabilityEventRecorderAttaching: AnyObject { + func attach(_ telemetry: ILogTelemetry) + + /// Ignored unless `telemetry` is the attached one, so a logger that lost the install race + /// cannot detach the winner. + func detach(_ telemetry: ILogTelemetry) +} + +/// Wraps the shared KMP recorder, which owns the flag check, the pre-attach queue and the +/// per-process cap. Events ride the remote logger's telemetry, so they share the crash gate +/// rather than the severity filter. +@_spi(OneSignalInternal) +public final class OSObservabilityEventRecorder: OSObservabilityEventRecorderProtocol, OSObservabilityEventRecorderAttaching { + public static let shared = OSObservabilityEventRecorder(isFeatureEnabled: featureIsEnabledIfInitialized) + + private let recorder: IObservabilityEventRecorder + + /// - Parameter isFeatureEnabled: the feature-manager read for a catalog flag key. + init(isFeatureEnabled: @escaping (String) -> Bool) { + // Console-only logger: `attach` runs under the remote logger's lifecycle lock, and + // `OneSignalLog` reaches app listeners synchronously, so a listener that re-enters the + // SDK would deadlock on that lock. + recorder = LoggerFactory.shared.createObservabilityEventRecorder( + flags: OSFeatureFlagReader(isFeatureEnabled: isFeatureEnabled), + logger: OSConsoleLogger() + ) + } + + /// Never constructs the feature manager: first touch latches every APP_STARTUP flag from + /// whatever storage returns, and a record can arrive from any thread at any time. Off until + /// the manager exists, which is also the documented state before the first flags fetch. + static func featureIsEnabledIfInitialized(_ key: String) -> Bool { + OSFeatureManager.enabledFeatureKeysIfInitialized().contains(key) + } + + public func record(event: OSObservabilityEvent, attributes: [String: String]) { + recorder.record(event: event.kmpEvent, attributes: attributes) + } + + /// Drops whatever an earlier app id queued; called beside the other app-id-change resets. + public func reset() { + recorder.reset() + } + + func attach(_ telemetry: ILogTelemetry) { + recorder.attach(telemetry: telemetry) + } + + func detach(_ telemetry: ILogTelemetry) { + recorder.detach(telemetry: telemetry) + } +} + +/// Answers the KMP recorder's flag lookups; each event's own gate decides which flag to ask about. +private final class OSFeatureFlagReader: IFeatureFlagReader { + private let isFeatureEnabled: (String) -> Bool + + init(isFeatureEnabled: @escaping (String) -> Bool) { + self.isFeatureEnabled = isFeatureEnabled + } + + func isEnabled(flag: FeatureFlag) -> Bool { + isFeatureEnabled(flag.key) + } +} + +private extension OSObservabilityEvent { + var kmpEvent: ObservabilityEvent { + switch self { + case .deviceGesture: + return .deviceGesture + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift index 74fbddcf1..d22ceb440 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift @@ -208,6 +208,10 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { private let crashUploader: LogCrashUploader private let logger: IOSLogger private let lifecycle: OSRemoteLoggerLifecycle + private let eventRecorder: OSObservabilityEventRecorderAttaching + /// Guarded by `lifecycleOperationLock`. The recorder is shared across instances, and a logger + /// that lost the install race is shut down without ever starting; it must not detach the winner. + private var didAttachEventRecorder = false private let lifecycleOperationLock = NSLock() private let uploaderOwner = UUID() @@ -262,7 +266,8 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { ) } - private init( + /// Internal so tests can stand in an event recorder; production goes through the convenience initializers. + init( installIdProvider: @escaping () -> String, onesignalIdProvider: @escaping () -> String?, pushSubscriptionIdProvider: @escaping () -> String?, @@ -270,7 +275,8 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { featureFlagsProvider: @escaping () -> [String], remoteLogLevelProvider: @escaping () -> String?, exporterLoggingEnabledProvider: @escaping () -> Bool, - requestSenderOverride: RequestSender? + requestSenderOverride: RequestSender?, + eventRecorder: OSObservabilityEventRecorderAttaching = OSObservabilityEventRecorder.shared ) { let provider = OSLoggerPlatformProvider( installIdProvider: installIdProvider, @@ -282,7 +288,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { exporterLoggingEnabledProvider: exporterLoggingEnabledProvider ) let logger = IOSLogger() - let crashLogger = OSCrashLogger() + let consoleLogger = OSConsoleLogger() let lifecycle = OSRemoteLoggerLifecycle() let fileStore = FileLogStore(rootPath: provider.crashStoragePath) // Console-only logger on purpose. Exporter diagnostics describe the POST that @@ -290,7 +296,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { // back into the export queue as a new record and never settle. let httpSender = Self.makeHttpSender( requestSender: requestSenderOverride, - logger: crashLogger, + logger: consoleLogger, isDiagnosticsEnabled: exporterLoggingEnabledProvider, lifecycle: lifecycle ) @@ -304,7 +310,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { ) let crashReporter = LoggerFactory.shared.createCrashReporter( crashTelemetry: crashTelemetry, - logger: crashLogger + logger: consoleLogger ) let crashHandler = OSLogCrashHandler(reporter: crashReporter) let crashUploader = LoggerFactory.shared.createCrashUploader( @@ -320,6 +326,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { self.crashUploader = crashUploader self.logger = logger self.lifecycle = lifecycle + self.eventRecorder = eventRecorder } private static func makeHttpSender( @@ -354,6 +361,9 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { } crashHandler.initialize() + // Events ride this telemetry, so the recorder follows it here and in shutdown(). + eventRecorder.attach(telemetry) + didAttachEventRecorder = true lifecycleOperationLock.unlock() let owner = uploaderOwner let crashUploader = self.crashUploader @@ -439,6 +449,10 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { OSCrashUploaderCoordinator.shared.cancel(owner: uploaderOwner) crashHandler.unregister() + if didAttachEventRecorder { + eventRecorder.detach(telemetry) + didAttachEventRecorder = false + } lifecycleOperationLock.unlock() // `telemetry.shutdown()` blocks for up to five seconds draining buffered diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift index fa237b7a4..475ef5bf5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift @@ -340,7 +340,7 @@ extension OSLogCrashHandlerTests { ) let reporter = LoggerFactory.shared.createCrashReporter( crashTelemetry: telemetry, - logger: OSCrashLogger() + logger: OSConsoleLogger() ) return OSLogCrashHandler(reporter: reporter) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift index dc3f9ad27..3c382a252 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift @@ -265,12 +265,15 @@ final class OSLoggerAdaptersTests: XCTestCase { XCTAssertEqual(listener.levels, [.LL_ERROR, .LL_WARN, .LL_INFO, .LL_DEBUG]) } - func testCrashLoggerHonorsConsoleLogLevelWithoutOneSignalLog() { + func testConsoleLoggerHonorsConsoleLogLevelWithoutOneSignalLog() { + // The point of this logger: it must never reach OneSignalLog, whose listeners run + // synchronously and would deadlock a caller holding the lifecycle lock, or feed the + // export queue a record about the export. let listener = LoggerAdapterListener() OneSignalLog.debug().__add(listener) defer { OneSignalLog.debug().__remove(listener) } var lines: [String] = [] - let logger = OSCrashLogger( + let logger = OSConsoleLogger( consoleLogLevel: { .LL_WARN }, write: { lines.append($0) } ) @@ -280,19 +283,21 @@ final class OSLoggerAdaptersTests: XCTestCase { logger.info(message: "info") logger.debug(message: "debug") + // The prefix marks the console-only path; the component is named by the message the + // caller passes, so this logger does not repeat it. XCTAssertEqual( lines, [ - "[OneSignal crash] ERROR: error", - "[OneSignal crash] WARN: warn" + "[OSConsoleLogger] ERROR: error", + "[OSConsoleLogger] WARN: warn" ] ) XCTAssertTrue(listener.levels.isEmpty) } - func testCrashLoggerSilentWhenConsoleLogLevelIsNone() { + func testConsoleLoggerSilentWhenConsoleLogLevelIsNone() { var lines: [String] = [] - let logger = OSCrashLogger( + let logger = OSConsoleLogger( consoleLogLevel: { .LL_NONE }, write: { lines.append($0) } ) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSObservabilityEventRecorderTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSObservabilityEventRecorderTests.swift new file mode 100644 index 000000000..26465d71a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSObservabilityEventRecorderTests.swift @@ -0,0 +1,262 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +import OneSignalKMP +@_spi(OneSignalInternal) @testable import OneSignalOSCore +import XCTest + +/// Stands in for the remote telemetry and records what the shared KMP recorder emits. +private final class TelemetrySpy: ILogTelemetry { + private let lock = NSLock() + var emitExpectation: XCTestExpectation? + private var storedRecords: [LogRecord] = [] + + var records: [LogRecord] { + lock.lock() + defer { lock.unlock() } + return storedRecords + } + + func emit(record: LogRecord, completionHandler: @escaping (Error?) -> Void) { + lock.lock() + storedRecords.append(record) + lock.unlock() + emitExpectation?.fulfill() + completionHandler(nil) + } + + func forceFlush(completionHandler: @escaping (Error?) -> Void) { + completionHandler(nil) + } + + func shutdown() {} +} + +/// Observes the seam `OSRemoteLogger` drives. +private final class EventRecorderSpy: OSObservabilityEventRecorderAttaching { + private(set) var attached: [ILogTelemetry] = [] + private(set) var detached: [ILogTelemetry] = [] + + func attach(_ telemetry: ILogTelemetry) { + attached.append(telemetry) + } + + func detach(_ telemetry: ILogTelemetry) { + detached.append(telemetry) + } +} + +final class OSObservabilityEventRecorderTests: XCTestCase { + override func tearDown() { + OSFeatureManager.didConstructForTesting = nil + OSFeatureManager.reset() + OSFeatureFlagsStore.shared.clear() + super.tearDown() + } + + private func makeRemoteLogger(recorder: EventRecorderSpy) -> OSRemoteLogger { + OSRemoteLogger( + installIdProvider: { "install-id" }, + onesignalIdProvider: { nil }, + pushSubscriptionIdProvider: { nil }, + appStateProvider: { "foreground" }, + featureFlagsProvider: { [] }, + remoteLogLevelProvider: { nil }, + exporterLoggingEnabledProvider: { false }, + requestSenderOverride: { _, completion in completion(nil, nil, nil) }, + eventRecorder: recorder + ) + } + + // MARK: - Recording through the shared KMP recorder + + func testRecordShipsAnInfoRecordNamedAfterTheEvent() throws { + let telemetry = TelemetrySpy() + telemetry.emitExpectation = expectation(description: "emits the event") + var askedKeys: [String] = [] + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { key in + askedKeys.append(key) + return true + }) + recorder.attach(telemetry) + + recorder.record(event: .deviceGesture, attributes: ["gesture.result": "copied"]) + + wait(for: [telemetry.emitExpectation!], timeout: 2) + let record = try XCTUnwrap(telemetry.records.first) + XCTAssertEqual(record.severity, LogSeverity.info) + XCTAssertEqual(record.body, "sdk.device_gesture") + XCTAssertEqual(record.attributes["event.name"], "sdk.device_gesture") + XCTAssertEqual(record.attributes["gesture.result"], "copied") + XCTAssertEqual(askedKeys, ["sdk_event_device_gesture"]) + } + + func testRecordDropsWhenTheEventFlagIsOff() { + let telemetry = TelemetrySpy() + telemetry.emitExpectation = expectation(description: "does not emit") + telemetry.emitExpectation?.isInverted = true + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { _ in false }) + recorder.attach(telemetry) + + recorder.record(event: .deviceGesture, attributes: [:]) + + wait(for: [telemetry.emitExpectation!], timeout: 0.2) + XCTAssertTrue(telemetry.records.isEmpty) + } + + func testEventsRecordedBeforeAttachFlushOnAttach() { + let telemetry = TelemetrySpy() + telemetry.emitExpectation = expectation(description: "flushes the queue") + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { _ in true }) + + recorder.record(event: .deviceGesture, attributes: ["n": "1"]) + recorder.attach(telemetry) + + wait(for: [telemetry.emitExpectation!], timeout: 2) + XCTAssertEqual(telemetry.records.map { $0.attributes["n"] }, ["1"]) + } + + func testDetachHoldsEventsUntilTheNextAttach() { + let first = TelemetrySpy() + let second = TelemetrySpy() + second.emitExpectation = expectation(description: "the next telemetry receives the held event") + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { _ in true }) + recorder.attach(first) + recorder.detach(first) + + recorder.record(event: .deviceGesture, attributes: [:]) + recorder.attach(second) + + wait(for: [second.emitExpectation!], timeout: 2) + XCTAssertTrue(first.records.isEmpty) + XCTAssertEqual(second.records.count, 1) + } + + func testDetachOfTelemetryThatIsNotAttachedIsIgnored() { + let winner = TelemetrySpy() + winner.emitExpectation = expectation(description: "the attached telemetry still receives the event") + let loser = TelemetrySpy() + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { _ in true }) + recorder.attach(winner) + + recorder.detach(loser) + recorder.record(event: .deviceGesture, attributes: [:]) + + wait(for: [winner.emitExpectation!], timeout: 2) + XCTAssertEqual(winner.records.count, 1) + XCTAssertTrue(loser.records.isEmpty) + } + + func testResetDropsTheQueue() { + let telemetry = TelemetrySpy() + telemetry.emitExpectation = expectation(description: "nothing from before the reset") + telemetry.emitExpectation?.isInverted = true + let recorder = OSObservabilityEventRecorder(isFeatureEnabled: { _ in true }) + recorder.record(event: .deviceGesture, attributes: ["n": "old app"]) + + recorder.reset() + recorder.attach(telemetry) + + wait(for: [telemetry.emitExpectation!], timeout: 0.2) + XCTAssertTrue(telemetry.records.isEmpty) + } + + // MARK: - The flag read + + func testTheFlagReadDoesNotConstructTheFeatureManager() { + OSFeatureManager.reset() + var constructed = false + OSFeatureManager.didConstructForTesting = { constructed = true } + + let enabled = OSObservabilityEventRecorder.featureIsEnabledIfInitialized(FeatureFlag.sdkEventDeviceGesture.key) + + XCTAssertFalse(enabled) + XCTAssertFalse(constructed) + } + + func testTheFlagReadSeesTheBuiltFeatureManager() { + OSFeatureManager.reset() + OSFeatureFlagsStore.shared.applyRemoteFlags([FeatureFlag.sdkEventDeviceGesture.key], metadata: nil) + _ = OSFeatureManager.shared + + XCTAssertTrue(OSObservabilityEventRecorder.featureIsEnabledIfInitialized(FeatureFlag.sdkEventDeviceGesture.key)) + XCTAssertFalse(OSObservabilityEventRecorder.featureIsEnabledIfInitialized(FeatureFlag.sdkIdentityVerification.key)) + } + + // MARK: - The mirror enum + + func testTheMirrorEnumMatchesTheKmpCatalog() { + // A KMP event added without a Swift case, or the reverse, fails here instead of compiling silently. + XCTAssertEqual( + Set(OSObservabilityEvent.allCases.map { $0.eventName }), + Set(ObservabilityEvent.entries.map { $0.eventName }) + ) + XCTAssertEqual(OSObservabilityEvent.allCases.count, ObservabilityEvent.entries.count) + } + + // MARK: - The remote logger seam + + func testRemoteLoggerAttachesOnStartAndDetachesTheSameTelemetryOnShutdown() { + let recorder = EventRecorderSpy() + let logger = makeRemoteLogger(recorder: recorder) + + logger.start() + XCTAssertEqual(recorder.attached.count, 1) + XCTAssertTrue(recorder.detached.isEmpty) + + logger.shutdown() + XCTAssertEqual(recorder.detached.count, 1) + XCTAssertTrue((recorder.detached.first as AnyObject?) === (recorder.attached.first as AnyObject?)) + } + + func testRemoteLoggerDetachesOnlyOncePerShutdown() { + let recorder = EventRecorderSpy() + let logger = makeRemoteLogger(recorder: recorder) + logger.start() + + logger.shutdown() + logger.shutdown() + logger.start() + + XCTAssertEqual(recorder.detached.count, 1) + XCTAssertEqual(recorder.attached.count, 1) + } + + func testRemoteLoggerThatNeverStartedDoesNotDetachOnShutdown() { + // The controller shuts down a logger that lost the install race without starting it, + // while the winner is attached to the shared recorder. + let recorder = EventRecorderSpy() + let logger = makeRemoteLogger(recorder: recorder) + + logger.shutdown() + + XCTAssertTrue(recorder.attached.isEmpty) + XCTAssertTrue(recorder.detached.isEmpty) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift index baf1733b9..b60a1ad77 100644 --- a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift +++ b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift @@ -109,6 +109,9 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { @objc static func reset() { shared.shutdown() + // An app-id change comes through here; events queued under the old app must not ship + // under the new one. + OSObservabilityEventRecorder.shared.reset() } private static func cache(configuration: OSRemoteLoggingConfiguration) {