diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6a967d269..4f6f3e9ed7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -401,6 +401,38 @@ jobs: if: failure() run: ./scripts/ci-diagnostics.sh + # This will be replaced once #6945 is merged. + pulse-integration-unit-tests: + name: SentryPulse Unit Tests + if: needs.files-changed.outputs.run_unit_tests_for_prs == 'true' + needs: files-changed + runs-on: macos-15 + steps: + - uses: actions/checkout@v6 + + - name: Select Xcode + run: ./scripts/ci-select-xcode.sh 16.4 + + - name: Setup local sentry-cocoa dependency + working-directory: 3rd-party-integrations/SentryPulse + run: swift package edit sentry-cocoa --path ../.. + + - name: Run Pulse tests + working-directory: 3rd-party-integrations/SentryPulse + run: swift test + + - name: Archiving Raw Logs + uses: actions/upload-artifact@v5 + if: ${{ failure() || cancelled() }} + with: + name: raw-output-pulse-integration + path: | + 3rd-party-integrations/SentryPulse/.build/**/*.log + + - name: Run CI Diagnostics + if: failure() + run: ./scripts/ci-diagnostics.sh + # This check validates that either all unit tests passed or were skipped, which allows us # to make unit tests a required check with only running the unit tests when required. # So, we don't have to run unit tests, for example, for Changelog or ReadMe changes. diff --git a/3rd-party-integrations/SentryPulse/.gitignore b/3rd-party-integrations/SentryPulse/.gitignore new file mode 100644 index 0000000000..cffaa2c670 --- /dev/null +++ b/3rd-party-integrations/SentryPulse/.gitignore @@ -0,0 +1,100 @@ +# --- macOS --- + +# General +.DS_Store +__MACOSX/ +.AppleDouble +.LSOverride +Icon[] + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# --- Swift --- + +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# --- Xcode --- + +## User settings +xcuserdata/ + +# Archive +*.xcarchive diff --git a/3rd-party-integrations/SentryPulse/Package.swift b/3rd-party-integrations/SentryPulse/Package.swift new file mode 100644 index 0000000000..b16dfe18db --- /dev/null +++ b/3rd-party-integrations/SentryPulse/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "SentryPulse", + platforms: [.iOS(.v15), .macOS(.v13), .tvOS(.v15), .watchOS(.v9), .visionOS(.v1)], + products: [ + .library( + name: "SentryPulse", + targets: ["SentryPulse"] + ) + ], + dependencies: [ + .package(url: "https://github.com/kean/Pulse", from: "5.0.0"), + .package(url: "https://github.com/getsentry/sentry-cocoa", from: "9.0.0") + ], + targets: [ + .target( + name: "SentryPulse", + dependencies: [ + .product(name: "Pulse", package: "Pulse"), + .product(name: "Sentry", package: "sentry-cocoa") + ] + ), + .testTarget( + name: "SentryPulseTests", + dependencies: [ + "SentryPulse", + .product(name: "Pulse", package: "Pulse"), + .product(name: "Sentry", package: "sentry-cocoa") + ] + ) + ] +) diff --git a/3rd-party-integrations/SentryPulse/README.md b/3rd-party-integrations/SentryPulse/README.md new file mode 100644 index 0000000000..76ba619e7e --- /dev/null +++ b/3rd-party-integrations/SentryPulse/README.md @@ -0,0 +1,111 @@ +# Sentry Pulse Integration + +A [Pulse](https://github.com/kean/Pulse) integration that automatically forwards log entries to Sentry's structured logging system, capturing application logs with full context including metadata, source location, and log levels. + +> [!NOTE] +> This repo is a mirror of [github.com/getsentry/sentry-cocoa](https://github.com/getsentry/sentry-cocoa). The source code lives in `3rd-party-integrations/SentryPulse/`. This allows users to import only what they need via SPM while keeping all integration code in the main repository. + +## Installation + +### Swift Package Manager + +Add the following dependencies to your `Package.swift` or Xcode package manager: + +```swift +dependencies: [ + .package(url: "https://github.com/getsentry/sentry-cocoa-pulse", from: "9.0.0") +] +``` + +## Quick Start + +```swift +import Sentry +import Pulse +import SentryPulse + +SentrySDK.start { options in + options.dsn = "YOUR_DSN" + options.enableLogs = true +} + +SentryPulse.start() + +let logger = LoggerStore.shared.makeLogger(label: "com.example.app") +logger.info("User logged in", metadata: ["userId": "12345"]) +logger.error("Payment failed", metadata: ["errorCode": 500]) +``` + +## Configuration + +### Custom LoggerStore + +By default, `SentryPulse.start()` uses `LoggerStore.shared`. You can specify a custom `LoggerStore`: + +```swift +let customStore = try LoggerStore(storeURL: customURL, options: [.inMemory]) +SentryPulse.start(loggerStore: customStore) +``` + +### Lifecycle Management + +The integration automatically observes Pulse's `LoggerStore.events` publisher and forwards all `.messageStored` events to Sentry. You can stop the integration if needed: + +```swift +SentryPulse.stop() +SentryPulse.start() +``` + +Calling `start()` multiple times has no effect - the integration is started only once. + +## Log Level Mapping + +Pulse log levels are automatically mapped to Sentry log levels: + +| Pulse Level | Sentry Log Level | +| ----------- | ---------------- | +| `.trace` | `.trace` | +| `.debug` | `.debug` | +| `.info` | `.info` | +| `.notice` | `.info` | +| `.warning` | `.warn` | +| `.error` | `.error` | +| `.critical` | `.fatal` | + +## Metadata Handling + +All Pulse metadata is automatically forwarded to Sentry with the `pulse.` prefix: + +```swift +logger.info("User action", metadata: [ + "userId": "12345", + "action": "purchase" +]) +``` + +The metadata will appear in Sentry as `pulse.userId` and `pulse.action`. + +Pulse metadata is already converted to `[String: String]` format before being forwarded, ensuring compatibility with Sentry's attribute system. + +## Automatic Attributes + +The integration automatically includes the following attributes with every log entry: + +- `sentry.origin`: `"auto.logging.pulse"` +- `pulse.level`: The original Pulse level name +- `pulse.label`: The logger label +- `pulse.file`: The source file name (if available) +- `pulse.function`: The function name (if available) +- `pulse.line`: The line number + +All Pulse metadata is prefixed with `pulse.` in Sentry attributes (e.g., `pulse.userId`, `pulse.action`). + +## Documentation + +- [Sentry Cocoa SDK Documentation](https://docs.sentry.io/platforms/apple/) +- [Sentry Logs Documentation](https://docs.sentry.io/platforms/apple/logs/) +- [Pulse Repository](https://github.com/kean/Pulse) + +## License + +This integration follows the same license as the Sentry Cocoa SDK. See the [LICENSE](https://github.com/getsentry/sentry-cocoa/blob/main/LICENSE.md) file for details. diff --git a/3rd-party-integrations/SentryPulse/Sources/SentryPulse.swift b/3rd-party-integrations/SentryPulse/Sources/SentryPulse.swift new file mode 100644 index 0000000000..328423a186 --- /dev/null +++ b/3rd-party-integrations/SentryPulse/Sources/SentryPulse.swift @@ -0,0 +1,175 @@ +import Combine +import Pulse +import Sentry + +/// Automatically forwards Pulse log messages to Sentry's structured logging system. +/// +/// `SentryPulse` observes Pulse's `LoggerStore.events` publisher and automatically +/// forwards all `.messageCreated` events to Sentry. This provides seamless integration between +/// Pulse and Sentry without requiring any changes to existing logging code. +/// +/// ## How It Works +/// - Subscribes to Pulse's `LoggerStore.events` publisher +/// - Listens for `.messageCreated` events +/// - Automatically forwards each log message to Sentry with appropriate level mapping +/// - Preserves all metadata and source information +/// +/// ## Level Mapping +/// Pulse log levels are mapped to Sentry log levels: +/// - `.trace` → `.trace` +/// - `.debug` → `.debug` +/// - `.info` → `.info` +/// - `.notice` → `.info` (notice maps to info as SentryLog doesn't have notice) +/// - `.warning` → `.warn` +/// - `.error` → `.error` +/// - `.critical` → `.fatal` +/// +/// ## Usage +/// +/// ```swift +/// import Pulse +/// import Sentry +/// import SentryPulse +/// +/// SentrySDK.start { options in +/// options.dsn = "YOUR_DSN" +/// } +/// +/// // Setup Pulse... +/// +/// SentryPulse.start() +/// ``` +/// +/// ## Lifecycle Management +/// - Call `SentryPulse.start()` once during app initialization to enable integration +/// - Call `SentryPulse.stop()` to disable log forwarding +/// - Integration persists for the lifetime of the app (unless explicitly stopped) +/// +/// - Note: Sentry Logs is currently in Beta. See the [Sentry Logs Documentation](https://docs.sentry.io/platforms/apple/logs/). +public final class SentryPulse { + + // The `nonisolated(unsafe)` attribute is used to silence compiler warnings, + // as the lock ensures that access is thread-safe. + private nonisolated(unsafe) static var shared: SentryPulse? + private static let lock = NSLock() + + private var cancellable: AnyCancellable? + private let sentryLogger: SentryLogger + + /// Starts forwarding Pulse logs to Sentry. + /// + /// Call this method once during app initialization, after initializing the Sentry SDK. + /// The integration will remain active for the lifetime of the app (or until `stop()` is called). + /// + /// ```swift + /// import Pulse + /// import Sentry + /// import SentryPulse + /// + /// SentrySDK.start { options in + /// options.dsn = "YOUR_DSN" + /// } + /// + /// // Setup Pulse... + /// + /// SentryPulse.start() + /// ``` + /// + /// - Parameters: + /// - loggerStore: The Pulse `LoggerStore` to observe. Defaults to `.shared`. + /// + /// - Note: Calling this method multiple times has no effect. The integration is started only once. + public static func start(loggerStore: LoggerStore = .shared) { + startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + } + + // Internal method for testing + static func startInternal(loggerStore: LoggerStore, sentryLogger: SentryLogger) { + lock.lock() + defer { lock.unlock() } + + guard shared == nil else { return } + shared = SentryPulse(loggerStore: loggerStore, sentryLogger: sentryLogger) + } + + /// Stops forwarding Pulse logs to Sentry. + /// + /// After calling this method, Pulse logs will no longer be sent to Sentry. + /// You can call `start()` again to re-enable the integration. + public static func stop() { + lock.lock() + defer { lock.unlock() } + + shared?.stop() + shared = nil + } + + // Internal initializer for testing + init(loggerStore: LoggerStore, sentryLogger: SentryLogger) { + self.sentryLogger = sentryLogger + cancellable = loggerStore.events + .sink { [weak self] event in + guard let self = self else { return } + + if case .messageStored(let message) = event { + self.forwardMessageToSentry(message) + } + } + } + + func stop() { + cancellable?.cancel() + cancellable = nil + } + + deinit { + stop() + } + + // MARK: - Private Implementation + + private func forwardMessageToSentry(_ message: LoggerStore.Event.MessageCreated) { + // Build attributes with Pulse-specific metadata + var attributes: [String: Any] = [:] + attributes["sentry.origin"] = "auto.logging.pulse" + attributes["pulse.level"] = message.level.name + attributes["pulse.label"] = message.label + if !message.file.isEmpty { + attributes["pulse.file"] = message.file + } + if !message.function.isEmpty { + attributes["pulse.function"] = message.function + } + attributes["pulse.line"] = message.line + + // Add metadata from Pulse message (already converted to [String: String]) + if let metadata = message.metadata { + for (key, value) in metadata { + attributes["pulse.\(key)"] = value + } + } + + // Forward to Sentry logger with appropriate level + logToSentry(message.level, message: message.message, attributes: attributes) + } + + private func logToSentry(_ level: LoggerStore.Level, message: String, attributes: [String: Any]) { + switch level { + case .trace: + sentryLogger.trace(message, attributes: attributes) + case .debug: + sentryLogger.debug(message, attributes: attributes) + case .info: + sentryLogger.info(message, attributes: attributes) + case .notice: + // Map notice to info as SentryLog doesn't have notice + sentryLogger.info(message, attributes: attributes) + case .warning: + sentryLogger.warn(message, attributes: attributes) + case .error: + sentryLogger.error(message, attributes: attributes) + case .critical: + sentryLogger.fatal(message, attributes: attributes) + } + } +} diff --git a/3rd-party-integrations/SentryPulse/Tests/SentryPulseTests.swift b/3rd-party-integrations/SentryPulse/Tests/SentryPulseTests.swift new file mode 100644 index 0000000000..ec969faa3c --- /dev/null +++ b/3rd-party-integrations/SentryPulse/Tests/SentryPulseTests.swift @@ -0,0 +1,280 @@ +import Pulse +import Sentry +@testable import SentryPulse +import XCTest + +// swiftlint:disable cyclomatic_complexity + +final class SentryPulseTests: XCTestCase { + + private var capturedLogs: [SentryLog] = [] + private var loggerStore: LoggerStore! + + override func setUpWithError() throws { + try super.setUpWithError() + capturedLogs = [] + + // Create in-memory LoggerStore for testing + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + loggerStore = try LoggerStore(storeURL: tempURL, options: [.inMemory]) + + SentrySDK.start { options in + options.dsn = "https://test@test.ingest.sentry.io/123456" + options.enableLogs = true + options.beforeSendLog = { [weak self] log in + self?.capturedLogs.append(log) + return nil + } + } + } + + override func tearDown() { + super.tearDown() + SentryPulse.stop() + SentrySDK.close() + capturedLogs = [] + loggerStore = nil + } + + // MARK: - Basic Logging Tests + + func testLog_WithTraceLevel() throws { + SentryPulse.startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + + loggerStore.storeMessage( + label: "test", + level: .trace, + message: "Test trace message", + metadata: [:], + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + + XCTAssertEqual(capturedLogs.count, 1, "Expected exactly one log to be captured") + let log = try XCTUnwrap(capturedLogs.first) + XCTAssertEqual(log.level, .trace) + XCTAssertEqual(log.body, "Test trace message") + XCTAssertEqual(log.attributes["sentry.origin"]?.value as? String, "auto.logging.pulse") + XCTAssertEqual(log.attributes["pulse.level"]?.value as? String, "trace") + XCTAssertEqual(log.attributes["pulse.label"]?.value as? String, "test") + XCTAssertEqual(log.attributes["pulse.file"]?.value as? String, "TestFile.swift") + XCTAssertEqual(log.attributes["pulse.function"]?.value as? String, "testFunction") + XCTAssertEqual(log.attributes["pulse.line"]?.value as? String, "1") + } + + func testLog_WithDebugLevel() throws { + SentryPulse.startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + + loggerStore.storeMessage( + label: "test", + level: .debug, + message: "Test debug message", + metadata: [:], + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + + XCTAssertEqual(capturedLogs.count, 1) + let log = try XCTUnwrap(capturedLogs.first) + XCTAssertEqual(log.level, .debug) + XCTAssertEqual(log.body, "Test debug message") + XCTAssertEqual(log.attributes["pulse.level"]?.value as? String, "debug") + } + + func testLog_WithInfoLevel() throws { + SentryPulse.startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + + loggerStore.storeMessage( + label: "test", + level: .info, + message: "Test info message", + metadata: [:], + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + + XCTAssertEqual(capturedLogs.count, 1) + let log = try XCTUnwrap(capturedLogs.first) + XCTAssertEqual(log.level, .info) + XCTAssertEqual(log.body, "Test info message") + XCTAssertEqual(log.attributes["pulse.level"]?.value as? String, "info") + } + + func testLog_WithNoticeLevel() throws { + SentryPulse.startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + + loggerStore.storeMessage( + label: "test", + level: .notice, + message: "Test notice message", + metadata: [:], + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + + // Notice should map to info + XCTAssertEqual(capturedLogs.count, 1) + let log = try XCTUnwrap(capturedLogs.first) + XCTAssertEqual(log.level, .info) + XCTAssertEqual(log.body, "Test notice message") + XCTAssertEqual(log.attributes["pulse.level"]?.value as? String, "notice") + } + + func testLog_WithWarningLevel() throws { + SentryPulse.startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + + loggerStore.storeMessage( + label: "test", + level: .warning, + message: "Test warning message", + metadata: [:], + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + + XCTAssertEqual(capturedLogs.count, 1) + let log = try XCTUnwrap(capturedLogs.first) + XCTAssertEqual(log.level, .warn) + XCTAssertEqual(log.body, "Test warning message") + XCTAssertEqual(log.attributes["pulse.level"]?.value as? String, "warning") + } + + func testLog_WithErrorLevel() throws { + SentryPulse.startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + + loggerStore.storeMessage( + label: "test", + level: .error, + message: "Test error message", + metadata: [:], + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + + XCTAssertEqual(capturedLogs.count, 1) + let log = try XCTUnwrap(capturedLogs.first) + XCTAssertEqual(log.level, .error) + XCTAssertEqual(log.body, "Test error message") + XCTAssertEqual(log.attributes["pulse.level"]?.value as? String, "error") + } + + func testLog_WithCriticalLevel() throws { + SentryPulse.startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + + loggerStore.storeMessage( + label: "test", + level: .critical, + message: "Test critical message", + metadata: [:], + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + + XCTAssertEqual(capturedLogs.count, 1) + let log = try XCTUnwrap(capturedLogs.first) + XCTAssertEqual(log.level, .fatal) + XCTAssertEqual(log.body, "Test critical message") + XCTAssertEqual(log.attributes["pulse.level"]?.value as? String, "critical") + } + + // MARK: - Metadata Tests + + func testLog_WithMetadata() throws { + SentryPulse.startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + + let metadata: [String: LoggerStore.MetadataValue] = [ + "user_id": .string("12345"), + "session_id": .string("abc-def-ghi") + ] + + loggerStore.storeMessage( + label: "test", + level: .info, + message: "Test with metadata", + metadata: metadata, + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + + XCTAssertEqual(capturedLogs.count, 1) + let log = try XCTUnwrap(capturedLogs.first) + XCTAssertEqual(log.attributes["pulse.user_id"]?.value as? String, "12345") + XCTAssertEqual(log.attributes["pulse.session_id"]?.value as? String, "abc-def-ghi") + } + + func testLog_WithComplexMetadata() throws { + SentryPulse.startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + + let metadata: [String: LoggerStore.MetadataValue] = [ + "count": .string("42"), + "enabled": .stringConvertible(true), + "score": .stringConvertible(3.14159) + ] + + loggerStore.storeMessage( + label: "test", + level: .info, + message: "Test with complex metadata", + metadata: metadata, + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + + XCTAssertEqual(capturedLogs.count, 1) + let log = try XCTUnwrap(capturedLogs.first) + XCTAssertEqual(log.attributes["pulse.count"]?.value as? String, "42") + XCTAssertEqual(log.attributes["pulse.enabled"]?.value as? String, "true") + XCTAssertEqual(log.attributes["pulse.score"]?.value as? String, "3.14159") + } + + // MARK: - Integration Lifecycle Tests + + func testStaticAPI_CanStartAndStop() { + SentryPulse.startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + loggerStore.storeMessage( + label: "test", + level: .info, + message: "With static API", + metadata: [:], + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + XCTAssertEqual(capturedLogs.count, 1, "Should capture log after start") + + SentryPulse.stop() + loggerStore.storeMessage( + label: "test", + level: .info, + message: "After static stop", + metadata: [:], + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + XCTAssertEqual(capturedLogs.count, 1, "Should not capture additional log after stop") + + SentryPulse.startInternal(loggerStore: loggerStore, sentryLogger: SentrySDK.logger) + loggerStore.storeMessage( + label: "test", + level: .info, + message: "After restart", + metadata: [:], + file: "TestFile.swift", + function: "testFunction", + line: 1 + ) + XCTAssertEqual(capturedLogs.count, 2, "Should capture additionallog after restart") + } +} + +// swiftlint:enable cyclomatic_complexity diff --git a/Utils/VersionBump/main.swift b/Utils/VersionBump/main.swift index fe6ad54076..2ab2ac5491 100644 --- a/Utils/VersionBump/main.swift +++ b/Utils/VersionBump/main.swift @@ -42,7 +42,8 @@ let files = [ "./SentrySwiftUI.podspec", "./Sources/Sentry/SentryMeta.m", "./Tests/HybridSDKTest/HybridPod.podspec", - "./3rd-party-integrations/SentrySwiftLog/Package.swift" + "./3rd-party-integrations/SentrySwiftLog/Package.swift", + "./3rd-party-integrations/SentryPulse/Package.swift" ] // Files that only accept the format x.x.x in order to release an app using the framework.