diff --git a/advanced/observability.mdx b/advanced/observability.mdx
index e6d7fd7c..3fb064d8 100644
--- a/advanced/observability.mdx
+++ b/advanced/observability.mdx
@@ -105,13 +105,13 @@ Spans are grouped under a stable `wa..` naming scheme so you can filte
### Levels
-Most spans are emitted at `debug` or `trace`. The connection-lifecycle spans (`wa.conn.connect`, `wa.conn.disconnect`, `wa.conn.reconnect`, `wa.conn.run`, `wa.conn.logout`) are at `info` so connection state is visible at the default level. Failures surface at `ERROR` via `err(Debug)` on the instrumented function, and the existing `warn!`/`error!` log calls surface through the bridge.
+Most spans are emitted at `debug` or `trace`. The connection-lifecycle spans (`wa.conn.connect`, `wa.conn.disconnect`, `wa.conn.reconnect`, `wa.conn.run`, `wa.conn.logout`) are at `info` so connection state is visible at the default level. Failures surface at `ERROR` via `err(Debug)` on the instrumented function, and the existing `warn!`/`error!` log calls surface through the bridge — with two exceptions: `wa.conn.connect` surfaces failures at `WARN` instead (its caller already classifies the real failures as `error!`, so the default `ERROR` was double-reporting transient handshake retries), and `wa.conn.read_loop` returns `Ok` (not `Err`) for a routine server-initiated stream recycle, so its `ERROR` capture fires only for genuine failures — never for WhatsApp's normal periodic reconnects.
A downstream binary can statically strip lower levels at compile time with `tracing`'s `release_max_level_info` / `release_max_level_warn` features.
### Account identity in spans
-The `wa.conn.run`, `wa.iq`, and `wa.send.message` spans carry `lid` and `pn` fields for your own account, so traces are filterable and groupable per account in multi-account deployments. `pn` is redacted via `Jid::observe()` like any other phone-number field (see [PII handling](#pii-handling) below); `lid` is rendered in full since it is pseudonymous.
+The `wa.conn.run`, `wa.conn.connect`, `wa.conn.read_loop`, `wa.iq`, and `wa.send.message` spans carry `lid` and `pn` fields for your own account, so traces are filterable and groupable per account in multi-account deployments. `pn` is redacted via `Jid::observe()` like any other phone-number field (see [PII handling](#pii-handling) below); `lid` is rendered in full since it is pseudonymous.
`wa.conn.run` re-records both fields on every reconnect-loop iteration rather than once before the loop, since a freshly-paired device has no `lid`/`pn` yet on the first pass — pairing resolves them on a detached task with no ambient span, so the identity appears on the *next* reconnect iteration instead (a routine event: server-initiated stream-end, network blips).
diff --git a/api/transport.mdx b/api/transport.mdx
index 444c6330..1a1b8081 100644
--- a/api/transport.mdx
+++ b/api/transport.mdx
@@ -185,6 +185,24 @@ pub enum DisconnectReason {
`DisconnectReason` implements `Display` for human-readable logging. Custom transports should emit the most specific variant they can determine.
+```rust
+impl DisconnectReason {
+ pub fn is_clean_shutdown(&self) -> bool {
+ // ...
+ }
+}
+```
+
+`is_clean_shutdown()` tells a benign, server-initiated stream recycle (the normal WhatsApp reconnect path) apart from a genuine transport failure, so consumers of [`events::Disconnected`](/concepts/events#disconnected) don't have to parse logs to classify a disconnect. It's deliberately conservative — anything ambiguous returns `false` (treated as a real failure):
+
+| Variant | `is_clean_shutdown()` |
+|---------|------------------------|
+| `StreamEnded` | `true` — EOF with no close frame is how the WA server recycles a connection |
+| `ServerClose` with code `None`, `Some(1000)`, or `Some(1001)` | `true` — no code, normal closure, or going-away are graceful |
+| `ServerClose` with any other code | `false` — protocol/server error, restart, etc. |
+| `ReadError(_)` | `false` — a transport read/IO error is always a real failure |
+| `Unknown` | `false` — an unreported reason is never assumed benign |
+
```rust
TransportEvent::Disconnected(reason) => {
tracing::info!(%reason, "transport closed");
diff --git a/concepts/events.mdx b/concepts/events.mdx
index 988d0076..aab3c5f2 100644
--- a/concepts/events.mdx
+++ b/concepts/events.mdx
@@ -227,17 +227,26 @@ Event::Connected(_) => {
### Disconnected
-**Emitted:** When connection is lost
+**Emitted:** When the connection ends without the client itself intentionally closing or reconnecting it — covers both a routine server-initiated stream recycle and a genuine transport failure (see `reason` below to tell them apart)
```rust
#[derive(Debug, Clone, Serialize)]
-pub struct Disconnected;
+pub struct Disconnected {
+ pub reason: DisconnectReason,
+}
-Event::Disconnected(Disconnected)
+Event::Disconnected(Disconnected { reason })
```
+**Fields:**
+- `reason: DisconnectReason` — why the transport ended. Check `reason.is_clean_shutdown()` to tell a routine server-initiated stream recycle (WhatsApp's normal reconnect path) apart from a genuine transport failure, without parsing logs. See [`DisconnectReason`](/api/transport#disconnected) for the variants.
+
**Behavior:** Client automatically attempts reconnection
+
+Breaking change: `Disconnected` gained the `reason` field (previously a unit struct). Update `Event::Disconnected(Disconnected)` patterns to `Event::Disconnected(Disconnected { reason })` or `Event::Disconnected(_)`.
+
+
### ConnectFailure
**Emitted:** When connection fails with a specific reason