A lightweight, thread-safe logging package for Swift apps using os.log under the hood.
Designed for clarity, ergonomics, and performance — without encryption.
Supports granular log levels, category filtering, custom formatting, and pluggable sinks.
- Clear log levels with emoji-first output; short codes are opt-in
- Reusable
LogCategorytype to avoid stringly-typed categories - Per-category
Loggerinstances for Console/Xcode filtering - Thread-safe, async logging via a dedicated queue
- os.log integration with sensible OSLogType mapping
- Category filtering through configuration (
Set<LogCategory>) - Customizable formatter using
LogConfiguration.FormatterContext - Pluggable sinks for mirroring logs (e.g., tests, files, remote endpoints)
- Minimal API for everyday logging via static
Loghelpers
Install via SPM from https://github.com/SenpaiHunters/Scribe.git
.package(url: "https://github.com/SenpaiHunters/Scribe.git", branch: "main")Then import in your source files:
import Scribeimport Scribe
// Define reusable categories once
extension LogCategory {
static let app = LogCategory("App")
static let auth = LogCategory("Auth")
static let network = LogCategory("NetworkLayer")
static let apiService = LogCategory("APIService")
static let profile = LogCategory("Profile")
}
// Set minimum level (messages below this level are ignored)
LogManager.shared.minimumLevel = .debug
// Optional: restrict logging to specific categories
let config = LogConfiguration(enabledCategories: [.network, .apiService])
LogManager.shared.configuration = config
// Optional: add a sink (e.g., for tests or file mirroring)
LogManager.shared.addSink { line in
print("SINK:", line)
}
// Log messages anywhere in your app
Log.debug("Bootstrapping app", category: .app)
Log.info("User signed in", category: .auth)
Log.warn("Slow response", category: .network)
Log.error("Failed to decode payload", category: .apiService)
Log.success("Profile updated", category: .profile)Represents message severity and domain.
- Families: development, general, problems, success, networking, security, performance, ui, data
- Helpers:
LogLevel.parse("ERR")→.errorLogLevel.levels(minimum: .info)— all levels at or above infoLogLevel.levels(in: .networking)— levels in a familyLogLevel.families()— all family cases
- Display:
level.emoji(e.g., "⚠️ ")level.shortCode(e.g., "WRN")level.name(e.g., "warning")
- Predefined sets:
LogLevel.allSevere— error, fatalLogLevel.allProblems— warning, error, fatalLogLevel.noisyLevels— trace, debug, print
- OS integration:
level.osLogTypemaps to OSLogType
Lightweight wrapper for reusable, strongly typed categories.
- Defaults to
LogCategory(#fileID)when you omit thecategoryparameter. - Extend it once and reuse everywhere to avoid typo-prone string literals:
extension LogCategory {
static let apiService = LogCategory("APIService")
static let storage = LogCategory("Storage")
}- Each category maps to its own
Loggerinstance under the same subsystem, so Console and Xcode show first-class category filters without extra configuration.
Automatically generates a log property for classes, structs, and enums.
@Loggable
struct TokenManager {
func refresh() {
log.info("Refreshing token")
}
}Options:
@Loggable— uses the type name as the category@Loggable("CustomName")— uses a custom category name@Loggable(category: .network)— uses an existingLogCategory@Loggable(style: .static)— generates a staticlogproperty instead of instance
Core logger with configuration and sinks.
- Properties:
LogManager.shared— singleton instanceminimumLevel: LogLevel— threshold (read/write)configuration: LogConfiguration— formatting and filtering (read/write)sinkCount: Int— number of registered sinksloggerCacheCount: Int— number of cachedLoggerinstances
- Methods:
log(_:level:category:file:function:line:)— core logging methodsetMinimumLevel(_:)— async level settergetMinimumLevel(_:)— async level getter with completion handlersetConfiguration(_:)— async configuration settergetConfiguration(_:)— async configuration getter with completion handleraddSink(categories:_:) -> LogSubscription— register a sink with optional category filter, returns ID for removalremoveSink(_:)— remove a specific sink by IDremoveAllSinks()— remove all sinksstream(categories:) -> AsyncStream<String>— create an async stream of log messages with optional category filterclearLoggerCache(completion:)— clear cachedLoggerinstances
Configuration struct for customizing log output.
enabledCategories: Set<LogCategory>?— categories to include;nilallows allformatter: ((LogConfiguration.FormatterContext) -> String)?— custom formatterincludeTimestamp: Bool— include timestamps (default:true)includeEmoji: Bool— include level emoji (default:true)includeShortCode: Bool— include level short code like[DBG](default:false)includeFileAndLineNumber: Bool— include source file and line number (default:true)autoLoggerCacheLimit: Int?— limit cached auto-generatedLoggerinstances (e.g.,#fileID);nilmeans unbounded; default is 100dateFormat: String— timestamp format (default:"yyyy-MM-dd HH:mm:ss.SSSZ")
FormatterContext fields:
level: LogLevelcategory: LogCategorymessage: Stringfile: Stringline: Inttimestamp: Date
Default formatter output:
[timestamp] [emoji] [Category] Message — File.swift:123
Example:
2025-11-28 10:15:30.123+1000 🔍 [App] Bootstrapping app — AppDelegate.swift:42
Ergonomic static helpers that auto-fill file/function/line:
- Development:
Log.trace,Log.debug,Log.print - Info:
Log.info,Log.notice - Problems:
Log.warn,Log.error,Log.fatal - Success:
Log.success,Log.done - Networking:
Log.network,Log.api - Security:
Log.security,Log.auth - Performance:
Log.metric,Log.analytics - UI & User:
Log.ui,Log.user - Data:
Log.database,Log.storage
Usage:
extension LogCategory {
static let apiService = LogCategory("APIService")
static let perf = LogCategory("Perf")
static let ui = LogCategory("UI")
}
Log.api("GET /v1/profile", category: .apiService)
Log.metric("Home render time: 34ms", category: .perf)
Log.user("Tapped Purchase", category: .ui)// Only log messages at info level or higher
LogManager.shared.minimumLevel = .infoRestrict logging to specific categories:
extension LogCategory {
static let networkLayer = LogCategory("NetworkLayer")
static let auth = LogCategory("Auth")
static let apiService = LogCategory("APIService")
}
let config = LogConfiguration(enabledCategories: [.networkLayer, .auth, .apiService])
LogManager.shared.configuration = configTo allow all categories:
let config = LogConfiguration(enabledCategories: nil)
LogManager.shared.configuration = configConsole/Xcode filtering: Each LogCategory uses its own Logger under the shared subsystem (bundle identifier by default). In Console.app or Xcode, filter by subsystem=<your bundle id> and category=<LogCategory name> to zero in on specific modules.
Provide your own formatter for complete control over log output. The formatter receives a single FormatterContext, so you don't need to juggle multiple parameters:
let config = LogConfiguration(
formatter: { context in
let fileName = (context.file as NSString).lastPathComponent
return "[\(context.level.shortCode)] [\(context.category.name)] \(context.message) (\(fileName):\(context.line))"
}
)
LogManager.shared.configuration = config- Default: emojis on, short codes off.
- Turn off emojis:
let config = LogConfiguration(includeEmoji: false)
LogManager.shared.configuration = config- Turn on short codes (and optionally keep emojis):
let config = LogConfiguration(includeEmoji: true, includeShortCode: true)
LogManager.shared.configuration = configlet config = LogConfiguration(includeTimestamp: false)
LogManager.shared.configuration = configlet config = LogConfiguration(dateFormat: "HH:mm:ss")
LogManager.shared.configuration = config- Cap cached auto-generated
Loggerinstances (e.g.,#fileID) to avoid unbounded growth:
let config = LogConfiguration(autoLoggerCacheLimit: 50)
LogManager.shared.configuration = config- Clear both auto-generated and custom logger caches (for long-running sessions or tests):
LogManager.shared.clearLoggerCache()- Notes:
- Auto-generated categories (
#fileID) are cached with a default limit of 100. When the limit is reached, the least recently used loggers are removed first. - Custom categories you define (e.g.,
LogCategory("APIService")) are cached permanently and not removed.
- Auto-generated categories (
let config = LogConfiguration(
enabledCategories: [.init("App"), .init("Network")],
includeTimestamp: true,
dateFormat: "HH:mm:ss.SSS"
)
LogManager.shared.configuration = configSinks receive formatted log lines and can forward them to files, remote endpoints, or test assertions.
let sinkID = LogManager.shared.addSink { line in
// Write to file
FileAppender.shared.append(line)
// Or send to remote service
RemoteLogger.shared.enqueue(line)
}// Store the ID when adding
let sinkID = LogManager.shared.addSink { line in
print("Captured:", line)
}
// Remove later
LogManager.shared.removeSink(sinkID)LogManager.shared.removeAllSinks()let count = LogManager.shared.sinkCountFor async/await, use stream() instead of callbacks:
let stream = LogManager.shared.stream()
Task {
for await line in stream {
print("Log:", line)
}
}- Logging occurs on a dedicated utility queue to minimize call-site blocking.
os_logdefers formatting efficiently and integrates with Console.app.- Use
minimumLevelto reduce overhead in production. - All configuration access is thread-safe.
- Each
LogCategorygets its ownLogger, so Console/Xcode filtering by category works out of the box.
- Use the
@Loggablemacro to automatically generate alogproperty for your types. - Use categories to group logs by module or feature (e.g.,
LogCategory("APIService"),LogCategory("Storage")). - Raise
minimumLevelin production (e.g.,.infoor.warning). - Avoid logging PII or secrets; this package does not perform encryption or redaction.
- Add a sink for test environments to assert on log output.
- Use
removeSink(_:)to clean up sinks when they're no longer needed.
extension LogCategory {
static let app = LogCategory("App")
}
@Loggable
final class APIService {
func fetchProfile() {
log.api("GET /v1/profile")
// ... network call ...
log.debug("Decoded Profile(id: 123)")
}
}
@main
struct MyApp: App {
init() {
// Configure logging
LogManager.shared.minimumLevel = .info
let config = LogConfiguration(
enabledCategories: [.app, APIService.logCategory],
includeTimestamp: true
)
LogManager.shared.configuration = config
Log.info("App launched", category: .app)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}func testLogging() {
let expectation = XCTestExpectation(description: "Log captured")
let sinkID = LogManager.shared.addSink { line in
XCTAssertTrue(line.contains("Test message"))
expectation.fulfill()
}
Log.info("Test message", category: .init("Test"))
wait(for: [expectation], timeout: 2.0)
LogManager.shared.removeSink(sinkID)
}Scribe is released under BSD 3-Clause License. See the LICENSE file in the repository for the full license text.