While the entire systemd
project has historically been rather polarizing, I have found it to be easier to maintain for my own environments.
When working on services on windows
or MacOS
, I have come to miss having journald when running services.
MacOS launchd services let you StandardOutPath and StandardErrorPath, each application can set them to whatever path they want.
While applications can directly use MacOS unified logging, I wanted to prototype an idea of launchd handling directing files.
Since I can not (easily) modify launchd, I decided to write a redirect-output helper, to prototype how this might look.
The goal, is that the running service itself, should not need to worry about stdout and stderr, similar to how applications running under systemd do not.
This means we need to have a program to wrap our target.
I decided on the following api for an example ~/Library/LaunchAgents/net.example.my-app.plist.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.my-app</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/redirect-output</string>
<string>com.example.my-app</string>
<string>/path/to/my-app</string>
<string>--additional=arguments</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
At a high level, our app needs to configure a Pipe to redirect our stdout and stderr to a Logger instance.
Using SwiftCommandParser, I have the basic setup
@main
struct RedirectTool: AsyncParsableCommand {
@Argument(help: "Subsystem for logging. Usually reverse dns notation")
public var subsystem: String
@Option(help: "Log category to use")
public var category: String = "default"
@Argument(parsing: .captureForPassthrough, help: "Sub command to run")
var remaining: [String] = []
public func run() async throws {}
}
First we need to setup the subprocess we want to call
let process = Process()
process.executableURL = URL(fileURLWithPath: remaining[0])
process.arguments = Array(remaining.dropFirst())
I like to use extensions to group code, so I also setup a few
extension Pipe {
func forwardTo(log: @Sendable @escaping (String) -> Void) { }
func emptyTo(log: @Sendable @escaping (String) -> Void) { }
}
extension Process {
func forwardSignal(_ sig: Int32) -> DispatchSourceSignal { }
}
to configure logging, the API I came up with looks like this.
// Logging Setup
let logger = Logger(subsystem: subsystem, category: category)
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
stdoutPipe.forwardTo(log: { logger.info("\($0, privacy: .public)") })
stderrPipe.forwardTo(log: { logger.error("\($0, privacy: .public)") })
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
try process.run()
Once the process is running a child, we also configure our signal forwarding
let sigterm = process.forwardSignal(SIGTERM)
let sigint = process.forwardSignal(SIGINT)
let sighup = process.forwardSignal(SIGHUP)
With that configured, we need to do cleanup when the application ends. This includes logging any remaining lines and restoring signals.
let status: Int32 = await withCheckedContinuation { continuation in
process.terminationHandler = { proc in
// Close our pipes
stdoutPipe.emptyTo(log: { logger.info("\($0, privacy: .public)") })
stderrPipe.emptyTo(log: { logger.error("\($0, privacy: .public)") })
// Cancel our signal forwarding
sigterm.cancel()
sigint.cancel()
sighup.cancel()
// Return status code
continuation.resume(returning: proc.terminationStatus)
}
}
// Because we're using AsyncParsableCommand we throw an ExitCode to return
// even if it looks strange
throw ExitCode(rawValue: status)
The internals of Pipe.forwardTo, Pipe.emptyTo and Process.forwardSignal are bits that I am not super sure of yet, and need to think how to test better, but the basic prototype seems to work well.
swift run redirect-output net.example /usr/bin/uname -a
swift run tail-output --subsystem net.example --level debug
log stream --predicate 'net.example' == subsystem --level debug
Filtering the log data using ""net.example" == subsystem"
Timestamp Thread Type Activity PID TTL
2026-06-08 19:16:49.311036+0900 0x604d15 Info 0x0 66622 0 redirect-output: [net.example:default] Darwin my-pc.example.net 24.6.0 Darwin Kernel Version 24.6.0: Tue Apr 21 20:17:54 PDT 2026; root:xnu-11417.140.69.710.16~1/RELEASE_X86_64 x86_64
I still need to play with the idea more, but for now, I have placed the code here. https://codeberg.org/kfdm/swift-output-redirect I wrote this draft in Swift, because that seemed natural to handle connections to MacOS logging, but with the simplicity of the overall structure, something like rust could also be a choice to test with.