A Swift package that wraps OSLog and streams logs over the network to a listener (TUI or CLI).
StreamLog solves the problem that OSLog cannot be subscribed to or intercepted on iOS. By providing a logger wrapper that apps use instead of os.Logger, logs can be teed to both OSLog (for system log storage) and a network stream (for real-time visibility in a development TUI).
┌─────────────────────────────────────────────────────────────────┐
│ iOS App │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ StreamLogger │───▶│ os.Logger │ │ Bonjour Browser │ │
│ │ │ └──────────────┘ │ finds TUI │ │
│ │ │───▶│ TCP Stream │◀───│ │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼ JSON lines over TCP
┌─────────────────────────────────────────────────────────────────┐
│ macOS │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Bonjour Service │───▶│ Go TUI / CLI │ │
│ │ _fieldlog._tcp │ │ displays logs │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
TUI advertises, apps connect.
- User starts the TUI/CLI listener
- Listener advertises
_fieldlog._tcpvia Bonjour - iOS app browses for
_fieldlog._tcpon launch - App connects to the listener via TCP
- Logs stream as JSON lines until disconnect
This is the most "automagic" approach:
- Start TUI once, all apps automatically find it
- No device selection UI needed
- Multiple apps/devices can connect simultaneously
import StreamLog
// Configure once at app launch
StreamLog.configure(
subsystem: Bundle.main.bundleIdentifier ?? "com.app",
serviceType: "_fieldlog._tcp" // default
)
// Create loggers (mirrors os.Logger pattern)
let log = StreamLogger(category: "network")
// Log messages (same API as os.Logger)
log.debug("Request started")
log.info("User \(userId) logged in")
log.error("Failed to fetch: \(error)")Maps directly to OSLogType:
| StreamLog | OSLogType |
|---|---|
.debug |
.debug |
.info |
.info |
.notice |
.default |
.warning |
.error |
.error |
.fault |
StreamLog.configure(
subsystem: String, // OSLog subsystem (required)
serviceType: String = "_fieldlog._tcp", // Bonjour service type
deviceName: String? = nil // Override device name (default: UIDevice.current.name)
)- Logging: Each log call writes to both
os.Loggerand the stream buffer - Discovery: On configure(), start browsing for
_fieldlog._tcp - Connection: When service found, connect via TCP
- Streaming: Send JSON lines for each log entry
- Heartbeat: Send
pingevery 5s, expectpongwithin 3s, mark dead if missing - Disconnection: On disconnect or timeout, stop heartbeat and retry cached endpoint before Bonjour
- Backgrounding: On app foreground, re-browse and reconnect
- Release builds: All streaming code compiled out via
#if DEBUG
Each log entry is a single JSON object followed by newline:
{"ts":1704825600.123,"subsystem":"com.app","category":"network","level":"info","file":"API.swift","line":42,"function":"fetch()","message":"Request completed","device":"iPhone","deviceId":"a1b2"}Fields:
ts: Unix timestamp with millisecondssubsystem: OSLog subsystemcategory: OSLog categorylevel: debug | info | notice | warning | errorfile: Source filename (basename only)line: Source line numberfunction: Function namemessage: Formatted log messagedevice: Device name (e.g., "iPhone", "iPad")deviceId: Last 4 characters of device identifier (for uniqueness)
All messages are JSON objects terminated by newline (\n).
Ping (iOS to server):
{"type":"ping","ts":1704825600.123}Pong (server to iOS):
{"type":"pong","ts":1704825600.123}Notes:
tsis a Unix timestamp with milliseconds.- Pong echoes the ping timestamp for latency measurement.
- Log messages do not include a
typefield.
Timing parameters:
- Ping interval: 5 seconds
- Pong timeout: 3 seconds
- Cached endpoint retries: 3 attempts, 500ms apart, then Bonjour discovery
All streaming code is wrapped in #if DEBUG:
public func info(_ message: String, file: String = #file, line: Int = #line, function: String = #function) {
let entry = LogEntry(level: .info, message: message, file: file, line: line, function: function)
osLogger.info("\(message)")
#if DEBUG
StreamLog.shared.send(entry)
#endif
}In release builds:
StreamLog.configure()is a no-op- No Bonjour browsing
- No TCP connections
- No memory overhead from buffering
- Only the
os.Loggercall remains
Policy: Cache recent unsent logs.
- Keep the last 2 minutes of logs while disconnected (max 1000 entries)
- Flush cached logs on reconnect before streaming new entries
- Drop older cached entries outside the window
- When app enters background: TCP connection will naturally close
- When app returns to foreground: Re-browse for service and reconnect
- Implemented via
NotificationCenterobservers forUIApplication.didBecomeActiveNotification
The wrapper logs formatted strings only, not the raw interpolation with privacy hints.
Since this is a debug-only tool:
- OSLog still handles privacy correctly in its own storage
- The stream receives the formatted message (which respects OSLog's formatting)
- In practice, debug builds often don't care about privacy redaction
Located in the Field monorepo: apps/server-go/internal/streamlog/
Provides:
- Bonjour service advertisement (
_fieldlog._tcp) - TCP listener accepting multiple connections
- JSON line parsing
- Callback/channel for incoming log entries
package streamlog
type LogEntry struct {
Timestamp float64 `json:"ts"`
Subsystem string `json:"subsystem"`
Category string `json:"category"`
Level string `json:"level"`
File string `json:"file"`
Line int `json:"line"`
Function string `json:"function"`
Message string `json:"message"`
Device string `json:"device"`
DeviceID string `json:"deviceId"`
}
type Listener struct {
Entries chan LogEntry
}
func NewListener(serviceType string) (*Listener, error)
func (l *Listener) Start() error
func (l *Listener) Stop()- Go TUI (
apps/server-go/internal/tui/ios_logs.go): Replace or augment current implementation withstreamlog.Listener - Standalone CLI: New binary
cmd/streamlog/main.gothat just prints logs
Interleaved with prefix showing source:
[MyApp/iPhone-a1b2] 10:42:15.123 INFO network: Request completed
[OtherApp/iPad-c3d4] 10:42:15.456 DEBUG ui: View appeared
[MyApp/iPhone-a1b2] 10:42:15.789 ERROR network: Connection failed
Existing apps using os.Logger directly need search-replace:
import os
let logger = Logger(subsystem: "com.app", category: "network")
logger.info("Request started")import StreamLog
// In App init or AppDelegate
StreamLog.configure(subsystem: "com.app")
// At call sites
let logger = StreamLogger(category: "network")
logger.info("Request started")- Add StreamLog package dependency
- In app entry point, call
StreamLog.configure(subsystem:) - Find/replace
import oswithimport StreamLogin files with logging - Find/replace
Logger(subsystem:withStreamLogger( - Remove subsystem from individual logger inits (now global)
StreamLog/
├── Package.swift
├── Sources/
│ └── StreamLog/
│ ├── StreamLog.swift # Main configuration singleton
│ ├── StreamLogger.swift # Logger replacement
│ ├── LogEntry.swift # Entry model
│ ├── BonjourBrowser.swift # Service discovery
│ └── TCPStreamer.swift # Network streaming
├── Tests/
│ └── StreamLogTests/
│ └── StreamLogTests.swift
├── SPEC.md # This file
└── README.md # Usage documentation
- Foundation (networking, JSON, Bonjour)
- os (OSLog)
- Network (NWBrowser, NWConnection) for modern networking
github.com/grandcat/zeroconfor similar for Bonjour- Standard library for TCP and JSON
- Log persistence (OSLog handles this)
- Log filtering in the package (TUI can filter)
- Encryption (local network, debug only)
- Authentication (debug tool assumption)
- Crash log capture (use standard crash reporting)
Not implementing now, but could add later:
- Log replay: Ring buffer with replay on connect
- Remote streaming: Stream to a server, not just local Bonjour
- Structured logging: Beyond message strings (though JSON format supports adding fields)
- SwiftLog backend: Implement as a
swift-logLogHandler for broader ecosystem compatibility