mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:25:19 +00:00
Observability: DEBUG debug-level default, greppable decision tags, log export + UDP LAN sink
Debugging the integration build on real iOS 26.5 devices over the air. - BitLogger DEBUG default minimumLevel now .debug (was .info) so a tap-launched sideload surfaces .debug decision logs without the BITCHAT_LOG_LEVEL env var; env still wins; release unchanged (.info, emits nothing). Extracted defaultMinimumLevel + a test asserting it. - Greppable bracket decision tags at .info (visible even at .info) via SecureLogger, ID-prefixes only, sanitized: [ROUTE] source-route vs flood origination (+reason) + fragment targeted-resync; [GW] uplink accept/reject, downlink rebroadcast/drop, loop skips, drain-timer; [WIFI] offer/accept/decline, AWDL connect, transfer start/complete+bytes, fallback+reason; [PREKEY] seal choice, consume, unknown-prekey open fail, bundle ingest, re-gossip; [SYNC] one concise summary per cycle + per-request served counts (no per-packet spam); [PING]/[TRACE] RTT + computed path. - New OSLog categories: mesh, gateway, transport. - Refactored BLESourceRouteOriginationPolicy.route -> decide returning a Decision enum (flood(reason)/route(hops)) so the flood reason is greppable and unit-tested. - DEBUG-only in-memory ring buffer (LogExportBuffer, ~2000 lines/512KB, thread-safe, off main thread) + App Info "Export Logs" share sheet (iOS UIActivityViewController / macOS NSSavePanel), VoiceOver labels. - DEBUG-only UDP LAN log sink (LogNetworkSink): opt-in, off by default, fire-and-forget, never blocks/throws; each line prefixed with the device nickname for demux; configured via App Info host:port fields. Same sanitized formatted line feeds buffer, sink, and export. All new code is #if DEBUG; release emits nothing and ships no export/sink UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// LogExportBuffer.swift
|
||||
// BitLogger
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if DEBUG
|
||||
import Foundation
|
||||
|
||||
/// In-memory ring buffer of the most recent sanitized log lines, so a tester
|
||||
/// can export logs untethered when live streaming isn't available.
|
||||
///
|
||||
/// DEBUG-only: never compiled into release builds, matching the rest of the
|
||||
/// logging stack. Bounded by both a line count and a byte budget (oldest
|
||||
/// evicted first). Appends run on a private serial queue so a hot logging path
|
||||
/// never blocks on buffer maintenance and the main thread is never touched.
|
||||
///
|
||||
/// It stores exactly the SecureLogger-sanitized text (fingerprints truncated,
|
||||
/// base64 redacted, peer IDs shortened), so the export carries no secrets.
|
||||
public final class LogExportBuffer {
|
||||
public static let shared = LogExportBuffer()
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.securelogger.export", qos: .utility)
|
||||
private var lines: [String] = []
|
||||
private var byteCount = 0
|
||||
|
||||
private let maxLines: Int
|
||||
private let maxBytes: Int
|
||||
|
||||
init(maxLines: Int = 2000, maxBytes: Int = 512 * 1024) {
|
||||
self.maxLines = maxLines
|
||||
self.maxBytes = maxBytes
|
||||
}
|
||||
|
||||
/// Append one already-formatted, already-sanitized log line. Non-blocking
|
||||
/// (async on the private queue).
|
||||
func append(_ line: String) {
|
||||
queue.async {
|
||||
self.lines.append(line)
|
||||
self.byteCount += line.utf8.count + 1 // + newline
|
||||
while self.lines.count > self.maxLines || self.byteCount > self.maxBytes {
|
||||
guard !self.lines.isEmpty else { break }
|
||||
let removed = self.lines.removeFirst()
|
||||
self.byteCount -= (removed.utf8.count + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A newline-joined snapshot of the buffered lines, oldest first. Safe to
|
||||
/// call from the main thread (brief synchronous read).
|
||||
public func snapshot() -> String {
|
||||
queue.sync { lines.joined(separator: "\n") }
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
queue.async {
|
||||
self.lines.removeAll(keepingCapacity: true)
|
||||
self.byteCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// LogNetworkSink.swift
|
||||
// BitLogger
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if DEBUG
|
||||
import Foundation
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
#endif
|
||||
|
||||
/// Best-effort, fire-and-forget UDP forwarder for sanitized log lines, so a
|
||||
/// sideloaded device on iOS 17+ (where `idevicesyslog` Wi-Fi streaming no
|
||||
/// longer works) can stream its logs to a LAN collector with no cable.
|
||||
///
|
||||
/// Design constraints:
|
||||
/// - DEBUG-only: never compiled into release. A privacy-first release build
|
||||
/// carries zero network-log code.
|
||||
/// - Opt-in and off by default: nothing is ever sent until a collector host
|
||||
/// is configured. An empty host means no egress.
|
||||
/// - Never blocks the caller and never throws into the app: every send runs
|
||||
/// on a private queue and failures are silently dropped. A dead or absent
|
||||
/// collector cannot affect app behavior (UDP is connectionless — datagrams
|
||||
/// to nowhere are simply lost).
|
||||
/// - Forwards the exact same sanitized text the ring buffer captures, so no
|
||||
/// secrets leave the device. Each line is prefixed with the device's label
|
||||
/// (`[nickname] …`) so one Mac-side `nc -lu <port>` can demux all devices.
|
||||
public final class LogNetworkSink {
|
||||
public static let shared = LogNetworkSink()
|
||||
|
||||
/// UserDefaults keys shared with the in-app config UI (App Info sheet).
|
||||
public static let hostDefaultsKey = "debug.logSink.host"
|
||||
public static let portDefaultsKey = "debug.logSink.port"
|
||||
public static let defaultPort = 9999
|
||||
/// Where the device label is read from (the app's chosen nickname).
|
||||
public static let nicknameDefaultsKey = "bitchat.nickname"
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.securelogger.netsink", qos: .utility)
|
||||
private let defaults: UserDefaults
|
||||
private var label = ""
|
||||
private var enabled = false
|
||||
#if canImport(Network)
|
||||
private var connection: NWConnection?
|
||||
#endif
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
/// Re-read collector host/port and the device label from UserDefaults and
|
||||
/// (re)build the UDP connection. Call on launch and whenever the config UI
|
||||
/// changes. An empty host tears the sink down (no egress).
|
||||
public func reloadConfiguration() {
|
||||
let host = (defaults.string(forKey: Self.hostDefaultsKey) ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let storedPort = defaults.integer(forKey: Self.portDefaultsKey)
|
||||
let port = storedPort > 0 ? storedPort : Self.defaultPort
|
||||
let label = (defaults.string(forKey: Self.nicknameDefaultsKey) ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
configure(host: host, port: port, label: label)
|
||||
}
|
||||
|
||||
/// Point the sink at `host:port` with a device `label`. Empty host or an
|
||||
/// invalid port disables egress.
|
||||
public func configure(host: String, port: Int, label: String) {
|
||||
queue.async {
|
||||
self.label = label
|
||||
#if canImport(Network)
|
||||
self.connection?.cancel()
|
||||
self.connection = nil
|
||||
guard !host.isEmpty,
|
||||
(1...65535).contains(port),
|
||||
let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else {
|
||||
self.enabled = false
|
||||
return
|
||||
}
|
||||
let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .udp)
|
||||
connection.start(queue: self.queue)
|
||||
self.connection = connection
|
||||
self.enabled = true
|
||||
#else
|
||||
self.enabled = false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward one already-formatted, already-sanitized line. Non-blocking;
|
||||
/// drops silently when disabled or on any send failure.
|
||||
func send(_ line: String) {
|
||||
queue.async {
|
||||
guard self.enabled else { return }
|
||||
#if canImport(Network)
|
||||
guard let connection = self.connection else { return }
|
||||
let prefixed = self.label.isEmpty ? line : "[\(self.label)] \(line)"
|
||||
guard let data = (prefixed + "\n").data(using: .utf8) else { return }
|
||||
connection.send(content: data, completion: .idempotent)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -20,4 +20,11 @@ public extension OSLog {
|
||||
static let security = OSLog(subsystem: subsystem, category: "security")
|
||||
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
|
||||
static let sync = OSLog(subsystem: subsystem, category: "sync")
|
||||
// Added for the observability pass: coarse filtering buckets for the mesh
|
||||
// routing layer, the internet gateway bridge, and the Wi-Fi bulk transport.
|
||||
// The bracket tags in the messages ([ROUTE]/[GW]/[WIFI]/…) remain the
|
||||
// primary filter; these just let `log stream --category` narrow further.
|
||||
static let mesh = OSLog(subsystem: subsystem, category: "mesh")
|
||||
static let gateway = OSLog(subsystem: subsystem, category: "gateway")
|
||||
static let transport = OSLog(subsystem: subsystem, category: "transport")
|
||||
}
|
||||
|
||||
@@ -86,6 +86,17 @@ public final class SecureLogger {
|
||||
case .fault: return 4
|
||||
}
|
||||
}
|
||||
|
||||
/// Short uppercase label for the in-memory export buffer line prefix.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .debug: return "DEBUG"
|
||||
case .info: return "INFO"
|
||||
case .warning: return "WARN"
|
||||
case .error: return "ERROR"
|
||||
case .fault: return "FAULT"
|
||||
}
|
||||
}
|
||||
|
||||
var osLogType: OSLogType {
|
||||
switch self {
|
||||
@@ -100,18 +111,34 @@ public final class SecureLogger {
|
||||
|
||||
// MARK: - Global Threshold
|
||||
|
||||
/// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL.
|
||||
/// Minimum level that will be logged. Override via env BITCHAT_LOG_LEVEL
|
||||
/// (env always wins). The default is `.debug` in DEBUG builds and `.info`
|
||||
/// otherwise: a sideloaded, tap-launched build can't receive the env var,
|
||||
/// so DEBUG must surface the `.debug` decision logs out of the box. Release
|
||||
/// emits nothing regardless (all log paths are `#if DEBUG`).
|
||||
/// Internal-settable so tests can verify level filtering; app code should not mutate it.
|
||||
internal static var minimumLevel: LogLevel = {
|
||||
internal static var minimumLevel: LogLevel = defaultMinimumLevel
|
||||
|
||||
/// The level used when nothing mutates `minimumLevel`. Honors the
|
||||
/// BITCHAT_LOG_LEVEL env override; otherwise `.debug` in DEBUG builds (so a
|
||||
/// tap-launched sideload surfaces decision logs) and `.info` elsewhere.
|
||||
/// Exposed for tests.
|
||||
internal static var defaultMinimumLevel: LogLevel {
|
||||
let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased()
|
||||
switch env {
|
||||
case "debug": return .debug
|
||||
case "info": return .info
|
||||
case "warning": return .warning
|
||||
case "error": return .error
|
||||
case "fault": return .fault
|
||||
default: return .info
|
||||
default:
|
||||
#if DEBUG
|
||||
return .debug
|
||||
#else
|
||||
return .info
|
||||
#endif
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
private static func shouldLog(_ level: LogLevel) -> Bool {
|
||||
return level.order >= minimumLevel.order
|
||||
@@ -168,6 +195,7 @@ public extension SecureLogger {
|
||||
|
||||
#if DEBUG
|
||||
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||
record(level: .error, message: "\(location) Error in \(sanitized): \(errorDesc)")
|
||||
#else
|
||||
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||
#endif
|
||||
@@ -258,9 +286,10 @@ private extension SecureLogger {
|
||||
let location = formatLocation(file: file, line: line, function: function)
|
||||
let sanitized = "\(location) \(message())".sanitized()
|
||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||
record(level: level, message: sanitized)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
/// Log a security event
|
||||
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
|
||||
file: String, line: Int, function: String) {
|
||||
@@ -269,6 +298,18 @@ private extension SecureLogger {
|
||||
let location = formatLocation(file: file, line: line, function: function)
|
||||
let message = "\(location) \(event.message)"
|
||||
os_log("%{public}@", log: .security, type: level.osLogType, message)
|
||||
record(level: level, message: message)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Fan a sanitized line out to the in-memory export buffer and the
|
||||
/// (opt-in, off by default) UDP network sink. DEBUG-only; both consumers
|
||||
/// are non-blocking. Same formatted text everywhere.
|
||||
static func record(level: LogLevel, message: String) {
|
||||
#if DEBUG
|
||||
let line = "[\(level.label)] \(message)"
|
||||
LogExportBuffer.shared.append(line)
|
||||
LogNetworkSink.shared.send(line)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user