Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
// swift-tools-version:5.10
// swift-tools-version:6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

// Broadcast requires Swift 6.0+: it uses `Synchronization.Mutex` and, on non-Apple platforms,
// `Foundation.FormatStyle` — both of which only exist in the Swift 6.0 toolchain on Linux.
//
// Boutique (and its Bodega/SQLite/swift-collections chain) imports `CryptoKit`, which is
// unavailable on non-Apple platforms, so its *target* dependency is gated to Apple platforms
// with `.when(platforms:)`. On Linux SwiftPM still resolves Boutique but never compiles it,
// and `MultiSessionLogger` (guarded by `#if canImport(Boutique)`) compiles to nothing.
// `ConsoleLogger` is likewise gated on `#if canImport(OSLog)`.
//
// `swiftLanguageModes: [.v5]` keeps Swift 5 language mode so existing `static var` globals do
// not become hard concurrency errors under Swift 6 mode.
let package = Package(
name: "Broadcast",
platforms: [
Expand All @@ -23,15 +34,24 @@ let package = Package(
.target(
name: "Broadcast",
dependencies: [
.product(name: "Boutique", package: "Boutique")
.product(
name: "Boutique",
package: "Boutique",
condition: .when(platforms: [.iOS, .macOS, .tvOS, .watchOS, .visionOS])
)
]
),
.testTarget(
name: "BroadcastTests",
dependencies: [
.product(name: "Boutique", package: "Boutique"),
"Broadcast"
"Broadcast",
.product(
name: "Boutique",
package: "Boutique",
condition: .when(platforms: [.iOS, .macOS, .tvOS, .watchOS, .visionOS])
)
]
)
]
],
swiftLanguageModes: [.v5]
)
16 changes: 12 additions & 4 deletions Sources/Broadcast/Log/Log.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,22 +88,30 @@ public extension Log {
/// lifetime control, deterministic tests, or multiple independently exported buffers.
static let sessionLogger = SessionLogger()

#if canImport(OSLog)
/// Broadcast's shared console destination.
///
/// Prefer creating your own ``ConsoleLogger`` with your app's subsystem and category
/// for production integrations.
static let consoleLogger = ConsoleLogger(subsystem: "com.mergesort.broadcast", category: "logs")
#endif

/// A convenience log that writes to Broadcast's default console and session destinations.
///
/// This is useful for quick integration or examples. Apps that need support-log
/// export, privacy-specific routing, or dependency injection should construct
/// their own ``Log``.
///
/// ``ConsoleLogger`` is only included on Apple platforms where OSLog is available;
/// elsewhere the default log writes to the ``SessionLogger`` alone.
static let `default` = Log(
destinations: [
Log.consoleLogger,
Log.sessionLogger
]
destinations: {
#if canImport(OSLog)
return [Log.consoleLogger, Log.sessionLogger]
#else
return [Log.sessionLogger]
#endif
}()
)
}

Expand Down
66 changes: 35 additions & 31 deletions Sources/Broadcast/Loggers/ConsoleLogger.swift
Original file line number Diff line number Diff line change
@@ -1,39 +1,43 @@
import OSLog
#if canImport(OSLog)
import OSLog

/// A destination that writes Broadcast logs to Apple's unified logging system.
///
/// Use ``ConsoleLogger`` when you want logs to appear in Console.app, Xcode, or OSLog
/// collection tools. Apps should usually initialize it with their own subsystem and
/// category so logs can be inspected separately from Broadcast internals. Broadcast
/// writes rendered log values with public privacy, so do not send secrets, tokens, or
/// sensitive user data to this destination.
public struct ConsoleLogger: LoggingDestination {
private let logger: Logger

/// Creates an OSLog-backed destination.
/// A destination that writes Broadcast logs to Apple's unified logging system.
///
/// Use your app or framework bundle identifier as the subsystem and a stable area
/// name as the category.
public init(subsystem: String, category: String) {
self.logger = Logger(subsystem: subsystem, category: category)
}
/// Use ``ConsoleLogger`` when you want logs to appear in Console.app, Xcode, or OSLog
/// collection tools. Apps should usually initialize it with their own subsystem and
/// category so logs can be inspected separately from Broadcast internals. Broadcast
/// writes rendered log values with public privacy, so do not send secrets, tokens, or
/// sensitive user data to this destination.
///
/// This destination is only available on Apple platforms where OSLog can be imported.
public struct ConsoleLogger: LoggingDestination {
private let logger: Logger

public func log(_ record: Log.Record) {
let text = self.recordFormatter.format(record)
self.logger.log(level: record.level.osLogType, "\(text, privacy: .public)")
/// Creates an OSLog-backed destination.
///
/// Use your app or framework bundle identifier as the subsystem and a stable area
/// name as the category.
public init(subsystem: String, category: String) {
self.logger = Logger(subsystem: subsystem, category: category)
}

public func log(_ record: Log.Record) {
let text = self.recordFormatter.format(record)
self.logger.log(level: record.level.osLogType, "\(text, privacy: .public)")
}
}
}

// MARK: Log.Level
// MARK: Log.Level

private extension Log.Level {
var osLogType: OSLogType {
switch self {
case .debug: .debug
case .info: .info
case .warn: .default
case .error: .error
case .fault: .fault
private extension Log.Level {
var osLogType: OSLogType {
switch self {
case .debug: .debug
case .info: .info
case .warn: .default
case .error: .error
case .fault: .fault
}
}
}
}
#endif
3 changes: 3 additions & 0 deletions Sources/Broadcast/Loggers/MultiSessionLogger.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if canImport(Boutique)
import Boutique
import Foundation
import Synchronization
Expand Down Expand Up @@ -137,3 +138,5 @@ private final class MultiSessionLogStorage {
try? await self.$records.removeAll()
}
}

#endif
19 changes: 18 additions & 1 deletion Tests/BroadcastTests/Log+Structured.Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@
import Foundation
import Testing

extension Trait where Self == ConditionTrait {
/// Disables the annotated test when OSLog is unavailable (non-Apple platforms), because
/// `ConsoleLogger` is not compiled into that build. The test is reported as disabled
/// rather than silently passing.
static var requiresConsoleLogger: Self {
#if canImport(OSLog)
.enabled(if: true)
#else
.disabled("Requires OSLog, which is only available on Apple platforms")
#endif
}
}

struct StructuredLogTests {
@Test
func formatsRecordWithDefaultBroadcastFormatStyle() {
Expand Down Expand Up @@ -338,8 +351,9 @@ struct StructuredLogTests {
#expect(record.formatted(style) == "[42] canonical-log-line level=info signal=Metric category=Sync message=\"Measured reminder sync\" duration=1.25s")
}

@Test
@Test(.requiresConsoleLogger)
func consoleLoggerUsesDefaultRecordFormatter() {
#if canImport(OSLog)
let logger = ConsoleLogger(subsystem: "com.mergesort.BroadcastTests", category: "logs")
let record = Log.Record(
timestamp: Log.Timestamp(Date(timeIntervalSince1970: 0)),
Expand All @@ -351,6 +365,9 @@ struct StructuredLogTests {
)

#expect(logger.recordFormatter.format(record) == "[Warn | Event | Notifications] @ 1970-01-01T00:00:00Z | Reached retry threshold | payload=[attempts=3]")
#else
Issue.record("OSLog is unavailable on this platform; this test should not run.")
#endif
}

@Test
Expand Down
Loading