Skip to content

Observability

A bus-based architecture earns its keep in production by being easy to see at runtime. Every impulse, every broadcast, every request crosses the same chokepoint — the SwitchBoard — which means a single well-placed listener can reveal the full behavior of an app without sprinkling log calls through handlers. Synapse ships three levels of observation, ordered by coverage and cost:

LevelSeesWhen to reach for it
Global loggerEvery value, every type, every intercept pointDev-time firehose; “what just happened?”
addLoggingInterceptors<T>Every value of a specific typeTracing one feature or one channel in isolation
TraceContext + listenerValues fired from tagged emitters, with originWhere is this impulse coming from?”

All three are read-only observers — they can’t change what’s on the bus, only watch it.

setGlobalLogger installs a single callback that fires for every value crossing every intercept point, in every direction, on every channel:

switchBoard.setGlobalLogger { point, clazz, data ->
Log.d("SwitchBoard", "[$point] ${clazz.simpleName}: $data")
}

The signature is (InterceptPoint, KClass<*>, Any) -> Unit. The logger runs after the interceptor pipeline at each point, so the data it sees is the fully-transformed value a consumer is about to receive — token already injected, normalization already applied. Pass null to remove.

This is deliberately a single slot, not a list. The global logger is the “dev console” hook — it’s fine for interactive debugging, for bringing up a new feature, or for an in-app debug drawer. In production you’d typically route it through a ring buffer or a conditional log tag rather than leaving it wired unconditionally.

When you only care about one domain, install a six-point read-only interceptor bracket for a single type:

switchBoard.addLoggingInterceptors<OrderPlaced> { event ->
Log.d("Orders", "order placed: ${event.orderId}")
}

Under the hood this registers six Interceptor.read<OrderPlaced> instances — one per intercept point — at priorities that put upstream loggers first (Int.MIN_VALUE) and downstream loggers last (Int.MAX_VALUE). The result is a clean bracket around every pass through the bus for that type, without affecting interceptors on any other type.

Because interception is keyed by Class.isAssignableFrom, you can pass a marker interface and cover every impulse that implements it:

interface AnalyticsEvent
switchBoard.addLoggingInterceptors<AnalyticsEvent> { event -> analytics.track(event) }

The concrete events don’t need to know the logger exists — they just need to implement the marker. The pattern is identical to the one used for TokenBearer interceptors in the Interceptors page.

The global logger tells you what is on the bus; TraceContext tells you where it came from. A Coordinator or CreateContext with a non-null tag parameter automatically attaches a TraceContext to the coroutine context for every Trigger and Broadcast call it makes:

Coordinator(switchBoard, lifecycleOwner, tag = "AuthCoordinator") {
ReactTo<LoginRequested> { /* ... */ }
}
// Compose-side
CreateContext(appContext, tag = "CheckoutScreen") {
Node(initialState = CheckoutState()) { /* ... */ }
}

Listen for the traces with setTraceListener:

switchBoard.setTraceListener { trace, point, clazz, data ->
Log.d("Trace", "[${trace.emitterTag}] $point ${clazz.simpleName}: $data")
}

The listener fires with a TraceContext, an InterceptPoint, a KClass, and the value itself. TraceContext carries four fields:

FieldTypeDefaultPurpose
traceIdStringmonotonic counterUnique identifier for this trace
parentTraceIdString?nullLink to a parent trace
emitterTagString?nullHuman-readable emitter label
timestampLongSystem.nanoTime()Monotonic creation timestamp

Crucially, TraceContext is a CoroutineContext.Element — it rides along the coroutine that called Trigger, but it never enters the SharedFlow itself. Interceptors and consumers still see the raw value; only the trace listener has access to the context. That’s why tracing is strictly additive: turning it on can’t break a handler that doesn’t know about it.

Tracing is designed so the off-path pays nothing. A coordinator constructed without a tag never creates a TraceContext, so there’s no allocation, no withContext, and no context-element lookup on every emission. On the listener side, the switchboard only calls currentCoroutineContext() when a trace listener is actually registered — the common case (no listener) skips the suspend intrinsic entirely.

Tag the coordinators and screens where you’ll actually want origin information — auth flows, navigation boundaries, anything where “who fired this?” is the first question you’d ask in a postmortem — and leave everything else untagged. You can always add tags later; the operation is local to a single call site.

All three mechanisms coexist — they’re independent slots on the switchboard, not alternatives. A practical setup during development looks like:

  • setGlobalLogger wired to a debug-drawer ring buffer so the developer can scroll back through the last N events on any channel.
  • addLoggingInterceptors<AnalyticsEvent> for the production analytics pipeline — it’s type-scoped, so it doesn’t pay for values it doesn’t care about, and it’s the same mechanism that real analytics interceptors use.
  • tag + setTraceListener on auth, navigation, and cross-feature coordinators so the trace log can answer “which coordinator fired this NavigateTo?” without needing to grep the codebase.

In production you’d typically keep the trace listener and the type-scoped loggers, and gate the global logger behind a debug build flag or a hidden in-app toggle.

  • Testing — writing tests with SynapseTestRule