Repository: fujiapple852/trippy (language: rust)

Issue:
Trippy Public API



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 682056a0f..3abfb4083 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -200,6 +200,8 @@ jobs:
         run: cargo msrv verify --output-format json --manifest-path crates/trippy/Cargo.toml -- cargo check
       - name: check msrv for trippy-core
         run: cargo msrv verify --output-format json --manifest-path crates/trippy-core/Cargo.toml -- cargo check
+      - name: check msrv for trippy-packet
+        run: cargo msrv verify --output-format json --manifest-path crates/trippy-packet/Cargo.toml -- cargo check
       - name: check msrv for trippy-dns
         run: cargo msrv verify --output-format json --manifest-path crates/trippy-dns/Cargo.toml -- cargo check
       - name: check msrv for trippy-privilege
diff --git a/Cargo.lock b/Cargo.lock
index f7d0fcf22..7eb6a779b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -746,6 +746,14 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
+[[package]]
+name = "hello-world"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "trippy",
+]
+
 [[package]]
 name = "hermit-abi"
 version = "0.3.9"
@@ -2071,6 +2079,16 @@ dependencies = [
  "winnow",
 ]
 
+[[package]]
+name = "traceroute"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "itertools 0.13.0",
+ "trippy",
+]
+
 [[package]]
 name = "tracing"
 version = "0.1.40"
@@ -2172,11 +2190,9 @@ dependencies = [
  "encoding_rs_io",
  "etcetera",
  "humantime",
- "indexmap 2.2.6",
  "insta",
  "itertools 0.13.0",
  "maxminddb",
- "parking_lot",
  "petgraph",
  "pretty_assertions",
  "ratatui",
@@ -2205,10 +2221,12 @@ dependencies = [
  "bitflags",
  "derive_more",
  "hex-literal",
+ "indexmap 2.2.6",
  "ipnetwork",
  "itertools 0.13.0",
  "mockall",
  "nix",
+ "parking_lot",
  "paste",
  "rand",
  "serde",
@@ -2221,6 +2239,7 @@ dependencies = [
  "tracing",
  "tracing-subscriber",
  "trippy-packet",
+ "trippy-privilege",
  "tun2",
  "widestring",
  "windows-sys 0.52.0",
diff --git a/Cargo.toml b/Cargo.toml
index ffbf44d3b..cdd78454d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [workspace]
 resolver = "2"
-members = ["crates/trippy", "crates/trippy-core", "crates/trippy-packet", "crates/trippy-privilege", "crates/trippy-dns"]
+members = ["crates/trippy", "crates/trippy-core", "crates/trippy-packet", "crates/trippy-privilege", "crates/trippy-dns", "examples/*"]
 
 [workspace.package]
 version = "0.11.0-dev"
@@ -14,6 +14,10 @@ edition = "2021"
 rust-version = "1.75"
 
 [workspace.dependencies]
+trippy-core = { version = "0.11.0-dev", path = "crates/trippy-core" }
+trippy-privilege = { version = "0.11.0-dev", path = "crates/trippy-privilege" }
+trippy-dns = { version = "0.11.0-dev", path = "crates/trippy-dns" }
+trippy-packet = { version = "0.11.0-dev", path = "crates/trippy-packet" }
 thiserror = "1.0.60"
 anyhow = "1.0.83"
 itertools = "0.13.0"
diff --git a/crates/trippy-core/Cargo.toml b/crates/trippy-core/Cargo.toml
index 1d920a52a..0d9e7ac75 100644
--- a/crates/trippy-core/Cargo.toml
+++ b/crates/trippy-core/Cargo.toml
@@ -14,11 +14,14 @@ keywords = ["traceroute", "ping", "icmp"]
 categories = ["network-programming"]
 
 [dependencies]
-trippy-packet = { version = "0.11.0-dev", path = "../trippy-packet" }
+trippy-packet.workspace = true
+trippy-privilege.workspace = true
 derive_more.workspace = true
 thiserror.workspace = true
 tracing.workspace = true
 itertools.workspace = true
+parking_lot.workspace = true
+indexmap = { version = "2.2.6", default-features = false, features = [ "std" ] }
 arrayvec = { version = "0.7.4", default-features = false }
 socket2 = { version = "0.5.7", features = [ "all" ] }
 bitflags = "2.5.0"
diff --git a/crates/trippy-core/src/builder.rs b/crates/trippy-core/src/builder.rs
index 1e3a4e88a..0c0539f78 100644
--- a/crates/trippy-core/src/builder.rs
+++ b/crates/trippy-core/src/builder.rs
@@ -1,79 +1,134 @@
+use crate::config::{ChannelConfig, StateConfig, StrategyConfig};
+use crate::constants::MAX_INITIAL_SEQUENCE;
 use crate::error::TraceResult;
-use crate::net::PlatformImpl;
 use crate::{
-    ChannelConfig, Config, IcmpExtensionParseMode, MaxInflight, MaxRounds, MultipathStrategy,
-    PacketSize, PayloadPattern, PortDirection, PrivilegeMode, Protocol, Sequence, SocketImpl,
-    SourceAddr, TimeToLive, TraceId, Tracer, TracerChannel, TracerRound, TypeOfService,
+    IcmpExtensionParseMode, MaxInflight, MaxRounds, MultipathStrategy, PacketSize, PayloadPattern,
+    PortDirection, PrivilegeMode, Protocol, Sequence, TimeToLive, TraceId, Tracer, TracerError,
+    TypeOfService, MAX_TTL,
 };
 use std::net::IpAddr;
+use std::num::NonZeroUsize;
 use std::time::Duration;
 
-/// Build and run a tracer.
+/// Build a tracer.
 ///
 /// This is a convenience builder to simplify the creation of execution of a
 /// tracer.
 ///
-/// The builder exposes all configuration items from`trippy::tracing::Config`
-/// and `trippy::tracing::TracerChannel`.
-///
-/// # Examples:
+/// # Examples
 ///
 /// ```no_run
+/// # fn main() -> anyhow::Result<()> {
 /// use trippy_core::{Builder, MultipathStrategy, Port, PortDirection, PrivilegeMode, Protocol};
 ///
 /// let addr = std::net::IpAddr::from([1, 2, 3, 4]);
-/// Builder::new(addr, |round| println!("{:?}", round))
+/// let tracer = Builder::new(addr)
 ///     .privilege_mode(PrivilegeMode::Unprivileged)
 ///     .protocol(Protocol::Udp)
 ///     .multipath_strategy(MultipathStrategy::Dublin)
 ///     .port_direction(PortDirection::FixedBoth(Port(33000), Port(3500)))
-///     .start()
-///     .unwrap();
+///     .build()?;
+/// # Ok(())
+/// # }
 /// ```
-pub struct Builder<F> {
-    target_addr: IpAddr,
-    on_round_handler: F,
-    channel_config: ChannelConfig,
-    tracer_config: Config,
-    trace_identifier: Option<TraceId>,
+///
+/// # See Also
+///
+/// - [`Tracer`] - A traceroute implementation.
+#[derive(Debug)]
+pub struct Builder {
     interface: Option<String>,
+    source_addr: Option<IpAddr>,
+    target_addr: IpAddr,
+    privilege_mode: PrivilegeMode,
+    protocol: Protocol,
+    packet_size: PacketSize,
+    payload_pattern: PayloadPattern,
+    tos: TypeOfService,
+    icmp_extension_parse_mode: IcmpExtensionParseMode,
+    read_timeout: Duration,
+    tcp_connect_timeout: Duration,
+    trace_identifier: TraceId,
+    max_rounds: Option<MaxRounds>,
+    first_ttl: TimeToLive,
+    max_ttl: TimeToLive,
+    grace_duration: Duration,
+    max_inflight: MaxInflight,
+    initial_sequence: Sequence,
+    multipath_strategy: MultipathStrategy,
+    port_direction: PortDirection,
+    min_round_duration: Duration,
+    max_round_duration: Duration,
+    max_samples: usize,
+    max_flows: usize,
+    drop_privileges: bool,
+}
+
+impl Default for Builder {
+    fn default() -> Self {
+        Self {
+            interface: None,
+            source_addr: None,
+            target_addr: ChannelConfig::default().target_addr,
+            privilege_mode: ChannelConfig::default().privilege_mode,
+            protocol: ChannelConfig::default().protocol,
+            packet_size: ChannelConfig::default().packet_size,
+            payload_pattern: ChannelConfig::default().payload_pattern,
+            tos: ChannelConfig::default().tos,
+            icmp_extension_parse_mode: ChannelConfig::default().icmp_extension_parse_mode,
+            read_timeout: ChannelConfig::default().read_timeout,
+            tcp_connect_timeout: ChannelConfig::default().tcp_connect_timeout,
+            trace_identifier: StrategyConfig::default().trace_identifier,
+            max_rounds: StrategyConfig::default().max_rounds,
+            first_ttl: StrategyConfig::default().first_ttl,
+            max_ttl: StrategyConfig::default().max_ttl,
+            grace_duration: StrategyConfig::default().grace_duration,
+            max_inflight: StrategyConfig::default().max_inflight,
+            initial_sequence: StrategyConfig::default().initial_sequence,
+            multipath_strategy: StrategyConfig::default().multipath_strategy,
+            port_direction: StrategyConfig::default().port_direction,
+            min_round_duration: StrategyConfig::default().min_round_duration,
+            max_round_duration: StrategyConfig::default().max_round_duration,
+            max_samples: StateConfig::default().max_samples,
+            max_flows: StateConfig::default().max_flows,
+            drop_privileges: false,
+        }
+    }
 }
 
-impl<F: Fn(&TracerRound<'_>)> Builder<F> {
+impl Builder {
     /// Build a tracer builder for a given target.
-    pub fn new(target_addr: IpAddr, on_round_handler: F) -> Self {
+    #[must_use]
+    pub fn new(target_addr: IpAddr) -> Self {
         Self {
             target_addr,
-            on_round_handler,
-            channel_config: ChannelConfig::default(),
-            tracer_config: Config::default(),
-            trace_identifier: None,
-            interface: None,
+            ..Default::default()
         }
     }
 
-    /// Set the trace identifier.
+    /// Set the source address.
     ///
-    /// If not set then 0 will be used as the trace identifier.
+    /// If not set then the source address will be discovered based on the
+    /// target address and the interface.
     #[must_use]
-    pub fn trace_identifier(self, trace_id: TraceId) -> Self {
+    pub fn source_addr(self, source_addr: Option<IpAddr>) -> Self {
         Self {
-            trace_identifier: Some(trace_id),
+            source_addr,
             ..self
         }
     }
 
     /// Set the source interface.
     ///
-    /// If the source interface is provided it will be used to lookup the IPv4
+    /// If the source interface is provided it will be used to look up the IPv4
     /// or IPv6 source address.
     ///
     /// If not provided the source address will be determined by OS based on
     /// the target IPv4 or IPv6 address.
     #[must_use]
-    pub fn interface(self, interface: &str) -> Self {
+    pub fn interface<S: Into<String>>(self, interface: Option<S>) -> Self {
         Self {
-            interface: Some(String::from(interface)),
+            interface: interface.map(Into::into),
             ..self
         }
     }
@@ -81,15 +136,16 @@ impl<F: Fn(&TracerRound<'_>)> Builder<F> {
     /// Set the protocol.
     #[must_use]
     pub fn protocol(self, protocol: Protocol) -> Self {
+        Self { protocol, ..self }
+    }
+
+    /// Set the trace identifier.
+    ///
+    /// If not set then 0 will be used as the trace identifier.
+    #[must_use]
+    pub fn trace_identifier(self, trace_id: u16) -> Self {
         Self {
-            channel_config: ChannelConfig {
-                protocol,
-                ..self.channel_config
-            },
-            tracer_config: Config {
-                protocol,
-                ..self.tracer_config
-            },
+            trace_identifier: TraceId(trace_id),
             ..self
         }
     }
@@ -98,10 +154,7 @@ impl<F: Fn(&TracerRound<'_>)> Builder<F> {
     #[must_use]
     pub fn privilege_mode(self, privilege_mode: PrivilegeMode) -> Self {
         Self {
-            channel_config: ChannelConfig {
-                privilege_mode,
-                ..self.channel_config
-            },
+            privilege_mode,
             ..self
         }
     }
@@ -110,58 +163,46 @@ impl<F: Fn(&TracerRound<'_>)> Builder<F> {
     #[must_use]
     pub fn multipath_strategy(self, multipath_strategy: MultipathStrategy) -> Self {
         Self {
-            tracer_config: Config {
-                multipath_strategy,
-                ..self.tracer_config
-            },
+            multipath_strategy,
             ..self
         }
     }
 
     /// Set the packet size.
     #[must_use]
-    pub fn packet_size(self, packet_size: PacketSize) -> Self {
+    pub fn packet_size(self, packet_size: u16) -> Self {
         Self {
-            channel_config: ChannelConfig {
-                packet_size,
-                ..self.channel_config
-            },
+            packet_size: PacketSize(packet_size),
             ..self
         }
     }
 
     /// Set the payload pattern.
     #[must_use]
-    pub fn payload_pattern(self, payload_pattern: PayloadPattern) -> Self {
+    pub fn payload_pattern(self, payload_pattern: u8) -> Self {
         Self {
-            channel_config: ChannelConfig {
-                payload_pattern,
-                ..self.channel_config
-            },
+            payload_pattern: PayloadPattern(payload_pattern),
             ..self
         }
     }
 
     /// Set the type of service.
     #[must_use]
-    pub fn tos(self, tos: TypeOfService) -> Self {
+    pub fn tos(self, tos: u8) -> Self {
         Self {
-            channel_config: ChannelConfig {
-                tos,
-                ..self.channel_config
-            },
+            tos: TypeOfService(tos),
             ..self
         }
     }
 
     /// Set the ICMP extensions mode.
     #[must_use]
-    pub fn icmp_extension_mode(self, icmp_extension_mode: IcmpExtensionParseMode) -> Self {
+    pub fn icmp_extension_parse_mode(
+        self,
+        icmp_extension_parse_mode: IcmpExtensionParseMode,
+    ) -> Self {
         Self {
-            channel_config: ChannelConfig {
-                icmp_extension_mode,
-                ..self.channel_config
-            },
+            icmp_extension_parse_mode,
             ..self
         }
     }
@@ -170,10 +211,7 @@ impl<F: Fn(&TracerRound<'_>)> Builder<F> {
     #[must_use]
     pub fn read_timeout(self, read_timeout: Duration) -> Self {
         Self {
-            channel_config: ChannelConfig {
-                read_timeout,
-                ..self.channel_config
-            },
+            read_timeout,
             ..self
         }
     }
@@ -182,46 +220,35 @@ impl<F: Fn(&TracerRound<'_>)> Builder<F> {
     #[must_use]
     pub fn tcp_connect_timeout(self, tcp_connect_timeout: Duration) -> Self {
         Self {
-            channel_config: ChannelConfig {
-                tcp_connect_timeout,
-                ..self.channel_config
-            },
+            tcp_connect_timeout,
             ..self
         }
     }
 
     /// Set the maximum number of rounds.
     #[must_use]
-    pub fn max_rounds(self, max_rounds: MaxRounds) -> Self {
+    pub fn max_rounds(self, max_rounds: Option<usize>) -> Self {
         Self {
-            tracer_config: Config {
-                max_rounds: Some(max_rounds),
-                ..self.tracer_config
-            },
+            max_rounds: max_rounds
+                .and_then(|max_rounds| NonZeroUsize::new(max_rounds).map(MaxRounds)),
             ..self
         }
     }
 
     /// Set the first ttl.
     #[must_use]
-    pub fn first_ttl(self, first_ttl: TimeToLive) -> Self {
+    pub fn first_ttl(self, first_ttl: u8) -> Self {
         Self {
-            tracer_config: Config {
-                first_ttl,
-                ..self.tracer_config
-            },
+            first_ttl: TimeToLive(first_ttl),
             ..self
         }
     }
 
     /// Set the maximum ttl.
     #[must_use]
-    pub fn max_ttl(self, max_ttl: TimeToLive) -> Self {
+    pub fn max_ttl(self, max_ttl: u8) -> Self {
         Self {
-            tracer_config: Config {
-                max_ttl,
-                ..self.tracer_config
-            },
+            max_ttl: TimeToLive(max_ttl),
             ..self
         }
     }
@@ -230,34 +257,25 @@ impl<F: Fn(&TracerRound<'_>)> Builder<F> {
     #[must_use]
     pub fn grace_duration(self, grace_duration: Duration) -> Self {
         Self {
-            tracer_config: Config {
-                grace_duration,
-                ..self.tracer_config
-            },
+            grace_duration,
             ..self
         }
     }
 
     /// Set the max inflight.
     #[must_use]
-    pub fn max_inflight(self, max_inflight: MaxInflight) -> Self {
+    pub fn max_inflight(self, max_inflight: u8) -> Self {
         Self {
-            tracer_config: Config {
-                max_inflight,
-                ..self.tracer_config
-            },
+            max_inflight: MaxInflight(max_inflight),
             ..self
         }
     }
 
     /// Set the initial sequence.
     #[must_use]
-    pub fn initial_sequence(self, initial_sequence: Sequence) -> Self {
+    pub fn initial_sequence(self, initial_sequence: u16) -> Self {
         Self {
-            tracer_config: Config {
-                initial_sequence,
-                ..self.tracer_config
-            },
+            initial_sequence: Sequence(initial_sequence),
             ..self
         }
     }
@@ -266,10 +284,7 @@ impl<F: Fn(&TracerRound<'_>)> Builder<F> {
     #[must_use]
     pub fn port_direction(self, port_direction: PortDirection) -> Self {
         Self {
-            tracer_config: Config {
-                port_direction,
-                ..self.tracer_config
-            },
+            port_direction,
             ..self
         }
     }
@@ -278,10 +293,7 @@ impl<F: Fn(&TracerRound<'_>)> Builder<F> {
     #[must_use]
     pub fn min_round_duration(self, min_round_duration: Duration) -> Self {
         Self {
-            tracer_config: Config {
-                min_round_duration,
-                ..self.tracer_config
-            },
+            min_round_duration,
             ..self
         }
     }
@@ -290,35 +302,248 @@ impl<F: Fn(&TracerRound<'_>)> Builder<F> {
     #[must_use]
     pub fn max_round_duration(self, max_round_duration: Duration) -> Self {
         Self {
-            tracer_config: Config {
-                max_round_duration,
-                ..self.tracer_config
-            },
+            max_round_duration,
+            ..self
+        }
+    }
+
+    /// Set the maximum number of samples to record.
+    #[must_use]
+    pub fn max_samples(self, max_samples: usize) -> Self {
+        Self {
+            max_samples,
+            ..self
+        }
+    }
+
+    /// Set the maximum number of flows to record.
+    #[must_use]
+    pub fn max_flows(self, max_flows: usize) -> Self {
+        Self { max_flows, ..self }
+    }
+
+    /// Drop privileges after connection is established.
+    #[must_use]
+    pub fn drop_privileges(self, drop_privileges: bool) -> Self {
+        Self {
+            drop_privileges,
             ..self
         }
     }
 
-    /// Start the tracer.
-    pub fn start(self) -> TraceResult<()> {
-        let trace_identifier = self.trace_identifier.unwrap_or_default();
-        let source_addr = SourceAddr::discover::<SocketImpl, PlatformImpl>(
+    /// Build the `Tracer`.
+    pub fn build(self) -> TraceResult<Tracer> {
+        match (self.protocol, self.port_direction) {
+            (Protocol::Udp, PortDirection::None) => {
+                return Err(TracerError::BadConfig(
+                    "port_direction may not be None for udp protocol".to_string(),
+                ));
+            }
+            (Protocol::Tcp, PortDirection::None) => {
+                return Err(TracerError::BadConfig(
+                    "port_direction may not be None for tcp protocol".to_string(),
+                ));
+            }
+            _ => (),
+        }
+        if self.first_ttl.0 > MAX_TTL {
+            return Err(TracerError::BadConfig(format!(
+                "first_ttl {} > {MAX_TTL}",
+                self.first_ttl.0
+            )));
+        }
+        if self.max_ttl.0 > MAX_TTL {
+            return Err(TracerError::BadConfig(format!(
+                "max_ttl {} > {MAX_TTL}",
+                self.max_ttl.0
+            )));
+        }
+        if self.initial_sequence.0 > MAX_INITIAL_SEQUENCE {
+            return Err(TracerError::BadConfig(format!(
+                "initial_sequence {} > {MAX_INITIAL_SEQUENCE}",
+                self.initial_sequence.0
+            )));
+        }
+        Ok(Tracer::new(
+            self.interface,
+            self.source_addr,
             self.target_addr,
-            self.tracer_config.port_direction,
-            self.interface.as_deref(),
-        )?;
-        let channel_config = ChannelConfig {
-            source_addr,
-            target_addr: self.target_addr,
-            ..self.channel_config
-        };
-        let channel = TracerChannel::<SocketImpl>::connect(&channel_config)?;
-        let tracer_config = Config {
-            trace_identifier,
-            target_addr: self.target_addr,
-            ..self.tracer_config
-        };
-        let tracer = Tracer::new(&tracer_config, self.on_round_handler);
-        tracer.trace(channel)?;
-        Ok(())
+            self.privilege_mode,
+            self.protocol,
+            self.packet_size,
+            self.payload_pattern,
+            self.tos,
+            self.icmp_extension_parse_mode,
+            self.read_timeout,
+            self.tcp_connect_timeout,
+            self.trace_identifier,
+            self.max_rounds,
+            self.first_ttl,
+            self.max_ttl,
+            self.grace_duration,
+            self.max_inflight,
+            self.initial_sequence,
+            self.multipath_strategy,
+            self.port_direction,
+            self.min_round_duration,
+            self.max_round_duration,
+            self.max_samples,
+            self.max_flows,
+            self.drop_privileges,
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{config, Port};
+    use config::defaults;
+    use std::net::Ipv4Addr;
+    use std::num::NonZeroUsize;
+
+    const SOURCE_ADDR: IpAddr = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));
+    const TARGET_ADDR: IpAddr = IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2));
+
+    #[test]
+    fn test_builder_minimal() {
+        let tracer = Builder::new(TARGET_ADDR).build().unwrap();
+        assert_eq!(TARGET_ADDR, tracer.target_addr());
+        assert_eq!(None, tracer.source_addr());
+        assert_eq!(None, tracer.interface());
+        assert_eq!(defaults::DEFAULT_MAX_SAMPLES, tracer.max_samples());
+        assert_eq!(defaults::DEFAULT_MAX_FLOWS, tracer.max_flows());
+        assert_eq!(defaults::DEFAULT_STRATEGY_PROTOCOL, tracer.protocol());
+        assert_eq!(TraceId::default(), tracer.trace_identifier());
+        assert_eq!(defaults::DEFAULT_PRIVILEGE_MODE, tracer.privilege_mode());
+        assert_eq!(
+            defaults::DEFAULT_STRATEGY_MULTIPATH,
+            tracer.multipath_strategy()
+        );
+        assert_eq!(
+            defaults::DEFAULT_STRATEGY_PACKET_SIZE,
+            tracer.packet_size().0
+        );
+        assert_eq!(
+            defaults::DEFAULT_STRATEGY_PAYLOAD_PATTERN,
+            tracer.payload_pattern().0
+        );
+        assert_eq!(defaults::DEFAULT_STRATEGY_TOS, tracer.tos().0);
+        assert_eq!(
+            defaults::DEFAULT_ICMP_EXTENSION_PARSE_MODE,
+            tracer.icmp_extension_parse_mode()
+        );
+        assert_eq!(
+            defaults::DEFAULT_STRATEGY_READ_TIMEOUT,
+            tracer.read_timeout()
+        );
+        assert_eq!(
+            defaults::DEFAULT_STRATEGY_TCP_CONNECT_TIMEOUT,
+            tracer.tcp_connect_timeout()
+        );
+        assert_eq!(None, tracer.max_rounds());
+        assert_eq!(defaults::DEFAULT_STRATEGY_FIRST_TTL, tracer.first_ttl().0);
+        assert_eq!(defaults::DEFAULT_STRATEGY_MAX_TTL, tracer.max_ttl().0);
+        assert_eq!(
+            defaults::DEFAULT_STRATEGY_GRACE_DURATION,
+            tracer.grace_duration()
+        );
+        assert_eq!(
+            defaults::DEFAULT_STRATEGY_MAX_INFLIGHT,
+            tracer.max_inflight().0
+        );
+        assert_eq!(
+            defaults::DEFAULT_STRATEGY_INITIAL_SEQUENCE,
+            tracer.initial_sequence().0
+        );
+        assert_eq!(PortDirection::None, tracer.port_direction());
+        assert_eq!(
+            defaults::DEFAULT_STRATEGY_MIN_ROUND_DURATION,
+            tracer.min_round_duration()
+        );
+        assert_eq!(
+            defaults::DEFAULT_STRATEGY_MAX_ROUND_DURATION,
+            tracer.max_round_duration()
+        );
+    }
+
+    #[test]
+    fn test_builder_full() {
+        let tracer = Builder::new(TARGET_ADDR)
+            .source_addr(Some(SOURCE_ADDR))
+            .interface(Some("eth0"))
+            .max_samples(10)
+            .max_flows(20)
+            .protocol(Protocol::Udp)
+            .trace_identifier(101)
+            .privilege_mode(PrivilegeMode::Unprivileged)
+            .multipath_strategy(MultipathStrategy::Paris)
+            .packet_size(128)
+            .payload_pattern(0xff)
+            .tos(0x1a)
+            .icmp_extension_parse_mode(IcmpExtensionParseMode::Enabled)
+            .read_timeout(Duration::from_millis(50))
+            .tcp_connect_timeout(Duration::from_millis(100))
+            .max_rounds(Some(10))
+            .first_ttl(2)
+            .max_ttl(16)
+            .grace_duration(Duration::from_millis(100))
+            .max_inflight(22)
+            .initial_sequence(35000)
+            .port_direction(PortDirection::FixedSrc(Port(8080)))
+            .min_round_duration(Duration::from_millis(500))
+            .max_round_duration(Duration::from_millis(1500))
+            .build()
+            .unwrap();
+
+        assert_eq!(TARGET_ADDR, tracer.target_addr());
+        // note that source_addr is not set until the tracer is run
+        assert_eq!(None, tracer.source_addr());
+        assert_eq!(Some("eth0"), tracer.interface());
+        assert_eq!(10, tracer.max_samples());
+        assert_eq!(20, tracer.max_flows());
+        assert_eq!(Protocol::Udp, tracer.protocol());
+        assert_eq!(TraceId(101), tracer.trace_identifier());
+        assert_eq!(PrivilegeMode::Unprivileged, tracer.privilege_mode());
+        assert_eq!(MultipathStrategy::Paris, tracer.multipath_strategy());
+        assert_eq!(PacketSize(128), tracer.packet_size());
+        assert_eq!(PayloadPattern(0xff), tracer.payload_pattern());
+        assert_eq!(TypeOfService(0x1a), tracer.tos());
+        assert_eq!(
+            IcmpExtensionParseMode::Enabled,
+            tracer.icmp_extension_parse_mode()
+        );
+        assert_eq!(Duration::from_millis(50), tracer.read_timeout());
+        assert_eq!(Duration::from_millis(100), tracer.tcp_connect_timeout());
+        assert_eq!(
+            Some(MaxRounds(NonZeroUsize::new(10).unwrap())),
+            tracer.max_rounds()
+        );
+        assert_eq!(TimeToLive(2), tracer.first_ttl());
+        assert_eq!(TimeToLive(16), tracer.max_ttl());
+        assert_eq!(Duration::from_millis(100), tracer.grace_duration());
+        assert_eq!(MaxInflight(22), tracer.max_inflight());
+        assert_eq!(Sequence(35000), tracer.initial_sequence());
+        assert_eq!(PortDirection::FixedSrc(Port(8080)), tracer.port_direction());
+        assert_eq!(Duration::from_millis(500), tracer.min_round_duration());
+        assert_eq!(Duration::from_millis(1500), tracer.max_round_duration());
+    }
+
+    #[test]
+    fn test_zero_max_rounds() {
+        let tracer = Builder::new(IpAddr::from([1, 2, 3, 4]))
+            .max_rounds(Some(0))
+            .build()
+            .unwrap();
+        assert_eq!(None, tracer.max_rounds());
+    }
+
+    #[test]
+    fn test_invalid_initial_sequence() {
+        let err = Builder::new(IpAddr::from([1, 2, 3, 4]))
+            .initial_sequence(u16::MAX)
+            .build()
+            .unwrap_err();
+        assert!(matches!(err, TracerError::BadConfig(s) if s == "initial_sequence 65535 > 64511"));
     }
 }
diff --git a/crates/trippy-core/src/config.rs b/crates/trippy-core/src/config.rs
index 031315c00..f5a97b7ae 100644
--- a/crates/trippy-core/src/config.rs
+++ b/crates/trippy-core/src/config.rs
@@ -1,12 +1,10 @@
-use crate::constants::{MAX_INITIAL_SEQUENCE, MAX_TTL};
-use crate::error::{TraceResult, TracerError};
-use crate::types::{
-    MaxInflight, MaxRounds, PacketSize, PayloadPattern, Port, Sequence, TimeToLive, TraceId,
+use crate::types::Port;
+use crate::{
+    MaxInflight, MaxRounds, PacketSize, PayloadPattern, Sequence, TimeToLive, TraceId,
     TypeOfService,
 };
 use std::fmt::{Display, Formatter};
 use std::net::{IpAddr, Ipv4Addr};
-use std::num::NonZeroUsize;
 use std::time::Duration;
 
 /// Default values for configuration.
@@ -158,15 +156,15 @@ pub enum MultipathStrategy {
     Classic,
     /// The UDP `checksum` field is used to store the sequence number.
     ///
-    /// a.k.a [`paris`](https://github.com/libparistraceroute/libparistraceroute/wiki/Checksum) traceroute approach.
+    /// a.k.a. [`paris`](https://github.com/libparistraceroute/libparistraceroute/wiki/Checksum) traceroute approach.
     ///
-    /// This requires that the UDP payload contains a well chosen value to ensure the UDP checksum
+    /// This requires that the UDP payload contains a well-chosen value to ensure the UDP checksum
     /// remains valid for the packet and therefore this cannot be used along with a custom
     /// payload pattern.
     Paris,
     /// The IP `identifier` field is used to store the sequence number.
     ///
-    /// a.k.a [`dublin`](https://github.com/insomniacslk/dublin-traceroute) traceroute approach.
+    /// a.k.a. [`dublin`](https://github.com/insomniacslk/dublin-traceroute) traceroute approach.
     ///
     /// The allow either the src or dest or both ports to be fixed.
     ///
@@ -208,7 +206,7 @@ pub enum PortDirection {
     /// Trace from a fixed source port to a fixed destination port (i.e. 5000 -> 80).
     ///
     /// When both ports are fixed another element of the IP header is required to vary per probe
-    /// such that probes can be identified.  Typically this is only used for UDP, whereby the
+    /// such that probes can be identified.  Typically, this is only used for UDP, whereby the
     /// checksum is manipulated by adjusting the payload and therefore used as the identifier.
     ///
     /// Note that this case is not currently implemented.
@@ -247,119 +245,32 @@ impl PortDirection {
     }
 }
 
-/// Build a `ChannelConfig`.
-#[derive(Debug)]
-pub struct ChannelConfigBuilder {
-    config: ChannelConfig,
+/// Tracer state configuration.
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub struct StateConfig {
+    /// The maximum number of samples to record per hop.
+    ///
+    /// Once the maximum number of samples has been reached the oldest sample
+    /// is discarded (FIFO).
+    pub max_samples: usize,
+    /// The maximum number of flows to record.
+    ///
+    /// Once the maximum number of flows has been reached no new flows will be
+    /// created, existing flows are updated and are never removed.
+    pub max_flows: usize,
 }
 
-impl ChannelConfigBuilder {
-    /// Create a new `ChannelConfigBuilder` between a source and target.
-    #[must_use]
-    pub fn new(source_addr: IpAddr, target_addr: IpAddr) -> Self {
-        Self {
-            config: ChannelConfig {
-                source_addr,
-                target_addr,
-                ..ChannelConfig::default()
-            },
-        }
-    }
-
-    /// Set the channel protocol.
-    #[must_use]
-    pub fn protocol(self, protocol: Protocol) -> Self {
-        Self {
-            config: ChannelConfig {
-                protocol,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the channel privilege mode.
-    #[must_use]
-    pub fn privilege_mode(self, privilege_mode: PrivilegeMode) -> Self {
-        Self {
-            config: ChannelConfig {
-                privilege_mode,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the channel packet size.
-    #[must_use]
-    pub fn packet_size(self, packet_size: PacketSize) -> Self {
-        Self {
-            config: ChannelConfig {
-                packet_size,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the channel payload pattern.
-    #[must_use]
-    pub fn payload_pattern(self, payload_pattern: PayloadPattern) -> Self {
-        Self {
-            config: ChannelConfig {
-                payload_pattern,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the channel type of service.
-    #[must_use]
-    pub fn tos(self, tos: TypeOfService) -> Self {
-        Self {
-            config: ChannelConfig { tos, ..self.config },
-        }
-    }
-
-    /// Set the channel ICMP extensions mode.
-    #[must_use]
-    pub fn icmp_extension_mode(self, icmp_extension_mode: IcmpExtensionParseMode) -> Self {
-        Self {
-            config: ChannelConfig {
-                icmp_extension_mode,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the channel read timeout.
-    #[must_use]
-    pub fn read_timeout(self, read_timeout: Duration) -> Self {
-        Self {
-            config: ChannelConfig {
-                read_timeout,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the channel TCP connect timeout.
-    #[must_use]
-    pub fn tcp_connect_timeout(self, tcp_connect_timeout: Duration) -> Self {
+impl Default for StateConfig {
+    fn default() -> Self {
         Self {
-            config: ChannelConfig {
-                tcp_connect_timeout,
-                ..self.config
-            },
+            max_samples: defaults::DEFAULT_MAX_SAMPLES,
+            max_flows: defaults::DEFAULT_MAX_FLOWS,
         }
     }
-
-    /// Build the `ChannelConfig` from this `ChannelConfigBuilder`.
-    #[must_use]
-    pub fn build(self) -> ChannelConfig {
-        self.config
-    }
 }
 
 /// Tracer network channel configuration.
-#[derive(Debug, Clone, Eq, PartialEq)]
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
 pub struct ChannelConfig {
     pub privilege_mode: PrivilegeMode,
     pub protocol: Protocol,
@@ -369,43 +280,11 @@ pub struct ChannelConfig {
     pub payload_pattern: PayloadPattern,
     pub initial_sequence: Sequence,
     pub tos: TypeOfService,
-    pub icmp_extension_mode: IcmpExtensionParseMode,
+    pub icmp_extension_parse_mode: IcmpExtensionParseMode,
     pub read_timeout: Duration,
     pub tcp_connect_timeout: Duration,
 }
 
-impl ChannelConfig {
-    #[allow(clippy::too_many_arguments)]
-    #[must_use]
-    pub fn new(
-        privilege_mode: PrivilegeMode,
-        protocol: Protocol,
-        source_addr: IpAddr,
-        target_addr: IpAddr,
-        packet_size: u16,
-        payload_pattern: u8,
-        initial_sequence: u16,
-        tos: u8,
-        icmp_extension_mode: IcmpExtensionParseMode,
-        read_timeout: Duration,
-        tcp_connect_timeout: Duration,
-    ) -> Self {
-        Self {
-            privilege_mode,
-            protocol,
-            source_addr,
-            target_addr,
-            packet_size: PacketSize(packet_size),
-            payload_pattern: PayloadPattern(payload_pattern),
-            initial_sequence: Sequence(initial_sequence),
-            tos: TypeOfService(tos),
-            icmp_extension_mode,
-            read_timeout,
-            tcp_connect_timeout,
-        }
-    }
-}
-
 impl Default for ChannelConfig {
     fn default() -> Self {
         Self {
@@ -417,163 +296,16 @@ impl Default for ChannelConfig {
             payload_pattern: PayloadPattern(defaults::DEFAULT_STRATEGY_PAYLOAD_PATTERN),
             initial_sequence: Sequence(defaults::DEFAULT_STRATEGY_INITIAL_SEQUENCE),
             tos: TypeOfService(defaults::DEFAULT_STRATEGY_TOS),
-            icmp_extension_mode: defaults::DEFAULT_ICMP_EXTENSION_PARSE_MODE,
+            icmp_extension_parse_mode: defaults::DEFAULT_ICMP_EXTENSION_PARSE_MODE,
             read_timeout: defaults::DEFAULT_STRATEGY_READ_TIMEOUT,
             tcp_connect_timeout: defaults::DEFAULT_STRATEGY_TCP_CONNECT_TIMEOUT,
         }
     }
 }
 
-/// Build a `Config`.
-#[derive(Debug)]
-pub struct ConfigBuilder {
-    config: Config,
-}
-
-impl ConfigBuilder {
-    /// Create a new `ConfigBuilder`.
-    #[must_use]
-    pub fn new(trace_identifier: TraceId, target_addr: IpAddr) -> Self {
-        Self {
-            config: Config {
-                target_addr,
-                trace_identifier,
-                ..Config::default()
-            },
-        }
-    }
-
-    /// Set the tracer protocol.
-    #[must_use]
-    pub fn protocol(self, protocol: Protocol) -> Self {
-        Self {
-            config: Config {
-                protocol,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the tracer maximum rounds.
-    #[must_use]
-    pub fn max_rounds(self, max_rounds: MaxRounds) -> Self {
-        Self {
-            config: Config {
-                max_rounds: Some(max_rounds),
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the tracer first ttl.
-    #[must_use]
-    pub fn first_ttl(self, first_ttl: TimeToLive) -> Self {
-        Self {
-            config: Config {
-                first_ttl,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the tracer max ttl.
-    #[must_use]
-    pub fn max_ttl(self, max_ttl: TimeToLive) -> Self {
-        Self {
-            config: Config {
-                max_ttl,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the tracer grace duration.
-    #[must_use]
-    pub fn grace_duration(self, grace_duration: Duration) -> Self {
-        Self {
-            config: Config {
-                grace_duration,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the tracer max inflight.
-    #[must_use]
-    pub fn max_inflight(self, max_inflight: MaxInflight) -> Self {
-        Self {
-            config: Config {
-                max_inflight,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the tracer initial sequence.
-    #[must_use]
-    pub fn initial_sequence(self, initial_sequence: Sequence) -> Self {
-        Self {
-            config: Config {
-                initial_sequence,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the tracer multipath strategy.
-    #[must_use]
-    pub fn multipath_strategy(self, multipath_strategy: MultipathStrategy) -> Self {
-        Self {
-            config: Config {
-                multipath_strategy,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the tracer port direction.
-    #[must_use]
-    pub fn port_direction(self, port_direction: PortDirection) -> Self {
-        Self {
-            config: Config {
-                port_direction,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the tracer minimum round duration.
-    #[must_use]
-    pub fn min_round_duration(self, min_round_duration: Duration) -> Self {
-        Self {
-            config: Config {
-                min_round_duration,
-                ..self.config
-            },
-        }
-    }
-
-    /// Set the tracer maximum round duration.
-    #[must_use]
-    pub fn max_round_duration(self, max_round_duration: Duration) -> Self {
-        Self {
-            config: Config {
-                max_round_duration,
-                ..self.config
-            },
-        }
-    }
-
-    /// Build the `Config` from this `ConfigBuilder`.
-    #[must_use]
-    pub fn build(self) -> Config {
-        self.config
-    }
-}
-
-/// Tracing algorithm configuration.
+/// Tracing strategy configuration.
 #[derive(Debug, Copy, Clone, Eq, PartialEq)]
-pub struct Config {
+pub struct StrategyConfig {
     pub target_addr: IpAddr,
     pub protocol: Protocol,
     pub trace_identifier: TraceId,
@@ -589,66 +321,7 @@ pub struct Config {
     pub max_round_duration: Duration,
 }
 
-impl Config {
-    #[allow(clippy::too_many_arguments)]
-    pub fn new(
-        target_addr: IpAddr,
-        protocol: Protocol,
-        max_rounds: Option<usize>,
-        trace_identifier: u16,
-        first_ttl: u8,
-        max_ttl: u8,
-        grace_duration: Duration,
-        max_inflight: u8,
-        initial_sequence: u16,
-        multipath_strategy: MultipathStrategy,
-        port_direction: PortDirection,
-        min_round_duration: Duration,
-        max_round_duration: Duration,
-    ) -> TraceResult<Self> {
-        if first_ttl > MAX_TTL {
-            return Err(TracerError::BadConfig(format!(
-                "first_ttl ({first_ttl}) > {MAX_TTL}"
-            )));
-        }
-        if max_ttl > MAX_TTL {
-            return Err(TracerError::BadConfig(format!(
-                "max_ttl ({first_ttl}) > {MAX_TTL}"
-            )));
-        }
-        if initial_sequence > MAX_INITIAL_SEQUENCE {
-            return Err(TracerError::BadConfig(format!(
-                "initial_sequence ({initial_sequence}) > {MAX_INITIAL_SEQUENCE}"
-            )));
-        }
-        let max_rounds = match max_rounds {
-            Some(max_rounds) if max_rounds > 0 => NonZeroUsize::new(max_rounds).map(MaxRounds),
-            Some(_) => {
-                return Err(TracerError::BadConfig(String::from(
-                    "max_rounds must be greater than zero",
-                )));
-            }
-            None => None,
-        };
-        Ok(Self {
-            target_addr,
-            protocol,
-            trace_identifier: TraceId(trace_identifier),
-            max_rounds,
-            first_ttl: TimeToLive(first_ttl),
-            max_ttl: TimeToLive(max_ttl),
-            grace_duration,
-            max_inflight: MaxInflight(max_inflight),
-            initial_sequence: Sequence(initial_sequence),
-            multipath_strategy,
-            port_direction,
-            min_round_duration,
-            max_round_duration,
-        })
-    }
-}
-
-impl Default for Config {
+impl Default for StrategyConfig {
     fn default() -> Self {
         Self {
             target_addr: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
@@ -667,99 +340,3 @@ impl Default for Config {
         }
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use std::num::NonZeroUsize;
-
-    const SOURCE_ADDR: IpAddr = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
-    const TARGET_ADDR: IpAddr = IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2));
-
-    #[test]
-    fn test_channel_config_builder_minimal() {
-        let cfg = ChannelConfigBuilder::new(SOURCE_ADDR, TARGET_ADDR).build();
-        assert_eq!(SOURCE_ADDR, cfg.source_addr);
-        assert_eq!(TARGET_ADDR, cfg.target_addr);
-        assert_eq!(
-            ChannelConfig {
-                source_addr: SOURCE_ADDR,
-                target_addr: TARGET_ADDR,
-                ..Default::default()
-            },
-            cfg
-        );
-    }
-
-    #[test]
-    fn test_channel_config_builder_full() {
-        let cfg = ChannelConfigBuilder::new(SOURCE_ADDR, TARGET_ADDR)
-            .protocol(Protocol::Tcp)
-            .privilege_mode(PrivilegeMode::Unprivileged)
-            .packet_size(PacketSize(128))
-            .payload_pattern(PayloadPattern(0xff))
-            .tos(TypeOfService(0x1a))
-            .icmp_extension_mode(IcmpExtensionParseMode::Enabled)
-            .read_timeout(Duration::from_millis(50))
-            .tcp_connect_timeout(Duration::from_millis(100))
-            .build();
-        assert_eq!(SOURCE_ADDR, cfg.source_addr);
-        assert_eq!(TARGET_ADDR, cfg.target_addr);
-        assert_eq!(Protocol::Tcp, cfg.protocol);
-        assert_eq!(PrivilegeMode::Unprivileged, cfg.privilege_mode);
-        assert_eq!(PacketSize(128), cfg.packet_size);
-        assert_eq!(PayloadPattern(0xff), cfg.payload_pattern);
-        assert_eq!(TypeOfService(0x1a), cfg.tos);
-        assert_eq!(IcmpExtensionParseMode::Enabled, cfg.icmp_extension_mode);
-        assert_eq!(Duration::from_millis(50), cfg.read_timeout);
-        assert_eq!(Duration::from_millis(100), cfg.tcp_connect_timeout);
-    }
-
-    #[test]
-    fn test_config_builder_minimal() {
-        let cfg = ConfigBuilder::new(TraceId(0), TARGET_ADDR).build();
-        assert_eq!(TraceId(0), cfg.trace_identifier);
-        assert_eq!(TARGET_ADDR, cfg.target_addr);
-        assert_eq!(
-            Config {
-                trace_identifier: TraceId(0),
-                target_addr: TARGET_ADDR,
-                ..Default::default()
-            },
-            cfg
-        );
-    }
-
-    #[test]
-    fn test_config_builder_full() {
-        let cfg = ConfigBuilder::new(TraceId(0), TARGET_ADDR)
-            .protocol(Protocol::Udp)
-            .max_rounds(MaxRounds(NonZeroUsize::new(10).unwrap()))
-            .first_ttl(TimeToLive(2))
-            .max_ttl(TimeToLive(16))
-            .grace_duration(Duration::from_millis(100))
-            .max_inflight(MaxInflight(22))
-            .initial_sequence(Sequence(35000))
-            .multipath_strategy(MultipathStrategy::Paris)
-            .port_direction(PortDirection::FixedSrc(Port(33000)))
-            .min_round_duration(Duration::from_millis(500))
-            .max_round_duration(Duration::from_millis(1500))
-            .build();
-        assert_eq!(TraceId(0), cfg.trace_identifier);
-        assert_eq!(TARGET_ADDR, cfg.target_addr);
-        assert_eq!(Protocol::Udp, cfg.protocol);
-        assert_eq!(
-            Some(MaxRounds(NonZeroUsize::new(10).unwrap())),
-            cfg.max_rounds
-        );
-        assert_eq!(TimeToLive(2), cfg.first_ttl);
-        assert_eq!(TimeToLive(16), cfg.max_ttl);
-        assert_eq!(Duration::from_millis(100), cfg.grace_duration);
-        assert_eq!(MaxInflight(22), cfg.max_inflight);
-        assert_eq!(Sequence(35000), cfg.initial_sequence);
-        assert_eq!(MultipathStrategy::Paris, cfg.multipath_strategy);
-        assert_eq!(PortDirection::FixedSrc(Port(33000)), cfg.port_direction);
-        assert_eq!(Duration::from_millis(500), cfg.min_round_duration);
-        assert_eq!(Duration::from_millis(1500), cfg.max_round_duration);
-    }
-}
diff --git a/crates/trippy-core/src/constants.rs b/crates/trippy-core/src/constants.rs
index 150dcd049..3f41cdf8a 100644
--- a/crates/trippy-core/src/constants.rs
+++ b/crates/trippy-core/src/constants.rs
@@ -1,4 +1,7 @@
 /// The maximum time-to-live value allowed.
+///
+/// The IP `ttl` is an u8 (0..255) but since a `ttl` of zero isn't useful we only allow 254 distinct
+/// hops (1..255).
 pub const MAX_TTL: u8 = 254;
 
 /// The maximum number of sequence numbers allowed per round.
diff --git a/crates/trippy-core/src/error.rs b/crates/trippy-core/src/error.rs
index d187feb1c..dabe012dd 100644
--- a/crates/trippy-core/src/error.rs
+++ b/crates/trippy-core/src/error.rs
@@ -29,6 +29,10 @@ pub enum TracerError {
     InvalidSourceAddr(IpAddr),
     #[error("missing address from socket call")]
     MissingAddr,
+    #[error("connect callback error: {0}")]
+    PrivilegeError(#[from] trippy_privilege::Error),
+    #[error("tracer error: {0}")]
+    Other(String),
 }
 
 /// Custom IO error result.
diff --git a/crates/trippy/src/backend/flows.rs b/crates/trippy-core/src/flows.rs
similarity index 100%
rename from crates/trippy/src/backend/flows.rs
rename to crates/trippy-core/src/flows.rs
diff --git a/crates/trippy-core/src/lib.rs b/crates/trippy-core/src/lib.rs
index e1b200e42..df115d6a8 100644
--- a/crates/trippy-core/src/lib.rs
+++ b/crates/trippy-core/src/lib.rs
@@ -18,7 +18,9 @@
 //! use trippy_core::Builder;
 //!
 //! let addr = IpAddr::from_str("1.1.1.1")?;
-//! Builder::new(addr, |round| println!("{:?}", round)).start()?;
+//! Builder::new(addr)
+//!     .build()?
+//!     .run_with(|round| println!("{:?}", round))?;
 //! # Ok(())
 //! # }
 //! ```
@@ -34,15 +36,24 @@
 //! use trippy_core::{Builder, MultipathStrategy, Port, PortDirection, PrivilegeMode, Protocol};
 //!
 //! let addr = IpAddr::from_str("1.1.1.1")?;
-//! Builder::new(addr, |round| println!("{:?}", round))
+//! Builder::new(addr)
 //!     .privilege_mode(PrivilegeMode::Unprivileged)
 //!     .protocol(Protocol::Udp)
 //!     .multipath_strategy(MultipathStrategy::Dublin)
 //!     .port_direction(PortDirection::FixedBoth(Port(33000), Port(3500)))
-//!     .start()?;
+//!     .build()?
+//!     .run_with(|round| println!("{:?}", round))?;
 //! # Ok(())
 //! # }
 //! ```
+//!
+//! # See Also
+//!
+//! - [`Builder`] - Build a [`Tracer`].
+//! - [`Tracer::run`] - Run the tracer on the current thread.
+//! - [`Tracer::run_with`] - Run the tracer with a custom round handler.
+//! - [`Tracer::spawn`] - Run the tracer on a new thread.
+//! - [`Tracer::spawn_with`] - Run the tracer on a new thread with a custom round handler.
 #![warn(clippy::all, clippy::pedantic, clippy::nursery, rust_2018_idioms)]
 #![allow(
     clippy::module_name_repetitions,
@@ -51,7 +62,8 @@
     clippy::option_if_let_else,
     clippy::missing_const_for_fn,
     clippy::cast_possible_truncation,
-    clippy::missing_errors_doc
+    clippy::missing_errors_doc,
+    clippy::cast_precision_loss
 )]
 #![deny(unsafe_code)]
 
@@ -59,24 +71,31 @@ mod builder;
 mod config;
 mod constants;
 mod error;
+mod flows;
 mod net;
 mod probe;
+mod state;
+mod strategy;
 mod tracer;
 mod types;
 
+use net::channel::TracerChannel;
+use net::source::SourceAddr;
+
 pub use builder::Builder;
 pub use config::{
-    defaults, ChannelConfig, ChannelConfigBuilder, Config, ConfigBuilder, IcmpExtensionParseMode,
-    MultipathStrategy, PortDirection, PrivilegeMode, Protocol,
+    defaults, IcmpExtensionParseMode, MultipathStrategy, PortDirection, PrivilegeMode, Protocol,
 };
-pub use net::channel::TracerChannel;
-pub use net::source::SourceAddr;
-pub use net::{PlatformImpl, SocketImpl};
+pub use constants::MAX_TTL;
+pub use error::TracerError;
+pub use flows::{FlowEntry, FlowId};
 pub use probe::{
     Extension, Extensions, IcmpPacketType, MplsLabelStack, MplsLabelStackMember, Probe,
     ProbeComplete, ProbeState, UnknownExtension,
 };
-pub use tracer::{CompletionReason, Tracer, TracerRound};
+pub use state::{Hop, TraceState};
+pub use strategy::{CompletionReason, TracerRound, TracerStrategy};
+pub use tracer::Tracer;
 pub use types::{
     Flags, MaxInflight, MaxRounds, PacketSize, PayloadPattern, Port, Round, Sequence, TimeToLive,
     TraceId, TypeOfService,
diff --git a/crates/trippy-core/src/net/channel.rs b/crates/trippy-core/src/net/channel.rs
index f6675f713..abc8f2a36 100644
--- a/crates/trippy-core/src/net/channel.rs
+++ b/crates/trippy-core/src/net/channel.rs
@@ -1,10 +1,10 @@
-use crate::config::IcmpExtensionParseMode;
+use crate::config::{ChannelConfig, IcmpExtensionParseMode};
 use crate::error::{TraceResult, TracerError};
 use crate::net::socket::Socket;
 use crate::net::{ipv4, ipv6, platform, Network};
 use crate::probe::{Probe, ProbeResponse};
 use crate::types::{PacketSize, PayloadPattern, TypeOfService};
-use crate::{ChannelConfig, Port, PrivilegeMode, Protocol, Sequence};
+use crate::{Port, PrivilegeMode, Protocol, Sequence};
 use arrayvec::ArrayVec;
 use std::net::IpAddr;
 use std::time::{Duration, SystemTime};
@@ -66,7 +66,7 @@ impl<S: Socket> TracerChannel<S> {
             payload_pattern: config.payload_pattern,
             initial_sequence: config.initial_sequence,
             tos: config.tos,
-            icmp_extension_mode: config.icmp_extension_mode,
+            icmp_extension_mode: config.icmp_extension_parse_mode,
             read_timeout: config.read_timeout,
             tcp_connect_timeout: config.tcp_connect_timeout,
             send_socket,
diff --git a/crates/trippy-core/src/net/common.rs b/crates/trippy-core/src/net/common.rs
index ae3f94004..3f09b724a 100644
--- a/crates/trippy-core/src/net/common.rs
+++ b/crates/trippy-core/src/net/common.rs
@@ -3,7 +3,8 @@ use crate::net::platform::in_progress_error;
 use std::io::ErrorKind;
 use std::net::SocketAddr;
 
-/// Helper function to convert an `IoResult` to a `TraceResult` with special handling for `AddressNotAvailable`.
+/// Helper function to convert an `IoResult` to a `TraceResult` with special handling for
+/// `AddressNotAvailable`.
 pub fn process_result(addr: SocketAddr, res: IoResult<()>) -> TraceResult<()> {
     match res {
         Ok(()) => Ok(()),
diff --git a/crates/trippy-core/src/net/platform/unix.rs b/crates/trippy-core/src/net/platform/unix.rs
index db832d558..068df0a34 100644
--- a/crates/trippy-core/src/net/platform/unix.rs
+++ b/crates/trippy-core/src/net/platform/unix.rs
@@ -20,7 +20,7 @@ mod address {
     use crate::error::{TraceResult, TracerError};
     use crate::net::platform::Ipv4ByteOrder;
     use crate::net::socket::Socket;
-    use crate::SocketImpl;
+    use crate::net::SocketImpl;
     use nix::sys::socket::{AddressFamily, SockaddrLike};
     use std::net::{IpAddr, SocketAddr};
     use tracing::instrument;
@@ -35,8 +35,8 @@ mod address {
     /// Discover the required byte ordering for the IPv4 header fields `total_length`, `flags` and
     /// `fragment_offset`.
     ///
-    /// Linux accepts either network byte order or host byte order for the `total_length` field and so
-    /// we skip the check and return network byte order unconditionally.
+    /// Linux accepts either network byte order or host byte order for the `total_length` field and
+    /// so we skip the check and return network byte order unconditionally.
     #[cfg(target_os = "linux")]
     #[allow(clippy::unnecessary_wraps)]
     pub fn for_address(_src_addr: IpAddr) -> TraceResult<Ipv4ByteOrder> {
@@ -64,11 +64,11 @@ mod address {
 
     /// Attempt to send an `ICMP` packet to a local address.
     ///
-    /// The packet is actually of length `256` bytes but we set the `total_length` based on the input
-    /// provided so as to test if the OS rejects the attempt during the call to `send_to`.
+    /// The packet is actually of length `256` bytes but we set the `total_length` based on the
+    /// input provided so as to test if the OS rejects the attempt during the call to `send_to`.
     ///
-    /// Note that this implementation will try to create an `IPPROTO_ICMP` socket and if that fails it
-    /// will fallback to creating an `IPPROTO_RAW` socket.
+    /// Note that this implementation will try to create an `IPPROTO_ICMP` socket and if that fails
+    /// it will fallback to creating an `IPPROTO_RAW` socket.
     #[cfg(not(target_os = "linux"))]
     #[instrument(ret)]
     fn test_send_local_ip4_packet(src_addr: Ipv4Addr, total_length: u16) -> TraceResult<()> {
diff --git a/crates/trippy-core/src/net/socket.rs b/crates/trippy-core/src/net/socket.rs
index b3d381d40..3db2f7cf4 100644
--- a/crates/trippy-core/src/net/socket.rs
+++ b/crates/trippy-core/src/net/socket.rs
@@ -52,8 +52,9 @@ where
 #[derive(Debug)]
 pub enum SocketError {
     ConnectionRefused,
+    #[allow(dead_code)]
     HostUnreachable,
-    Other(std::io::Error),
+    Other(#[allow(dead_code)] std::io::Error),
 }
 
 #[cfg(test)]
diff --git a/crates/trippy/src/backend/trace.rs b/crates/trippy-core/src/state.rs
similarity index 90%
rename from crates/trippy/src/backend/trace.rs
rename to crates/trippy-core/src/state.rs
index feea8c142..be3768843 100644
--- a/crates/trippy/src/backend/trace.rs
+++ b/crates/trippy-core/src/state.rs
@@ -1,99 +1,104 @@
-use crate::backend::flows::{Flow, FlowId, FlowRegistry};
-use crate::config::MAX_HOPS;
+use crate::config::StateConfig;
+use crate::constants::MAX_TTL;
+use crate::flows::{Flow, FlowId, FlowRegistry};
+use crate::{Extensions, IcmpPacketType, ProbeState, Round, TimeToLive, TracerRound};
 use indexmap::IndexMap;
 use std::collections::HashMap;
 use std::iter::once;
 use std::net::IpAddr;
 use std::time::Duration;
-use trippy_core::{Extensions, IcmpPacketType, ProbeState, Round, TimeToLive, TracerRound};
 
-/// The state of all hops in a trace.
-#[derive(Debug, Clone)]
-pub struct Trace {
-    /// The maximum number of samples to record per hop.
-    ///
-    /// Once the maximum number of samples has been reached the oldest sample
-    /// is discarded (FIFO).
-    max_samples: usize,
-    /// The maximum number of flows to record.
-    ///
-    /// Once the maximum number of flows has been reached no new flows will be
-    /// created, existing flows are updated and are never removed.
-    max_flows: usize,
+/// The state of a trace.
+#[derive(Debug, Clone, Default)]
+pub struct TraceState {
+    state_config: StateConfig,
     /// The flow id for the current round.
     round_flow_id: FlowId,
     /// Tracing data per registered flow id.
-    trace_data: HashMap<FlowId, TraceData>,
+    state: HashMap<FlowId, FlowState>,
     /// Flow registry.
     registry: FlowRegistry,
     /// Tracing error message.
     error: Option<String>,
 }
 
-impl Trace {
+impl TraceState {
     /// Create a new `Trace`.
-    pub fn new(max_samples: usize, max_flows: usize) -> Self {
+    #[must_use]
+    pub fn new(state_config: StateConfig) -> Self {
         Self {
-            trace_data: once((Self::default_flow_id(), TraceData::new(max_samples)))
-                .collect::<HashMap<FlowId, TraceData>>(),
+            state: once((
+                Self::default_flow_id(),
+                FlowState::new(state_config.max_samples),
+            ))
+            .collect::<HashMap<FlowId, FlowState>>(),
             round_flow_id: Self::default_flow_id(),
-            max_samples,
-            max_flows,
+            state_config,
             registry: FlowRegistry::new(),
             error: None,
         }
     }
 
     /// Return the id of the default flow.
+    #[must_use]
     pub fn default_flow_id() -> FlowId {
         FlowId(0)
     }
 
     /// Information about each hop for a given flow.
+    #[must_use]
     pub fn hops(&self, flow_id: FlowId) -> &[Hop] {
-        self.trace_data[&flow_id].hops()
+        self.state[&flow_id].hops()
     }
 
     /// Is a given `Hop` the target hop for a given flow?
     ///
     /// A `Hop` is considered to be the target if it has the highest `ttl` value observed.
     ///
-    /// Note that if the target host does not respond to probes then the the highest `ttl` observed
+    /// Note that if the target host does not respond to probes then the highest `ttl` observed
     /// will be one greater than the `ttl` of the last host which did respond.
+    #[must_use]
     pub fn is_target(&self, hop: &Hop, flow_id: FlowId) -> bool {
-        self.trace_data[&flow_id].is_target(hop)
+        self.state[&flow_id].is_target(hop)
     }
 
     /// Is a given `Hop` in the current round for a given flow?
+    #[must_use]
     pub fn is_in_round(&self, hop: &Hop, flow_id: FlowId) -> bool {
-        self.trace_data[&flow_id].is_in_round(hop)
+        self.state[&flow_id].is_in_round(hop)
     }
 
     /// Return the target `Hop` for a given flow.
+    #[must_use]
     pub fn target_hop(&self, flow_id: FlowId) -> &Hop {
-        self.trace_data[&flow_id].target_hop()
+        self.state[&flow_id].target_hop()
     }
 
     /// The current round of tracing for a given flow.
+    #[must_use]
     pub fn round(&self, flow_id: FlowId) -> Option<usize> {
-        self.trace_data[&flow_id].round()
+        self.state[&flow_id].round()
     }
 
     /// The total rounds of tracing for a given flow.
+    #[must_use]
     pub fn round_count(&self, flow_id: FlowId) -> usize {
-        self.trace_data[&flow_id].round_count()
+        self.state[&flow_id].round_count()
     }
 
     /// The `FlowId` for the current round.
+    #[must_use]
     pub fn round_flow_id(&self) -> FlowId {
         self.round_flow_id
     }
 
     /// The registry of flows in the trace.
+    #[must_use]
     pub fn flows(&self) -> &[(Flow, FlowId)] {
         self.registry.flows()
     }
 
+    #[must_use]
     pub fn error(&self) -> Option<&str> {
         self.error.as_deref()
     }
@@ -103,17 +108,19 @@ impl Trace {
     }
 
     /// The maximum number of samples to record per hop.
+    #[must_use]
     pub fn max_samples(&self) -> usize {
-        self.max_samples
+        self.state_config.max_samples
     }
 
     /// The maximum number of flows to record.
+    #[must_use]
     pub fn max_flows(&self) -> usize {
-        self.max_flows
+        self.state_config.max_flows
     }
 
     /// Update the tracing state from a `TracerRound`.
-    pub(super) fn update_from_round(&mut self, round: &TracerRound<'_>) {
+    pub fn update_from_round(&mut self, round: &TracerRound<'_>) {
         let flow = Flow::from_hops(
             round
                 .probes
@@ -126,7 +133,7 @@ impl Trace {
                 .take(usize::from(round.largest_ttl.0)),
         );
         self.update_trace_flow(Self::default_flow_id(), round);
-        if self.registry.flows().len() < self.max_flows {
+        if self.registry.flows().len() < self.state_config.max_flows {
             let flow_id = self.registry.register(flow);
             self.round_flow_id = flow_id;
             self.update_trace_flow(flow_id, round);
@@ -135,9 +142,9 @@ impl Trace {
 
     fn update_trace_flow(&mut self, flow_id: FlowId, round: &TracerRound<'_>) {
         let flow_trace = self
-            .trace_data
+            .state
             .entry(flow_id)
-            .or_insert_with(|| TraceData::new(self.max_samples));
+            .or_insert_with(|| FlowState::new(self.state_config.max_samples));
         flow_trace.update_from_round(round);
     }
 }
@@ -187,6 +194,7 @@ pub struct Hop {
 
 impl Hop {
     /// The time-to-live of this hop.
+    #[must_use]
     pub fn ttl(&self) -> u8 {
         self.ttl
     }
@@ -201,21 +209,25 @@ impl Hop {
     }
 
     /// The number of unique address observed for this time-to-live.
+    #[must_use]
     pub fn addr_count(&self) -> usize {
         self.addrs.len()
     }
 
     /// The total number of probes sent.
+    #[must_use]
     pub fn total_sent(&self) -> usize {
         self.total_sent
     }
 
     /// The total number of probes responses received.
+    #[must_use]
     pub fn total_recv(&self) -> usize {
         self.total_recv
     }
 
     /// The % of packets that are lost.
+    #[must_use]
     pub fn loss_pct(&self) -> f64 {
         if self.total_sent > 0 {
             let lost = self.total_sent - self.total_recv;
@@ -226,21 +238,25 @@ impl Hop {
     }
 
     /// The duration of the last probe.
+    #[must_use]
     pub fn last_ms(&self) -> Option<f64> {
         self.last.map(|last| last.as_secs_f64() * 1000_f64)
     }
 
     /// The duration of the best probe observed.
+    #[must_use]
     pub fn best_ms(&self) -> Option<f64> {
         self.best.map(|last| last.as_secs_f64() * 1000_f64)
     }
 
     /// The duration of the worst probe observed.
+    #[must_use]
     pub fn worst_ms(&self) -> Option<f64> {
         self.worst.map(|last| last.as_secs_f64() * 1000_f64)
     }
 
     /// The average duration of all probes.
+    #[must_use]
     pub fn avg_ms(&self) -> f64 {
         if self.total_recv() > 0 {
             (self.total_time.as_secs_f64() * 1000_f64) / self.total_recv as f64
@@ -250,6 +266,7 @@ impl Hop {
     }
 
     /// The standard deviation of all probes.
+    #[must_use]
     pub fn stddev_ms(&self) -> f64 {
         if self.total_recv > 1 {
             (self.m2 / (self.total_recv - 1) as f64).sqrt()
@@ -259,50 +276,60 @@ impl Hop {
     }
 
     /// The duration of the jitter probe observed.
+    #[must_use]
     pub fn jitter_ms(&self) -> Option<f64> {
         self.jitter.map(|j| j.as_secs_f64() * 1000_f64)
     }
 
-    /// The duration of the jworst probe observed.
+    /// The duration of the worst probe observed.
+    #[must_use]
     pub fn jmax_ms(&self) -> Option<f64> {
         self.jmax.map(|x| x.as_secs_f64() * 1000_f64)
     }
 
     /// The jitter average duration of all probes.
+    #[must_use]
     pub fn javg_ms(&self) -> f64 {
         self.javg
     }
 
     /// The jitter interval of all probes.
+    #[must_use]
     pub fn jinta(&self) -> f64 {
         self.jinta
     }
 
     /// The source port for last probe for this hop.
+    #[must_use]
     pub fn last_src_port(&self) -> u16 {
         self.last_src_port
     }
 
     /// The destination port for last probe for this hop.
+    #[must_use]
     pub fn last_dest_port(&self) -> u16 {
         self.last_dest_port
     }
 
     /// The sequence number for the last probe for this hop.
+    #[must_use]
     pub fn last_sequence(&self) -> u16 {
         self.last_sequence
     }
 
     /// The icmp packet type for the last probe for this hop.
+    #[must_use]
     pub fn last_icmp_packet_type(&self) -> Option<IcmpPacketType> {
         self.last_icmp_packet_type
     }
 
     /// The last N samples.
+    #[must_use]
     pub fn samples(&self) -> &[Duration] {
         &self.samples
     }
 
+    #[must_use]
     pub fn extensions(&self) -> Option<&Extensions> {
         self.extensions.as_ref()
     }
@@ -335,9 +362,9 @@ impl Default for Hop {
     }
 }
 
-/// Data for a trace.
+/// Data for a single trace flow.
 #[derive(Debug, Clone)]
-struct TraceData {
+struct FlowState {
     /// The maximum number of samples to record.
     max_samples: usize,
     /// The lowest ttl observed across all rounds.
@@ -354,7 +381,7 @@ struct TraceData {
     hops: Vec<Hop>,
 }
 
-impl TraceData {
+impl FlowState {
     fn new(max_samples: usize) -> Self {
         Self {
             max_samples,
@@ -363,7 +390,7 @@ impl TraceData {
             highest_ttl_for_round: 0,
             round: None,
             round_count: 0,
-            hops: (0..MAX_HOPS).map(|_| Hop::default()).collect(),
+            hops: (0..MAX_TTL).map(|_| Hop::default()).collect(),
         }
     }
 
@@ -491,6 +518,10 @@ impl TraceData {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::{
+        CompletionReason, Flags, IcmpPacketType, Port, Probe, ProbeComplete, ProbeState, Sequence,
+        TimeToLive, TraceId,
+    };
     use anyhow::anyhow;
     use serde::Deserialize;
     use std::collections::HashSet;
@@ -498,10 +529,6 @@ mod tests {
     use std::str::FromStr;
     use std::time::SystemTime;
     use test_case::test_case;
-    use trippy_core::{
-        CompletionReason, Flags, IcmpPacketType, Port, Probe, ProbeComplete, ProbeState, Sequence,
-        TimeToLive, TraceId,
-    };
 
     /// A test scenario.
     #[derive(Deserialize, Debug)]
@@ -639,7 +666,7 @@ mod tests {
 
     macro_rules! file {
         ($path:expr) => {{
-            let yaml = include_str!(concat!("../../tests/resources/backend/", $path));
+            let yaml = include_str!(concat!("../tests/resources/backend/", $path));
             serde_yaml::from_str(yaml).unwrap()
         }};
     }
@@ -649,7 +676,10 @@ mod tests {
     #[test_case(file!("ipv4_4probes_all_status.yaml"))]
     #[test_case(file!("ipv4_4probes_0latency.yaml"))]
     fn test_scenario(scenario: Scenario) {
-        let mut trace = Trace::new(100, 1);
+        let mut trace = TraceState::new(StateConfig {
+            max_flows: 1,
+            ..StateConfig::default()
+        });
         for (i, round) in scenario.rounds.into_iter().enumerate() {
             let probes = round
                 .probes
@@ -662,7 +692,7 @@ mod tests {
                 TracerRound::new(&probes, largest_ttl, CompletionReason::TargetFound);
             trace.update_from_round(&tracer_round);
         }
-        let actual_hops = trace.hops(Trace::default_flow_id());
+        let actual_hops = trace.hops(TraceState::default_flow_id());
         let expected_hops = scenario.expected.hops;
         for (actual, expected) in actual_hops.iter().zip(expected_hops) {
             assert_eq!(actual.ttl(), expected.ttl);
diff --git a/crates/trippy-core/src/strategy.rs b/crates/trippy-core/src/strategy.rs
new file mode 100644
index 000000000..71e8e64c2
--- /dev/null
+++ b/crates/trippy-core/src/strategy.rs
@@ -0,0 +1,1234 @@
+use self::state::TracerState;
+use crate::config::StrategyConfig;
+use crate::error::{TraceResult, TracerError};
+use crate::net::Network;
+use crate::probe::{
+    ProbeResponse, ProbeResponseData, ProbeResponseSeq, ProbeResponseSeqIcmp, ProbeResponseSeqTcp,
+    ProbeResponseSeqUdp, ProbeState,
+};
+use crate::types::{Sequence, TimeToLive, TraceId};
+use crate::{MultipathStrategy, PortDirection, Protocol};
+use std::net::IpAddr;
+use std::time::{Duration, SystemTime};
+use tracing::instrument;
+
+/// The output from a round of tracing.
+#[derive(Debug, Clone)]
+pub struct TracerRound<'a> {
+    /// The state of all `ProbeState` that were sent in the round.
+    pub probes: &'a [ProbeState],
+    /// The largest time-to-live (ttl) for which we received a reply in the round.
+    pub largest_ttl: TimeToLive,
+    /// Indicates what triggered the completion of the tracing round.
+    pub reason: CompletionReason,
+}
+
+impl<'a> TracerRound<'a> {
+    #[must_use]
+    pub fn new(
+        probes: &'a [ProbeState],
+        largest_ttl: TimeToLive,
+        reason: CompletionReason,
+    ) -> Self {
+        Self {
+            probes,
+            largest_ttl,
+            reason,
+        }
+    }
+}
+
+/// Indicates what triggered the completion of the tracing round.
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub enum CompletionReason {
+    /// The round ended because the target was found.
+    TargetFound,
+    /// The round ended because the time exceeded the configured maximum round time.
+    RoundTimeLimitExceeded,
+}
+
+/// Trace a path to a target.
+#[derive(Debug, Clone)]
+pub struct TracerStrategy<F> {
+    config: StrategyConfig,
+    publish: F,
+}
+
+impl<F: Fn(&TracerRound<'_>)> TracerStrategy<F> {
+    #[instrument(skip_all)]
+    pub fn new(config: &StrategyConfig, publish: F) -> Self {
+        tracing::debug!(?config);
+        Self {
+            config: *config,
+            publish,
+        }
+    }
+
+    /// Run a continuous trace and publish results.
+    #[instrument(skip(self, network))]
+    pub fn run<N: Network>(self, mut network: N) -> TraceResult<()> {
+        let mut state = TracerState::new(self.config);
+        while !state.finished(self.config.max_rounds) {
+            self.send_request(&mut network, &mut state)?;
+            self.recv_response(&mut network, &mut state)?;
+            self.update_round(&mut state);
+        }
+        Ok(())
+    }
+
+    /// Send the next probe if required.
+    ///
+    /// Send a `ProbeState` for the next time-to-live (ttl) if all the following are true:
+    ///
+    /// 1 - the target host has not been found
+    /// 2 - the next ttl is not greater than the maximum allowed ttl
+    /// 3 - if the target ttl of the target is known:
+    ///       - the next ttl is not greater than the ttl of the target host observed from the prior
+    ///         round
+    ///     otherwise:
+    ///       - the number of unknown-in-flight probes is lower than the maximum allowed
+    #[instrument(skip(self, network, st))]
+    fn send_request<N: Network>(&self, network: &mut N, st: &mut TracerState) -> TraceResult<()> {
+        let can_send_ttl = if let Some(target_ttl) = st.target_ttl() {
+            st.ttl() <= target_ttl
+        } else {
+            st.ttl() - st.max_received_ttl().unwrap_or_default()
+                < TimeToLive(self.config.max_inflight.0)
+        };
+        if !st.target_found() && st.ttl() <= self.config.max_ttl && can_send_ttl {
+            let sent = SystemTime::now();
+            match self.config.protocol {
+                Protocol::Icmp => {
+                    network.send_probe(st.next_probe(sent))?;
+                }
+                Protocol::Udp => network.send_probe(st.next_probe(sent))?,
+                Protocol::Tcp => {
+                    let mut probe = if st.round_has_capacity() {
+                        st.next_probe(sent)
+                    } else {
+                        return Err(TracerError::InsufficientCapacity);
+                    };
+                    while let Err(err) = network.send_probe(probe) {
+                        match err {
+                            TracerError::AddressNotAvailable(_) => {
+                                if st.round_has_capacity() {
+                                    probe = st.reissue_probe(SystemTime::now());
+                                } else {
+                                    return Err(TracerError::InsufficientCapacity);
+                                }
+                            }
+                            other => return Err(other),
+                        }
+                    }
+                }
+            };
+        }
+        Ok(())
+    }
+
+    /// Read and process the next incoming `ICMP` packet.
+    ///
+    /// We allow multiple probes to be in-flight at any time, and we cannot guarantee that responses
+    /// will be received in-order.  We therefore maintain a buffer which holds details of each
+    /// `ProbeState` which is indexed by the offset of the sequence number from the sequence number
+    /// at the beginning of the round.  The sequence number is set in the outgoing `ICMP`
+    /// `EchoRequest` (or `UDP` / `TCP`) packet and returned in both the `TimeExceeded` and
+    /// `EchoReply` responses.
+    ///
+    /// Each incoming `ICMP` packet contains the original `ICMP` `EchoRequest` packet from which we
+    /// can read the `identifier` that we set which we can now validate to ensure we only
+    /// process responses which correspond to packets sent from this process.  For The `UDP` and
+    /// `TCP` protocols, only packets destined for our src port will be delivered to us by the
+    /// OS and so no other `identifier` is needed, and so we allow the special case value of 0.
+    ///
+    /// When we process an `EchoReply` from the target host we extract the time-to-live from the
+    /// corresponding original `EchoRequest`.  Note that this may not be the greatest
+    /// time-to-live that was sent in the round as the algorithm will send `EchoRequest` with
+    /// larger time-to-live values before the `EchoReply` is received.
+    #[instrument(skip(self, network, st))]
+    fn recv_response<N: Network>(&self, network: &mut N, st: &mut TracerState) -> TraceResult<()> {
+        let next = network.recv_probe()?;
+        match next {
+            Some(ProbeResponse::TimeExceeded(data, icmp_code, extensions)) => {
+                let (trace_id, sequence, received, host) = self.extract(&data);
+                let is_target = host == self.config.target_addr;
+                if self.check_trace_id(trace_id) && st.in_round(sequence) && self.validate(&data) {
+                    st.complete_probe_time_exceeded(
+                        sequence, host, received, is_target, icmp_code, extensions,
+                    );
+                }
+            }
+            Some(ProbeResponse::DestinationUnreachable(data, icmp_code, extensions)) => {
+                let (trace_id, sequence, received, host) = self.extract(&data);
+                if self.check_trace_id(trace_id) && st.in_round(sequence) && self.validate(&data) {
+                    st.complete_probe_unreachable(sequence, host, received, icmp_code, extensions);
+                }
+            }
+            Some(ProbeResponse::EchoReply(data, icmp_code)) => {
+                let (trace_id, sequence, received, host) = self.extract(&data);
+                if self.check_trace_id(trace_id) && st.in_round(sequence) && self.validate(&data) {
+                    st.complete_probe_echo_reply(sequence, host, received, icmp_code);
+                }
+            }
+            Some(ProbeResponse::TcpReply(data) | ProbeResponse::TcpRefused(data)) => {
+                let (trace_id, sequence, received, host) = self.extract(&data);
+                if self.check_trace_id(trace_id) && st.in_round(sequence) && self.validate(&data) {
+                    st.complete_probe_other(sequence, host, received);
+                }
+            }
+            None => {}
+        }
+        Ok(())
+    }
+
+    /// Check if the round is complete and publish the results.
+    ///
+    /// A round is considered to be complete when:
+    ///
+    /// 1 - the round has exceeded the minimum round duration AND
+    /// 2 - the duration since the last packet was received exceeds the grace period AND
+    /// 3 - either:
+    ///     A - the target has been found OR
+    ///     B - the target has not been found and the round has exceeded the maximum round duration
+    #[instrument(skip(self, st))]
+    fn update_round(&self, st: &mut TracerState) {
+        let now = SystemTime::now();
+        let round_duration = now.duration_since(st.round_start()).unwrap_or_default();
+        let round_min = round_duration > self.config.min_round_duration;
+        let grace_exceeded = exceeds(st.received_time(), now, self.config.grace_duration);
+        let round_max = round_duration > self.config.max_round_duration;
+        let target_found = st.target_found();
+        if round_min && grace_exceeded && target_found || round_max {
+            self.publish_trace(st);
+            st.advance_round(self.config.first_ttl);
+        }
+    }
+
+    /// Publish details of all `ProbeState` in the completed round.
+    ///
+    /// If the round completed without receiving an `EchoReply` from the target host then we also
+    /// publish the next `ProbeState` which is assumed to represent the TTL of the target host.
+    #[instrument(skip(self, state))]
+    fn publish_trace(&self, state: &TracerState) {
+        let max_received_ttl = if let Some(target_ttl) = state.target_ttl() {
+            target_ttl
+        } else {
+            state
+                .max_received_ttl()
+                .map_or(TimeToLive(0), |max_received_ttl| {
+                    let max_sent_ttl = state.ttl() - TimeToLive(1);
+                    max_sent_ttl.min(max_received_ttl + TimeToLive(1))
+                })
+        };
+        let probes = state.probes();
+        let largest_ttl = max_received_ttl;
+        let reason = if state.target_found() {
+            CompletionReason::TargetFound
+        } else {
+            CompletionReason::RoundTimeLimitExceeded
+        };
+        (self.publish)(&TracerRound::new(probes, largest_ttl, reason));
+    }
+
+    /// Check if the `TraceId` matches the expected value for this tracer.
+    ///
+    /// A special value of `0` is accepted for `udp` and `tcp` which do not have an identifier.
+    #[instrument(skip(self))]
+    fn check_trace_id(&self, trace_id: TraceId) -> bool {
+        self.config.trace_identifier == trace_id || trace_id == TraceId(0)
+    }
+
+    /// Validate the probe response data.
+    ///
+    /// Carries out specific check for UDP/TCP probe responses.  This is
+    /// required as the network layer may receive incoming ICMP
+    /// `DestinationUnreachable` (and other types) packets with a UDP/TCP
+    /// original datagram which does not correspond to a probe sent by the
+    /// tracer and must therefore be ignored.
+    ///
+    /// For UDP and TCP probe responses, check that the src/dest ports and
+    /// dest address match the expected values.
+    ///
+    /// For ICMP probe responses no additional checks are required.
+    fn validate(&self, resp: &ProbeResponseData) -> bool {
+        fn validate_ports(port_direction: PortDirection, src_port: u16, dest_port: u16) -> bool {
+            match port_direction {
+                PortDirection::FixedSrc(src) if src.0 == src_port => true,
+                PortDirection::FixedDest(dest) if dest.0 == dest_port => true,
+                PortDirection::FixedBoth(src, dest) if src.0 == src_port && dest.0 == dest_port => {
+                    true
+                }
+                _ => false,
+            }
+        }
+        match resp.resp_seq {
+            ProbeResponseSeq::Icmp(_) => true,
+            ProbeResponseSeq::Udp(ProbeResponseSeqUdp {
+                dest_addr,
+                src_port,
+                dest_port,
+                has_magic,
+                ..
+            }) => {
+                let check_ports = validate_ports(self.config.port_direction, src_port, dest_port);
+                let check_dest_addr = self.config.target_addr == dest_addr;
+                let check_magic = match (self.config.multipath_strategy, self.config.target_addr) {
+                    (MultipathStrategy::Dublin, IpAddr::V6(_)) => has_magic,
+                    _ => true,
+                };
+                check_dest_addr && check_ports && check_magic
+            }
+            ProbeResponseSeq::Tcp(ProbeResponseSeqTcp {
+                dest_addr,
+                src_port,
+                dest_port,
+            }) => {
+                let check_ports = validate_ports(self.config.port_direction, src_port, dest_port);
+                let check_dest_addr = self.config.target_addr == dest_addr;
+                check_dest_addr && check_ports
+            }
+        }
+    }
+
+    /// Extract the `TraceId`, `Sequence`, `SystemTime` and `IpAddr` from the `ProbeResponseData` in
+    /// a protocol specific way.
+    #[instrument(skip(self))]
+    fn extract(&self, resp: &ProbeResponseData) -> (TraceId, Sequence, SystemTime, IpAddr) {
+        match resp.resp_seq {
+            ProbeResponseSeq::Icmp(ProbeResponseSeqIcmp {
+                identifier,
+                sequence,
+            }) => (
+                TraceId(identifier),
+                Sequence(sequence),
+                resp.recv,
+                resp.addr,
+            ),
+            ProbeResponseSeq::Udp(ProbeResponseSeqUdp {
+                identifier,
+                src_port,
+                dest_port,
+                checksum,
+                payload_len,
+                ..
+            }) => {
+                let sequence = match (
+                    self.config.multipath_strategy,
+                    self.config.port_direction,
+                    self.config.target_addr,
+                ) {
+                    (MultipathStrategy::Classic, PortDirection::FixedDest(_), _) => src_port,
+                    (MultipathStrategy::Classic, _, _) => dest_port,
+                    (MultipathStrategy::Paris, _, _) => checksum,
+                    (MultipathStrategy::Dublin, _, IpAddr::V4(_)) => identifier,
+                    (MultipathStrategy::Dublin, _, IpAddr::V6(_)) => {
+                        self.config.initial_sequence.0 + payload_len
+                    }
+                };
+                (TraceId(0), Sequence(sequence), resp.recv, resp.addr)
+            }
+            ProbeResponseSeq::Tcp(ProbeResponseSeqTcp {
+                src_port,
+                dest_port,
+                ..
+            }) => {
+                let sequence = match self.config.port_direction {
+                    PortDirection::FixedSrc(_) => dest_port,
+                    _ => src_port,
+                };
+                (TraceId(0), Sequence(sequence), resp.recv, resp.addr)
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::net::MockNetwork;
+    use crate::probe::IcmpPacketCode;
+    use crate::{MaxRounds, Port};
+    use std::net::Ipv4Addr;
+    use std::num::NonZeroUsize;
+
+    // The network can return both `DestinationUnreachable` and `TcpRefused`
+    // for the same sequence number.  This can occur for the target hop for
+    // TCP protocol as the network layer check for ICMP responses such as
+    // `DestinationUnreachable` and also synthesizes a `TcpRefused` response.
+    //
+    // This test simulates sending 1 TCP probe (seq=33000) and receiving two
+    // responses for that probe, a `DestinationUnreachable` followed by a
+    // `TcpRefused`.
+    #[test]
+    fn test_tcp_dest_unreachable_and_refused() -> anyhow::Result<()> {
+        let sequence = 33000;
+        let target_addr = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
+
+        let mut network = MockNetwork::new();
+        let mut seq = mockall::Sequence::new();
+        network.expect_send_probe().times(1).returning(|_| Ok(()));
+        network
+            .expect_recv_probe()
+            .times(1)
+            .in_sequence(&mut seq)
+            .returning(move || {
+                Ok(Some(ProbeResponse::DestinationUnreachable(
+                    ProbeResponseData::new(
+                        SystemTime::now(),
+                        target_addr,
+                        ProbeResponseSeq::Tcp(ProbeResponseSeqTcp::new(target_addr, sequence, 80)),
+                    ),
+                    IcmpPacketCode(1),
+                    None,
+                )))
+            });
+        network
+            .expect_recv_probe()
+            .times(1)
+            .in_sequence(&mut seq)
+            .returning(move || {
+                Ok(Some(ProbeResponse::TcpRefused(ProbeResponseData::new(
+                    SystemTime::now(),
+                    target_addr,
+                    ProbeResponseSeq::Tcp(ProbeResponseSeqTcp::new(target_addr, sequence, 80)),
+                ))))
+            });
+
+        let config = StrategyConfig {
+            target_addr,
+            max_rounds: Some(MaxRounds(NonZeroUsize::MIN)),
+            initial_sequence: Sequence(sequence),
+            port_direction: PortDirection::FixedDest(Port(80)),
+            protocol: Protocol::Tcp,
+            ..Default::default()
+        };
+        let tracer = TracerStrategy::new(&config, |_| {});
+        let mut state = TracerState::new(config);
+        tracer.send_request(&mut network, &mut state)?;
+        tracer.recv_response(&mut network, &mut state)?;
+        tracer.recv_response(&mut network, &mut state)?;
+        Ok(())
+    }
+}
+
+/// Mutable state needed for the tracing algorithm.
+///
+/// This is contained within a submodule to ensure that mutations are only performed via methods on
+/// the `TracerState` struct.
+mod state {
+    use crate::constants::MAX_SEQUENCE_PER_ROUND;
+    use crate::probe::{Extensions, IcmpPacketCode, IcmpPacketType, Probe, ProbeState};
+    use crate::strategy::StrategyConfig;
+    use crate::types::{MaxRounds, Port, Round, Sequence, TimeToLive, TraceId};
+    use crate::{Flags, MultipathStrategy, PortDirection, Protocol};
+    use std::array::from_fn;
+    use std::net::IpAddr;
+    use std::time::SystemTime;
+    use tracing::instrument;
+
+    /// The maximum number of `ProbeState` entries in the buffer.
+    ///
+    /// This is larger than maximum number of time-to-live (TTL) we can support to allow for skipped
+    /// sequences.
+    const BUFFER_SIZE: u16 = MAX_SEQUENCE_PER_ROUND;
+
+    /// The maximum sequence number.
+    ///
+    /// The sequence number is only ever wrapped between rounds, and so we need to ensure that there
+    /// are enough sequence numbers for a complete round.
+    ///
+    /// A sequence number can be skipped if, for example, the port for that sequence number cannot
+    /// be bound as it is already in use.
+    ///
+    /// To ensure each `ProbeState` is in the correct place in the buffer (i.e. the index into the
+    /// buffer is always `Probe.sequence - round_sequence`), when we skip a sequence we leave
+    /// the skipped `ProbeState` in-place and use the next slot for the next sequence.
+    ///
+    /// We cap the number of sequences that can potentially be skipped in a round to ensure that
+    /// sequence number does not even need to wrap around during a round.
+    ///
+    /// We only ever send `ttl` in the range 1..255, and so we may use all buffer capacity, except
+    /// the minimum needed to send up to a max `ttl` of 255 (a `ttl` of 0 is never sent).
+    const MAX_SEQUENCE: Sequence = Sequence(u16::MAX - BUFFER_SIZE);
+
+    /// Mutable state needed for the tracing algorithm.
+    #[derive(Debug)]
+    pub struct TracerState {
+        /// Tracer configuration.
+        config: StrategyConfig,
+        /// The state of all `ProbeState` requests and responses.
+        buffer: [ProbeState; BUFFER_SIZE as usize],
+        /// An increasing sequence number for every `EchoRequest`.
+        sequence: Sequence,
+        /// The starting sequence number of the current round.
+        round_sequence: Sequence,
+        /// The time-to-live for the _next_ `EchoRequest` packet to be sent.
+        ttl: TimeToLive,
+        /// The current round.
+        round: Round,
+        /// The timestamp of when the current round started.
+        round_start: SystemTime,
+        /// Did we receive an `EchoReply` from the target host in this round?
+        target_found: bool,
+        /// The maximum time-to-live echo response packet we have received.
+        max_received_ttl: Option<TimeToLive>,
+        /// The observed time-to-live of the `EchoReply` from the target host.
+        ///
+        /// Note that this is _not_ reset each round and that it can also _change_ over time,
+        /// including going _down_ as responses can be received out-of-order.
+        target_ttl: Option<TimeToLive>,
+        /// The timestamp of the echo response packet.
+        received_time: Option<SystemTime>,
+    }
+
+    impl TracerState {
+        pub fn new(config: StrategyConfig) -> Self {
+            Self {
+                config,
+                buffer: from_fn(|_| ProbeState::default()),
+                sequence: config.initial_sequence,
+                round_sequence: config.initial_sequence,
+                ttl: config.first_ttl,
+                round: Round(0),
+                round_start: SystemTime::now(),
+                target_found: false,
+                max_received_ttl: None,
+                target_ttl: None,
+                received_time: None,
+            }
+        }
+
+        /// Get a slice of `ProbeState` for the current round.
+        pub fn probes(&self) -> &[ProbeState] {
+            let round_size = self.sequence - self.round_sequence;
+            &self.buffer[..round_size.0 as usize]
+        }
+
+        /// Get the `ProbeState` for `sequence`
+        pub fn probe_at(&self, sequence: Sequence) -> ProbeState {
+            self.buffer[usize::from(sequence - self.round_sequence)].clone()
+        }
+
+        pub const fn ttl(&self) -> TimeToLive {
+            self.ttl
+        }
+
+        pub const fn round_start(&self) -> SystemTime {
+            self.round_start
+        }
+
+        pub const fn target_found(&self) -> bool {
+            self.target_found
+        }
+
+        pub const fn max_received_ttl(&self) -> Option<TimeToLive> {
+            self.max_received_ttl
+        }
+
+        pub const fn target_ttl(&self) -> Option<TimeToLive> {
+            self.target_ttl
+        }
+
+        pub const fn received_time(&self) -> Option<SystemTime> {
+            self.received_time
+        }
+
+        /// Is `sequence` in the current round?
+        pub fn in_round(&self, sequence: Sequence) -> bool {
+            sequence >= self.round_sequence && sequence.0 - self.round_sequence.0 < BUFFER_SIZE
+        }
+
+        /// Do we have capacity in the current round for another sequence?
+        pub fn round_has_capacity(&self) -> bool {
+            let round_size = self.sequence - self.round_sequence;
+            round_size.0 < BUFFER_SIZE
+        }
+
+        /// Are all rounds complete?
+        pub fn finished(&self, max_rounds: Option<MaxRounds>) -> bool {
+            match max_rounds {
+                None => false,
+                Some(max_rounds) => self.round.0 > max_rounds.0.get() - 1,
+            }
+        }
+
+        /// Create and return the next `Probe` at the current `sequence` and `ttl`.
+        ///
+        /// We post-increment `ttl` here and so in practice we only allow `ttl` values in the range
+        /// `1..254` to allow us to use a `u8`.
+        #[instrument(skip(self))]
+        pub fn next_probe(&mut self, sent: SystemTime) -> Probe {
+            let (src_port, dest_port, identifier, flags) = self.probe_data();
+            let probe = Probe::new(
+                self.sequence,
+                identifier,
+                src_port,
+                dest_port,
+                self.ttl,
+                self.round,
+                sent,
+                flags,
+            );
+            let probe_index = usize::from(self.sequence - self.round_sequence);
+            self.buffer[probe_index] = ProbeState::Awaited(probe.clone());
+            debug_assert!(self.ttl < TimeToLive(u8::MAX));
+            self.ttl += TimeToLive(1);
+            debug_assert!(self.sequence < Sequence(u16::MAX));
+            self.sequence += Sequence(1);
+            probe
+        }
+
+        /// Re-issue the `Probe` with the next sequence number.
+        ///
+        /// This will mark the `ProbeState` at the previous `sequence` as skipped and re-create it
+        /// with the previous `ttl` and the current `sequence`.
+        ///
+        /// For example, if the sequence is `4` and the `ttl` is `5` prior to calling this method
+        /// then afterward:
+        /// - The `ProbeState` at sequence `3` will be set to `Skipped` state
+        /// - A new `ProbeState` will be created at sequence `4` with a `ttl` of `5`
+        #[instrument(skip(self))]
+        pub fn reissue_probe(&mut self, sent: SystemTime) -> Probe {
+            let probe_index = usize::from(self.sequence - self.round_sequence);
+            self.buffer[probe_index - 1] = ProbeState::Skipped;
+            let (src_port, dest_port, identifier, flags) = self.probe_data();
+            let probe = Probe::new(
+                self.sequence,
+                identifier,
+                src_port,
+                dest_port,
+                self.ttl - TimeToLive(1),
+                self.round,
+                sent,
+                flags,
+            );
+            self.buffer[probe_index] = ProbeState::Awaited(probe.clone());
+            debug_assert!(self.sequence < Sequence(u16::MAX));
+            self.sequence += Sequence(1);
+            probe
+        }
+
+        /// Determine the `src_port`, `dest_port` and `identifier` for the current probe.
+        ///
+        /// This will differ depending on the `TracerProtocol`, `MultipathStrategy` &
+        /// `PortDirection`.
+        fn probe_data(&self) -> (Port, Port, TraceId, Flags) {
+            match self.config.protocol {
+                Protocol::Icmp => self.probe_icmp_data(),
+                Protocol::Udp => self.probe_udp_data(),
+                Protocol::Tcp => self.probe_tcp_data(),
+            }
+        }
+
+        /// Determine the `src_port`, `dest_port` and `identifier` for the current ICMP probe.
+        fn probe_icmp_data(&self) -> (Port, Port, TraceId, Flags) {
+            (
+                Port(0),
+                Port(0),
+                self.config.trace_identifier,
+                Flags::empty(),
+            )
+        }
+
+        /// Determine the `src_port`, `dest_port` and `identifier` for the current UDP probe.
+        fn probe_udp_data(&self) -> (Port, Port, TraceId, Flags) {
+            match self.config.multipath_strategy {
+                MultipathStrategy::Classic => match self.config.port_direction {
+                    PortDirection::FixedSrc(src_port) => (
+                        Port(src_port.0),
+                        Port(self.sequence.0),
+                        TraceId(0),
+                        Flags::empty(),
+                    ),
+                    PortDirection::FixedDest(dest_port) => (
+                        Port(self.sequence.0),
+                        Port(dest_port.0),
+                        TraceId(0),
+                        Flags::empty(),
+                    ),
+                    PortDirection::FixedBoth(_, _) | PortDirection::None => {
+                        unimplemented!()
+                    }
+                },
+                MultipathStrategy::Paris => {
+                    let round_port = ((self.config.initial_sequence.0 as usize + self.round.0)
+                        % usize::from(u16::MAX)) as u16;
+                    match self.config.port_direction {
+                        PortDirection::FixedSrc(src_port) => (
+                            Port(src_port.0),
+                            Port(round_port),
+                            TraceId(0),
+                            Flags::PARIS_CHECKSUM,
+                        ),
+                        PortDirection::FixedDest(dest_port) => (
+                            Port(round_port),
+                            Port(dest_port.0),
+                            TraceId(0),
+                            Flags::PARIS_CHECKSUM,
+                        ),
+                        PortDirection::FixedBoth(src_port, dest_port) => (
+                            Port(src_port.0),
+                            Port(dest_port.0),
+                            TraceId(0),
+                            Flags::PARIS_CHECKSUM,
+                        ),
+                        PortDirection::None => unimplemented!(),
+                    }
+                }
+                MultipathStrategy::Dublin => {
+                    let round_port = ((self.config.initial_sequence.0 as usize + self.round.0)
+                        % usize::from(u16::MAX)) as u16;
+                    match self.config.port_direction {
+                        PortDirection::FixedSrc(src_port) => (
+                            Port(src_port.0),
+                            Port(round_port),
+                            TraceId(self.sequence.0),
+                            Flags::DUBLIN_IPV6_PAYLOAD_LENGTH,
+                        ),
+                        PortDirection::FixedDest(dest_port) => (
+                            Port(round_port),
+                            Port(dest_port.0),
+                            TraceId(self.sequence.0),
+                            Flags::DUBLIN_IPV6_PAYLOAD_LENGTH,
+                        ),
+                        PortDirection::FixedBoth(src_port, dest_port) => (
+                            Port(src_port.0),
+                            Port(dest_port.0),
+                            TraceId(self.sequence.0),
+                            Flags::DUBLIN_IPV6_PAYLOAD_LENGTH,
+                        ),
+                        PortDirection::None => unimplemented!(),
+                    }
+                }
+            }
+        }
+
+        /// Determine the `src_port`, `dest_port` and `identifier` for the current TCP probe.
+        fn probe_tcp_data(&self) -> (Port, Port, TraceId, Flags) {
+            let (src_port, dest_port) = match self.config.port_direction {
+                PortDirection::FixedSrc(src_port) => (src_port.0, self.sequence.0),
+                PortDirection::FixedDest(dest_port) => (self.sequence.0, dest_port.0),
+                PortDirection::FixedBoth(_, _) | PortDirection::None => unimplemented!(),
+            };
+            (Port(src_port), Port(dest_port), TraceId(0), Flags::empty())
+        }
+
+        /// Mark the `ProbeState` at `sequence` completed as `TimeExceeded` and update the round
+        /// state.
+        #[instrument(skip(self))]
+        pub fn complete_probe_time_exceeded(
+            &mut self,
+            sequence: Sequence,
+            host: IpAddr,
+            received: SystemTime,
+            is_target: bool,
+            icmp_code: IcmpPacketCode,
+            extensions: Option<Extensions>,
+        ) {
+            self.complete_probe(
+                sequence,
+                IcmpPacketType::TimeExceeded(icmp_code),
+                host,
+                received,
+                is_target,
+                extensions,
+            );
+        }
+
+        /// Mark the `ProbeState` at `sequence` completed as `Unreachable` and update the round
+        /// state.
+        #[instrument(skip(self))]
+        pub fn complete_probe_unreachable(
+            &mut self,
+            sequence: Sequence,
+            host: IpAddr,
+            received: SystemTime,
+            icmp_code: IcmpPacketCode,
+            extensions: Option<Extensions>,
+        ) {
+            self.complete_probe(
+                sequence,
+                IcmpPacketType::Unreachable(icmp_code),
+                host,
+                received,
+                true,
+                extensions,
+            );
+        }
+
+        /// Mark the `ProbeState` at `sequence` completed as `EchoReply` and update the round state.
+        #[instrument(skip(self))]
+        pub fn complete_probe_echo_reply(
+            &mut self,
+            sequence: Sequence,
+            host: IpAddr,
+            received: SystemTime,
+            icmp_code: IcmpPacketCode,
+        ) {
+            self.complete_probe(
+                sequence,
+                IcmpPacketType::EchoReply(icmp_code),
+                host,
+                received,
+                true,
+                None,
+            );
+        }
+
+        /// Mark the `ProbeState` at `sequence` completed as `NotApplicable` and update the round
+        /// state.
+        #[instrument(skip(self))]
+        pub fn complete_probe_other(
+            &mut self,
+            sequence: Sequence,
+            host: IpAddr,
+            received: SystemTime,
+        ) {
+            self.complete_probe(
+                sequence,
+                IcmpPacketType::NotApplicable,
+                host,
+                received,
+                true,
+                None,
+            );
+        }
+
+        /// Update the state of a `ProbeState` and the trace.
+        ///
+        /// We want to update:
+        ///
+        /// - the `target_ttl` to be the time-to-live of the `ProbeState` request from the target
+        /// - the `max_received_ttl` we have observed this round
+        /// - the latest packet `received_time` in this round
+        /// - whether the target has been found in this round
+        ///
+        /// The ICMP replies may arrive out-of-order, and so we must be careful here to avoid
+        /// overwriting the state with stale values.  We may also receive multiple replies
+        /// from the target host with differing time-to-live values and so must ensure we
+        /// use the time-to-live with the lowest sequence number.
+        #[instrument(skip(self))]
+        fn complete_probe(
+            &mut self,
+            sequence: Sequence,
+            icmp_packet_type: IcmpPacketType,
+            host: IpAddr,
+            received: SystemTime,
+            is_target: bool,
+            extensions: Option<Extensions>,
+        ) {
+            // Retrieve and update the `ProbeState` at `sequence`.
+            let probe = self.probe_at(sequence);
+            let awaited = match probe {
+                ProbeState::Awaited(awaited) => awaited,
+                // there is a valid scenario for TCP where a probe is already
+                // `Complete`, see `test_tcp_dest_unreachable_and_refused`.
+                ProbeState::Complete(_) => {
+                    return;
+                }
+                _ => {
+                    debug_assert!(
+                        false,
+                        "completed probe was not in Awaited state (probe={probe:#?})"
+                    );
+                    return;
+                }
+            };
+            let completed = awaited.complete(host, received, icmp_packet_type, extensions);
+            let ttl = completed.ttl;
+            self.buffer[usize::from(sequence - self.round_sequence)] =
+                ProbeState::Complete(completed);
+
+            // If this `ProbeState` found the target then we set the `target_tll` if not already
+            // set, being careful to account for `Probes` being received out-of-order.
+            //
+            // If this `ProbeState` did not find the target but has a ttl that is greater or equal
+            // to the target ttl (if known) then we reset the target ttl to None.  This
+            // is to support Equal Cost Multi-path Routing (ECMP) cases where the number
+            // of hops to the target will vary over the lifetime of the trace.
+            self.target_ttl = if is_target {
+                match self.target_ttl {
+                    None => Some(ttl),
+                    Some(target_ttl) if ttl < target_ttl => Some(ttl),
+                    Some(target_ttl) => Some(target_ttl),
+                }
+            } else {
+                match self.target_ttl {
+                    Some(target_ttl) if ttl >= target_ttl => None,
+                    Some(target_ttl) => Some(target_ttl),
+                    None => None,
+                }
+            };
+
+            self.max_received_ttl = match self.max_received_ttl {
+                None => Some(ttl),
+                Some(max_received_ttl) => Some(max_received_ttl.max(ttl)),
+            };
+
+            self.received_time = Some(received);
+            self.target_found |= is_target;
+        }
+
+        /// Advance to the next round.
+        ///
+        /// If, during the rond which just completed, we went above the max sequence number then we
+        /// reset it here. We do this here to avoid having to deal with the sequence number
+        /// wrapping during a round, which is more problematic.
+        #[instrument(skip(self))]
+        pub fn advance_round(&mut self, first_ttl: TimeToLive) {
+            if self.sequence >= self.max_sequence() {
+                self.sequence = self.config.initial_sequence;
+            }
+            self.target_found = false;
+            self.round_sequence = self.sequence;
+            self.received_time = None;
+            self.round_start = SystemTime::now();
+            self.max_received_ttl = None;
+            self.round += Round(1);
+            self.ttl = first_ttl;
+        }
+
+        /// The maximum sequence number allowed.
+        ///
+        /// The Dublin multipath strategy for IPv6/udp encodes the sequence
+        /// number as the payload length and consequently the maximum sequence
+        /// number must be no larger than the maximum IPv6/udp payload size.
+        ///
+        /// It is also required that the range of possible sequence numbers is
+        /// _at least_ `BUFFER_SIZE` to ensure delayed responses from a prior
+        /// round are not incorrectly associated with later rounds (see
+        /// `in_round` function).
+        fn max_sequence(&self) -> Sequence {
+            match (self.config.multipath_strategy, self.config.target_addr) {
+                (MultipathStrategy::Dublin, IpAddr::V6(_)) => {
+                    self.config.initial_sequence + Sequence(BUFFER_SIZE)
+                }
+                _ => MAX_SEQUENCE,
+            }
+        }
+    }
+
+    #[cfg(test)]
+    mod tests {
+        use super::*;
+        use crate::probe::IcmpPacketType;
+        use crate::types::MaxInflight;
+        use rand::Rng;
+        use std::net::{IpAddr, Ipv4Addr};
+        use std::time::Duration;
+
+        #[allow(
+            clippy::cognitive_complexity,
+            clippy::too_many_lines,
+            clippy::bool_assert_comparison
+        )]
+        #[test]
+        fn test_state() {
+            let mut state = TracerState::new(cfg(Sequence(33000)));
+
+            // Validate the initial TracerState
+            assert_eq!(state.round, Round(0));
+            assert_eq!(state.sequence, Sequence(33000));
+            assert_eq!(state.round_sequence, Sequence(33000));
+            assert_eq!(state.ttl, TimeToLive(1));
+            assert_eq!(state.max_received_ttl, None);
+            assert_eq!(state.received_time, None);
+            assert_eq!(state.target_ttl, None);
+            assert_eq!(state.target_found, false);
+
+            // The initial state of the probe before sending
+            let prob_init = state.probe_at(Sequence(33000));
+            assert_eq!(ProbeState::NotSent, prob_init);
+
+            // Prepare probe 1 (round 0, sequence 33000, ttl 1) for sending
+            let sent_1 = SystemTime::now();
+            let probe_1 = state.next_probe(sent_1);
+            assert_eq!(probe_1.sequence, Sequence(33000));
+            assert_eq!(probe_1.ttl, TimeToLive(1));
+            assert_eq!(probe_1.round, Round(0));
+            assert_eq!(probe_1.sent, sent_1);
+
+            // Update the state of the probe 1 after receiving a TimeExceeded
+            let received_1 = SystemTime::now();
+            let host = IpAddr::V4(Ipv4Addr::LOCALHOST);
+            state.complete_probe_time_exceeded(
+                Sequence(33000),
+                host,
+                received_1,
+                false,
+                IcmpPacketCode(1),
+                None,
+            );
+
+            // Validate the state of the probe 1 after the update
+            let probe_1_fetch = state.probe_at(Sequence(33000)).try_into_complete().unwrap();
+            assert_eq!(probe_1_fetch.sequence, Sequence(33000));
+            assert_eq!(probe_1_fetch.ttl, TimeToLive(1));
+            assert_eq!(probe_1_fetch.round, Round(0));
+            assert_eq!(probe_1_fetch.received, received_1);
+            assert_eq!(probe_1_fetch.host, host);
+            assert_eq!(probe_1_fetch.sent, sent_1);
+            assert_eq!(
+                probe_1_fetch.icmp_packet_type,
+                IcmpPacketType::TimeExceeded(IcmpPacketCode(1))
+            );
+
+            // Validate the TracerState after the update
+            assert_eq!(state.round, Round(0));
+            assert_eq!(state.sequence, Sequence(33001));
+            assert_eq!(state.round_sequence, Sequence(33000));
+            assert_eq!(state.ttl, TimeToLive(2));
+            assert_eq!(state.max_received_ttl, Some(TimeToLive(1)));
+            assert_eq!(state.received_time, Some(received_1));
+            assert_eq!(state.target_ttl, None);
+            assert_eq!(state.target_found, false);
+
+            // Validate the probes() iterator returns only a single probe
+            {
+                let mut probe_iter = state.probes().iter();
+                let probe_next1 = probe_iter.next().unwrap();
+                assert_eq!(ProbeState::Complete(probe_1_fetch), probe_next1.clone());
+                assert_eq!(None, probe_iter.next());
+            }
+
+            // Advance to the next round
+            state.advance_round(TimeToLive(1));
+
+            // Validate the TracerState after the round update
+            assert_eq!(state.round, Round(1));
+            assert_eq!(state.sequence, Sequence(33001));
+            assert_eq!(state.round_sequence, Sequence(33001));
+            assert_eq!(state.ttl, TimeToLive(1));
+            assert_eq!(state.max_received_ttl, None);
+            assert_eq!(state.received_time, None);
+            assert_eq!(state.target_ttl, None);
+            assert_eq!(state.target_found, false);
+
+            // Prepare probe 2 (round 1, sequence 33001, ttl 1) for sending
+            let sent_2 = SystemTime::now();
+            let probe_2 = state.next_probe(sent_2);
+            assert_eq!(probe_2.sequence, Sequence(33001));
+            assert_eq!(probe_2.ttl, TimeToLive(1));
+            assert_eq!(probe_2.round, Round(1));
+            assert_eq!(probe_2.sent, sent_2);
+
+            // Prepare probe 3 (round 1, sequence 33002, ttl 2) for sending
+            let sent_3 = SystemTime::now();
+            let probe_3 = state.next_probe(sent_3);
+            assert_eq!(probe_3.sequence, Sequence(33002));
+            assert_eq!(probe_3.ttl, TimeToLive(2));
+            assert_eq!(probe_3.round, Round(1));
+            assert_eq!(probe_3.sent, sent_3);
+
+            // Update the state of probe 2 after receiving a TimeExceeded
+            let received_2 = SystemTime::now();
+            let host = IpAddr::V4(Ipv4Addr::LOCALHOST);
+            state.complete_probe_time_exceeded(
+                Sequence(33001),
+                host,
+                received_2,
+                false,
+                IcmpPacketCode(1),
+                None,
+            );
+            let probe_2_recv = state.probe_at(Sequence(33001));
+
+            // Validate the TracerState after the update to probe 2
+            assert_eq!(state.round, Round(1));
+            assert_eq!(state.sequence, Sequence(33003));
+            assert_eq!(state.round_sequence, Sequence(33001));
+            assert_eq!(state.ttl, TimeToLive(3));
+            assert_eq!(state.max_received_ttl, Some(TimeToLive(1)));
+            assert_eq!(state.received_time, Some(received_2));
+            assert_eq!(state.target_ttl, None);
+            assert_eq!(state.target_found, false);
+
+            // Validate the probes() iterator returns the two probes in the states we expect
+            {
+                let mut probe_iter = state.probes().iter();
+                let probe_next1 = probe_iter.next().unwrap();
+                assert_eq!(&probe_2_recv, probe_next1);
+                let probe_next2 = probe_iter.next().unwrap();
+                assert_eq!(ProbeState::Awaited(probe_3), probe_next2.clone());
+            }
+
+            // Update the state of probe 3 after receiving a EchoReply
+            let received_3 = SystemTime::now();
+            let host = IpAddr::V4(Ipv4Addr::LOCALHOST);
+            state.complete_probe_echo_reply(Sequence(33002), host, received_3, IcmpPacketCode(0));
+            let probe_3_recv = state.probe_at(Sequence(33002));
+
+            // Validate the TracerState after the update to probe 3
+            assert_eq!(state.round, Round(1));
+            assert_eq!(state.sequence, Sequence(33003));
+            assert_eq!(state.round_sequence, Sequence(33001));
+            assert_eq!(state.ttl, TimeToLive(3));
+            assert_eq!(state.max_received_ttl, Some(TimeToLive(2)));
+            assert_eq!(state.received_time, Some(received_3));
+            assert_eq!(state.target_ttl, Some(TimeToLive(2)));
+            assert_eq!(state.target_found, true);
+
+            // Validate the probes() iterator returns the two probes in the states we expect
+            {
+                let mut probe_iter = state.probes().iter();
+                let probe_next1 = probe_iter.next().unwrap();
+                assert_eq!(&probe_2_recv, probe_next1);
+                let probe_next2 = probe_iter.next().unwrap();
+                assert_eq!(&probe_3_recv, probe_next2);
+            }
+        }
+
+        #[test]
+        fn test_sequence_wrap1() {
+            // Start from MAX_SEQUENCE - 1 which is (65279 - 1) == 65278
+            let initial_sequence = Sequence(65278);
+            let mut state = TracerState::new(cfg(initial_sequence));
+            assert_eq!(state.round, Round(0));
+            assert_eq!(state.sequence, initial_sequence);
+            assert_eq!(state.round_sequence, initial_sequence);
+
+            // Create a probe at seq 65278
+            assert_eq!(
+                state.next_probe(SystemTime::now()).sequence,
+                Sequence(65278)
+            );
+            assert_eq!(state.sequence, Sequence(65279));
+
+            // Validate the probes()
+            {
+                let mut iter = state.probes().iter();
+                assert_eq!(
+                    iter.next()
+                        .unwrap()
+                        .clone()
+                        .try_into_awaited()
+                        .unwrap()
+                        .sequence,
+                    Sequence(65278)
+                );
+                iter.take(BUFFER_SIZE as usize - 1)
+                    .for_each(|p| assert!(matches!(p, ProbeState::NotSent)));
+            }
+
+            // Advance the round, which will wrap the sequence back to initial_sequence
+            state.advance_round(TimeToLive(1));
+            assert_eq!(state.round, Round(1));
+            assert_eq!(state.sequence, initial_sequence);
+            assert_eq!(state.round_sequence, initial_sequence);
+
+            // Create a probe at seq 65278
+            assert_eq!(
+                state.next_probe(SystemTime::now()).sequence,
+                Sequence(65278)
+            );
+            assert_eq!(state.sequence, Sequence(65279));
+
+            // Validate the probes() again
+            {
+                let mut iter = state.probes().iter();
+                assert_eq!(
+                    iter.next()
+                        .unwrap()
+                        .clone()
+                        .try_into_awaited()
+                        .unwrap()
+                        .sequence,
+                    Sequence(65278)
+                );
+                iter.take(BUFFER_SIZE as usize - 1)
+                    .for_each(|p| assert!(matches!(p, ProbeState::NotSent)));
+            }
+        }
+
+        #[test]
+        fn test_sequence_wrap2() {
+            let total_rounds = 2000;
+            let max_probe_per_round = 254;
+            let mut state = TracerState::new(cfg(Sequence(33000)));
+            for _ in 0..total_rounds {
+                for _ in 0..max_probe_per_round {
+                    let _probe = state.next_probe(SystemTime::now());
+                }
+                state.advance_round(TimeToLive(1));
+            }
+            assert_eq!(state.round, Round(2000));
+            assert_eq!(state.round_sequence, Sequence(57130));
+            assert_eq!(state.sequence, Sequence(57130));
+        }
+
+        #[test]
+        fn test_sequence_wrap3() {
+            let total_rounds = 2000;
+            let max_probe_per_round = 20;
+            let mut state = TracerState::new(cfg(Sequence(33000)));
+            let mut rng = rand::thread_rng();
+            for _ in 0..total_rounds {
+                for _ in 0..rng.gen_range(0..max_probe_per_round) {
+                    state.next_probe(SystemTime::now());
+                }
+                state.advance_round(TimeToLive(1));
+            }
+        }
+
+        #[test]
+        fn test_sequence_wrap_with_skip() {
+            let total_rounds = 2000;
+            let max_probe_per_round = 254;
+            let mut state = TracerState::new(cfg(Sequence(33000)));
+            for _ in 0..total_rounds {
+                for _ in 0..max_probe_per_round {
+                    _ = state.next_probe(SystemTime::now());
+                    _ = state.reissue_probe(SystemTime::now());
+                }
+                state.advance_round(TimeToLive(1));
+            }
+            assert_eq!(state.round, Round(2000));
+            assert_eq!(state.round_sequence, Sequence(41128));
+            assert_eq!(state.sequence, Sequence(41128));
+        }
+
+        #[test]
+        fn test_in_round() {
+            let state = TracerState::new(cfg(Sequence(33000)));
+            assert!(state.in_round(Sequence(33000)));
+            assert!(state.in_round(Sequence(33511)));
+            assert!(!state.in_round(Sequence(33512)));
+        }
+
+        #[test]
+        #[should_panic(expected = "assertion failed: !state.in_round(Sequence(64491))")]
+        fn test_in_delayed_probe_not_in_round() {
+            let mut state = TracerState::new(cfg(Sequence(64000)));
+            for _ in 0..55 {
+                _ = state.next_probe(SystemTime::now());
+            }
+            state.advance_round(TimeToLive(1));
+            assert!(!state.in_round(Sequence(64491)));
+        }
+
+        fn cfg(initial_sequence: Sequence) -> StrategyConfig {
+            StrategyConfig {
+                target_addr: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
+                protocol: Protocol::Icmp,
+                trace_identifier: TraceId::default(),
+                max_rounds: None,
+                first_ttl: TimeToLive(1),
+                max_ttl: TimeToLive(24),
+                grace_duration: Duration::default(),
+                max_inflight: MaxInflight::default(),
+                initial_sequence,
+                multipath_strategy: MultipathStrategy::Classic,
+                port_direction: PortDirection::None,
+                min_round_duration: Duration::default(),
+                max_round_duration: Duration::default(),
+            }
+        }
+    }
+}
+
+/// Returns true if the duration between start and end is grater than a duration, false otherwise.
+fn exceeds(start: Option<SystemTime>, end: SystemTime, dur: Duration) -> bool {
+    start.map_or(false, |start| {
+        end.duration_since(start).unwrap_or_default() > dur
+    })
+}
diff --git a/crates/trippy-core/src/tracer.rs b/crates/trippy-core/src/tracer.rs
index 2d6643920..b92199aab 100644
--- a/crates/trippy-core/src/tracer.rs
+++ b/crates/trippy-core/src/tracer.rs
@@ -1,1230 +1,709 @@
-use self::state::TracerState;
-use crate::error::{TraceResult, TracerError};
-use crate::net::Network;
-use crate::probe::{
-    ProbeResponse, ProbeResponseData, ProbeResponseSeq, ProbeResponseSeqIcmp, ProbeResponseSeqTcp,
-    ProbeResponseSeqUdp, ProbeState,
+use crate::error::TraceResult;
+use crate::{
+    IcmpExtensionParseMode, MaxInflight, MaxRounds, MultipathStrategy, PacketSize, PayloadPattern,
+    PortDirection, PrivilegeMode, Protocol, Sequence, TimeToLive, TraceId, TraceState, TracerError,
+    TracerRound, TypeOfService,
 };
-use crate::types::{Sequence, TimeToLive, TraceId};
-use crate::Config;
-use crate::{MultipathStrategy, PortDirection, Protocol};
+use std::fmt::Debug;
 use std::net::IpAddr;
-use std::time::{Duration, SystemTime};
-use tracing::instrument;
+use std::sync::Arc;
+use std::thread;
+use std::thread::JoinHandle;
+use std::time::Duration;
 
-/// The output from a round of tracing.
+/// A traceroute implementation.
+///
+/// See the [`crate`] documentation for more information.
+///
+/// Note that this is type cheaply cloneable.
 #[derive(Debug, Clone)]
-pub struct TracerRound<'a> {
-    /// The state of all `ProbeState` that were sent in the round.
-    pub probes: &'a [ProbeState],
-    /// The largest time-to-live (ttl) for which we received a reply in the round.
-    pub largest_ttl: TimeToLive,
-    /// Indicates what triggered the completion of the tracing round.
-    pub reason: CompletionReason,
+pub struct Tracer {
+    inner: Arc<inner::TracerInner>,
 }
 
-impl<'a> TracerRound<'a> {
+impl Tracer {
+    /// Create a `Tracer`.
+    ///
+    /// Use the [`crate::Builder`] type to create a [`Tracer`].
+    #[allow(clippy::too_many_arguments)]
     #[must_use]
-    pub fn new(
-        probes: &'a [ProbeState],
-        largest_ttl: TimeToLive,
-        reason: CompletionReason,
+    pub(crate) fn new(
+        interface: Option<String>,
+        source_addr: Option<IpAddr>,
+        target_addr: IpAddr,
+        privilege_mode: PrivilegeMode,
+        protocol: Protocol,
+        packet_size: PacketSize,
+        payload_pattern: PayloadPattern,
+        tos: TypeOfService,
+        icmp_extension_parse_mode: IcmpExtensionParseMode,
+        read_timeout: Duration,
+        tcp_connect_timeout: Duration,
+        trace_identifier: TraceId,
+        max_rounds: Option<MaxRounds>,
+        first_ttl: TimeToLive,
+        max_ttl: TimeToLive,
+        grace_duration: Duration,
+        max_inflight: MaxInflight,
+        initial_sequence: Sequence,
+        multipath_strategy: MultipathStrategy,
+        port_direction: PortDirection,
+        min_round_duration: Duration,
+        max_round_duration: Duration,
+        max_samples: usize,
+        max_flows: usize,
+        drop_privileges: bool,
     ) -> Self {
         Self {
-            probes,
-            largest_ttl,
-            reason,
-        }
-    }
-}
-
-/// Indicates what triggered the completion of the tracing round.
-#[derive(Debug, Copy, Clone, Eq, PartialEq)]
-pub enum CompletionReason {
-    /// The round ended because the target was found.
-    TargetFound,
-    /// The round ended because the time exceeded the configured maximum round time.
-    RoundTimeLimitExceeded,
-}
-
-/// Trace a path to a target.
-#[derive(Debug, Clone)]
-pub struct Tracer<F> {
-    config: Config,
-    publish: F,
-}
-
-impl<F: Fn(&TracerRound<'_>)> Tracer<F> {
-    #[instrument(skip_all)]
-    pub fn new(config: &Config, publish: F) -> Self {
-        tracing::debug!(?config);
-        Self {
-            config: *config,
-            publish,
+            inner: Arc::new(inner::TracerInner::new(
+                interface,
+                source_addr,
+                target_addr,
+                privilege_mode,
+                protocol,
+                packet_size,
+                payload_pattern,
+                tos,
+                icmp_extension_parse_mode,
+                read_timeout,
+                tcp_connect_timeout,
+                trace_identifier,
+                max_rounds,
+                first_ttl,
+                max_ttl,
+                grace_duration,
+                max_inflight,
+                initial_sequence,
+                multipath_strategy,
+                port_direction,
+                min_round_duration,
+                max_round_duration,
+                max_samples,
+                max_flows,
+                drop_privileges,
+            )),
         }
     }
 
-    /// Run a continuous trace and publish results.
-    #[instrument(skip(self, network))]
-    pub fn trace<N: Network>(self, mut network: N) -> TraceResult<()> {
-        let mut state = TracerState::new(self.config);
-        while !state.finished(self.config.max_rounds) {
-            self.send_request(&mut network, &mut state)?;
-            self.recv_response(&mut network, &mut state)?;
-            self.update_round(&mut state);
-        }
-        Ok(())
+    /// Run the [`Tracer`].
+    ///
+    /// This method will block until either the trace completes all rounds (if
+    /// [`crate::Builder::max_rounds`] has been called to set to a non-zero
+    /// value) or until the trace fails.
+    ///
+    /// At the completion of the trace, the state of the tracer can be
+    /// retrieved using the [`Tracer::snapshot`] method.
+    ///
+    /// If you want to run the tracer indefinitely (by not setting
+    /// [`crate::Builder::max_rounds`]), you can either clone and run the
+    /// tracer on a separate thread by using the [`Tracer::spawn`] method or
+    /// by use the [`Tracer::run_with`] method in the current thread to gather
+    /// pee round state manually.
+    ///
+    /// # Example
+    ///
+    /// The following will run the tracer for a fixed number (3) of rounds and
+    /// then retrieve the final state snapshot:
+    ///
+    /// ```no_run
+    /// # fn main() -> anyhow::Result<()> {
+    /// # use std::net::IpAddr;
+    /// # use std::str::FromStr;
+    /// use trippy_core::Builder;
+    ///
+    /// let addr = IpAddr::from_str("1.1.1.1")?;
+    /// let tracer = Builder::new(addr).max_rounds(Some(3)).build()?;
+    /// tracer.run()?;
+    /// let _state = tracer.snapshot();
+    /// # Ok(())
+    /// # }
+    /// ```
+    ///
+    /// # See Also
+    ///
+    /// - [`Tracer::run_with`] - Run the tracer with a custom round handler.
+    /// - [`Tracer::spawn`] - Spawn the tracer on a new thread without a
+    /// custom round handler.
+    pub fn run(&self) -> TraceResult<()> {
+        self.inner.run()
     }
 
-    /// Send the next probe if required.
+    /// Run the [`Tracer`] with a custom round handler.
     ///
-    /// Send a `ProbeState` for the next time-to-live (ttl) if all the following are true:
+    /// This method will block until either the trace completes all rounds (if
+    /// [`crate::Builder::max_rounds`] has been called to set to a non-zero
+    /// value) or until the trace fails.
     ///
-    /// 1 - the target host has not been found
-    /// 2 - the next ttl is not greater than the maximum allowed ttl
-    /// 3 - if the target ttl of the target is known:
-    ///       - the next ttl is not greater than the ttl of the target host observed from the prior
-    ///         round
-    ///     otherwise:
-    ///       - the number of unknown-in-flight probes is lower than the maximum allowed
-    #[instrument(skip(self, network, st))]
-    fn send_request<N: Network>(&self, network: &mut N, st: &mut TracerState) -> TraceResult<()> {
-        let can_send_ttl = if let Some(target_ttl) = st.target_ttl() {
-            st.ttl() <= target_ttl
-        } else {
-            st.ttl() - st.max_received_ttl().unwrap_or_default()
-                < TimeToLive(self.config.max_inflight.0)
-        };
-        if !st.target_found() && st.ttl() <= self.config.max_ttl && can_send_ttl {
-            let sent = SystemTime::now();
-            match self.config.protocol {
-                Protocol::Icmp => {
-                    network.send_probe(st.next_probe(sent))?;
-                }
-                Protocol::Udp => network.send_probe(st.next_probe(sent))?,
-                Protocol::Tcp => {
-                    let mut probe = if st.round_has_capacity() {
-                        st.next_probe(sent)
-                    } else {
-                        return Err(TracerError::InsufficientCapacity);
-                    };
-                    while let Err(err) = network.send_probe(probe) {
-                        match err {
-                            TracerError::AddressNotAvailable(_) => {
-                                if st.round_has_capacity() {
-                                    probe = st.reissue_probe(SystemTime::now());
-                                } else {
-                                    return Err(TracerError::InsufficientCapacity);
-                                }
-                            }
-                            other => return Err(other),
-                        }
-                    }
-                }
-            };
-        }
-        Ok(())
+    /// At the completion of the trace, the state of the tracer can be
+    /// retrieved using the [`Tracer::snapshot`] method.
+    ///
+    /// This method will additionally call the provided function for each round
+    /// that is completed.  This can be useful if you want to gather round state
+    /// manually if the tracer is run indefinitely (by not setting
+    /// [`crate::Builder::max_rounds`])
+    ///
+    /// # Example
+    ///
+    /// The following will run the tracer indefinitely and print the data from
+    /// each round of tracing:
+    ///
+    /// ```no_run
+    /// # fn main() -> anyhow::Result<()> {
+    /// # use std::net::IpAddr;
+    /// # use std::str::FromStr;
+    /// use trippy_core::Builder;
+    ///
+    /// let addr = IpAddr::from_str("1.1.1.1")?;
+    /// let tracer = Builder::new(addr).build()?;
+    /// tracer.run_with(|round| println!("{:?}", round))?;
+    /// # Ok(())
+    /// # }
+    /// ```
+    ///
+    /// # See Also
+    ///
+    /// - [`Tracer::run`] - Run the tracer without a custom round handler.
+    pub fn run_with<F: Fn(&TracerRound<'_>)>(&self, func: F) -> TraceResult<()> {
+        self.inner.run_with(func)
     }
 
-    /// Read and process the next incoming `ICMP` packet.
+    /// Spawn the tracer on a new thread.
     ///
-    /// We allow multiple probes to be in-flight at any time, and we cannot guarantee that responses
-    /// will be received in-order.  We therefore maintain a buffer which holds details of each
-    /// `ProbeState` which is indexed by the offset of the sequence number from the sequence number
-    /// at the beginning of the round.  The sequence number is set in the outgoing `ICMP`
-    /// `EchoRequest` (or `UDP` / `TCP`) packet and returned in both the `TimeExceeded` and
-    /// `EchoReply` responses.
+    /// This method will spawn a new thread to run the tracer and immediately
+    /// return the [`Tracer`] and a handle to the thread, so it may be joined
+    /// with [`JoinHandle::join`].
     ///
-    /// Each incoming `ICMP` packet contains the original `ICMP` `EchoRequest` packet from which we
-    /// can read the `identifier` that we set which we can now validate to ensure we only
-    /// process responses which correspond to packets sent from this process.  For The `UDP` and
-    /// `TCP` protocols, only packets destined for our src port will be delivered to us by the
-    /// OS and so no other `identifier` is needed, and so we allow the special case value of 0.
+    /// If you want to run the tracer indefinitely (by not setting
+    /// [`crate::Builder::max_rounds`]) you can use this method to spawn the
+    /// tracer on a new thread and return the [`Tracer`] such that a
+    /// [`Tracer::snapshot`] of the state can be taken at any time.
     ///
-    /// When we process an `EchoReply` from the target host we extract the time-to-live from the
-    /// corresponding original `EchoRequest`.  Note that this may not be the greatest
-    /// time-to-live that was sent in the round as the algorithm will send `EchoRequest` with
-    /// larger time-to-live values before the `EchoReply` is received.
-    #[instrument(skip(self, network, st))]
-    fn recv_response<N: Network>(&self, network: &mut N, st: &mut TracerState) -> TraceResult<()> {
-        let next = network.recv_probe()?;
-        match next {
-            Some(ProbeResponse::TimeExceeded(data, icmp_code, extensions)) => {
-                let (trace_id, sequence, received, host) = self.extract(&data);
-                let is_target = host == self.config.target_addr;
-                if self.check_trace_id(trace_id) && st.in_round(sequence) && self.validate(&data) {
-                    st.complete_probe_time_exceeded(
-                        sequence, host, received, is_target, icmp_code, extensions,
-                    );
-                }
-            }
-            Some(ProbeResponse::DestinationUnreachable(data, icmp_code, extensions)) => {
-                let (trace_id, sequence, received, host) = self.extract(&data);
-                if self.check_trace_id(trace_id) && st.in_round(sequence) && self.validate(&data) {
-                    st.complete_probe_unreachable(sequence, host, received, icmp_code, extensions);
-                }
-            }
-            Some(ProbeResponse::EchoReply(data, icmp_code)) => {
-                let (trace_id, sequence, received, host) = self.extract(&data);
-                if self.check_trace_id(trace_id) && st.in_round(sequence) && self.validate(&data) {
-                    st.complete_probe_echo_reply(sequence, host, received, icmp_code);
-                }
-            }
-            Some(ProbeResponse::TcpReply(data) | ProbeResponse::TcpRefused(data)) => {
-                let (trace_id, sequence, received, host) = self.extract(&data);
-                if self.check_trace_id(trace_id) && st.in_round(sequence) && self.validate(&data) {
-                    st.complete_probe_other(sequence, host, received);
-                }
-            }
-            None => {}
-        }
-        Ok(())
+    /// # Example
+    ///
+    /// The following will spawn a tracer on a new thread and take a snapshot
+    /// of the state every 5 seconds:
+    ///
+    /// ```no_run
+    /// # fn main() -> anyhow::Result<()> {
+    /// # use std::net::IpAddr;
+    /// # use std::str::FromStr;
+    /// # use std::thread;
+    /// # use std::time::Duration;
+    /// use trippy_core::Builder;
+    ///
+    /// let addr = IpAddr::from_str("1.1.1.1")?;
+    /// let (tracer, _) = Builder::new(addr).build()?.spawn()?;
+    /// loop {
+    ///     thread::sleep(Duration::from_secs(5));
+    ///     // get the latest state.
+    ///     let _state = tracer.snapshot();
+    /// }
+    /// # Ok(())
+    /// # }
+    /// ```
+    ///
+    /// # See Also
+    ///
+    /// - [`Tracer::run`] - Run the tracer on the current thread.
+    pub fn spawn(self) -> TraceResult<(Self, JoinHandle<TraceResult<()>>)> {
+        let tracer = self.clone();
+        let handle = thread::Builder::new()
+            .name(format!("tracer-{}", self.trace_identifier().0))
+            .spawn(move || tracer.run())
+            .map_err(|err| TracerError::Other(err.to_string()))?;
+        Ok((self, handle))
     }
 
-    /// Check if the round is complete and publish the results.
+    /// Spawn the tracer with a custom round handler on a new thread.
     ///
-    /// A round is considered to be complete when:
+    /// This method will spawn a new thread to run the tracer with a custom
+    /// round handler and immediately return the [`Tracer`] and a handle to the
+    /// thread, so it may be joined with [`JoinHandle::join`].
     ///
-    /// 1 - the round has exceeded the minimum round duration AND
-    /// 2 - the duration since the last packet was received exceeds the grace period AND
-    /// 3 - either:
-    ///     A - the target has been found OR
-    ///     B - the target has not been found and the round has exceeded the maximum round duration
-    #[instrument(skip(self, st))]
-    fn update_round(&self, st: &mut TracerState) {
-        let now = SystemTime::now();
-        let round_duration = now.duration_since(st.round_start()).unwrap_or_default();
-        let round_min = round_duration > self.config.min_round_duration;
-        let grace_exceeded = exceeds(st.received_time(), now, self.config.grace_duration);
-        let round_max = round_duration > self.config.max_round_duration;
-        let target_found = st.target_found();
-        if round_min && grace_exceeded && target_found || round_max {
-            self.publish_trace(st);
-            st.advance_round(self.config.first_ttl);
-        }
+    /// # Example
+    ///
+    /// The following will spawn a tracer on a new thread with a custom round
+    /// handler to print the data from each round of tracing and also take a
+    /// snapshot of the state every 5 seconds until the tracer completes all
+    /// rounds:
+    ///
+    /// ```no_run
+    /// # fn main() -> anyhow::Result<()> {
+    /// # use std::net::IpAddr;
+    /// # use std::str::FromStr;
+    /// # use std::thread;
+    /// # use std::time::Duration;
+    /// use trippy_core::Builder;
+    ///
+    /// let addr = IpAddr::from_str("1.1.1.1")?;
+    /// let (tracer, handle) = Builder::new(addr)
+    ///     .max_rounds(Some(3))
+    ///     .build()?
+    ///     .spawn_with(|round| println!("{:?}", round))?;
+    /// for i in 0..3 {
+    ///     thread::sleep(Duration::from_secs(5));
+    ///     // get the latest state.
+    ///     let _state = tracer.snapshot();
+    /// }
+    /// handle.join().unwrap()?;
+    /// # Ok(())
+    /// # }
+    /// ```
+    ///
+    /// # See Also
+    ///
+    /// - [`Tracer::spawn`] - Spawn the tracer on a new thread without a
+    /// custom round handler.
+    pub fn spawn_with<F: Fn(&TracerRound<'_>) + Send + 'static>(
+        self,
+        func: F,
+    ) -> TraceResult<(Self, JoinHandle<TraceResult<()>>)> {
+        let tracer = self.clone();
+        let handle = thread::Builder::new()
+            .name(format!("tracer-{}", self.trace_identifier().0))
+            .spawn(move || tracer.run_with(func))
+            .map_err(|err| TracerError::Other(err.to_string()))?;
+        Ok((self, handle))
     }
 
-    /// Publish details of all `ProbeState` in the completed round.
-    ///
-    /// If the round completed without receiving an `EchoReply` from the target host then we also
-    /// publish the next `ProbeState` which is assumed to represent the TTL of the target host.
-    #[instrument(skip(self, state))]
-    fn publish_trace(&self, state: &TracerState) {
-        let max_received_ttl = if let Some(target_ttl) = state.target_ttl() {
-            target_ttl
-        } else {
-            state
-                .max_received_ttl()
-                .map_or(TimeToLive(0), |max_received_ttl| {
-                    let max_sent_ttl = state.ttl() - TimeToLive(1);
-                    max_sent_ttl.min(max_received_ttl + TimeToLive(1))
-                })
-        };
-        let probes = state.probes();
-        let largest_ttl = max_received_ttl;
-        let reason = if state.target_found() {
-            CompletionReason::TargetFound
-        } else {
-            CompletionReason::RoundTimeLimitExceeded
-        };
-        (self.publish)(&TracerRound::new(probes, largest_ttl, reason));
+    /// Take a snapshot of the tracer state.
+    #[must_use]
+    pub fn snapshot(&self) -> TraceState {
+        self.inner.snapshot()
     }
 
-    /// Check if the `TraceId` matches the expected value for this tracer.
-    ///
-    /// A special value of `0` is accepted for `udp` and `tcp` which do not have an identifier.
-    #[instrument(skip(self))]
-    fn check_trace_id(&self, trace_id: TraceId) -> bool {
-        self.config.trace_identifier == trace_id || trace_id == TraceId(0)
+    /// Clear the tracer state.
+    pub fn clear(&self) {
+        self.inner.clear();
     }
 
-    /// Validate the probe response data.
-    ///
-    /// Carries out specific check for UDP/TCP probe responses.  This is
-    /// required as the network layer may receive incoming ICMP
-    /// `DestinationUnreachable` (and other types) packets with a UDP/TCP
-    /// original datagram which does not correspond to a probe sent by the
-    /// tracer and must therefore be ignored.
-    ///
-    /// For UDP and TCP probe responses, check that the src/dest ports and
-    /// dest address match the expected values.
-    ///
-    /// For ICMP probe responses no additional checks are required.
-    fn validate(&self, resp: &ProbeResponseData) -> bool {
-        fn validate_ports(port_direction: PortDirection, src_port: u16, dest_port: u16) -> bool {
-            match port_direction {
-                PortDirection::FixedSrc(src) if src.0 == src_port => true,
-                PortDirection::FixedDest(dest) if dest.0 == dest_port => true,
-                PortDirection::FixedBoth(src, dest) if src.0 == src_port && dest.0 == dest_port => {
-                    true
-                }
-                _ => false,
-            }
-        }
-        match resp.resp_seq {
-            ProbeResponseSeq::Icmp(_) => true,
-            ProbeResponseSeq::Udp(ProbeResponseSeqUdp {
-                dest_addr,
-                src_port,
-                dest_port,
-                has_magic,
-                ..
-            }) => {
-                let check_ports = validate_ports(self.config.port_direction, src_port, dest_port);
-                let check_dest_addr = self.config.target_addr == dest_addr;
-                let check_magic = match (self.config.multipath_strategy, self.config.target_addr) {
-                    (MultipathStrategy::Dublin, IpAddr::V6(_)) => has_magic,
-                    _ => true,
-                };
-                check_dest_addr && check_ports && check_magic
-            }
-            ProbeResponseSeq::Tcp(ProbeResponseSeqTcp {
-                dest_addr,
-                src_port,
-                dest_port,
-            }) => {
-                let check_ports = validate_ports(self.config.port_direction, src_port, dest_port);
-                let check_dest_addr = self.config.target_addr == dest_addr;
-                check_dest_addr && check_ports
-            }
-        }
+    #[must_use]
+    pub fn max_flows(&self) -> usize {
+        self.inner.max_flows()
     }
 
-    /// Extract the `TraceId`, `Sequence`, `SystemTime` and `IpAddr` from the `ProbeResponseData` in
-    /// a protocol specific way.
-    #[instrument(skip(self))]
-    fn extract(&self, resp: &ProbeResponseData) -> (TraceId, Sequence, SystemTime, IpAddr) {
-        match resp.resp_seq {
-            ProbeResponseSeq::Icmp(ProbeResponseSeqIcmp {
-                identifier,
-                sequence,
-            }) => (
-                TraceId(identifier),
-                Sequence(sequence),
-                resp.recv,
-                resp.addr,
-            ),
-            ProbeResponseSeq::Udp(ProbeResponseSeqUdp {
-                identifier,
-                src_port,
-                dest_port,
-                checksum,
-                payload_len,
-                ..
-            }) => {
-                let sequence = match (
-                    self.config.multipath_strategy,
-                    self.config.port_direction,
-                    self.config.target_addr,
-                ) {
-                    (MultipathStrategy::Classic, PortDirection::FixedDest(_), _) => src_port,
-                    (MultipathStrategy::Classic, _, _) => dest_port,
-                    (MultipathStrategy::Paris, _, _) => checksum,
-                    (MultipathStrategy::Dublin, _, IpAddr::V4(_)) => identifier,
-                    (MultipathStrategy::Dublin, _, IpAddr::V6(_)) => {
-                        self.config.initial_sequence.0 + payload_len
-                    }
-                };
-                (TraceId(0), Sequence(sequence), resp.recv, resp.addr)
-            }
-            ProbeResponseSeq::Tcp(ProbeResponseSeqTcp {
-                src_port,
-                dest_port,
-                ..
-            }) => {
-                let sequence = match self.config.port_direction {
-                    PortDirection::FixedSrc(_) => dest_port,
-                    _ => src_port,
-                };
-                (TraceId(0), Sequence(sequence), resp.recv, resp.addr)
-            }
-        }
+    #[must_use]
+    pub fn max_samples(&self) -> usize {
+        self.inner.max_samples()
     }
-}
 
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::net::MockNetwork;
-    use crate::probe::IcmpPacketCode;
-    use crate::{MaxRounds, Port};
-    use std::net::Ipv4Addr;
-    use std::num::NonZeroUsize;
-
-    // The network can return both `DestinationUnreachable` and `TcpRefused`
-    // for the same sequence number.  This can occur for the target hop for
-    // TCP protocol as the network layer check for ICMP responses such as
-    // `DestinationUnreachable` and also synthesizes a `TcpRefused` response.
-    //
-    // This test simulates sending 1 TCP probe (seq=33000) and receiving two
-    // responses for that probe, a `DestinationUnreachable` followed by a
-    // `TcpRefused`.
-    #[test]
-    fn test_tcp_dest_unreachable_and_refused() -> anyhow::Result<()> {
-        let sequence = 33000;
-        let target_addr = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
-
-        let mut network = MockNetwork::new();
-        let mut seq = mockall::Sequence::new();
-        network.expect_send_probe().times(1).returning(|_| Ok(()));
-        network
-            .expect_recv_probe()
-            .times(1)
-            .in_sequence(&mut seq)
-            .returning(move || {
-                Ok(Some(ProbeResponse::DestinationUnreachable(
-                    ProbeResponseData::new(
-                        SystemTime::now(),
-                        target_addr,
-                        ProbeResponseSeq::Tcp(ProbeResponseSeqTcp::new(target_addr, sequence, 80)),
-                    ),
-                    IcmpPacketCode(1),
-                    None,
-                )))
-            });
-        network
-            .expect_recv_probe()
-            .times(1)
-            .in_sequence(&mut seq)
-            .returning(move || {
-                Ok(Some(ProbeResponse::TcpRefused(ProbeResponseData::new(
-                    SystemTime::now(),
-                    target_addr,
-                    ProbeResponseSeq::Tcp(ProbeResponseSeqTcp::new(target_addr, sequence, 80)),
-                ))))
-            });
+    #[must_use]
+    pub fn privilege_mode(&self) -> PrivilegeMode {
+        self.inner.privilege_mode()
+    }
 
-        let config = Config {
-            target_addr,
-            max_rounds: Some(MaxRounds(NonZeroUsize::MIN)),
-            initial_sequence: Sequence(sequence),
-            port_direction: PortDirection::FixedDest(Port(80)),
-            protocol: Protocol::Tcp,
-            ..Default::default()
-        };
-        let tracer = Tracer::new(&config, |_| {});
-        let mut state = TracerState::new(config);
-        tracer.send_request(&mut network, &mut state)?;
-        tracer.recv_response(&mut network, &mut state)?;
-        tracer.recv_response(&mut network, &mut state)?;
-        Ok(())
+    #[must_use]
+    pub fn protocol(&self) -> Protocol {
+        self.inner.protocol()
+    }
+
+    #[must_use]
+    pub fn interface(&self) -> Option<&str> {
+        self.inner.interface()
+    }
+
+    #[must_use]
+    pub fn source_addr(&self) -> Option<IpAddr> {
+        self.inner.source_addr()
+    }
+
+    #[must_use]
+    pub fn target_addr(&self) -> IpAddr {
+        self.inner.target_addr()
+    }
+
+    #[must_use]
+    pub fn packet_size(&self) -> PacketSize {
+        self.inner.packet_size()
+    }
+
+    #[must_use]
+    pub fn payload_pattern(&self) -> PayloadPattern {
+        self.inner.payload_pattern()
+    }
+
+    #[must_use]
+    pub fn initial_sequence(&self) -> Sequence {
+        self.inner.initial_sequence()
+    }
+
+    #[must_use]
+    pub fn tos(&self) -> TypeOfService {
+        self.inner.tos()
+    }
+
+    #[must_use]
+    pub fn icmp_extension_parse_mode(&self) -> IcmpExtensionParseMode {
+        self.inner.icmp_extension_parse_mode()
+    }
+
+    #[must_use]
+    pub fn read_timeout(&self) -> Duration {
+        self.inner.read_timeout()
+    }
+
+    #[must_use]
+    pub fn tcp_connect_timeout(&self) -> Duration {
+        self.inner.tcp_connect_timeout()
+    }
+
+    #[must_use]
+    pub fn trace_identifier(&self) -> TraceId {
+        self.inner.trace_identifier()
+    }
+
+    #[must_use]
+    pub fn max_rounds(&self) -> Option<MaxRounds> {
+        self.inner.max_rounds()
+    }
+
+    #[must_use]
+    pub fn first_ttl(&self) -> TimeToLive {
+        self.inner.first_ttl()
+    }
+
+    #[must_use]
+    pub fn max_ttl(&self) -> TimeToLive {
+        self.inner.max_ttl()
+    }
+
+    #[must_use]
+    pub fn grace_duration(&self) -> Duration {
+        self.inner.grace_duration()
+    }
+
+    #[must_use]
+    pub fn max_inflight(&self) -> MaxInflight {
+        self.inner.max_inflight()
+    }
+
+    #[must_use]
+    pub fn multipath_strategy(&self) -> MultipathStrategy {
+        self.inner.multipath_strategy()
+    }
+
+    #[must_use]
+    pub fn port_direction(&self) -> PortDirection {
+        self.inner.port_direction()
+    }
+
+    #[must_use]
+    pub fn min_round_duration(&self) -> Duration {
+        self.inner.min_round_duration()
+    }
+
+    #[must_use]
+    pub fn max_round_duration(&self) -> Duration {
+        self.inner.max_round_duration()
     }
 }
 
-/// Mutable state needed for the tracing algorithm.
-///
-/// This is contained within a submodule to ensure that mutations are only performed via methods on
-/// the `TracerState` struct.
-mod state {
-    use crate::constants::MAX_SEQUENCE_PER_ROUND;
-    use crate::probe::{Extensions, IcmpPacketCode, IcmpPacketType, Probe, ProbeState};
-    use crate::types::{MaxRounds, Port, Round, Sequence, TimeToLive, TraceId};
-    use crate::{Config, Flags, MultipathStrategy, PortDirection, Protocol};
-    use std::array::from_fn;
+mod inner {
+    use crate::config::{ChannelConfig, StateConfig, StrategyConfig};
+    use crate::error::TraceResult;
+    use crate::net::{PlatformImpl, SocketImpl};
+    use crate::{
+        IcmpExtensionParseMode, MaxInflight, MaxRounds, MultipathStrategy, PacketSize,
+        PayloadPattern, PortDirection, PrivilegeMode, Protocol, Sequence, SourceAddr, TimeToLive,
+        TraceId, TraceState, TracerChannel, TracerError, TracerRound, TracerStrategy,
+        TypeOfService,
+    };
+    use parking_lot::RwLock;
+    use std::fmt::Debug;
     use std::net::IpAddr;
-    use std::time::SystemTime;
+    use std::sync::OnceLock;
+    use std::time::Duration;
     use tracing::instrument;
+    use trippy_privilege::Privilege;
 
-    /// The maximum number of `ProbeState` entries in the buffer.
-    ///
-    /// This is larger than maximum number of time-to-live (TTL) we can support to allow for skipped
-    /// sequences.
-    const BUFFER_SIZE: u16 = MAX_SEQUENCE_PER_ROUND;
-
-    /// The maximum sequence number.
-    ///
-    /// The sequence number is only ever wrapped between rounds, and so we need to ensure that there
-    /// are enough sequence numbers for a complete round.
-    ///
-    /// A sequence number can be skipped if, for example, the port for that sequence number cannot
-    /// be bound as it is already in use.
-    ///
-    /// To ensure each `ProbeState` is in the correct place in the buffer (i.e. the index into the buffer
-    /// is always `Probe.sequence - round_sequence`), when we skip a sequence we leave the
-    /// skipped `ProbeState` in-place and use the next slot for the next sequence.
-    ///
-    /// We cap the number of sequences that can potentially be skipped in a round to ensure that
-    /// sequence number does not even need to wrap around during a round.
-    ///
-    /// We only ever send `ttl` in the range 1..255, and so we may use all buffer capacity, except
-    /// the minimum needed to send up to a max `ttl` of 255 (a `ttl` of 0 is never sent).
-    const MAX_SEQUENCE: Sequence = Sequence(u16::MAX - BUFFER_SIZE);
-
-    /// Mutable state needed for the tracing algorithm.
     #[derive(Debug)]
-    pub struct TracerState {
-        /// Tracer configuration.
-        config: Config,
-        /// The state of all `ProbeState` requests and responses.
-        buffer: [ProbeState; BUFFER_SIZE as usize],
-        /// An increasing sequence number for every `EchoRequest`.
-        sequence: Sequence,
-        /// The starting sequence number of the current round.
-        round_sequence: Sequence,
-        /// The time-to-live for the _next_ `EchoRequest` packet to be sent.
-        ttl: TimeToLive,
-        /// The current round.
-        round: Round,
-        /// The timestamp of when the current round started.
-        round_start: SystemTime,
-        /// Did we receive an `EchoReply` from the target host in this round?
-        target_found: bool,
-        /// The maximum time-to-live echo response packet we have received.
-        max_received_ttl: Option<TimeToLive>,
-        /// The observed time-to-live of the `EchoReply` from the target host.
-        ///
-        /// Note that this is _not_ reset each round and that it can also _change_ over time,
-        /// including going _down_ as responses can be received out-of-order.
-        target_ttl: Option<TimeToLive>,
-        /// The timestamp of the echo response packet.
-        received_time: Option<SystemTime>,
+    pub(super) struct TracerInner {
+        source_addr: Option<IpAddr>,
+        interface: Option<String>,
+        target_addr: IpAddr,
+        privilege_mode: PrivilegeMode,
+        protocol: Protocol,
+        packet_size: PacketSize,
+        payload_pattern: PayloadPattern,
+        tos: TypeOfService,
+        icmp_extension_parse_mode: IcmpExtensionParseMode,
+        read_timeout: Duration,
+        tcp_connect_timeout: Duration,
+        trace_identifier: TraceId,
+        max_rounds: Option<MaxRounds>,
+        first_ttl: TimeToLive,
+        max_ttl: TimeToLive,
+        grace_duration: Duration,
+        max_inflight: MaxInflight,
+        initial_sequence: Sequence,
+        multipath_strategy: MultipathStrategy,
+        port_direction: PortDirection,
+        min_round_duration: Duration,
+        max_round_duration: Duration,
+        max_samples: usize,
+        max_flows: usize,
+        drop_privileges: bool,
+        state: RwLock<TraceState>,
+        src: OnceLock<IpAddr>,
     }
 
-    impl TracerState {
-        pub fn new(config: Config) -> Self {
+    impl TracerInner {
+        #[allow(clippy::too_many_arguments)]
+        pub(super) fn new(
+            interface: Option<String>,
+            source_addr: Option<IpAddr>,
+            target_addr: IpAddr,
+            privilege_mode: PrivilegeMode,
+            protocol: Protocol,
+            packet_size: PacketSize,
+            payload_pattern: PayloadPattern,
+            tos: TypeOfService,
+            icmp_extension_parse_mode: IcmpExtensionParseMode,
+            read_timeout: Duration,
+            tcp_connect_timeout: Duration,
+            trace_identifier: TraceId,
+            max_rounds: Option<MaxRounds>,
+            first_ttl: TimeToLive,
+            max_ttl: TimeToLive,
+            grace_duration: Duration,
+            max_inflight: MaxInflight,
+            initial_sequence: Sequence,
+            multipath_strategy: MultipathStrategy,
+            port_direction: PortDirection,
+            min_round_duration: Duration,
+            max_round_duration: Duration,
+            max_samples: usize,
+            max_flows: usize,
+            drop_privileges: bool,
+        ) -> Self {
             Self {
-                config,
-                buffer: from_fn(|_| ProbeState::default()),
-                sequence: config.initial_sequence,
-                round_sequence: config.initial_sequence,
-                ttl: config.first_ttl,
-                round: Round(0),
-                round_start: SystemTime::now(),
-                target_found: false,
-                max_received_ttl: None,
-                target_ttl: None,
-                received_time: None,
+                source_addr,
+                interface,
+                target_addr,
+                privilege_mode,
+                protocol,
+                packet_size,
+                payload_pattern,
+                tos,
+                icmp_extension_parse_mode,
+                read_timeout,
+                tcp_connect_timeout,
+                trace_identifier,
+                max_rounds,
+                first_ttl,
+                max_ttl,
+                grace_duration,
+                max_inflight,
+                initial_sequence,
+                multipath_strategy,
+                port_direction,
+                min_round_duration,
+                max_round_duration,
+                max_samples,
+                max_flows,
+                drop_privileges,
+                state: RwLock::new(TraceState::new(Self::make_state_config(
+                    max_flows,
+                    max_samples,
+                ))),
+                src: OnceLock::new(),
             }
         }
 
-        /// Get a slice of `ProbeState` for the current round.
-        pub fn probes(&self) -> &[ProbeState] {
-            let round_size = self.sequence - self.round_sequence;
-            &self.buffer[..round_size.0 as usize]
+        #[instrument(skip_all)]
+        pub(super) fn run(&self) -> TraceResult<()> {
+            self.run_internal(|_| ())
+                .map_err(|err| self.handle_error(err))
         }
 
-        /// Get the `ProbeState` for `sequence`
-        pub fn probe_at(&self, sequence: Sequence) -> ProbeState {
-            self.buffer[usize::from(sequence - self.round_sequence)].clone()
+        #[instrument(skip_all)]
+        pub(super) fn run_with<F: Fn(&TracerRound<'_>)>(&self, func: F) -> TraceResult<()> {
+            self.run_internal(func)
+                .map_err(|err| self.handle_error(err))
         }
 
-        pub const fn ttl(&self) -> TimeToLive {
-            self.ttl
+        pub(super) fn snapshot(&self) -> TraceState {
+            self.state.read().clone()
         }
 
-        pub const fn round_start(&self) -> SystemTime {
-            self.round_start
+        pub(super) fn clear(&self) {
+            *self.state.write() =
+                TraceState::new(Self::make_state_config(self.max_flows, self.max_samples));
         }
 
-        pub const fn target_found(&self) -> bool {
-            self.target_found
+        pub(super) fn max_flows(&self) -> usize {
+            self.max_flows
         }
 
-        pub const fn max_received_ttl(&self) -> Option<TimeToLive> {
-            self.max_received_ttl
+        pub(super) fn max_samples(&self) -> usize {
+            self.max_samples
         }
 
-        pub const fn target_ttl(&self) -> Option<TimeToLive> {
-            self.target_ttl
+        pub(super) fn privilege_mode(&self) -> PrivilegeMode {
+            self.privilege_mode
         }
 
-        pub const fn received_time(&self) -> Option<SystemTime> {
-            self.received_time
+        pub(super) fn protocol(&self) -> Protocol {
+            self.protocol
         }
 
-        /// Is `sequence` in the current round?
-        pub fn in_round(&self, sequence: Sequence) -> bool {
-            sequence >= self.round_sequence && sequence.0 - self.round_sequence.0 < BUFFER_SIZE
+        pub(super) fn interface(&self) -> Option<&str> {
+            self.interface.as_deref()
         }
 
-        /// Do we have capacity in the current round for another sequence?
-        pub fn round_has_capacity(&self) -> bool {
-            let round_size = self.sequence - self.round_sequence;
-            round_size.0 < BUFFER_SIZE
+        pub(super) fn source_addr(&self) -> Option<IpAddr> {
+            self.src.get().copied()
         }
 
-        /// Are all rounds complete?
-        pub fn finished(&self, max_rounds: Option<MaxRounds>) -> bool {
-            match max_rounds {
-                None => false,
-                Some(max_rounds) => self.round.0 > max_rounds.0.get() - 1,
-            }
+        pub(super) fn target_addr(&self) -> IpAddr {
+            self.target_addr
         }
 
-        /// Create and return the next `Probe` at the current `sequence` and `ttl`.
-        ///
-        /// We post-increment `ttl` here and so in practice we only allow `ttl` values in the range
-        /// `1..254` to allow us to use a `u8`.
-        #[instrument(skip(self))]
-        pub fn next_probe(&mut self, sent: SystemTime) -> Probe {
-            let (src_port, dest_port, identifier, flags) = self.probe_data();
-            let probe = Probe::new(
-                self.sequence,
-                identifier,
-                src_port,
-                dest_port,
-                self.ttl,
-                self.round,
-                sent,
-                flags,
-            );
-            let probe_index = usize::from(self.sequence - self.round_sequence);
-            self.buffer[probe_index] = ProbeState::Awaited(probe.clone());
-            debug_assert!(self.ttl < TimeToLive(u8::MAX));
-            self.ttl += TimeToLive(1);
-            debug_assert!(self.sequence < Sequence(u16::MAX));
-            self.sequence += Sequence(1);
-            probe
+        pub(super) fn packet_size(&self) -> PacketSize {
+            self.packet_size
         }
 
-        /// Re-issue the `Probe` with the next sequence number.
-        ///
-        /// This will mark the `ProbeState` at the previous `sequence` as skipped and re-create it with
-        /// the previous `ttl` and the current `sequence`.
-        ///
-        /// For example, if the sequence is `4` and the `ttl` is `5` prior to calling this method
-        /// then afterward:
-        /// - The `ProbeState` at sequence `3` will be set to `Skipped` state
-        /// - A new `ProbeState` will be created at sequence `4` with a `ttl` of `5`
-        #[instrument(skip(self))]
-        pub fn reissue_probe(&mut self, sent: SystemTime) -> Probe {
-            let probe_index = usize::from(self.sequence - self.round_sequence);
-            self.buffer[probe_index - 1] = ProbeState::Skipped;
-            let (src_port, dest_port, identifier, flags) = self.probe_data();
-            let probe = Probe::new(
-                self.sequence,
-                identifier,
-                src_port,
-                dest_port,
-                self.ttl - TimeToLive(1),
-                self.round,
-                sent,
-                flags,
-            );
-            self.buffer[probe_index] = ProbeState::Awaited(probe.clone());
-            debug_assert!(self.sequence < Sequence(u16::MAX));
-            self.sequence += Sequence(1);
-            probe
+        pub(super) fn payload_pattern(&self) -> PayloadPattern {
+            self.payload_pattern
         }
 
-        /// Determine the `src_port`, `dest_port` and `identifier` for the current probe.
-        ///
-        /// This will differ depending on the `TracerProtocol`, `MultipathStrategy` &
-        /// `PortDirection`.
-        fn probe_data(&self) -> (Port, Port, TraceId, Flags) {
-            match self.config.protocol {
-                Protocol::Icmp => self.probe_icmp_data(),
-                Protocol::Udp => self.probe_udp_data(),
-                Protocol::Tcp => self.probe_tcp_data(),
-            }
+        pub(super) fn initial_sequence(&self) -> Sequence {
+            self.initial_sequence
         }
 
-        /// Determine the `src_port`, `dest_port` and `identifier` for the current ICMP probe.
-        fn probe_icmp_data(&self) -> (Port, Port, TraceId, Flags) {
-            (
-                Port(0),
-                Port(0),
-                self.config.trace_identifier,
-                Flags::empty(),
-            )
+        pub(super) fn tos(&self) -> TypeOfService {
+            self.tos
         }
 
-        /// Determine the `src_port`, `dest_port` and `identifier` for the current UDP probe.
-        fn probe_udp_data(&self) -> (Port, Port, TraceId, Flags) {
-            match self.config.multipath_strategy {
-                MultipathStrategy::Classic => match self.config.port_direction {
-                    PortDirection::FixedSrc(src_port) => (
-                        Port(src_port.0),
-                        Port(self.sequence.0),
-                        TraceId(0),
-                        Flags::empty(),
-                    ),
-                    PortDirection::FixedDest(dest_port) => (
-                        Port(self.sequence.0),
-                        Port(dest_port.0),
-                        TraceId(0),
-                        Flags::empty(),
-                    ),
-                    PortDirection::FixedBoth(_, _) | PortDirection::None => {
-                        unimplemented!()
-                    }
-                },
-                MultipathStrategy::Paris => {
-                    let round_port = ((self.config.initial_sequence.0 as usize + self.round.0)
-                        % usize::from(u16::MAX)) as u16;
-                    match self.config.port_direction {
-                        PortDirection::FixedSrc(src_port) => (
-                            Port(src_port.0),
-                            Port(round_port),
-                            TraceId(0),
-                            Flags::PARIS_CHECKSUM,
-                        ),
-                        PortDirection::FixedDest(dest_port) => (
-                            Port(round_port),
-                            Port(dest_port.0),
-                            TraceId(0),
-                            Flags::PARIS_CHECKSUM,
-                        ),
-                        PortDirection::FixedBoth(src_port, dest_port) => (
-                            Port(src_port.0),
-                            Port(dest_port.0),
-                            TraceId(0),
-                            Flags::PARIS_CHECKSUM,
-                        ),
-                        PortDirection::None => unimplemented!(),
-                    }
-                }
-                MultipathStrategy::Dublin => {
-                    let round_port = ((self.config.initial_sequence.0 as usize + self.round.0)
-                        % usize::from(u16::MAX)) as u16;
-                    match self.config.port_direction {
-                        PortDirection::FixedSrc(src_port) => (
-                            Port(src_port.0),
-                            Port(round_port),
-                            TraceId(self.sequence.0),
-                            Flags::DUBLIN_IPV6_PAYLOAD_LENGTH,
-                        ),
-                        PortDirection::FixedDest(dest_port) => (
-                            Port(round_port),
-                            Port(dest_port.0),
-                            TraceId(self.sequence.0),
-                            Flags::DUBLIN_IPV6_PAYLOAD_LENGTH,
-                        ),
-                        PortDirection::FixedBoth(src_port, dest_port) => (
-                            Port(src_port.0),
-                            Port(dest_port.0),
-                            TraceId(self.sequence.0),
-                            Flags::DUBLIN_IPV6_PAYLOAD_LENGTH,
-                        ),
-                        PortDirection::None => unimplemented!(),
-                    }
-                }
-            }
+        pub(super) fn icmp_extension_parse_mode(&self) -> IcmpExtensionParseMode {
+            self.icmp_extension_parse_mode
         }
 
-        /// Determine the `src_port`, `dest_port` and `identifier` for the current TCP probe.
-        fn probe_tcp_data(&self) -> (Port, Port, TraceId, Flags) {
-            let (src_port, dest_port) = match self.config.port_direction {
-                PortDirection::FixedSrc(src_port) => (src_port.0, self.sequence.0),
-                PortDirection::FixedDest(dest_port) => (self.sequence.0, dest_port.0),
-                PortDirection::FixedBoth(_, _) | PortDirection::None => unimplemented!(),
-            };
-            (Port(src_port), Port(dest_port), TraceId(0), Flags::empty())
+        pub(super) fn read_timeout(&self) -> Duration {
+            self.read_timeout
         }
 
-        /// Mark the `ProbeState` at `sequence` completed as `TimeExceeded` and update the round state.
-        #[instrument(skip(self))]
-        pub fn complete_probe_time_exceeded(
-            &mut self,
-            sequence: Sequence,
-            host: IpAddr,
-            received: SystemTime,
-            is_target: bool,
-            icmp_code: IcmpPacketCode,
-            extensions: Option<Extensions>,
-        ) {
-            self.complete_probe(
-                sequence,
-                IcmpPacketType::TimeExceeded(icmp_code),
-                host,
-                received,
-                is_target,
-                extensions,
-            );
+        pub(super) fn tcp_connect_timeout(&self) -> Duration {
+            self.tcp_connect_timeout
         }
 
-        /// Mark the `ProbeState` at `sequence` completed as `Unreachable` and update the round state.
-        #[instrument(skip(self))]
-        pub fn complete_probe_unreachable(
-            &mut self,
-            sequence: Sequence,
-            host: IpAddr,
-            received: SystemTime,
-            icmp_code: IcmpPacketCode,
-            extensions: Option<Extensions>,
-        ) {
-            self.complete_probe(
-                sequence,
-                IcmpPacketType::Unreachable(icmp_code),
-                host,
-                received,
-                true,
-                extensions,
-            );
+        pub(super) fn trace_identifier(&self) -> TraceId {
+            self.trace_identifier
         }
 
-        /// Mark the `ProbeState` at `sequence` completed as `EchoReply` and update the round state.
-        #[instrument(skip(self))]
-        pub fn complete_probe_echo_reply(
-            &mut self,
-            sequence: Sequence,
-            host: IpAddr,
-            received: SystemTime,
-            icmp_code: IcmpPacketCode,
-        ) {
-            self.complete_probe(
-                sequence,
-                IcmpPacketType::EchoReply(icmp_code),
-                host,
-                received,
-                true,
-                None,
-            );
+        pub(super) fn max_rounds(&self) -> Option<MaxRounds> {
+            self.max_rounds
         }
 
-        /// Mark the `ProbeState` at `sequence` completed as `NotApplicable` and update the round state.
-        #[instrument(skip(self))]
-        pub fn complete_probe_other(
-            &mut self,
-            sequence: Sequence,
-            host: IpAddr,
-            received: SystemTime,
-        ) {
-            self.complete_probe(
-                sequence,
-                IcmpPacketType::NotApplicable,
-                host,
-                received,
-                true,
-                None,
-            );
+        pub(super) fn first_ttl(&self) -> TimeToLive {
+            self.first_ttl
         }
 
-        /// Update the state of a `ProbeState` and the trace.
-        ///
-        /// We want to update:
-        ///
-        /// - the `target_ttl` to be the time-to-live of the `ProbeState` request from the target
-        /// - the `max_received_ttl` we have observed this round
-        /// - the latest packet `received_time` in this round
-        /// - whether the target has been found in this round
-        ///
-        /// The ICMP replies may arrive out-of-order, and so we must be careful here to avoid
-        /// overwriting the state with stale values.  We may also receive multiple replies
-        /// from the target host with differing time-to-live values and so must ensure we
-        /// use the time-to-live with the lowest sequence number.
-        #[instrument(skip(self))]
-        fn complete_probe(
-            &mut self,
-            sequence: Sequence,
-            icmp_packet_type: IcmpPacketType,
-            host: IpAddr,
-            received: SystemTime,
-            is_target: bool,
-            extensions: Option<Extensions>,
-        ) {
-            // Retrieve and update the `ProbeState` at `sequence`.
-            let probe = self.probe_at(sequence);
-            let awaited = match probe {
-                ProbeState::Awaited(awaited) => awaited,
-                // there is a valid scenario for TCP where a probe is already
-                // `Complete`, see `test_tcp_dest_unreachable_and_refused`.
-                ProbeState::Complete(_) => {
-                    return;
-                }
-                _ => {
-                    debug_assert!(
-                        false,
-                        "completed probe was not in Awaited state (probe={probe:#?})"
-                    );
-                    return;
-                }
-            };
-            let completed = awaited.complete(host, received, icmp_packet_type, extensions);
-            let ttl = completed.ttl;
-            self.buffer[usize::from(sequence - self.round_sequence)] =
-                ProbeState::Complete(completed);
-
-            // If this `ProbeState` found the target then we set the `target_tll` if not already set,
-            // being careful to account for `Probes` being received out-of-order.
-            //
-            // If this `ProbeState` did not find the target but has a ttl that is greater or equal to the
-            // target ttl (if known) then we reset the target ttl to None.  This is to
-            // support Equal Cost Multi-path Routing (ECMP) cases where the number of
-            // hops to the target will vary over the lifetime of the trace.
-            self.target_ttl = if is_target {
-                match self.target_ttl {
-                    None => Some(ttl),
-                    Some(target_ttl) if ttl < target_ttl => Some(ttl),
-                    Some(target_ttl) => Some(target_ttl),
-                }
-            } else {
-                match self.target_ttl {
-                    Some(target_ttl) if ttl >= target_ttl => None,
-                    Some(target_ttl) => Some(target_ttl),
-                    None => None,
-                }
-            };
-
-            self.max_received_ttl = match self.max_received_ttl {
-                None => Some(ttl),
-                Some(max_received_ttl) => Some(max_received_ttl.max(ttl)),
-            };
-
-            self.received_time = Some(received);
-            self.target_found |= is_target;
+        pub(super) fn max_ttl(&self) -> TimeToLive {
+            self.max_ttl
         }
 
-        /// Advance to the next round.
-        ///
-        /// If, during the rond which just completed, we went above the max sequence number then we
-        /// reset it here. We do this here to avoid having to deal with the sequence number
-        /// wrapping during a round, which is more problematic.
-        #[instrument(skip(self))]
-        pub fn advance_round(&mut self, first_ttl: TimeToLive) {
-            if self.sequence >= self.max_sequence() {
-                self.sequence = self.config.initial_sequence;
-            }
-            self.target_found = false;
-            self.round_sequence = self.sequence;
-            self.received_time = None;
-            self.round_start = SystemTime::now();
-            self.max_received_ttl = None;
-            self.round += Round(1);
-            self.ttl = first_ttl;
+        pub(super) fn grace_duration(&self) -> Duration {
+            self.grace_duration
         }
 
-        /// The maximum sequence number allowed.
-        ///
-        /// The Dublin multipath strategy for IPv6/udp encodes the sequence
-        /// number as the payload length and consequently the maximum sequence
-        /// number must be no larger than the maximum IPv6/udp payload size.
-        ///
-        /// It is also required that the range of possible sequence numbers is
-        /// _at least_ `BUFFER_SIZE` to ensure delayed responses from a prior
-        /// round are not incorrectly associated with later rounds (see
-        /// `in_round` function).
-        fn max_sequence(&self) -> Sequence {
-            match (self.config.multipath_strategy, self.config.target_addr) {
-                (MultipathStrategy::Dublin, IpAddr::V6(_)) => {
-                    self.config.initial_sequence + Sequence(BUFFER_SIZE)
-                }
-                _ => MAX_SEQUENCE,
-            }
+        pub(super) fn max_inflight(&self) -> MaxInflight {
+            self.max_inflight
         }
-    }
 
-    #[cfg(test)]
-    mod tests {
-        use super::*;
-        use crate::probe::IcmpPacketType;
-        use crate::types::MaxInflight;
-        use rand::Rng;
-        use std::net::{IpAddr, Ipv4Addr};
-        use std::time::Duration;
-
-        #[allow(
-            clippy::cognitive_complexity,
-            clippy::too_many_lines,
-            clippy::bool_assert_comparison
-        )]
-        #[test]
-        fn test_state() {
-            let mut state = TracerState::new(cfg(Sequence(33000)));
-
-            // Validate the initial TracerState
-            assert_eq!(state.round, Round(0));
-            assert_eq!(state.sequence, Sequence(33000));
-            assert_eq!(state.round_sequence, Sequence(33000));
-            assert_eq!(state.ttl, TimeToLive(1));
-            assert_eq!(state.max_received_ttl, None);
-            assert_eq!(state.received_time, None);
-            assert_eq!(state.target_ttl, None);
-            assert_eq!(state.target_found, false);
-
-            // The initial state of the probe before sending
-            let prob_init = state.probe_at(Sequence(33000));
-            assert_eq!(ProbeState::NotSent, prob_init);
-
-            // Prepare probe 1 (round 0, sequence 33000, ttl 1) for sending
-            let sent_1 = SystemTime::now();
-            let probe_1 = state.next_probe(sent_1);
-            assert_eq!(probe_1.sequence, Sequence(33000));
-            assert_eq!(probe_1.ttl, TimeToLive(1));
-            assert_eq!(probe_1.round, Round(0));
-            assert_eq!(probe_1.sent, sent_1);
-
-            // Update the state of the probe 1 after receiving a TimeExceeded
-            let received_1 = SystemTime::now();
-            let host = IpAddr::V4(Ipv4Addr::LOCALHOST);
-            state.complete_probe_time_exceeded(
-                Sequence(33000),
-                host,
-                received_1,
-                false,
-                IcmpPacketCode(1),
-                None,
-            );
-
-            // Validate the state of the probe 1 after the update
-            let probe_1_fetch = state.probe_at(Sequence(33000)).try_into_complete().unwrap();
-            assert_eq!(probe_1_fetch.sequence, Sequence(33000));
-            assert_eq!(probe_1_fetch.ttl, TimeToLive(1));
-            assert_eq!(probe_1_fetch.round, Round(0));
-            assert_eq!(probe_1_fetch.received, received_1);
-            assert_eq!(probe_1_fetch.host, host);
-            assert_eq!(probe_1_fetch.sent, sent_1);
-            assert_eq!(
-                probe_1_fetch.icmp_packet_type,
-                IcmpPacketType::TimeExceeded(IcmpPacketCode(1))
-            );
-
-            // Validate the TracerState after the update
-            assert_eq!(state.round, Round(0));
-            assert_eq!(state.sequence, Sequence(33001));
-            assert_eq!(state.round_sequence, Sequence(33000));
-            assert_eq!(state.ttl, TimeToLive(2));
-            assert_eq!(state.max_received_ttl, Some(TimeToLive(1)));
-            assert_eq!(state.received_time, Some(received_1));
-            assert_eq!(state.target_ttl, None);
-            assert_eq!(state.target_found, false);
-
-            // Validate the probes() iterator returns only a single probe
-            {
-                let mut probe_iter = state.probes().iter();
-                let probe_next1 = probe_iter.next().unwrap();
-                assert_eq!(ProbeState::Complete(probe_1_fetch), probe_next1.clone());
-                assert_eq!(None, probe_iter.next());
-            }
-
-            // Advance to the next round
-            state.advance_round(TimeToLive(1));
-
-            // Validate the TracerState after the round update
-            assert_eq!(state.round, Round(1));
-            assert_eq!(state.sequence, Sequence(33001));
-            assert_eq!(state.round_sequence, Sequence(33001));
-            assert_eq!(state.ttl, TimeToLive(1));
-            assert_eq!(state.max_received_ttl, None);
-            assert_eq!(state.received_time, None);
-            assert_eq!(state.target_ttl, None);
-            assert_eq!(state.target_found, false);
-
-            // Prepare probe 2 (round 1, sequence 33001, ttl 1) for sending
-            let sent_2 = SystemTime::now();
-            let probe_2 = state.next_probe(sent_2);
-            assert_eq!(probe_2.sequence, Sequence(33001));
-            assert_eq!(probe_2.ttl, TimeToLive(1));
-            assert_eq!(probe_2.round, Round(1));
-            assert_eq!(probe_2.sent, sent_2);
-
-            // Prepare probe 3 (round 1, sequence 33002, ttl 2) for sending
-            let sent_3 = SystemTime::now();
-            let probe_3 = state.next_probe(sent_3);
-            assert_eq!(probe_3.sequence, Sequence(33002));
-            assert_eq!(probe_3.ttl, TimeToLive(2));
-            assert_eq!(probe_3.round, Round(1));
-            assert_eq!(probe_3.sent, sent_3);
-
-            // Update the state of probe 2 after receiving a TimeExceeded
-            let received_2 = SystemTime::now();
-            let host = IpAddr::V4(Ipv4Addr::LOCALHOST);
-            state.complete_probe_time_exceeded(
-                Sequence(33001),
-                host,
-                received_2,
-                false,
-                IcmpPacketCode(1),
-                None,
-            );
-            let probe_2_recv = state.probe_at(Sequence(33001));
-
-            // Validate the TracerState after the update to probe 2
-            assert_eq!(state.round, Round(1));
-            assert_eq!(state.sequence, Sequence(33003));
-            assert_eq!(state.round_sequence, Sequence(33001));
-            assert_eq!(state.ttl, TimeToLive(3));
-            assert_eq!(state.max_received_ttl, Some(TimeToLive(1)));
-            assert_eq!(state.received_time, Some(received_2));
-            assert_eq!(state.target_ttl, None);
-            assert_eq!(state.target_found, false);
-
-            // Validate the probes() iterator returns the two probes in the states we expect
-            {
-                let mut probe_iter = state.probes().iter();
-                let probe_next1 = probe_iter.next().unwrap();
-                assert_eq!(&probe_2_recv, probe_next1);
-                let probe_next2 = probe_iter.next().unwrap();
-                assert_eq!(ProbeState::Awaited(probe_3), probe_next2.clone());
-            }
+        pub(super) fn multipath_strategy(&self) -> MultipathStrategy {
+            self.multipath_strategy
+        }
 
-            // Update the state of probe 3 after receiving a EchoReply
-            let received_3 = SystemTime::now();
-            let host = IpAddr::V4(Ipv4Addr::LOCALHOST);
-            state.complete_probe_echo_reply(Sequence(33002), host, received_3, IcmpPacketCode(0));
-            let probe_3_recv = state.probe_at(Sequence(33002));
-
-            // Validate the TracerState after the update to probe 3
-            assert_eq!(state.round, Round(1));
-            assert_eq!(state.sequence, Sequence(33003));
-            assert_eq!(state.round_sequence, Sequence(33001));
-            assert_eq!(state.ttl, TimeToLive(3));
-            assert_eq!(state.max_received_ttl, Some(TimeToLive(2)));
-            assert_eq!(state.received_time, Some(received_3));
-            assert_eq!(state.target_ttl, Some(TimeToLive(2)));
-            assert_eq!(state.target_found, true);
-
-            // Validate the probes() iterator returns the two probes in the states we expect
-            {
-                let mut probe_iter = state.probes().iter();
-                let probe_next1 = probe_iter.next().unwrap();
-                assert_eq!(&probe_2_recv, probe_next1);
-                let probe_next2 = probe_iter.next().unwrap();
-                assert_eq!(&probe_3_recv, probe_next2);
-            }
+        pub(super) fn port_direction(&self) -> PortDirection {
+            self.port_direction
         }
 
-        #[test]
-        fn test_sequence_wrap1() {
-            // Start from MAX_SEQUENCE - 1 which is (65279 - 1) == 65278
-            let initial_sequence = Sequence(65278);
-            let mut state = TracerState::new(cfg(initial_sequence));
-            assert_eq!(state.round, Round(0));
-            assert_eq!(state.sequence, initial_sequence);
-            assert_eq!(state.round_sequence, initial_sequence);
-
-            // Create a probe at seq 65278
-            assert_eq!(
-                state.next_probe(SystemTime::now()).sequence,
-                Sequence(65278)
-            );
-            assert_eq!(state.sequence, Sequence(65279));
-
-            // Validate the probes()
-            {
-                let mut iter = state.probes().iter();
-                assert_eq!(
-                    iter.next()
-                        .unwrap()
-                        .clone()
-                        .try_into_awaited()
-                        .unwrap()
-                        .sequence,
-                    Sequence(65278)
-                );
-                iter.take(BUFFER_SIZE as usize - 1)
-                    .for_each(|p| assert!(matches!(p, ProbeState::NotSent)));
-            }
+        pub(super) fn min_round_duration(&self) -> Duration {
+            self.min_round_duration
+        }
 
-            // Advance the round, which will wrap the sequence back to initial_sequence
-            state.advance_round(TimeToLive(1));
-            assert_eq!(state.round, Round(1));
-            assert_eq!(state.sequence, initial_sequence);
-            assert_eq!(state.round_sequence, initial_sequence);
-
-            // Create a probe at seq 65278
-            assert_eq!(
-                state.next_probe(SystemTime::now()).sequence,
-                Sequence(65278)
-            );
-            assert_eq!(state.sequence, Sequence(65279));
-
-            // Validate the probes() again
-            {
-                let mut iter = state.probes().iter();
-                assert_eq!(
-                    iter.next()
-                        .unwrap()
-                        .clone()
-                        .try_into_awaited()
-                        .unwrap()
-                        .sequence,
-                    Sequence(65278)
-                );
-                iter.take(BUFFER_SIZE as usize - 1)
-                    .for_each(|p| assert!(matches!(p, ProbeState::NotSent)));
-            }
+        pub(super) fn max_round_duration(&self) -> Duration {
+            self.max_round_duration
         }
 
-        #[test]
-        fn test_sequence_wrap2() {
-            let total_rounds = 2000;
-            let max_probe_per_round = 254;
-            let mut state = TracerState::new(cfg(Sequence(33000)));
-            for _ in 0..total_rounds {
-                for _ in 0..max_probe_per_round {
-                    let _probe = state.next_probe(SystemTime::now());
-                }
-                state.advance_round(TimeToLive(1));
+        #[instrument(skip_all)]
+        fn run_internal<F: Fn(&TracerRound<'_>)>(&self, func: F) -> TraceResult<()> {
+            // if we are given a source address, validate it otherwise
+            // discover it based on the target address and interface.
+            let source_addr = match self.source_addr {
+                None => SourceAddr::discover::<SocketImpl, PlatformImpl>(
+                    self.target_addr,
+                    self.port_direction,
+                    self.interface.as_deref(),
+                )?,
+                Some(addr) => SourceAddr::validate::<SocketImpl>(addr)?,
+            };
+            self.src
+                .set(source_addr)
+                .map_err(|_| TracerError::Other(String::from("failed to set source_addr")))?;
+            let channel_config = self.make_channel_config(source_addr);
+            let channel = TracerChannel::<SocketImpl>::connect(&channel_config)?;
+            if self.drop_privileges {
+                Privilege::drop_privileges()?;
             }
-            assert_eq!(state.round, Round(2000));
-            assert_eq!(state.round_sequence, Sequence(57130));
-            assert_eq!(state.sequence, Sequence(57130));
+            let strategy_config = self.make_strategy_config();
+            let strategy = TracerStrategy::new(&strategy_config, |round| {
+                self.handler(round);
+                func(round);
+            });
+            strategy.run(channel)?;
+            Ok(())
         }
 
-        #[test]
-        fn test_sequence_wrap3() {
-            let total_rounds = 2000;
-            let max_probe_per_round = 20;
-            let mut state = TracerState::new(cfg(Sequence(33000)));
-            let mut rng = rand::thread_rng();
-            for _ in 0..total_rounds {
-                for _ in 0..rng.gen_range(0..max_probe_per_round) {
-                    state.next_probe(SystemTime::now());
-                }
-                state.advance_round(TimeToLive(1));
-            }
+        fn handler(&self, round: &TracerRound<'_>) {
+            self.state.write().update_from_round(round);
         }
 
-        #[test]
-        fn test_sequence_wrap_with_skip() {
-            let total_rounds = 2000;
-            let max_probe_per_round = 254;
-            let mut state = TracerState::new(cfg(Sequence(33000)));
-            for _ in 0..total_rounds {
-                for _ in 0..max_probe_per_round {
-                    _ = state.next_probe(SystemTime::now());
-                    _ = state.reissue_probe(SystemTime::now());
-                }
-                state.advance_round(TimeToLive(1));
-            }
-            assert_eq!(state.round, Round(2000));
-            assert_eq!(state.round_sequence, Sequence(41128));
-            assert_eq!(state.sequence, Sequence(41128));
+        fn handle_error(&self, err: TracerError) -> TracerError {
+            self.state.write().set_error(Some(err.to_string()));
+            err
         }
 
-        #[test]
-        fn test_in_round() {
-            let state = TracerState::new(cfg(Sequence(33000)));
-            assert!(state.in_round(Sequence(33000)));
-            assert!(state.in_round(Sequence(33511)));
-            assert!(!state.in_round(Sequence(33512)));
+        fn make_state_config(max_flows: usize, max_samples: usize) -> StateConfig {
+            StateConfig {
+                max_samples,
+                max_flows,
+            }
         }
 
-        #[test]
-        #[should_panic(expected = "assertion failed: !state.in_round(Sequence(64491))")]
-        fn test_in_delayed_probe_not_in_round() {
-            let mut state = TracerState::new(cfg(Sequence(64000)));
-            for _ in 0..55 {
-                _ = state.next_probe(SystemTime::now());
+        fn make_channel_config(&self, source_addr: IpAddr) -> ChannelConfig {
+            ChannelConfig {
+                privilege_mode: self.privilege_mode,
+                protocol: self.protocol,
+                source_addr,
+                target_addr: self.target_addr,
+                packet_size: self.packet_size,
+                payload_pattern: self.payload_pattern,
+                initial_sequence: self.initial_sequence,
+                tos: self.tos,
+                icmp_extension_parse_mode: self.icmp_extension_parse_mode,
+                read_timeout: self.read_timeout,
+                tcp_connect_timeout: self.tcp_connect_timeout,
             }
-            state.advance_round(TimeToLive(1));
-            assert!(!state.in_round(Sequence(64491)));
         }
 
-        fn cfg(initial_sequence: Sequence) -> Config {
-            Config {
-                target_addr: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
-                protocol: Protocol::Icmp,
-                trace_identifier: TraceId::default(),
-                max_rounds: None,
-                first_ttl: TimeToLive(1),
-                max_ttl: TimeToLive(24),
-                grace_duration: Duration::default(),
-                max_inflight: MaxInflight::default(),
-                initial_sequence,
-                multipath_strategy: MultipathStrategy::Classic,
-                port_direction: PortDirection::None,
-                min_round_duration: Duration::default(),
-                max_round_duration: Duration::default(),
+        fn make_strategy_config(&self) -> StrategyConfig {
+            StrategyConfig {
+                target_addr: self.target_addr,
+                protocol: self.protocol,
+                trace_identifier: self.trace_identifier,
+                max_rounds: self.max_rounds,
+                first_ttl: self.first_ttl,
+                max_ttl: self.max_ttl,
+                grace_duration: self.grace_duration,
+                max_inflight: self.max_inflight,
+                initial_sequence: self.initial_sequence,
+                multipath_strategy: self.multipath_strategy,
+                port_direction: self.port_direction,
+                min_round_duration: self.min_round_duration,
+                max_round_duration: self.max_round_duration,
             }
         }
     }
 }
-
-/// Returns true if the duration between start and end is grater than a duration, false otherwise.
-fn exceeds(start: Option<SystemTime>, end: SystemTime, dur: Duration) -> bool {
-    start.map_or(false, |start| {
-        end.duration_since(start).unwrap_or_default() > dur
-    })
-}
diff --git a/crates/trippy-dns/src/lazy_resolver.rs b/crates/trippy-dns/src/lazy_resolver.rs
index 95991c822..ecb39195d 100644
--- a/crates/trippy-dns/src/lazy_resolver.rs
+++ b/crates/trippy-dns/src/lazy_resolver.rs
@@ -15,6 +15,16 @@ pub struct Config {
     pub timeout: Duration,
 }
 
+impl Default for Config {
+    fn default() -> Self {
+        Self {
+            resolve_method: ResolveMethod::System,
+            addr_family: IpAddrFamily::Ipv4thenIpv6,
+            timeout: Duration::from_millis(5000),
+        }
+    }
+}
+
 /// How DNS queries will be resolved.
 #[derive(Debug, Copy, Clone, Eq, PartialEq)]
 pub enum ResolveMethod {
diff --git a/crates/trippy-dns/src/resolver.rs b/crates/trippy-dns/src/resolver.rs
index 020579180..b9ce8c442 100644
--- a/crates/trippy-dns/src/resolver.rs
+++ b/crates/trippy-dns/src/resolver.rs
@@ -48,7 +48,7 @@ pub type Result<T> = std::result::Result<T, Error>;
 #[derive(Error, Debug)]
 pub enum Error {
     #[error("DNS lookup failed")]
-    LookupFailed(Box<dyn std::error::Error>),
+    LookupFailed(Box<dyn std::error::Error + Send + Sync + 'static>),
     #[error("ASN origin query failed")]
     QueryAsnOriginFailed,
     #[error("ASN query failed")]
diff --git a/crates/trippy-packet/src/lib.rs b/crates/trippy-packet/src/lib.rs
index 9604540fa..d68c66bd9 100644
--- a/crates/trippy-packet/src/lib.rs
+++ b/crates/trippy-packet/src/lib.rs
@@ -38,9 +38,9 @@
 //!
 //! ```rust
 //! # fn main() -> anyhow::Result<()> {
-//! use trippy_packet::icmpv4::echo_request::EchoRequestPacket;
-//! use trippy_packet::icmpv4::{IcmpPacket, IcmpType, IcmpCode};
 //! use trippy_packet::checksum::icmp_ipv4_checksum;
+//! use trippy_packet::icmpv4::echo_request::EchoRequestPacket;
+//! use trippy_packet::icmpv4::{IcmpCode, IcmpPacket, IcmpType};
 //!
 //! let mut buf = [0; IcmpPacket::minimum_packet_size()];
 //! let mut icmp = EchoRequestPacket::new(&mut buf)?;
diff --git a/crates/trippy-privilege/src/lib.rs b/crates/trippy-privilege/src/lib.rs
index f90627499..a419935be 100644
--- a/crates/trippy-privilege/src/lib.rs
+++ b/crates/trippy-privilege/src/lib.rs
@@ -137,8 +137,8 @@ impl Privilege {
 
     /// Does our platform always need privileges for `ICMP`?
     ///
-    /// Specifically, each platform requires privileges unless it supports the `IPPROTO_ICMP` socket type which _also_
-    /// allows the `IP_HDRINCL` socket option to be set.
+    /// Specifically, each platform requires privileges unless it supports the `IPPROTO_ICMP` socket
+    /// type which _also_ allows the `IP_HDRINCL` socket option to be set.
     #[must_use]
     pub fn needs_privileges(&self) -> bool {
         self.needs_privileges
@@ -212,8 +212,9 @@ impl Privilege {
     #[cfg(all(unix, not(target_os = "macos")))]
     /// Does the platform always require privileges?
     ///
-    /// Whilst Linux supports the `IPPROTO_ICMP` socket type, it does not allow using it with the `IP_HDRINCL` socket
-    /// option and is therefore not supported.  This may be supported in the future.
+    /// Whilst Linux supports the `IPPROTO_ICMP` socket type, it does not allow using it with the
+    /// `IP_HDRINCL` socket option and is therefore not supported.  This may be supported in the
+    /// future.
     ///
     /// `NetBSD`, `OpenBSD` and `FreeBSD` do not support `IPPROTO_ICMP`.
     fn check_needs_privileges() -> bool {
diff --git a/crates/trippy/Cargo.toml b/crates/trippy/Cargo.toml
index effd98ba9..ee5f2df11 100644
--- a/crates/trippy/Cargo.toml
+++ b/crates/trippy/Cargo.toml
@@ -19,16 +19,15 @@ path = "src/main.rs"
 name = "trip"
 
 [dependencies]
-trippy-core = { version = "0.11.0-dev", path = "../trippy-core" }
-trippy-privilege = { version = "0.11.0-dev", path = "../trippy-privilege" }
-trippy-dns = { version = "0.11.0-dev", path = "../trippy-dns" }
+trippy-core.workspace = true
+trippy-privilege.workspace = true
+trippy-dns.workspace = true
 thiserror.workspace = true
 anyhow.workspace = true
 itertools.workspace = true
 tracing.workspace = true
 serde.workspace = true
 derive_more.workspace = true
-parking_lot.workspace = true
 clap = { version = "4.4.0",  default-features = false, features = [ "cargo", "derive", "wrap_help", "usage", "unstable-styles", "color", "suggestions", "error-context" ] }
 clap_complete = "4.4.9"
 humantime = "2.1.0"
@@ -40,7 +39,6 @@ comfy-table = { version = "7.1.0", default-features = false }
 strum = { version = "0.26.2", default-features = false, features = [ "std", "derive" ] }
 etcetera = "0.8.0"
 toml = { version = "0.8.14", default-features = false, features = [ "parse" ] }
-indexmap = { version = "2.2.6", default-features = false }
 maxminddb = "0.24.0"
 tracing-subscriber = { version = "0.3.18", default-features = false, features = [ "json", "env-filter" ] }
 tracing-chrome = "0.7.2"
diff --git a/crates/trippy/src/app.rs b/crates/trippy/src/app.rs
index c8fca0b8f..75f609181 100644
--- a/crates/trippy/src/app.rs
+++ b/crates/trippy/src/app.rs
@@ -1,26 +1,18 @@
-use crate::backend::trace::Trace;
-use crate::backend::Backend;
 use crate::config::{LogFormat, LogSpanEvents, Mode, TrippyConfig};
 use crate::frontend::TuiConfig;
 use crate::geoip::GeoIpLookup;
 use crate::{frontend, report};
 use anyhow::{anyhow, Error};
-use parking_lot::RwLock;
 use std::net::IpAddr;
-use std::sync::Arc;
-use std::thread;
-use std::time::Duration;
 use tracing_chrome::{ChromeLayerBuilder, FlushGuard};
 use tracing_subscriber::fmt::format::FmtSpan;
 use tracing_subscriber::layer::SubscriberExt;
 use tracing_subscriber::util::SubscriberInitExt;
-use trippy_core::{
-    ChannelConfig, Config, IcmpExtensionParseMode, MultipathStrategy, PlatformImpl, PortDirection,
-    PrivilegeMode, Protocol, SocketImpl, SourceAddr,
-};
+use trippy_core::{Builder, Tracer};
 use trippy_dns::{DnsResolver, Resolver};
 use trippy_privilege::Privilege;
 
+/// Run the trippy application.
 pub fn run_trippy(cfg: &TrippyConfig, pid: u16) -> anyhow::Result<()> {
     let _guard = configure_logging(cfg);
     let resolver = start_dns_resolver(cfg)?;
@@ -60,33 +52,34 @@ fn start_tracer(
     target_addr: IpAddr,
     trace_identifier: u16,
 ) -> Result<TraceInfo, Error> {
-    let source_addr = match cfg.source_addr {
-        None => SourceAddr::discover::<SocketImpl, PlatformImpl>(
-            target_addr,
-            cfg.port_direction,
-            cfg.interface.as_deref(),
-        )?,
-        Some(addr) => SourceAddr::validate::<SocketImpl>(addr)?,
-    };
-    let channel_config = make_channel_config(cfg, source_addr, target_addr);
-    let tracer_config = make_tracer_config(cfg, target_addr, trace_identifier)?;
-    let backend = Backend::new(
-        tracer_config,
-        channel_config,
-        cfg.max_samples,
-        cfg.max_flows(),
-    );
-    let trace_data = backend.trace();
-    thread::Builder::new()
-        .name(format!("tracer-{}", tracer_config.trace_identifier.0))
-        .spawn(move || backend.start().expect("failed to run tracer backend"))?;
-    Ok(make_trace_info(
-        cfg,
-        trace_data,
-        source_addr,
-        target_host.to_string(),
-        target_addr,
-    ))
+    let (tracer, _) = Builder::new(target_addr)
+        .interface(cfg.interface.clone())
+        .source_addr(cfg.source_addr)
+        .privilege_mode(cfg.privilege_mode)
+        .protocol(cfg.protocol)
+        .packet_size(cfg.packet_size)
+        .payload_pattern(cfg.payload_pattern)
+        .tos(cfg.tos)
+        .icmp_extension_parse_mode(cfg.icmp_extension_parse_mode)
+        .read_timeout(cfg.read_timeout)
+        .tcp_connect_timeout(cfg.min_round_duration)
+        .trace_identifier(trace_identifier)
+        .max_rounds(cfg.max_rounds)
+        .first_ttl(cfg.first_ttl)
+        .max_ttl(cfg.max_ttl)
+        .grace_duration(cfg.grace_duration)
+        .max_inflight(cfg.max_inflight)
+        .initial_sequence(cfg.initial_sequence)
+        .multipath_strategy(cfg.multipath_strategy)
+        .port_direction(cfg.port_direction)
+        .min_round_duration(cfg.min_round_duration)
+        .max_round_duration(cfg.max_round_duration)
+        .max_flows(cfg.max_flows())
+        .max_samples(cfg.max_samples)
+        .drop_privileges(true)
+        .build()?
+        .spawn()?;
+    Ok(make_trace_info(tracer, target_host.to_string()))
 }
 
 /// Run the TUI, stream or report.
@@ -195,84 +188,6 @@ fn configure_logging(cfg: &TrippyConfig) -> Option<FlushGuard> {
     None
 }
 
-/// Make the tracer configuration.
-fn make_tracer_config(
-    args: &TrippyConfig,
-    target_addr: IpAddr,
-    trace_identifier: u16,
-) -> anyhow::Result<Config> {
-    Ok(Config::new(
-        target_addr,
-        args.protocol,
-        args.max_rounds,
-        trace_identifier,
-        args.first_ttl,
-        args.max_ttl,
-        args.grace_duration,
-        args.max_inflight,
-        args.initial_sequence,
-        args.multipath_strategy,
-        args.port_direction,
-        args.min_round_duration,
-        args.max_round_duration,
-    )?)
-}
-
-/// Make the tracer configuration.
-fn make_channel_config(
-    args: &TrippyConfig,
-    source_addr: IpAddr,
-    target_addr: IpAddr,
-) -> ChannelConfig {
-    ChannelConfig::new(
-        args.privilege_mode,
-        args.protocol,
-        source_addr,
-        target_addr,
-        args.packet_size,
-        args.payload_pattern,
-        args.initial_sequence,
-        args.tos,
-        args.icmp_extension_parse_mode,
-        args.read_timeout,
-        args.min_round_duration,
-    )
-}
-
-/// Make the per-trace information.
-fn make_trace_info(
-    args: &TrippyConfig,
-    trace_data: Arc<RwLock<Trace>>,
-    source_addr: IpAddr,
-    target: String,
-    target_addr: IpAddr,
-) -> TraceInfo {
-    TraceInfo::new(
-        trace_data,
-        source_addr,
-        target,
-        target_addr,
-        args.privilege_mode,
-        args.multipath_strategy,
-        args.port_direction,
-        args.protocol,
-        args.first_ttl,
-        args.max_ttl,
-        args.grace_duration,
-        args.min_round_duration,
-        args.max_round_duration,
-        args.max_inflight,
-        args.initial_sequence,
-        args.icmp_extension_parse_mode,
-        args.read_timeout,
-        args.packet_size,
-        args.payload_pattern,
-        args.interface.clone(),
-        args.geoip_mmdb_file.clone(),
-        args.dns_resolve_all,
-    )
-}
-
 /// Make the TUI configuration.
 fn make_tui_config(args: &TrippyConfig) -> TuiConfig {
     TuiConfig::new(
@@ -288,86 +203,29 @@ fn make_tui_config(args: &TrippyConfig) -> TuiConfig {
         args.tui_theme,
         &args.tui_bindings,
         &args.tui_custom_columns,
+        args.geoip_mmdb_file.clone(),
+        args.dns_resolve_all,
     )
 }
 
+/// Make the per-trace information.
+fn make_trace_info(tracer: Tracer, target: String) -> TraceInfo {
+    TraceInfo::new(tracer, target)
+}
+
 /// Information about a `Trace` needed for the Tui, stream and reports.
 #[derive(Debug, Clone)]
 pub struct TraceInfo {
-    pub data: Arc<RwLock<Trace>>,
-    pub source_addr: IpAddr,
+    pub data: Tracer,
     pub target_hostname: String,
-    pub target_addr: IpAddr,
-    pub privilege_mode: PrivilegeMode,
-    pub multipath_strategy: MultipathStrategy,
-    pub port_direction: PortDirection,
-    pub protocol: Protocol,
-    pub first_ttl: u8,
-    pub max_ttl: u8,
-    pub grace_duration: Duration,
-    pub min_round_duration: Duration,
-    pub max_round_duration: Duration,
-    pub max_inflight: u8,
-    pub initial_sequence: u16,
-    pub icmp_extensions: IcmpExtensionParseMode,
-    pub read_timeout: Duration,
-    pub packet_size: u16,
-    pub payload_pattern: u8,
-    pub interface: Option<String>,
-    pub geoip_mmdb_file: Option<String>,
-    pub dns_resolve_all: bool,
 }
 
 impl TraceInfo {
-    #[allow(clippy::too_many_arguments)]
     #[must_use]
-    pub fn new(
-        data: Arc<RwLock<Trace>>,
-        source_addr: IpAddr,
-        target_hostname: String,
-        target_addr: IpAddr,
-        privilege_mode: PrivilegeMode,
-        multipath_strategy: MultipathStrategy,
-        port_direction: PortDirection,
-        protocol: Protocol,
-        first_ttl: u8,
-        max_ttl: u8,
-        grace_duration: Duration,
-        min_round_duration: Duration,
-        max_round_duration: Duration,
-        max_inflight: u8,
-        initial_sequence: u16,
-        icmp_extensions: IcmpExtensionParseMode,
-        read_timeout: Duration,
-        packet_size: u16,
-        payload_pattern: u8,
-        interface: Option<String>,
-        geoip_mmdb_file: Option<String>,
-        dns_resolve_all: bool,
-    ) -> Self {
+    pub fn new(data: Tracer, target_hostname: String) -> Self {
         Self {
             data,
-            source_addr,
             target_hostname,
-            target_addr,
-            privilege_mode,
-            multipath_strategy,
-            port_direction,
-            protocol,
-            first_ttl,
-            max_ttl,
-            grace_duration,
-            min_round_duration,
-            max_round_duration,
-            max_inflight,
-            initial_sequence,
-            icmp_extensions,
-            read_timeout,
-            packet_size,
-            payload_pattern,
-            interface,
-            geoip_mmdb_file,
-            dns_resolve_all,
         }
     }
 }
diff --git a/crates/trippy/src/backend.rs b/crates/trippy/src/backend.rs
deleted file mode 100644
index 4647eb25f..000000000
--- a/crates/trippy/src/backend.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-use parking_lot::RwLock;
-use std::fmt::Debug;
-use std::sync::Arc;
-use trace::Trace;
-use tracing::instrument;
-use trippy_core::{ChannelConfig, Config, SocketImpl, Tracer, TracerChannel};
-use trippy_privilege::Privilege;
-
-pub mod flows;
-pub mod trace;
-
-/// A tracing backend.
-#[derive(Debug)]
-pub struct Backend {
-    tracer_config: Config,
-    channel_config: ChannelConfig,
-    trace: Arc<RwLock<Trace>>,
-}
-
-impl Backend {
-    /// Create a tracing `Backend`.
-    pub fn new(
-        tracer_config: Config,
-        channel_config: ChannelConfig,
-        max_samples: usize,
-        max_flows: usize,
-    ) -> Self {
-        Self {
-            tracer_config,
-            channel_config,
-            trace: Arc::new(RwLock::new(Trace::new(max_samples, max_flows))),
-        }
-    }
-
-    pub fn trace(&self) -> Arc<RwLock<Trace>> {
-        self.trace.clone()
-    }
-
-    /// Run the tracing backend.
-    ///
-    /// Note that this implementation blocks the tracer on the `RwLock` and so any delays in the the TUI
-    /// will delay the next round of the trace.
-    #[instrument(skip_all)]
-    pub fn start(&self) -> anyhow::Result<()> {
-        let td = self.trace.clone();
-        let channel =
-            TracerChannel::<SocketImpl>::connect(&self.channel_config).map_err(|err| {
-                td.write().set_error(Some(err.to_string()));
-                err
-            })?;
-        Privilege::drop_privileges()?;
-        let tracer = Tracer::new(&self.tracer_config, move |round| {
-            self.trace.write().update_from_round(round);
-        });
-        match tracer.trace(channel) {
-            Ok(()) => {}
-            Err(err) => {
-                td.write().set_error(Some(err.to_string()));
-            }
-        };
-        Ok(())
-    }
-}
diff --git a/crates/trippy/src/config.rs b/crates/trippy/src/config.rs
index 14a252d01..46cebfe61 100644
--- a/crates/trippy/src/config.rs
+++ b/crates/trippy/src/config.rs
@@ -9,6 +9,7 @@ use std::net::IpAddr;
 use std::time::Duration;
 use trippy_core::{
     defaults, IcmpExtensionParseMode, MultipathStrategy, PortDirection, PrivilegeMode, Protocol,
+    MAX_TTL,
 };
 use trippy_dns::{IpAddrFamily, ResolveMethod};
 
@@ -23,7 +24,6 @@ use crate::config::file::ConfigTui;
 pub use binding::{TuiBindings, TuiCommandItem, TuiKeyBinding};
 pub use cmd::Args;
 pub use columns::{TuiColumn, TuiColumns};
-pub use constants::MAX_HOPS;
 pub use theme::{TuiColor, TuiTheme, TuiThemeItem};
 use trippy_privilege::Privilege;
 
@@ -931,13 +931,13 @@ fn validate_flows(mode: Mode, strategy: MultipathStrategy) -> anyhow::Result<()>
 
 /// Validate `first_ttl` and `max_ttl`.
 fn validate_ttl(first_ttl: u8, max_ttl: u8) -> anyhow::Result<()> {
-    if (first_ttl as usize) < 1 || (first_ttl as usize) > MAX_HOPS {
+    if !(1..=MAX_TTL).contains(&first_ttl) {
         Err(anyhow!(
-            "first-ttl ({first_ttl}) must be in the range 1..{MAX_HOPS}"
+            "first-ttl ({first_ttl}) must be in the range 1..{MAX_TTL}"
         ))
-    } else if (max_ttl as usize) < 1 || (max_ttl as usize) > MAX_HOPS {
+    } else if !(1..=MAX_TTL).contains(&max_ttl) {
         Err(anyhow!(
-            "max-ttl ({max_ttl}) must be in the range 1..{MAX_HOPS}"
+            "max-ttl ({max_ttl}) must be in the range 1..{MAX_TTL}"
         ))
     } else if first_ttl > max_ttl {
         Err(anyhow!(
@@ -1261,12 +1261,12 @@ mod tests {
     #[test_case("trip example.com", Ok(cfg().first_ttl(1).build()); "default first ttl")]
     #[test_case("trip example.com --first-ttl 5", Ok(cfg().first_ttl(5).build()); "custom first ttl")]
     #[test_case("trip example.com -f 5", Ok(cfg().first_ttl(5).build()); "custom first ttl short")]
-    #[test_case("trip example.com --first-ttl 0", Err(anyhow!("first-ttl (0) must be in the range 1..255")); "invalid low first ttl")]
+    #[test_case("trip example.com --first-ttl 0", Err(anyhow!("first-ttl (0) must be in the range 1..254")); "invalid low first ttl")]
     #[test_case("trip example.com --first-ttl 500", Err(anyhow!("error: invalid value '500' for '--first-ttl <FIRST_TTL>': 500 is not in 0..=255 For more information, try '--help'.")); "invalid high first ttl")]
     #[test_case("trip example.com", Ok(cfg().first_ttl(1).build()); "default max ttl")]
     #[test_case("trip example.com --max-ttl 5", Ok(cfg().max_ttl(5).build()); "custom max ttl")]
     #[test_case("trip example.com -t 5", Ok(cfg().max_ttl(5).build()); "custom max ttl short")]
-    #[test_case("trip example.com --max-ttl 0", Err(anyhow!("max-ttl (0) must be in the range 1..255")); "invalid low max ttl")]
+    #[test_case("trip example.com --max-ttl 0", Err(anyhow!("max-ttl (0) must be in the range 1..254")); "invalid low max ttl")]
     #[test_case("trip example.com --max-ttl 500", Err(anyhow!("error: invalid value '500' for '--max-ttl <MAX_TTL>': 500 is not in 0..=255 For more information, try '--help'.")); "invalid high max ttl")]
     #[test_case("trip example.com --first-ttl 3 --max-ttl 2", Err(anyhow!("first-ttl (3) must be less than or equal to max-ttl (2)")); "first ttl higher than max ttl")]
     #[test_case("trip example.com --first-ttl 5 --max-ttl 5", Ok(cfg().first_ttl(5).max_ttl(5).build()); "custom first and max ttl")]
diff --git a/crates/trippy/src/config/constants.rs b/crates/trippy/src/config/constants.rs
index 78b91eb34..148035446 100644
--- a/crates/trippy/src/config/constants.rs
+++ b/crates/trippy/src/config/constants.rs
@@ -4,12 +4,6 @@ use crate::config::{
 };
 use std::time::Duration;
 
-/// The maximum number of hops we allow.
-///
-/// The IP `ttl` is a u8 (0..255) but since a `ttl` of zero isn't useful we only allow 255 distinct
-/// hops.
-pub const MAX_HOPS: usize = u8::MAX as usize;
-
 /// The default value for `mode`.
 pub const DEFAULT_MODE: Mode = Mode::Tui;
 
diff --git a/crates/trippy/src/frontend/config.rs b/crates/trippy/src/frontend/config.rs
index 353420c74..8b8249710 100644
--- a/crates/trippy/src/frontend/config.rs
+++ b/crates/trippy/src/frontend/config.rs
@@ -32,6 +32,8 @@ pub struct TuiConfig {
     pub bindings: Bindings,
     /// The columns to display in the hops table.
     pub tui_columns: Columns,
+    pub geoip_mmdb_file: Option<String>,
+    pub dns_resolve_all: bool,
 }
 
 impl TuiConfig {
@@ -49,6 +51,9 @@ impl TuiConfig {
         tui_theme: TuiTheme,
         tui_bindings: &TuiBindings,
         tui_columns: &TuiColumns,
+
+        geoip_mmdb_file: Option<String>,
+        dns_resolve_all: bool,
     ) -> Self {
         Self {
             refresh_rate,
@@ -63,6 +68,8 @@ impl TuiConfig {
             theme: Theme::from(tui_theme),
             bindings: Bindings::from(*tui_bindings),
             tui_columns: Columns::from(tui_columns.clone()),
+            geoip_mmdb_file,
+            dns_resolve_all,
         }
     }
 }
diff --git a/crates/trippy/src/frontend/render/app.rs b/crates/trippy/src/frontend/render/app.rs
index 53736cd26..84d19033f 100644
--- a/crates/trippy/src/frontend/render/app.rs
+++ b/crates/trippy/src/frontend/render/app.rs
@@ -26,12 +26,13 @@ use ratatui::Frame;
 /// |                 |                  |
 ///  ------------------------------------
 ///
-/// Header - the title, configuration, destination, clock and keyboard controls
-/// Tab - a tab for each target being traced (shown if > 1 target requested, can't be used with flows)
-/// Flows - a navigable chart of individual trace flows (toggled on/off, can't be used with tabs)
-/// Hops - a table where each row represents a single hop (time-to-live) in the trace
-/// History - a graph of historic round-trip ping samples for the target host
-/// Frequency - a histogram of sample frequencies by round-trip time for the target host
+/// - Header: the title, configuration, destination, clock and keyboard controls
+/// - Tab: a tab for each target being traced (shown if > 1 target requested, can't be used with
+///   flows)
+/// - Flows: a navigable chart of individual trace flows (toggled on/off, can't be used with tabs)
+/// - Hops: a table where each row represents a single hop (time-to-live) in the trace
+/// - History: a graph of historic round-trip ping samples for the target host
+/// - Frequency: a histogram of sample frequencies by round-trip time for the target host
 ///
 /// On startup a splash screen is shown in place of the hops table, until the completion of the
 /// first round.
diff --git a/crates/trippy/src/frontend/render/body.rs b/crates/trippy/src/frontend/render/body.rs
index e61295e78..6f9a98fdd 100644
--- a/crates/trippy/src/frontend/render/body.rs
+++ b/crates/trippy/src/frontend/render/body.rs
@@ -1,8 +1,8 @@
-use crate::backend::trace::Trace;
 use crate::frontend::render::{bsod, chart, splash, table, world};
 use crate::frontend::tui_app::TuiApp;
 use ratatui::layout::Rect;
 use ratatui::Frame;
+use trippy_core::TraceState;
 
 /// Render the body.
 ///
@@ -11,7 +11,11 @@ use ratatui::Frame;
 pub fn render(f: &mut Frame<'_>, rec: Rect, app: &mut TuiApp) {
     if let Some(err) = app.selected_tracer_data.error() {
         bsod::render(f, rec, err);
-    } else if app.tracer_data().hops(Trace::default_flow_id()).is_empty() {
+    } else if app
+        .tracer_data()
+        .hops(TraceState::default_flow_id())
+        .is_empty()
+    {
         splash::render(f, app, rec);
     } else if app.show_chart {
         chart::render(f, app, rec);
diff --git a/crates/trippy/src/frontend/render/header.rs b/crates/trippy/src/frontend/render/header.rs
index 9390f336e..043fa0746 100644
--- a/crates/trippy/src/frontend/render/header.rs
+++ b/crates/trippy/src/frontend/render/header.rs
@@ -40,22 +40,22 @@ pub fn render(f: &mut Frame<'_>, app: &TuiApp, rect: Rect) {
         .style(Style::default())
         .block(header_block.clone())
         .alignment(Alignment::Right);
-    let protocol = match app.tracer_config().protocol {
+    let protocol = match app.tracer_config().data.protocol() {
         Protocol::Icmp => format!(
             "icmp({}, {})",
-            render_target_family(app.tracer_config().target_addr),
-            app.tracer_config().privilege_mode
+            render_target_family(app.tracer_config().data.target_addr()),
+            app.tracer_config().data.privilege_mode()
         ),
         Protocol::Udp => format!(
             "udp({}, {}, {})",
-            render_target_family(app.tracer_config().target_addr),
-            app.tracer_config().multipath_strategy,
-            app.tracer_config().privilege_mode
+            render_target_family(app.tracer_config().data.target_addr()),
+            app.tracer_config().data.multipath_strategy(),
+            app.tracer_config().data.privilege_mode()
         ),
         Protocol::Tcp => format!(
             "tcp({}, {})",
-            render_target_family(app.tracer_config().target_addr),
-            app.tracer_config().privilege_mode
+            render_target_family(app.tracer_config().data.target_addr()),
+            app.tracer_config().data.privilege_mode()
         ),
     };
     let details = if app.show_hop_details {
@@ -138,28 +138,29 @@ fn render_target_family(target: IpAddr) -> &'static str {
 
 /// Render the source address of the trace.
 fn render_source(app: &TuiApp) -> String {
-    let src_hostname = app
-        .resolver
-        .lazy_reverse_lookup(app.tracer_config().source_addr);
-    let src_addr = app.tracer_config().source_addr;
-    match app.tracer_config().port_direction {
-        PortDirection::None => {
-            format!("{src_hostname} ({src_addr})")
-        }
-        PortDirection::FixedDest(_) => {
-            format!("{src_hostname}:* ({src_addr}:*)")
-        }
-        PortDirection::FixedSrc(src) | PortDirection::FixedBoth(src, _) => {
-            format!("{src_hostname}:{} ({src_addr}:{})", src.0, src.0)
+    if let Some(src_addr) = app.tracer_config().data.source_addr() {
+        let src_hostname = app.resolver.lazy_reverse_lookup(src_addr);
+        match app.tracer_config().data.port_direction() {
+            PortDirection::None => {
+                format!("{src_hostname} ({src_addr})")
+            }
+            PortDirection::FixedDest(_) => {
+                format!("{src_hostname}:* ({src_addr}:*)")
+            }
+            PortDirection::FixedSrc(src) | PortDirection::FixedBoth(src, _) => {
+                format!("{src_hostname}:{} ({src_addr}:{})", src.0, src.0)
+            }
         }
+    } else {
+        String::from("unknown")
     }
 }
 
 /// Render the destination address.
 fn render_destination(app: &TuiApp) -> String {
     let dest_hostname = &app.tracer_config().target_hostname;
-    let dest_addr = app.tracer_config().target_addr;
-    match app.tracer_config().port_direction {
+    let dest_addr = app.tracer_config().data.target_addr();
+    match app.tracer_config().data.port_direction() {
         PortDirection::None => {
             format!("{dest_hostname} ({dest_addr})")
         }
diff --git a/crates/trippy/src/frontend/render/settings.rs b/crates/trippy/src/frontend/render/settings.rs
index 552f7f532..8677491ec 100644
--- a/crates/trippy/src/frontend/render/settings.rs
+++ b/crates/trippy/src/frontend/render/settings.rs
@@ -199,43 +199,55 @@ fn format_tui_settings(app: &TuiApp) -> Vec<SettingsItem> {
 /// Format trace settings.
 fn format_trace_settings(app: &TuiApp) -> Vec<SettingsItem> {
     let cfg = app.tracer_config();
-    let interface = if let Some(iface) = cfg.interface.as_deref() {
+    let interface = if let Some(iface) = cfg.data.interface() {
         iface.to_string()
     } else {
         "auto".to_string()
     };
-    let (src_port, dst_port) = match cfg.port_direction {
+    let (src_port, dst_port) = match cfg.data.port_direction() {
         PortDirection::None => ("n/a".to_string(), "n/a".to_string()),
         PortDirection::FixedDest(dst) => ("auto".to_string(), format!("{}", dst.0)),
         PortDirection::FixedSrc(src) => (format!("{}", src.0), "auto".to_string()),
         PortDirection::FixedBoth(src, dst) => (format!("{}", src.0), format!("{}", dst.0)),
     };
     vec![
-        SettingsItem::new("first-ttl", format!("{}", cfg.first_ttl)),
-        SettingsItem::new("max-ttl", format!("{}", cfg.max_ttl)),
+        SettingsItem::new("first-ttl", format!("{}", cfg.data.first_ttl().0)),
+        SettingsItem::new("max-ttl", format!("{}", cfg.data.max_ttl().0)),
         SettingsItem::new(
             "min-round-duration",
-            format!("{}", format_duration(cfg.min_round_duration)),
+            format!("{}", format_duration(cfg.data.min_round_duration())),
         ),
         SettingsItem::new(
             "max-round-duration",
-            format!("{}", format_duration(cfg.max_round_duration)),
+            format!("{}", format_duration(cfg.data.max_round_duration())),
         ),
         SettingsItem::new(
             "grace-duration",
-            format!("{}", format_duration(cfg.grace_duration)),
+            format!("{}", format_duration(cfg.data.grace_duration())),
+        ),
+        SettingsItem::new("max-inflight", format!("{}", cfg.data.max_inflight().0)),
+        SettingsItem::new(
+            "initial-sequence",
+            format!("{}", cfg.data.initial_sequence().0),
         ),
-        SettingsItem::new("max-inflight", format!("{}", cfg.max_inflight)),
-        SettingsItem::new("initial-sequence", format!("{}", cfg.initial_sequence)),
         SettingsItem::new(
             "read-timeout",
-            format!("{}", format_duration(cfg.read_timeout)),
+            format!("{}", format_duration(cfg.data.read_timeout())),
+        ),
+        SettingsItem::new("packet-size", format!("{}", cfg.data.packet_size().0)),
+        SettingsItem::new(
+            "payload-pattern",
+            format!("{}", cfg.data.payload_pattern().0),
+        ),
+        SettingsItem::new(
+            "icmp-extensions",
+            format!("{}", cfg.data.icmp_extension_parse_mode()),
         ),
-        SettingsItem::new("packet-size", format!("{}", cfg.packet_size)),
-        SettingsItem::new("payload-pattern", format!("{}", cfg.payload_pattern)),
-        SettingsItem::new("icmp-extensions", format!("{}", cfg.icmp_extensions)),
         SettingsItem::new("interface", interface),
-        SettingsItem::new("multipath-strategy", cfg.multipath_strategy.to_string()),
+        SettingsItem::new(
+            "multipath-strategy",
+            cfg.data.multipath_strategy().to_string(),
+        ),
         SettingsItem::new("target-port", dst_port),
         SettingsItem::new("source-port", src_port),
         SettingsItem::new(
@@ -262,7 +274,7 @@ fn format_dns_settings(app: &TuiApp) -> Vec<SettingsItem> {
         ),
         SettingsItem::new(
             "dns-resolve-all",
-            format!("{}", app.tracer_config().dns_resolve_all),
+            format!("{}", app.tui_config.dns_resolve_all),
         ),
         SettingsItem::new(
             "dns-lookup-as-info",
@@ -275,7 +287,7 @@ fn format_dns_settings(app: &TuiApp) -> Vec<SettingsItem> {
 fn format_geoip_settings(app: &TuiApp) -> Vec<SettingsItem> {
     vec![SettingsItem::new(
         "geoip-mmdb-file",
-        app.tracer_config()
+        app.tui_config
             .geoip_mmdb_file
             .as_deref()
             .unwrap_or("none")
diff --git a/crates/trippy/src/frontend/render/table.rs b/crates/trippy/src/frontend/render/table.rs
index 8976d60f7..aa85b2f9a 100644
--- a/crates/trippy/src/frontend/render/table.rs
+++ b/crates/trippy/src/frontend/render/table.rs
@@ -1,4 +1,3 @@
-use crate::backend::trace::Hop;
 use crate::config::{AddressMode, AsMode, GeoIpMode, IcmpExtensionMode};
 use crate::frontend::columns::{ColumnType, Columns};
 use crate::frontend::config::TuiConfig;
@@ -12,6 +11,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, Table};
 use ratatui::Frame;
 use std::net::IpAddr;
 use std::rc::Rc;
+use trippy_core::Hop;
 use trippy_core::{Extension, Extensions, IcmpPacketType, MplsLabelStackMember, UnknownExtension};
 use trippy_dns::{AsInfo, DnsEntry, DnsResolver, Resolved, Resolver, Unresolved};
 
diff --git a/crates/trippy/src/frontend/render/world.rs b/crates/trippy/src/frontend/render/world.rs
index 0239f0664..efe090b59 100644
--- a/crates/trippy/src/frontend/render/world.rs
+++ b/crates/trippy/src/frontend/render/world.rs
@@ -1,4 +1,3 @@
-use crate::backend::trace::Hop;
 use crate::frontend::tui_app::TuiApp;
 use itertools::Itertools;
 use ratatui::layout::{Alignment, Constraint, Direction, Layout, Margin, Rect};
@@ -9,6 +8,7 @@ use ratatui::widgets::canvas::{Canvas, Circle, Context, Map, MapResolution, Rect
 use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
 use ratatui::Frame;
 use std::collections::HashMap;
+use trippy_core::Hop;
 
 /// Render the `GeoIp` map.
 pub fn render(f: &mut Frame<'_>, app: &TuiApp, rect: Rect) {
@@ -148,7 +148,7 @@ fn render_map_info_panel(f: &mut Frame<'_>, app: &TuiApp, rect: Rect, entries: &
         "**Hidden**".to_string()
     } else {
         match locations.as_slice() {
-            _ if app.tracer_config().geoip_mmdb_file.is_none() => "GeoIp not enabled".to_string(),
+            _ if app.tui_config.geoip_mmdb_file.is_none() => "GeoIp not enabled".to_string(),
             [] if selected_hop.addr_count() > 0 => format!(
                 "No GeoIp data for hop {} ({})",
                 selected_hop.ttl(),
diff --git a/crates/trippy/src/frontend/tui_app.rs b/crates/trippy/src/frontend/tui_app.rs
index a39cdaeee..dba819d4c 100644
--- a/crates/trippy/src/frontend/tui_app.rs
+++ b/crates/trippy/src/frontend/tui_app.rs
@@ -1,17 +1,17 @@
 use crate::app::TraceInfo;
-use crate::backend::flows::FlowId;
-use crate::backend::trace::Hop;
-use crate::backend::trace::Trace;
 use crate::frontend::config::TuiConfig;
 use crate::frontend::render::settings::{SETTINGS_TABS, SETTINGS_TAB_COLUMNS};
 use crate::geoip::GeoIpLookup;
 use itertools::Itertools;
 use ratatui::widgets::TableState;
 use std::time::SystemTime;
+use trippy_core::FlowId;
+use trippy_core::Hop;
+use trippy_core::TraceState;
 use trippy_dns::{DnsResolver, ResolveMethod};
 
 pub struct TuiApp {
-    pub selected_tracer_data: Trace,
+    pub selected_tracer_data: TraceState,
     pub trace_info: Vec<TraceInfo>,
     pub tui_config: TuiConfig,
     /// The state of the hop table.
@@ -54,7 +54,7 @@ impl TuiApp {
         trace_info: Vec<TraceInfo>,
     ) -> Self {
         Self {
-            selected_tracer_data: Trace::new(0, 0),
+            selected_tracer_data: TraceState::default(),
             trace_info,
             tui_config,
             table_state: TableState::default(),
@@ -62,7 +62,7 @@ impl TuiApp {
             trace_selected: 0,
             settings_tab_selected: 0,
             selected_hop_address: 0,
-            selected_flow: Trace::default_flow_id(),
+            selected_flow: TraceState::default_flow_id(),
             flow_counts: vec![],
             resolver,
             geoip_lookup,
@@ -78,19 +78,16 @@ impl TuiApp {
         }
     }
 
-    pub fn tracer_data(&self) -> &Trace {
+    pub fn tracer_data(&self) -> &TraceState {
         &self.selected_tracer_data
     }
 
     pub fn snapshot_trace_data(&mut self) {
-        self.selected_tracer_data = self.trace_info[self.trace_selected].data.read().clone();
+        self.selected_tracer_data = self.trace_info[self.trace_selected].data.snapshot();
     }
 
     pub fn clear_trace_data(&mut self) {
-        *self.trace_info[self.trace_selected].data.write() = Trace::new(
-            self.selected_tracer_data.max_samples(),
-            self.selected_tracer_data.max_flows(),
-        );
+        self.trace_info[self.trace_selected].data.clear();
     }
 
     pub fn selected_hop_or_target(&self) -> &Hop {
diff --git a/crates/trippy/src/lib.rs b/crates/trippy/src/lib.rs
index b454fd0e7..457f97570 100644
--- a/crates/trippy/src/lib.rs
+++ b/crates/trippy/src/lib.rs
@@ -1,5 +1,19 @@
 #![allow(rustdoc::broken_intra_doc_links, rustdoc::bare_urls)]
 #![doc = include_str!("../../../README.md")]
 
-// Re-export the core library, so it may be used from trippy crate.
-pub use trippy_core::*;
+// Re-export the user facing libraries, so they may be used from trippy crate directly.
+
+/// A network tracer.
+pub mod core {
+    pub use trippy_core::*;
+}
+
+/// A lazy DNS resolver.
+pub mod dns {
+    pub use trippy_dns::*;
+}
+
+/// Discover platform privileges.
+pub mod privilege {
+    pub use trippy_privilege::*;
+}
diff --git a/crates/trippy/src/main.rs b/crates/trippy/src/main.rs
index 5ebf6301e..43452b1c2 100644
--- a/crates/trippy/src/main.rs
+++ b/crates/trippy/src/main.rs
@@ -22,7 +22,6 @@ use std::process;
 use trippy_privilege::Privilege;
 
 mod app;
-mod backend;
 mod config;
 mod frontend;
 mod geoip;
diff --git a/crates/trippy/src/report.rs b/crates/trippy/src/report.rs
index 1e37f9991..1f0976f4d 100644
--- a/crates/trippy/src/report.rs
+++ b/crates/trippy/src/report.rs
@@ -1,7 +1,6 @@
-use crate::backend::trace::Trace;
 use anyhow::anyhow;
-use parking_lot::RwLock;
-use std::sync::Arc;
+use trippy_core::TraceState;
+use trippy_core::Tracer;
 
 pub mod csv;
 pub mod dot;
@@ -13,12 +12,12 @@ pub mod table;
 mod types;
 
 /// Block until trace data for round `round` is available.
-fn wait_for_round(trace_data: &Arc<RwLock<Trace>>, report_cycles: usize) -> anyhow::Result<Trace> {
-    let mut trace = trace_data.read().clone();
-    while trace.round(Trace::default_flow_id()).is_none()
-        || trace.round(Trace::default_flow_id()) < Some(report_cycles - 1)
+fn wait_for_round(trace_data: &Tracer, report_cycles: usize) -> anyhow::Result<TraceState> {
+    let mut trace = trace_data.snapshot();
+    while trace.round(TraceState::default_flow_id()).is_none()
+        || trace.round(TraceState::default_flow_id()) < Some(report_cycles - 1)
     {
-        trace = trace_data.read().clone();
+        trace = trace_data.snapshot();
         if let Some(err) = trace.error() {
             return Err(anyhow!("error: {}", err));
         }
diff --git a/crates/trippy/src/report/csv.rs b/crates/trippy/src/report/csv.rs
index 9b2a83642..0eb8a8679 100644
--- a/crates/trippy/src/report/csv.rs
+++ b/crates/trippy/src/report/csv.rs
@@ -1,10 +1,9 @@
 use crate::app::TraceInfo;
-use crate::backend;
-use crate::backend::trace::Trace;
 use crate::report::types::fixed_width;
 use itertools::Itertools;
 use serde::Serialize;
 use std::net::IpAddr;
+use trippy_core::TraceState;
 use trippy_dns::Resolver;
 
 /// Generate a CSV report of trace data.
@@ -15,8 +14,13 @@ pub fn report<R: Resolver>(
 ) -> anyhow::Result<()> {
     let trace = super::wait_for_round(&info.data, report_cycles)?;
     let mut writer = csv::Writer::from_writer(std::io::stdout());
-    for hop in trace.hops(Trace::default_flow_id()) {
-        let row = CsvRow::new(&info.target_hostname, info.target_addr, hop, resolver);
+    for hop in trace.hops(TraceState::default_flow_id()) {
+        let row = CsvRow::new(
+            &info.target_hostname,
+            info.data.target_addr(),
+            hop,
+            resolver,
+        );
         writer.serialize(row)?;
     }
     Ok(())
@@ -59,7 +63,7 @@ impl CsvRow {
     fn new<R: Resolver>(
         target: &str,
         target_addr: IpAddr,
-        hop: &backend::trace::Hop,
+        hop: &trippy_core::Hop,
         resolver: &R,
     ) -> Self {
         let ttl = hop.ttl();
diff --git a/crates/trippy/src/report/dot.rs b/crates/trippy/src/report/dot.rs
index a36cf5d7e..92559ecde 100644
--- a/crates/trippy/src/report/dot.rs
+++ b/crates/trippy/src/report/dot.rs
@@ -1,9 +1,9 @@
 use crate::app::TraceInfo;
-use crate::backend::flows::FlowEntry;
 use petgraph::dot::{Config, Dot};
 use petgraph::graphmap::DiGraphMap;
 use std::fmt::{Debug, Formatter};
 use std::net::{IpAddr, Ipv4Addr};
+use trippy_core::FlowEntry;
 
 /// Run a trace and generate a dot file.
 pub fn report(info: &TraceInfo, report_cycles: usize) -> anyhow::Result<()> {
@@ -14,7 +14,7 @@ pub fn report(info: &TraceInfo, report_cycles: usize) -> anyhow::Result<()> {
         }
     }
     super::wait_for_round(&info.data, report_cycles)?;
-    let trace = info.data.read().clone();
+    let trace = info.data.snapshot();
     let mut graph: DiGraphMap<IpAddr, ()> = DiGraphMap::new();
     for (flow, _id) in trace.flows() {
         for (fst, snd) in flow.entries.windows(2).map(|pair| (pair[0], pair[1])) {
diff --git a/crates/trippy/src/report/flows.rs b/crates/trippy/src/report/flows.rs
index f1c2aa759..0d49014ec 100644
--- a/crates/trippy/src/report/flows.rs
+++ b/crates/trippy/src/report/flows.rs
@@ -3,7 +3,7 @@ use crate::app::TraceInfo;
 /// Run a trace and report all flows observed.
 pub fn report(info: &TraceInfo, report_cycles: usize) -> anyhow::Result<()> {
     super::wait_for_round(&info.data, report_cycles)?;
-    let trace = info.data.read().clone();
+    let trace = info.data.snapshot();
     for (flow, flow_id) in trace.flows() {
         println!("flow {flow_id}: {flow}");
     }
diff --git a/crates/trippy/src/report/json.rs b/crates/trippy/src/report/json.rs
index ffd4f20ef..f3fde9916 100644
--- a/crates/trippy/src/report/json.rs
+++ b/crates/trippy/src/report/json.rs
@@ -1,6 +1,6 @@
 use crate::app::TraceInfo;
-use crate::backend::trace::Trace;
 use crate::report::types::{Hop, Host, Info, Report};
+use trippy_core::TraceState;
 use trippy_dns::Resolver;
 
 /// Generate a json report of trace data.
@@ -11,14 +11,14 @@ pub fn report<R: Resolver>(
 ) -> anyhow::Result<()> {
     let trace = super::wait_for_round(&info.data, report_cycles)?;
     let hops: Vec<Hop> = trace
-        .hops(Trace::default_flow_id())
+        .hops(TraceState::default_flow_id())
         .iter()
         .map(|hop| Hop::from((hop, resolver)))
         .collect();
     let report = Report {
         info: Info {
             target: Host {
-                ip: info.target_addr,
+                ip: info.data.target_addr(),
                 hostname: info.target_hostname.to_string(),
             },
         },
diff --git a/crates/trippy/src/report/stream.rs b/crates/trippy/src/report/stream.rs
index 99c9cbbce..1770d3639 100644
--- a/crates/trippy/src/report/stream.rs
+++ b/crates/trippy/src/report/stream.rs
@@ -1,19 +1,23 @@
 use crate::app::TraceInfo;
-use crate::backend::trace::Trace;
 use crate::report::types::Hop;
 use anyhow::anyhow;
 use std::thread::sleep;
+use trippy_core::TraceState;
 use trippy_dns::Resolver;
 
 /// Display a continuous stream of trace data.
 pub fn report<R: Resolver>(info: &TraceInfo, resolver: &R) -> anyhow::Result<()> {
-    println!("Tracing to {} ({})", info.target_hostname, info.target_addr);
+    println!(
+        "Tracing to {} ({})",
+        info.target_hostname,
+        info.data.target_addr()
+    );
     loop {
-        let trace_data = &info.data.read().clone();
+        let trace_data = &info.data.snapshot();
         if let Some(err) = trace_data.error() {
             return Err(anyhow!("error: {}", err));
         }
-        for hop in trace_data.hops(Trace::default_flow_id()) {
+        for hop in trace_data.hops(TraceState::default_flow_id()) {
             let hop = Hop::from((hop, resolver));
             let ttl = hop.ttl;
             let addrs = hop.hosts.to_string();
@@ -30,6 +34,6 @@ pub fn report<R: Resolver>(info: &TraceInfo, resolver: &R) -> anyhow::Result<()>
                 "ttl={ttl} addrs={addrs} exts={exts} loss_pct={loss_pct:.1} sent={sent} recv={recv} last={last:.1} best={best:.1} worst={worst:.1} avg={avg:.1} stddev={stddev:.1}"
             );
         }
-        sleep(info.min_round_duration);
+        sleep(info.data.min_round_duration());
     }
 }
diff --git a/crates/trippy/src/report/table.rs b/crates/trippy/src/report/table.rs
index e3639619c..f5fb5064d 100644
--- a/crates/trippy/src/report/table.rs
+++ b/crates/trippy/src/report/table.rs
@@ -1,8 +1,8 @@
 use crate::app::TraceInfo;
-use crate::backend::trace::Trace;
 use comfy_table::presets::{ASCII_MARKDOWN, UTF8_FULL};
 use comfy_table::{ContentArrangement, Table};
 use itertools::Itertools;
+use trippy_core::TraceState;
 use trippy_dns::Resolver;
 
 /// Generate a markdown table report of trace data.
@@ -38,7 +38,7 @@ fn run_report_table<R: Resolver>(
         .load_preset(preset)
         .set_content_arrangement(ContentArrangement::Dynamic)
         .set_header(columns);
-    for hop in trace.hops(Trace::default_flow_id()) {
+    for hop in trace.hops(TraceState::default_flow_id()) {
         let ttl = hop.ttl().to_string();
         let ips = hop.addrs().join("\n");
         let ip = if ips.is_empty() {
diff --git a/crates/trippy/src/report/types.rs b/crates/trippy/src/report/types.rs
index 33f349cab..49e805e9e 100644
--- a/crates/trippy/src/report/types.rs
+++ b/crates/trippy/src/report/types.rs
@@ -1,4 +1,3 @@
-use crate::backend;
 use itertools::Itertools;
 use serde::{Serialize, Serializer};
 use std::fmt::{Display, Formatter};
@@ -45,8 +44,8 @@ pub struct Hop {
     pub jinta: f64,
 }
 
-impl<R: Resolver> From<(&backend::trace::Hop, &R)> for Hop {
-    fn from((value, resolver): (&backend::trace::Hop, &R)) -> Self {
+impl<R: Resolver> From<(&trippy_core::Hop, &R)> for Hop {
+    fn from((value, resolver): (&trippy_core::Hop, &R)) -> Self {
         let hosts = Hosts::from((value.addrs(), resolver));
         let extensions = value.extensions().map(Extensions::from).unwrap_or_default();
         Self {
diff --git a/examples/hello-world/Cargo.toml b/examples/hello-world/Cargo.toml
new file mode 100644
index 000000000..9f5223b3e
--- /dev/null
+++ b/examples/hello-world/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "hello-world"
+version = "0.1.0"
+license = "Apache-2.0"
+edition = "2021"
+rust-version = "1.75"
+
+[dependencies]
+trippy = { version = "0.11.0-dev", path = "../../crates/trippy" }
+anyhow = "1.0.86"
\ No newline at end of file
diff --git a/examples/hello-world/src/main.rs b/examples/hello-world/src/main.rs
new file mode 100644
index 000000000..8cdba8bb1
--- /dev/null
+++ b/examples/hello-world/src/main.rs
@@ -0,0 +1,10 @@
+use std::str::FromStr;
+use trippy::core::Builder;
+
+fn main() -> anyhow::Result<()> {
+    let addr = std::net::IpAddr::from_str("1.1.1.1")?;
+    Builder::new(addr)
+        .build()?
+        .run_with(|round| println!("{:?}", round))?;
+    Ok(())
+}
diff --git a/examples/traceroute/Cargo.toml b/examples/traceroute/Cargo.toml
new file mode 100644
index 000000000..7b7fc76af
--- /dev/null
+++ b/examples/traceroute/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "traceroute"
+version = "0.1.0"
+license = "Apache-2.0"
+edition = "2021"
+rust-version = "1.75"
+
+[dependencies]
+trippy = { version = "0.11.0-dev", path = "../../crates/trippy" }
+anyhow = "1.0.86"
+itertools = "0.13.0"
+clap = "4.5.7"
\ No newline at end of file
diff --git a/examples/traceroute/src/main.rs b/examples/traceroute/src/main.rs
new file mode 100644
index 000000000..bbe1888b3
--- /dev/null
+++ b/examples/traceroute/src/main.rs
@@ -0,0 +1,119 @@
+use anyhow::anyhow;
+use clap::Parser;
+use itertools::Itertools;
+use std::net::IpAddr;
+use std::str::FromStr;
+use std::time::Duration;
+use trippy::core::{Builder, Port, PortDirection, Protocol, TraceState};
+use trippy::dns::{Config, DnsResolver, Resolver};
+
+/// A toy clone of BSD4.3 (macOS) traceroute.
+///
+/// *** This is for demonstration purposes only. ***
+#[derive(Parser, Debug)]
+#[command(version, about, long_about = None, arg_required_else_help(true))]
+struct Args {
+    host: String,
+    #[arg(short = 'f')]
+    first_ttl: Option<u8>,
+    #[arg(short = 'm')]
+    max_ttl: Option<u8>,
+    #[arg(short = 'i')]
+    interface: Option<String>,
+    #[arg(short = 'p')]
+    port: Option<u16>,
+    #[arg(short = 'q')]
+    nqueries: Option<usize>,
+    #[arg(short = 's')]
+    src_addr: Option<String>,
+    #[arg(short = 't')]
+    tos: Option<u8>,
+    #[arg(short = 'z')]
+    pausemsecs: Option<u64>,
+    #[arg(short = 'e')]
+    evasion: bool,
+}
+
+fn main() -> anyhow::Result<()> {
+    let args = Args::parse();
+    let hostname = args.host;
+    let interface = args.interface;
+    let src_addr = args
+        .src_addr
+        .as_ref()
+        .map(|addr| IpAddr::from_str(addr))
+        .transpose()?;
+    let port = args.port.unwrap_or(33434);
+    let first_ttl = args.first_ttl.unwrap_or(1);
+    let max_ttl = args.max_ttl.unwrap_or(64);
+    let nqueries = args.nqueries.unwrap_or(3);
+    let tos = args.tos.unwrap_or(0);
+    let pausemecs = args.pausemsecs.unwrap_or(100);
+    let port_direction = if args.evasion {
+        PortDirection::FixedDest(Port(port))
+    } else {
+        PortDirection::FixedSrc(Port(port))
+    };
+    let resolver = DnsResolver::start(Config::default())?;
+    let addrs: Vec<_> = resolver
+        .lookup(&hostname)
+        .map_err(|_| anyhow!(format!("traceroute: unknown host {}", hostname)))?
+        .into_iter()
+        .collect();
+    let addr = match addrs.as_slice() {
+        [] => return Err(anyhow!("traceroute: unknown host {}", hostname)),
+        [addr] => *addr,
+        [addr, ..] => {
+            println!("traceroute: Warning: bbc.co.uk has multiple addresses; using {addr}");
+            *addr
+        }
+    };
+    let tracer = Builder::new(addr)
+        .interface(interface)
+        .source_addr(src_addr)
+        .protocol(Protocol::Udp)
+        .port_direction(port_direction)
+        .packet_size(52)
+        .first_ttl(first_ttl)
+        .max_ttl(max_ttl)
+        .tos(tos)
+        .max_flows(1)
+        .max_rounds(Some(nqueries))
+        .min_round_duration(Duration::from_millis(pausemecs))
+        .max_round_duration(Duration::from_millis(pausemecs))
+        .build()?;
+    println!(
+        "traceroute to {} ({}), {} hops max, {} byte packets",
+        &hostname,
+        tracer.target_addr(),
+        tracer.max_ttl().0,
+        tracer.packet_size().0
+    );
+    tracer.run()?;
+    let snapshot = &tracer.snapshot();
+    if let Some(err) = snapshot.error() {
+        return Err(anyhow!("error: {}", err));
+    }
+    for hop in snapshot.hops(TraceState::default_flow_id()) {
+        let ttl = hop.ttl();
+        let samples: String = hop
+            .samples()
+            .iter()
+            .map(|s| format!("{:.3} ms", s.as_secs_f64() * 1000_f64))
+            .join("  ");
+        if hop.addr_count() > 0 {
+            for (i, addr) in hop.addrs().enumerate() {
+                let host = resolver.reverse_lookup(*addr).to_string();
+                let address = format!("{host} ({addr})");
+                if i != 0 {
+                    println!("    {address} {samples}");
+                } else {
+                    println!(" {ttl}  {address} {samples}");
+                }
+            }
+        } else {
+            println!(" {ttl}  * * * {samples}");
+        }
+    }
+    Ok(())
+}


Test patch:
diff --git a/crates/trippy/tests/resources/backend/ipv4_3probes_3hops_completed.yaml b/crates/trippy-core/tests/resources/backend/ipv4_3probes_3hops_completed.yaml
similarity index 100%
rename from crates/trippy/tests/resources/backend/ipv4_3probes_3hops_completed.yaml
rename to crates/trippy-core/tests/resources/backend/ipv4_3probes_3hops_completed.yaml
diff --git a/crates/trippy/tests/resources/backend/ipv4_3probes_3hops_mixed_multi.yaml b/crates/trippy-core/tests/resources/backend/ipv4_3probes_3hops_mixed_multi.yaml
similarity index 100%
rename from crates/trippy/tests/resources/backend/ipv4_3probes_3hops_mixed_multi.yaml
rename to crates/trippy-core/tests/resources/backend/ipv4_3probes_3hops_mixed_multi.yaml
diff --git a/crates/trippy/tests/resources/backend/ipv4_4probes_0latency.yaml b/crates/trippy-core/tests/resources/backend/ipv4_4probes_0latency.yaml
similarity index 100%
rename from crates/trippy/tests/resources/backend/ipv4_4probes_0latency.yaml
rename to crates/trippy-core/tests/resources/backend/ipv4_4probes_0latency.yaml
diff --git a/crates/trippy/tests/resources/backend/ipv4_4probes_all_status.yaml b/crates/trippy-core/tests/resources/backend/ipv4_4probes_all_status.yaml
similarity index 100%
rename from crates/trippy/tests/resources/backend/ipv4_4probes_all_status.yaml
rename to crates/trippy-core/tests/resources/backend/ipv4_4probes_all_status.yaml
diff --git a/crates/trippy-core/tests/resources/simulation/ipv4_icmp_wrap.yaml b/crates/trippy-core/tests/resources/simulation/ipv4_icmp_wrap.yaml
index 515ef0b24..ff7174902 100644
--- a/crates/trippy-core/tests/resources/simulation/ipv4_icmp_wrap.yaml
+++ b/crates/trippy-core/tests/resources/simulation/ipv4_icmp_wrap.yaml
@@ -1,9 +1,9 @@
 name: IPv4/ICMP wrap sequence
-target: 10.0.0.107
-rounds: 10
+target: 10.0.0.130
+rounds: 20
 protocol: Icmp
 icmp_identifier: 8
-initial_sequence: 65000
+initial_sequence: 64511
 hops:
   - ttl: 1
     resp: !SingleHost
@@ -32,4 +32,96 @@ hops:
   - ttl: 7
     resp: !SingleHost
       addr: 10.0.0.107
+      rtt_ms: 0
+  - ttl: 8
+    resp: !SingleHost
+      addr: 10.0.0.108
+      rtt_ms: 0
+  - ttl: 9
+    resp: !SingleHost
+      addr: 10.0.0.109
+      rtt_ms: 0
+  - ttl: 10
+    resp: !SingleHost
+      addr: 10.0.0.110
+      rtt_ms: 0
+  - ttl: 11
+    resp: !SingleHost
+      addr: 10.0.0.111
+      rtt_ms: 0
+  - ttl: 12
+    resp: !SingleHost
+      addr: 10.0.0.112
+      rtt_ms: 0
+  - ttl: 13
+    resp: !SingleHost
+      addr: 10.0.0.113
+      rtt_ms: 0
+  - ttl: 14
+    resp: !SingleHost
+      addr: 10.0.0.114
+      rtt_ms: 0
+  - ttl: 15
+    resp: !SingleHost
+      addr: 10.0.0.115
+      rtt_ms: 0
+  - ttl: 16
+    resp: !SingleHost
+      addr: 10.0.0.116
+      rtt_ms: 0
+  - ttl: 17
+    resp: !SingleHost
+      addr: 10.0.0.117
+      rtt_ms: 0
+  - ttl: 18
+    resp: !SingleHost
+      addr: 10.0.0.118
+      rtt_ms: 0
+  - ttl: 19
+    resp: !SingleHost
+      addr: 10.0.0.119
+      rtt_ms: 0
+  - ttl: 20
+    resp: !SingleHost
+      addr: 10.0.0.120
+      rtt_ms: 0
+  - ttl: 21
+    resp: !SingleHost
+      addr: 10.0.0.121
+      rtt_ms: 0
+  - ttl: 22
+    resp: !SingleHost
+      addr: 10.0.0.122
+      rtt_ms: 0
+  - ttl: 23
+    resp: !SingleHost
+      addr: 10.0.0.123
+      rtt_ms: 0
+  - ttl: 24
+    resp: !SingleHost
+      addr: 10.0.0.124
+      rtt_ms: 0
+  - ttl: 25
+    resp: !SingleHost
+      addr: 10.0.0.125
+      rtt_ms: 0
+  - ttl: 26
+    resp: !SingleHost
+      addr: 10.0.0.126
+      rtt_ms: 0
+  - ttl: 27
+    resp: !SingleHost
+      addr: 10.0.0.127
+      rtt_ms: 0
+  - ttl: 28
+    resp: !SingleHost
+      addr: 10.0.0.128
+      rtt_ms: 0
+  - ttl: 29
+    resp: !SingleHost
+      addr: 10.0.0.129
+      rtt_ms: 0
+  - ttl: 30
+    resp: !SingleHost
+      addr: 10.0.0.130
       rtt_ms: 0
\ No newline at end of file
diff --git a/crates/trippy-core/tests/sim/network.rs b/crates/trippy-core/tests/sim/network.rs
index 0fd584d88..3b8c264f6 100644
--- a/crates/trippy-core/tests/sim/network.rs
+++ b/crates/trippy-core/tests/sim/network.rs
@@ -97,7 +97,8 @@ pub async fn run(
             }
         }
 
-        // if the ttl is greater than the largest ttl in our sim we will reply as the last node in the sim
+        // if the ttl is greater than the largest ttl in our sim we will reply as the last node in
+        // the sim
         let index = std::cmp::min(usize::from(ipv4.get_ttl()) - 1, sim.hops.len() - 1);
         let (reply_addr, reply_delay_ms) = match sim.hops[index].resp {
             Response::NoResponse => {
diff --git a/crates/trippy-core/tests/sim/tracer.rs b/crates/trippy-core/tests/sim/tracer.rs
index b3c520fc8..9f6577d1a 100644
--- a/crates/trippy-core/tests/sim/tracer.rs
+++ b/crates/trippy-core/tests/sim/tracer.rs
@@ -1,14 +1,13 @@
 use crate::simulation::{Response, Simulation, SingleHost};
 use std::cell::RefCell;
-use std::num::NonZeroUsize;
 use std::sync::Arc;
 use std::thread;
 use std::time::Duration;
 use tokio_util::sync::CancellationToken;
 use tracing::info;
 use trippy_core::{
-    defaults, Builder, CompletionReason, MaxRounds, MultipathStrategy, PacketSize, PayloadPattern,
-    PortDirection, PrivilegeMode, ProbeState, Protocol, Sequence, TimeToLive, TraceId, TracerRound,
+    defaults, Builder, CompletionReason, MultipathStrategy, PortDirection, PrivilegeMode,
+    ProbeState, Protocol, TimeToLive, TracerRound,
 };
 
 // The length of time to wait after the completion of the tracing before
@@ -50,27 +49,27 @@ impl Tracer {
 
     pub fn trace(&self) -> anyhow::Result<()> {
         let result = RefCell::new(Ok(()));
-        let tracer_res = Builder::new(self.sim.target, |round| self.validate_round(round, &result))
+        let tracer = Builder::new(self.sim.target)
             .privilege_mode(PrivilegeMode::from(self.sim.privilege_mode))
-            .trace_identifier(TraceId(self.sim.icmp_identifier))
-            .initial_sequence(Sequence(
+            .trace_identifier(self.sim.icmp_identifier)
+            .initial_sequence(
                 self.sim
                     .initial_sequence
                     .unwrap_or(defaults::DEFAULT_STRATEGY_INITIAL_SEQUENCE),
-            ))
+            )
             .protocol(Protocol::from(self.sim.protocol))
             .port_direction(PortDirection::from(self.sim.port_direction))
             .multipath_strategy(MultipathStrategy::from(self.sim.multipath_strategy))
-            .packet_size(PacketSize(
+            .packet_size(
                 self.sim
                     .packet_size
                     .unwrap_or(defaults::DEFAULT_STRATEGY_PACKET_SIZE),
-            ))
-            .payload_pattern(PayloadPattern(
+            )
+            .payload_pattern(
                 self.sim
                     .payload_pattern
                     .unwrap_or(defaults::DEFAULT_STRATEGY_PAYLOAD_PATTERN),
-            ))
+            )
             .min_round_duration(self.sim.min_round_duration.map_or(
                 defaults::DEFAULT_STRATEGY_MIN_ROUND_DURATION,
                 Duration::from_millis,
@@ -83,13 +82,10 @@ impl Tracer {
                 defaults::DEFAULT_STRATEGY_GRACE_DURATION,
                 Duration::from_millis,
             ))
-            .max_rounds(MaxRounds(
-                self.sim
-                    .rounds
-                    .and_then(NonZeroUsize::new)
-                    .unwrap_or(NonZeroUsize::MIN),
-            ))
-            .start()
+            .max_rounds(self.sim.rounds.or(Some(1)))
+            .build()?;
+        let tracer_res = tracer
+            .run_with(|round| self.validate_round(round, &result))
             .map_err(anyhow::Error::from);
         thread::sleep(CLEANUP_DELAY);
         self.token.cancel();