Slice 3 of dev-overlay-end-to-end. Adds a fourth DevOverlay tab that
tails every log record from every Jarvis subsystem in real time —
agent, voice, vision, memory, MCP, bus, replay, system — without
polling files or relying on Console.app.
How it works:
1. `LogBroadcaster` (new) — process-wide actor with a 500-line ring
buffer + subscriber set. Borrowed shape from
OrchestratorEventBroadcaster so the project's three broadcasters
share a mental model. New subscribers replay the buffer in order,
then go live.
2. `BroadcastLogHandler` (new) — swift-log LogHandler conformance.
Formats each log call into a `LogLine` and schedules an unstructured
`Task { await LogBroadcaster.shared.publish(...) }` so the logging
caller never blocks on actor isolation. Synchronous handler contract
preserved; ordering is best-effort under heavy load.
3. `LoggingBootstrap.make(label:)` now returns
`MultiplexLogHandler([file, os, broadcast])` — the new handler is
purely additive. File rotation + os.Logger streaming unchanged.
4. `DevOverlayLogsView` (new) — fourth tab in the overlay. Subscribes
to `LogBroadcaster.shared` on appear, caps at 1000 lines, renders
monospaced. Per-channel filter (Menu w/ checkboxes), per-level
filter (debug → critical), substring search, Pause/Resume button
(Cmd+P), auto-scroll-to-bottom while not paused. Color-coded by
level (warn=orange, error/critical=red).
Tests:
- 5 new LogBroadcasterTests covering publish/cap/replay/live/handler.
- swift test --package-path packages/Logging → 22/22 pass.
- swift test --package-path packages/DevOverlay → 14/14 pass.
- bash scripts/check-app-builds.sh → PASS.
- 18/18 boundary gates green.
Redaction note: file handler still routes through Redact.apply for
disk writes; the broadcaster sees raw messages because the DevOverlay
is a developer-only in-process surface. If this ever becomes
user-facing, route through Redact before publishing.
238 lines
8.5 KiB
Swift
238 lines
8.5 KiB
Swift
// LogBroadcaster.swift
|
|
//
|
|
// Process-wide fan-out of swift-log records to in-app subscribers. The
|
|
// file + os.Logger handlers already cover persistence and the system
|
|
// log; this third handler exists exclusively so the DevOverlay (and any
|
|
// future in-process diagnostic surface) can tail every log line live
|
|
// without polling files or relying on Console.app.
|
|
//
|
|
// Design:
|
|
// - `BroadcastLogHandler` (LogHandler conformance) formats each log
|
|
// call into a `LogLine` value and hands it to `LogBroadcaster.shared`.
|
|
// - `LogBroadcaster` is an actor that owns a bounded ring buffer
|
|
// (default 500) plus a set of subscriber continuations. New
|
|
// subscribers receive the buffer's contents replayed in order, then
|
|
// all subsequent live lines.
|
|
// - The shape is borrowed from `OrchestratorEventBroadcaster` so the
|
|
// mental model is consistent across the project's three broadcasters:
|
|
// subscriber stream + bounded back-pressure + drop-oldest under load.
|
|
//
|
|
// Threading:
|
|
// - `LogBroadcaster` is a global actor (`shared`). All mutating work
|
|
// (`publish`, `subscribe`, `unsubscribe`) hops to the actor.
|
|
// - `BroadcastLogHandler.log(...)` is synchronous and reentrancy-safe;
|
|
// it schedules an unstructured `Task { await broadcaster.publish(...) }`
|
|
// so the caller never blocks on the actor. Drop-oldest under load on
|
|
// the subscriber side means a slow consumer never pressures the
|
|
// emitter.
|
|
//
|
|
// Why not just write to a file the UI tails? Because the UI would have
|
|
// to re-parse the file, the file goes through the redaction pipeline
|
|
// (good for disk, lossy for live UX), and a third subscriber surface
|
|
// (DevOverlay) is the same architectural shape the project already
|
|
// uses for OrchestratorEvents — keeping it consistent beats inventing.
|
|
|
|
import Foundation
|
|
import Logging
|
|
|
|
/// One log entry as it appears in the broadcaster. Equatable + Sendable
|
|
/// so SwiftUI can store an array of these in a `@Published` slot and
|
|
/// re-diff cheaply.
|
|
///
|
|
/// ## Topics
|
|
///
|
|
/// - ``timestamp``
|
|
/// - ``channel``
|
|
/// - ``level``
|
|
/// - ``message``
|
|
/// - ``metadata``
|
|
public struct LogLine: Sendable, Equatable, Identifiable {
|
|
/// Deterministic identity for SwiftUI's `ForEach`. Composed from
|
|
/// timestamp + a monotonic sequence so identical messages a
|
|
/// millisecond apart still render as distinct rows.
|
|
public let id: UInt64
|
|
public let timestamp: Date
|
|
public let channel: String
|
|
public let level: Logger.Level
|
|
public let message: String
|
|
public let metadata: [String: String]
|
|
|
|
public init(
|
|
id: UInt64,
|
|
timestamp: Date,
|
|
channel: String,
|
|
level: Logger.Level,
|
|
message: String,
|
|
metadata: [String: String]
|
|
) {
|
|
self.id = id
|
|
self.timestamp = timestamp
|
|
self.channel = channel
|
|
self.level = level
|
|
self.message = message
|
|
self.metadata = metadata
|
|
}
|
|
}
|
|
|
|
/// Process-wide actor that fans out `LogLine`s to in-app subscribers.
|
|
///
|
|
/// ## Overview
|
|
///
|
|
/// Each subscriber gets the current buffer replayed at subscribe time
|
|
/// (so a DevOverlay opened mid-run shows the last 500 lines immediately),
|
|
/// followed by live lines as they're published. Subscribers are
|
|
/// identified by `UUID`; cancelling the AsyncStream's terminator removes
|
|
/// them from the broadcaster set.
|
|
///
|
|
/// ## Threading
|
|
///
|
|
/// Global actor. Every mutating method must be `await`-called. The
|
|
/// `BroadcastLogHandler` side schedules unstructured `Task { … }` writes
|
|
/// rather than blocking the logging caller — swift-log handlers must be
|
|
/// synchronous, so we trade strict ordering for non-blocking semantics.
|
|
/// Under heavy logging the ring buffer's drop-oldest keeps memory bounded.
|
|
public actor LogBroadcaster {
|
|
public static let shared = LogBroadcaster()
|
|
|
|
/// Bounded ring of the most recent lines. Subscribers joining late
|
|
/// replay this slice in order before going live.
|
|
private var buffer: [LogLine] = []
|
|
private let bufferCapacity: Int
|
|
|
|
/// Live subscribers. Continuations finish on `unsubscribe`.
|
|
private var subscribers: [UUID: AsyncStream<LogLine>.Continuation] = [:]
|
|
|
|
/// Monotonic sequence used to give each line a unique `id`.
|
|
private var sequence: UInt64 = 0
|
|
|
|
public init(bufferCapacity: Int = 500) {
|
|
precondition(bufferCapacity > 0)
|
|
self.bufferCapacity = bufferCapacity
|
|
}
|
|
|
|
/// Append a log line to the ring + fan out to every live subscriber.
|
|
public func publish(
|
|
timestamp: Date,
|
|
channel: String,
|
|
level: Logger.Level,
|
|
message: String,
|
|
metadata: [String: String]
|
|
) {
|
|
sequence &+= 1
|
|
let line = LogLine(
|
|
id: sequence,
|
|
timestamp: timestamp,
|
|
channel: channel,
|
|
level: level,
|
|
message: message,
|
|
metadata: metadata
|
|
)
|
|
buffer.append(line)
|
|
if buffer.count > bufferCapacity {
|
|
buffer.removeFirst(buffer.count - bufferCapacity)
|
|
}
|
|
for cont in subscribers.values {
|
|
cont.yield(line)
|
|
}
|
|
}
|
|
|
|
/// Subscribe to the live stream. The current buffer is replayed first,
|
|
/// then new lines arrive as they're published. Cancel the consumer's
|
|
/// task or break out of the for-await to terminate.
|
|
///
|
|
/// The stream is `.bufferingNewest(256)` — a slow consumer drops the
|
|
/// oldest in-flight lines rather than back-pressuring the publisher.
|
|
public func subscribe() -> AsyncStream<LogLine> {
|
|
let id = UUID()
|
|
let snapshot = buffer
|
|
let (stream, cont) = AsyncStream<LogLine>.makeStream(
|
|
bufferingPolicy: .bufferingNewest(256)
|
|
)
|
|
// Replay buffer in order so the UI shows historical context the
|
|
// instant it opens.
|
|
for line in snapshot {
|
|
cont.yield(line)
|
|
}
|
|
subscribers[id] = cont
|
|
cont.onTermination = { @Sendable [weak self] _ in
|
|
Task { await self?.removeSubscriber(id: id) }
|
|
}
|
|
return stream
|
|
}
|
|
|
|
/// Internal helper for `onTermination` cleanup.
|
|
fileprivate func removeSubscriber(id: UUID) {
|
|
if let cont = subscribers.removeValue(forKey: id) {
|
|
cont.finish()
|
|
}
|
|
}
|
|
|
|
/// Test seam — count of live subscribers.
|
|
public var subscriberCount: Int { subscribers.count }
|
|
|
|
/// Test seam — current buffer size.
|
|
public var bufferedCount: Int { buffer.count }
|
|
}
|
|
|
|
/// `swift-log` LogHandler that forwards every record to `LogBroadcaster.shared`.
|
|
///
|
|
/// Constructed by `LoggingBootstrap.make(label:)` alongside the file +
|
|
/// os.Logger handlers. The three handlers are wrapped in a
|
|
/// `MultiplexLogHandler`; this one is purely additive — disabling it
|
|
/// (removing it from the multiplex) leaves persistence + os logging
|
|
/// untouched.
|
|
///
|
|
/// Redaction note: the file handler's `Redact.apply(_:)` strips secrets
|
|
/// from disk writes. The broadcaster sees the **raw** message because
|
|
/// the DevOverlay is a developer-only surface running in-process; a
|
|
/// developer trying to debug their own session needs to see what
|
|
/// they actually logged. If this ever becomes user-facing, route through
|
|
/// `Redact.apply(_:)` before publishing.
|
|
public struct BroadcastLogHandler: LogHandler {
|
|
public let label: String
|
|
public var logLevel: Logger.Level = .trace
|
|
public var metadata: Logger.Metadata = [:]
|
|
public var metadataProvider: Logger.MetadataProvider?
|
|
|
|
public init(label: String) {
|
|
self.label = label
|
|
}
|
|
|
|
public func log(
|
|
level: Logger.Level,
|
|
message: Logger.Message,
|
|
metadata: Logger.Metadata?,
|
|
source: String,
|
|
file: String,
|
|
function: String,
|
|
line: UInt
|
|
) {
|
|
let merged = self.metadata.merging(metadata ?? [:]) { _, new in new }
|
|
var flat: [String: String] = [:]
|
|
flat.reserveCapacity(merged.count)
|
|
for (k, v) in merged {
|
|
flat[k] = "\(v)"
|
|
}
|
|
// Snapshot values into Sendable locals before hopping to the actor.
|
|
let ts = Date()
|
|
let channelCopy = label
|
|
let levelCopy = level
|
|
let msgCopy = "\(message)"
|
|
let metaCopy = flat
|
|
Task {
|
|
await LogBroadcaster.shared.publish(
|
|
timestamp: ts,
|
|
channel: channelCopy,
|
|
level: levelCopy,
|
|
message: msgCopy,
|
|
metadata: metaCopy
|
|
)
|
|
}
|
|
}
|
|
|
|
public subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
|
get { metadata[key] }
|
|
set { metadata[key] = newValue }
|
|
}
|
|
}
|