Broadcast Logging Best Practices
Use this skill when integrating Broadcast into a Swift project. Treat Broadcast as an external reusable logging library; do not assume you are modifying Broadcast itself unless the user explicitly asks to work on the library.
Before making non-trivial Broadcast changes or integrations, read the current Broadcast README and relevant DocC articles in Sources/Broadcast/Documentation.docc/ so examples, API choices, and terminology match the library's public guidance.
Integration Workflow
- Add Broadcast as a dependency using the host project's normal package workflow.
- Import
Broadcastwhere logging is configured or called. - Create a shared
Loginstance from one or more destinations. - For app or app-specific package code, prefer one immutable app or package-scoped global
let logso SwiftUI and non-SwiftUI surfaces share the same destination graph. - Use Broadcast's built-in
Log.Payloadhelper factories for common typed values, then add app-specificLog.Categoryvalues and app-specificLog.Payloadhelpers in the integrating app or app-specific logging package. - Replace important free-form logs with structured logs where they help explain user-visible behavior, support diagnostics, startup, sync, entitlement, routing, persistence, or network decisions.
- Validate with the host project's normal build and test commands.
Core APIs
Logfans out one log call to one or more destinations.LoggingDestinationis the write-only sink protocol whose primitive islog(_ record:); Broadcast provides defaultdebug,info,warn,error, andfaultconvenience methods that create records.BufferedLoggingDestinationextendsLoggingDestinationwithrecords()for destinations that need access to canonical records, plus overridable defaultlogs()text export.ConsoleLoggerwrites process-local output.SessionLoggerbuffers logs in memory for the current process.MultiSessionLoggerbuffers canonicalLog.Recordvalues across launches using persistent storage; use it only when the host app has configured the required persistence.Log.Signaldescribes the intent of a structured log. Defaults include.action,.state,.event,.metric, and.diagnostic.Log.Categorydescribes a human-readable subsystem or area.Log.Payloadstores typed key-value diagnostics and exposes public typed initializers plus helper factories such as.string,.bool,.int,.uuid,.url,.date,.duration,.error,.count,.id, and.timestampso call-sites do not need to format values manually.Log.Recordis the identifiable timestamped semantic structured log value that destinations format throughrecordFormatter, which defaults to Broadcast's standard single-line format; records create a UUID and current timestamp by default.Log.Record.Formatteris the type-erased destination-level formatter for structuredLog.Recordvalues.- Built-in
Log.Recordformat styles include.default,.json,.tokenOptimized, and.canonicalLogLine. Log.DateProvidersupplies dates for buffered loggers, andLog.Timestamp.FormatStyleformats those dates;Log.Timestampdefaults to the current date and is available asLog.Timestamp.now, but fixed date providers are still the deterministic-test path for logger output.
Broadcast Setup
Create one app or package-owned global let log near the app or package's composition root, using destinations that match its diagnostics needs.
For local console output plus in-memory support export, define one app or package-owned global logger:
import Broadcast
public let sessionLogger = SessionLogger()
public let log = Log(
destinations: [
ConsoleLogger(subsystem: "com.example.app", category: "logs"),
sessionLogger
]
)
Use public when the logger lives in an app-specific logging module; omit it
when the logger is only needed inside one app target.
This keeps call-sites short while preserving one shared destination graph:
log.info(.event, "App launched", category: .startup)
Keep destination references global too when the app or package needs them later. For
example, sessionLogger should remain accessible to the support-export flow if
the app exposes sessionLogger.logs() or sessionLogger.records().
When a SwiftUI app wants the logger in view code, expose the same shared logger
through SwiftUI's modern @Entry environment syntax. The environment should point
at the app's global logger, not a separate SwiftUI-only logger.
import Broadcast
import SwiftUI
extension EnvironmentValues {
@Entry var log: Log = ExampleApp.log
}
struct ReminderListView: View {
@Environment(\.log) private var log
var body: some View {
Button("Sync") {
self.log.info(.action, "Started sync", category: "Sync")
}
}
}
For deterministic tests, inject a fixed date provider:
let logger = SessionLogger(
dateProvider: Log.DateProvider { Date(timeIntervalSince1970: 0) }
)
Use .timestamp when exported logs should render Unix timestamp seconds instead
of the default ISO-8601 timestamp, not as the default deterministic-test strategy.
For custom timestamp output, define a Foundation.FormatStyle where
FormatInput == Log.Timestamp and FormatOutput == String:
struct SupportTimestampFormatStyle: Foundation.FormatStyle {
func format(_ value: Log.Timestamp) -> String {
value.date.formatted(date: .abbreviated, time: .shortened)
}
}
extension FormatStyle where Self == SupportTimestampFormatStyle {
static var supportTimestamp: Self {
Self()
}
}
let text = Log.Timestamp.now.formatted(.supportTimestamp)
Use custom timestamp styles directly or inside custom destinations that own
timestamp rendering. Broadcast's concrete SessionLogger and MultiSessionLogger
currently accept the built-in Log.Timestamp.FormatStyle options, such as
.default and .timestamp, for logger-level timestamp configuration.
For support-log export, keep a reference to a BufferedLoggingDestination and expose its logs() output through the host app's support flow:
let exportedLogs = sessionLogger.logs()
let records = sessionLogger.records()
Use records() plus a specific record formatter when an export surface needs a
shape other than the destination's default text output:
let promptLogs = sessionLogger.records()
.map({ $0.formatted(.tokenOptimized) })
.joined(separator: "\n")
For multi-session export, use MultiSessionLogger only after the host app configures its persistence store. Keep persistence setup in the host app layer and pass the configured store into the logger.
Expose Log using the host project's existing pattern:
- App or app-specific package code: prefer one immutable app or package-scoped global
let logfor the common logging surface. - SwiftUI apps: expose that same global logger to views with
EnvironmentValuesplus@Entry, or pass it through an observable dependency container or app services object when that already exists. - UIKit/AppKit apps: use the app global directly at app-owned call-sites, and pass
Logthrough coordinators, service containers, or initializers when a dependency boundary needs it. - Server apps: expose one process/app logger where appropriate, and register or pass
Logthrough the application/request dependency container when handlers and services need explicit dependencies. - Reusable packages: accept
Login initializers instead of reaching for app globals, so tests can inject capturing destinations.
Avoid creating a new Log at every call-site. Prefer a shared instance so destinations, buffering, and export behavior stay consistent.
Call-Site Guidance
The examples below use a hypothetical Reminders app only as a concrete reference point. Adapt the categories and payload keys to the host app's domain.
Prefer structured logs for durable diagnostics:
log.info(
.state,
"Loaded reminders",
category: "Reminders",
payload: [
.string("result", "Success"),
.int("reminderCount", reminderCount)
]
)
Keep the event message at the call-site. If many logs need the same contextual values, extract a payload builder instead of hiding the log call behind a broad wrapper:
func reminderDiagnosticsPayload(additionalPayload: [Log.Payload] = []) -> [Log.Payload] {
additionalPayload + [
.string("title", reminder.title),
.string("priority", reminder.priority)
]
}
log.info(
.action,
"Updated reminder priority",
category: "Reminders",
payload: reminderDiagnosticsPayload(additionalPayload: [
.string("result", "Success")
])
)
Use plain logs for short-lived local debugging:
log.info("Created", "Reminder", 3)
Passing an array to Log, such as log.info([a, b, c]), logs that array as one value, matching Swift's print behavior. Use log.info(a, b, c) when the intent is multiple logged values.
Abstraction Guardrails
Prefer direct log calls at real production action and state-transition sites:
log.info(.event, "App launched", category: .startup)
Do not introduce a wrapper type solely so a log call can be unit tested. A logging wrapper is justified only when production code needs shared payload construction, repeated event shape, dependency composition, or a real integration boundary.
Common smells:
AppLifecycleLogger,SearchLogger, or similar types that only forward one-line log calls.- Tests that exist only to prove forwarding wrappers emit expected strings.
- Logger abstractions created before the feature workflow they describe exists.
Prefer testing durable pieces instead:
- Category identifiers.
- Payload helper formatting.
- Redaction behavior.
- Diagnostics export content.
- Custom destinations or formatters.
- Persistent/session log export.
SwiftUI Lifecycle Logging
For app lifecycle logs, instrument the real SwiftUI boundaries directly:
@main
struct ExampleApp: App {
@Environment(\.scenePhase) private var scenePhase
init() {
log.info(.event, "App launched", category: .startup)
}
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: self.scenePhase) { _, scenePhase in
log.info(.state, "Scene phase changed", category: .app, payload: [.string("route", String(describing: scenePhase))])
}
}
}
Use direct lifecycle logs unless the host app already has a production services container that owns lifecycle instrumentation.
Record Formatting
Broadcast uses Swift FormatStyle types for structured log formatting customization.
Log keeps call-sites ergonomic and forwards structured values to each destination.
Destinations then format those values with their recordFormatter. This lets
different destinations eventually render the same semantic record differently while
preserving one call-site API.
Use the default structured format directly when needed:
let record = Log.Record(
timestamp: Log.Timestamp(Date(timeIntervalSince1970: 0)),
level: .info,
signal: .state,
message: "Loaded reminders",
category: "Reminders",
payload: [.string("result", "Success")]
)
let text = record.formatted(.default)
Broadcast ships these record formats:
.default: human-readable support text, e.g.[Info | State | Reminders] @ 1970-01-01T00:00:00Z | Loaded reminders | payload=[result=Success]..json: a conventional structured JSON object. Export one record per line for JSON Lines-compatible output..tokenOptimized: compact prompt-friendly text, e.g.t=42125 l=info s=State c=Reminders m="Loaded reminders" p.result=Success. It uses epoch milliseconds for the record timestamp,p.for payload fields, epoch-millisecond date payloads, and integer millisecond duration payloads..canonicalLogLine: Stripe-style canonical log line text with normalized keys and quoted values when needed, e.g.[1970-01-01T00:00:00Z] canonical-log-line level=info signal=State category=Reminders message="Loaded reminders" result=Success.
Use the type-erased formatter conveniences when configuring destinations or export objects:
let formatter = Log.Record.Formatter.tokenOptimized
let text = formatter.format(record)
Create a custom record formatter by defining a Swift FormatStyle where
FormatInput == Log.Record and FormatOutput == String:
struct CompactRecordFormatStyle: Foundation.FormatStyle, Sendable {
func format(_ value: Log.Record) -> String {
var text = [
value.signal?.identifier,
value.category?.identifier
]
.compactMap({ $0 })
.joined(separator: "/")
if !text.isEmpty {
text += ": "
}
text += value.message
if !value.payload.isEmpty {
text += " (\(value.payload.map({ $0.formatted(.logPayload) }).joined(separator: ", ")))"
}
return text
}
}
extension FormatStyle where Self == CompactRecordFormatStyle {
static var compactRecord: Self {
Self()
}
}
Apply a custom record formatter to a destination by wrapping it in
Log.Record.Formatter:
final class CompactLoggingDestination: LoggingDestination {
var recordFormatter: Log.Record.Formatter {
Log.Record.Formatter(.compactRecord)
}
func log(_ record: Log.Record) {
let text = self.recordFormatter.format(record)
// Write `text` to this destination.
}
}
If a destination only needs Broadcast's default record shape with a different timestamp style, use the timestamp convenience initializer instead of rebuilding the default record format:
var recordFormatter: Log.Record.Formatter {
Log.Record.Formatter(timestampFormatStyle: .timestamp)
}
Custom record formatters may use payload.formatted(.logPayload) when they need
Broadcast's default key=value payload rendering. Consumers can define their own
Swift FormatStyle where FormatInput == Log.Payload when a custom record
formatter needs a different payload shape.
When modifying Broadcast itself, keep typed payload storage separate from rendered
text fields. Log.Payload remains the typed semantic model. Internal text formats
can convert rendered strings into Log.Record.KeyValuePair values and format those
pairs with Log.Record.KeyValueFormatStyle:
.rawpreserves the key and value text and backs default payload formatting..normalizednormalizes keys and quotes ambiguous values for canonical log lines..tokenOptimizeduses normalized key-value output and escapes control characters so one record stays on one physical line.
Structured Log Style
- Keep structured logs single-line:
[Info | State | Category] @ 2026-05-31T18:06:16Z | Message | payload=[key=value]. - Start messages with a capital letter.
- Make categories human-readable:
"Reminders", notreminders. - Use stable, compact payload keys:
id,count,duration,result,reason,route. - Preserve payload order so the most important diagnostic values appear first.
- Put errors in payloads instead of interpolating them into messages.
- Include explicit result payloads for durable outcomes: success for completed operations, failure for structured error operations, and specific alternatives such as skipped, rejected, cancelled, or conflict when those are more accurate.
- Use durable structured errors for failures that explain user-visible behavior, data loss, sync, payments, routing, startup, or persistence issues.
- Do not put real user data, tokens, secrets, or realistic account identifiers into examples, tests, or documentation.
- Follow the host project's local logging style for helper names, file names, visibility, and payload grouping.
- Treat structured log calls as visual blocks: separate them from adjacent executable code with a blank line, while avoiding extra blank lines against structural boundaries such as opening braces,
case,catch, and closing braces.
Privacy Checklist
Prefer durable, low-risk metadata over raw user content:
- Log token presence such as
hasAccessToken=true, never access token or refresh token values. - Log search length or query category, not raw search text.
- Log counts, result, reason, route, status code, duration, and operation names.
- Log safe stable IDs only when they are useful for diagnostics and not sensitive in the host domain.
- Log scope counts, or sorted scope names only when scopes are expected support context.
- Do not log full API response bodies, profile data, payment data, contact details, device tokens, or arbitrary user-entered content.
- Use synthetic fixtures in examples and tests; never include realistic secrets or account identifiers.
For auth, search, media, sync, and account features, default to presence and counts first. Add raw identifiers only after deciding they are safe and useful for debugging that specific product.
App-Specific Extensions
Create app-specific categories and payload helpers outside Broadcast:
import Broadcast
extension Log.Category {
static let reminders: Self = "Reminders"
static let sync: Self = "Sync"
}
extension Log.Payload {
static func priority(_ value: String) -> Self {
.string("priority", value)
}
}
Use Broadcast's built-in Log.Payload helpers, or typed
Log.Payload(key:value:) initializers when needed, instead of adding global
formatting extensions on String, UUID, Bool, Error, or unrelated types.
Keep helpers close to the code that owns them. Broad concepts used across many modules can live in a shared logging package; narrow payloads should live in the app target or feature package that emits those logs.
Prefer typed helper factories when a payload key is part of the host app's
durable diagnostic vocabulary. Broadcast's built-in .string, .int, .id,
.error, .duration, and similar helpers are fine for one-off local details,
but repeated keys should become app-specific helpers so spelling and value
formatting stay consistent.
Destination Guidance
- App code should normally call
Log, not individual destinations. - Custom destinations conform to
LoggingDestinationby implementinglog(_ record: Log.Record). - Custom destinations do not need to implement zero-argument methods like
info(); Broadcast provides those as print-like convenience extensions. - Custom destinations do not need to implement each level method unless they are intentionally overriding Broadcast's default record creation path.
- Custom destinations can override
recordFormatterwhen they need a different structured record format. - Custom destinations receive semantic records, so they can inspect
level,timestamp,signal,category,message, andpayloadbefore formatting or exporting. - Use
BufferedLoggingDestinationwhen a destination needs to export canonical records or formatted text for support, diagnostics, or other use cases where individual records are useful.
Concurrency Guidance
- Logging destination methods are synchronous, so protect shared mutable memory synchronously.
- Prefer simple synchronous synchronization for in-memory buffers on supported platforms.
- Keep persistence integrations isolated behind the host app's appropriate actor or synchronization boundary.
- Keep logged records
Sendablewhen they cross task or isolation boundaries. - Avoid async-only destination APIs unless the host app is intentionally redesigning logging around async calls.
Testing Guidance
When integrating Broadcast, add focused tests for the host app's logging layer:
- App-specific
Log.CategoryandLog.Payloadhelpers produce expected identifiers and typed values. - Structured logs include the intended signal, category, message, and payload order.
- Shared payload builders preserve the intended ordering of event-specific values and common context.
- Support-log export works when using a buffered destination.
- Important privacy constraints are enforced with synthetic fixtures.
- Array-as-one-value behavior is understood at
Logcall-sites if the app relies on it. - If the host app defines custom payload, timestamp, or record
FormatStyleimplementations, test each formatter independently using normalimport Broadcast. - If the host app defines a custom destination
recordFormatter, add an end-to-end test proving the destination appliesLog.Record.Formatterand combines record formatting with any relevant timestamp or payload formatting. - Do not add tests that only prove package names, target linkage, or forwarding wrappers. Those tests create confidence noise without protecting behavior.
Run the host project's normal test/build command. If no standard command exists, inspect the project first and choose the least surprising validation path for that project.