Skip to content

Latest commit

 

History

History
321 lines (242 loc) · 10.7 KB

File metadata and controls

321 lines (242 loc) · 10.7 KB

StreamLog Specification

A Swift package that wraps OSLog and streams logs over the network to a listener (TUI or CLI).

Overview

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).

Architecture

┌─────────────────────────────────────────────────────────────────┐
│  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   │                   │
│  └──────────────────┘    └──────────────────┘                   │
└─────────────────────────────────────────────────────────────────┘

Connection Model

TUI advertises, apps connect.

  1. User starts the TUI/CLI listener
  2. Listener advertises _fieldlog._tcp via Bonjour
  3. iOS app browses for _fieldlog._tcp on launch
  4. App connects to the listener via TCP
  5. 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

Swift Package (StreamLog)

Public API

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)")

Log Levels

Maps directly to OSLogType:

StreamLog OSLogType
.debug .debug
.info .info
.notice .default
.warning .error
.error .fault

Configuration Options

StreamLog.configure(
    subsystem: String,              // OSLog subsystem (required)
    serviceType: String = "_fieldlog._tcp",  // Bonjour service type
    deviceName: String? = nil       // Override device name (default: UIDevice.current.name)
)

Internal Behavior

  1. Logging: Each log call writes to both os.Logger and the stream buffer
  2. Discovery: On configure(), start browsing for _fieldlog._tcp
  3. Connection: When service found, connect via TCP
  4. Streaming: Send JSON lines for each log entry
  5. Heartbeat: Send ping every 5s, expect pong within 3s, mark dead if missing
  6. Disconnection: On disconnect or timeout, stop heartbeat and retry cached endpoint before Bonjour
  7. Backgrounding: On app foreground, re-browse and reconnect
  8. Release builds: All streaming code compiled out via #if DEBUG

Wire Format (JSON Lines)

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 milliseconds
  • subsystem: OSLog subsystem
  • category: OSLog category
  • level: debug | info | notice | warning | error
  • file: Source filename (basename only)
  • line: Source line number
  • function: Function name
  • message: Formatted log message
  • device: Device name (e.g., "iPhone", "iPad")
  • deviceId: Last 4 characters of device identifier (for uniqueness)

Heartbeat Protocol

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:

  • ts is a Unix timestamp with milliseconds.
  • Pong echoes the ping timestamp for latency measurement.
  • Log messages do not include a type field.

Timing parameters:

  • Ping interval: 5 seconds
  • Pong timeout: 3 seconds
  • Cached endpoint retries: 3 attempts, 500ms apart, then Bonjour discovery

Build Configuration

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.Logger call remains

Buffering & Backpressure

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

Backgrounding Behavior

  • When app enters background: TCP connection will naturally close
  • When app returns to foreground: Re-browse for service and reconnect
  • Implemented via NotificationCenter observers for UIApplication.didBecomeActiveNotification

Privacy

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

Go Listener Package

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

API

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()

Integration Points

  1. Go TUI (apps/server-go/internal/tui/ios_logs.go): Replace or augment current implementation with streamlog.Listener
  2. Standalone CLI: New binary cmd/streamlog/main.go that just prints logs

Display Format

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

Migration Guide

Existing apps using os.Logger directly need search-replace:

Before

import os

let logger = Logger(subsystem: "com.app", category: "network")
logger.info("Request started")

After

import StreamLog

// In App init or AppDelegate
StreamLog.configure(subsystem: "com.app")

// At call sites
let logger = StreamLogger(category: "network")
logger.info("Request started")

Migration Steps

  1. Add StreamLog package dependency
  2. In app entry point, call StreamLog.configure(subsystem:)
  3. Find/replace import os with import StreamLog in files with logging
  4. Find/replace Logger(subsystem: with StreamLogger(
  5. Remove subsystem from individual logger inits (now global)

Package Structure

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

Dependencies

Swift Package

  • Foundation (networking, JSON, Bonjour)
  • os (OSLog)
  • Network (NWBrowser, NWConnection) for modern networking

Go Package

  • github.com/grandcat/zeroconf or similar for Bonjour
  • Standard library for TCP and JSON

Out of Scope

  • 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)

Future Considerations

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-log LogHandler for broader ecosystem compatibility