From 455e6403e49f1840b37c475b7e1bfa2db536d8e2 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Thu, 23 Oct 2025 17:07:33 +0200 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] =?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/13] 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() {