tailtriage-core 0.3.0

Framework-agnostic request instrumentation and run schema for tailtriage triage artifacts
Documentation

tailtriage-core

tailtriage-core is the framework-agnostic capture foundation for tailtriage.

Use it when you want explicit request lifecycle instrumentation and bounded JSON artifacts without controller, Axum, or Tokio runtime-sampler APIs unless you add them separately.

What this crate does

tailtriage-core owns capture-side lifecycle semantics:

  • request admission
  • queue/stage/inflight instrumentation
  • explicit request completion
  • bounded in-memory retention
  • JSON run artifact writing

For in-process analysis/report generation, use tailtriage-analyzer. For command-line analysis of saved artifacts, use tailtriage-cli.

Crate selection

Use tailtriage-core when you want the smallest framework-agnostic capture surface.

Use tailtriage when you want the recommended default entry point: an aggregator/re-export crate with optional integrations behind features.

Installation

cargo add tailtriage-core

Quick start

use tailtriage_core::Tailtriage;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let run = Tailtriage::builder("checkout-service")
        .output("tailtriage-run.json")
        .build()?;

    let started = run.begin_request("/checkout");
    started.completion.finish_ok();

    run.shutdown()?;
    Ok(())
}

Request lifecycle

begin_request(...) / begin_request_with(...) returns StartedRequest with:

  • started.handle for queue/stage/inflight instrumentation
  • started.completion for explicit finish

For Arc<Tailtriage> flows that move request handles across spawned tasks or helper layers, use begin_request_owned(...) / begin_request_with_owned(...). Owned handles keep the same lifecycle rule: instrumentation does not finish the request, and the completion token must be finished exactly once.

use tailtriage_core::{RequestOptions, Tailtriage};

async fn demo() -> Result<(), Box<dyn std::error::Error>> {
    let run = Tailtriage::builder("checkout-service")
        .output("tailtriage-run.json")
        .build()?;

    let started = run.begin_request_with(
        "/checkout",
        RequestOptions::new().request_id("req-1").kind("http"),
    );
    let req = started.handle.clone();

    req.queue("ingress").await_on(async {}).await;
    req.stage("db")
        .await_on(async { Ok::<(), std::io::Error>(()) })
        .await?;

    started.completion.finish_ok();
    run.shutdown()?;
    Ok(())
}

Output sinks

tailtriage-core captures run data and finalizes through a sink. It does not perform analysis/report generation.

  • LocalJsonSink (or builder .output(...)) writes Run artifact JSON to disk.
  • request_id is the per-run tailtriage identity of one completed logical request/work item. Explicit IDs should be unique among completed requests in one Run; queue and stage events should reuse an ID only for evidence from that same logical request. Duplicate retained completed IDs are allowed for backward compatibility but surface a lifecycle warning because request-scoped attribution can be ambiguous.
  • MemorySink stores finalized typed Run values in memory.
  • DiscardSink finalizes lifecycle and drops the finalized Run without persisting output.

MemorySink stores only the last finalized Run; each new finalized run replaces the previous stored value.

Use MemorySink when you want in-process analysis. DiscardSink drops finalized runs; use MemorySink instead when the finalized Run should be analyzed in process.

use tailtriage_core::{MemorySink, Tailtriage};

# fn example() -> Result<(), Box<dyn std::error::Error>> {
let sink = MemorySink::new();
let run = Tailtriage::builder("checkout-service")
    .sink(sink.clone())
    .build()?;

let started = run.begin_request("/checkout");
started.completion.finish_ok();
run.shutdown()?;

let finalized = sink.last_run();
# let _ = finalized;
# Ok(())
# }

Two easy-to-miss helpers

For infallible async work, StageTimer::await_value(...) avoids a dummy Result:

# use tailtriage_core::Tailtriage;
# async fn demo(run: Tailtriage) {
# let req = run.begin_request("/x").handle;
let value = req.stage("cache").await_value(async { 42 }).await;
# let _ = value;
# }

When queue depth is known at enqueue time, QueueTimer::with_depth_at_start(...) records it directly:

# use tailtriage_core::Tailtriage;
# async fn demo(run: Tailtriage) {
# let req = run.begin_request("/x").handle;
req.queue("ingress")
    .with_depth_at_start(12)
    .await_on(async {})
    .await;
# }

Lifecycle contract

  • queue(...), stage(...), and inflight(...) do not finish requests.
  • Every admitted request must be finished exactly once.
  • Dropping a completion token does not auto-finish.
  • Non-strict lifecycle: shutdown() writes the artifact and records unfinished-request warnings/metadata.
  • strict_lifecycle(true): unfinished requests cause shutdown() to return an error and no artifact is written.

Finalization timestamps:

  • Active snapshot() output is not finalized (metadata.finalized_at_unix_ms == None).
  • shutdown() writes final artifacts with both:
    • metadata.finished_at_unix_ms set to shutdown time
    • metadata.finalized_at_unix_ms set to that same timestamp
  • Older artifacts may deserialize with metadata.finalized_at_unix_ms == None.
  • When finalized_at_unix_ms is present, prefer that field as the finalization signal; finished_at_unix_ms remains for backward compatibility.

Timing model

  • Duration fields in microseconds (latency_us, wait_us, stage latency_us) are authoritative for elapsed-time analysis.
  • Unix millisecond timestamps are wall-clock anchors for log correlation, artifact readability, and coarse temporal grouping.
  • Wall-clock timestamps can be coarse and can move if the system clock changes.
  • Analyzer scoring uses duration fields for latency, queue wait, and stage duration.
  • Temporal segmentation prefers run-relative monotonic offsets when present and falls back to Unix-ms wall-clock anchors for older or imported artifacts without complete run-relative timing.

Capture modes

Modes change retention defaults only. They do not change lifecycle semantics and do not auto-start runtime sampling.

  • CaptureMode::Light
  • CaptureMode::Investigation

Override limits with:

  • capture_limits(...) (full override)
  • capture_limits_override(...) (field-level override)

Advanced: assembling completed run artifacts

Most users should use Tailtriage::builder(...) for live request instrumentation. Use RunBuilder only when you already have completed request, stage, queue, in-flight, or runtime evidence and need to assemble a standard Run artifact.

RunBuilder is intended for import/conversion paths. It does not perform live lifecycle tracking. RunBuilder::new validates top-level run timestamp ordering. Each push_* call validates required event/snapshot shape and timestamp ordering before retention is applied. Completed request latency, stage latency, and queue wait fields are authoritative evidence; RunBuilder does not synthesize, repair, or reject those durations based on wall-clock timestamp deltas. It applies the same bounded retention/truncation semantics as live core capture, so events beyond configured CaptureLimits are dropped without error and Run.truncation counters are updated. RunBuilder does not validate cross-event correlation (for example a stage without a matching request) and does not synthesize lifecycle completions. It surfaces a lifecycle warning when retained completed requests contain duplicate request IDs. For assembled/imported artifacts, host and pid default to None, and generated run IDs use the same core run-id semantics as live capture.

use tailtriage_core::{RequestEvent, RunBuilder, RunBuilderOptions};

fn assemble_run() -> Result<(), Box<dyn std::error::Error>> {
    let mut builder = RunBuilder::new(RunBuilderOptions::new("checkout-service"))?;
    builder.push_request(RequestEvent {
        request_id: "req-1".into(),
        route: "/test".into(),
        kind: Some("http".into()),
        started_at_unix_ms: 1,
        started_at_run_us: None,
        finished_at_unix_ms: 2,
        finished_at_run_us: None,
        latency_us: 1_000,
        outcome: "ok".into(),
    })?;

    let run = builder.finish();
    assert_eq!(run.requests.len(), 1);
    Ok(())
}

What this crate does not do

This crate does not provide:

  • repeated arm/disarm controller windows
  • Tokio runtime sampling
  • Axum middleware/extractors
  • analysis/report generation

Use sibling crates for those surfaces: tailtriage-controller, tailtriage-tokio, tailtriage-axum, tailtriage-analyzer, and tailtriage-cli.