Forward logs from Apple's built-in OSLog system to BunnyLogs — no third-party dependencies, using only URLSession and a thin wrapper around Logger.
OSLog doesn't support pluggable destinations the way CocoaLumberjack does, so the approach is a
thin BunnyLogger wrapper that calls both os.Logger (for the system log / Xcode console)
and your BunnyLogs endpoint in one call. No interception or swizzling required.
import Foundation
import os
struct BunnyLogger {
private let oslog: Logger
private let uuid: String
private let program: String
private let session: URLSession
private let url: URL
init(subsystem: String, category: String, uuid: String, program: String = "ios") {
self.oslog = Logger(subsystem: subsystem, category: category)
self.uuid = uuid
self.program = program
self.session = URLSession(configuration: .default)
self.url = URL(string: "https://bunnylogs.com/live/\(uuid)")!
}
func debug(_ message: String) { oslog.debug("\(message)"); ship(message, level: "DEBUG") }
func info(_ message: String) { oslog.info("\(message)"); ship(message, level: "INFO") }
func warning(_ message: String) { oslog.warning("\(message)"); ship(message, level: "WARN") }
func error(_ message: String) { oslog.error("\(message)"); ship(message, level: "ERROR") }
func fault(_ message: String) { oslog.fault("\(message)"); ship(message, level: "ERROR") }
private func ship(_ message: String, level: String) {
let body: [String: String] = [
"message": message,
"level": level,
"program": program,
]
guard let data = try? JSONSerialization.data(withJSONObject: body) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = data
session.dataTask(with: request).resume()
}
}
Create a shared instance, typically as a global or in your dependency container:
// Shared across the app
let logger = BunnyLogger(
subsystem: "com.example.myapp",
category: "general",
uuid: "<your-uuid>"
)
Or one per module:
enum Log {
static let network = BunnyLogger(subsystem: "com.example.myapp", category: "network", uuid: "<your-uuid>", program: "ios/network")
static let auth = BunnyLogger(subsystem: "com.example.myapp", category: "auth", uuid: "<your-uuid>", program: "ios/auth")
static let ui = BunnyLogger(subsystem: "com.example.myapp", category: "ui", uuid: "<your-uuid>", program: "ios/ui")
}
Replace <your-uuid> with the UUID from your stream URL (https://bunnylogs.com/live/<uuid>).
logger.info("User signed in: \(userId)")
logger.warning("Cache miss — fetching from network")
logger.error("Payment failed: \(error.localizedDescription)")
// Module-scoped loggers
Log.network.info("Request completed in \(ms)ms")
Log.auth.error("Token refresh failed")
Wrap the ship call in a debug guard to keep local development free of network traffic:
private func ship(_ message: String, level: String) {
#if DEBUG
return
#endif
// ... rest of ship()
}
Or gate on minimum level — only send warnings and above:
private func ship(_ message: String, level: String) {
guard level == "WARN" || level == "ERROR" else { return }
// ...
}
| BunnyLogs field | Source |
|---|---|
message | String passed to info() / error() / etc. |
level | Method name mapped to DEBUG, INFO, WARN, or ERROR |
program | program constructor parameter (default: ios) |
timestamp | Server receipt time (no client timestamp sent) |
session.dataTask(with:).resume() is non-blocking — the network call dispatches to URLSession's own thread pool immediately.<private> in Console.app). The BunnyLogs side receives the full string because it is handled before OSLog's privacy filtering applies.Info.plist or a secrets file rather than hardcoding it in source.URLSession per BunnyLogger instance is fine for low-to-medium volume. For a single high-throughput logger, share one session across instances.level=ERROR and program=ios to get notified on errors via Slack, Telegram, or Discord.