diff --git a/CHANGELOG.md b/CHANGELOG.md index f1c8eee2d6..8e08c57931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ This option is still disabled by default and will be enabled in a future major release. - Move `enableDataSwizzling` from experimental options to top-level options (#6592). This option remains enabled by default. - Add `sentry.replay_id` attribute to logs ([#6515](https://github.com/getsentry/sentry-cocoa/pull/6515)) +- Structured Logs: Add log APIs to `Hub` and `Client` (#6518) ### Fixes diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 868e262da8..a888729858 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -718,6 +718,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, ); }; }; @@ -732,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 */; }; @@ -2082,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 = ""; }; @@ -2095,7 +2098,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 = ""; }; @@ -2694,6 +2696,7 @@ 84B0E0062CD963F9007FB332 /* SentryIconography.swift */, 621F61F02BEA073A005E654F /* SentryEnabledFeaturesBuilder.swift */, F4FE9DFB2E622CD70014FED5 /* SentryDefaultObjCRuntimeWrapper.swift */, + 92622E132EABBDA900ABE7FF /* SentryLog+SPM.swift */, F4FE9DFC2E622CD70014FED5 /* SentryObjCRuntimeWrapper.swift */, ); path = Helper; @@ -3614,6 +3617,7 @@ F4A930242E661856006DA6EF /* SentryMobileProvisionParserTests.swift */, D4F7BD7C2E4373BB004A2D77 /* SentryLevelMapperTests.swift */, D8AE48BE2C578D540092A2A6 /* SentrySDKLog.swift */, + 92622E082EABB71000ABE7FF /* SentryLogSPMTests.swift */, 849AC3FF29E0C1FF00889C16 /* SentryFormatterTests.swift */, 7B88F30324BC8E6500ADF90A /* SentrySerializationTests.swift */, 62F4DDA02C04CB9700588890 /* SentryBaggageSerializationTests.swift */, @@ -5224,7 +5228,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 */, @@ -6101,6 +6104,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 */, @@ -6210,6 +6214,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/SentryTestUtils/Sources/TestClient.swift b/SentryTestUtils/Sources/TestClient.swift index bb76f14e26..8a91e6cee8 100644 --- a/SentryTestUtils/Sources/TestClient.swift +++ b/SentryTestUtils/Sources/TestClient.swift @@ -161,8 +161,10 @@ public class TestClient: SentryClientInternal { 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 _swiftCaptureLog(_ log: NSObject, with scope: Scope) { + if let castLog = log as? SentryLog { + captureLogInvocations.record((castLog, scope)) + } } } diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index a7a9b46f52..ccff66043a 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -44,13 +44,14 @@ NS_ASSUME_NONNULL_BEGIN -@interface SentryClientInternal () +@interface SentryClientInternal () @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 @@ -113,6 +114,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 + 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. @@ -618,7 +623,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 in-memory logs, until we'll write batched logs to disk, + // to avoid data loss due to crashes. This is a trade-off until then. + [self.transportAdapter flush:fmax(timeout / 2, timeout - captureLogsDuration)]; } - (void)close @@ -1088,7 +1097,14 @@ - (void)removeAttachmentProcessor:(id)attachmen return processedAttachments; } -- (void)captureLogsData:(NSData *)data with:(NSNumber *)itemCount; +- (void)_swiftCaptureLog:(NSObject *)log withScope:(SentryScope *)scope +{ + if ([log isKindOfClass:[SentryLog class]]) { + [self.logBatcher addLog:(SentryLog *)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 bdb50f5292..99601691e2 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -26,7 +26,7 @@ NS_ASSUME_NONNULL_BEGIN -@interface SentryHubInternal () +@interface SentryHubInternal () @property (nullable, atomic, strong) SentryClientInternal *client; @property (nullable, nonatomic, strong) SentryScope *scope; @@ -73,6 +73,10 @@ - (instancetype)initWithClient:(nullable SentryClientInternal *)client if (_scope) { [_crashWrapper enrichScope:SENTRY_UNWRAP_NULLABLE(SentryScope, _scope)]; } + + __swiftLogger = [[SentryLogger alloc] + initWithDelegate:self + dateProvider:SentryDependencyContainer.sharedInstance.dateProvider]; } return self; @@ -833,6 +837,34 @@ - (void)unregisterSessionListener:(id)listener } } +// SentryLoggerDelegate + +- (void)captureLog:(SentryLog *)log +{ + SentryClientInternal *client = self.client; + if (client != nil) { +#if SENTRY_TARGET_REPLAY_SUPPORTED + NSString *scopeReplayId = self.scope.replayId; + if (scopeReplayId != nil) { + // Session mode: use scope replay ID + [log setAttribute:[[SentryStructuredLogAttribute alloc] initWithString:scopeReplayId] + forKey:@"sentry.replay_id"]; + } else { + // Buffer mode: check if hub has a session replay ID + NSString *sessionReplayId = [self getSessionReplayId]; + if (sessionReplayId != nil) { + [log setAttribute:[[SentryStructuredLogAttribute alloc] + initWithString:sessionReplayId] + forKey:@"sentry.replay_id"]; + [log setAttribute:[[SentryStructuredLogAttribute alloc] initWithBoolean:YES] + forKey:@"sentry._internal.replay_is_buffering"]; + } + } +#endif + [client _swiftCaptureLog:log withScope:self.scope]; + } +} + #pragma mark - Protected - (NSArray *)trimmedInstalledIntegrationNames diff --git a/Sources/Sentry/SentrySDKInternal.m b/Sources/Sentry/SentrySDKInternal.m index 8c0cc79462..cd5861f104 100644 --- a/Sources/Sentry/SentrySDKInternal.m +++ b/Sources/Sentry/SentrySDKInternal.m @@ -633,8 +633,6 @@ + (void)close [SentrySDKInternal setCurrentHub:nil]; - [SentrySDK clearLogger]; - [SentryDependencyContainer.sharedInstance.crashWrapper stopBinaryImageCache]; [SentryDependencyContainer.sharedInstance.binaryImageCache stop]; diff --git a/Sources/Sentry/include/SentryClient+Private.h b/Sources/Sentry/include/SentryClient+Private.h index 50cb527a02..cb0f69aa89 100644 --- a/Sources/Sentry/include/SentryClient+Private.h +++ b/Sources/Sentry/include/SentryClient+Private.h @@ -78,6 +78,10 @@ NS_ASSUME_NONNULL_BEGIN - (void)addAttachmentProcessor:(id)attachmentProcessor; - (void)removeAttachmentProcessor:(id)attachmentProcessor; +// Do not use this directly, instead use the non-underscored `captureLog` method that is +// defined through a SentryClient.swift file. +- (void)_swiftCaptureLog:(NSObject *)log withScope:(SentryScope *)scope; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryHub+Private.h b/Sources/Sentry/include/SentryHub+Private.h index c47e3c07df..d593dca3ee 100644 --- a/Sources/Sentry/include/SentryHub+Private.h +++ b/Sources/Sentry/include/SentryHub+Private.h @@ -29,6 +29,10 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong) NSMutableArray> *installedIntegrations; +// Do not use this directly, instead use the non-underscored `logger` property that is +// defined through a SentryHub.swift file. +@property (nonatomic, readonly, strong) NSObject *_swiftLogger; + /** * Every integration starts with "Sentry" and ends with "Integration". To keep the payload of the * event small we remove both. diff --git a/Sources/Sentry/include/SentryPrivate.h b/Sources/Sentry/include/SentryPrivate.h index cce88841ba..9f9ac51ab4 100644 --- a/Sources/Sentry/include/SentryPrivate.h +++ b/Sources/Sentry/include/SentryPrivate.h @@ -28,7 +28,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/SentryLog+SPM.swift b/Sources/Swift/Helper/SentryLog+SPM.swift new file mode 100644 index 0000000000..ea7309841c --- /dev/null +++ b/Sources/Swift/Helper/SentryLog+SPM.swift @@ -0,0 +1,31 @@ +@_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, +// which causes Swift-to-Objective-C bridging issues. These extensions work around +// that limitation by providing Swift-native methods and properties that use dynamic +// dispatch internally. + +#if SWIFT_PACKAGE + +/** + * 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") } + } +} + +#endif // SWIFT_PACKAGE diff --git a/Sources/Swift/Helper/SentrySDK.swift b/Sources/Swift/Helper/SentrySDK.swift index 4c3c500401..427a5f281c 100644 --- a/Sources/Swift/Helper/SentrySDK.swift +++ b/Sources/Swift/Helper/SentrySDK.swift @@ -27,28 +27,13 @@ import Foundation /// API to access Sentry logs @objc public static var logger: SentryLogger { - return _loggerLock.synchronized { - let sdkEnabled = SentrySDKInternal.isEnabled - if !sdkEnabled { - SentrySDKLog.fatal("Logs called before SentrySDK.start() will be dropped.") - } - if let _logger, _loggerConfigured { - return _logger - } - let hub = SentrySDKInternal.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 - ) - _logger = logger - _loggerConfigured = sdkEnabled - return logger + if !SentrySDKInternal.isEnabled { + SentrySDKLog.fatal("Logs called before SentrySDK.start() will be dropped.") } + // We know the type so it's fine to force cast. + // swiftlint:disable force_cast + return SentrySDKInternal.currentHub()._swiftLogger as! SentryLogger + // swiftlint:enable force_cast } /// Inits and configures Sentry (`SentryHub`, `SentryClient`) and sets up all integrations. Make sure to @@ -360,18 +345,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() } @@ -412,32 +391,6 @@ import Foundation SentrySDKInternal.stopProfiler() } #endif - - // MARK: Internal - - /// - note: Conceptually internal but needs to be marked public with SPI for ObjC visibility - @objc @_spi(Private) public static func clearLogger() { - _loggerLock.synchronized { - _logger = nil - _loggerConfigured = false - } - } - - // MARK: Private - - private static var _loggerLock = NSLock() - private static var _logger: SentryLogger? - // Flag to re-create instance if accessed before SDK init. - private static var _loggerConfigured = false - - @discardableResult - private static func captureLogs() -> TimeInterval { - var duration: TimeInterval = 0.0 - _loggerLock.synchronized { - duration = _logger?.captureLogs() ?? 0.0 - } - return duration - } } // swiftlint:enable file_length diff --git a/Sources/Swift/Protocol/SentryLog.swift b/Sources/Swift/Protocol/SentryLog.swift index 33fc58e333..6aad6a4f70 100644 --- a/Sources/Swift/Protocol/SentryLog.swift +++ b/Sources/Swift/Protocol/SentryLog.swift @@ -6,7 +6,7 @@ public final class SentryLog: NSObject { /// The timestamp when the log event occurred public var timestamp: Date - /// The trace ID to associate this log with distributed tracing + /// The trace ID to associate this log with distributed tracing. This will be set to a valid non-empty value during processing. public var traceId: SentryId /// The severity level of the log entry public var level: Level @@ -17,6 +17,42 @@ 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 + ) { + self.init( + timestamp: Date(), + traceId: SentryId.empty, + level: level, + body: body, + attributes: [:] + ) + } + + /// 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, + attributes: [String: Attribute] + ) { + self.init( + timestamp: Date(), + traceId: SentryId.empty, + level: level, + body: body, + attributes: attributes + ) + } + internal init( timestamp: Date, traceId: SentryId, @@ -33,6 +69,18 @@ public final class SentryLog: NSObject { self.severityNumber = severityNumber ?? NSNumber(value: level.toSeverityNumber()) super.init() } + + /// Adds or updates an attribute in the log entry. + /// - Parameters: + /// - attribute: The attribute value to add + /// - key: The key for the attribute + @objc public func setAttribute(_ attribute: Attribute?, forKey key: String) { + if let attribute = attribute { + attributes[key] = attribute + } else { + attributes.removeValue(forKey: key) + } + } } // MARK: - Internal Codable Support diff --git a/Sources/Swift/SentryClient.swift b/Sources/Swift/SentryClient.swift index 8565f84b8f..d669e83751 100644 --- a/Sources/Swift/SentryClient.swift +++ b/Sources/Swift/SentryClient.swift @@ -103,6 +103,14 @@ import Foundation scope: scope) } + /// Captures a log entry and sends it to Sentry. + /// - Parameters: + /// - log: The log entry to send to Sentry. + /// - scope: The scope containing event metadata. + @objc(captureLog:withScope:) public func capture(log: SentryLog, scope: Scope) { + helper._swiftCaptureLog(log, with: scope) + } + /// Waits synchronously for the SDK to flush out all queued and cached items for up to the specified timeout in seconds. /// If there is no internet connection, the function returns immediately. The SDK doesn't dispose the client or the hub. /// - Parameter timeout: The time to wait for the SDK to complete the flush. diff --git a/Sources/Swift/SentryHub.swift b/Sources/Swift/SentryHub.swift index c29a124c2e..7ef036bf52 100644 --- a/Sources/Swift/SentryHub.swift +++ b/Sources/Swift/SentryHub.swift @@ -191,6 +191,14 @@ import Foundation helper.scope } + /// Returns the logger associated with this Hub. + @objc public var logger: SentryLogger { + // We know the type so it's fine to force cast. + // swiftlint:disable force_cast + return self.helper._swiftLogger as! SentryLogger + // swiftlint:enable force_cast + } + /// Binds a different client to the hub. @objc public func bindClient(_ client: SentryClient?) { helper.bindClient(client?.helper) diff --git a/Sources/Swift/Tools/SentryLogBatcher.swift b/Sources/Swift/Tools/SentryLogBatcher.swift index 2393273c62..ead26be4e8 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: SentryClientInternal + 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,38 +22,88 @@ import Foundation private var encodedLogsSize: Int = 0 private var timerWorkItem: DispatchWorkItem? + private weak var delegate: SentryLogBatcherDelegate? + + /// Convenience initializer with default flush timeout and buffer size. + /// - Parameters: + /// - options: The Sentry configuration options + /// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state + /// - delegate: The delegate to handle captured log batches + /// + /// - Important: The `dispatchQueue` parameter MUST be a serial queue to ensure thread safety. + /// Passing a concurrent queue will result in undefined behavior and potential data races. + @_spi(Private) public convenience init( + options: Options, + dispatchQueue: SentryDispatchQueueWrapper, + delegate: SentryLogBatcherDelegate + ) { + self.init( + options: options, + flushTimeout: 5, + maxBufferSizeBytes: 1_024 * 1_024, // 1MB + dispatchQueue: dispatchQueue, + delegate: delegate + ) + } + /// Initializes a new SentryLogBatcher. /// - Parameters: - /// - client: The SentryClient to use for sending logs + /// - options: The Sentry configuration options /// - flushTimeout: The timeout interval after which buffered logs will be flushed /// - maxBufferSizeBytes: The maximum buffer size in bytes before triggering an immediate flush /// - dispatchQueue: A **serial** dispatch queue wrapper for thread-safe access to mutable state + /// - delegate: The delegate to handle captured log batches /// /// - Important: The `dispatchQueue` parameter MUST be a serial queue to ensure thread safety. /// Passing a concurrent queue will result in undefined behavior and potential data races. - init( - client: SentryClientInternal, - flushTimeout: TimeInterval = 5, - maxBufferSizeBytes: Int = 1_024 * 1_024, - dispatchQueue: SentryDispatchQueueWrapper + @_spi(Private) public init( + options: Options, + flushTimeout: TimeInterval, + maxBufferSizeBytes: Int, + dispatchQueue: SentryDispatchQueueWrapper, + delegate: SentryLogBatcherDelegate ) { - self.client = client - self.options = client.options + self.options = options self.flushTimeout = flushTimeout self.maxBufferSizeBytes = maxBufferSizeBytes self.dispatchQueue = dispatchQueue + self.delegate = delegate super.init() } - @_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) + addReplayAttributes(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() @@ -61,6 +114,71 @@ 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) + } + } + + private func addReplayAttributes(to attributes: inout [String: SentryLog.Attribute], scope: Scope) { +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + if let scopeReplayId = scope.replayId { + // Session mode: use scope replay ID + attributes["sentry.replay_id"] = .init(string: scopeReplayId) + } +#endif +#endif + } + // Only ever call this from the serial dispatch queue. private func encodeAndBuffer(log: SentryLog) { do { @@ -123,6 +241,10 @@ import Foundation // Send the payload. - client.captureLogsData(payloadData, with: NSNumber(value: encodedLogs.count)) + if let delegate { + delegate.capture(logsData: payloadData as NSData, count: NSNumber(value: encodedLogs.count)) + } else { + SentrySDKLog.debug("SentryLogBatcher: Delegate not set, not capturing logs data.") + } } } diff --git a/Sources/Swift/Tools/SentryLogger.swift b/Sources/Swift/Tools/SentryLogger.swift index 5cae988391..33c72fab02 100644 --- a/Sources/Swift/Tools/SentryLogger.swift +++ b/Sources/Swift/Tools/SentryLogger.swift @@ -2,8 +2,11 @@ import Foundation -/// **EXPERIMENTAL** - A structured logging API for Sentry. -/// +@objc @_spi(Private) public protocol SentryLoggerDelegate: AnyObject { + @objc(captureLog:) + func capture(log: SentryLog) +} + /// `SentryLogger` provides a structured logging interface that captures log entries /// and sends them to Sentry. Supports multiple log levels (trace, debug, info, warn, /// error, fatal) and allows attaching arbitrary attributes for enhanced context. @@ -13,9 +16,6 @@ import Foundation /// - `Float` (converted to `Double`) /// - Other types (converted to string) /// -/// - Note: Sentry Logs is currently in Beta. See the [Sentry Logs Documentation](https://docs.sentry.io/product/explore/logs/). -/// - Warning: This API is experimental and subject to change without notice. -/// /// ## Usage /// ```swift /// let logger = SentrySDK.logger @@ -29,15 +29,13 @@ import Foundation /// ``` @objc public final class SentryLogger: NSObject { - private let hub: SentryHubInternal + private weak var delegate: SentryLoggerDelegate? 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? - init(hub: SentryHubInternal, dateProvider: SentryCurrentDateProvider, batcher: SentryLogBatcher?) { - self.hub = hub + @objc(initWithDelegate:dateProvider:) + @_spi(Private) public init(delegate: SentryLoggerDelegate, dateProvider: SentryCurrentDateProvider) { + self.delegate = delegate self.dateProvider = dateProvider - self.batcher = batcher super.init() } @@ -166,21 +164,14 @@ 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 { + guard let delegate else { + SentrySDKLog.warning("No delegate set for SentryLogger, skipping log capture.") return } - // Convert provided attributes to SentryLog.Attribute format var logAttributes = attributes.mapValues { SentryLog.Attribute(value: $0) } @@ -193,133 +184,14 @@ 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) - addReplayAttributes(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) - } - } - - private func addReplayAttributes(to attributes: inout [String: SentryLog.Attribute]) { -#if canImport(UIKit) && !SENTRY_NO_UIKIT -#if os(iOS) || os(tvOS) - if let scopeReplayId = hub.scope.replayId { - // Session mode: use scope replay ID - attributes["sentry.replay_id"] = .init(string: scopeReplayId) - } else if let sessionReplayId = hub.getSessionReplayId() { - // Buffer mode: scope has no ID but integration does - attributes["sentry.replay_id"] = .init(string: sessionReplayId) - attributes["sentry._internal.replay_is_buffering"] = .init(boolean: true) - } -#endif -#endif - } -} - -#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") } + delegate.capture(log: log) } } -#endif // SWIFT_PACKAGE diff --git a/Tests/SentryTests/Helper/SentryLogSPMTests.swift b/Tests/SentryTests/Helper/SentryLogSPMTests.swift new file mode 100644 index 0000000000..784702fcce --- /dev/null +++ b/Tests/SentryTests/Helper/SentryLogSPMTests.swift @@ -0,0 +1,155 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +/// Tests for SPM log workarounds using dynamic dispatch. +final class SentryLogSPMTests: XCTestCase { + + private class Fixture { + let options: Options + + init() { + options = Options() + options.dsn = TestConstants.dsnAsString(username: "SentryLogSPMTests") + options.enableLogs = true + } + } + + private var fixture: Fixture! + + override func setUp() { + super.setUp() + fixture = Fixture() + } + + override func tearDown() { + super.tearDown() + } + + // 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")) + } + + func testOptions_BeforeSendLog_ViaKVC_DirectProperty() { + // Test if we can use "beforeSendLog" directly via KVC instead of "beforeSendLogDynamic" + // This would work in non-SPM builds but not in SPM builds where the property isn't declared + let callback: (SentryLog) -> SentryLog? = { log in + let modifiedLog = log + modifiedLog.body = "Modified: \(log.body)" + return modifiedLog + } + + // Try to set using "beforeSendLog" directly + fixture.options.setValue(callback, forKey: "beforeSendLog") + + // Try to get using "beforeSendLog" directly + let retrievedCallback = fixture.options.value(forKey: "beforeSendLog") as? (SentryLog) -> SentryLog? + + // In SPM builds, this will be nil because the property doesn't exist + // In non-SPM builds, this should work because the property is auto-synthesized + #if SWIFT_PACKAGE + XCTAssertNil(retrievedCallback, "In SPM builds, 'beforeSendLog' property doesn't exist, so KVC should fail") + #else + XCTAssertNotNil(retrievedCallback, "In non-SPM builds, 'beforeSendLog' property exists and should work via KVC") + + if let retrievedCallback = retrievedCallback { + let originalLog = SentryLog(level: .info, body: "Original message") + let modifiedLog = retrievedCallback(originalLog) + XCTAssertNotNil(modifiedLog) + XCTAssertEqual(modifiedLog?.body, "Modified: Original message") + } + #endif + } +} diff --git a/Tests/SentryTests/Protocol/SentryLogTests.swift b/Tests/SentryTests/Protocol/SentryLogTests.swift index d7eee96e12..1aff2dd6a7 100644 --- a/Tests/SentryTests/Protocol/SentryLogTests.swift +++ b/Tests/SentryTests/Protocol/SentryLogTests.swift @@ -173,4 +173,32 @@ final class SentryLogTests: XCTestCase { let decodedScoreValue = try XCTUnwrap(decoded.attributes["score"]?.value as? Double) XCTAssertEqual(decodedScoreValue, 3.14159, accuracy: 0.00001) } + + // MARK: - setAttribute Tests + + func testSetAttribute_AddsUpdatedsRemovesAtribute() { + let log = SentryLog( + level: .info, + body: "Test message", + attributes: [:] + ) + + log.setAttribute(SentryLog.Attribute(string: "test_value"), forKey: "test_key") + + XCTAssertEqual(log.attributes.count, 1) + XCTAssertEqual(log.attributes["test_key"]?.type, "string") + XCTAssertEqual(log.attributes["test_key"]?.value as? String, "test_value") + + log.setAttribute(SentryLog.Attribute(string: "test_value_2"), forKey: "test_key") + + XCTAssertEqual(log.attributes.count, 1) + XCTAssertEqual(log.attributes["test_key"]?.type, "string") + XCTAssertEqual(log.attributes["test_key"]?.value as? String, "test_value_2") + + log.setAttribute(nil, forKey: "test_key") + + XCTAssertEqual(log.attributes.count, 0) + XCTAssertNil(log.attributes["test_key"]) + } + } diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index 7f2b466823..d7bd84ae2e 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -2172,40 +2172,55 @@ 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) + // Create a test batcher to verify addLog is called + let testDelegate = TestLogBatcherDelegateForClient() + let testBatcher = TestLogBatcherForClient( + options: sut.options, + dispatchQueue: TestSentryDispatchQueueWrapper(), + delegate: testDelegate + ) + Dynamic(sut).logBatcher = testBatcher + + let log = SentryLog( + timestamp: Date(timeIntervalSince1970: 1_627_846_801), + traceId: SentryId.empty, // Temporary set to empty until its assigned by the batcher. + level: .info, + body: "Test log message", + attributes: [:] + ) + let scope = Scope() - // 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) + sut._swiftCaptureLog(log, with: scope) - // Verify the envelope item data - XCTAssertEqual(logData, item.data) + // 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 testCaptureLogsData_WithDisabledClient() { - let sut = fixture.getSutDisabledSdk() - let logData = Data("{\"items\":[{\"timestamp\":1627846801,\"level\":\"info\",\"body\":\"Test log message\"}]}".utf8) + func testFlushCallsLogBatcherCaptureLogs() { + let sut = fixture.getSut() + + // Create a test batcher to verify captureLogs is called + let testDelegate = TestLogBatcherDelegateForClient() + let testBatcher = TestLogBatcherForClient( + options: sut.options, + dispatchQueue: TestSentryDispatchQueueWrapper(), + delegate: testDelegate + ) + Dynamic(sut).logBatcher = testBatcher - sut.captureLogsData(logData, with: NSNumber(value: 1)) + // Verify initial state + XCTAssertEqual(testBatcher.captureLogsInvocations.count, 0) - // Verify that no envelope was sent when client is disabled - XCTAssertEqual(0, fixture.transport.sentEnvelopes.count) + // Call flush - this should trigger the log batcher to capture logs + sut.flush(timeout: 1.0) + + // Verify that captureLogs was called on the log batcher + XCTAssertEqual(testBatcher.captureLogsInvocations.count, 1) } func testCaptureSentryWrappedException() throws { @@ -2414,6 +2429,27 @@ private extension SentryClientTests { } +final class TestLogBatcherForClient: SentryLogBatcher { + var addLogInvocations = Invocations<(log: SentryLog, scope: Scope)>() + var captureLogsInvocations = Invocations() + + override func addLog(_ log: SentryLog, scope: Scope) { + addLogInvocations.record((log, scope)) + } + + @discardableResult + override func captureLogs() -> TimeInterval { + captureLogsInvocations.record(()) + return super.captureLogs() + } +} + +final class TestLogBatcherDelegateForClient: NSObject, SentryLogBatcherDelegate { + func capture(logsData: NSData, count: NSNumber) { + // No-op for tests that don't need to verify delegate calls + } +} + enum SentryClientError: Error { case someError case invalidInput(String) diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index b3710274a2..db62f49ab5 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -664,6 +664,88 @@ class SentryHubTests: XCTestCase { } } + func testCaptureLog() { + let hub = fixture.getSut(fixture.options, fixture.scope) + (hub._swiftLogger as! SentryLogger).info("Test log message") + + XCTAssertEqual(1, fixture.client.captureLogInvocations.count) + if let logArguments = fixture.client.captureLogInvocations.first { + XCTAssertEqual("Test log message", logArguments.log.body) + XCTAssertEqual(fixture.scope, logArguments.scope) + } + } + + // MARK: - Replay Attributes Tests + +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + func testCaptureLog_ReplayAttributes_SessionMode_AddsReplayId() { + // Setup replay integration + let replayOptions = SentryReplayOptions(sessionSampleRate: 1.0, onErrorSampleRate: 0.0) + fixture.options.sessionReplay = replayOptions + + let replayIntegration = SentrySessionReplayIntegration() + sut.addInstalledIntegration(replayIntegration, name: "SentrySessionReplayIntegration") + + // Set replayId on scope (session mode) + let replayId = "12345678-1234-1234-1234-123456789012" + fixture.scope.replayId = replayId + + let sut = fixture.getSut(fixture.options, fixture.scope) + (sut._swiftLogger as! SentryLogger).info("Test message") + + XCTAssertEqual(1, fixture.client.captureLogInvocations.count) + let capturedLog = fixture.client.captureLogInvocations.first?.log + XCTAssertEqual(capturedLog?.attributes["sentry.replay_id"]?.value as? String, replayId) + XCTAssertNil(capturedLog?.attributes["sentry._internal.replay_is_buffering"]) + } + + func testCaptureLog_ReplayAttributes_BufferMode_AddsReplayIdAndBufferingFlag() { + // Set up buffer mode: hub has an ID, but scope.replayId is nil + let mockReplayId = SentryId() + let testHub = TestHub(client: fixture.client, andScope: fixture.scope) + testHub.mockReplayId = mockReplayId.sentryIdString + fixture.scope.replayId = nil + + (testHub._swiftLogger as! SentryLogger).info("Test message") + + XCTAssertEqual(1, fixture.client.captureLogInvocations.count) + let capturedLog = fixture.client.captureLogInvocations.first?.log + let replayIdString = capturedLog?.attributes["sentry.replay_id"]?.value as? String + XCTAssertEqual(replayIdString, mockReplayId.sentryIdString) + XCTAssertEqual(capturedLog?.attributes["sentry._internal.replay_is_buffering"]?.value as? Bool, true) + } + + func testCaptureLog_ReplayAttributes_NoReplay_NoAttributesAdded() { + // Don't set up replay integration + let sut = fixture.getSut(fixture.options, fixture.scope) + + (sut._swiftLogger as! SentryLogger).info("Test message") + + XCTAssertEqual(1, fixture.client.captureLogInvocations.count) + let capturedLog = fixture.client.captureLogInvocations.first?.log + XCTAssertNil(capturedLog?.attributes["sentry.replay_id"]) + XCTAssertNil(capturedLog?.attributes["sentry._internal.replay_is_buffering"]) + } + + func testCaptureLog_ReplayAttributes_BothSessionAndScopeReplayId_SessionMode() { + // Session mode: scope has the ID, hub also has one + let replayId = "12345678-1234-1234-1234-123456789012" + let testHub = TestHub(client: fixture.client, andScope: fixture.scope) + testHub.mockReplayId = replayId + fixture.scope.replayId = replayId + + (testHub._swiftLogger as! SentryLogger).info("Test message") + + XCTAssertEqual(1, fixture.client.captureLogInvocations.count) + let capturedLog = fixture.client.captureLogInvocations.first?.log + // Session mode should use scope's ID (takes precedence) and not add buffering flag + XCTAssertEqual(capturedLog?.attributes["sentry.replay_id"]?.value as? String, replayId) + XCTAssertNil(capturedLog?.attributes["sentry._internal.replay_is_buffering"]) + } +#endif +#endif + func testCaptureErrorWithScope() { fixture.getSut().capture(error: fixture.error, scope: fixture.scope).assertIsNotEmpty() diff --git a/Tests/SentryTests/SentryLogBatcherTests.swift b/Tests/SentryTests/SentryLogBatcherTests.swift index 99856566e7..33bbe15488 100644 --- a/Tests/SentryTests/SentryLogBatcherTests.swift +++ b/Tests/SentryTests/SentryLogBatcherTests.swift @@ -5,33 +5,35 @@ 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! + private var scope: Scope! override func setUp() { 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 - dispatchQueue: testDispatchQueue + maxBufferSizeBytes: 800, // byte limit for testing (log with attributes ~390 bytes) + dispatchQueue: testDispatchQueue, + 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,35 +219,34 @@ 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 + dispatchQueue: SentryDispatchQueueWrapper(), // Real dispatch queue + delegate: testDelegate ) let expectation = XCTestExpectation(description: "Concurrent adds") @@ -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 + dispatchQueue: SentryDispatchQueueWrapper(), // Real dispatch queue + 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,327 @@ 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: - 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() { + 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 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) + 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 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 +635,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 086de04929..79eca6b502 100644 --- a/Tests/SentryTests/SentryLoggerTests.swift +++ b/Tests/SentryTests/SentryLoggerTests.swift @@ -6,29 +6,26 @@ import XCTest final class SentryLoggerTests: XCTestCase { + private class TestLoggerDelegate: NSObject, SentryLoggerDelegate { + let capturedLogs = Invocations() + + func capture(log: SentryLog) { + capturedLogs.record(log) + } + } + private class Fixture { - let hub: TestHub - let client: TestClient + let delegate: TestLoggerDelegate let dateProvider: TestCurrentDateProvider - let options: Options - let scope: Scope - let batcher: TestLogBatcher init() { - options = Options() - options.dsn = TestConstants.dsnAsString(username: "SentryLoggerTests") - - client = TestClient(options: options)! - scope = Scope() - hub = TestHub(client: client, andScope: scope) + delegate = TestLoggerDelegate() 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(delegate: delegate, dateProvider: dateProvider) } } @@ -262,9 +259,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.delegate.capturedLogs.count, 6) + let logs = fixture.delegate.capturedLogs.invocations XCTAssertEqual(logs[0].level, .trace) XCTAssertEqual(logs[1].level, .debug) XCTAssertEqual(logs[2].level, .info) @@ -273,59 +271,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,348 +440,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: - Replay Attributes Tests -#if canImport(UIKit) && !SENTRY_NO_UIKIT -#if os(iOS) || os(tvOS) - func testReplayAttributes_SessionMode_AddsReplayId() { - // Setup replay integration - let replayOptions = SentryReplayOptions(sessionSampleRate: 1.0, onErrorSampleRate: 0.0) - fixture.options.sessionReplay = replayOptions - - let replayIntegration = SentrySessionReplayIntegration() - fixture.hub.addInstalledIntegration(replayIntegration, name: "SentrySessionReplayIntegration") - - // Set replayId on scope (session mode) - let replayId = "12345678-1234-1234-1234-123456789012" - fixture.scope.replayId = replayId - - sut.info("Test message") - - let log = getLastCapturedLog() - XCTAssertEqual(log.attributes["sentry.replay_id"]?.value as? String, replayId) - XCTAssertNil(log.attributes["sentry._internal.replay_is_buffering"]) - } - - func testReplayAttributes_BufferMode_AddsReplayIdAndBufferingFlag() { - // Set up buffer mode: hub has an ID, but scope.replayId is nil - let mockReplayId = SentryId() - fixture.hub.mockReplayId = mockReplayId.sentryIdString - fixture.scope.replayId = nil - - sut.info("Test message") - - let log = getLastCapturedLog() - let replayIdString = log.attributes["sentry.replay_id"]?.value as? String - XCTAssertEqual(replayIdString, mockReplayId.sentryIdString) - XCTAssertEqual(log.attributes["sentry._internal.replay_is_buffering"]?.value as? Bool, true) - } - - func testReplayAttributes_NoReplay_NoAttributesAdded() { - // Don't set up replay integration - - sut.info("Test message") - - let log = getLastCapturedLog() - XCTAssertNil(log.attributes["sentry.replay_id"]) - XCTAssertNil(log.attributes["sentry._internal.replay_is_buffering"]) - } - - func testReplayAttributes_BothSessionAndScopeReplayId_SessionMode() { - // Session mode: scope has the ID, hub also has one - let replayId = "12345678-1234-1234-1234-123456789012" - fixture.hub.mockReplayId = replayId - fixture.scope.replayId = replayId - - sut.info("Test message") - - let log = getLastCapturedLog() - // Session mode should use scope's ID (takes precedence) and not add buffering flag - XCTAssertEqual(log.attributes["sentry.replay_id"]?.value as? String, replayId) - XCTAssertNil(log.attributes["sentry._internal.replay_is_buffering"]) - } -#endif -#endif - // MARK: - Helper Methods private func assertLogCaptured( @@ -846,44 +449,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) @@ -917,20 +488,10 @@ final class SentryLoggerTests: XCTestCase { } private func getLastCapturedLog() -> SentryLog { - let logs = fixture.batcher.addInvocations.invocations - guard let lastLog = logs.last else { + guard let lastLog = fixture.delegate.capturedLogs.invocations.last else { XCTFail("No logs captured") return SentryLog(timestamp: Date(), traceId: .empty, level: .info, body: "", attributes: [:]) } return lastLog } } - -final class TestLogBatcher: SentryLogBatcher { - - var addInvocations = Invocations() - - override func add(_ log: SentryLog) { - addInvocations.record(log) - } -} diff --git a/Tests/SentryTests/SentrySDKInternalTests.swift b/Tests/SentryTests/SentrySDKInternalTests.swift index e9297a48d6..3f4b80cb86 100644 --- a/Tests/SentryTests/SentrySDKInternalTests.swift +++ b/Tests/SentryTests/SentrySDKInternalTests.swift @@ -613,7 +613,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) } func testLogger_ReturnsSameInstanceOnMultipleCalls() { @@ -625,44 +625,16 @@ class SentrySDKInternalTests: XCTestCase { XCTAssertIdentical(logger1, logger2) } - func testClose_ResetsLogger() { - givenSdkWithHub() - - // Get logger instance - let logger1 = SentrySDK.logger - XCTAssertNotNil(logger1) - - // Close SDK - SentrySDK.close() - - // Start SDK again - givenSdkWithHub() - - // Get logger instance again - let logger2 = SentrySDK.logger - XCTAssertNotNil(logger2) - - // Should be a different instance - XCTAssertNotIdentical(logger1, logger2) - } - - func testLogger_WithLogsEnabled_CapturesLog() { - fixture.client.options.enableLogs = true + func testLogger_WithClient_CapturesLog() { givenSdkWithHub() SentrySDK.logger.error(String(repeating: "S", count: 1_024 * 1_024)) - 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() { - fixture.client.options.enableLogs = true let hubWithoutClient = SentryHubInternal(client: nil, andScope: nil) SentrySDKInternal.setCurrentHub(hubWithoutClient) @@ -674,17 +646,9 @@ class SentrySDKInternalTests: XCTestCase { } waitForExpectations(timeout: 5.0) - XCTAssertEqual(fixture.client.captureLogsDataInvocations.count, 0) + XCTAssertEqual(fixture.client.captureLogInvocations.count, 0) } - - func testLogger_WithLogsDisabled_DoesNotCaptureLog() { - fixture.client.options.enableLogs = false - givenSdkWithHub() - - SentrySDK.logger.error("foo") - XCTAssertEqual(fixture.client.captureLogsDataInvocations.count, 0) - } - + func testFlush_CallsFlushCorrectlyOnTransport() throws { SentrySDK.start { options in options.dsn = SentrySDKInternalTests.dsnAsString @@ -700,7 +664,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) } #if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index 03c2d8708f..c7ce308a10 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -427,14 +427,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() { @@ -445,62 +446,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) - } - - func testLogger_RecreatedWhenSDKStartedAfterAccess() { - // Access logger before SDK is started - let loggerBeforeStart = SentrySDK.logger - - // Now properly start the SDK using internal APIs - fixture.client.options.enableLogs = true - SentrySDKInternal.setCurrentHub(fixture.hub) - SentrySDKInternal.setStart(with: fixture.client.options) - - // Access logger again after SDK is started - let loggerAfterStart = SentrySDK.logger - - // Verify it's a different instance (recreated) - XCTAssertNotIdentical(loggerBeforeStart, loggerAfterStart) - - // Verify the new logger can actually capture logs - loggerAfterStart.info("Test log message") - - // 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) + // The log should still be captured + XCTAssertEqual(fixture.client.captureLogInvocations.count, 1) } } diff --git a/sdk_api.json b/sdk_api.json index 5270251333..e2a19f8744 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -41786,6 +41786,135 @@ } ] }, + { + "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": "Function", + "name": "setAttribute", + "printedName": "setAttribute(_:forKey:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Sentry.SentryLog.Attribute?", + "children": [ + { + "kind": "TypeNominal", + "name": "Attribute", + "printedName": "Sentry.SentryLog.Attribute", + "usr": "s:6Sentry0A3LogC9AttributeC" + } + ], + "usr": "s:Sq" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Func", + "usr": "c:@M@Sentry@objc(cs)SentryLog(im)setAttribute:forKey:", + "mangledName": "$s6Sentry0A3LogC12setAttribute_6forKeyyAC0D0CSg_SStF", + "moduleName": "Sentry", + "declAttributes": [ + "Final", + "ObjC" + ], + "funcSelfKind": "NonMutating" + }, { "kind": "TypeDecl", "name": "Level", @@ -43398,7 +43527,6 @@ "ObjC" ], "superclassUsr": "c:objc(cs)NSObject", - "hasMissingDesignatedInitializers": true, "inheritsConvenienceInitializers": true, "superclassNames": [ "ObjectiveC.NSObject" @@ -50517,6 +50645,51 @@ } ] }, + { + "kind": "Var", + "name": "logger", + "printedName": "logger", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryLogger", + "printedName": "Sentry.SentryLogger", + "usr": "c:@M@Sentry@objc(cs)SentryLogger" + } + ], + "declKind": "Var", + "usr": "c:@M@Sentry@objc(cs)SentryHub(py)logger", + "mangledName": "$s6Sentry0A3HubC6loggerAA0A6LoggerCvp", + "moduleName": "Sentry", + "declAttributes": [ + "Final", + "ObjC" + ], + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "SentryLogger", + "printedName": "Sentry.SentryLogger", + "usr": "c:@M@Sentry@objc(cs)SentryLogger" + } + ], + "declKind": "Accessor", + "usr": "c:@M@Sentry@objc(cs)SentryHub(im)logger", + "mangledName": "$s6Sentry0A3HubC6loggerAA0A6LoggerCvg", + "moduleName": "Sentry", + "declAttributes": [ + "Final", + "ObjC" + ], + "accessorKind": "get" + } + ] + }, { "kind": "Function", "name": "bindClient", @@ -54800,6 +54973,40 @@ ], "funcSelfKind": "NonMutating" }, + { + "kind": "Function", + "name": "capture", + "printedName": "capture(log:scope:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "SentryLog", + "printedName": "Sentry.SentryLog", + "usr": "c:@M@Sentry@objc(cs)SentryLog" + }, + { + "kind": "TypeNominal", + "name": "Scope", + "printedName": "Sentry.Scope", + "usr": "c:objc(cs)SentryScope" + } + ], + "declKind": "Func", + "usr": "c:@M@Sentry@objc(cs)SentryClient(im)captureLog:withScope:", + "mangledName": "$s6Sentry0A6ClientC7capture3log5scopeyAA0A3LogC_So0A5ScopeCtF", + "moduleName": "Sentry", + "objc_name": "captureLog:withScope:", + "declAttributes": [ + "Final", + "ObjC" + ], + "funcSelfKind": "NonMutating" + }, { "kind": "Function", "name": "flush",