From 455e6403e49f1840b37c475b7e1bfa2db536d8e2 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Thu, 23 Oct 2025 17:07:33 +0200 Subject: [PATCH 01/32] Support capturing logs in hub and client --- Sentry.xcodeproj/project.pbxproj | 4 - Sources/Sentry/Public/SentryClient.h | 4 + Sources/Sentry/Public/SentryHub.h | 6 + Sources/Sentry/SentryClient.m | 20 ++- Sources/Sentry/SentryHub.m | 14 ++ Sources/Sentry/include/SentryClient+Logs.h | 14 -- Sources/Sentry/include/SentryPrivate.h | 1 - Sources/Swift/Helper/SentrySDK.swift | 29 +--- Sources/Swift/Protocol/SentryLog.swift | 27 ++++ Sources/Swift/Tools/SentryLogBatcher.swift | 162 +++++++++++++++++---- Sources/Swift/Tools/SentryLogger.swift | 124 +--------------- 11 files changed, 209 insertions(+), 196 deletions(-) delete mode 100644 Sources/Sentry/include/SentryClient+Logs.h diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 0166c545b26..4ba9f4a8fcb 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -734,7 +734,6 @@ 92D957732E05A44600E20E66 /* SentryAsyncLog.m in Sources */ = {isa = PBXBuildFile; fileRef = 92D957722E05A44600E20E66 /* SentryAsyncLog.m */; }; 92D957772E05A4F300E20E66 /* SentryAsyncLog.h in Headers */ = {isa = PBXBuildFile; fileRef = 92D957762E05A4F300E20E66 /* SentryAsyncLog.h */; }; 92E5F3D62CDBB3BF00B7AD98 /* SentrySampling.h in Headers */ = {isa = PBXBuildFile; fileRef = 8E8C57A525EEFC42001CEEFA /* SentrySampling.h */; }; - 92EC54CE2E1EB54B00A10AC2 /* SentryClient+Logs.h in Headers */ = {isa = PBXBuildFile; fileRef = 92EC54CD2E1EB54B00A10AC2 /* SentryClient+Logs.h */; }; 92ECD7202E05A7DF0063EC10 /* SentryLogC.h in Headers */ = {isa = PBXBuildFile; fileRef = D8AE48B12C5786AA0092A2A6 /* SentryLogC.h */; settings = {ATTRIBUTES = (Private, ); }; }; 92ECD73C2E05ACE00063EC10 /* SentryLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92ECD73B2E05ACDE0063EC10 /* SentryLog.swift */; }; 92ECD73E2E05AD320063EC10 /* SentryLogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92ECD73D2E05AD2B0063EC10 /* SentryLogLevel.swift */; }; @@ -2093,7 +2092,6 @@ 92B6BDAC2E05B9F700D538B3 /* SentryLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogTests.swift; sourceTree = ""; }; 92D957722E05A44600E20E66 /* SentryAsyncLog.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryAsyncLog.m; sourceTree = ""; }; 92D957762E05A4F300E20E66 /* SentryAsyncLog.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryAsyncLog.h; path = include/SentryAsyncLog.h; sourceTree = ""; }; - 92EC54CD2E1EB54B00A10AC2 /* SentryClient+Logs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentryClient+Logs.h"; path = "include/SentryClient+Logs.h"; sourceTree = ""; }; 92ECD73B2E05ACDE0063EC10 /* SentryLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLog.swift; sourceTree = ""; }; 92ECD73D2E05AD2B0063EC10 /* SentryLogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogLevel.swift; sourceTree = ""; }; 92ECD73F2E05AD500063EC10 /* SentryLogAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogAttribute.swift; sourceTree = ""; }; @@ -3113,7 +3111,6 @@ 63AA76941EB9C1C200D153DE /* SentryClient.h */, 63AA75ED1EB8B3C400D153DE /* SentryClient.m */, 7B85DC1C24EFAFCD007D01D2 /* SentryClient+Private.h */, - 92EC54CD2E1EB54B00A10AC2 /* SentryClient+Logs.h */, 7B610D5E2512390E00B0B5D9 /* SentrySDK+Private.h */, FA6555132E30181B009917BC /* SentrySDKInternal.h */, FA6555152E30182B009917BC /* SentrySDKInternal.m */, @@ -5148,7 +5145,6 @@ 8E7C98312693E1CC00E6336C /* SentryTraceHeader.h in Headers */, 62C316812B1F2E93000D7031 /* SentryDelayedFramesTracker.h in Headers */, 92D957772E05A4F300E20E66 /* SentryAsyncLog.h in Headers */, - 92EC54CE2E1EB54B00A10AC2 /* SentryClient+Logs.h in Headers */, 7B8713AE26415ADF006D6004 /* SentryAppStartTrackingIntegration.h in Headers */, 7B7D873224864BB900D2ECFF /* SentryCrashMachineContextWrapper.h in Headers */, 861265F92404EC1500C4AFDE /* SentryArray.h in Headers */, diff --git a/Sources/Sentry/Public/SentryClient.h b/Sources/Sentry/Public/SentryClient.h index 19d0d22de29..c3e3713c8c9 100644 --- a/Sources/Sentry/Public/SentryClient.h +++ b/Sources/Sentry/Public/SentryClient.h @@ -12,6 +12,7 @@ @class SentryOptions; @class SentryScope; @class SentryTransaction; +@class SentryLog; NS_ASSUME_NONNULL_BEGIN @@ -101,6 +102,9 @@ SENTRY_NO_INIT - (void)captureFeedback:(SentryFeedback *)feedback withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(feedback:scope:)); +- (void)captureLog:(SentryLog *)log + withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)); + /** * Waits synchronously for the SDK to flush out all queued and cached items for up to the specified * timeout in seconds. If there is no internet connection, the function returns immediately. The SDK diff --git a/Sources/Sentry/Public/SentryHub.h b/Sources/Sentry/Public/SentryHub.h index 3633bd5580d..3358131cae5 100644 --- a/Sources/Sentry/Public/SentryHub.h +++ b/Sources/Sentry/Public/SentryHub.h @@ -15,6 +15,7 @@ @class SentryScope; @class SentryTransactionContext; @class SentryUser; +@class SentryLog; NS_ASSUME_NONNULL_BEGIN @interface SentryHub : NSObject @@ -175,6 +176,11 @@ SENTRY_NO_INIT */ - (void)captureFeedback:(SentryFeedback *)feedback; +- (void)captureLog:(SentryLog *)log NS_SWIFT_NAME(capture(log:)); + +- (void)captureLog:(SentryLog *)log + withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)); + /** * Use this method to modify the Scope of the Hub. The SDK uses the Scope to attach * contextual data to events. diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index 03dfe4a1d52..bf1e4e8f189 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -46,13 +46,14 @@ NS_ASSUME_NONNULL_BEGIN -@interface SentryClient () +@interface SentryClient () @property (nonatomic, strong) SentryTransportAdapter *transportAdapter; @property (nonatomic, strong) SentryDebugImageProvider *debugImageProvider; @property (nonatomic, strong) id random; @property (nonatomic, strong) NSLocale *locale; @property (nonatomic, strong) NSTimeZone *timezone; +@property (nonatomic, strong) SentryLogBatcher *logBatcher; @end @@ -149,6 +150,10 @@ - (instancetype)initWithOptions:(SentryOptions *)options self.locale = locale; self.timezone = timezone; self.attachmentProcessors = [[NSMutableArray alloc] init]; + self.logBatcher = [[SentryLogBatcher alloc] + initWithOptions:options + dispatchQueue:SentryDependencyContainer.sharedInstance.dispatchQueueWrapper]; + self.logBatcher.delegate = self; // The SDK stores the installationID in a file. The first call requires file IO. To avoid // executing this on the main thread, we cache the installationID async here. @@ -664,7 +669,11 @@ - (SentryEvent *_Nullable)prepareEvent:(SentryEvent *)event - (void)flush:(NSTimeInterval)timeout { - [self.transportAdapter flush:timeout]; + NSTimeInterval captureLogsDuration = [self.logBatcher captureLogs]; + // Capturing batched logs should never take long, but we need to fall back to a sane value. + // This is a workaround for experimental logs, until we'll write batched logs to disk, + // to avoid data loss due to crashes. This is a trade-off until then. + [self.transportAdapter flush:fmax(timeout / 2, timeout - captureLogsDuration)]; } - (void)close @@ -1121,7 +1130,12 @@ - (void)removeAttachmentProcessor:(id)attachmen return processedAttachments; } -- (void)captureLogsData:(NSData *)data with:(NSNumber *)itemCount; +- (void)captureLog:(SentryLog *)log withScope:(SentryScope *)scope +{ + [self.logBatcher addLog:log scope:scope]; +} + +- (void)captureLogsData:(NSData *)data with:(NSNumber *)itemCount { SentryEnvelopeItemHeader *header = [[SentryEnvelopeItemHeader alloc] initWithType:SentryEnvelopeItemTypes.log diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index e92c237a0b4..51b65eaa272 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -539,6 +539,20 @@ - (void)captureFeedback:(SentryFeedback *)feedback } } +- (void)captureLog:(SentryLog *)log NS_SWIFT_NAME(capture(log:)) +{ + [self captureLog:log withScope:self.scope]; +} + +- (void)captureLog:(SentryLog *)log + withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)) +{ + SentryClient *client = self.client; + if (client != nil) { + [client captureLog:log withScope:self.scope]; + } +} + - (void)captureSerializedFeedback:(NSDictionary *)serializedFeedback withEventId:(NSString *)feedbackEventId attachments:(NSArray *)feedbackAttachments diff --git a/Sources/Sentry/include/SentryClient+Logs.h b/Sources/Sentry/include/SentryClient+Logs.h deleted file mode 100644 index b25c951d5a0..00000000000 --- a/Sources/Sentry/include/SentryClient+Logs.h +++ /dev/null @@ -1,14 +0,0 @@ -#import "SentryClient.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface SentryClient () - -/** - * Helper to capture encoded logs, as SentryEnvelope can't be used in the Swift SDK. - */ -- (void)captureLogsData:(NSData *)data with:(NSNumber *)itemCount; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryPrivate.h b/Sources/Sentry/include/SentryPrivate.h index 93ae7fb6acf..dcad2c92d8a 100644 --- a/Sources/Sentry/include/SentryPrivate.h +++ b/Sources/Sentry/include/SentryPrivate.h @@ -27,7 +27,6 @@ #import "SentryANRTrackerV1.h" #import "SentryANRTrackerV2.h" #import "SentryAsyncLog.h" -#import "SentryClient+Logs.h" #import "SentryContinuousProfiler.h" #import "SentryCrash.h" #import "SentryCrashDebug.h" diff --git a/Sources/Swift/Helper/SentrySDK.swift b/Sources/Swift/Helper/SentrySDK.swift index 293b63efa7d..5669c217448 100644 --- a/Sources/Swift/Helper/SentrySDK.swift +++ b/Sources/Swift/Helper/SentrySDK.swift @@ -32,18 +32,12 @@ import Foundation if !sdkEnabled { SentrySDKLog.fatal("Logs called before SentrySDK.start() will be dropped.") } - if let _logger, _loggerConfigured { + if let _logger, !_loggerConfigured { return _logger } - let hub = SentryDependencyContainerSwiftHelper.currentHub() - var batcher: SentryLogBatcher? - if let client = hub.getClient(), client.options.enableLogs { - batcher = SentryLogBatcher(client: client, dispatchQueue: Dependencies.dispatchQueueWrapper) - } let logger = SentryLogger( - hub: hub, - dateProvider: Dependencies.dateProvider, - batcher: batcher + hub: SentryDependencyContainerSwiftHelper.currentHub(), + dateProvider: Dependencies.dateProvider ) _logger = logger _loggerConfigured = sdkEnabled @@ -360,18 +354,12 @@ import Foundation /// - note: This might take slightly longer than the specified timeout if there are many batched logs to capture. @objc(flush:) public static func flush(timeout: TimeInterval) { - let captureLogsDuration = captureLogs() - // Capturing batched logs should never take long, but we need to fall back to a sane value. - // This is a workaround for experimental logs, until we'll write batched logs to disk, - // to avoid data loss due to crashes. This is a trade-off until then. - SentrySDKInternal.flush(timeout: max(timeout / 2, timeout - captureLogsDuration)) + SentrySDKInternal.flush(timeout: timeout) } /// Closes the SDK, uninstalls all the integrations, and calls `flush` with /// `SentryOptions.shutdownTimeInterval`. @objc public static func close() { - // Capturing batched logs should never take long, ignore the duration here. - _ = captureLogs() SentrySDKInternal.close() } @@ -430,15 +418,6 @@ import Foundation private static var _logger: SentryLogger? // Flag to re-create instance if accessed before SDK init. private static var _loggerConfigured = false - - @discardableResult - private static func captureLogs() -> TimeInterval { - var duration: TimeInterval = 0.0 - _loggerLock.synchronized { - duration = _logger?.captureLogs() ?? 0.0 - } - return duration - } } extension SentryIdWrapper { diff --git a/Sources/Swift/Protocol/SentryLog.swift b/Sources/Swift/Protocol/SentryLog.swift index 33fc58e333f..b5dd23dc7e2 100644 --- a/Sources/Swift/Protocol/SentryLog.swift +++ b/Sources/Swift/Protocol/SentryLog.swift @@ -17,6 +17,33 @@ public final class SentryLog: NSObject { /// Numeric representation of the severity level (Int) public var severityNumber: NSNumber? + @objc public convenience init( + level: Level, + body: String + ) { + self.init( + timestamp: Date(), + traceId: SentryId.empty, + level: level, + body: body, + attributes: [:] + ) + } + + @objc public convenience init( + level: Level, + body: String, + attributes: [String: Attribute] + ) { + self.init( + timestamp: Date(), + traceId: SentryId.empty, + level: level, + body: body, + attributes: attributes + ) + } + internal init( timestamp: Date, traceId: SentryId, diff --git a/Sources/Swift/Tools/SentryLogBatcher.swift b/Sources/Swift/Tools/SentryLogBatcher.swift index 09055b30a92..ae934af13b8 100644 --- a/Sources/Swift/Tools/SentryLogBatcher.swift +++ b/Sources/Swift/Tools/SentryLogBatcher.swift @@ -1,17 +1,20 @@ @_implementationOnly import _SentryPrivate import Foundation +@objc @_spi(Private) public protocol SentryLogBatcherDelegate: AnyObject { + @objc(captureLogsData:with:) + func capture(logsData: NSData, count: NSNumber) +} + @objc @objcMembers @_spi(Private) public class SentryLogBatcher: NSObject { - private let client: SentryClient + private let options: Options private let flushTimeout: TimeInterval private let maxBufferSizeBytes: Int private let dispatchQueue: SentryDispatchQueueWrapper - internal let options: Options - // All mutable state is accessed from the same serial dispatch queue. // Every logs data is added sepratley. They are flushed together in an envelope. @@ -19,9 +22,25 @@ import Foundation private var encodedLogsSize: Int = 0 private var timerWorkItem: DispatchWorkItem? + public weak var delegate: SentryLogBatcherDelegate? + + /// Convenience initializer with default flush timeout and buffer size. + /// - Parameters: + /// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state + /// + /// - Important: The `dispatchQueue` parameter MUST be a serial queue to ensure thread safety. + /// Passing a concurrent queue will result in undefined behavior and potential data races. + @_spi(Private) public convenience init(options: Options, dispatchQueue: SentryDispatchQueueWrapper) { + self.init( + options: options, + flushTimeout: 5, + maxBufferSizeBytes: 1_024 * 1_024, // 1MB + dispatchQueue: dispatchQueue + ) + } + /// Initializes a new SentryLogBatcher. /// - Parameters: - /// - client: The SentryClient to use for sending logs /// - flushTimeout: The timeout interval after which buffered logs will be flushed /// - maxBufferSizeBytes: The maximum buffer size in bytes before triggering an immediate flush /// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state @@ -29,44 +48,50 @@ import Foundation /// - Important: The `dispatchQueue` parameter MUST be a serial queue to ensure thread safety. /// Passing a concurrent queue will result in undefined behavior and potential data races. @_spi(Private) public init( - client: SentryClient, + options: Options, flushTimeout: TimeInterval, maxBufferSizeBytes: Int, dispatchQueue: SentryDispatchQueueWrapper ) { - self.client = client - self.options = client.options + self.options = options self.flushTimeout = flushTimeout self.maxBufferSizeBytes = maxBufferSizeBytes self.dispatchQueue = dispatchQueue super.init() } - /// Convenience initializer with default flush timeout and buffer size. - /// - Parameters: - /// - client: The SentryClient to use for sending logs - /// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state - /// - /// - Important: The `dispatchQueue` parameter MUST be a serial queue to ensure thread safety. - /// Passing a concurrent queue will result in undefined behavior and potential data races. - @_spi(Private) public convenience init(client: SentryClient, dispatchQueue: SentryDispatchQueueWrapper) { - self.init( - client: client, - flushTimeout: 5, - maxBufferSizeBytes: 1_024 * 1_024, // 1MB - dispatchQueue: dispatchQueue - ) - } - - @_spi(Private) func add(_ log: SentryLog) { - dispatchQueue.dispatchAsync { [weak self] in - self?.encodeAndBuffer(log: log) + @_spi(Private) @objc public func addLog(_ log: SentryLog, scope: Scope) { + guard options.enableLogs else { + return + } + + addDefaultAttributes(to: &log.attributes, scope: scope) + addOSAttributes(to: &log.attributes, scope: scope) + addDeviceAttributes(to: &log.attributes, scope: scope) + addUserAttributes(to: &log.attributes, scope: scope) + + let propagationContextTraceIdString = scope.propagationContextTraceIdString + log.traceId = SentryId(uuidString: propagationContextTraceIdString) + + var processedLog: SentryLog? = log + if let beforeSendLog = options.beforeSendLog { + processedLog = beforeSendLog(log) + } + + if let processedLog { + SentrySDKLog.log( + message: "[SentryLogger] \(processedLog.body)", + andLevel: processedLog.level.toSentryLevel() + ) + dispatchQueue.dispatchAsync { [weak self] in + self?.encodeAndBuffer(log: processedLog) + } } } // Captures batched logs sync and returns the duration. @discardableResult - @_spi(Private) func captureLogs() -> TimeInterval { + @_spi(Private) @objc public func captureLogs() -> TimeInterval { let startTimeNs = SentryDefaultCurrentDateProvider.getAbsoluteTime() dispatchQueue.dispatchSync { [weak self] in self?.performCaptureLogs() @@ -77,6 +102,60 @@ import Foundation // Helper + private func addDefaultAttributes(to attributes: inout [String: SentryLog.Attribute], scope: Scope) { + attributes["sentry.sdk.name"] = .init(string: SentryMeta.sdkName) + attributes["sentry.sdk.version"] = .init(string: SentryMeta.versionString) + attributes["sentry.environment"] = .init(string: options.environment) + if let releaseName = options.releaseName { + attributes["sentry.release"] = .init(string: releaseName) + } + if let span = scope.span { + attributes["sentry.trace.parent_span_id"] = .init(string: span.spanId.sentrySpanIdString) + } + } + + private func addOSAttributes(to attributes: inout [String: SentryLog.Attribute], scope: Scope) { + guard let osContext = scope.getContextForKey(SENTRY_CONTEXT_OS_KEY) else { + return + } + if let osName = osContext["name"] as? String { + attributes["os.name"] = .init(string: osName) + } + if let osVersion = osContext["version"] as? String { + attributes["os.version"] = .init(string: osVersion) + } + } + + private func addDeviceAttributes(to attributes: inout [String: SentryLog.Attribute], scope: Scope) { + guard let deviceContext = scope.getContextForKey(SENTRY_CONTEXT_DEVICE_KEY) else { + return + } + // For Apple devices, brand is always "Apple" + attributes["device.brand"] = .init(string: "Apple") + + if let deviceModel = deviceContext["model"] as? String { + attributes["device.model"] = .init(string: deviceModel) + } + if let deviceFamily = deviceContext["family"] as? String { + attributes["device.family"] = .init(string: deviceFamily) + } + } + + private func addUserAttributes(to attributes: inout [String: SentryLog.Attribute], scope: Scope) { + guard let user = scope.userObject else { + return + } + if let userId = user.userId { + attributes["user.id"] = .init(string: userId) + } + if let userName = user.name { + attributes["user.name"] = .init(string: userName) + } + if let userEmail = user.email { + attributes["user.email"] = .init(string: userEmail) + } + } + // Only ever call this from the serial dispatch queue. private func encodeAndBuffer(log: SentryLog) { do { @@ -138,7 +217,32 @@ import Foundation payloadData.append(Data("]}".utf8)) // Send the payload. - - client.captureLogsData(payloadData, with: NSNumber(value: encodedLogs.count)) + delegate?.capture(logsData: payloadData as NSData, count: NSNumber(value: encodedLogs.count)) + } +} + +#if SWIFT_PACKAGE +/** + * Use this callback to drop or modify a log before the SDK sends it to Sentry. Return `nil` to + * drop the log. + */ +public typealias SentryBeforeSendLogCallback = (SentryLog) -> SentryLog? + +// Makes the `beforeSendLog` property visible as the Swift type `SentryBeforeSendLogCallback`. +// This works around `SentryLog` being only forward declared in the objc header, resulting in +// compile time issues with SPM builds. +@objc +public extension Options { + /** + * Use this callback to drop or modify a log before the SDK sends it to Sentry. Return `nil` to + * drop the log. + */ + @objc + var beforeSendLog: SentryBeforeSendLogCallback? { + // Note: This property provides SentryLog type safety for SPM builds where the native Objective-C + // property cannot be used due to Swift-to-Objective-C bridging limitations. + get { return value(forKey: "beforeSendLogDynamic") as? SentryBeforeSendLogCallback } + set { setValue(newValue, forKey: "beforeSendLogDynamic") } } } +#endif // SWIFT_PACKAGE diff --git a/Sources/Swift/Tools/SentryLogger.swift b/Sources/Swift/Tools/SentryLogger.swift index dd5bbff5d60..2326ff62bc9 100644 --- a/Sources/Swift/Tools/SentryLogger.swift +++ b/Sources/Swift/Tools/SentryLogger.swift @@ -31,13 +31,10 @@ import Foundation public final class SentryLogger: NSObject { private let hub: SentryHub private let dateProvider: SentryCurrentDateProvider - // Nil in the case where the Hub's client is nil or logs are disabled through options. - private let batcher: SentryLogBatcher? - @_spi(Private) public init(hub: SentryHub, dateProvider: SentryCurrentDateProvider, batcher: SentryLogBatcher?) { + @_spi(Private) public init(hub: SentryHub, dateProvider: SentryCurrentDateProvider) { self.hub = hub self.dateProvider = dateProvider - self.batcher = batcher super.init() } @@ -166,21 +163,10 @@ public final class SentryLogger: NSObject { let message = SentryLogMessage(stringLiteral: body) captureLog(level: .fatal, logMessage: message, attributes: attributes) } - - // MARK: - Internal - - // Captures batched logs sync and return the duration. - func captureLogs() -> TimeInterval { - return batcher?.captureLogs() ?? 0.0 - } // MARK: - Private - private func captureLog(level: SentryLog.Level, logMessage: SentryLogMessage, attributes: [String: Any]) { - guard let batcher else { - return - } - + private func captureLog(level: SentryLog.Level, logMessage: SentryLogMessage, attributes: [String: Any]) { // Convert provided attributes to SentryLog.Attribute format var logAttributes = attributes.mapValues { SentryLog.Attribute(value: $0) } @@ -193,117 +179,15 @@ public final class SentryLogger: NSObject { for (index, attribute) in logMessage.attributes.enumerated() { logAttributes["sentry.message.parameter.\(index)"] = attribute } - - addDefaultAttributes(to: &logAttributes) - addOSAttributes(to: &logAttributes) - addDeviceAttributes(to: &logAttributes) - addUserAttributes(to: &logAttributes) - let propagationContextTraceIdString = hub.scope.propagationContextTraceIdString - let propagationContextTraceId = SentryId(uuidString: propagationContextTraceIdString) - let log = SentryLog( timestamp: dateProvider.date(), - traceId: propagationContextTraceId, + traceId: SentryId.empty, level: level, body: logMessage.message, attributes: logAttributes ) - var processedLog: SentryLog? = log - if let beforeSendLog = batcher.options.beforeSendLog { - processedLog = beforeSendLog(log) - } - - if let processedLog { - SentrySDKLog.log( - message: "[SentryLogger] \(processedLog.body)", - andLevel: processedLog.level.toSentryLevel() - ) - batcher.add(processedLog) - } - } - - private func addDefaultAttributes(to attributes: inout [String: SentryLog.Attribute]) { - guard let batcher else { - return - } - attributes["sentry.sdk.name"] = .init(string: SentryMeta.sdkName) - attributes["sentry.sdk.version"] = .init(string: SentryMeta.versionString) - attributes["sentry.environment"] = .init(string: batcher.options.environment) - if let releaseName = batcher.options.releaseName { - attributes["sentry.release"] = .init(string: releaseName) - } - if let span = hub.scope.span { - attributes["sentry.trace.parent_span_id"] = .init(string: span.spanId.sentrySpanIdString) - } - } - - private func addOSAttributes(to attributes: inout [String: SentryLog.Attribute]) { - guard let osContext = hub.scope.getContextForKey(SENTRY_CONTEXT_OS_KEY) else { - return - } - if let osName = osContext["name"] as? String { - attributes["os.name"] = .init(string: osName) - } - if let osVersion = osContext["version"] as? String { - attributes["os.version"] = .init(string: osVersion) - } - } - - private func addDeviceAttributes(to attributes: inout [String: SentryLog.Attribute]) { - guard let deviceContext = hub.scope.getContextForKey(SENTRY_CONTEXT_DEVICE_KEY) else { - return - } - // For Apple devices, brand is always "Apple" - attributes["device.brand"] = .init(string: "Apple") - - if let deviceModel = deviceContext["model"] as? String { - attributes["device.model"] = .init(string: deviceModel) - } - if let deviceFamily = deviceContext["family"] as? String { - attributes["device.family"] = .init(string: deviceFamily) - } - } - - private func addUserAttributes(to attributes: inout [String: SentryLog.Attribute]) { - guard let user = hub.scope.userObject else { - return - } - if let userId = user.userId { - attributes["user.id"] = .init(string: userId) - } - if let userName = user.name { - attributes["user.name"] = .init(string: userName) - } - if let userEmail = user.email { - attributes["user.email"] = .init(string: userEmail) - } - } -} - -#if SWIFT_PACKAGE -/** - * Use this callback to drop or modify a log before the SDK sends it to Sentry. Return `nil` to - * drop the log. - */ -public typealias SentryBeforeSendLogCallback = (SentryLog) -> SentryLog? - -// Makes the `beforeSendLog` property visible as the Swift type `SentryBeforeSendLogCallback`. -// This works around `SentryLog` being only forward declared in the objc header, resulting in -// compile time issues with SPM builds. -@objc -public extension Options { - /** - * Use this callback to drop or modify a log before the SDK sends it to Sentry. Return `nil` to - * drop the log. - */ - @objc - var beforeSendLog: SentryBeforeSendLogCallback? { - // Note: This property provides SentryLog type safety for SPM builds where the native Objective-C - // property cannot be used due to Swift-to-Objective-C bridging limitations. - get { return value(forKey: "beforeSendLogDynamic") as? SentryBeforeSendLogCallback } - set { setValue(newValue, forKey: "beforeSendLogDynamic") } + hub.capture(log: log) } } -#endif // SWIFT_PACKAGE From 79f488f56ace71730151a8b83248465541215b1f Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Thu, 23 Oct 2025 17:10:35 +0200 Subject: [PATCH 02/32] fix typo --- Sources/Swift/Helper/SentrySDK.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Swift/Helper/SentrySDK.swift b/Sources/Swift/Helper/SentrySDK.swift index 5669c217448..7df83f37296 100644 --- a/Sources/Swift/Helper/SentrySDK.swift +++ b/Sources/Swift/Helper/SentrySDK.swift @@ -32,7 +32,7 @@ import Foundation if !sdkEnabled { SentrySDKLog.fatal("Logs called before SentrySDK.start() will be dropped.") } - if let _logger, !_loggerConfigured { + if let _logger, _loggerConfigured { return _logger } let logger = SentryLogger( From 0a4031e2e93a78f24dc21305c0d0611338321ab3 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Fri, 24 Oct 2025 14:30:51 +0200 Subject: [PATCH 03/32] update tests --- SentryTestUtils/TestClient.swift | 6 +- Tests/SentryTests/SentryClientTests.swift | 59 +- Tests/SentryTests/SentryLogBatcherTests.swift | 506 +++++++++++++++--- Tests/SentryTests/SentryLoggerTests.swift | 393 +------------- .../SentryTests/SentrySDKInternalTests.swift | 27 +- Tests/SentryTests/SentrySDKTests.swift | 47 +- 6 files changed, 483 insertions(+), 555 deletions(-) diff --git a/SentryTestUtils/TestClient.swift b/SentryTestUtils/TestClient.swift index 38e6865fd9f..2a7f3f0546a 100644 --- a/SentryTestUtils/TestClient.swift +++ b/SentryTestUtils/TestClient.swift @@ -166,8 +166,8 @@ public class TestClient: SentryClient { flushInvocations.record(timeout) } - public var captureLogsDataInvocations = Invocations<(data: Data, count: NSNumber)>() - public override func captureLogsData(_ data: Data, with count: NSNumber) { - captureLogsDataInvocations.record((data, count)) + public var captureLogInvocations = Invocations<(log: SentryLog, scope: Scope)>() + public override func capture(log: SentryLog, scope: Scope) { + captureLogInvocations.record((log, scope)) } } diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index ddd57565842..d3b36fbb8e0 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -2171,40 +2171,31 @@ class SentryClientTests: XCTestCase { XCTAssertEqual(scope.replayId, "someReplay") } - func testCaptureLogsData() throws { + func testCaptureLog() throws { let sut = fixture.getSut() - let logData = Data("{\"items\":[{\"timestamp\":1627846801,\"level\":\"info\",\"body\":\"Test log message\"}]}".utf8) - sut.captureLogsData(logData, with: NSNumber(value: 1)) - - // Verify that an envelope was sent - XCTAssertEqual(1, fixture.transport.sentEnvelopes.count) - - let envelope = try XCTUnwrap(fixture.transport.sentEnvelopes.first) - - // Verify envelope has one item - XCTAssertEqual(1, envelope.items.count) - - let item = try XCTUnwrap(envelope.items.first) - - // Verify the envelope item header - XCTAssertEqual("log", item.header.type) - XCTAssertEqual(UInt(logData.count), item.header.length) - XCTAssertEqual("application/vnd.sentry.items.log+json", item.header.contentType) - XCTAssertEqual(NSNumber(value: 1), item.header.itemCount) - - // Verify the envelope item data - XCTAssertEqual(logData, item.data) - } - - func testCaptureLogsData_WithDisabledClient() { - let sut = fixture.getSutDisabledSdk() - let logData = Data("{\"items\":[{\"timestamp\":1627846801,\"level\":\"info\",\"body\":\"Test log message\"}]}".utf8) + // Create a test batcher to verify addLog is called + let testBatcher = TestLogBatcherForClient( + options: sut.options, + dispatchQueue: TestSentryDispatchQueueWrapper() + ) + Dynamic(sut).logBatcher = testBatcher + + let log = SentryLog( + timestamp: Date(timeIntervalSince1970: 1_627_846_801), + traceId: SentryId.empty, + level: .info, + body: "Test log message", + attributes: [:] + ) + let scope = Scope() - sut.captureLogsData(logData, with: NSNumber(value: 1)) + sut.capture(log: log, scope: scope) - // Verify that no envelope was sent when client is disabled - XCTAssertEqual(0, fixture.transport.sentEnvelopes.count) + // Verify that the log was passed to the batcher + XCTAssertEqual(testBatcher.addLogInvocations.count, 1) + XCTAssertEqual(testBatcher.addLogInvocations.first?.log.body, "Test log message") + XCTAssertEqual(testBatcher.addLogInvocations.first?.log.level, .info) } func testCaptureSentryWrappedException() throws { @@ -2414,6 +2405,14 @@ private extension SentryClientTests { } +final class TestLogBatcherForClient: SentryLogBatcher { + var addLogInvocations = Invocations<(log: SentryLog, scope: Scope)>() + + override func addLog(_ log: SentryLog, scope: Scope) { + addLogInvocations.record((log, scope)) + } +} + enum SentryClientError: Error { case someError case invalidInput(String) diff --git a/Tests/SentryTests/SentryLogBatcherTests.swift b/Tests/SentryTests/SentryLogBatcherTests.swift index 99856566e76..8aece56356b 100644 --- a/Tests/SentryTests/SentryLogBatcherTests.swift +++ b/Tests/SentryTests/SentryLogBatcherTests.swift @@ -5,7 +5,7 @@ import XCTest final class SentryLogBatcherTests: XCTestCase { private var options: Options! - private var testClient: TestClient! + private var testDelegate: TestLogBatcherDelegate! private var testDispatchQueue: TestSentryDispatchQueueWrapper! private var sut: SentryLogBatcher! private var scope: Scope! @@ -14,24 +14,26 @@ final class SentryLogBatcherTests: XCTestCase { super.setUp() options = Options() + options.dsn = TestConstants.dsnAsString(username: "SentryLogBatcherTests") options.enableLogs = true - testClient = TestClient(options: options) + testDelegate = TestLogBatcherDelegate() testDispatchQueue = TestSentryDispatchQueueWrapper() testDispatchQueue.dispatchAsyncExecutesBlock = true // Execute encoding immediately sut = SentryLogBatcher( - client: testClient, + options: options, flushTimeout: 0.1, // Very small timeout for testing - maxBufferSizeBytes: 500, // Small byte limit for testing + maxBufferSizeBytes: 800, // byte limit for testing (log with attributes ~390 bytes) dispatchQueue: testDispatchQueue ) + sut.delegate = testDelegate scope = Scope() } override func tearDown() { super.tearDown() - testClient = nil + testDelegate = nil testDispatchQueue = nil sut = nil scope = nil @@ -45,21 +47,22 @@ final class SentryLogBatcherTests: XCTestCase { let log2 = createTestLog(body: "Log 2") // When - sut.add(log1) - sut.add(log2) + sut.addLog(log1, scope: scope) + sut.addLog(log2, scope: scope) // Then - no immediate flush since buffer not full - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 0) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) // Trigger flush manually sut.captureLogs() // Verify both logs are batched together - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 1) - let sentData = try XCTUnwrap(testClient.captureLogsDataInvocations.first).data - let jsonObject = try XCTUnwrap(JSONSerialization.jsonObject(with: sentData) as? [String: Any]) - let items = try XCTUnwrap(jsonObject["items"] as? [[String: Any]]) - XCTAssertEqual(2, items.count) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) + + let capturedLogs = testDelegate.getCapturedLogs() + XCTAssertEqual(capturedLogs.count, 2) + XCTAssertEqual(capturedLogs[0].body, "Log 1") + XCTAssertEqual(capturedLogs[1].body, "Log 2") } // MARK: - Buffer Size Tests @@ -70,17 +73,15 @@ final class SentryLogBatcherTests: XCTestCase { let largeLog = createTestLog(body: largeLogBody) // When - add a log that exceeds buffer size - sut.add(largeLog) + sut.addLog(largeLog, scope: scope) // Then - should trigger immediate flush - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 1) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) // Verify the large log is sent - let sentData = try XCTUnwrap(testClient.captureLogsDataInvocations.first).data - let jsonObject = try XCTUnwrap(JSONSerialization.jsonObject(with: sentData) as? [String: Any]) - let items = try XCTUnwrap(jsonObject["items"] as? [[String: Any]]) - XCTAssertEqual(1, items.count) - XCTAssertEqual(largeLogBody, items[0]["body"] as? String) + let capturedLogs = testDelegate.getCapturedLogs() + XCTAssertEqual(capturedLogs.count, 1) + XCTAssertEqual(capturedLogs[0].body, largeLogBody) } // MARK: - Timeout Tests @@ -90,10 +91,10 @@ final class SentryLogBatcherTests: XCTestCase { let log = createTestLog() // When - sut.add(log) + sut.addLog(log, scope: scope) // Then - no immediate flush but timer should be started - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 0) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.interval, 0.1) @@ -101,11 +102,9 @@ final class SentryLogBatcherTests: XCTestCase { testDispatchQueue.invokeLastDispatchAfterWorkItem() // Verify flush occurred - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 1) - let sentData = try XCTUnwrap(testClient.captureLogsDataInvocations.first).data - let jsonObject = try XCTUnwrap(JSONSerialization.jsonObject(with: sentData) as? [String: Any]) - let items = try XCTUnwrap(jsonObject["items"] as? [[String: Any]]) - XCTAssertEqual(1, items.count) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) + let capturedLogs = testDelegate.getCapturedLogs() + XCTAssertEqual(capturedLogs.count, 1) } func testAddingLogToEmptyBuffer_StartsTimer() throws { @@ -114,30 +113,28 @@ final class SentryLogBatcherTests: XCTestCase { let log2 = createTestLog(body: "Log 2") // When - add first log to empty buffer - sut.add(log1) + sut.addLog(log1, scope: scope) // Then - timer should be started for first log XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.interval, 0.1) // When - add second log to non-empty buffer - sut.add(log2) + sut.addLog(log2, scope: scope) // Then - no additional timer should be started XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) // Should not flush immediately - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 0) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) // Manually trigger the timer testDispatchQueue.invokeLastDispatchAfterWorkItem() // Verify both logs are flushed together - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 1) - let sentData = try XCTUnwrap(testClient.captureLogsDataInvocations.first).data - let jsonObject = try XCTUnwrap(JSONSerialization.jsonObject(with: sentData) as? [String: Any]) - let items = try XCTUnwrap(jsonObject["items"] as? [[String: Any]]) - XCTAssertEqual(2, items.count) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) + let capturedLogs = testDelegate.getCapturedLogs() + XCTAssertEqual(capturedLogs.count, 2) } // MARK: - Manual Capture Logs Tests @@ -148,19 +145,17 @@ final class SentryLogBatcherTests: XCTestCase { let log2 = createTestLog(body: "Log 2") // When - sut.add(log1) - sut.add(log2) - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 0) + sut.addLog(log1, scope: scope) + sut.addLog(log2, scope: scope) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) sut.captureLogs() // Then - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 1) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) - let sentData = try XCTUnwrap(testClient.captureLogsDataInvocations.first).data - let jsonObject = try XCTUnwrap(JSONSerialization.jsonObject(with: sentData) as? [String: Any]) - let items = try XCTUnwrap(jsonObject["items"] as? [[String: Any]]) - XCTAssertEqual(2, items.count) + let capturedLogs = testDelegate.getCapturedLogs() + XCTAssertEqual(capturedLogs.count, 2) } func testManualCaptureLogs_CancelsScheduledCapture() throws { @@ -168,7 +163,7 @@ final class SentryLogBatcherTests: XCTestCase { let log = createTestLog() // When - sut.add(log) + sut.addLog(log, scope: scope) // Then - timer should be started XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) @@ -176,13 +171,13 @@ final class SentryLogBatcherTests: XCTestCase { // Manual flush immediately sut.captureLogs() - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 1, "Manual flush should work") + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1, "Manual flush should work") // Try to trigger the timer work item (should not flush again since timer was cancelled) timerWorkItem.perform() // Then - no additional flush should occur (timer was cancelled by performFlush) - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 1, "Timer should be cancelled") + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1, "Timer should be cancelled") } func testManualCaptureLogs_WithEmptyBuffer_DoesNothing() { @@ -190,7 +185,7 @@ final class SentryLogBatcherTests: XCTestCase { sut.captureLogs() // Then - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 0) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) } // MARK: - Edge Cases Tests @@ -202,20 +197,20 @@ final class SentryLogBatcherTests: XCTestCase { let log2 = createTestLog(body: largeLogBody) // Together > 500 bytes // When - add first log (starts timer) - sut.add(log1) - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 0) + sut.addLog(log1, scope: scope) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) XCTAssertEqual(testDispatchQueue.dispatchAfterWorkItemInvocations.count, 1) let timerWorkItem = try XCTUnwrap(testDispatchQueue.dispatchAfterWorkItemInvocations.first?.workItem) // Add second log that triggers size-based flush - sut.add(log2) - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 1) + sut.addLog(log2, scope: scope) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) // Try to trigger the original timer work item (should not flush again) timerWorkItem.perform() // Then - no additional flush should occur - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 1) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) } func testAddLogAfterFlush_StartsNewBatch() throws { @@ -224,36 +219,35 @@ final class SentryLogBatcherTests: XCTestCase { let log2 = createTestLog(body: "Log 2") // When - sut.add(log1) + sut.addLog(log1, scope: scope) sut.captureLogs() - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 1) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1) - sut.add(log2) + sut.addLog(log2, scope: scope) sut.captureLogs() // Then - should have two separate flush calls - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 2) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 2) // Verify each flush contains only one log - for (index, invocation) in testClient.captureLogsDataInvocations.invocations.enumerated() { - let jsonObject = try XCTUnwrap(JSONSerialization.jsonObject(with: invocation.data) as? [String: Any]) - let items = try XCTUnwrap(jsonObject["items"] as? [[String: Any]]) - XCTAssertEqual(1, items.count) - XCTAssertEqual("Log \(index + 1)", items[0]["body"] as? String) - } + let allCapturedLogs = testDelegate.getCapturedLogs() + XCTAssertEqual(allCapturedLogs.count, 2) + XCTAssertEqual(allCapturedLogs[0].body, "Log 1") + XCTAssertEqual(allCapturedLogs[1].body, "Log 2") } - // MARK: - IntegrationTests + // MARK: - Integration Tests func testConcurrentAdds_ThreadSafe() throws { // Given let sutWithRealQueue = SentryLogBatcher( - client: testClient, + options: options, flushTimeout: 5, maxBufferSizeBytes: 10_000, // Large buffer to avoid immediate flushes dispatchQueue: SentryDispatchQueueWrapper() // Real dispatch queue ) + sutWithRealQueue.delegate = testDelegate let expectation = XCTestExpectation(description: "Concurrent adds") expectation.expectedFulfillmentCount = 10 @@ -262,7 +256,7 @@ final class SentryLogBatcherTests: XCTestCase { for i in 0..<10 { DispatchQueue.global().async { let log = self.createTestLog(body: "Log \(i)") - sutWithRealQueue.add(log) + sutWithRealQueue.addLog(log, scope: self.scope) expectation.fulfill() } } @@ -270,31 +264,30 @@ final class SentryLogBatcherTests: XCTestCase { sutWithRealQueue.captureLogs() - // Verify all 10 logs were included in the single batch - let sentData = try XCTUnwrap(testClient.captureLogsDataInvocations.first).data - let jsonObject = try XCTUnwrap(JSONSerialization.jsonObject(with: sentData) as? [String: Any]) - let items = try XCTUnwrap(jsonObject["items"] as? [[String: Any]]) - XCTAssertEqual(10, items.count, "All 10 concurrently added logs should be in the batch") + // Verify all 10 logs were included in the batch + let capturedLogs = self.testDelegate.getCapturedLogs() + XCTAssertEqual(capturedLogs.count, 10, "All 10 concurrently added logs should be in the batch") // Note: We can't verify exact order due to concurrency, but count should be correct } func testDispatchAfterTimeoutWithRealDispatchQueue() throws { // Given - create batcher with real dispatch queue and short timeout let sutWithRealQueue = SentryLogBatcher( - client: testClient, + options: options, flushTimeout: 0.2, // Short but realistic timeout maxBufferSizeBytes: 10_000, // Large buffer to avoid size-based flush dispatchQueue: SentryDispatchQueueWrapper() // Real dispatch queue ) + sutWithRealQueue.delegate = testDelegate let log = createTestLog(body: "Real timeout test log") let expectation = XCTestExpectation(description: "Real timeout flush") // When - add log and wait for real timeout - sutWithRealQueue.add(log) + sutWithRealQueue.addLog(log, scope: scope) // Initially no flush should have occurred - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 0) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) // Wait for timeout to trigger flush DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { // Wait longer than timeout @@ -303,13 +296,317 @@ final class SentryLogBatcherTests: XCTestCase { wait(for: [expectation], timeout: 1.0) // Then - verify flush occurred due to timeout - XCTAssertEqual(testClient.captureLogsDataInvocations.count, 1, "Timeout should trigger flush") + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 1, "Timeout should trigger flush") + + let capturedLogs = self.testDelegate.getCapturedLogs() + XCTAssertEqual(capturedLogs.count, 1, "Should contain exactly one log") + XCTAssertEqual(capturedLogs[0].body, "Real timeout test log") + } + + // MARK: - Attribute Enrichment Tests + + func testAddLog_AddsDefaultAttributes() { + options.environment = "test-environment" + options.releaseName = "1.0.0" + + let span = SentryTracer(transactionContext: TransactionContext(name: "Test Transaction", operation: "test-operation"), hub: nil) + scope.span = span + + let log = createTestLog(body: "Test log message") + sut.addLog(log, scope: scope) + sut.captureLogs() + + // Verify the log was batched and sent + let capturedLogs = testDelegate.getCapturedLogs() + XCTAssertEqual(capturedLogs.count, 1) + + let capturedLog = capturedLogs.first! + let attributes = capturedLog.attributes + + XCTAssertEqual(attributes["sentry.sdk.name"]?.value as? String, SentryMeta.sdkName) + XCTAssertEqual(attributes["sentry.sdk.version"]?.value as? String, SentryMeta.versionString) + XCTAssertEqual(attributes["sentry.environment"]?.value as? String, "test-environment") + XCTAssertEqual(attributes["sentry.release"]?.value as? String, "1.0.0") + XCTAssertEqual(attributes["sentry.trace.parent_span_id"]?.value as? String, span.spanId.sentrySpanIdString) + } + + func testAddLog_DoesNotAddNilDefaultAttributes() { + options.releaseName = nil + // No span set on scope + + let log = createTestLog(body: "Test log message") + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + let attributes = capturedLog.attributes + + XCTAssertNil(attributes["sentry.release"]) + XCTAssertNil(attributes["sentry.trace.parent_span_id"]) + + // But should still have the non-nil defaults + XCTAssertEqual(attributes["sentry.sdk.name"]?.value as? String, SentryMeta.sdkName) + XCTAssertEqual(attributes["sentry.sdk.version"]?.value as? String, SentryMeta.versionString) + XCTAssertNotNil(attributes["sentry.environment"]) + } + + func testAddLog_SetsTraceIdFromPropagationContext() { + let expectedTraceId = SentryId() + let propagationContext = SentryPropagationContext(trace: expectedTraceId, spanId: SpanId()) + scope.propagationContext = propagationContext + + let log = createTestLog(body: "Test log message with trace ID") + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + XCTAssertEqual(capturedLog.traceId, expectedTraceId) + } + + func testAddLog_AddsUserAttributes() { + let user = User() + user.userId = "123" + user.email = "test@test.com" + user.name = "test-name" + scope.setUser(user) + + let log = createTestLog(body: "Test log message with user") + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + let attributes = capturedLog.attributes + + XCTAssertEqual(attributes["user.id"]?.value as? String, "123") + XCTAssertEqual(attributes["user.name"]?.value as? String, "test-name") + XCTAssertEqual(attributes["user.email"]?.value as? String, "test@test.com") + } + + func testAddLog_DoesNotAddNilUserAttributes() { + let user = User() + user.userId = "123" + // email and name are nil + scope.setUser(user) + + let log = createTestLog(body: "Test log message with partial user") + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + let attributes = capturedLog.attributes + + XCTAssertEqual(attributes["user.id"]?.value as? String, "123") + XCTAssertNil(attributes["user.name"]) + XCTAssertNil(attributes["user.email"]) + } + + func testAddLog_DoesNotAddUserAttributesWhenNoUser() { + // No user set on scope + + let log = createTestLog(body: "Test log message without user") + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + let attributes = capturedLog.attributes + + XCTAssertNil(attributes["user.id"]) + XCTAssertNil(attributes["user.name"]) + XCTAssertNil(attributes["user.email"]) + } + + func testAddLog_AddsOSAndDeviceAttributes() { + let osContext = ["name": "iOS", "version": "16.0.1"] + let deviceContext = ["family": "iOS", "model": "iPhone14,4"] + + scope.setContext(value: osContext, key: "os") + scope.setContext(value: deviceContext, key: "device") + + let log = createTestLog(body: "Test log message") + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + let attributes = capturedLog.attributes + + XCTAssertEqual(attributes["os.name"]?.value as? String, "iOS") + XCTAssertEqual(attributes["os.version"]?.value as? String, "16.0.1") + XCTAssertEqual(attributes["device.brand"]?.value as? String, "Apple") + XCTAssertEqual(attributes["device.model"]?.value as? String, "iPhone14,4") + XCTAssertEqual(attributes["device.family"]?.value as? String, "iOS") + } + + func testAddLog_HandlesPartialOSAndDeviceAttributes() { + let osContext = ["name": "macOS"] // Missing version + let deviceContext = ["family": "macOS"] // Missing model + + scope.setContext(value: osContext, key: "os") + scope.setContext(value: deviceContext, key: "device") + + let log = createTestLog(body: "Test log message") + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + let attributes = capturedLog.attributes + + XCTAssertEqual(attributes["os.name"]?.value as? String, "macOS") + XCTAssertNil(attributes["os.version"]) + XCTAssertEqual(attributes["device.brand"]?.value as? String, "Apple") + XCTAssertNil(attributes["device.model"]) + XCTAssertEqual(attributes["device.family"]?.value as? String, "macOS") + } + + func testAddLog_HandlesMissingOSAndDeviceContext() { + // Clear any OS and device context + scope.removeContext(key: "os") + scope.removeContext(key: "device") + + let log = createTestLog(body: "Test log message") + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + let attributes = capturedLog.attributes + + XCTAssertNil(attributes["os.name"]) + XCTAssertNil(attributes["os.version"]) + XCTAssertNil(attributes["device.brand"]) + XCTAssertNil(attributes["device.model"]) + XCTAssertNil(attributes["device.family"]) + } + + // MARK: - BeforeSendLog Callback Tests + + func testBeforeSendLog_ReturnsModifiedLog() { + var beforeSendCalled = false + options.beforeSendLog = { log in + beforeSendCalled = true + + XCTAssertEqual(log.level, .info) + XCTAssertEqual(log.body, "Original message") + + log.body = "Modified by callback" + log.level = .warn + log.attributes["callback_modified"] = SentryLog.Attribute(boolean: true) + + return log + } + + let log = createTestLog(level: .info, body: "Original message") + sut.addLog(log, scope: scope) + sut.captureLogs() + + XCTAssertTrue(beforeSendCalled) + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + XCTAssertEqual(capturedLog.level, .warn) + XCTAssertEqual(capturedLog.body, "Modified by callback") + XCTAssertEqual(capturedLog.attributes["callback_modified"]?.value as? Bool, true) + } + + func testBeforeSendLog_ReturnsNil_LogNotCaptured() { + var beforeSendCalled = false + options.beforeSendLog = { _ in + beforeSendCalled = true + return nil // Drop the log + } + + let log = createTestLog(body: "This log should be dropped") + sut.addLog(log, scope: scope) + sut.captureLogs() + + XCTAssertTrue(beforeSendCalled) + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) + } + + func testBeforeSendLog_NotSet_LogCapturedUnmodified() { + options.beforeSendLog = nil + + let log = createTestLog(level: .debug, body: "Debug message") + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + XCTAssertEqual(capturedLogs.count, 1) + + let capturedLog = capturedLogs.first! + XCTAssertEqual(capturedLog.level, .debug) + XCTAssertEqual(capturedLog.body, "Debug message") + } + + func testBeforeSendLog_PreservesOriginalLogAttributes() { + options.beforeSendLog = { log in + log.attributes["added_by_callback"] = SentryLog.Attribute(string: "callback_value") + return log + } + + let logAttributes: [String: SentryLog.Attribute] = [ + "original_key": SentryLog.Attribute(string: "original_value"), + "user_id": SentryLog.Attribute(integer: 12_345) + ] + + let log = createTestLog(body: "Test message", attributes: logAttributes) + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + let attributes = capturedLog.attributes + + XCTAssertEqual(attributes["original_key"]?.value as? String, "original_value") + XCTAssertEqual(attributes["user_id"]?.value as? Int, 12_345) + XCTAssertEqual(attributes["added_by_callback"]?.value as? String, "callback_value") + } + + func testBeforeSendLogCallback_DynamicAccessGetAndSet() { + // Test dynamic access can both set and get the callback + let originalCallback: (SentryLog) -> SentryLog? = { log in + log.body = "Modified by original callback" + return log + } + + // Set using dynamic access + options.setValue(originalCallback, forKey: "beforeSendLogDynamic") + + // Get using dynamic access and verify it's the same callback + let retrievedCallback = options.value(forKey: "beforeSendLogDynamic") as? (SentryLog) -> SentryLog? + XCTAssertNotNil(retrievedCallback, "Dynamic access should retrieve the callback") + + let log = SentryLog(timestamp: Date(), traceId: .empty, level: .info, body: "foo", attributes: [:]) + let modifiedLog = retrievedCallback?(log) - let sentData = try XCTUnwrap(testClient.captureLogsDataInvocations.first).data - let jsonObject = try XCTUnwrap(JSONSerialization.jsonObject(with: sentData) as? [String: Any]) - let items = try XCTUnwrap(jsonObject["items"] as? [[String: Any]]) - XCTAssertEqual(1, items.count, "Should contain exactly one log") - XCTAssertEqual("Real timeout test log", items[0]["body"] as? String) + XCTAssertEqual(modifiedLog?.body, "Modified by original callback") + + // Test setting to nil using dynamic access + options.setValue(nil, forKey: "beforeSendLogDynamic") + let nilCallback = options.value(forKey: "beforeSendLogDynamic") + XCTAssertNil(nilCallback, "Dynamic access should allow setting to nil") + } + + func testAddLog_WithLogsDisabled_DoesNotCaptureLog() { + // Given - logs are disabled + options.enableLogs = false + + let log = createTestLog(body: "This log should be ignored") + + // When + sut.addLog(log, scope: scope) + sut.captureLogs() + + // Then - no logs should be captured when logs are disabled + XCTAssertEqual(testDelegate.captureLogsDataInvocations.count, 0) + let capturedLogs = testDelegate.getCapturedLogs() + XCTAssertEqual(capturedLogs.count, 0) } // MARK: - Helper Methods @@ -328,3 +625,54 @@ final class SentryLogBatcherTests: XCTestCase { ) } } + +// MARK: - Test Helpers + +final class TestLogBatcherDelegate: NSObject, SentryLogBatcherDelegate { + var captureLogsDataInvocations = Invocations<(data: Data, count: NSNumber)>() + + func capture(logsData: NSData, count: NSNumber) { + captureLogsDataInvocations.record((logsData as Data, count)) + } + + // Helper to get captured logs + func getCapturedLogs() -> [SentryLog] { + var allLogs: [SentryLog] = [] + + for invocation in captureLogsDataInvocations.invocations { + if let jsonObject = try? JSONSerialization.jsonObject(with: invocation.data) as? [String: Any], + let items = jsonObject["items"] as? [[String: Any]] { + for item in items { + if let log = parseSentryLog(from: item) { + allLogs.append(log) + } + } + } + } + + return allLogs + } + + private func parseSentryLog(from dict: [String: Any]) -> SentryLog? { + guard let body = dict["body"] as? String, + let levelString = dict["level"] as? String, + let level = try? SentryLog.Level(value: levelString) else { + return nil + } + + let timestamp = Date(timeIntervalSince1970: (dict["timestamp"] as? TimeInterval) ?? 0) + let traceIdString = dict["trace_id"] as? String ?? "" + let traceId = SentryId(uuidString: traceIdString) + + var attributes: [String: SentryLog.Attribute] = [:] + if let attributesDict = dict["attributes"] as? [String: [String: Any]] { + for (key, value) in attributesDict { + if let attrValue = value["value"] { + attributes[key] = SentryLog.Attribute(value: attrValue) + } + } + } + + return SentryLog(timestamp: timestamp, traceId: traceId, level: level, body: body, attributes: attributes) + } +} diff --git a/Tests/SentryTests/SentryLoggerTests.swift b/Tests/SentryTests/SentryLoggerTests.swift index 3a298b0f188..0bc1382c43d 100644 --- a/Tests/SentryTests/SentryLoggerTests.swift +++ b/Tests/SentryTests/SentryLoggerTests.swift @@ -12,23 +12,22 @@ final class SentryLoggerTests: XCTestCase { let dateProvider: TestCurrentDateProvider let options: Options let scope: Scope - let batcher: TestLogBatcher init() { options = Options() options.dsn = TestConstants.dsnAsString(username: "SentryLoggerTests") + options.enableLogs = true client = TestClient(options: options)! scope = Scope() hub = TestHub(client: client, andScope: scope) dateProvider = TestCurrentDateProvider() - batcher = TestLogBatcher(client: client, dispatchQueue: TestSentryDispatchQueueWrapper()) dateProvider.setDate(date: Date(timeIntervalSince1970: 1_627_846_800.123456)) } func getSut() -> SentryLogger { - return SentryLogger(hub: hub, dateProvider: dateProvider, batcher: batcher) + return SentryLogger(hub: hub, dateProvider: dateProvider) } } @@ -262,9 +261,10 @@ final class SentryLoggerTests: XCTestCase { sut.error("Error message") sut.fatal("Fatal message") - let logs = fixture.batcher.addInvocations.invocations - XCTAssertEqual(logs.count, 6) + // Verify all 6 logs were captured + XCTAssertEqual(fixture.client.captureLogInvocations.count, 6) + let logs = fixture.client.captureLogInvocations.invocations.map { $0.log } XCTAssertEqual(logs[0].level, .trace) XCTAssertEqual(logs[1].level, .debug) XCTAssertEqual(logs[2].level, .info) @@ -273,59 +273,6 @@ final class SentryLoggerTests: XCTestCase { XCTAssertEqual(logs[5].level, .fatal) } - // MARK: - Default Attributes Tests - - func testCaptureLog_AddsDefaultAttributes() { - fixture.options.environment = "test-environment" - fixture.options.releaseName = "1.0.0" - - let span = fixture.hub.startTransaction(name: "Test Transaction", operation: "test-operation") - fixture.hub.scope.span = span - - sut.info("Test log message") - - let capturedLog = getLastCapturedLog() - - // Verify default attributes were added to the log - XCTAssertEqual(capturedLog.attributes["sentry.sdk.name"]?.value as? String, SentryMeta.sdkName) - XCTAssertEqual(capturedLog.attributes["sentry.sdk.version"]?.value as? String, SentryMeta.versionString) - XCTAssertEqual(capturedLog.attributes["sentry.environment"]?.value as? String, "test-environment") - XCTAssertEqual(capturedLog.attributes["sentry.release"]?.value as? String, "1.0.0") - XCTAssertEqual(capturedLog.attributes["sentry.trace.parent_span_id"]?.value as? String, span.spanId.sentrySpanIdString) - } - - func testCaptureLog_DoesNotAddNilDefaultAttributes() { - fixture.options.releaseName = nil - - sut.info("Test log message") - - let capturedLog = getLastCapturedLog() - - XCTAssertNil(capturedLog.attributes["sentry.release"]) - XCTAssertNil(capturedLog.attributes["sentry.trace.parent_span_id"]) - - // But should still have the non-nil defaults - XCTAssertEqual(capturedLog.attributes["sentry.sdk.name"]?.value as? String, SentryMeta.sdkName) - XCTAssertEqual(capturedLog.attributes["sentry.sdk.version"]?.value as? String, SentryMeta.versionString) - XCTAssertEqual(capturedLog.attributes["sentry.environment"]?.value as? String, fixture.options.environment) - } - - func testCaptureLog_SetsTraceIdFromPropagationContext() { - fixture.options.enableLogs = true - - let expectedTraceId = SentryId() - let propagationContext = SentryPropagationContext(trace: expectedTraceId, spanId: SpanId()) - - fixture.hub.scope.propagationContext = propagationContext - - sut.info("Test log message with trace ID") - - let capturedLog = getLastCapturedLog() - - // Verify that the log's trace ID matches the one from the propagation context - XCTAssertEqual(capturedLog.traceId, expectedTraceId) - } - // MARK: - Formatted String Tests func testTrace_WithFormattedString() { @@ -495,286 +442,6 @@ final class SentryLoggerTests: XCTestCase { ) } - // MARK: - User Attributes Tests - - func testCaptureLog_AddsUserAttributes() { - let user = User() - user.userId = "123" - user.email = "test@test.com" - user.name = "test-name" - - // Set the user on the scope - fixture.hub.scope.setUser(user) - - sut.info("Test log message with user") - - let capturedLog = getLastCapturedLog() - - // Verify user attributes were added to the log - XCTAssertEqual(capturedLog.attributes["user.id"]?.value as? String, "123") - XCTAssertEqual(capturedLog.attributes["user.id"]?.type, "string") - - XCTAssertEqual(capturedLog.attributes["user.name"]?.value as? String, "test-name") - XCTAssertEqual(capturedLog.attributes["user.name"]?.type, "string") - - XCTAssertEqual(capturedLog.attributes["user.email"]?.value as? String, "test@test.com") - XCTAssertEqual(capturedLog.attributes["user.email"]?.type, "string") - } - - func testCaptureLog_DoesNotAddNilUserAttributes() { - let user = User() - user.userId = "123" - // email and name are nil - - fixture.hub.scope.setUser(user) - - sut.info("Test log message with partial user") - - let capturedLog = getLastCapturedLog() - - // Should only have user.id - XCTAssertEqual(capturedLog.attributes["user.id"]?.value as? String, "123") - XCTAssertNil(capturedLog.attributes["user.name"]) - XCTAssertNil(capturedLog.attributes["user.email"]) - } - - func testCaptureLog_DoesNotAddUserAttributesWhenNoUser() { - // No user set on scope - - sut.info("Test log message without user") - - let capturedLog = getLastCapturedLog() - - // Should not have any user attributes - XCTAssertNil(capturedLog.attributes["user.id"]) - XCTAssertNil(capturedLog.attributes["user.name"]) - XCTAssertNil(capturedLog.attributes["user.email"]) - } - - func testCaptureLog_AddsOSAndDeviceAttributes() { - // Set up OS context - let osContext = [ - "name": "iOS", - "version": "16.0.1" - ] - - // Set up device context - let deviceContext = [ - "family": "iOS", - "model": "iPhone14,4" - ] - - // Set up scope context - fixture.hub.scope.setContext(value: osContext, key: "os") - fixture.hub.scope.setContext(value: deviceContext, key: "device") - - sut.info("Test log message") - - let capturedLog = getLastCapturedLog() - - // Verify OS attributes - XCTAssertEqual(capturedLog.attributes["os.name"]?.value as? String, "iOS") - XCTAssertEqual(capturedLog.attributes["os.version"]?.value as? String, "16.0.1") - - // Verify device attributes - XCTAssertEqual(capturedLog.attributes["device.brand"]?.value as? String, "Apple") - XCTAssertEqual(capturedLog.attributes["device.model"]?.value as? String, "iPhone14,4") - XCTAssertEqual(capturedLog.attributes["device.family"]?.value as? String, "iOS") - } - - func testCaptureLog_HandlesPartialOSAndDeviceAttributes() { - // Set up partial OS context (missing version) - let osContext = [ - "name": "macOS" - ] - - // Set up partial device context (missing model) - let deviceContext = [ - "family": "macOS" - ] - - // Set up scope context - fixture.hub.scope.setContext(value: osContext, key: "os") - fixture.hub.scope.setContext(value: deviceContext, key: "device") - - sut.info("Test log message") - - let capturedLog = getLastCapturedLog() - - // Verify only available OS attributes are added - XCTAssertEqual(capturedLog.attributes["os.name"]?.value as? String, "macOS") - XCTAssertNil(capturedLog.attributes["os.version"]) - - // Verify only available device attributes are added - XCTAssertEqual(capturedLog.attributes["device.brand"]?.value as? String, "Apple") - XCTAssertNil(capturedLog.attributes["device.model"]) - XCTAssertEqual(capturedLog.attributes["device.family"]?.value as? String, "macOS") - } - - func testCaptureLog_HandlesMissingOSAndDeviceContext() { - // Clear any OS and device context that might be automatically populated - fixture.hub.scope.removeContext(key: "os") - fixture.hub.scope.removeContext(key: "device") - - sut.info("Test log message") - - let capturedLog = getLastCapturedLog() - - // Verify no OS or device attributes are added when context is missing - XCTAssertNil(capturedLog.attributes["os.name"]) - XCTAssertNil(capturedLog.attributes["os.version"]) - XCTAssertNil(capturedLog.attributes["device.brand"]) - XCTAssertNil(capturedLog.attributes["device.model"]) - XCTAssertNil(capturedLog.attributes["device.family"]) - } - - // MARK: - BeforeSendLog Callback Tests - - func testBeforeSendLogCallback_ReturnsModifiedLog() { - var beforeSendCalled = false - fixture.options.beforeSendLog = { log in - beforeSendCalled = true - - // Verify the mutable log has expected properties - XCTAssertEqual(log.level, .info) - XCTAssertEqual(log.body, "Original message") - - // Modify the log - log.body = "Modified by callback" - log.level = .warn - log.attributes["callback_modified"] = SentryLog.Attribute(boolean: true) - - return log - } - - sut.info("Original message") - - XCTAssertTrue(beforeSendCalled, "beforeSendLog callback should be called") - - let logs = fixture.batcher.addInvocations.invocations - XCTAssertEqual(logs.count, 1) - - let capturedLog = logs[0] - XCTAssertEqual(capturedLog.level, .warn) - XCTAssertEqual(capturedLog.body, "Modified by callback") - XCTAssertEqual(capturedLog.attributes["callback_modified"]?.value as? Bool, true) - } - - func testBeforeSendLogCallback_ReturnsNil_LogNotCaptured() { - var beforeSendCalled = false - fixture.options.beforeSendLog = { _ in - beforeSendCalled = true - return nil // Drop the log - } - - sut.error("This log should be dropped") - - XCTAssertTrue(beforeSendCalled, "beforeSendLog callback should be called") - - let logs = fixture.batcher.addInvocations.invocations - XCTAssertEqual(logs.count, 0, "Log should be dropped when callback returns nil") - } - - func testBeforeSendLogCallback_NotSet_LogCapturedUnmodified() { - // No beforeSendLog callback set - fixture.options.beforeSendLog = nil - - sut.debug("Debug message") - - let logs = fixture.batcher.addInvocations.invocations - XCTAssertEqual(logs.count, 1) - - let capturedLog = logs[0] - XCTAssertEqual(capturedLog.level, .debug) - XCTAssertEqual(capturedLog.body, "Debug message") - } - - func testBeforeSendLogCallback_MultipleLogLevels() { - var callbackInvocations: [(SentryLog.Level, String)] = [] - - fixture.options.beforeSendLog = { log in - callbackInvocations.append((log.level, log.body)) - log.attributes["processed"] = SentryLog.Attribute(boolean: true) - return log - } - - sut.trace("Trace message") - sut.debug("Debug message") - sut.info("Info message") - sut.warn("Warn message") - sut.error("Error message") - sut.fatal("Fatal message") - - XCTAssertEqual(callbackInvocations.count, 6) - XCTAssertEqual(callbackInvocations[0].0, .trace) - XCTAssertEqual(callbackInvocations[0].1, "Trace message") - XCTAssertEqual(callbackInvocations[1].0, .debug) - XCTAssertEqual(callbackInvocations[1].1, "Debug message") - XCTAssertEqual(callbackInvocations[2].0, .info) - XCTAssertEqual(callbackInvocations[2].1, "Info message") - XCTAssertEqual(callbackInvocations[3].0, .warn) - XCTAssertEqual(callbackInvocations[3].1, "Warn message") - XCTAssertEqual(callbackInvocations[4].0, .error) - XCTAssertEqual(callbackInvocations[4].1, "Error message") - XCTAssertEqual(callbackInvocations[5].0, .fatal) - XCTAssertEqual(callbackInvocations[5].1, "Fatal message") - - // Verify all logs were processed - let logs = fixture.batcher.addInvocations.invocations - XCTAssertEqual(logs.count, 6) - for log in logs { - XCTAssertEqual(log.attributes["processed"]?.value as? Bool, true) - } - } - - func testBeforeSendLogCallback_PreservesOriginalLogAttributes() { - fixture.options.beforeSendLog = { log in - // Add new attributes without removing existing ones - log.attributes["added_by_callback"] = SentryLog.Attribute(string: "callback_value") - return log - } - - sut.info("Test message", attributes: [ - "original_key": "original_value", - "user_id": 12_345 - ]) - - let logs = fixture.batcher.addInvocations.invocations - XCTAssertEqual(logs.count, 1) - - let capturedLog = logs[0] - // Original attributes should be preserved - XCTAssertEqual(capturedLog.attributes["original_key"]?.value as? String, "original_value") - XCTAssertEqual(capturedLog.attributes["user_id"]?.value as? Int, 12_345) - // New attribute should be added - XCTAssertEqual(capturedLog.attributes["added_by_callback"]?.value as? String, "callback_value") - } - - func testBeforeSendLogCallback_DynamicAccessGetAndSet() { - // Test dynamic access can both set and get the callback - let originalCallback: (SentryLog) -> SentryLog? = { log in - log.body = "Modified by original callback" - return log - } - - // Set using dynamic access - fixture.options.setValue(originalCallback, forKey: "beforeSendLogDynamic") - - // Get using dynamic access and verify it's the same callback - let retrievedCallback = fixture.options.value(forKey: "beforeSendLogDynamic") as? (SentryLog) -> SentryLog? - XCTAssertNotNil(retrievedCallback, "Dynamic access should retrieve the callback") - - let log = SentryLog(timestamp: Date(), traceId: .empty, level: .info, body: "foo", attributes: [:]) - let modifiedLog = retrievedCallback?(log) - - XCTAssertEqual(modifiedLog?.body, "Modified by original callback") - - // Test setting to nil using dynamic access - fixture.options.setValue(nil, forKey: "beforeSendLogDynamic") - let nilCallback = fixture.options.value(forKey: "beforeSendLogDynamic") - XCTAssertNil(nilCallback, "Dynamic access should allow setting to nil") - } - // MARK: - Helper Methods private func assertLogCaptured( @@ -784,44 +451,12 @@ final class SentryLoggerTests: XCTestCase { file: StaticString = #file, line: UInt = #line ) { - let logs = fixture.batcher.addInvocations.invocations - XCTAssertEqual(logs.count, 1, "Expected exactly one log to be captured", file: file, line: line) - - guard let capturedLog = logs.first else { - XCTFail("No log captured", file: file, line: line) - return - } + let capturedLog = getLastCapturedLog() XCTAssertEqual(capturedLog.level, expectedLevel, "Log level mismatch", file: file, line: line) XCTAssertEqual(capturedLog.body, expectedBody, "Log body mismatch", file: file, line: line) - XCTAssertEqual(capturedLog.timestamp, fixture.dateProvider.date(), "Log timestamp mismatch", file: file, line: line) - - // Count expected default attributes dynamically - var expectedDefaultAttributeCount = 3 // sdk.name, sdk.version, environment are always present - if fixture.options.releaseName != nil { - expectedDefaultAttributeCount += 1 // sentry.release - } - if fixture.hub.scope.span != nil { - expectedDefaultAttributeCount += 1 // sentry.trace.parent_span_id - } - // OS and device attributes (up to 5 more if context is available) - if let contextDictionary = fixture.hub.scope.serialize()["context"] as? [String: [String: Any]] { - if let osContext = contextDictionary["os"] { - if osContext["name"] != nil { expectedDefaultAttributeCount += 1 } - if osContext["version"] != nil { expectedDefaultAttributeCount += 1 } - } - if contextDictionary["device"] != nil { - expectedDefaultAttributeCount += 1 // device.brand (always "Apple") - if let deviceContext = contextDictionary["device"] { - if deviceContext["model"] != nil { expectedDefaultAttributeCount += 1 } - if deviceContext["family"] != nil { expectedDefaultAttributeCount += 1 } - } - } - } - - // Compare attributes - XCTAssertEqual(capturedLog.attributes.count, expectedAttributes.count + expectedDefaultAttributeCount, "Attribute count mismatch", file: file, line: line) + // Only verify the user-provided attributes, not the auto-enriched ones for (key, expectedAttribute) in expectedAttributes { guard let actualAttribute = capturedLog.attributes[key] else { XCTFail("Missing attribute key: \(key)", file: file, line: line) @@ -855,20 +490,10 @@ final class SentryLoggerTests: XCTestCase { } private func getLastCapturedLog() -> SentryLog { - let logs = fixture.batcher.addInvocations.invocations - guard let lastLog = logs.last else { + guard let lastInvocation = fixture.client.captureLogInvocations.invocations.last else { XCTFail("No logs captured") return SentryLog(timestamp: Date(), traceId: .empty, level: .info, body: "", attributes: [:]) } - return lastLog - } -} - -final class TestLogBatcher: SentryLogBatcher { - - var addInvocations = Invocations() - - override func add(_ log: SentryLog) { - addInvocations.record(log) + return lastInvocation.log } } diff --git a/Tests/SentryTests/SentrySDKInternalTests.swift b/Tests/SentryTests/SentrySDKInternalTests.swift index 301353dafcd..ca7ce9e3ca7 100644 --- a/Tests/SentryTests/SentrySDKInternalTests.swift +++ b/Tests/SentryTests/SentrySDKInternalTests.swift @@ -706,13 +706,8 @@ class SentrySDKInternalTests: XCTestCase { SentrySDK.logger.error(String(repeating: "S", count: 1_024 * 1_024)) - let expectation = self.expectation(description: "Wait for async add.") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - expectation.fulfill() - } - waitForExpectations(timeout: 5.0) - - XCTAssertEqual(fixture.client.captureLogsDataInvocations.count, 1) + // Verify the log was captured + XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) } func testLogger_WithNoClient_DoesNotCaptureLog() { @@ -722,22 +717,8 @@ class SentrySDKInternalTests: XCTestCase { SentrySDK.logger.error(String(repeating: "S", count: 1_024 * 1_024)) - let expectation = self.expectation(description: "Wait for async add.") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - expectation.fulfill() - } - waitForExpectations(timeout: 5.0) - - XCTAssertEqual(fixture.client.captureLogsDataInvocations.count, 0) - } - - @available(*, deprecated, message: "This is deprecated because SentryOptions integrations is deprecated") - func testLogger_WithLogsDisabled_DoesNotCaptureLog() { - fixture.client.options.enableLogs = false - givenSdkWithHub() - - SentrySDK.logger.error("foo") - XCTAssertEqual(fixture.client.captureLogsDataInvocations.count, 0) + // Verify no logs were captured (no client to receive them) + XCTAssertEqual(fixture.client.captureLogInvocations.count, 0) } @available(*, deprecated, message: "This is deprecated because SentryOptions integrations is deprecated") diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index 384fb1ff21b..af7d5addba7 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -454,14 +454,15 @@ class SentrySDKTests: XCTestCase { // Add a log to ensure there's something to flush SentrySDK.logger.info("Test log message") - // Initially no logs should be sent (they're buffered) - XCTAssertEqual(fixture.client.captureLogsDataInvocations.count, 0) + // Verify the log was captured + XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) + XCTAssertEqual(fixture.client.captureLogInvocations.first?.log.body, "Test log message") - // Flush the SDK + // Flush the SDK - this should trigger the log batcher to flush SentrySDK.flush(timeout: 1.0) - // Now logs should be sent - XCTAssertEqual(fixture.client.captureLogsDataInvocations.count, 1) + // The log should still be captured (flush doesn't clear the invocations) + XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) } func testClose_CallsLoggerCaptureLogs() { @@ -472,14 +473,14 @@ class SentrySDKTests: XCTestCase { // Add a log to ensure there's something to flush SentrySDK.logger.info("Test log message") - // Initially no logs should be sent (they're buffered) - XCTAssertEqual(fixture.client.captureLogsDataInvocations.count, 0) + // Verify the log was captured + XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) // Close the SDK SentrySDK.close() - // Now logs should be sent - XCTAssertEqual(fixture.client.captureLogsDataInvocations.count, 1) + // The log should still be captured + XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) } func testLogger_RecreatedWhenSDKStartedAfterAccess() { @@ -500,34 +501,8 @@ class SentrySDKTests: XCTestCase { // Verify the new logger can actually capture logs loggerAfterStart.info("Test log message") - // Force flush by closing the SDK - SentrySDK.close() - // Verify log was captured - XCTAssertEqual(fixture.client.captureLogsDataInvocations.count, 1) - } - - func testLogger_WhenLogsDisabled() { - // Start SDK with logs disabled - fixture.client.options.enableLogs = false - SentrySDKInternal.setCurrentHub(fixture.hub) - SentrySDKInternal.setStart(with: fixture.client.options) - - // Access logger - let logger = SentrySDK.logger - - // Verify that logs are not captured when disabled - logger.info("Test log message") - - // Wait a bit for async processing - let expectation = self.expectation(description: "Wait for log capture") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - expectation.fulfill() - } - waitForExpectations(timeout: 5.0) - - // Verify no logs were captured - XCTAssertEqual(fixture.client.captureLogsDataInvocations.count, 0) + XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) } } From 7a38ee2c1879f8d5694824df406f86f1d072733f Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Fri, 24 Oct 2025 14:32:53 +0200 Subject: [PATCH 04/32] add documentation --- Sources/Sentry/Public/SentryClient.h | 5 +++++ Sources/Sentry/Public/SentryHub.h | 9 +++++++++ Sources/Swift/Protocol/SentryLog.swift | 9 +++++++++ 3 files changed, 23 insertions(+) diff --git a/Sources/Sentry/Public/SentryClient.h b/Sources/Sentry/Public/SentryClient.h index c3e3713c8c9..ed5fa876b4b 100644 --- a/Sources/Sentry/Public/SentryClient.h +++ b/Sources/Sentry/Public/SentryClient.h @@ -102,6 +102,11 @@ SENTRY_NO_INIT - (void)captureFeedback:(SentryFeedback *)feedback withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(feedback:scope:)); +/** + * Captures a log entry and sends it to Sentry. + * @param log The log entry to send to Sentry. + * @param scope The current scope from which to gather contextual information. + */ - (void)captureLog:(SentryLog *)log withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)); diff --git a/Sources/Sentry/Public/SentryHub.h b/Sources/Sentry/Public/SentryHub.h index 3358131cae5..7d1f8f3a3c4 100644 --- a/Sources/Sentry/Public/SentryHub.h +++ b/Sources/Sentry/Public/SentryHub.h @@ -176,8 +176,17 @@ SENTRY_NO_INIT */ - (void)captureFeedback:(SentryFeedback *)feedback; +/** + * Captures a log entry and sends it to Sentry. + * @param log The log entry to send to Sentry. + */ - (void)captureLog:(SentryLog *)log NS_SWIFT_NAME(capture(log:)); +/** + * Captures a log entry and sends it to Sentry. + * @param log The log entry to send to Sentry. + * @param scope The scope containing event metadata. + */ - (void)captureLog:(SentryLog *)log withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)); diff --git a/Sources/Swift/Protocol/SentryLog.swift b/Sources/Swift/Protocol/SentryLog.swift index b5dd23dc7e2..13613ad91f3 100644 --- a/Sources/Swift/Protocol/SentryLog.swift +++ b/Sources/Swift/Protocol/SentryLog.swift @@ -17,6 +17,10 @@ public final class SentryLog: NSObject { /// Numeric representation of the severity level (Int) public var severityNumber: NSNumber? + /// Creates a log entry with the specified level and message. + /// - Parameters: + /// - level: The severity level of the log entry + /// - body: The main log message content @objc public convenience init( level: Level, body: String @@ -30,6 +34,11 @@ public final class SentryLog: NSObject { ) } + /// Creates a log entry with the specified level, message, and attributes. + /// - Parameters: + /// - level: The severity level of the log entry + /// - body: The main log message content + /// - attributes: A dictionary of structured attributes to add to the log entry @objc public convenience init( level: Level, body: String, From 010294f76577c1fd2e8a25da150a4d4ebe662437 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Fri, 24 Oct 2025 14:34:45 +0200 Subject: [PATCH 05/32] add cl entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa573350348..5d386e4850b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Add SentryDistribution as Swift Package Manager target (#6149) - Add option `enablePropagateTraceparent` to support OTel/W3C trace propagation (#6356) +- Structured Logs: Add `captureLog` to `Hub` and `Client` (#6518) ### Fixes From f37625488fd83bf2015a75315d83a425ea7ef729 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Fri, 24 Oct 2025 14:42:46 +0200 Subject: [PATCH 06/32] update public api --- sdk_api.json | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/sdk_api.json b/sdk_api.json index 56e5dea5834..b53078fffcd 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -24436,6 +24436,47 @@ ], "funcSelfKind": "NonMutating" }, + { + "kind": "Function", + "name": "capture", + "printedName": "capture(log:scope:)", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "SentryLog", + "printedName": "Sentry.SentryLog", + "usr": "c:@M@Sentry@objc(cs)SentryLog" + }, + { + "kind": "TypeNominal", + "name": "Scope", + "printedName": "Sentry.Scope", + "usr": "c:objc(cs)SentryScope" + } + ], + "declKind": "Func", + "usr": "c:objc(cs)SentryClient(im)captureLog:withScope:", + "moduleName": "Sentry", + "isOpen": true, + "objc_name": "captureLog:withScope:", + "declAttributes": [ + "ObjC", + "Dynamic" + ], + "funcSelfKind": "NonMutating" + }, { "kind": "Function", "name": "flush", @@ -27187,6 +27228,82 @@ ], "funcSelfKind": "NonMutating" }, + { + "kind": "Function", + "name": "capture", + "printedName": "capture(log:)", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "SentryLog", + "printedName": "Sentry.SentryLog", + "usr": "c:@M@Sentry@objc(cs)SentryLog" + } + ], + "declKind": "Func", + "usr": "c:objc(cs)SentryHub(im)captureLog:", + "moduleName": "Sentry", + "isOpen": true, + "objc_name": "captureLog:", + "declAttributes": [ + "ObjC", + "Dynamic" + ], + "funcSelfKind": "NonMutating" + }, + { + "kind": "Function", + "name": "capture", + "printedName": "capture(log:scope:)", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "SentryLog", + "printedName": "Sentry.SentryLog", + "usr": "c:@M@Sentry@objc(cs)SentryLog" + }, + { + "kind": "TypeNominal", + "name": "Scope", + "printedName": "Sentry.Scope", + "usr": "c:objc(cs)SentryScope" + } + ], + "declKind": "Func", + "usr": "c:objc(cs)SentryHub(im)captureLog:withScope:", + "moduleName": "Sentry", + "isOpen": true, + "objc_name": "captureLog:withScope:", + "declAttributes": [ + "ObjC", + "Dynamic" + ], + "funcSelfKind": "NonMutating" + }, { "kind": "Function", "name": "configureScope", @@ -43263,6 +43380,94 @@ } ] }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(level:body:)", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryLog", + "printedName": "Sentry.SentryLog", + "usr": "c:@M@Sentry@objc(cs)SentryLog" + }, + { + "kind": "TypeNominal", + "name": "Level", + "printedName": "Sentry.SentryLog.Level", + "usr": "s:6Sentry0A3LogC5LevelO" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Constructor", + "usr": "c:@M@Sentry@objc(cs)SentryLog(im)initWithLevel:body:", + "mangledName": "$s6Sentry0A3LogC5level4bodyA2C5LevelO_SStcfc", + "moduleName": "Sentry", + "objc_name": "initWithLevel:body:", + "declAttributes": [ + "ObjC" + ], + "init_kind": "Convenience" + }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(level:body:attributes:)", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryLog", + "printedName": "Sentry.SentryLog", + "usr": "c:@M@Sentry@objc(cs)SentryLog" + }, + { + "kind": "TypeNominal", + "name": "Level", + "printedName": "Sentry.SentryLog.Level", + "usr": "s:6Sentry0A3LogC5LevelO" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "Dictionary", + "printedName": "[Swift.String : Sentry.SentryLog.Attribute]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "Attribute", + "printedName": "Sentry.SentryLog.Attribute", + "usr": "s:6Sentry0A3LogC9AttributeC" + } + ], + "usr": "s:SD" + } + ], + "declKind": "Constructor", + "usr": "c:@M@Sentry@objc(cs)SentryLog(im)initWithLevel:body:attributes:", + "mangledName": "$s6Sentry0A3LogC5level4body10attributesA2C5LevelO_SSSDySSAC9AttributeCGtcfc", + "moduleName": "Sentry", + "objc_name": "initWithLevel:body:attributes:", + "declAttributes": [ + "ObjC" + ], + "init_kind": "Convenience" + }, { "kind": "TypeDecl", "name": "Level", From f1e0dc3e98b44dbf8519b494dd9ab31ef13f087d Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Fri, 24 Oct 2025 14:51:42 +0200 Subject: [PATCH 07/32] relax flush accuracy --- Tests/SentryTests/SentrySDKInternalTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SentryTests/SentrySDKInternalTests.swift b/Tests/SentryTests/SentrySDKInternalTests.swift index ca7ce9e3ca7..94ccafd9869 100644 --- a/Tests/SentryTests/SentrySDKInternalTests.swift +++ b/Tests/SentryTests/SentrySDKInternalTests.swift @@ -664,7 +664,7 @@ class SentrySDKInternalTests: XCTestCase { SentrySDKInternal.currentHub().bindClient(client) SentrySDK.close() - XCTAssertEqual(Options().shutdownTimeInterval, transport.flushInvocations.first) + XCTAssertEqual(Options().shutdownTimeInterval, transport.flushInvocations.first ?? 0.0, accuracy: 0.001) } @available(*, deprecated, message: "This is deprecated because SentryOptions integrations is deprecated") @@ -737,7 +737,7 @@ class SentrySDKInternalTests: XCTestCase { let flushTimeout = 10.0 SentrySDK.flush(timeout: flushTimeout) - XCTAssertEqual(flushTimeout, transport.flushInvocations.first ?? 0.0, accuracy: 0.001) + XCTAssertEqual(flushTimeout, transport.flushInvocations.first ?? 0.0, accuracy: 0.002) } @available(*, deprecated, message: "This is deprecated because SentryOptions integrations is deprecated") From e9d6a1e0d0044e7fc0edf52a038efa0858753b5f Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Fri, 24 Oct 2025 14:58:10 +0200 Subject: [PATCH 08/32] fix SPM circular dependency issue --- Sources/Swift/Tools/SentryLogger.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/Swift/Tools/SentryLogger.swift b/Sources/Swift/Tools/SentryLogger.swift index 2326ff62bc9..a8b13bf79e2 100644 --- a/Sources/Swift/Tools/SentryLogger.swift +++ b/Sources/Swift/Tools/SentryLogger.swift @@ -188,6 +188,12 @@ public final class SentryLogger: NSObject { attributes: logAttributes ) + #if SWIFT_PACKAGE + // Work around Swift-to-Objective-C bridging limitations in SPM builds. + // SentryLog is only forward declared in SentryHub.h, so we use dynamic dispatch. + hub.perform(Selector("captureLog:"), with: log) + #else hub.capture(log: log) + #endif } } From b8be3048b0c9dd5efdb655d25f2fe3d86caad386 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Fri, 24 Oct 2025 16:07:24 +0200 Subject: [PATCH 09/32] make new hub/scope selectors available in SPM --- Sentry.xcodeproj/project.pbxproj | 8 + Sources/Swift/Helper/SentryLog+SPM.swift | 79 ++++++ Sources/Swift/Tools/SentryLogBatcher.swift | 26 -- Sources/Swift/Tools/SentryLogger.swift | 8 +- .../Helper/SentryLogSPMTests.swift | 225 ++++++++++++++++++ Tests/SentryTests/SentryLogBatcherTests.swift | 25 -- 6 files changed, 314 insertions(+), 57 deletions(-) create mode 100644 Sources/Swift/Helper/SentryLog+SPM.swift create mode 100644 Tests/SentryTests/Helper/SentryLogSPMTests.swift diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 09a2b3c1337..d0bbe890b8b 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -720,6 +720,8 @@ 92235CAE2E15549C00865983 /* SentryLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92235CAD2E15549C00865983 /* SentryLogger.swift */; }; 92235CB02E155B2600865983 /* SentryLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92235CAF2E155B2600865983 /* SentryLoggerTests.swift */; }; 925824C22CB5897700C9B20B /* SentrySessionReplayIntegration-Hybrid.h in Headers */ = {isa = PBXBuildFile; fileRef = D80382BE2C09C6FD0090E048 /* SentrySessionReplayIntegration-Hybrid.h */; settings = {ATTRIBUTES = (Private, ); }; }; + 92622E092EABB71000ABE7FF /* SentryLogSPMTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92622E082EABB71000ABE7FF /* SentryLogSPMTests.swift */; }; + 92622E142EABBDA900ABE7FF /* SentryLog+SPM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92622E132EABBDA900ABE7FF /* SentryLog+SPM.swift */; }; 9264E1EB2E2E385E00B077CF /* SentryLogMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9264E1EA2E2E385B00B077CF /* SentryLogMessage.swift */; }; 9264E1ED2E2E397C00B077CF /* SentryLogMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9264E1EC2E2E397400B077CF /* SentryLogMessageTests.swift */; }; 92672BB629C9A2A9006B021C /* SentryBreadcrumb+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 92672BB529C9A2A9006B021C /* SentryBreadcrumb+Private.h */; settings = {ATTRIBUTES = (Private, ); }; }; @@ -2081,6 +2083,8 @@ 92235CAB2E15369900865983 /* SentryLogBatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogBatcher.swift; sourceTree = ""; }; 92235CAD2E15549C00865983 /* SentryLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogger.swift; sourceTree = ""; }; 92235CAF2E155B2600865983 /* SentryLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLoggerTests.swift; sourceTree = ""; }; + 92622E082EABB71000ABE7FF /* SentryLogSPMTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogSPMTests.swift; sourceTree = ""; }; + 92622E132EABBDA900ABE7FF /* SentryLog+SPM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SentryLog+SPM.swift"; sourceTree = ""; }; 9264E1EA2E2E385B00B077CF /* SentryLogMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogMessage.swift; sourceTree = ""; }; 9264E1EC2E2E397400B077CF /* SentryLogMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLogMessageTests.swift; sourceTree = ""; }; 92672BB529C9A2A9006B021C /* SentryBreadcrumb+Private.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "SentryBreadcrumb+Private.h"; path = "include/HybridPublic/SentryBreadcrumb+Private.h"; sourceTree = ""; }; @@ -2687,6 +2691,7 @@ 84B0E0062CD963F9007FB332 /* SentryIconography.swift */, 621F61F02BEA073A005E654F /* SentryEnabledFeaturesBuilder.swift */, F4FE9DFB2E622CD70014FED5 /* SentryDefaultObjCRuntimeWrapper.swift */, + 92622E132EABBDA900ABE7FF /* SentryLog+SPM.swift */, F4FE9DFC2E622CD70014FED5 /* SentryObjCRuntimeWrapper.swift */, ); path = Helper; @@ -3609,6 +3614,7 @@ F4A930242E661856006DA6EF /* SentryMobileProvisionParserTests.swift */, D4F7BD7C2E4373BB004A2D77 /* SentryLevelMapperTests.swift */, D8AE48BE2C578D540092A2A6 /* SentrySDKLog.swift */, + 92622E082EABB71000ABE7FF /* SentryLogSPMTests.swift */, 849AC3FF29E0C1FF00889C16 /* SentryFormatterTests.swift */, 7B88F30324BC8E6500ADF90A /* SentrySerializationTests.swift */, 62F4DDA02C04CB9700588890 /* SentryBaggageSerializationTests.swift */, @@ -6029,6 +6035,7 @@ 7B14089824878F950035403D /* SentryCrashStackEntryMapper.m in Sources */, D8BC28C82BFF5EBB0054DA4D /* SentryTouchTracker.swift in Sources */, 63FE711720DA4C1000CDBAE8 /* SentryCrashStackCursor_Backtrace.c in Sources */, + 92622E142EABBDA900ABE7FF /* SentryLog+SPM.swift in Sources */, FA3A42722E1C5F9B00A08C39 /* SentryNSNotificationCenterWrapper.swift in Sources */, 63FE70CB20DA4C1000CDBAE8 /* SentryCrashReportFixer.c in Sources */, F4A930232E65FDBF006DA6EF /* SentryMobileProvisionParser.swift in Sources */, @@ -6137,6 +6144,7 @@ 7BE3C78724472E9800A38442 /* TestRequestManager.swift in Sources */, 63FE722220DA66EC00CDBAE8 /* SentryCrashJSONCodec_Tests.m in Sources */, 7B0A5452252311CE00A71716 /* SentryBreadcrumbTests.swift in Sources */, + 92622E092EABB71000ABE7FF /* SentryLogSPMTests.swift in Sources */, 7BE3C7752445C82300A38442 /* SentryCurrentDateTests.swift in Sources */, 7B3398672459C4AE00BD9C96 /* SentryEnvelopeRateLimitTests.swift in Sources */, 8EA9AF492665AC48002771B4 /* SentryPerformanceTrackerTests.swift in Sources */, diff --git a/Sources/Swift/Helper/SentryLog+SPM.swift b/Sources/Swift/Helper/SentryLog+SPM.swift new file mode 100644 index 00000000000..85699a2ad27 --- /dev/null +++ b/Sources/Swift/Helper/SentryLog+SPM.swift @@ -0,0 +1,79 @@ +@_implementationOnly import _SentryPrivate +import Foundation + +// Swift extensions to provide properly typed log-related APIs for SPM builds. +// In SPM builds, SentryLog is only forward declared in the Objective-C headers, +// causing Swift-to-Objective-C bridging issues. These extensions work around that +// by providing Swift-native methods and properties that use dynamic dispatch internally. + +#if SWIFT_PACKAGE + +/** + * Use this callback to drop or modify a log before the SDK sends it to Sentry. Return `nil` to + * drop the log. + */ +public typealias SentryBeforeSendLogCallback = (SentryLog) -> SentryLog? + +@objc +public extension Options { + /** + * Use this callback to drop or modify a log before the SDK sends it to Sentry. Return `nil` to + * drop the log. + */ + @objc + var beforeSendLog: SentryBeforeSendLogCallback? { + get { return value(forKey: "beforeSendLogDynamic") as? SentryBeforeSendLogCallback } + set { setValue(newValue, forKey: "beforeSendLogDynamic") } + } +} + +@objc +@_spi(Private) public protocol HubSelectors { + func captureLog(_ log: SentryLog) + func captureLog(_ log: SentryLog, withScope: Scope) +} + +@objc +public extension SentryHub { + /// Captures a log entry and sends it to Sentry. + /// - Parameter log: The log entry to send to Sentry. + /// + /// This method is provided for SPM builds where the Objective-C `captureLog:` method + /// may not be properly bridged due to `SentryLog` being defined in Swift. + func capture(log: SentryLog) { + // Use dynamic dispatch to work around bridging limitations + perform(#selector(HubSelectors.captureLog(_:)), with: log) + } + + /// Captures a log entry and sends it to Sentry with a specific scope. + /// - Parameters: + /// - log: The log entry to send to Sentry. + /// - scope: The scope containing event metadata. + /// + /// This method is provided for SPM builds where the Objective-C `captureLog:withScope:` method + /// may not be properly bridged due to `SentryLog` being defined in Swift. + func capture(log: SentryLog, scope: Scope) { + // Use dynamic dispatch to work around bridging limitations + perform(#selector(HubSelectors.captureLog(_:withScope:)), with: log, with: scope) + } +} + +@objc +@_spi(Private) public protocol ClientSelectors { + func captureLog(_ log: SentryLog, withScope: Scope) +} + +/// Extension to provide log capture methods for SPM builds. +@objc +public extension SentryClient { + /// Captures a log entry and sends it to Sentry. + /// - Parameters: + /// - log: The log entry to send to Sentry. + /// - scope: The scope containing event metadata. + func captureLog(_ log: SentryLog, withScope scope: Scope) { + // Use dynamic dispatch to work around bridging limitations + perform(#selector(ClientSelectors.captureLog(_:withScope:)), with: log, with: scope) + } +} + +#endif // SWIFT_PACKAGE diff --git a/Sources/Swift/Tools/SentryLogBatcher.swift b/Sources/Swift/Tools/SentryLogBatcher.swift index ae934af13b8..ab784c35fb9 100644 --- a/Sources/Swift/Tools/SentryLogBatcher.swift +++ b/Sources/Swift/Tools/SentryLogBatcher.swift @@ -220,29 +220,3 @@ import Foundation delegate?.capture(logsData: payloadData as NSData, count: NSNumber(value: encodedLogs.count)) } } - -#if SWIFT_PACKAGE -/** - * Use this callback to drop or modify a log before the SDK sends it to Sentry. Return `nil` to - * drop the log. - */ -public typealias SentryBeforeSendLogCallback = (SentryLog) -> SentryLog? - -// Makes the `beforeSendLog` property visible as the Swift type `SentryBeforeSendLogCallback`. -// This works around `SentryLog` being only forward declared in the objc header, resulting in -// compile time issues with SPM builds. -@objc -public extension Options { - /** - * Use this callback to drop or modify a log before the SDK sends it to Sentry. Return `nil` to - * drop the log. - */ - @objc - var beforeSendLog: SentryBeforeSendLogCallback? { - // Note: This property provides SentryLog type safety for SPM builds where the native Objective-C - // property cannot be used due to Swift-to-Objective-C bridging limitations. - get { return value(forKey: "beforeSendLogDynamic") as? SentryBeforeSendLogCallback } - set { setValue(newValue, forKey: "beforeSendLogDynamic") } - } -} -#endif // SWIFT_PACKAGE diff --git a/Sources/Swift/Tools/SentryLogger.swift b/Sources/Swift/Tools/SentryLogger.swift index a8b13bf79e2..7144f95ebf6 100644 --- a/Sources/Swift/Tools/SentryLogger.swift +++ b/Sources/Swift/Tools/SentryLogger.swift @@ -188,12 +188,8 @@ public final class SentryLogger: NSObject { attributes: logAttributes ) - #if SWIFT_PACKAGE - // Work around Swift-to-Objective-C bridging limitations in SPM builds. - // SentryLog is only forward declared in SentryHub.h, so we use dynamic dispatch. - hub.perform(Selector("captureLog:"), with: log) - #else + // Note: In SPM builds, this uses the extension method defined in SentryHub+SPM.swift + // which works around Swift-to-Objective-C bridging limitations. hub.capture(log: log) - #endif } } diff --git a/Tests/SentryTests/Helper/SentryLogSPMTests.swift b/Tests/SentryTests/Helper/SentryLogSPMTests.swift new file mode 100644 index 00000000000..62f954bff17 --- /dev/null +++ b/Tests/SentryTests/Helper/SentryLogSPMTests.swift @@ -0,0 +1,225 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +/// Tests for SPM log capture workarounds using dynamic dispatch. +/// These tests verify that the Objective-C methods can be called via perform(Selector:with:), +/// which is what the SPM extensions (SentryLob+SPM.swift) do internally. +final class SentryLogSPMTests: XCTestCase { + + private class Fixture { + let hub: TestHub + let client: TestClient + let dateProvider: TestCurrentDateProvider + let options: Options + let scope: Scope + + init() { + options = Options() + options.dsn = TestConstants.dsnAsString(username: "SentryLogSPMTests") + options.enableLogs = true + + client = TestClient(options: options)! + scope = Scope() + hub = TestHub(client: client, andScope: scope) + dateProvider = TestCurrentDateProvider() + + dateProvider.setDate(date: Date(timeIntervalSince1970: 1_627_846_800.123456)) + } + } + + private var fixture: Fixture! + + override func setUp() { + super.setUp() + fixture = Fixture() + } + + override func tearDown() { + super.tearDown() + clearTestState() + } + + // MARK: - SentryHub Tests + + func testHub_CaptureLog_ViaPerformSelector() { + // This test verifies that dynamic dispatch to captureLog: works correctly. + // This is what SentryLog+SPM.swift does internally in the capture(log:) extension method. + + let log = SentryLog( + timestamp: fixture.dateProvider.date(), + traceId: SentryId.empty, + level: .info, + body: "Test message via perform selector", + attributes: [ + "test_key": SentryLog.Attribute(string: "test_value"), + "count": SentryLog.Attribute(integer: 42) + ] + ) + + // Call using dynamic dispatch - mimics SPM extension behavior + fixture.hub.perform(#selector(HubSelectors.captureLog(_:)), with: log) + + // Verify the log was captured + XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) + + let capturedLog = fixture.client.captureLogInvocations.invocations.last!.log + XCTAssertEqual(capturedLog.level, .info) + XCTAssertEqual(capturedLog.body, "Test message via perform selector") + XCTAssertEqual(capturedLog.attributes["test_key"]?.value as? String, "test_value") + XCTAssertEqual(capturedLog.attributes["count"]?.value as? Int, 42) + } + + func testHub_CaptureLogWithScope_ViaPerformSelector() { + // This test verifies that dynamic dispatch to captureLog:withScope: works correctly. + // This is what SentryLog+SPM.swift does internally in the capture(log:scope:) extension method. + + let log = SentryLog( + timestamp: fixture.dateProvider.date(), + traceId: SentryId.empty, + level: .error, + body: "Test message with scope via perform selector", + attributes: [ + "severity": SentryLog.Attribute(string: "high") + ] + ) + + let customScope = Scope() + customScope.setTag(value: "test-value", key: "test-tag") + + // Call using dynamic dispatch - mimics SPM extension behavior + fixture.hub.perform(#selector(HubSelectors.captureLog(_:withScope:)), with: log, with: fixture.scope) + + // Verify the log was captured + XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) + + let capturedLog = fixture.client.captureLogInvocations.invocations.last!.log + XCTAssertEqual(capturedLog.level, .error) + XCTAssertEqual(capturedLog.body, "Test message with scope via perform selector") + XCTAssertEqual(capturedLog.attributes["severity"]?.value as? String, "high") + } + + // MARK: - SentryClient Tests + + func testClient_CaptureLog_ViaPerformSelector() { + // This test verifies that dynamic dispatch to captureLog:withScope: works correctly on client. + // This is what SentryLog+SPM.swift does internally in the captureLog(_:withScope:) extension method. + + let log = SentryLog( + timestamp: fixture.dateProvider.date(), + traceId: SentryId.empty, + level: .warn, + body: "Test message via client perform selector", + attributes: [ + "priority": SentryLog.Attribute(string: "medium") + ] + ) + + // Call using dynamic dispatch - mimics SPM extension behavior + fixture.client.perform(#selector(HubSelectors.captureLog(_:withScope:)), with: log, with: fixture.scope) + + // Verify the log was captured + XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) + + let capturedLog = fixture.client.captureLogInvocations.invocations.last!.log + XCTAssertEqual(capturedLog.level, .warn) + XCTAssertEqual(capturedLog.body, "Test message via client perform selector") + XCTAssertEqual(capturedLog.attributes["priority"]?.value as? String, "medium") + } + + // MARK: - SentryOptions Tests + + func testOptions_BeforeSendLog_ViaKVC() { + // This test verifies that options.value(forKey:) and setValue(:forKey:) work correctly for beforeSendLog. + // This is what SentryOptions+SPM.swift does internally in its beforeSendLog property getter/setter. + + let callback: (SentryLog) -> SentryLog? = { log in + let modifiedLog = log + modifiedLog.body = "Modified: \(log.body)" + return modifiedLog + } + + // Set using KVC - mimics SPM extension behavior + fixture.options.setValue(callback, forKey: "beforeSendLogDynamic") + + // Get using KVC - mimics SPM extension behavior + let retrievedCallback = fixture.options.value(forKey: "beforeSendLogDynamic") as? (SentryLog) -> SentryLog? + + XCTAssertNotNil(retrievedCallback) + + let originalLog = SentryLog(level: .info, body: "Original message") + let modifiedLog = retrievedCallback?(originalLog) + + XCTAssertNotNil(modifiedLog) + XCTAssertEqual(modifiedLog?.body, "Modified: Original message") + } + + func testOptions_BeforeSendLog_CanDropLog() { + let callback: (SentryLog) -> SentryLog? = { log in + // Drop logs with "spam" in the body + return log.body.contains("spam") ? nil : log + } + + fixture.options.setValue(callback, forKey: "beforeSendLogDynamic") + let retrievedCallback = fixture.options.value(forKey: "beforeSendLogDynamic") as? (SentryLog) -> SentryLog? + + let normalLog = SentryLog(level: .info, body: "Normal message") + let spamLog = SentryLog(level: .info, body: "This is spam") + + XCTAssertNotNil(retrievedCallback?(normalLog)) + XCTAssertNil(retrievedCallback?(spamLog)) + } + + func testOptions_BeforeSendLog_CanFilterByLevel() { + // Only allow error and fatal logs + let callback: (SentryLog) -> SentryLog? = { log in + return (log.level == .error || log.level == .fatal) ? log : nil + } + + fixture.options.setValue(callback, forKey: "beforeSendLogDynamic") + let retrievedCallback = fixture.options.value(forKey: "beforeSendLogDynamic") as? (SentryLog) -> SentryLog? + + let infoLog = SentryLog(level: .info, body: "Info message") + let errorLog = SentryLog(level: .error, body: "Error message") + let fatalLog = SentryLog(level: .fatal, body: "Fatal message") + + XCTAssertNil(retrievedCallback?(infoLog)) + XCTAssertNotNil(retrievedCallback?(errorLog)) + XCTAssertNotNil(retrievedCallback?(fatalLog)) + } + + func testOptions_BeforeSendLog_CanModifyAttributes() { + let callback: (SentryLog) -> SentryLog? = { log in + let modifiedLog = log + var newAttributes = log.attributes + newAttributes["processed"] = SentryLog.Attribute(boolean: true) + modifiedLog.attributes = newAttributes + return modifiedLog + } + + fixture.options.setValue(callback, forKey: "beforeSendLogDynamic") + let retrievedCallback = fixture.options.value(forKey: "beforeSendLogDynamic") as? (SentryLog) -> SentryLog? + + let log = SentryLog( + level: .info, + body: "Test message", + attributes: ["original": SentryLog.Attribute(string: "value")] + ) + + let modifiedLog = retrievedCallback?(log) + + XCTAssertNotNil(modifiedLog) + XCTAssertEqual(modifiedLog?.attributes.count, 2) + XCTAssertEqual(modifiedLog?.attributes["original"]?.value as? String, "value") + XCTAssertEqual(modifiedLog?.attributes["processed"]?.value as? Bool, true) + } + + func testOptions_BeforeSendLog_CanBeCleared() { + fixture.options.setValue({ (log: SentryLog) in log }, forKey: "beforeSendLogDynamic") + XCTAssertNotNil(fixture.options.value(forKey: "beforeSendLogDynamic")) + + fixture.options.setValue(nil, forKey: "beforeSendLogDynamic") + + XCTAssertNil(fixture.options.value(forKey: "beforeSendLogDynamic")) + } +} diff --git a/Tests/SentryTests/SentryLogBatcherTests.swift b/Tests/SentryTests/SentryLogBatcherTests.swift index 8aece56356b..b1382cdb8a3 100644 --- a/Tests/SentryTests/SentryLogBatcherTests.swift +++ b/Tests/SentryTests/SentryLogBatcherTests.swift @@ -568,31 +568,6 @@ final class SentryLogBatcherTests: XCTestCase { XCTAssertEqual(attributes["added_by_callback"]?.value as? String, "callback_value") } - func testBeforeSendLogCallback_DynamicAccessGetAndSet() { - // Test dynamic access can both set and get the callback - let originalCallback: (SentryLog) -> SentryLog? = { log in - log.body = "Modified by original callback" - return log - } - - // Set using dynamic access - options.setValue(originalCallback, forKey: "beforeSendLogDynamic") - - // Get using dynamic access and verify it's the same callback - let retrievedCallback = options.value(forKey: "beforeSendLogDynamic") as? (SentryLog) -> SentryLog? - XCTAssertNotNil(retrievedCallback, "Dynamic access should retrieve the callback") - - let log = SentryLog(timestamp: Date(), traceId: .empty, level: .info, body: "foo", attributes: [:]) - let modifiedLog = retrievedCallback?(log) - - XCTAssertEqual(modifiedLog?.body, "Modified by original callback") - - // Test setting to nil using dynamic access - options.setValue(nil, forKey: "beforeSendLogDynamic") - let nilCallback = options.value(forKey: "beforeSendLogDynamic") - XCTAssertNil(nilCallback, "Dynamic access should allow setting to nil") - } - func testAddLog_WithLogsDisabled_DoesNotCaptureLog() { // Given - logs are disabled options.enableLogs = false From e060a58da6ec8f467fe5efc4052341830d081fa3 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Fri, 24 Oct 2025 16:13:42 +0200 Subject: [PATCH 10/32] provide selector protocols outside of swift pkg if/else --- Sources/Swift/Helper/SentryLog+SPM.swift | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/Swift/Helper/SentryLog+SPM.swift b/Sources/Swift/Helper/SentryLog+SPM.swift index 85699a2ad27..aa2b3448a5a 100644 --- a/Sources/Swift/Helper/SentryLog+SPM.swift +++ b/Sources/Swift/Helper/SentryLog+SPM.swift @@ -6,6 +6,17 @@ import Foundation // causing Swift-to-Objective-C bridging issues. These extensions work around that // by providing Swift-native methods and properties that use dynamic dispatch internally. +@objc +protocol HubSelectors { + func captureLog(_ log: SentryLog) + func captureLog(_ log: SentryLog, withScope: Scope) +} + +@objc +protocol ClientSelectors { + func captureLog(_ log: SentryLog, withScope: Scope) +} + #if SWIFT_PACKAGE /** @@ -27,12 +38,6 @@ public extension Options { } } -@objc -@_spi(Private) public protocol HubSelectors { - func captureLog(_ log: SentryLog) - func captureLog(_ log: SentryLog, withScope: Scope) -} - @objc public extension SentryHub { /// Captures a log entry and sends it to Sentry. @@ -58,11 +63,6 @@ public extension SentryHub { } } -@objc -@_spi(Private) public protocol ClientSelectors { - func captureLog(_ log: SentryLog, withScope: Scope) -} - /// Extension to provide log capture methods for SPM builds. @objc public extension SentryClient { From ca30a2d9817a26f85a2934363266d35aa434db84 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Fri, 24 Oct 2025 16:32:09 +0200 Subject: [PATCH 11/32] use correct scope parameter, add test --- Sources/Sentry/SentryHub.m | 2 +- Tests/SentryTests/SentryHubTests.swift | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 51b65eaa272..545bf18eca3 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -549,7 +549,7 @@ - (void)captureLog:(SentryLog *)log { SentryClient *client = self.client; if (client != nil) { - [client captureLog:log withScope:self.scope]; + [client captureLog:log withScope:scope]; } } diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index 193e7cf7cfa..2edacc232eb 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -15,6 +15,7 @@ class SentryHubTests: XCTestCase { let crumb = Breadcrumb(level: .error, category: "default") let scope = Scope() let message = "some message" + let log = SentryLog(level: .info, body: "Test log message") let event: Event let currentDateProvider = TestCurrentDateProvider() let sentryCrashWrapper = TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo) @@ -665,6 +666,27 @@ class SentryHubTests: XCTestCase { } } + func testCaptureLog() { + fixture.getSut(fixture.options, fixture.scope).capture(log: fixture.log) + + XCTAssertEqual(1, fixture.client.captureLogInvocations.count) + if let logArguments = fixture.client.captureLogInvocations.first { + XCTAssertEqual(fixture.log, logArguments.log) + XCTAssertEqual(fixture.scope, logArguments.scope) + } + } + + func testCaptureLogWithScope() { + let scope = Scope() + fixture.getSut().capture(log: fixture.log, scope: scope) + + XCTAssertEqual(1, fixture.client.captureLogInvocations.count) + if let logArguments = fixture.client.captureLogInvocations.first { + XCTAssertEqual(fixture.log, logArguments.log) + XCTAssertEqual(scope, logArguments.scope) + } + } + func testCaptureErrorWithScope() { fixture.getSut().capture(error: fixture.error, scope: fixture.scope).assertIsNotEmpty() From 4533b766318453ed7e06aebd1ef4e2f212a35717 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 28 Oct 2025 11:17:24 +0100 Subject: [PATCH 12/32] =?UTF-8?q?introduce=20captureLog=20dispatcher,=20ha?= =?UTF-8?q?ndle=20case=20where=20it=E2=80=99ll=20not=20work=20and=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Swift/Helper/SentryLog+SPM.swift | 49 ++++++++-- .../Helper/SentryLogSPMTests.swift | 95 +++++++++++++++---- 2 files changed, 114 insertions(+), 30 deletions(-) diff --git a/Sources/Swift/Helper/SentryLog+SPM.swift b/Sources/Swift/Helper/SentryLog+SPM.swift index aa2b3448a5a..b9435dab6b1 100644 --- a/Sources/Swift/Helper/SentryLog+SPM.swift +++ b/Sources/Swift/Helper/SentryLog+SPM.swift @@ -7,14 +7,48 @@ import Foundation // by providing Swift-native methods and properties that use dynamic dispatch internally. @objc -protocol HubSelectors { +protocol CaptureLogSelectors { func captureLog(_ log: SentryLog) func captureLog(_ log: SentryLog, withScope: Scope) } +/// Helper class to handle dynamic dispatch for log capture. +/// This is used in SPM builds to work around Swift-to-Objective-C bridging issues. @objc -protocol ClientSelectors { - func captureLog(_ log: SentryLog, withScope: Scope) +class CaptureLogDispatcher: NSObject { + + /// Captures a log using dynamic dispatch on the target object + /// - Parameters: + /// - log: The log to capture + /// - target: The object that should handle the log capture (typically SentryHub) + /// - Returns: true if the log was captured, false if the selector was not available + @discardableResult + static func captureLog(_ log: SentryLog, on target: NSObject) -> Bool { + let selector = #selector(CaptureLogSelectors.captureLog(_:)) + guard target.responds(to: selector) else { + SentrySDKLog.error("Target \(type(of: target)) does not respond to captureLog(_:). The log will not be captured.") + return false + } + target.perform(selector, with: log) + return true + } + + /// Captures a log with a scope using dynamic dispatch on the target object + /// - Parameters: + /// - log: The log to capture + /// - scope: The scope containing event metadata + /// - target: The object that should handle the log capture (typically SentryHub or SentryClient) + /// - Returns: true if the log was captured, false if the selector was not available + @discardableResult + static func captureLog(_ log: SentryLog, withScope scope: Scope, on target: NSObject) -> Bool { + let selector = #selector(CaptureLogSelectors.captureLog(_:withScope:)) + guard target.responds(to: selector) else { + SentrySDKLog.error("Target \(type(of: target)) does not respond to captureLog(_:withScope:). The log will not be captured.") + return false + } + target.perform(selector, with: log, with: scope) + return true + } } #if SWIFT_PACKAGE @@ -46,8 +80,7 @@ public extension SentryHub { /// This method is provided for SPM builds where the Objective-C `captureLog:` method /// may not be properly bridged due to `SentryLog` being defined in Swift. func capture(log: SentryLog) { - // Use dynamic dispatch to work around bridging limitations - perform(#selector(HubSelectors.captureLog(_:)), with: log) + CaptureLogDispatcher.captureLog(log, on: self) } /// Captures a log entry and sends it to Sentry with a specific scope. @@ -58,8 +91,7 @@ public extension SentryHub { /// This method is provided for SPM builds where the Objective-C `captureLog:withScope:` method /// may not be properly bridged due to `SentryLog` being defined in Swift. func capture(log: SentryLog, scope: Scope) { - // Use dynamic dispatch to work around bridging limitations - perform(#selector(HubSelectors.captureLog(_:withScope:)), with: log, with: scope) + CaptureLogDispatcher.captureLog(log, withScope: scope, on: self) } } @@ -71,8 +103,7 @@ public extension SentryClient { /// - log: The log entry to send to Sentry. /// - scope: The scope containing event metadata. func captureLog(_ log: SentryLog, withScope scope: Scope) { - // Use dynamic dispatch to work around bridging limitations - perform(#selector(ClientSelectors.captureLog(_:withScope:)), with: log, with: scope) + CaptureLogDispatcher.captureLog(log, withScope: scope, on: self) } } diff --git a/Tests/SentryTests/Helper/SentryLogSPMTests.swift b/Tests/SentryTests/Helper/SentryLogSPMTests.swift index 62f954bff17..4121b281b24 100644 --- a/Tests/SentryTests/Helper/SentryLogSPMTests.swift +++ b/Tests/SentryTests/Helper/SentryLogSPMTests.swift @@ -13,6 +13,10 @@ final class SentryLogSPMTests: XCTestCase { let dateProvider: TestCurrentDateProvider let options: Options let scope: Scope + let logOutput: TestLogOutput + var oldDebug: Bool! + var oldLevel: SentryLevel! + var oldOutput: SentryLogOutput! init() { options = Options() @@ -25,6 +29,19 @@ final class SentryLogSPMTests: XCTestCase { dateProvider = TestCurrentDateProvider() dateProvider.setDate(date: Date(timeIntervalSince1970: 1_627_846_800.123456)) + + // Set up log capture for testing error messages + oldDebug = SentrySDKLog.isDebug + oldLevel = SentrySDKLog.diagnosticLevel + oldOutput = SentrySDKLog.getOutput() + logOutput = TestLogOutput() + SentrySDKLog.setLogOutput(logOutput) + SentrySDKLogSupport.configure(true, diagnosticLevel: .error) + } + + func tearDown() { + SentrySDKLogSupport.configure(oldDebug, diagnosticLevel: oldLevel) + SentrySDKLog.setOutput(oldOutput) } } @@ -37,48 +54,50 @@ final class SentryLogSPMTests: XCTestCase { override func tearDown() { super.tearDown() + fixture.tearDown() clearTestState() } // MARK: - SentryHub Tests - func testHub_CaptureLog_ViaPerformSelector() { - // This test verifies that dynamic dispatch to captureLog: works correctly. + func testHub_CaptureLog_ViaDispatcher() { + // This test verifies that the dispatcher works correctly for captureLog. // This is what SentryLog+SPM.swift does internally in the capture(log:) extension method. let log = SentryLog( timestamp: fixture.dateProvider.date(), traceId: SentryId.empty, level: .info, - body: "Test message via perform selector", + body: "Test message via dispatcher", attributes: [ "test_key": SentryLog.Attribute(string: "test_value"), "count": SentryLog.Attribute(integer: 42) ] ) - // Call using dynamic dispatch - mimics SPM extension behavior - fixture.hub.perform(#selector(HubSelectors.captureLog(_:)), with: log) + // Call using dispatcher - tests the actual implementation + let result = CaptureLogDispatcher.captureLog(log, on: fixture.hub) - // Verify the log was captured + // Verify success + XCTAssertTrue(result) XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) let capturedLog = fixture.client.captureLogInvocations.invocations.last!.log XCTAssertEqual(capturedLog.level, .info) - XCTAssertEqual(capturedLog.body, "Test message via perform selector") + XCTAssertEqual(capturedLog.body, "Test message via dispatcher") XCTAssertEqual(capturedLog.attributes["test_key"]?.value as? String, "test_value") XCTAssertEqual(capturedLog.attributes["count"]?.value as? Int, 42) } - func testHub_CaptureLogWithScope_ViaPerformSelector() { - // This test verifies that dynamic dispatch to captureLog:withScope: works correctly. + func testHub_CaptureLogWithScope_ViaDispatcher() { + // This test verifies that the dispatcher works correctly for captureLog:withScope:. // This is what SentryLog+SPM.swift does internally in the capture(log:scope:) extension method. let log = SentryLog( timestamp: fixture.dateProvider.date(), traceId: SentryId.empty, level: .error, - body: "Test message with scope via perform selector", + body: "Test message with scope via dispatcher", attributes: [ "severity": SentryLog.Attribute(string: "high") ] @@ -87,43 +106,45 @@ final class SentryLogSPMTests: XCTestCase { let customScope = Scope() customScope.setTag(value: "test-value", key: "test-tag") - // Call using dynamic dispatch - mimics SPM extension behavior - fixture.hub.perform(#selector(HubSelectors.captureLog(_:withScope:)), with: log, with: fixture.scope) + // Call using dispatcher - tests the actual implementation + let result = CaptureLogDispatcher.captureLog(log, withScope: fixture.scope, on: fixture.hub) - // Verify the log was captured + // Verify success + XCTAssertTrue(result) XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) let capturedLog = fixture.client.captureLogInvocations.invocations.last!.log XCTAssertEqual(capturedLog.level, .error) - XCTAssertEqual(capturedLog.body, "Test message with scope via perform selector") + XCTAssertEqual(capturedLog.body, "Test message with scope via dispatcher") XCTAssertEqual(capturedLog.attributes["severity"]?.value as? String, "high") } // MARK: - SentryClient Tests - func testClient_CaptureLog_ViaPerformSelector() { - // This test verifies that dynamic dispatch to captureLog:withScope: works correctly on client. + func testClient_CaptureLog_ViaDispatcher() { + // This test verifies that the dispatcher works correctly for client.captureLog:withScope:. // This is what SentryLog+SPM.swift does internally in the captureLog(_:withScope:) extension method. let log = SentryLog( timestamp: fixture.dateProvider.date(), traceId: SentryId.empty, level: .warn, - body: "Test message via client perform selector", + body: "Test message via client dispatcher", attributes: [ "priority": SentryLog.Attribute(string: "medium") ] ) - // Call using dynamic dispatch - mimics SPM extension behavior - fixture.client.perform(#selector(HubSelectors.captureLog(_:withScope:)), with: log, with: fixture.scope) + // Call using dispatcher - tests the actual implementation + let result = CaptureLogDispatcher.captureLog(log, withScope: fixture.scope, on: fixture.client) - // Verify the log was captured + // Verify success + XCTAssertTrue(result) XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) let capturedLog = fixture.client.captureLogInvocations.invocations.last!.log XCTAssertEqual(capturedLog.level, .warn) - XCTAssertEqual(capturedLog.body, "Test message via client perform selector") + XCTAssertEqual(capturedLog.body, "Test message via client dispatcher") XCTAssertEqual(capturedLog.attributes["priority"]?.value as? String, "medium") } @@ -222,4 +243,36 @@ final class SentryLogSPMTests: XCTestCase { XCTAssertNil(fixture.options.value(forKey: "beforeSendLogDynamic")) } + + // MARK: - CaptureLogDispatcher Error Handling Tests + + func testDispatcher_CaptureLog_FailsWhenSelectorNotAvailable() { + // Test with a plain NSObject that doesn't implement captureLog methods + let plainObject = NSObject() + let log = SentryLog(level: .info, body: "Test message") + + let result = CaptureLogDispatcher.captureLog(log, on: plainObject) + + XCTAssertFalse(result) + XCTAssertTrue(fixture.logOutput.loggedMessages.contains { message in + message.contains("NSObject") && + message.contains("does not respond to captureLog(_:)") + }) + } + + func testDispatcher_CaptureLogWithScope_FailsWhenSelectorNotAvailable() { + // Test with a plain NSObject that doesn't implement captureLog methods + let plainObject = NSObject() + let log = SentryLog(level: .info, body: "Test message") + let scope = Scope() + + let result = CaptureLogDispatcher.captureLog(log, withScope: scope, on: plainObject) + + XCTAssertFalse(result) + XCTAssertTrue(fixture.logOutput.loggedMessages.contains { message in + message.contains("NSObject") && + message.contains("does not respond to captureLog(_:withScope:)") + }) + } + } From 5220b7100a49e2b8151f60c51f43dbe684dad66f Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 3 Nov 2025 13:48:04 +0100 Subject: [PATCH 13/32] add test for replayid --- Sources/Swift/Tools/SentryLogBatcher.swift | 1 - Tests/SentryTests/SentryLogBatcherTests.swift | 35 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Sources/Swift/Tools/SentryLogBatcher.swift b/Sources/Swift/Tools/SentryLogBatcher.swift index 742adb30f39..1acf1b972db 100644 --- a/Sources/Swift/Tools/SentryLogBatcher.swift +++ b/Sources/Swift/Tools/SentryLogBatcher.swift @@ -163,7 +163,6 @@ import Foundation if let scopeReplayId = scope.replayId { // Session mode: use scope replay ID attributes["sentry.replay_id"] = .init(string: scopeReplayId) - attributes.removeValue(forKey: "sentry._internal.replay_is_buffering") } #endif #endif diff --git a/Tests/SentryTests/SentryLogBatcherTests.swift b/Tests/SentryTests/SentryLogBatcherTests.swift index 1d3fda58caf..f1e9dbe6750 100644 --- a/Tests/SentryTests/SentryLogBatcherTests.swift +++ b/Tests/SentryTests/SentryLogBatcherTests.swift @@ -484,6 +484,41 @@ final class SentryLogBatcherTests: XCTestCase { XCTAssertNil(attributes["device.family"]) } + // MARK: - Replay Attributes Tests + +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + func testAddLog_ReplayAttributes_SessionMode_AddsReplayId() { + // Set replayId on scope (session mode) + let replayId = "12345678-1234-1234-1234-123456789012" + scope.replayId = replayId + + let log = createTestLog(body: "Test message") + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + XCTAssertEqual(capturedLog.attributes["sentry.replay_id"]?.value as? String, replayId) + XCTAssertNil(capturedLog.attributes["sentry._internal.replay_is_buffering"]) + } + + func testAddLog_ReplayAttributes_NoReplayId_NoAttributesAdded() { + // Don't set replayId on scope + scope.replayId = nil + + let log = createTestLog(body: "Test message") + sut.addLog(log, scope: scope) + sut.captureLogs() + + let capturedLogs = testDelegate.getCapturedLogs() + let capturedLog = capturedLogs.first! + XCTAssertNil(capturedLog.attributes["sentry.replay_id"]) + XCTAssertNil(capturedLog.attributes["sentry._internal.replay_is_buffering"]) + } +#endif +#endif + // MARK: - BeforeSendLog Callback Tests func testBeforeSendLog_ReturnsModifiedLog() { From 2eafc0020deff794cefe123fc481042409281f93 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 3 Nov 2025 14:20:45 +0100 Subject: [PATCH 14/32] remove NS_SWIFT_NAME --- SentryTestUtils/TestClient.swift | 2 +- Sources/Sentry/Public/SentryClient.h | 3 +-- Sources/Sentry/Public/SentryHub.h | 5 ++--- Sources/Sentry/SentryHub.m | 5 ++--- Sources/Swift/Tools/SentryLogger.swift | 2 +- Tests/SentryTests/SentryClientTests.swift | 2 +- Tests/SentryTests/SentryHubTests.swift | 12 ++++++------ 7 files changed, 14 insertions(+), 17 deletions(-) diff --git a/SentryTestUtils/TestClient.swift b/SentryTestUtils/TestClient.swift index ba9e6e9a0d0..e2058a9bbb8 100644 --- a/SentryTestUtils/TestClient.swift +++ b/SentryTestUtils/TestClient.swift @@ -162,7 +162,7 @@ public class TestClient: SentryClient { } public var captureLogInvocations = Invocations<(log: SentryLog, scope: Scope)>() - public override func capture(log: SentryLog, scope: Scope) { + public override func capture(_ log: SentryLog, with scope: Scope) { captureLogInvocations.record((log, scope)) } } diff --git a/Sources/Sentry/Public/SentryClient.h b/Sources/Sentry/Public/SentryClient.h index ed5fa876b4b..8df290a21f9 100644 --- a/Sources/Sentry/Public/SentryClient.h +++ b/Sources/Sentry/Public/SentryClient.h @@ -107,8 +107,7 @@ SENTRY_NO_INIT * @param log The log entry to send to Sentry. * @param scope The current scope from which to gather contextual information. */ -- (void)captureLog:(SentryLog *)log - withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)); +- (void)captureLog:(SentryLog *)log withScope:(SentryScope *)scope; /** * Waits synchronously for the SDK to flush out all queued and cached items for up to the specified diff --git a/Sources/Sentry/Public/SentryHub.h b/Sources/Sentry/Public/SentryHub.h index 7d1f8f3a3c4..aaeb2f6713f 100644 --- a/Sources/Sentry/Public/SentryHub.h +++ b/Sources/Sentry/Public/SentryHub.h @@ -180,15 +180,14 @@ SENTRY_NO_INIT * Captures a log entry and sends it to Sentry. * @param log The log entry to send to Sentry. */ -- (void)captureLog:(SentryLog *)log NS_SWIFT_NAME(capture(log:)); +- (void)captureLog:(SentryLog *)log; /** * Captures a log entry and sends it to Sentry. * @param log The log entry to send to Sentry. * @param scope The scope containing event metadata. */ -- (void)captureLog:(SentryLog *)log - withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)); +- (void)captureLog:(SentryLog *)log withScope:(SentryScope *)scope; /** * Use this method to modify the Scope of the Hub. The SDK uses the Scope to attach diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 67787416401..9fa291f95ce 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -534,13 +534,12 @@ - (void)captureFeedback:(SentryFeedback *)feedback } } -- (void)captureLog:(SentryLog *)log NS_SWIFT_NAME(capture(log:)) +- (void)captureLog:(SentryLog *)log { [self captureLog:log withScope:self.scope]; } -- (void)captureLog:(SentryLog *)log - withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)) +- (void)captureLog:(SentryLog *)log withScope:(SentryScope *)scope { SentryClient *client = self.client; if (client != nil) { diff --git a/Sources/Swift/Tools/SentryLogger.swift b/Sources/Swift/Tools/SentryLogger.swift index 7144f95ebf6..35942898b49 100644 --- a/Sources/Swift/Tools/SentryLogger.swift +++ b/Sources/Swift/Tools/SentryLogger.swift @@ -190,6 +190,6 @@ public final class SentryLogger: NSObject { // Note: In SPM builds, this uses the extension method defined in SentryHub+SPM.swift // which works around Swift-to-Objective-C bridging limitations. - hub.capture(log: log) + hub.capture(log) } } diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index aa902a8f43a..1eb14228471 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -2191,7 +2191,7 @@ class SentryClientTests: XCTestCase { ) let scope = Scope() - sut.capture(log: log, scope: scope) + sut.capture(log, with: scope) // Verify that the log was passed to the batcher XCTAssertEqual(testBatcher.addLogInvocations.count, 1) diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index 62b7b974eb0..997f144c5b0 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -666,7 +666,7 @@ class SentryHubTests: XCTestCase { } func testCaptureLog() { - fixture.getSut(fixture.options, fixture.scope).capture(log: fixture.log) + fixture.getSut(fixture.options, fixture.scope).capture(fixture.log) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) if let logArguments = fixture.client.captureLogInvocations.first { @@ -677,7 +677,7 @@ class SentryHubTests: XCTestCase { func testCaptureLogWithScope() { let scope = Scope() - fixture.getSut().capture(log: fixture.log, scope: scope) + fixture.getSut().capture(fixture.log, with: scope) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) if let logArguments = fixture.client.captureLogInvocations.first { @@ -704,7 +704,7 @@ class SentryHubTests: XCTestCase { let sut = fixture.getSut(fixture.options, fixture.scope) let log = SentryLog(level: .info, body: "Test message") - sut.capture(log: log, scope: fixture.scope) + sut.capture(log, with: fixture.scope) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -720,7 +720,7 @@ class SentryHubTests: XCTestCase { fixture.scope.replayId = nil let log = SentryLog(level: .info, body: "Test message") - testHub.capture(log: log, scope: fixture.scope) + testHub.capture(log, with: fixture.scope) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -734,7 +734,7 @@ class SentryHubTests: XCTestCase { let sut = fixture.getSut(fixture.options, fixture.scope) let log = SentryLog(level: .info, body: "Test message") - sut.capture(log: log, scope: fixture.scope) + sut.capture(log, with: fixture.scope) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -750,7 +750,7 @@ class SentryHubTests: XCTestCase { fixture.scope.replayId = replayId let log = SentryLog(level: .info, body: "Test message") - testHub.capture(log: log, scope: fixture.scope) + testHub.capture(log, with: fixture.scope) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log From b42d510fadcc8be65f36d7d9e79bb1d07bcd604d Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 3 Nov 2025 14:22:00 +0100 Subject: [PATCH 15/32] fix typo --- Sources/Sentry/SentryClient.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index fe328f1133c..39bd45c017a 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -627,7 +627,7 @@ - (void)flush:(NSTimeInterval)timeout { NSTimeInterval captureLogsDuration = [self.logBatcher captureLogs]; // Capturing batched logs should never take long, but we need to fall back to a sane value. - // This is a workaround for experimental logs, until we'll write batched logs to disk, + // This is a workaround for in-memory logs, until we'll write batched logs to disk, // to avoid data loss due to crashes. This is a trade-off until then. [self.transportAdapter flush:fmax(timeout / 2, timeout - captureLogsDuration)]; } From f00c0695304209075e68601bb4c021b321333492 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 3 Nov 2025 14:52:34 +0100 Subject: [PATCH 16/32] add comment --- Sources/Swift/Protocol/SentryLog.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Swift/Protocol/SentryLog.swift b/Sources/Swift/Protocol/SentryLog.swift index 13613ad91f3..8faddce91f2 100644 --- a/Sources/Swift/Protocol/SentryLog.swift +++ b/Sources/Swift/Protocol/SentryLog.swift @@ -6,7 +6,7 @@ public final class SentryLog: NSObject { /// The timestamp when the log event occurred public var timestamp: Date - /// The trace ID to associate this log with distributed tracing + /// The trace ID to associate this log with distributed tracing. This will be set to a valid non-empty value during processing. public var traceId: SentryId /// The severity level of the log entry public var level: Level From dee5276306a84baf4b46bf46b6444f4b2e89e303 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 3 Nov 2025 15:33:45 +0100 Subject: [PATCH 17/32] rename --- SentryTestUtils/TestClient.swift | 2 +- Sources/Sentry/Public/SentryClient.h | 3 ++- Sources/Sentry/Public/SentryHub.h | 6 ++++-- Sources/Swift/Tools/SentryLogger.swift | 2 +- Tests/SentryTests/SentryClientTests.swift | 2 +- Tests/SentryTests/SentryHubTests.swift | 12 ++++++------ 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/SentryTestUtils/TestClient.swift b/SentryTestUtils/TestClient.swift index e2058a9bbb8..ba9e6e9a0d0 100644 --- a/SentryTestUtils/TestClient.swift +++ b/SentryTestUtils/TestClient.swift @@ -162,7 +162,7 @@ public class TestClient: SentryClient { } public var captureLogInvocations = Invocations<(log: SentryLog, scope: Scope)>() - public override func capture(_ log: SentryLog, with scope: Scope) { + public override func capture(log: SentryLog, scope: Scope) { captureLogInvocations.record((log, scope)) } } diff --git a/Sources/Sentry/Public/SentryClient.h b/Sources/Sentry/Public/SentryClient.h index 8df290a21f9..ed5fa876b4b 100644 --- a/Sources/Sentry/Public/SentryClient.h +++ b/Sources/Sentry/Public/SentryClient.h @@ -107,7 +107,8 @@ SENTRY_NO_INIT * @param log The log entry to send to Sentry. * @param scope The current scope from which to gather contextual information. */ -- (void)captureLog:(SentryLog *)log withScope:(SentryScope *)scope; +- (void)captureLog:(SentryLog *)log + withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)); /** * Waits synchronously for the SDK to flush out all queued and cached items for up to the specified diff --git a/Sources/Sentry/Public/SentryHub.h b/Sources/Sentry/Public/SentryHub.h index aaeb2f6713f..eb684feeb82 100644 --- a/Sources/Sentry/Public/SentryHub.h +++ b/Sources/Sentry/Public/SentryHub.h @@ -16,6 +16,7 @@ @class SentryTransactionContext; @class SentryUser; @class SentryLog; +@class SentryLogger; NS_ASSUME_NONNULL_BEGIN @interface SentryHub : NSObject @@ -180,14 +181,15 @@ SENTRY_NO_INIT * Captures a log entry and sends it to Sentry. * @param log The log entry to send to Sentry. */ -- (void)captureLog:(SentryLog *)log; +- (void)captureLog:(SentryLog *)log NS_SWIFT_NAME(capture(log:)); /** * Captures a log entry and sends it to Sentry. * @param log The log entry to send to Sentry. * @param scope The scope containing event metadata. */ -- (void)captureLog:(SentryLog *)log withScope:(SentryScope *)scope; +- (void)captureLog:(SentryLog *)log + withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)); /** * Use this method to modify the Scope of the Hub. The SDK uses the Scope to attach diff --git a/Sources/Swift/Tools/SentryLogger.swift b/Sources/Swift/Tools/SentryLogger.swift index 35942898b49..7144f95ebf6 100644 --- a/Sources/Swift/Tools/SentryLogger.swift +++ b/Sources/Swift/Tools/SentryLogger.swift @@ -190,6 +190,6 @@ public final class SentryLogger: NSObject { // Note: In SPM builds, this uses the extension method defined in SentryHub+SPM.swift // which works around Swift-to-Objective-C bridging limitations. - hub.capture(log) + hub.capture(log: log) } } diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index 1eb14228471..aa902a8f43a 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -2191,7 +2191,7 @@ class SentryClientTests: XCTestCase { ) let scope = Scope() - sut.capture(log, with: scope) + sut.capture(log: log, scope: scope) // Verify that the log was passed to the batcher XCTAssertEqual(testBatcher.addLogInvocations.count, 1) diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index 997f144c5b0..62b7b974eb0 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -666,7 +666,7 @@ class SentryHubTests: XCTestCase { } func testCaptureLog() { - fixture.getSut(fixture.options, fixture.scope).capture(fixture.log) + fixture.getSut(fixture.options, fixture.scope).capture(log: fixture.log) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) if let logArguments = fixture.client.captureLogInvocations.first { @@ -677,7 +677,7 @@ class SentryHubTests: XCTestCase { func testCaptureLogWithScope() { let scope = Scope() - fixture.getSut().capture(fixture.log, with: scope) + fixture.getSut().capture(log: fixture.log, scope: scope) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) if let logArguments = fixture.client.captureLogInvocations.first { @@ -704,7 +704,7 @@ class SentryHubTests: XCTestCase { let sut = fixture.getSut(fixture.options, fixture.scope) let log = SentryLog(level: .info, body: "Test message") - sut.capture(log, with: fixture.scope) + sut.capture(log: log, scope: fixture.scope) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -720,7 +720,7 @@ class SentryHubTests: XCTestCase { fixture.scope.replayId = nil let log = SentryLog(level: .info, body: "Test message") - testHub.capture(log, with: fixture.scope) + testHub.capture(log: log, scope: fixture.scope) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -734,7 +734,7 @@ class SentryHubTests: XCTestCase { let sut = fixture.getSut(fixture.options, fixture.scope) let log = SentryLog(level: .info, body: "Test message") - sut.capture(log, with: fixture.scope) + sut.capture(log: log, scope: fixture.scope) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -750,7 +750,7 @@ class SentryHubTests: XCTestCase { fixture.scope.replayId = replayId let log = SentryLog(level: .info, body: "Test message") - testHub.capture(log, with: fixture.scope) + testHub.capture(log: log, scope: fixture.scope) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log From 11031c7013b417d0b77a52a14cdf424c7dbcee5e Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 3 Nov 2025 16:21:18 +0100 Subject: [PATCH 18/32] expose sentrylogger on Hub --- Sources/Sentry/Public/SentryHub.h | 19 ++--- Sources/Sentry/SentryHub.m | 72 ++++++++--------- Sources/Sentry/SentrySDKInternal.m | 2 - Sources/Swift/Helper/SentryLog+SPM.swift | 38 +-------- Sources/Swift/Helper/SentrySDK.swift | 35 +-------- Sources/Swift/Tools/SentryLogger.swift | 22 ++++-- .../Helper/SentryLogSPMTests.swift | 77 +++---------------- Tests/SentryTests/SentryHubTests.swift | 25 +++--- Tests/SentryTests/SentryLoggerTests.swift | 32 ++++---- 9 files changed, 102 insertions(+), 220 deletions(-) diff --git a/Sources/Sentry/Public/SentryHub.h b/Sources/Sentry/Public/SentryHub.h index eb684feeb82..4eaa09cfb9c 100644 --- a/Sources/Sentry/Public/SentryHub.h +++ b/Sources/Sentry/Public/SentryHub.h @@ -177,20 +177,6 @@ SENTRY_NO_INIT */ - (void)captureFeedback:(SentryFeedback *)feedback; -/** - * Captures a log entry and sends it to Sentry. - * @param log The log entry to send to Sentry. - */ -- (void)captureLog:(SentryLog *)log NS_SWIFT_NAME(capture(log:)); - -/** - * Captures a log entry and sends it to Sentry. - * @param log The log entry to send to Sentry. - * @param scope The scope containing event metadata. - */ -- (void)captureLog:(SentryLog *)log - withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)); - /** * Use this method to modify the Scope of the Hub. The SDK uses the Scope to attach * contextual data to events. @@ -214,6 +200,11 @@ SENTRY_NO_INIT */ @property (nonatomic, readonly, strong) SentryScope *scope; +/** + * Returns the logger associated with this Hub. + */ +@property (nonatomic, readonly, strong) SentryLogger *logger; + /** * Binds a different client to the hub. */ diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 9fa291f95ce..4eaa99746ff 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -26,7 +26,7 @@ NS_ASSUME_NONNULL_BEGIN -@interface SentryHub () +@interface SentryHub () @property (nullable, atomic, strong) SentryClient *client; @property (nullable, nonatomic, strong) SentryScope *scope; @@ -35,6 +35,7 @@ @interface SentryHub () @property (nonatomic, strong) NSMutableSet *installedIntegrationNames; @property (nonatomic) NSUInteger errorsBeforeSession; @property (nonatomic, weak) id sessionListener; +@property (nonatomic, strong) SentryLogger *logger; @end @@ -73,6 +74,10 @@ - (instancetype)initWithClient:(nullable SentryClient *)client if (_scope) { [_crashWrapper enrichScope:SENTRY_UNWRAP_NULLABLE(SentryScope, _scope)]; } + + _logger = [[SentryLogger alloc] + initWithDelegate:self + dateProvider:SentryDependencyContainer.sharedInstance.dateProvider]; } return self; @@ -534,40 +539,6 @@ - (void)captureFeedback:(SentryFeedback *)feedback } } -- (void)captureLog:(SentryLog *)log -{ - [self captureLog:log withScope:self.scope]; -} - -- (void)captureLog:(SentryLog *)log withScope:(SentryScope *)scope -{ - SentryClient *client = self.client; - if (client != nil) { -#if SENTRY_TARGET_REPLAY_SUPPORTED - NSMutableDictionary *mutableAttributes = - [log.attributes mutableCopy]; - - NSString *scopeReplayId = self.scope.replayId; - if (scopeReplayId != nil) { - // Session mode: use scope replay ID - mutableAttributes[@"sentry.replay_id"] = - [[SentryStructuredLogAttribute alloc] initWithString:scopeReplayId]; - } else { - // Buffer mode: check if hub has a session replay ID - NSString *sessionReplayId = [self getSessionReplayId]; - if (sessionReplayId != nil) { - mutableAttributes[@"sentry.replay_id"] = - [[SentryStructuredLogAttribute alloc] initWithString:sessionReplayId]; - mutableAttributes[@"sentry._internal.replay_is_buffering"] = - [[SentryStructuredLogAttribute alloc] initWithBoolean:YES]; - } - } - log.attributes = mutableAttributes; -#endif - [client captureLog:log withScope:scope]; - } -} - - (void)captureSerializedFeedback:(NSDictionary *)serializedFeedback withEventId:(NSString *)feedbackEventId attachments:(NSArray *)feedbackAttachments @@ -867,6 +838,37 @@ - (void)unregisterSessionListener:(id)listener } } +// SentryLoggerDelegate + +- (void)captureLog:(SentryLog *)log +{ + SentryClient *client = self.client; + if (client != nil) { +#if SENTRY_TARGET_REPLAY_SUPPORTED + NSMutableDictionary *mutableAttributes = + [log.attributes mutableCopy]; + + NSString *scopeReplayId = self.scope.replayId; + if (scopeReplayId != nil) { + // Session mode: use scope replay ID + mutableAttributes[@"sentry.replay_id"] = + [[SentryStructuredLogAttribute alloc] initWithString:scopeReplayId]; + } else { + // Buffer mode: check if hub has a session replay ID + NSString *sessionReplayId = [self getSessionReplayId]; + if (sessionReplayId != nil) { + mutableAttributes[@"sentry.replay_id"] = + [[SentryStructuredLogAttribute alloc] initWithString:sessionReplayId]; + mutableAttributes[@"sentry._internal.replay_is_buffering"] = + [[SentryStructuredLogAttribute alloc] initWithBoolean:YES]; + } + } + log.attributes = mutableAttributes; +#endif + [client captureLog:log withScope:self.scope]; + } +} + #pragma mark - Protected - (NSArray *)trimmedInstalledIntegrationNames diff --git a/Sources/Sentry/SentrySDKInternal.m b/Sources/Sentry/SentrySDKInternal.m index b0cff51e8be..9f827707751 100644 --- a/Sources/Sentry/SentrySDKInternal.m +++ b/Sources/Sentry/SentrySDKInternal.m @@ -662,8 +662,6 @@ + (void)close [SentrySDKInternal setCurrentHub:nil]; - [SentrySDK clearLogger]; - [SentryDependencyContainer.sharedInstance.crashWrapper stopBinaryImageCache]; [SentryDependencyContainer.sharedInstance.binaryImageCache stop]; diff --git a/Sources/Swift/Helper/SentryLog+SPM.swift b/Sources/Swift/Helper/SentryLog+SPM.swift index b9435dab6b1..70f9888adc0 100644 --- a/Sources/Swift/Helper/SentryLog+SPM.swift +++ b/Sources/Swift/Helper/SentryLog+SPM.swift @@ -8,7 +8,6 @@ import Foundation @objc protocol CaptureLogSelectors { - func captureLog(_ log: SentryLog) func captureLog(_ log: SentryLog, withScope: Scope) } @@ -17,22 +16,6 @@ protocol CaptureLogSelectors { @objc class CaptureLogDispatcher: NSObject { - /// Captures a log using dynamic dispatch on the target object - /// - Parameters: - /// - log: The log to capture - /// - target: The object that should handle the log capture (typically SentryHub) - /// - Returns: true if the log was captured, false if the selector was not available - @discardableResult - static func captureLog(_ log: SentryLog, on target: NSObject) -> Bool { - let selector = #selector(CaptureLogSelectors.captureLog(_:)) - guard target.responds(to: selector) else { - SentrySDKLog.error("Target \(type(of: target)) does not respond to captureLog(_:). The log will not be captured.") - return false - } - target.perform(selector, with: log) - return true - } - /// Captures a log with a scope using dynamic dispatch on the target object /// - Parameters: /// - log: The log to capture @@ -74,24 +57,9 @@ public extension Options { @objc public extension SentryHub { - /// Captures a log entry and sends it to Sentry. - /// - Parameter log: The log entry to send to Sentry. - /// - /// This method is provided for SPM builds where the Objective-C `captureLog:` method - /// may not be properly bridged due to `SentryLog` being defined in Swift. - func capture(log: SentryLog) { - CaptureLogDispatcher.captureLog(log, on: self) - } - - /// Captures a log entry and sends it to Sentry with a specific scope. - /// - Parameters: - /// - log: The log entry to send to Sentry. - /// - scope: The scope containing event metadata. - /// - /// This method is provided for SPM builds where the Objective-C `captureLog:withScope:` method - /// may not be properly bridged due to `SentryLog` being defined in Swift. - func capture(log: SentryLog, scope: Scope) { - CaptureLogDispatcher.captureLog(log, withScope: scope, on: self) + @objc + var logger: SentryLogger? { + return value(forKey: "logger") as? SentryLogger } } diff --git a/Sources/Swift/Helper/SentrySDK.swift b/Sources/Swift/Helper/SentrySDK.swift index f345877a7b8..13afe5fd96d 100644 --- a/Sources/Swift/Helper/SentrySDK.swift +++ b/Sources/Swift/Helper/SentrySDK.swift @@ -27,22 +27,10 @@ import Foundation /// API to access Sentry logs @objc public static var logger: SentryLogger { - return _loggerLock.synchronized { - let sdkEnabled = SentrySDKInternal.isEnabled - if !sdkEnabled { - SentrySDKLog.fatal("Logs called before SentrySDK.start() will be dropped.") - } - if let _logger, _loggerConfigured { - return _logger - } - let logger = SentryLogger( - hub: SentryDependencyContainerSwiftHelper.currentHub(), - dateProvider: Dependencies.dateProvider - ) - _logger = logger - _loggerConfigured = sdkEnabled - return logger + if !SentrySDKInternal.isEnabled { + SentrySDKLog.fatal("Logs called before SentrySDK.start() will be dropped.") } + return SentrySDKInternal.currentHub().logger } /// Inits and configures Sentry (`SentryHub`, `SentryClient`) and sets up all integrations. Make sure to @@ -400,23 +388,6 @@ import Foundation SentrySDKInternal.stopProfiler() } #endif - - // MARK: Internal - - /// - note: Conceptually internal but needs to be marked public with SPI for ObjC visibility - @objc @_spi(Private) public static func clearLogger() { - _loggerLock.synchronized { - _logger = nil - _loggerConfigured = false - } - } - - // MARK: Private - - private static var _loggerLock = NSLock() - private static var _logger: SentryLogger? - // Flag to re-create instance if accessed before SDK init. - private static var _loggerConfigured = false } extension SentryIdWrapper { diff --git a/Sources/Swift/Tools/SentryLogger.swift b/Sources/Swift/Tools/SentryLogger.swift index 7144f95ebf6..e692bb2d2fd 100644 --- a/Sources/Swift/Tools/SentryLogger.swift +++ b/Sources/Swift/Tools/SentryLogger.swift @@ -2,6 +2,11 @@ import Foundation +@objc @_spi(Private) public protocol SentryLoggerDelegate: AnyObject { + @objc(captureLog:) + func capture(log: SentryLog) +} + /// **EXPERIMENTAL** - A structured logging API for Sentry. /// /// `SentryLogger` provides a structured logging interface that captures log entries @@ -29,11 +34,12 @@ import Foundation /// ``` @objc public final class SentryLogger: NSObject { - private let hub: SentryHub + private weak var delegate: SentryLoggerDelegate? private let dateProvider: SentryCurrentDateProvider - @_spi(Private) public init(hub: SentryHub, dateProvider: SentryCurrentDateProvider) { - self.hub = hub + @objc(initWithDelegate:dateProvider:) + @_spi(Private) public init(delegate: SentryLoggerDelegate, dateProvider: SentryCurrentDateProvider) { + self.delegate = delegate self.dateProvider = dateProvider super.init() } @@ -166,7 +172,10 @@ public final class SentryLogger: NSObject { // MARK: - Private - private func captureLog(level: SentryLog.Level, logMessage: SentryLogMessage, attributes: [String: Any]) { + private func captureLog(level: SentryLog.Level, logMessage: SentryLogMessage, attributes: [String: Any]) { + guard let delegate else { + return + } // Convert provided attributes to SentryLog.Attribute format var logAttributes = attributes.mapValues { SentryLog.Attribute(value: $0) } @@ -187,9 +196,6 @@ public final class SentryLogger: NSObject { body: logMessage.message, attributes: logAttributes ) - - // Note: In SPM builds, this uses the extension method defined in SentryHub+SPM.swift - // which works around Swift-to-Objective-C bridging limitations. - hub.capture(log: log) + delegate.capture(log: log) } } diff --git a/Tests/SentryTests/Helper/SentryLogSPMTests.swift b/Tests/SentryTests/Helper/SentryLogSPMTests.swift index 4121b281b24..c6bfea4db56 100644 --- a/Tests/SentryTests/Helper/SentryLogSPMTests.swift +++ b/Tests/SentryTests/Helper/SentryLogSPMTests.swift @@ -60,68 +60,29 @@ final class SentryLogSPMTests: XCTestCase { // MARK: - SentryHub Tests - func testHub_CaptureLog_ViaDispatcher() { - // This test verifies that the dispatcher works correctly for captureLog. - // This is what SentryLog+SPM.swift does internally in the capture(log:) extension method. + func testHub_Logger_ViaKVC() { + // This test verifies that the logger property can be accessed via KVC. + // This is important for SPM builds where there might be Swift-to-Objective-C bridging issues. - let log = SentryLog( - timestamp: fixture.dateProvider.date(), - traceId: SentryId.empty, - level: .info, - body: "Test message via dispatcher", - attributes: [ - "test_key": SentryLog.Attribute(string: "test_value"), - "count": SentryLog.Attribute(integer: 42) - ] - ) - - // Call using dispatcher - tests the actual implementation - let result = CaptureLogDispatcher.captureLog(log, on: fixture.hub) - - // Verify success - XCTAssertTrue(result) - XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) - - let capturedLog = fixture.client.captureLogInvocations.invocations.last!.log - XCTAssertEqual(capturedLog.level, .info) - XCTAssertEqual(capturedLog.body, "Test message via dispatcher") - XCTAssertEqual(capturedLog.attributes["test_key"]?.value as? String, "test_value") - XCTAssertEqual(capturedLog.attributes["count"]?.value as? Int, 42) - } - - func testHub_CaptureLogWithScope_ViaDispatcher() { - // This test verifies that the dispatcher works correctly for captureLog:withScope:. - // This is what SentryLog+SPM.swift does internally in the capture(log:scope:) extension method. + let hub = fixture.hub - let log = SentryLog( - timestamp: fixture.dateProvider.date(), - traceId: SentryId.empty, - level: .error, - body: "Test message with scope via dispatcher", - attributes: [ - "severity": SentryLog.Attribute(string: "high") - ] - ) + // Access logger via KVC - mimics dynamic property access + let logger = hub.value(forKey: "logger") as? SentryLogger - let customScope = Scope() - customScope.setTag(value: "test-value", key: "test-tag") + XCTAssertNotNil(logger, "Logger should be accessible via KVC") - // Call using dispatcher - tests the actual implementation - let result = CaptureLogDispatcher.captureLog(log, withScope: fixture.scope, on: fixture.hub) + // Use the logger directly (this tests that KVC access works) + logger?.info("Test message via KVC logger") - // Verify success - XCTAssertTrue(result) XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) - let capturedLog = fixture.client.captureLogInvocations.invocations.last!.log - XCTAssertEqual(capturedLog.level, .error) - XCTAssertEqual(capturedLog.body, "Test message with scope via dispatcher") - XCTAssertEqual(capturedLog.attributes["severity"]?.value as? String, "high") + XCTAssertEqual(capturedLog.level, .info) + XCTAssertEqual(capturedLog.body, "Test message via KVC logger") } // MARK: - SentryClient Tests - func testClient_CaptureLog_ViaDispatcher() { + func testClient_CaptureLogWithScope_ViaDispatcher() { // This test verifies that the dispatcher works correctly for client.captureLog:withScope:. // This is what SentryLog+SPM.swift does internally in the captureLog(_:withScope:) extension method. @@ -246,20 +207,6 @@ final class SentryLogSPMTests: XCTestCase { // MARK: - CaptureLogDispatcher Error Handling Tests - func testDispatcher_CaptureLog_FailsWhenSelectorNotAvailable() { - // Test with a plain NSObject that doesn't implement captureLog methods - let plainObject = NSObject() - let log = SentryLog(level: .info, body: "Test message") - - let result = CaptureLogDispatcher.captureLog(log, on: plainObject) - - XCTAssertFalse(result) - XCTAssertTrue(fixture.logOutput.loggedMessages.contains { message in - message.contains("NSObject") && - message.contains("does not respond to captureLog(_:)") - }) - } - func testDispatcher_CaptureLogWithScope_FailsWhenSelectorNotAvailable() { // Test with a plain NSObject that doesn't implement captureLog methods let plainObject = NSObject() diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index 62b7b974eb0..9f181652048 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -666,22 +666,27 @@ class SentryHubTests: XCTestCase { } func testCaptureLog() { - fixture.getSut(fixture.options, fixture.scope).capture(log: fixture.log) + let hub = fixture.getSut(fixture.options, fixture.scope) + hub.logger.info(fixture.log.body) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) if let logArguments = fixture.client.captureLogInvocations.first { - XCTAssertEqual(fixture.log, logArguments.log) + XCTAssertEqual(fixture.log.body, logArguments.log.body) + XCTAssertEqual(fixture.log.level, logArguments.log.level) XCTAssertEqual(fixture.scope, logArguments.scope) } } func testCaptureLogWithScope() { let scope = Scope() - fixture.getSut().capture(log: fixture.log, scope: scope) + // Note: logger uses hub's scope, so we create a new hub with the specific scope + let hubWithScope = fixture.getSut(fixture.options, scope) + hubWithScope.logger.info(fixture.log.body) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) if let logArguments = fixture.client.captureLogInvocations.first { - XCTAssertEqual(fixture.log, logArguments.log) + XCTAssertEqual(fixture.log.body, logArguments.log.body) + XCTAssertEqual(fixture.log.level, logArguments.log.level) XCTAssertEqual(scope, logArguments.scope) } } @@ -703,8 +708,7 @@ class SentryHubTests: XCTestCase { fixture.scope.replayId = replayId let sut = fixture.getSut(fixture.options, fixture.scope) - let log = SentryLog(level: .info, body: "Test message") - sut.capture(log: log, scope: fixture.scope) + sut.logger.info("Test message") XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -719,8 +723,7 @@ class SentryHubTests: XCTestCase { testHub.mockReplayId = mockReplayId.sentryIdString fixture.scope.replayId = nil - let log = SentryLog(level: .info, body: "Test message") - testHub.capture(log: log, scope: fixture.scope) + testHub.logger.info("Test message") XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -733,8 +736,7 @@ class SentryHubTests: XCTestCase { // Don't set up replay integration let sut = fixture.getSut(fixture.options, fixture.scope) - let log = SentryLog(level: .info, body: "Test message") - sut.capture(log: log, scope: fixture.scope) + sut.logger.info("Test message") XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -749,8 +751,7 @@ class SentryHubTests: XCTestCase { testHub.mockReplayId = replayId fixture.scope.replayId = replayId - let log = SentryLog(level: .info, body: "Test message") - testHub.capture(log: log, scope: fixture.scope) + testHub.logger.info("Test message") XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log diff --git a/Tests/SentryTests/SentryLoggerTests.swift b/Tests/SentryTests/SentryLoggerTests.swift index 0bc1382c43d..79eca6b5022 100644 --- a/Tests/SentryTests/SentryLoggerTests.swift +++ b/Tests/SentryTests/SentryLoggerTests.swift @@ -6,28 +6,26 @@ import XCTest final class SentryLoggerTests: XCTestCase { + private class TestLoggerDelegate: NSObject, SentryLoggerDelegate { + let capturedLogs = Invocations() + + func capture(log: SentryLog) { + capturedLogs.record(log) + } + } + private class Fixture { - let hub: TestHub - let client: TestClient + let delegate: TestLoggerDelegate let dateProvider: TestCurrentDateProvider - let options: Options - let scope: Scope init() { - options = Options() - options.dsn = TestConstants.dsnAsString(username: "SentryLoggerTests") - options.enableLogs = true - - client = TestClient(options: options)! - scope = Scope() - hub = TestHub(client: client, andScope: scope) + delegate = TestLoggerDelegate() dateProvider = TestCurrentDateProvider() - dateProvider.setDate(date: Date(timeIntervalSince1970: 1_627_846_800.123456)) } func getSut() -> SentryLogger { - return SentryLogger(hub: hub, dateProvider: dateProvider) + return SentryLogger(delegate: delegate, dateProvider: dateProvider) } } @@ -262,9 +260,9 @@ final class SentryLoggerTests: XCTestCase { sut.fatal("Fatal message") // Verify all 6 logs were captured - XCTAssertEqual(fixture.client.captureLogInvocations.count, 6) + XCTAssertEqual(fixture.delegate.capturedLogs.count, 6) - let logs = fixture.client.captureLogInvocations.invocations.map { $0.log } + let logs = fixture.delegate.capturedLogs.invocations XCTAssertEqual(logs[0].level, .trace) XCTAssertEqual(logs[1].level, .debug) XCTAssertEqual(logs[2].level, .info) @@ -490,10 +488,10 @@ final class SentryLoggerTests: XCTestCase { } private func getLastCapturedLog() -> SentryLog { - guard let lastInvocation = fixture.client.captureLogInvocations.invocations.last else { + guard let lastLog = fixture.delegate.capturedLogs.invocations.last else { XCTFail("No logs captured") return SentryLog(timestamp: Date(), traceId: .empty, level: .info, body: "", attributes: [:]) } - return lastInvocation.log + return lastLog } } From 0239c61b2c358ee0774402a055bacd9e01c55cda Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 09:03:50 +0100 Subject: [PATCH 19/32] use cast to exose logger --- Sources/Sentry/SentryHub.m | 2 +- Sources/Sentry/include/SentryHub.h | 9 +++------ Sources/Swift/Helper/SentrySDK.swift | 4 ++-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 736ac7e84f6..4bce7741e2b 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -74,7 +74,7 @@ - (instancetype)initWithClient:(nullable SentryClientInternal *)client [_crashWrapper enrichScope:SENTRY_UNWRAP_NULLABLE(SentryScope, _scope)]; } - _logger = [[SentryLogger alloc] + __swiftLogger = [[SentryLogger alloc] initWithDelegate:self dateProvider:SentryDependencyContainer.sharedInstance.dateProvider]; } diff --git a/Sources/Sentry/include/SentryHub.h b/Sources/Sentry/include/SentryHub.h index 8a7b46832b2..5d9da570c38 100644 --- a/Sources/Sentry/include/SentryHub.h +++ b/Sources/Sentry/include/SentryHub.h @@ -15,8 +15,6 @@ @class SentryScope; @class SentryTransactionContext; @class SentryUser; -@class SentryLog; -@class SentryLogger; NS_ASSUME_NONNULL_BEGIN @interface SentryHubInternal : NSObject @@ -200,10 +198,9 @@ SENTRY_NO_INIT */ @property (nonatomic, readonly, strong) SentryScope *scope; -/** - * Returns the logger associated with this Hub. - */ -@property (nonatomic, readonly, strong) SentryLogger *logger; +// Do not use this directly, instead use the non-underscored `logger` property that is +// defined through a SentryHub.swift file. +@property (nonatomic, readonly, strong) NSObject *_swiftLogger; /** * Binds a different client to the hub. diff --git a/Sources/Swift/Helper/SentrySDK.swift b/Sources/Swift/Helper/SentrySDK.swift index 20da2e2ddbb..427a5f281c1 100644 --- a/Sources/Swift/Helper/SentrySDK.swift +++ b/Sources/Swift/Helper/SentrySDK.swift @@ -30,9 +30,9 @@ import Foundation if !SentrySDKInternal.isEnabled { SentrySDKLog.fatal("Logs called before SentrySDK.start() will be dropped.") } - // Temp until we figure out how to access the logger from the helper + // We know the type so it's fine to force cast. // swiftlint:disable force_cast - return SentrySDKInternal.currentHub().value(forKey: "logger") as! SentryLogger + return SentrySDKInternal.currentHub()._swiftLogger as! SentryLogger // swiftlint:enable force_cast } From 130e34d6ade6f7982bbb8f7b2a8eec99cad914ad Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 09:06:49 +0100 Subject: [PATCH 20/32] update test --- Tests/SentryTests/SentryHubTests.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index a8d54893541..3b058e56541 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -667,7 +667,7 @@ class SentryHubTests: XCTestCase { func testCaptureLog() { let hub = fixture.getSut(fixture.options, fixture.scope) - hub.logger.info(fixture.log.body) + (hub._swiftLogger as! SentryLogger).info(fixture.log.body) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) if let logArguments = fixture.client.captureLogInvocations.first { @@ -681,7 +681,7 @@ class SentryHubTests: XCTestCase { let scope = Scope() // Note: logger uses hub's scope, so we create a new hub with the specific scope let hubWithScope = fixture.getSut(fixture.options, scope) - hubWithScope.logger.info(fixture.log.body) + (hubWithScope._swiftLogger as! SentryLogger).info(fixture.log.body) XCTAssertEqual(1, fixture.client.captureLogInvocations.count) if let logArguments = fixture.client.captureLogInvocations.first { @@ -708,7 +708,7 @@ class SentryHubTests: XCTestCase { fixture.scope.replayId = replayId let sut = fixture.getSut(fixture.options, fixture.scope) - sut.logger.info("Test message") + (sut._swiftLogger as! SentryLogger).info("Test message") XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -723,7 +723,7 @@ class SentryHubTests: XCTestCase { testHub.mockReplayId = mockReplayId.sentryIdString fixture.scope.replayId = nil - testHub.logger.info("Test message") + (testHub._swiftLogger as! SentryLogger).info("Test message") XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -736,7 +736,7 @@ class SentryHubTests: XCTestCase { // Don't set up replay integration let sut = fixture.getSut(fixture.options, fixture.scope) - sut.logger.info("Test message") + (sut._swiftLogger as! SentryLogger).info("Test message") XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log @@ -751,7 +751,7 @@ class SentryHubTests: XCTestCase { testHub.mockReplayId = replayId fixture.scope.replayId = replayId - testHub.logger.info("Test message") + (testHub._swiftLogger as! SentryLogger).info("Test message") XCTAssertEqual(1, fixture.client.captureLogInvocations.count) let capturedLog = fixture.client.captureLogInvocations.first?.log From dd00bcbba80e78285d67f861038b760a4ce8cb6d Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 09:23:00 +0100 Subject: [PATCH 21/32] update access to fw decalered log APIs --- SentryTestUtils/TestClient.swift | 6 +- Sources/Sentry/SentryClient.m | 6 +- Sources/Sentry/SentryHub.m | 2 +- Sources/Sentry/include/SentryClient.h | 11 +-- Sources/Swift/Helper/SentryLog+SPM.swift | 45 +----------- Sources/Swift/SentryClient.swift | 2 +- .../Helper/SentryLogSPMTests.swift | 69 ------------------- Tests/SentryTests/SentryClientTests.swift | 2 +- 8 files changed, 17 insertions(+), 126 deletions(-) diff --git a/SentryTestUtils/TestClient.swift b/SentryTestUtils/TestClient.swift index 776382fd6bc..8a91e6cee8c 100644 --- a/SentryTestUtils/TestClient.swift +++ b/SentryTestUtils/TestClient.swift @@ -162,7 +162,9 @@ public class TestClient: SentryClientInternal { } public var captureLogInvocations = Invocations<(log: SentryLog, scope: Scope)>() - public override func capture(log: SentryLog, scope: Scope) { - captureLogInvocations.record((log, scope)) + public override func _swiftCaptureLog(_ log: NSObject, with scope: Scope) { + if let castLog = log as? SentryLog { + captureLogInvocations.record((castLog, scope)) + } } } diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index 8efa32816ec..71b824b7585 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -1097,9 +1097,11 @@ - (void)removeAttachmentProcessor:(id)attachmen return processedAttachments; } -- (void)captureLog:(SentryLog *)log withScope:(SentryScope *)scope +- (void)_swiftCaptureLog:(NSObject *)log withScope:(SentryScope *)scope { - [self.logBatcher addLog:log scope:scope]; + if ([log isKindOfClass:[SentryLog class]]) { + [self.logBatcher addLog:(SentryLog *)log scope:scope]; + } } - (void)captureLogsData:(NSData *)data with:(NSNumber *)itemCount diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 4bce7741e2b..c3e885189e9 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -864,7 +864,7 @@ - (void)captureLog:(SentryLog *)log } log.attributes = mutableAttributes; #endif - [client captureLog:log withScope:self.scope]; + [client _swiftCaptureLog:log withScope:self.scope]; } } diff --git a/Sources/Sentry/include/SentryClient.h b/Sources/Sentry/include/SentryClient.h index e4a82030e9e..deb4b079ca1 100644 --- a/Sources/Sentry/include/SentryClient.h +++ b/Sources/Sentry/include/SentryClient.h @@ -12,7 +12,6 @@ @class SentryScope; @class SentryId; @class SentryTransaction; -@class SentryLog; NS_ASSUME_NONNULL_BEGIN @@ -102,13 +101,9 @@ SENTRY_NO_INIT - (void)captureFeedback:(SentryFeedback *)feedback withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(feedback:scope:)); -/** - * Captures a log entry and sends it to Sentry. - * @param log The log entry to send to Sentry. - * @param scope The current scope from which to gather contextual information. - */ -- (void)captureLog:(SentryLog *)log - withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(log:scope:)); +// Do not use this directly, instead use the non-underscored `captureLog` method that is +// defined through a SentryClient.swift file. +- (void)_swiftCaptureLog:(NSObject *)log withScope:(SentryScope *)scope; /** * Waits synchronously for the SDK to flush out all queued and cached items for up to the specified diff --git a/Sources/Swift/Helper/SentryLog+SPM.swift b/Sources/Swift/Helper/SentryLog+SPM.swift index d6a47e48320..ea7309841cc 100644 --- a/Sources/Swift/Helper/SentryLog+SPM.swift +++ b/Sources/Swift/Helper/SentryLog+SPM.swift @@ -3,36 +3,9 @@ import Foundation // Swift extensions to provide properly typed log-related APIs for SPM builds. // In SPM builds, SentryLog is only forward declared in the Objective-C headers, -// causing Swift-to-Objective-C bridging issues. These extensions work around that -// by providing Swift-native methods and properties that use dynamic dispatch internally. - -@objc -protocol CaptureLogSelectors { - func captureLog(_ log: SentryLog, withScope: Scope) -} - -/// Helper class to handle dynamic dispatch for log capture. -/// This is used in SPM builds to work around Swift-to-Objective-C bridging issues. -@objc -class CaptureLogDispatcher: NSObject { - - /// Captures a log with a scope using dynamic dispatch on the target object - /// - Parameters: - /// - log: The log to capture - /// - scope: The scope containing event metadata - /// - target: The object that should handle the log capture (typically SentryHub or SentryClient) - /// - Returns: true if the log was captured, false if the selector was not available - @discardableResult - static func captureLog(_ log: SentryLog, withScope scope: Scope, on target: NSObject) -> Bool { - let selector = #selector(CaptureLogSelectors.captureLog(_:withScope:)) - guard target.responds(to: selector) else { - SentrySDKLog.error("Target \(type(of: target)) does not respond to captureLog(_:withScope:). The log will not be captured.") - return false - } - target.perform(selector, with: log, with: scope) - return true - } -} +// which causes Swift-to-Objective-C bridging issues. These extensions work around +// that limitation by providing Swift-native methods and properties that use dynamic +// dispatch internally. #if SWIFT_PACKAGE @@ -55,16 +28,4 @@ public extension Options { } } -/// Extension to provide log capture methods for SPM builds. -@objc -public extension SentryClient { - /// Captures a log entry and sends it to Sentry. - /// - Parameters: - /// - log: The log entry to send to Sentry. - /// - scope: The scope containing event metadata. - func captureLog(_ log: SentryLog, withScope scope: Scope) { - CaptureLogDispatcher.captureLog(log, withScope: scope, on: self) - } -} - #endif // SWIFT_PACKAGE diff --git a/Sources/Swift/SentryClient.swift b/Sources/Swift/SentryClient.swift index 406b27468b9..d669e837511 100644 --- a/Sources/Swift/SentryClient.swift +++ b/Sources/Swift/SentryClient.swift @@ -108,7 +108,7 @@ import Foundation /// - log: The log entry to send to Sentry. /// - scope: The scope containing event metadata. @objc(captureLog:withScope:) public func capture(log: SentryLog, scope: Scope) { - helper.capture(log: log, scope: scope) + helper._swiftCaptureLog(log, with: scope) } /// Waits synchronously for the SDK to flush out all queued and cached items for up to the specified timeout in seconds. diff --git a/Tests/SentryTests/Helper/SentryLogSPMTests.swift b/Tests/SentryTests/Helper/SentryLogSPMTests.swift index c6bfea4db56..7765be95d5e 100644 --- a/Tests/SentryTests/Helper/SentryLogSPMTests.swift +++ b/Tests/SentryTests/Helper/SentryLogSPMTests.swift @@ -57,57 +57,6 @@ final class SentryLogSPMTests: XCTestCase { fixture.tearDown() clearTestState() } - - // MARK: - SentryHub Tests - - func testHub_Logger_ViaKVC() { - // This test verifies that the logger property can be accessed via KVC. - // This is important for SPM builds where there might be Swift-to-Objective-C bridging issues. - - let hub = fixture.hub - - // Access logger via KVC - mimics dynamic property access - let logger = hub.value(forKey: "logger") as? SentryLogger - - XCTAssertNotNil(logger, "Logger should be accessible via KVC") - - // Use the logger directly (this tests that KVC access works) - logger?.info("Test message via KVC logger") - - XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) - let capturedLog = fixture.client.captureLogInvocations.invocations.last!.log - XCTAssertEqual(capturedLog.level, .info) - XCTAssertEqual(capturedLog.body, "Test message via KVC logger") - } - - // MARK: - SentryClient Tests - - func testClient_CaptureLogWithScope_ViaDispatcher() { - // This test verifies that the dispatcher works correctly for client.captureLog:withScope:. - // This is what SentryLog+SPM.swift does internally in the captureLog(_:withScope:) extension method. - - let log = SentryLog( - timestamp: fixture.dateProvider.date(), - traceId: SentryId.empty, - level: .warn, - body: "Test message via client dispatcher", - attributes: [ - "priority": SentryLog.Attribute(string: "medium") - ] - ) - - // Call using dispatcher - tests the actual implementation - let result = CaptureLogDispatcher.captureLog(log, withScope: fixture.scope, on: fixture.client) - - // Verify success - XCTAssertTrue(result) - XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) - - let capturedLog = fixture.client.captureLogInvocations.invocations.last!.log - XCTAssertEqual(capturedLog.level, .warn) - XCTAssertEqual(capturedLog.body, "Test message via client dispatcher") - XCTAssertEqual(capturedLog.attributes["priority"]?.value as? String, "medium") - } // MARK: - SentryOptions Tests @@ -204,22 +153,4 @@ final class SentryLogSPMTests: XCTestCase { XCTAssertNil(fixture.options.value(forKey: "beforeSendLogDynamic")) } - - // MARK: - CaptureLogDispatcher Error Handling Tests - - func testDispatcher_CaptureLogWithScope_FailsWhenSelectorNotAvailable() { - // Test with a plain NSObject that doesn't implement captureLog methods - let plainObject = NSObject() - let log = SentryLog(level: .info, body: "Test message") - let scope = Scope() - - let result = CaptureLogDispatcher.captureLog(log, withScope: scope, on: plainObject) - - XCTAssertFalse(result) - XCTAssertTrue(fixture.logOutput.loggedMessages.contains { message in - message.contains("NSObject") && - message.contains("does not respond to captureLog(_:withScope:)") - }) - } - } diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index d8a361e322a..0325563383f 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -2191,7 +2191,7 @@ class SentryClientTests: XCTestCase { ) let scope = Scope() - sut.capture(log: log, scope: scope) + sut._swiftCaptureLog(log, with: scope) // Verify that the log was passed to the batcher XCTAssertEqual(testBatcher.addLogInvocations.count, 1) From df339074bc42d93c70febc891bdce0ef41cdf7ed Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 09:55:58 +0100 Subject: [PATCH 22/32] cleanup --- CHANGELOG.md | 2 +- Sources/Swift/Tools/SentryLogBatcher.swift | 2 ++ Tests/SentryTests/SentryClientTests.swift | 27 +++++++++++++++++++ Tests/SentryTests/SentryHubTests.swift | 11 +++----- .../SentryTests/SentrySDKInternalTests.swift | 27 ++----------------- Tests/SentryTests/SentrySDKTests.swift | 22 --------------- 6 files changed, 36 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 272d1dc579d..b2e82364a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,11 +38,11 @@ - Add SentryDistribution as Swift Package Manager target (#6149) - Moves SentryClient and SentryHub to be written in Swift (#6627) - Add option `enablePropagateTraceparent` to support OTel/W3C trace propagation (#6356) -- Structured Logs: Add `captureLog` to `Hub` and `Client` (#6518) - Move `enableFileManagerSwizzling` from experimental options to top-level options (#6592). This option is still disabled by default and will be enabled in a future major release. - Move `enableDataSwizzling` from experimental options to top-level options (#6592). This option remains enabled by default. - Add `sentry.replay_id` attribute to logs ([#6515](https://github.com/getsentry/sentry-cocoa/pull/6515)) +- Structured Logs: Add log APIs to `Hub` and `Client` (#6518) ### Fixes diff --git a/Sources/Swift/Tools/SentryLogBatcher.swift b/Sources/Swift/Tools/SentryLogBatcher.swift index 1acf1b972db..789c1db9cdd 100644 --- a/Sources/Swift/Tools/SentryLogBatcher.swift +++ b/Sources/Swift/Tools/SentryLogBatcher.swift @@ -26,6 +26,7 @@ import Foundation /// Convenience initializer with default flush timeout and buffer size. /// - Parameters: + /// - options: The Sentry configuration options /// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state /// /// - Important: The `dispatchQueue` parameter MUST be a serial queue to ensure thread safety. @@ -41,6 +42,7 @@ import Foundation /// Initializes a new SentryLogBatcher. /// - Parameters: + /// - options: The Sentry configuration options /// - flushTimeout: The timeout interval after which buffered logs will be flushed /// - maxBufferSizeBytes: The maximum buffer size in bytes before triggering an immediate flush /// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index 0325563383f..e95f2245ebd 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -2199,6 +2199,26 @@ class SentryClientTests: XCTestCase { XCTAssertEqual(testBatcher.addLogInvocations.first?.log.level, .info) } + func testFlushCallsLogBatcherCaptureLogs() { + let sut = fixture.getSut() + + // Create a test batcher to verify captureLogs is called + let testBatcher = TestLogBatcherForClient( + options: sut.options, + dispatchQueue: TestSentryDispatchQueueWrapper() + ) + Dynamic(sut).logBatcher = testBatcher + + // Verify initial state + XCTAssertEqual(testBatcher.captureLogsInvocations.count, 0) + + // Call flush - this should trigger the log batcher to capture logs + sut.flush(timeout: 1.0) + + // Verify that captureLogs was called on the log batcher + XCTAssertEqual(testBatcher.captureLogsInvocations.count, 1) + } + func testCaptureSentryWrappedException() throws { #if os(macOS) let exception = NSException(name: NSExceptionName("exception"), reason: "reason", userInfo: nil) @@ -2407,10 +2427,17 @@ private extension SentryClientTests { final class TestLogBatcherForClient: SentryLogBatcher { var addLogInvocations = Invocations<(log: SentryLog, scope: Scope)>() + var captureLogsInvocations = Invocations() override func addLog(_ log: SentryLog, scope: Scope) { addLogInvocations.record((log, scope)) } + + @discardableResult + override func captureLogs() -> TimeInterval { + captureLogsInvocations.record(()) + return super.captureLogs() + } } enum SentryClientError: Error { diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index 3b058e56541..e40dd9f1bca 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -15,7 +15,6 @@ class SentryHubTests: XCTestCase { let crumb = Breadcrumb(level: .error, category: "default") let scope = Scope() let message = "some message" - let log = SentryLog(level: .info, body: "Test log message") let event: Event let currentDateProvider = TestCurrentDateProvider() let sentryCrashWrapper = TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo) @@ -667,12 +666,11 @@ class SentryHubTests: XCTestCase { func testCaptureLog() { let hub = fixture.getSut(fixture.options, fixture.scope) - (hub._swiftLogger as! SentryLogger).info(fixture.log.body) + (hub._swiftLogger as! SentryLogger).info("Test log message") XCTAssertEqual(1, fixture.client.captureLogInvocations.count) if let logArguments = fixture.client.captureLogInvocations.first { - XCTAssertEqual(fixture.log.body, logArguments.log.body) - XCTAssertEqual(fixture.log.level, logArguments.log.level) + XCTAssertEqual("Test log message", logArguments.log.body) XCTAssertEqual(fixture.scope, logArguments.scope) } } @@ -681,12 +679,11 @@ class SentryHubTests: XCTestCase { let scope = Scope() // Note: logger uses hub's scope, so we create a new hub with the specific scope let hubWithScope = fixture.getSut(fixture.options, scope) - (hubWithScope._swiftLogger as! SentryLogger).info(fixture.log.body) + (hubWithScope._swiftLogger as! SentryLogger).info("Test log message") XCTAssertEqual(1, fixture.client.captureLogInvocations.count) if let logArguments = fixture.client.captureLogInvocations.first { - XCTAssertEqual(fixture.log.body, logArguments.log.body) - XCTAssertEqual(fixture.log.level, logArguments.log.level) + XCTAssertEqual("Test log message", logArguments.log.body) XCTAssertEqual(scope, logArguments.scope) } } diff --git a/Tests/SentryTests/SentrySDKInternalTests.swift b/Tests/SentryTests/SentrySDKInternalTests.swift index ac02bfc3fd3..3f4b80cb863 100644 --- a/Tests/SentryTests/SentrySDKInternalTests.swift +++ b/Tests/SentryTests/SentrySDKInternalTests.swift @@ -625,29 +625,7 @@ class SentrySDKInternalTests: XCTestCase { XCTAssertIdentical(logger1, logger2) } - func testClose_ResetsLogger() { - givenSdkWithHub() - - // Get logger instance - let logger1 = SentrySDK.logger - XCTAssertNotNil(logger1) - - // Close SDK - SentrySDK.close() - - // Start SDK again - givenSdkWithHub() - - // Get logger instance again - let logger2 = SentrySDK.logger - XCTAssertNotNil(logger2) - - // Should be a different instance - XCTAssertNotIdentical(logger1, logger2) - } - - func testLogger_WithLogsEnabled_CapturesLog() { - fixture.client.options.enableLogs = true + func testLogger_WithClient_CapturesLog() { givenSdkWithHub() SentrySDK.logger.error(String(repeating: "S", count: 1_024 * 1_024)) @@ -657,7 +635,6 @@ class SentrySDKInternalTests: XCTestCase { } func testLogger_WithNoClient_DoesNotCaptureLog() { - fixture.client.options.enableLogs = true let hubWithoutClient = SentryHubInternal(client: nil, andScope: nil) SentrySDKInternal.setCurrentHub(hubWithoutClient) @@ -671,7 +648,7 @@ class SentrySDKInternalTests: XCTestCase { XCTAssertEqual(fixture.client.captureLogInvocations.count, 0) } - + func testFlush_CallsFlushCorrectlyOnTransport() throws { SentrySDK.start { options in options.dsn = SentrySDKInternalTests.dsnAsString diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index 70e9bfbc6d5..c7ce308a103 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -455,28 +455,6 @@ class SentrySDKTests: XCTestCase { // The log should still be captured XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) } - - func testLogger_RecreatedWhenSDKStartedAfterAccess() { - // Access logger before SDK is started - let loggerBeforeStart = SentrySDK.logger - - // Now properly start the SDK using internal APIs - fixture.client.options.enableLogs = true - SentrySDKInternal.setCurrentHub(fixture.hub) - SentrySDKInternal.setStart(with: fixture.client.options) - - // Access logger again after SDK is started - let loggerAfterStart = SentrySDK.logger - - // Verify it's a different instance (recreated) - XCTAssertNotIdentical(loggerBeforeStart, loggerAfterStart) - - // Verify the new logger can actually capture logs - loggerAfterStart.info("Test log message") - - // Verify log was captured - XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) - } } extension SentrySDKTests { From 11f2254637de7394972154e579a76b1f8da0a16e Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 09:59:50 +0100 Subject: [PATCH 23/32] remove mention of logs in beta --- Sources/Swift/Tools/SentryLogger.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/Swift/Tools/SentryLogger.swift b/Sources/Swift/Tools/SentryLogger.swift index e692bb2d2fd..6a97d86388b 100644 --- a/Sources/Swift/Tools/SentryLogger.swift +++ b/Sources/Swift/Tools/SentryLogger.swift @@ -7,8 +7,6 @@ import Foundation func capture(log: SentryLog) } -/// **EXPERIMENTAL** - A structured logging API for Sentry. -/// /// `SentryLogger` provides a structured logging interface that captures log entries /// and sends them to Sentry. Supports multiple log levels (trace, debug, info, warn, /// error, fatal) and allows attaching arbitrary attributes for enhanced context. @@ -18,9 +16,6 @@ import Foundation /// - `Float` (converted to `Double`) /// - Other types (converted to string) /// -/// - Note: Sentry Logs is currently in Beta. See the [Sentry Logs Documentation](https://docs.sentry.io/product/explore/logs/). -/// - Warning: This API is experimental and subject to change without notice. -/// /// ## Usage /// ```swift /// let logger = SentrySDK.logger From ddc8ad71ef18bbb91d7f8442e9dce87250c5eb1b Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 10:03:45 +0100 Subject: [PATCH 24/32] use _swiftLogger in hub swift --- Sources/Swift/SentryHub.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Swift/SentryHub.swift b/Sources/Swift/SentryHub.swift index b0639b012a3..7ef036bf52c 100644 --- a/Sources/Swift/SentryHub.swift +++ b/Sources/Swift/SentryHub.swift @@ -193,9 +193,9 @@ import Foundation /// Returns the logger associated with this Hub. @objc public var logger: SentryLogger { - // Temp until we figure out how to access the logger from the helper + // We know the type so it's fine to force cast. // swiftlint:disable force_cast - return self.helper.value(forKey: "logger") as! SentryLogger + return self.helper._swiftLogger as! SentryLogger // swiftlint:enable force_cast } From 1ee870f0ed890b179dac713874c57b06827e2ce9 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 10:15:31 +0100 Subject: [PATCH 25/32] cleanup --- .../Helper/SentryLogSPMTests.swift | 65 +++++++++---------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/Tests/SentryTests/Helper/SentryLogSPMTests.swift b/Tests/SentryTests/Helper/SentryLogSPMTests.swift index 7765be95d5e..784702fccee 100644 --- a/Tests/SentryTests/Helper/SentryLogSPMTests.swift +++ b/Tests/SentryTests/Helper/SentryLogSPMTests.swift @@ -2,46 +2,16 @@ @_spi(Private) import SentryTestUtils import XCTest -/// Tests for SPM log capture workarounds using dynamic dispatch. -/// These tests verify that the Objective-C methods can be called via perform(Selector:with:), -/// which is what the SPM extensions (SentryLob+SPM.swift) do internally. +/// Tests for SPM log workarounds using dynamic dispatch. final class SentryLogSPMTests: XCTestCase { private class Fixture { - let hub: TestHub - let client: TestClient - let dateProvider: TestCurrentDateProvider let options: Options - let scope: Scope - let logOutput: TestLogOutput - var oldDebug: Bool! - var oldLevel: SentryLevel! - var oldOutput: SentryLogOutput! init() { options = Options() options.dsn = TestConstants.dsnAsString(username: "SentryLogSPMTests") options.enableLogs = true - - client = TestClient(options: options)! - scope = Scope() - hub = TestHub(client: client, andScope: scope) - dateProvider = TestCurrentDateProvider() - - dateProvider.setDate(date: Date(timeIntervalSince1970: 1_627_846_800.123456)) - - // Set up log capture for testing error messages - oldDebug = SentrySDKLog.isDebug - oldLevel = SentrySDKLog.diagnosticLevel - oldOutput = SentrySDKLog.getOutput() - logOutput = TestLogOutput() - SentrySDKLog.setLogOutput(logOutput) - SentrySDKLogSupport.configure(true, diagnosticLevel: .error) - } - - func tearDown() { - SentrySDKLogSupport.configure(oldDebug, diagnosticLevel: oldLevel) - SentrySDKLog.setOutput(oldOutput) } } @@ -54,8 +24,6 @@ final class SentryLogSPMTests: XCTestCase { override func tearDown() { super.tearDown() - fixture.tearDown() - clearTestState() } // MARK: - SentryOptions Tests @@ -153,4 +121,35 @@ final class SentryLogSPMTests: XCTestCase { XCTAssertNil(fixture.options.value(forKey: "beforeSendLogDynamic")) } + + func testOptions_BeforeSendLog_ViaKVC_DirectProperty() { + // Test if we can use "beforeSendLog" directly via KVC instead of "beforeSendLogDynamic" + // This would work in non-SPM builds but not in SPM builds where the property isn't declared + let callback: (SentryLog) -> SentryLog? = { log in + let modifiedLog = log + modifiedLog.body = "Modified: \(log.body)" + return modifiedLog + } + + // Try to set using "beforeSendLog" directly + fixture.options.setValue(callback, forKey: "beforeSendLog") + + // Try to get using "beforeSendLog" directly + let retrievedCallback = fixture.options.value(forKey: "beforeSendLog") as? (SentryLog) -> SentryLog? + + // In SPM builds, this will be nil because the property doesn't exist + // In non-SPM builds, this should work because the property is auto-synthesized + #if SWIFT_PACKAGE + XCTAssertNil(retrievedCallback, "In SPM builds, 'beforeSendLog' property doesn't exist, so KVC should fail") + #else + XCTAssertNotNil(retrievedCallback, "In non-SPM builds, 'beforeSendLog' property exists and should work via KVC") + + if let retrievedCallback = retrievedCallback { + let originalLog = SentryLog(level: .info, body: "Original message") + let modifiedLog = retrievedCallback(originalLog) + XCTAssertNotNil(modifiedLog) + XCTAssertEqual(modifiedLog?.body, "Modified: Original message") + } + #endif + } } From 1fe6bb714fd8adda81b66788ad2b6571c23efa90 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 10:19:31 +0100 Subject: [PATCH 26/32] remove redundant test --- Tests/SentryTests/SentryHubTests.swift | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index e40dd9f1bca..db62f49ab56 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -675,19 +675,6 @@ class SentryHubTests: XCTestCase { } } - func testCaptureLogWithScope() { - let scope = Scope() - // Note: logger uses hub's scope, so we create a new hub with the specific scope - let hubWithScope = fixture.getSut(fixture.options, scope) - (hubWithScope._swiftLogger as! SentryLogger).info("Test log message") - - XCTAssertEqual(1, fixture.client.captureLogInvocations.count) - if let logArguments = fixture.client.captureLogInvocations.first { - XCTAssertEqual("Test log message", logArguments.log.body) - XCTAssertEqual(scope, logArguments.scope) - } - } - // MARK: - Replay Attributes Tests #if canImport(UIKit) && !SENTRY_NO_UIKIT From d7e46ca2c0748276dfb82fd0a2ad7824bcacf0ea Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 10:23:42 +0100 Subject: [PATCH 27/32] update public api --- sdk_api.json | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/sdk_api.json b/sdk_api.json index 3e72506f01e..feb3537e28d 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -41819,6 +41819,94 @@ } ] }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(level:body:)", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryLog", + "printedName": "Sentry.SentryLog", + "usr": "c:@M@Sentry@objc(cs)SentryLog" + }, + { + "kind": "TypeNominal", + "name": "Level", + "printedName": "Sentry.SentryLog.Level", + "usr": "s:6Sentry0A3LogC5LevelO" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Constructor", + "usr": "c:@M@Sentry@objc(cs)SentryLog(im)initWithLevel:body:", + "mangledName": "$s6Sentry0A3LogC5level4bodyA2C5LevelO_SStcfc", + "moduleName": "Sentry", + "objc_name": "initWithLevel:body:", + "declAttributes": [ + "ObjC" + ], + "init_kind": "Convenience" + }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(level:body:attributes:)", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryLog", + "printedName": "Sentry.SentryLog", + "usr": "c:@M@Sentry@objc(cs)SentryLog" + }, + { + "kind": "TypeNominal", + "name": "Level", + "printedName": "Sentry.SentryLog.Level", + "usr": "s:6Sentry0A3LogC5LevelO" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "Dictionary", + "printedName": "[Swift.String : Sentry.SentryLog.Attribute]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "Attribute", + "printedName": "Sentry.SentryLog.Attribute", + "usr": "s:6Sentry0A3LogC9AttributeC" + } + ], + "usr": "s:SD" + } + ], + "declKind": "Constructor", + "usr": "c:@M@Sentry@objc(cs)SentryLog(im)initWithLevel:body:attributes:", + "mangledName": "$s6Sentry0A3LogC5level4body10attributesA2C5LevelO_SSSDySSAC9AttributeCGtcfc", + "moduleName": "Sentry", + "objc_name": "initWithLevel:body:attributes:", + "declAttributes": [ + "ObjC" + ], + "init_kind": "Convenience" + }, { "kind": "TypeDecl", "name": "Level", @@ -43431,7 +43519,6 @@ "ObjC" ], "superclassUsr": "c:objc(cs)NSObject", - "hasMissingDesignatedInitializers": true, "inheritsConvenienceInitializers": true, "superclassNames": [ "ObjectiveC.NSObject" @@ -50550,6 +50637,51 @@ } ] }, + { + "kind": "Var", + "name": "logger", + "printedName": "logger", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryLogger", + "printedName": "Sentry.SentryLogger", + "usr": "c:@M@Sentry@objc(cs)SentryLogger" + } + ], + "declKind": "Var", + "usr": "c:@M@Sentry@objc(cs)SentryHub(py)logger", + "mangledName": "$s6Sentry0A3HubC6loggerAA0A6LoggerCvp", + "moduleName": "Sentry", + "declAttributes": [ + "Final", + "ObjC" + ], + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryLogger", + "printedName": "Sentry.SentryLogger", + "usr": "c:@M@Sentry@objc(cs)SentryLogger" + } + ], + "declKind": "Accessor", + "usr": "c:@M@Sentry@objc(cs)SentryHub(im)logger", + "mangledName": "$s6Sentry0A3HubC6loggerAA0A6LoggerCvg", + "moduleName": "Sentry", + "declAttributes": [ + "Final", + "ObjC" + ], + "accessorKind": "get" + } + ] + }, { "kind": "Function", "name": "bindClient", @@ -54833,6 +54965,40 @@ ], "funcSelfKind": "NonMutating" }, + { + "kind": "Function", + "name": "capture", + "printedName": "capture(log:scope:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "SentryLog", + "printedName": "Sentry.SentryLog", + "usr": "c:@M@Sentry@objc(cs)SentryLog" + }, + { + "kind": "TypeNominal", + "name": "Scope", + "printedName": "Sentry.Scope", + "usr": "c:objc(cs)SentryScope" + } + ], + "declKind": "Func", + "usr": "c:@M@Sentry@objc(cs)SentryClient(im)captureLog:withScope:", + "mangledName": "$s6Sentry0A6ClientC7capture3log5scopeyAA0A3LogC_So0A5ScopeCtF", + "moduleName": "Sentry", + "objc_name": "captureLog:withScope:", + "declAttributes": [ + "Final", + "ObjC" + ], + "funcSelfKind": "NonMutating" + }, { "kind": "Function", "name": "flush", From 75d39960df526469e3ca3a3b786ac9639e429197 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 16:45:28 +0100 Subject: [PATCH 28/32] Add possibility to mutate log attributes from ObjC, Add heleper methods/.property to +Private header --- Sources/Sentry/SentryHub.m | 17 +++++------ Sources/Sentry/include/SentryClient+Private.h | 4 +++ Sources/Sentry/include/SentryClient.h | 4 --- Sources/Sentry/include/SentryHub+Private.h | 4 +++ Sources/Sentry/include/SentryHub.h | 4 --- Sources/Swift/Protocol/SentryLog.swift | 12 ++++++++ .../SentryTests/Protocol/SentryLogTests.swift | 28 +++++++++++++++++++ 7 files changed, 55 insertions(+), 18 deletions(-) diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index c3e885189e9..99601691e21 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -844,25 +844,22 @@ - (void)captureLog:(SentryLog *)log SentryClientInternal *client = self.client; if (client != nil) { #if SENTRY_TARGET_REPLAY_SUPPORTED - NSMutableDictionary *mutableAttributes = - [log.attributes mutableCopy]; - NSString *scopeReplayId = self.scope.replayId; if (scopeReplayId != nil) { // Session mode: use scope replay ID - mutableAttributes[@"sentry.replay_id"] = - [[SentryStructuredLogAttribute alloc] initWithString:scopeReplayId]; + [log setAttribute:[[SentryStructuredLogAttribute alloc] initWithString:scopeReplayId] + forKey:@"sentry.replay_id"]; } else { // Buffer mode: check if hub has a session replay ID NSString *sessionReplayId = [self getSessionReplayId]; if (sessionReplayId != nil) { - mutableAttributes[@"sentry.replay_id"] = - [[SentryStructuredLogAttribute alloc] initWithString:sessionReplayId]; - mutableAttributes[@"sentry._internal.replay_is_buffering"] = - [[SentryStructuredLogAttribute alloc] initWithBoolean:YES]; + [log setAttribute:[[SentryStructuredLogAttribute alloc] + initWithString:sessionReplayId] + forKey:@"sentry.replay_id"]; + [log setAttribute:[[SentryStructuredLogAttribute alloc] initWithBoolean:YES] + forKey:@"sentry._internal.replay_is_buffering"]; } } - log.attributes = mutableAttributes; #endif [client _swiftCaptureLog:log withScope:self.scope]; } diff --git a/Sources/Sentry/include/SentryClient+Private.h b/Sources/Sentry/include/SentryClient+Private.h index 50cb527a025..cb0f69aa892 100644 --- a/Sources/Sentry/include/SentryClient+Private.h +++ b/Sources/Sentry/include/SentryClient+Private.h @@ -78,6 +78,10 @@ NS_ASSUME_NONNULL_BEGIN - (void)addAttachmentProcessor:(id)attachmentProcessor; - (void)removeAttachmentProcessor:(id)attachmentProcessor; +// Do not use this directly, instead use the non-underscored `captureLog` method that is +// defined through a SentryClient.swift file. +- (void)_swiftCaptureLog:(NSObject *)log withScope:(SentryScope *)scope; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryClient.h b/Sources/Sentry/include/SentryClient.h index deb4b079ca1..b159358b4bb 100644 --- a/Sources/Sentry/include/SentryClient.h +++ b/Sources/Sentry/include/SentryClient.h @@ -101,10 +101,6 @@ SENTRY_NO_INIT - (void)captureFeedback:(SentryFeedback *)feedback withScope:(SentryScope *)scope NS_SWIFT_NAME(capture(feedback:scope:)); -// Do not use this directly, instead use the non-underscored `captureLog` method that is -// defined through a SentryClient.swift file. -- (void)_swiftCaptureLog:(NSObject *)log withScope:(SentryScope *)scope; - /** * Waits synchronously for the SDK to flush out all queued and cached items for up to the specified * timeout in seconds. If there is no internet connection, the function returns immediately. The SDK diff --git a/Sources/Sentry/include/SentryHub+Private.h b/Sources/Sentry/include/SentryHub+Private.h index c47e3c07dfc..d593dca3ee8 100644 --- a/Sources/Sentry/include/SentryHub+Private.h +++ b/Sources/Sentry/include/SentryHub+Private.h @@ -29,6 +29,10 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong) NSMutableArray> *installedIntegrations; +// Do not use this directly, instead use the non-underscored `logger` property that is +// defined through a SentryHub.swift file. +@property (nonatomic, readonly, strong) NSObject *_swiftLogger; + /** * Every integration starts with "Sentry" and ends with "Integration". To keep the payload of the * event small we remove both. diff --git a/Sources/Sentry/include/SentryHub.h b/Sources/Sentry/include/SentryHub.h index 5d9da570c38..c211ad2aead 100644 --- a/Sources/Sentry/include/SentryHub.h +++ b/Sources/Sentry/include/SentryHub.h @@ -198,10 +198,6 @@ SENTRY_NO_INIT */ @property (nonatomic, readonly, strong) SentryScope *scope; -// Do not use this directly, instead use the non-underscored `logger` property that is -// defined through a SentryHub.swift file. -@property (nonatomic, readonly, strong) NSObject *_swiftLogger; - /** * Binds a different client to the hub. */ diff --git a/Sources/Swift/Protocol/SentryLog.swift b/Sources/Swift/Protocol/SentryLog.swift index 8faddce91f2..6aad6a4f70d 100644 --- a/Sources/Swift/Protocol/SentryLog.swift +++ b/Sources/Swift/Protocol/SentryLog.swift @@ -69,6 +69,18 @@ public final class SentryLog: NSObject { self.severityNumber = severityNumber ?? NSNumber(value: level.toSeverityNumber()) super.init() } + + /// Adds or updates an attribute in the log entry. + /// - Parameters: + /// - attribute: The attribute value to add + /// - key: The key for the attribute + @objc public func setAttribute(_ attribute: Attribute?, forKey key: String) { + if let attribute = attribute { + attributes[key] = attribute + } else { + attributes.removeValue(forKey: key) + } + } } // MARK: - Internal Codable Support diff --git a/Tests/SentryTests/Protocol/SentryLogTests.swift b/Tests/SentryTests/Protocol/SentryLogTests.swift index d7eee96e121..1aff2dd6a7c 100644 --- a/Tests/SentryTests/Protocol/SentryLogTests.swift +++ b/Tests/SentryTests/Protocol/SentryLogTests.swift @@ -173,4 +173,32 @@ final class SentryLogTests: XCTestCase { let decodedScoreValue = try XCTUnwrap(decoded.attributes["score"]?.value as? Double) XCTAssertEqual(decodedScoreValue, 3.14159, accuracy: 0.00001) } + + // MARK: - setAttribute Tests + + func testSetAttribute_AddsUpdatedsRemovesAtribute() { + let log = SentryLog( + level: .info, + body: "Test message", + attributes: [:] + ) + + log.setAttribute(SentryLog.Attribute(string: "test_value"), forKey: "test_key") + + XCTAssertEqual(log.attributes.count, 1) + XCTAssertEqual(log.attributes["test_key"]?.type, "string") + XCTAssertEqual(log.attributes["test_key"]?.value as? String, "test_value") + + log.setAttribute(SentryLog.Attribute(string: "test_value_2"), forKey: "test_key") + + XCTAssertEqual(log.attributes.count, 1) + XCTAssertEqual(log.attributes["test_key"]?.type, "string") + XCTAssertEqual(log.attributes["test_key"]?.value as? String, "test_value_2") + + log.setAttribute(nil, forKey: "test_key") + + XCTAssertEqual(log.attributes.count, 0) + XCTAssertNil(log.attributes["test_key"]) + } + } From c6625227d26bb7e28f56d137cf808518fefad7f7 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 16:52:40 +0100 Subject: [PATCH 29/32] Move log abtcher delegate to ctor and make it private --- Sources/Sentry/SentryClient.m | 4 ++-- Sources/Swift/Tools/SentryLogBatcher.swift | 17 +++++++++++++---- Sources/Swift/Tools/SentryLogger.swift | 1 + Tests/SentryTests/SentryClientTests.swift | 6 ++++-- Tests/SentryTests/SentryLogBatcherTests.swift | 12 ++++++------ 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index 71b824b7585..ccff66043a8 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -116,8 +116,8 @@ - (instancetype)initWithOptions:(SentryOptions *)options self.attachmentProcessors = [[NSMutableArray alloc] init]; self.logBatcher = [[SentryLogBatcher alloc] initWithOptions:options - dispatchQueue:SentryDependencyContainer.sharedInstance.dispatchQueueWrapper]; - self.logBatcher.delegate = self; + dispatchQueue:SentryDependencyContainer.sharedInstance.dispatchQueueWrapper + delegate:self]; // The SDK stores the installationID in a file. The first call requires file IO. To avoid // executing this on the main thread, we cache the installationID async here. diff --git a/Sources/Swift/Tools/SentryLogBatcher.swift b/Sources/Swift/Tools/SentryLogBatcher.swift index 789c1db9cdd..5d88077028d 100644 --- a/Sources/Swift/Tools/SentryLogBatcher.swift +++ b/Sources/Swift/Tools/SentryLogBatcher.swift @@ -22,21 +22,27 @@ import Foundation private var encodedLogsSize: Int = 0 private var timerWorkItem: DispatchWorkItem? - public weak var delegate: SentryLogBatcherDelegate? + private weak var delegate: SentryLogBatcherDelegate? /// Convenience initializer with default flush timeout and buffer size. /// - Parameters: /// - options: The Sentry configuration options /// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state + /// - delegate: The delegate to handle captured log batches /// /// - Important: The `dispatchQueue` parameter MUST be a serial queue to ensure thread safety. /// Passing a concurrent queue will result in undefined behavior and potential data races. - @_spi(Private) public convenience init(options: Options, dispatchQueue: SentryDispatchQueueWrapper) { + @_spi(Private) public convenience init( + options: Options, + dispatchQueue: SentryDispatchQueueWrapper, + delegate: SentryLogBatcherDelegate? + ) { self.init( options: options, flushTimeout: 5, maxBufferSizeBytes: 1_024 * 1_024, // 1MB - dispatchQueue: dispatchQueue + dispatchQueue: dispatchQueue, + delegate: delegate ) } @@ -46,6 +52,7 @@ import Foundation /// - flushTimeout: The timeout interval after which buffered logs will be flushed /// - maxBufferSizeBytes: The maximum buffer size in bytes before triggering an immediate flush /// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state + /// - delegate: The delegate to handle captured log batches /// /// - Important: The `dispatchQueue` parameter MUST be a serial queue to ensure thread safety. /// Passing a concurrent queue will result in undefined behavior and potential data races. @@ -53,12 +60,14 @@ import Foundation options: Options, flushTimeout: TimeInterval, maxBufferSizeBytes: Int, - dispatchQueue: SentryDispatchQueueWrapper + dispatchQueue: SentryDispatchQueueWrapper, + delegate: SentryLogBatcherDelegate? ) { self.options = options self.flushTimeout = flushTimeout self.maxBufferSizeBytes = maxBufferSizeBytes self.dispatchQueue = dispatchQueue + self.delegate = delegate super.init() } diff --git a/Sources/Swift/Tools/SentryLogger.swift b/Sources/Swift/Tools/SentryLogger.swift index 6a97d86388b..33c72fab024 100644 --- a/Sources/Swift/Tools/SentryLogger.swift +++ b/Sources/Swift/Tools/SentryLogger.swift @@ -169,6 +169,7 @@ public final class SentryLogger: NSObject { private func captureLog(level: SentryLog.Level, logMessage: SentryLogMessage, attributes: [String: Any]) { guard let delegate else { + SentrySDKLog.warning("No delegate set for SentryLogger, skipping log capture.") return } // Convert provided attributes to SentryLog.Attribute format diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index e95f2245ebd..d679eabe9de 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -2178,7 +2178,8 @@ class SentryClientTests: XCTestCase { // Create a test batcher to verify addLog is called let testBatcher = TestLogBatcherForClient( options: sut.options, - dispatchQueue: TestSentryDispatchQueueWrapper() + dispatchQueue: TestSentryDispatchQueueWrapper(), + delegate: nil ) Dynamic(sut).logBatcher = testBatcher @@ -2205,7 +2206,8 @@ class SentryClientTests: XCTestCase { // Create a test batcher to verify captureLogs is called let testBatcher = TestLogBatcherForClient( options: sut.options, - dispatchQueue: TestSentryDispatchQueueWrapper() + dispatchQueue: TestSentryDispatchQueueWrapper(), + delegate: nil ) Dynamic(sut).logBatcher = testBatcher diff --git a/Tests/SentryTests/SentryLogBatcherTests.swift b/Tests/SentryTests/SentryLogBatcherTests.swift index f1e9dbe6750..33bbe15488f 100644 --- a/Tests/SentryTests/SentryLogBatcherTests.swift +++ b/Tests/SentryTests/SentryLogBatcherTests.swift @@ -25,9 +25,9 @@ final class SentryLogBatcherTests: XCTestCase { options: options, flushTimeout: 0.1, // Very small timeout for testing maxBufferSizeBytes: 800, // byte limit for testing (log with attributes ~390 bytes) - dispatchQueue: testDispatchQueue + dispatchQueue: testDispatchQueue, + delegate: testDelegate ) - sut.delegate = testDelegate scope = Scope() } @@ -245,9 +245,9 @@ final class SentryLogBatcherTests: XCTestCase { options: options, flushTimeout: 5, maxBufferSizeBytes: 10_000, // Large buffer to avoid immediate flushes - dispatchQueue: SentryDispatchQueueWrapper() // Real dispatch queue + dispatchQueue: SentryDispatchQueueWrapper(), // Real dispatch queue + delegate: testDelegate ) - sutWithRealQueue.delegate = testDelegate let expectation = XCTestExpectation(description: "Concurrent adds") expectation.expectedFulfillmentCount = 10 @@ -276,9 +276,9 @@ final class SentryLogBatcherTests: XCTestCase { options: options, flushTimeout: 0.2, // Short but realistic timeout maxBufferSizeBytes: 10_000, // Large buffer to avoid size-based flush - dispatchQueue: SentryDispatchQueueWrapper() // Real dispatch queue + dispatchQueue: SentryDispatchQueueWrapper(), // Real dispatch queue + delegate: testDelegate ) - sutWithRealQueue.delegate = testDelegate let log = createTestLog(body: "Real timeout test log") let expectation = XCTestExpectation(description: "Real timeout flush") From a8766e9a58c4a3c2dd6ce4b979fd7e80beac3921 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 16:56:46 +0100 Subject: [PATCH 30/32] make teh delegate non-nil --- Sources/Swift/Tools/SentryLogBatcher.swift | 11 ++++++++--- Tests/SentryTests/SentryClientTests.swift | 12 ++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Sources/Swift/Tools/SentryLogBatcher.swift b/Sources/Swift/Tools/SentryLogBatcher.swift index 5d88077028d..ead26be4e86 100644 --- a/Sources/Swift/Tools/SentryLogBatcher.swift +++ b/Sources/Swift/Tools/SentryLogBatcher.swift @@ -35,7 +35,7 @@ import Foundation @_spi(Private) public convenience init( options: Options, dispatchQueue: SentryDispatchQueueWrapper, - delegate: SentryLogBatcherDelegate? + delegate: SentryLogBatcherDelegate ) { self.init( options: options, @@ -61,7 +61,7 @@ import Foundation flushTimeout: TimeInterval, maxBufferSizeBytes: Int, dispatchQueue: SentryDispatchQueueWrapper, - delegate: SentryLogBatcherDelegate? + delegate: SentryLogBatcherDelegate ) { self.options = options self.flushTimeout = flushTimeout @@ -240,6 +240,11 @@ import Foundation payloadData.append(Data("]}".utf8)) // Send the payload. - delegate?.capture(logsData: payloadData as NSData, count: NSNumber(value: encodedLogs.count)) + + if let delegate { + delegate.capture(logsData: payloadData as NSData, count: NSNumber(value: encodedLogs.count)) + } else { + SentrySDKLog.debug("SentryLogBatcher: Delegate not set, not capturing logs data.") + } } } diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index d679eabe9de..24ff66c7595 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -2176,10 +2176,11 @@ class SentryClientTests: XCTestCase { let sut = fixture.getSut() // Create a test batcher to verify addLog is called + let testDelegate = TestLogBatcherDelegateForClient() let testBatcher = TestLogBatcherForClient( options: sut.options, dispatchQueue: TestSentryDispatchQueueWrapper(), - delegate: nil + delegate: testDelegate ) Dynamic(sut).logBatcher = testBatcher @@ -2204,10 +2205,11 @@ class SentryClientTests: XCTestCase { let sut = fixture.getSut() // Create a test batcher to verify captureLogs is called + let testDelegate = TestLogBatcherDelegateForClient() let testBatcher = TestLogBatcherForClient( options: sut.options, dispatchQueue: TestSentryDispatchQueueWrapper(), - delegate: nil + delegate: testDelegate ) Dynamic(sut).logBatcher = testBatcher @@ -2442,6 +2444,12 @@ final class TestLogBatcherForClient: SentryLogBatcher { } } +final class TestLogBatcherDelegateForClient: NSObject, SentryLogBatcherDelegate { + func capture(logsData: NSData, count: NSNumber) { + // No-op for tests that don't need to verify delegate calls + } +} + enum SentryClientError: Error { case someError case invalidInput(String) From c6c9bb6636f5520438c203667e094c7176e846cd Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 17:00:38 +0100 Subject: [PATCH 31/32] add comment --- Tests/SentryTests/SentryClientTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index 24ff66c7595..d7bd84ae2e1 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -2186,7 +2186,7 @@ class SentryClientTests: XCTestCase { let log = SentryLog( timestamp: Date(timeIntervalSince1970: 1_627_846_801), - traceId: SentryId.empty, + traceId: SentryId.empty, // Temporary set to empty until its assigned by the batcher. level: .info, body: "Test log message", attributes: [:] From c811a521870e434abebeff59c31617acbb92413f Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 4 Nov 2025 17:24:49 +0100 Subject: [PATCH 32/32] update public api --- sdk_api.json | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/sdk_api.json b/sdk_api.json index 9829985bb4a..e2a19f8744e 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -41874,6 +41874,47 @@ ], "init_kind": "Convenience" }, + { + "kind": "Function", + "name": "setAttribute", + "printedName": "setAttribute(_:forKey:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Sentry.SentryLog.Attribute?", + "children": [ + { + "kind": "TypeNominal", + "name": "Attribute", + "printedName": "Sentry.SentryLog.Attribute", + "usr": "s:6Sentry0A3LogC9AttributeC" + } + ], + "usr": "s:Sq" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Func", + "usr": "c:@M@Sentry@objc(cs)SentryLog(im)setAttribute:forKey:", + "mangledName": "$s6Sentry0A3LogC12setAttribute_6forKeyyAC0D0CSg_SStF", + "moduleName": "Sentry", + "declAttributes": [ + "Final", + "ObjC" + ], + "funcSelfKind": "NonMutating" + }, { "kind": "TypeDecl", "name": "Level",