diff --git a/CHANGELOG.md b/CHANGELOG.md index a19b9eb515f..af682ea2d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Structured Logs: Collect `stdout/stderr` (#6441) + ## 9.0.0-rc.0 ### Breaking Changes diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift index 04199a023c5..24dae66cfb7 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift @@ -157,6 +157,11 @@ public struct SentrySDKWrapper { #endif // !os(macOS) && !os(tvOS) && !os(watchOS) && !os(visionOS) options.enableLogs = true + options.experimental.enableStdOutCapture = true + options.beforeSendLog = { log in + print("foo") + return log + } // Experimental features options.enableFileManagerSwizzling = !SentrySDKOverrides.Other.disableFileManagerSwizzling.boolValue diff --git a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift index b685e071d6a..c3a11007935 100644 --- a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift +++ b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift @@ -23,6 +23,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } SentrySDKWrapper.shared.startSentry() + SampleAppDebugMenu.shared.display() metricKit.receiveReports() diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 384821fdb8c..b7bf0ff5d07 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -711,10 +711,14 @@ 92235CAC2E15369900865983 /* SentryLogBatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92235CAB2E15369900865983 /* SentryLogBatcher.swift */; }; 92235CAE2E15549C00865983 /* SentryLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92235CAD2E15549C00865983 /* SentryLogger.swift */; }; 92235CB02E155B2600865983 /* SentryLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92235CAF2E155B2600865983 /* SentryLoggerTests.swift */; }; + 9229D1462E9FF09E00FD09ED /* SentryStdOutLogIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9229D1452E9FF09E00FD09ED /* SentryStdOutLogIntegrationTests.swift */; }; 925824C22CB5897700C9B20B /* SentrySessionReplayIntegration-Hybrid.h in Headers */ = {isa = PBXBuildFile; fileRef = D80382BE2C09C6FD0090E048 /* SentrySessionReplayIntegration-Hybrid.h */; settings = {ATTRIBUTES = (Private, ); }; }; + 925B67CC2EA11970005B2D3B /* SentryStdOutLogIntegration.h in Headers */ = {isa = PBXBuildFile; fileRef = 925B67CB2EA11970005B2D3B /* SentryStdOutLogIntegration.h */; }; + 925B67D02EA11B0E005B2D3B /* SentryStdoutLogIntegration.m in Sources */ = {isa = PBXBuildFile; fileRef = 925B67CF2EA11B0E005B2D3B /* SentryStdoutLogIntegration.m */; }; 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, ); }; }; + 92793D542ECB7509007EA926 /* SentryStdOutLogIntegrationDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92793D522ECB7509007EA926 /* SentryStdOutLogIntegrationDriver.swift */; }; 927A5CC42DD7626B00B82404 /* SentryEnvelopeItemHeaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 927A5CC32DD7626400B82404 /* SentryEnvelopeItemHeaderTests.swift */; }; 928207C42E251B8F009285A4 /* SentryScope+PrivateSwift.h in Headers */ = {isa = PBXBuildFile; fileRef = 928207C32E251B8F009285A4 /* SentryScope+PrivateSwift.h */; }; 9286059529A5096600F96038 /* SentryGeo.h in Headers */ = {isa = PBXBuildFile; fileRef = 9286059429A5096600F96038 /* SentryGeo.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -2074,9 +2078,13 @@ 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 = ""; }; + 9229D1452E9FF09E00FD09ED /* SentryStdOutLogIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryStdOutLogIntegrationTests.swift; sourceTree = ""; }; + 925B67CB2EA11970005B2D3B /* SentryStdOutLogIntegration.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryStdOutLogIntegration.h; path = include/SentryStdOutLogIntegration.h; sourceTree = ""; }; + 925B67CF2EA11B0E005B2D3B /* SentryStdoutLogIntegration.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryStdoutLogIntegration.m; 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 = ""; }; + 92793D522ECB7509007EA926 /* SentryStdOutLogIntegrationDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryStdOutLogIntegrationDriver.swift; sourceTree = ""; }; 927A5CC32DD7626400B82404 /* SentryEnvelopeItemHeaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryEnvelopeItemHeaderTests.swift; sourceTree = ""; }; 928207C32E251B8F009285A4 /* SentryScope+PrivateSwift.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentryScope+PrivateSwift.h"; path = "include/SentryScope+PrivateSwift.h"; sourceTree = ""; }; 9286059429A5096600F96038 /* SentryGeo.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryGeo.h; path = Public/SentryGeo.h; sourceTree = ""; }; @@ -2939,6 +2947,7 @@ D85596EF280580BE0041FF8B /* Screenshot */, 0A9BF4E028A114690068D266 /* ViewHierarchy */, D80CD8D52B752FD9002F710B /* SessionReplay */, + 925B67892EA118EA005B2D3B /* StdOutLog */, FA034AC72DD3DB4900FE3107 /* SentryIntegrationProtocol.h */, 7BA235622600B61200E12865 /* SentryInternalNotificationNames.h */, 0A2D8D5C289815EB008720F6 /* SentryBaseIntegration.h */, @@ -3529,6 +3538,7 @@ 7BE0DC3F272AE9F0004FA8B7 /* Session */, 7BE0DC3E272AE9DC004FA8B7 /* SentryCrash */, D80694C12B7CC85800B820E6 /* SessionReplay */, + 9292AA712EA1110E005DF5E2 /* StdOutLog */, 7B59398324AB481B0003AAD2 /* NotificationCenterTestCase.swift */, 0A2D8D8628992260008720F6 /* SentryBaseIntegrationTests.swift */, ); @@ -4196,6 +4206,31 @@ name = Transaction; sourceTree = ""; }; + 925B67892EA118EA005B2D3B /* StdOutLog */ = { + isa = PBXGroup; + children = ( + 925B67CB2EA11970005B2D3B /* SentryStdOutLogIntegration.h */, + 925B67CF2EA11B0E005B2D3B /* SentryStdoutLogIntegration.m */, + ); + name = StdOutLog; + sourceTree = ""; + }; + 92793D532ECB7509007EA926 /* StdOutLog */ = { + isa = PBXGroup; + children = ( + 92793D522ECB7509007EA926 /* SentryStdOutLogIntegrationDriver.swift */, + ); + path = StdOutLog; + sourceTree = ""; + }; + 9292AA712EA1110E005DF5E2 /* StdOutLog */ = { + isa = PBXGroup; + children = ( + 9229D1452E9FF09E00FD09ED /* SentryStdOutLogIntegrationTests.swift */, + ); + path = StdOutLog; + sourceTree = ""; + }; D4009EA02D77196F0007AF30 /* ViewCapture */ = { isa = PBXGroup; children = ( @@ -4839,6 +4874,7 @@ D8CAC02D2BA0663E00E38F34 /* Integrations */ = { isa = PBXGroup; children = ( + 92793D532ECB7509007EA926 /* StdOutLog */, FAB0073C2E9F47DE001C806A /* Session */, FAE579B42E7DBE9400B710F9 /* SentryGlobalEventProcessor.swift */, D49064862DFAE1B700555785 /* Screenshot */, @@ -5305,6 +5341,7 @@ 7BA61CC8247D125400C130A8 /* SentryDefaultThreadInspector.h in Headers */, 63FE713320DA4C1100CDBAE8 /* SentryCrashCPU.h in Headers */, 6271ADF32BA06D9B0098D2E9 /* SentryInternalSerializable.h in Headers */, + 925B67CC2EA11970005B2D3B /* SentryStdOutLogIntegration.h in Headers */, D8853C842833EABC00700D64 /* SentryANRTrackerV1.h in Headers */, 63FE715B20DA4C1100CDBAE8 /* SentryCrashSignalInfo.h in Headers */, 63FE70E520DA4C1000CDBAE8 /* SentryCrashMonitor_CPPException.h in Headers */, @@ -5774,6 +5811,7 @@ 84DBC62C2CE82F12000C4904 /* SentryFeedback.swift in Sources */, F41362132E1C566100B84443 /* SentryScopePersistentStore+User.swift in Sources */, 63B818FA1EC34639002FDF4C /* SentryDebugMeta.m in Sources */, + 925B67D02EA11B0E005B2D3B /* SentryStdoutLogIntegration.m in Sources */, 7B98D7D325FB65AE00C5A389 /* SentryWatchdogTerminationTracker.m in Sources */, 8E564AE8267AF22600FE117D /* SentryNetworkTrackingIntegration.m in Sources */, 63AA75EF1EB8B3C400D153DE /* SentryClient.m in Sources */, @@ -6158,6 +6196,7 @@ 620379DD2AFE1432005AC0C1 /* SentryBuildAppStartSpans.m in Sources */, 6292585F2DAFA8290049388F /* SentryCrashMach-O.c in Sources */, 7B77BE3727EC8460003C9020 /* SentryDiscardReasonMapper.m in Sources */, + 92793D542ECB7509007EA926 /* SentryStdOutLogIntegrationDriver.swift in Sources */, 63FE712520DA4C1000CDBAE8 /* SentryCrashSignalInfo.c in Sources */, 63FE70F320DA4C1000CDBAE8 /* SentryCrashMonitor_Signal.c in Sources */, D859696F27BECDA20036A46E /* SentryCoreDataTracker.m in Sources */, @@ -6229,6 +6268,7 @@ FA27EBF52EB82FAD00F2ECF7 /* FileIOTrackerTestHelpers.swift in Sources */, 7B0A54562523178700A71716 /* SentryScopeSwiftTests.swift in Sources */, 7B5B94332657A816002E474B /* SentryAppStartTrackingIntegrationTests.swift in Sources */, + 9229D1462E9FF09E00FD09ED /* SentryStdOutLogIntegrationTests.swift in Sources */, 62278CA82E30B21A0022ABC6 /* SentryHttpTransportFlushIntegrationTests.swift in Sources */, 0A5370A128A3EC2400B2DCDE /* SentryViewHierarchyProviderTests.swift in Sources */, D8FFE50C2703DBB400607131 /* SwizzlingCallTests.swift in Sources */, diff --git a/Sources/Sentry/SentrySDKInternal.m b/Sources/Sentry/SentrySDKInternal.m index 5707f7d0738..e89590ae51b 100644 --- a/Sources/Sentry/SentrySDKInternal.m +++ b/Sources/Sentry/SentrySDKInternal.m @@ -22,6 +22,7 @@ #import "SentryScope.h" #import "SentrySerialization.h" #import "SentrySessionReplayIntegration.h" +#import "SentryStdOutLogIntegration.h" #import "SentrySwift.h" #import "SentrySwiftAsyncIntegration.h" #import "SentryTransactionContext.h" @@ -539,7 +540,7 @@ + (void)endSession [SentryANRTrackingIntegration class], [SentryAutoBreadcrumbTrackingIntegration class], [SentryAutoSessionTrackingIntegration class], [SentryCoreDataTrackingIntegration class], [SentryFileIOTrackingIntegration class], [SentryNetworkTrackingIntegration class], - [SentrySwiftAsyncIntegration class], nil]; + [SentrySwiftAsyncIntegration class], [SentryStdOutLogIntegration class], nil]; #if TARGET_OS_IOS && SENTRY_HAS_UIKIT [defaultIntegrations addObject:[SentryUserFeedbackIntegration class]]; diff --git a/Sources/Sentry/SentryStdoutLogIntegration.m b/Sources/Sentry/SentryStdoutLogIntegration.m new file mode 100644 index 00000000000..0273e64efec --- /dev/null +++ b/Sources/Sentry/SentryStdoutLogIntegration.m @@ -0,0 +1,59 @@ +#import "SentryStdOutLogIntegration.h" +#import "SentrySDK+Private.h" +#import "SentrySwift.h" + +@interface SentryStdOutLogIntegration () + +@property (strong, nonatomic, nullable) SentryStdOutLogIntegrationDriver *driver; + +@end + +@implementation SentryStdOutLogIntegration + +// Only for testing +- (instancetype)initWithDispatchQueue:(SentryDispatchQueueWrapper *)dispatchQueue + logger:(SentryLogger *)logger +{ + if (self = [super init]) { + _driver = [[SentryStdOutLogIntegrationDriver alloc] initWithDispatchQueue:dispatchQueue + logger:logger]; + } + return self; +} + +- (BOOL)installWithOptions:(SentryOptions *)options +{ + if (![super installWithOptions:options]) { + return NO; + } + + // Only install if logs are enabled + if (!options.enableLogs) { + return NO; + } + + // Only install if experimental flag is enabled + if (!options.experimental.enableStdOutCapture) { + return NO; + } + + // Use default instances if driver wasn't initialized (for production use) + if (!_driver) { + SentryLogger *logger = SentrySDK.logger; + SentryDispatchQueueWrapper *dispatchQueue + = SentryDependencyContainer.sharedInstance.dispatchQueueWrapper; + _driver = [[SentryStdOutLogIntegrationDriver alloc] initWithDispatchQueue:dispatchQueue + logger:logger]; + } + + [_driver start]; + + return YES; +} + +- (void)uninstall +{ + [_driver stop]; +} + +@end diff --git a/Sources/Sentry/include/SentryStdOutLogIntegration.h b/Sources/Sentry/include/SentryStdOutLogIntegration.h new file mode 100644 index 00000000000..7b64f57cdfd --- /dev/null +++ b/Sources/Sentry/include/SentryStdOutLogIntegration.h @@ -0,0 +1,19 @@ +#import "SentryBaseIntegration.h" + +NS_ASSUME_NONNULL_BEGIN + +@class SentryLogger; +@class SentryDispatchQueueWrapper; + +/** + * Integration that captures stdout and stderr output and forwards it to Sentry logs. + */ +@interface SentryStdOutLogIntegration : SentryBaseIntegration + +// Only for testing +- (instancetype)initWithDispatchQueue:(SentryDispatchQueueWrapper *)dispatchQueue + logger:(SentryLogger *)logger; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Swift/Core/Tools/SentrySDKLog.swift b/Sources/Swift/Core/Tools/SentrySDKLog.swift index 7c47b160662..173dcbf62a5 100644 --- a/Sources/Swift/Core/Tools/SentrySDKLog.swift +++ b/Sources/Swift/Core/Tools/SentrySDKLog.swift @@ -1,6 +1,6 @@ import Foundation -typealias SentryLogOutput = ((String) -> Void) +@_spi(Private) public typealias SentryLogOutput = ((String) -> Void) /// A note on the thread safety: /// The methods configure and log don't use synchronization mechanisms, meaning they aren't strictly speaking thread-safe. @@ -57,12 +57,13 @@ typealias SentryLogOutput = ((String) -> Void) return isDebug && level.rawValue >= diagnosticLevel.rawValue } - #if SENTRY_TEST || SENTRY_TEST_CI - - static func setOutput(_ output: @escaping SentryLogOutput) { + /// Set a custom output function for logs. Used by integrations to redirect output. + @objc @_spi(Private) public static func setOutput(_ output: @escaping SentryLogOutput) { logOutput = output } + #if SENTRY_TEST || SENTRY_TEST_CI + static func getOutput() -> SentryLogOutput { return logOutput } diff --git a/Sources/Swift/Integrations/StdOutLog/SentryStdOutLogIntegrationDriver.swift b/Sources/Swift/Integrations/StdOutLog/SentryStdOutLogIntegrationDriver.swift new file mode 100644 index 00000000000..7a63de3cd96 --- /dev/null +++ b/Sources/Swift/Integrations/StdOutLog/SentryStdOutLogIntegrationDriver.swift @@ -0,0 +1,132 @@ +@_implementationOnly import _SentryPrivate +import Foundation + +/** + * Driver class for capturing stdout and stderr output and forwarding it to Sentry logs. + * This is used by SentryStdOutLogIntegration. + */ +@objc @_spi(Private) public class SentryStdOutLogIntegrationDriver: NSObject { + private var stdErrPipe: Pipe? + private var stdOutPipe: Pipe? + private var originalStdOut: Int32 = -1 + private var originalStdErr: Int32 = -1 + + private let logger: SentryLogger + private let dispatchQueue: SentryDispatchQueueWrapper + + @objc(initWithDispatchQueue:logger:) + @_spi(Private) public init(dispatchQueue: SentryDispatchQueueWrapper, logger: SentryLogger) { + self.dispatchQueue = dispatchQueue + self.logger = logger + super.init() + } + + @objc @_spi(Private) public func start() { + originalStdOut = dup(fileno(stdout)) + originalStdErr = dup(fileno(stderr)) + + configureSentrySDKLogToBypassPipe() + + stdOutPipe = duplicateFileDescriptor(fileno(stdout), isStderr: false) + stdErrPipe = duplicateFileDescriptor(fileno(stderr), isStderr: true) + } + + @objc @_spi(Private) public func stop() { + // Restore SDK log print output + + SentrySDKLog.setOutput { + print($0) + } + + guard stdOutPipe != nil || stdErrPipe != nil else { + return + } + + // Restore original file descriptors + + if originalStdOut >= 0 { + fflush(stdout) + dup2(originalStdOut, fileno(stdout)) + close(originalStdOut) + originalStdOut = -1 + } + + if originalStdErr >= 0 { + fflush(stderr) + dup2(originalStdErr, fileno(stderr)) + close(originalStdErr) + originalStdErr = -1 + } + + // Clean up pipes + + stdOutPipe?.fileHandleForReading.readabilityHandler = nil + stdOutPipe = nil + + stdErrPipe?.fileHandleForReading.readabilityHandler = nil + stdErrPipe = nil + } + + /// Write the input file descriptor to the input file handle, preserving the original output as well. + /// This can be used to save stdout/stderr to a file while also keeping it on the console. + private func duplicateFileDescriptor(_ fileDescriptor: Int32, isStderr: Bool) -> Pipe? { + let pipe = Pipe() + let newDescriptor = dup(fileDescriptor) + let newFileHandle = FileHandle(fileDescriptor: newDescriptor, closeOnDealloc: true) + + if dup2(pipe.fileHandleForWriting.fileDescriptor, fileDescriptor) < 0 { + SentrySDKLog.error("Unable to duplicate file descriptor \(fileDescriptor)") + close(newDescriptor) + return nil + } + + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + guard let self = self else { return } + + let data = handle.availableData + self.dispatchQueue.dispatchAsync { + self.handleLogData(data, isStderr: isStderr) + } + newFileHandle.write(data) + } + + return pipe + } + + // This way we do not produce loops by using SentrySDKLog during stdout log capture. + private func configureSentrySDKLogToBypassPipe() { + let fd = originalStdOut + + SentrySDKLog.setOutput { message in + guard fd >= 0 else { return } + + // Append newline to match print() behavior + let messageWithNewline = message + "\n" + guard let data = messageWithNewline.data(using: .utf8) else { + return + } + data.withUnsafeBytes { bytes in + if let baseAddress = bytes.baseAddress { + write(fd, baseAddress, data.count) + } + } + } + } + + private func handleLogData(_ data: Data, isStderr: Bool) { + guard data.count > 0, + let logString = String(data: data, encoding: .utf8) else { + return + } + + let attributes: [String: Any] = [ + "sentry.log.source": isStderr ? "stderr" : "stdout" + ] + + if isStderr { + logger.warn(logString, attributes: attributes) + } else { + logger.info(logString, attributes: attributes) + } + } +} diff --git a/Sources/Swift/SentryExperimentalOptions.swift b/Sources/Swift/SentryExperimentalOptions.swift index 63aca7cbf61..85de085e5ea 100644 --- a/Sources/Swift/SentryExperimentalOptions.swift +++ b/Sources/Swift/SentryExperimentalOptions.swift @@ -29,6 +29,17 @@ public final class SentryExperimentalOptions: NSObject { */ public var enableSessionReplayInUnreliableEnvironment = false + /** + * Enables capturing stdout and stderr output and forwarding it to Sentry logs. + * + * When enabled, the SDK will capture all output written to stdout and stderr (including print statements, + * NSLog calls, etc.) and forward them as structured logs to Sentry. + * + * - Note: This requires `SentryOptions.enableLogs` to be set to `true`. + * - Experiment: This is an experimental feature and is therefore disabled by default. + */ + public var enableStdOutCapture = false + @_spi(Private) public func validateOptions(_ options: [String: Any]?) { } } diff --git a/Tests/SentryTests/Integrations/StdOutLog/SentryStdOutLogIntegrationTests.swift b/Tests/SentryTests/Integrations/StdOutLog/SentryStdOutLogIntegrationTests.swift new file mode 100644 index 00000000000..c09e4c9d3f9 --- /dev/null +++ b/Tests/SentryTests/Integrations/StdOutLog/SentryStdOutLogIntegrationTests.swift @@ -0,0 +1,155 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +class SentryStdOutLogIntegrationTests: XCTestCase { + + private class TestLoggerDelegate: NSObject, SentryLoggerDelegate { + let capturedLogs = Invocations() + + func capture(log: SentryLog) { + capturedLogs.record(log) + } + } + + private class Fixture { + let options: Options + let client: TestClient + let delegate: TestLoggerDelegate + let logger: SentryLogger + let testQueue: TestSentryDispatchQueueWrapper + + init() { + options = Options() + options.enableLogs = true + + client = TestClient(options: options)! + delegate = TestLoggerDelegate() + logger = SentryLogger(delegate: delegate, dateProvider: TestCurrentDateProvider()) + + testQueue = TestSentryDispatchQueueWrapper() + testQueue.dispatchAsyncExecutesBlock = true + } + + func getIntegration() -> SentryStdOutLogIntegration { + return SentryStdOutLogIntegration(dispatchQueue: testQueue, logger: logger) + } + } + + private var fixture: Fixture! + + override func setUp() { + super.setUp() + fixture = Fixture() + } + + override func tearDown() { + super.tearDown() + clearTestState() + } + + func testInstallWithLogsEnabled() { + let integration = fixture.getIntegration() + let installed = integration.install(with: fixture.options) + + XCTAssertTrue(installed, "Integration should install when logs are enabled") + + // Clean up + integration.uninstall() + } + + func testInstallWithLogsDisabled() { + let options = Options() + options.enableLogs = false + + let integration = fixture.getIntegration() + let result = integration.install(with: options) + + XCTAssertFalse(result, "Integration should not install when logs are disabled") + } + + func testUninstall() { + let integration = fixture.getIntegration() + let installed = integration.install(with: fixture.options) + XCTAssertTrue(installed, "Integration should install first") + + // Uninstall should not crash + integration.uninstall() + + // Test that we can uninstall multiple times without issues + integration.uninstall() + } + + func testStdoutCapture() throws { + let integration = fixture.getIntegration() + _ = integration.install(with: fixture.options) + + print("App stdout message from print") + expect("Wait for stdout capture to trigger async dispatch") + + let log = try XCTUnwrap(fixture.delegate.capturedLogs.first) + XCTAssertEqual(log.level, SentryLog.Level.info, "Should use info level for stdout") + XCTAssertTrue(log.body.contains("App stdout message from print"), "Should contain the stdout test message") + XCTAssertEqual(log.attributes["sentry.log.source"]?.value as? String, "stdout", "Should have stdout source attribute") + + // Clean up + integration.uninstall() + } + + func testStderrCapture() throws { + let integration = fixture.getIntegration() + _ = integration.install(with: fixture.options) + + // Use NSLog to write to stderr (this should be captured) + NSLog("App stderr message from NSLog") + expect("Wait for stderr capture to trigger async dispatch") + + let log = try XCTUnwrap(fixture.delegate.capturedLogs.first) + XCTAssertEqual(log.level, SentryLog.Level.warn, "Should use warn level for stderr") + XCTAssertTrue(log.body.contains("App stderr message from NSLog"), "Should contain the stderr test message") + XCTAssertEqual(log.attributes["sentry.log.source"]?.value as? String, "stderr", "Should have stderr source attribute") + + // Clean up + integration.uninstall() + } + + func testSentryLogsAreIgnored() throws { + let integration = fixture.getIntegration() + _ = integration.install(with: fixture.options) + + SentrySDKLog.error("This is a internal SentrySDK NSLog log message") + + print("A normal log") + expect("Wait for second normal log capture") + + // Verify only 1 log was captured (the [Sentry] logs were skipped) + XCTAssertEqual(fixture.delegate.capturedLogs.count, 1, "Only non-Sentry logs should be captured") + + let log = try XCTUnwrap(fixture.delegate.capturedLogs.first) + XCTAssertTrue(log.body.contains("A normal log"), "Only the normal log should be captured") + XCTAssertFalse(log.body.contains("[Sentry]"), "Sentry internal log should not be captured") + + // Clean up + integration.uninstall() + } + + // Helper + + private func expect(_ description: String) { + // Record the initial count of async dispatch invocations + let initialAsyncCount = fixture.testQueue.dispatchAsyncInvocations.count + + // Wait for the log handler to be dispatched to its queue + let expectation = XCTestExpectation(description: description) + let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in + let currentAsyncCount = self.fixture.testQueue.dispatchAsyncInvocations.count + if currentAsyncCount > initialAsyncCount { + expectation.fulfill() + timer.invalidate() + } + } + + wait(for: [expectation], timeout: 1) + timer.invalidate() + } +} diff --git a/Tests/SentryTests/SentryTests-Bridging-Header.h b/Tests/SentryTests/SentryTests-Bridging-Header.h index 694f10fb78e..eb3e6da8c94 100644 --- a/Tests/SentryTests/SentryTests-Bridging-Header.h +++ b/Tests/SentryTests/SentryTests-Bridging-Header.h @@ -159,6 +159,7 @@ #import "SentrySpotlightTransport.h" #import "SentryStacktrace.h" #import "SentryStacktraceBuilder.h" +#import "SentryStdOutLogIntegration.h" #import "SentrySubClassFinder.h" #import "SentrySwift.h" #import "SentrySwiftAsyncIntegration.h"