How to Monitor Connection Events and Path Changes Using iroh's Hooks System

iroh provides an asynchronous hook system that lets you intercept connection lifecycle events and path metrics by implementing the EndpointHooks trait and registering it during endpoint construction.

The iroh networking library exposes a lightweight hook mechanism for observing QUIC connection lifecycle events without blocking the async runtime. By implementing the EndpointHooks trait defined in iroh/src/endpoint/hooks.rs, you can capture handshake details, monitor path characteristics like RTT and MTU, and log final connection statistics when connections close.

How the Endpoint Hooks System Works

The hook system centers around the EndpointHooks trait, which acts as a callback interface for connection lifecycle events. When you build an Endpoint using Endpoint::builder, the system stores your hooks in an EndpointHooksList (a Vec<Box<dyn DynEndpointHooks>>) that the endpoint consults during connection state transitions.

Hooks are invoked at three critical lifecycle points defined in iroh/src/endpoint/connection.rs:

  • before_connect – Called before a connection is accepted or initiated, allowing you to reject incoming connections early.
  • after_handshake – Called immediately after the QUIC handshake completes, providing access to the negotiated ALPN and remote endpoint ID.
  • after_close – Called when the connection terminates, offering final statistics and closure reasons.

Each method returns an outcome enum (Accept or Reject) that determines whether the connection proceeds. This design guarantees that hooks run synchronously during state transitions while still allowing asynchronous monitoring through weak handles.

Capturing Connections with Weak Handles

The key to non-blocking monitoring is the WeakConnectionHandle obtained via conn.weak_handle() inside the after_handshake method. This handle is cheap to clone and store because it does not prevent the connection from closing, avoiding reference-count cycles that could leak memory.

To monitor path metrics asynchronously:

  1. Store the WeakConnectionHandle in a background task.
  2. Call handle.upgrade() to temporarily obtain a Connection reference.
  3. Access paths() to iterate over active paths and read RTT, MTU, and other socket metrics.
  4. Await handle.closed() to receive final statistics (bytes sent/received) when the connection ends.

Because upgrade() returns None if the connection has already closed, this pattern is safe for long-running monitoring tasks without risking use-after-close errors.

Implementing the EndpointHooks Trait

To create a custom monitor, implement the EndpointHooks trait and handle the after_handshake method. The trait uses async methods that receive a &Connection reference, allowing you to extract static metadata (ALPN protocol, remote endpoint ID) before spawning background tasks.

The implementation must avoid storing the Endpoint itself to prevent reference cycles, as noted in the safety comments within iroh/src/endpoint/hooks.rs. Instead, channel senders or other lightweight synchronization primitives should ferry data to your monitoring infrastructure.

Complete Working Example

Below is a self-contained implementation that mirrors the official monitor-connections.rs example. It demonstrates installing a hook, capturing connection metadata, and asynchronously logging path RTT and closure statistics.

use std::{sync::Arc, time::Duration};
use iroh::{
    Endpoint,
    endpoint::{AfterHandshakeOutcome, Connection, EndpointHooks, WeakConnectionHandle, presets},
};
use tokio::{
    sync::mpsc::{UnboundedReceiver, UnboundedSender},
    task::JoinSet,
};
use tracing::{info, info_span, Instrument};

/// Hook implementation that forwards connection info to a background task.
#[derive(Clone, Debug)]
struct ConnectionMonitor {
    tx: UnboundedSender<MonitoredConnection>,
    _task: Arc<tokio::task::JoinHandle<()>>,
}

#[derive(Debug)]
struct MonitoredConnection {
    alpn: Vec<u8>,
    remote_id: iroh::EndpointId,
    handle: WeakConnectionHandle,
}

impl EndpointHooks for ConnectionMonitor {
    async fn after_handshake(&self, conn: &Connection) -> AfterHandshakeOutcome {
        let info = MonitoredConnection {
            alpn: conn.alpn().to_vec(),
            remote_id: conn.remote_id(),
            handle: conn.weak_handle(),
        };
        // Forward to background logger; failure is non-fatal.
        let _ = self.tx.send(info);
        AfterHandshakeOutcome::Accept
    }
}

impl ConnectionMonitor {
    fn new() -> Self {
        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
        let task = tokio::spawn(Self::run(rx).instrument(info_span!("monitor")));
        Self {
            tx,
            _task: Arc::new(task),
        }
    }

    async fn run(mut rx: UnboundedReceiver<MonitoredConnection>) {
        let mut tasks = JoinSet::new();
        while let Some(MonitoredConnection { alpn, remote_id, handle }) = rx.recv().await {
            let alpn_str = String::from_utf8_lossy(&alpn).to_string();
            let remote_short = remote_id.fmt_short();

            info!(%remote_short, %alpn_str, "new connection established");

            // Spawn task to await closure and log final stats.
            tasks.spawn(async move {
                match handle.closed().await {
                    Some(closed) => {
                        // Access path metrics if connection still exists.
                        let rtt = handle.upgrade()
                            .and_then(|c| c.paths().iter().map(|p| p.rtt()).min());
                        
                        info!(%remote_short, %alpn_str,
                              rtt = ?rtt,
                              udp_rx = closed.stats.udp_rx.bytes,
                              udp_tx = closed.stats.udp_tx.bytes,
                              "connection closed");
                    }
                    None => {
                        info!(%remote_short, %alpn_str, "connection closed before tracking");
                    }
                }
            }.instrument(tracing::Span::current()));
        }

        while let Some(res) = tasks.join_next().await {
            res.expect("connection monitor task panicked");
        }
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt().init();
    
    let monitor = ConnectionMonitor::new();
    
    let endpoint = Endpoint::builder(presets::Minimal)
        .hooks(monitor.clone())
        .bind()
        .await?;
    
    // Server logic continues here...
    Ok(())
}

Key Source Files

The hook system spans several critical files in the iroh repository:

Summary

  • Register hooks during endpoint construction using Endpoint::builder(...).hooks(my_hook) to intercept connection events.
  • Implement EndpointHooks to receive callbacks at before_connect, after_handshake, and after_close lifecycle points.
  • Use WeakConnectionHandle to monitor connections asynchronously without preventing garbage collection or creating reference cycles.
  • Access path metrics via handle.upgrade()?.paths() to read RTT, MTU, and socket statistics, and await handle.closed() for final byte counters.
  • Return outcomes (Accept or Reject) from hook methods to programmatically filter connections based on custom logic.

Frequently Asked Questions

What is the difference between before_connect and after_handshake hooks?

The before_connect hook fires before any network handshake occurs, allowing you to reject connections based on IP address or initial metadata without allocating connection resources. The after_handshake hook fires after the QUIC cryptographic handshake completes, giving you access to the negotiated ALPN protocol and authenticated remote endpoint ID. According to the source code in iroh/src/endpoint/connection.rs, before_connect is ideal for early filtering, while after_handshake is where you capture the WeakConnectionHandle for monitoring.

How do I access real-time path metrics without blocking the endpoint?

Capture a WeakConnectionHandle inside after_handshake using conn.weak_handle(), then spawn a background task that periodically calls handle.upgrade(). If upgrade() returns Some(conn), you can iterate over conn.paths() to read RTT, MTU, and congestion statistics. This pattern avoids keeping a strong reference to the connection, ensuring the endpoint can close connections naturally while your monitor observes metrics asynchronously.

Can I reject connections based on custom logic in the hooks?

Yes. Both before_connect and after_handshake return an outcome enum that controls connection acceptance. Return BeforeConnectOutcome::Reject or AfterHandshakeOutcome::Reject to terminate the connection immediately. The endpoint respects these outcomes synchronously, preventing the connection from proceeding further into the QUIC stack.

Where can I find the official example demonstrating this pattern?

The repository includes iroh/examples/monitor-connections.rs, which demonstrates a complete implementation of the EndpointHooks trait that logs connection events, tracks path RTT minima, and reports final UDP byte counts upon connection closure. This example serves as the reference implementation for production monitoring systems built on iroh.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →