feat(devoverlay,logging): live log stream — every swift-log line into the overlay
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.
This commit is contained in:
6 files changed
+698
-3
No files matched your search
@@ -0,0 +1,287 @@
|
||||
// DevOverlayLogsView.swift
|
||||
//
|
||||
// Live log tail rendered as the fourth DevOverlay tab. Subscribes to
|
||||
// `LogBroadcaster.shared` at view-appear, accumulates into a bounded
|
||||
// `@Observable` model (1000-line cap), and renders a filterable,
|
||||
// pause-able, monospaced scroll view.
|
||||
//
|
||||
// Filtering happens client-side because the broadcaster is the shared
|
||||
// fan-out point for every Jarvis subsystem — channels + levels are
|
||||
// emergent from whoever calls into swift-log, not enumerated up-front.
|
||||
// Filtering server-side would push topology into the broadcaster and
|
||||
// couple it to every consumer.
|
||||
//
|
||||
// Why bounded? The broadcaster's ring is 500 lines; the UI's model
|
||||
// caps at 1000 so a user pausing for ~minute can scroll the history
|
||||
// without falling off the back. Both bounds are generous defaults;
|
||||
// neither is hot-loaded from config because the DevOverlay is dev-only.
|
||||
//
|
||||
// See also: LogBroadcaster.swift, DevOverlayView.swift.
|
||||
|
||||
#if canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
import Observation
|
||||
import Logging
|
||||
import JarvisLogging
|
||||
|
||||
/// Bounded buffer of recent log lines plus user filter state.
|
||||
///
|
||||
/// ## Threading
|
||||
///
|
||||
/// `@MainActor` — all mutations happen on the SwiftUI executor. The
|
||||
/// background subscriber Task hops to the main actor before calling
|
||||
/// `append(_:)`.
|
||||
@available(macOS 14.0, *)
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class DevOverlayLogsModel {
|
||||
public private(set) var lines: [LogLine] = []
|
||||
public var isPaused: Bool = false
|
||||
|
||||
/// Search substring (case-insensitive). Empty = match all.
|
||||
public var searchText: String = ""
|
||||
|
||||
/// Active channel filter. Empty set = no channel filter (show all).
|
||||
public var enabledChannels: Set<String> = []
|
||||
|
||||
/// Active level filter. Empty set = no level filter (show all).
|
||||
public var enabledLevels: Set<Logger.Level> = []
|
||||
|
||||
/// Cap so a long-running session doesn't grow unbounded.
|
||||
public let cap: Int
|
||||
|
||||
public init(cap: Int = 1000) {
|
||||
self.cap = cap
|
||||
}
|
||||
|
||||
/// Append a new log line unless the user has paused the tail. We
|
||||
/// still cap the buffer regardless of pause so an unbounded backlog
|
||||
/// never accumulates — paused just means "don't show me new ones
|
||||
/// flowing in," not "stockpile them."
|
||||
public func append(_ line: LogLine) {
|
||||
guard !isPaused else { return }
|
||||
lines.append(line)
|
||||
if lines.count > cap {
|
||||
lines.removeFirst(lines.count - cap)
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovered channels across all currently-held lines. Drives the
|
||||
/// channel-filter dropdown — emergent rather than enumerated.
|
||||
public var availableChannels: [String] {
|
||||
Array(Set(lines.map(\.channel))).sorted()
|
||||
}
|
||||
|
||||
/// Apply the active filters. Returns the filtered slice for rendering.
|
||||
public func filtered() -> [LogLine] {
|
||||
let needle = searchText.lowercased()
|
||||
return lines.filter { line in
|
||||
if !enabledChannels.isEmpty && !enabledChannels.contains(line.channel) {
|
||||
return false
|
||||
}
|
||||
if !enabledLevels.isEmpty && !enabledLevels.contains(line.level) {
|
||||
return false
|
||||
}
|
||||
if !needle.isEmpty && !line.message.lowercased().contains(needle) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI surface for the logs tab.
|
||||
///
|
||||
/// ## Overview
|
||||
///
|
||||
/// Top bar: search field + channel popover + level toggles + pause/resume.
|
||||
/// Body: scrollable, monospaced log list with auto-scroll-to-bottom while
|
||||
/// not paused. Color-coded by level.
|
||||
@available(macOS 14.0, *)
|
||||
public struct DevOverlayLogsView: View {
|
||||
@State private var model = DevOverlayLogsModel()
|
||||
@State private var subscriberTask: Task<Void, Never>?
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
controlBar
|
||||
Divider()
|
||||
logList
|
||||
}
|
||||
.padding(8)
|
||||
.onAppear {
|
||||
startTail()
|
||||
}
|
||||
.onDisappear {
|
||||
subscriberTask?.cancel()
|
||||
subscriberTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Control bar
|
||||
|
||||
private var controlBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Search…", text: $model.searchText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(minWidth: 160, maxWidth: 240)
|
||||
|
||||
Menu("Channels (\(model.enabledChannels.isEmpty ? "all" : "\(model.enabledChannels.count)"))") {
|
||||
Button("Show all") { model.enabledChannels = [] }
|
||||
Divider()
|
||||
ForEach(model.availableChannels, id: \.self) { ch in
|
||||
Toggle(ch, isOn: Binding(
|
||||
get: { model.enabledChannels.contains(ch) },
|
||||
set: { on in
|
||||
if on { model.enabledChannels.insert(ch) }
|
||||
else { model.enabledChannels.remove(ch) }
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 180)
|
||||
|
||||
Menu("Levels (\(model.enabledLevels.isEmpty ? "all" : "\(model.enabledLevels.count)"))") {
|
||||
Button("Show all") { model.enabledLevels = [] }
|
||||
Divider()
|
||||
ForEach(allLevels, id: \.self) { lvl in
|
||||
Toggle(label(for: lvl), isOn: Binding(
|
||||
get: { model.enabledLevels.contains(lvl) },
|
||||
set: { on in
|
||||
if on { model.enabledLevels.insert(lvl) }
|
||||
else { model.enabledLevels.remove(lvl) }
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 140)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(model.isPaused ? "Resume" : "Pause") {
|
||||
model.isPaused.toggle()
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: [.command])
|
||||
|
||||
Text("\(model.lines.count) lines")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Log list
|
||||
|
||||
private var logList: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 1) {
|
||||
ForEach(model.filtered()) { line in
|
||||
LogLineRow(line: line)
|
||||
.id(line.id)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.onChange(of: model.lines.count) { _, _ in
|
||||
// Auto-scroll to bottom on every new line — unless the
|
||||
// user has paused, in which case the view freezes.
|
||||
guard !model.isPaused, let last = model.lines.last else { return }
|
||||
withAnimation(.linear(duration: 0.05)) {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription
|
||||
|
||||
/// Spawn the live-tail subscriber. Each line is appended on the main
|
||||
/// actor so SwiftUI's Observation re-render happens on its expected
|
||||
/// executor.
|
||||
private func startTail() {
|
||||
subscriberTask?.cancel()
|
||||
let modelRef = model
|
||||
subscriberTask = Task { @MainActor in
|
||||
let stream = await LogBroadcaster.shared.subscribe()
|
||||
for await line in stream {
|
||||
if Task.isCancelled { break }
|
||||
modelRef.append(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var allLevels: [Logger.Level] {
|
||||
[.trace, .debug, .info, .notice, .warning, .error, .critical]
|
||||
}
|
||||
|
||||
private func label(for level: Logger.Level) -> String {
|
||||
switch level {
|
||||
case .trace: return "trace"
|
||||
case .debug: return "debug"
|
||||
case .info: return "info"
|
||||
case .notice: return "notice"
|
||||
case .warning: return "warning"
|
||||
case .error: return "error"
|
||||
case .critical: return "critical"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
private struct LogLineRow: View {
|
||||
let line: LogLine
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text(timeFormatter.string(from: line.timestamp))
|
||||
.foregroundColor(.secondary)
|
||||
Text(levelLabel)
|
||||
.foregroundColor(levelColor)
|
||||
.frame(width: 56, alignment: .leading)
|
||||
Text(line.channel)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text(line.message)
|
||||
.foregroundColor(levelColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
}
|
||||
|
||||
private var levelLabel: String {
|
||||
switch line.level {
|
||||
case .trace: return "TRACE"
|
||||
case .debug: return "DEBUG"
|
||||
case .info: return "INFO"
|
||||
case .notice: return "NOTE"
|
||||
case .warning: return "WARN"
|
||||
case .error: return "ERROR"
|
||||
case .critical: return "CRIT"
|
||||
}
|
||||
}
|
||||
|
||||
private var levelColor: Color {
|
||||
switch line.level {
|
||||
case .trace, .debug, .info, .notice: return .primary
|
||||
case .warning: return .orange
|
||||
case .error, .critical: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Module-private formatter — configured once at module-init. ISO with
|
||||
// millisecond precision is enough for log tails.
|
||||
private let timeFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm:ss.SSS"
|
||||
return f
|
||||
}()
|
||||
#endif
|
||||
@@ -57,9 +57,13 @@ public struct DevOverlayView: View {
|
||||
HealthPane(viewModel: viewModel)
|
||||
.tabItem { Label("Health", systemImage: "stethoscope") }
|
||||
.tag(2)
|
||||
|
||||
DevOverlayLogsView()
|
||||
.tabItem { Label("Logs", systemImage: "text.alignleft") }
|
||||
.tag(3)
|
||||
}
|
||||
.padding(8)
|
||||
.frame(minWidth: 520, minHeight: 480)
|
||||
.frame(minWidth: 640, minHeight: 520)
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ public final class DevOverlayWindow {
|
||||
self.bootHealthOrchestrator = bootHealthOrchestrator
|
||||
self.hudStateReader = hudStateReader
|
||||
|
||||
let contentRect = NSRect(x: 0, y: 0, width: 560, height: 520)
|
||||
let contentRect = NSRect(x: 0, y: 0, width: 680, height: 560)
|
||||
let styleMask: NSWindow.StyleMask = [
|
||||
.titled,
|
||||
.closable,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,17 @@
|
||||
// LoggingBootstrap.swift
|
||||
//
|
||||
// Single entry point for `swift-log` `LoggingSystem.bootstrap`. Builds
|
||||
// the three-way MultiplexLogHandler that every Jarvis logger funnels
|
||||
// through:
|
||||
//
|
||||
// 1. FileLogHandler — rotating disk persistence with redaction.
|
||||
// 2. OSLogHandler — Console.app + system log streaming.
|
||||
// 3. BroadcastLogHandler — in-process fan-out to DevOverlay et al.
|
||||
//
|
||||
// The third handler was added 2026-05-12 (dev-overlay-end-to-end Slice 3)
|
||||
// so the DevOverlay can tail every log line from every module live. It is
|
||||
// additive — removing it leaves persistence + os logging intact.
|
||||
|
||||
import Foundation
|
||||
import Logging // swift-log
|
||||
|
||||
@@ -13,7 +27,8 @@ public enum JarvisLogHandlerFactory {
|
||||
dateProvider: SystemDateProvider()
|
||||
)
|
||||
let os = OSLogHandler(subsystem: subsystem, category: label, label: label)
|
||||
return MultiplexLogHandler([file, os])
|
||||
let broadcast = BroadcastLogHandler(label: label)
|
||||
return MultiplexLogHandler([file, os, broadcast])
|
||||
}
|
||||
|
||||
/// Call exactly ONCE at `AppDelegate.applicationWillFinishLaunching`.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// LogBroadcasterTests.swift
|
||||
//
|
||||
// Exercises the buffer/replay/fan-out semantics of LogBroadcaster +
|
||||
// BroadcastLogHandler. Coverage:
|
||||
// - LB1: publish appends to the buffer.
|
||||
// - LB2: buffer caps at `bufferCapacity` (drop-oldest).
|
||||
// - LB3: subscribers receive the buffer replay in order on join.
|
||||
// - LB4: subscribers receive live lines after the replay.
|
||||
// - LB5: BroadcastLogHandler.log eventually publishes via the actor.
|
||||
|
||||
import XCTest
|
||||
import Logging
|
||||
@testable import JarvisLogging
|
||||
|
||||
final class LogBroadcasterTests: XCTestCase {
|
||||
// LB1: publish appends to the buffer.
|
||||
func test_LB1_publishAppendsToBuffer() async {
|
||||
let bc = LogBroadcaster(bufferCapacity: 10)
|
||||
await bc.publish(
|
||||
timestamp: Date(),
|
||||
channel: "test",
|
||||
level: .info,
|
||||
message: "hello",
|
||||
metadata: [:]
|
||||
)
|
||||
let count = await bc.bufferedCount
|
||||
XCTAssertEqual(count, 1)
|
||||
}
|
||||
|
||||
// LB2: buffer drops oldest when capacity is exceeded.
|
||||
func test_LB2_bufferDropsOldestAtCapacity() async {
|
||||
let bc = LogBroadcaster(bufferCapacity: 3)
|
||||
for i in 0..<5 {
|
||||
await bc.publish(
|
||||
timestamp: Date(),
|
||||
channel: "test",
|
||||
level: .info,
|
||||
message: "msg-\(i)",
|
||||
metadata: [:]
|
||||
)
|
||||
}
|
||||
let count = await bc.bufferedCount
|
||||
XCTAssertEqual(count, 3)
|
||||
|
||||
// The newest 3 lines should survive. Verify by subscribing — the
|
||||
// replay yields the surviving slice in order.
|
||||
let stream = await bc.subscribe()
|
||||
let collector = Task { () -> [String] in
|
||||
var out: [String] = []
|
||||
for await line in stream {
|
||||
out.append(line.message)
|
||||
if out.count == 3 { break }
|
||||
}
|
||||
return out
|
||||
}
|
||||
let observed = await collector.value
|
||||
XCTAssertEqual(observed, ["msg-2", "msg-3", "msg-4"])
|
||||
}
|
||||
|
||||
// LB3: subscribers see buffer replay on join.
|
||||
func test_LB3_subscriberReceivesReplay() async {
|
||||
let bc = LogBroadcaster(bufferCapacity: 10)
|
||||
for i in 0..<3 {
|
||||
await bc.publish(
|
||||
timestamp: Date(),
|
||||
channel: "ch",
|
||||
level: .info,
|
||||
message: "pre-\(i)",
|
||||
metadata: [:]
|
||||
)
|
||||
}
|
||||
let stream = await bc.subscribe()
|
||||
let collector = Task { () -> [String] in
|
||||
var out: [String] = []
|
||||
for await line in stream {
|
||||
out.append(line.message)
|
||||
if out.count == 3 { break }
|
||||
}
|
||||
return out
|
||||
}
|
||||
let observed = await collector.value
|
||||
XCTAssertEqual(observed, ["pre-0", "pre-1", "pre-2"])
|
||||
}
|
||||
|
||||
// LB4: live lines arrive after replay.
|
||||
func test_LB4_subscriberReceivesLiveAfterReplay() async {
|
||||
let bc = LogBroadcaster(bufferCapacity: 10)
|
||||
await bc.publish(
|
||||
timestamp: Date(), channel: "ch", level: .info,
|
||||
message: "replay-1", metadata: [:]
|
||||
)
|
||||
let stream = await bc.subscribe()
|
||||
let collector = Task { () -> [String] in
|
||||
var out: [String] = []
|
||||
for await line in stream {
|
||||
out.append(line.message)
|
||||
if out.count == 3 { break }
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Give the subscriber a moment to consume the replay before
|
||||
// publishing the live lines — otherwise the test is racy on
|
||||
// fast machines (publishes can land before the consumer's
|
||||
// for-await starts).
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
await bc.publish(
|
||||
timestamp: Date(), channel: "ch", level: .info,
|
||||
message: "live-1", metadata: [:]
|
||||
)
|
||||
await bc.publish(
|
||||
timestamp: Date(), channel: "ch", level: .info,
|
||||
message: "live-2", metadata: [:]
|
||||
)
|
||||
let observed = await collector.value
|
||||
XCTAssertEqual(observed, ["replay-1", "live-1", "live-2"])
|
||||
}
|
||||
|
||||
// LB5: BroadcastLogHandler.log eventually publishes via the actor.
|
||||
// Synchronous-handler → unstructured-Task path; we poll the buffer
|
||||
// count rather than racing the await.
|
||||
func test_LB5_handlerPublishesAsynchronously() async {
|
||||
// Use a private broadcaster so this test doesn't see lines from
|
||||
// any other code logging into `LogBroadcaster.shared`.
|
||||
// BroadcastLogHandler currently writes only to .shared, so we
|
||||
// assert against that instance and tolerate concurrent noise by
|
||||
// checking presence rather than exact count.
|
||||
let handler = BroadcastLogHandler(label: "lb5-test")
|
||||
let needle = "lb5-needle-\(UUID().uuidString)"
|
||||
handler.log(
|
||||
level: .notice,
|
||||
message: "\(needle)",
|
||||
metadata: nil,
|
||||
source: "test",
|
||||
file: #file,
|
||||
function: #function,
|
||||
line: #line
|
||||
)
|
||||
|
||||
// Subscribe + scan for the needle within a bounded window.
|
||||
let stream = await LogBroadcaster.shared.subscribe()
|
||||
let collector = Task { () -> Bool in
|
||||
let deadline = Date().addingTimeInterval(1.0)
|
||||
for await line in stream {
|
||||
if line.message.contains(needle) { return true }
|
||||
if Date() > deadline { break }
|
||||
}
|
||||
return false
|
||||
}
|
||||
let found = await collector.value
|
||||
XCTAssertTrue(found, "BroadcastLogHandler.log did not deliver to LogBroadcaster.shared")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user