Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Instrument tier — researcher / measurement surface

The Instrument tier exposes engine internals (PIT, FIB, CS, strategy table, measurements) and packet-tap primitives for researchers, measurement tooling, and in-process tests. It sits below the Extend tier: protocol authors implement traits; researchers read engine state directly.

Stability: Instrument items are feature-gated behind experimental-instrument. The shape of the feature surface follows SemVer; individual items inside may move between patch releases. Out-of-feature use is intentionally inconvenient (see §“gating” below).

When to reach for this tier

  • You’re measuring forwarding behaviour and want PIT / CS hit counts at the source.
  • You’re writing a researcher experiment that wires two in-process engines back-to-back and observes wire packets between them.
  • You’re building tooling (dashboard, OpenTelemetry bridge, packet sniffer) that needs structured engine state.

Inventory

ItemCrate pathPurpose
ForwarderEngine::fib() / rib() / pit() / cs() / strategy_table() / measurements() / routing() / discovery_ctx()ndn_engine::engine (crates/ndn-engine/src/engine.rs)Direct table access.
ContextEnricherndn_engine::enricherPipeline-stage hook for cross-layer enrichment.
observability::targetsndn_engine::observability::targetsTracing target taxonomy.
InProcFace::new_kindndn_face::local::InProcFaceSynthesize an in-process face with a custom FaceKind.
CallbackFacendn_face::callback::CallbackFaceVirtual face whose send-path is a Rust callback.
TapFacendn_face::callback::TapFaceRecords every wire packet sent to it without participating in forwarding.

Gating

Each carrier crate declares the feature:

# crates/ndn-engine/Cargo.toml — and likewise for ndn-face
[features]
experimental-instrument = []

Items carry #[cfg_attr(not(feature = "experimental-instrument"), doc(hidden))] so they remain pub (the workspace itself calls them) but are absent from cargo doc output unless the consuming crate opts in.

To use the tier in your own crate:

[dependencies]
ndn-engine = { version = "0.1", features = ["experimental-instrument"] }
ndn-face  = { version = "0.1", features = ["experimental-instrument"] }

TapFace

TapFace is the workhorse for wire-packet tracing.

use ndn_face::callback::TapFace;
use ndn_engine::{EngineBuilder, EngineConfig};
use ndn_transport::FaceId;

async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let tap = TapFace::new(FaceId(99));
// `.face()` consumes the transport, so grab a shared handle to the
// capture buffer first:
let buf = tap.capture_handle(); // Arc<Mutex<Vec<Bytes>>>
let (_engine, _shutdown) = EngineBuilder::new(EngineConfig::default())
    .face(tap)
    .build()
    .await?;

// Drive traffic, then read every wire packet the engine routed to FaceId(99).
// The handle's `Mutex` is `tokio::sync::Mutex`, so the lock is `.await`ed:
for bytes in buf.lock().await.iter() {
    // parse / inspect / log
}
Ok(()) }

When the tap is not handed to the builder, drain the buffer in one shot with the async captured() method, which returns Vec<Bytes> and clears the tap:

use ndn_face::callback::TapFace;
use ndn_transport::FaceId;
async fn run() {
let tap = TapFace::new(FaceId(99));
// ... drive traffic ...
let packets: Vec<bytes::Bytes> = tap.captured().await;
}

TapFace does not participate in forwarding: the engine sends to it, the bytes accumulate, and nothing is returned. Use it alongside real faces to record what the engine would have sent over them.

In-tree reference: crates/ndn-face/src/callback.rs.

Engine table access

With the feature enabled, the engine exposes its tables:

use ndn_engine::{EngineBuilder, EngineConfig};
use ndn_store::pit::{PitKeyDiscriminator, PitToken};
async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (engine, _shutdown) = EngineBuilder::new(EngineConfig::default())
    .build()
    .await?;

// FIB inspection. `dump()` returns a `Vec<(Name, Arc<FibEntry>)>`
// snapshot of every installed prefix; `nexthops` is a field on
// `FibEntry`, each carrying a `face_id` and a `cost`:
for (name, entry) in engine.fib().dump() {
    let faces: Vec<_> = entry.nexthops.iter().map(|n| n.face_id).collect();
    println!("{name} -> {faces:?}");
}

// PIT inspection. The PIT is a sharded `DashMap` keyed by `PitToken`,
// so there is no global iterator on the hot path. Read live size with
// `len()`, and inspect a known name under a closure with
// `with_named_entry` (which holds the shard lock for the call):
println!("pending interests: {}", engine.pit().len());

let name: ndn_packet::Name = "/probe".parse()?;
engine.pit().with_named_entry(
    &name,
    PitKeyDiscriminator::Classical,
    |entry| {
        println!(
            "{} has {} in-records",
            entry.name,
            entry.in_records.len(),
        );
    },
);
Ok(()) }

These accessors expose live tables. The PIT closures (with_entry, with_entry_mut, with_named_entry) hold the relevant shard lock for the duration of the call, so keep the body short. Mutating PIT state for injection experiments (e.g. fabricating in-records) is filed for v0.1.x.

CallbackFace

CallbackFace builds a virtual face whose send-path runs an application callback: the engine routes an Interest to it, the callback returns Some(Data) to satisfy it or None to emit a NoRoute Nack. Use it when a function can directly produce Data for any name.

The callback is async — it returns a BoxFuture<'static, Option<Data>>:

use ndn_face::callback::CallbackFace;
use ndn_packet::{Data, Interest};
use ndn_transport::FaceId;

let face = CallbackFace::new(FaceId(7), |interest: Interest| {
    Box::pin(async move {
        // ... look up / synthesize Data for `interest.name` ...
        None::<Data>
    })
});

When the lookup is synchronous, CallbackFace::from_fn takes a plain Fn(Interest) -> Option<Data> and wraps it for you:

use ndn_face::callback::CallbackFace;
use ndn_packet::{Data, Interest};
use ndn_transport::FaceId;
let face = CallbackFace::from_fn(FaceId(7), |_interest: Interest| {
    None::<Data>
});

Two-engine experiments

A common Instrument-tier pattern is wiring two engines through in-process faces. InProcFace::new(id, buffer) returns a linked (InProcFace, InProcHandle): the engine holds the face, and the handle drives the recv/send channels from the other side. (Use InProcFace::new_kind to stamp a non-default FaceKind on the engine side.)

use ndn_face::local::InProcFace;
use ndn_engine::{EngineBuilder, EngineConfig};
use ndn_transport::FaceId;

async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Each engine gets its own InProcFace; the test harness owns both
// handles and shuttles wire bytes between them.
let (face_a, handle_a) = InProcFace::new(FaceId(1), 64);
let (face_b, handle_b) = InProcFace::new(FaceId(2), 64);

let (_engine_a, _s_a) = EngineBuilder::new(EngineConfig::default()).face(face_a).build().await?;
let (_engine_b, _s_b) = EngineBuilder::new(EngineConfig::default()).face(face_b).build().await?;

// Forward whatever engine A emits into engine B (and vice-versa):
while let Some(bytes) = handle_a.recv().await {
    handle_b.send(bytes).await.ok();
}
Ok(()) }

The audit witness at testbed/tests/audit/phase3_fetch_object_rdr.sh uses this shape to verify segmented fetch_object end-to-end without opening any network sockets.

What this tier does not expose

  • Structured packet-trace export (jsonl, OTLP, pcap). TapFace ships raw bytes only; export formats are v0.2 candidates.
  • PIT injection / fake in-records. v0.1.x.
  • Strategy injection at runtime (bypass register_strategy!). v0.1.x if a use case appears.

See also

  • Develop tier — application-author surface.
  • Extend tier — protocol-author trait surface.
  • Loggingobservability::targets is the same taxonomy the operator-facing logging page uses.