| name | broadcast-logging-best-practices |
| description | Integrate Broadcast into a Swift app or package, including configuring shared Log destinations, dependency injection, structured Log.Signal, Log.Category, and Log.Payload APIs, app-specific logging helpers, buffered log export, and logging tests. |
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
Broadcast where logging is configured or called.
- Create a shared
Log instance from one or more destinations.
- For app or app-specific package code, prefer one immutable app or package-scoped global
let log so SwiftUI and non-SwiftUI surfaces share the same destination graph.
- Use Broadcast's built-in
Log.Payload helper factories for common typed values, then add app-specific Log.Category values and app-specific Log.Payload helpers 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
Log fans out one log call to one or more destinations.
LoggingDestination is the write-only sink protocol whose primitive is log(_ record:); Broadcast provides default debug, info, warn, error, and fault convenience methods that create records.
BufferedLoggingDestination extends LoggingDestination with records() for destinations that need access to canonical records, plus overridable default logs() text export.
ConsoleLogger writes process-local output.
SessionLogger buffers logs in memory for the current process.
MultiSessionLogger buffers canonical Log.Record values across launches using persistent storage; use it only when the host app has configured the required persistence.
Log.Signal describes the intent of a structured log. Defaults include .action, .state, .event, .metric, and .diagnostic.
Log.Category describes a human-readable subsystem or area.
Log.Payload stores 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 .timestamp so call-sites do not need to format values manually.
Log.Record is the identifiable timestamped semantic structured log value that destinations format through recordFormatter, which defaults to Broadcast's standard single-line format; records create a UUID and current timestamp by default.
Log.Record.Formatter is the type-erased destination-level formatter for structured Log.Record values.
- Built-in
Log.Record format styles include .default, .json, .tokenOptimized, and .canonicalLogLine.
Log.DateProvider supplies dates for buffered loggers, and Log.Timestamp.FormatStyle formats those dates; Log.Timestamp defaults to the current date and is available as Log.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 log for the common logging surface.
- SwiftUI apps: expose that same global logger to views with
EnvironmentValues plus @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
Log through coordinators, service containers, or initializers when a dependency boundary needs it.
- Server apps: expose one process/app logger where appropriate, and register or pass
Log through the application/request dependency container when handlers and services need explicit dependencies.
- Reusable packages: accept
Log in 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)
}
}
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:
.raw preserves the key and value text and backs default payload formatting.
.normalized normalizes keys and quotes ambiguous values for canonical log lines.
.tokenOptimized uses 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", not reminders.
- 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
LoggingDestination by implementing log(_ 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
recordFormatter when they need a different structured record format.
- Custom destinations receive semantic records, so they can inspect
level, timestamp, signal, category, message, and payload before formatting or exporting.
- Use
BufferedLoggingDestination when 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
Sendable when 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.Category and Log.Payload helpers 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
Log call-sites if the app relies on it.
- If the host app defines custom payload, timestamp, or record
FormatStyle implementations, test each formatter independently using normal import Broadcast.
- If the host app defines a custom destination
recordFormatter, add an end-to-end test proving the destination applies Log.Record.Formatter and 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.