Skip to content

feat(libsy): add OTel metrics, tracing spans, and structured logging#69

Open
eric-liu-nvidia wants to merge 10 commits into
mainfrom
eric-liu/switch-928-instrument-new-libsy-algorithms-observability
Open

feat(libsy): add OTel metrics, tracing spans, and structured logging#69
eric-liu-nvidia wants to merge 10 commits into
mainfrom
eric-liu/switch-928-instrument-new-libsy-algorithms-observability

Conversation

@eric-liu-nvidia

@eric-liu-nvidia eric-liu-nvidia commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements SWITCH-928: the observability layer for the libsy algorithm layer, built on OpenTelemetry per the discussion on the ticket (opentelemetry-rust as the metrics sink; tracing for spans and structured logs, which hosts bridge into OTel with tracing-opentelemetry).

All instrumentation lives in the libsy core at the Decision hook and the offload boundary, so algorithms carry no telemetry code beyond the new Algorithm::name() label:

  • Metrics (OTel global meter provider, scope libsy; the library owns no exporter): counters libsy.runs, libsy.llm_calls, libsy.decisions, libsy.{input,output,total,reasoning}_tokens; histograms libsy.run_duration_ms, libsy.llm_call_duration_ms. Attributes are algorithm / selected_model / outcome (ok|error), so failure rates fall out of the outcome split. Instrument naming and cardinality bounds follow the switchyard OTel conventions.
  • Spans (tracing): one libsy.run per request carrying the Metadata correlation ids and outcome, with one child libsy.llm_call per offloaded call carrying the selected model, latency, outcome, and token counts.
  • Structured logs (tracing, target libsy): an info event per published Decision with its reasoning; warn events for failed calls and runs.

API changes: new required Algorithm::name(&self) -> &str (implemented for the three reference algorithms), Usage re-exported from libsy, and the unused Driver::default() removed (drivers now carry the algorithm label).

With no OTel SDK or tracing subscriber installed, all instrumentation is a no-op.

Testing

  • New integration tests in crates/libsy/tests/observability.rs assert the success path (counter/histogram values, exact token counts, span parentage and fields, decision log) and the failure path (outcome=error at run and call level, error text on spans, warn logs) via an in-memory OTel exporter and a capturing tracing layer.
  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace all pass (50/50 test binaries).

Fixes SWITCH-928

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added observability for algorithm runs, model calls, and routing decisions through structured tracing, logs, and OpenTelemetry metrics.
    • Captures run outcomes, durations, model selections, errors, and token usage when available.
    • Added stable names for built-in algorithms to improve telemetry labeling.
  • Documentation

    • Expanded guidance for exploring the Rust libraries.
    • Documented algorithm naming and observability setup, including available metrics and tracing behavior.
  • Tests

    • Added coverage for successful and failed runs, telemetry data, spans, metrics, and structured logs.

@eric-liu-nvidia
eric-liu-nvidia requested a review from a team as a code owner July 15, 2026 15:43
@eric-liu-nvidia
eric-liu-nvidia force-pushed the eric-liu/switch-928-instrument-new-libsy-algorithms-observability branch from 22913d7 to 96d2593 Compare July 15, 2026 15:46
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

libsy adds a required algorithm name, instruments algorithm runs and LLM calls with tracing and OpenTelemetry metrics, records routing decisions, re-exports Usage, updates examples, and adds integration tests for successful and failed telemetry paths.

Changes

libsy observability

Layer / File(s) Summary
Observability contract and dependencies
crates/libsy/Cargo.toml, crates/libsy/src/lib.rs, crates/libsy/README.md, .agents/skills/...
The Algorithm contract gains name, Usage is re-exported, telemetry dependencies and documentation are added, and exploration guidance points to the Rust algorithm crates.
Telemetry helpers and recording
crates/libsy/src/observability.rs
Run and LLM-call spans, outcome and token metrics, structured logs, and routing-decision counters are implemented.
Algorithm naming and execution wiring
crates/libsy/src/lib.rs, crates/libsy-examples/src/*.rs
Driver and run_stream propagate algorithm names, instrument execution, record decisions, and update test and example algorithms with stable identifiers.
Observability integration validation
crates/libsy/tests/observability.rs
In-memory telemetry tests validate successful and failed runs, metrics, span relationships and fields, token handling, and structured logs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Poem

I’m a rabbit with metrics tucked under my ear,
Spans hop through runs so outcomes stay clear.
Tokens leave tracks, decisions softly glow,
Errors thump warnings wherever they go.
“Name every algorithm!” I cheer from the burrow below.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding OpenTelemetry metrics, tracing spans, and structured logging to libsy.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/libsy/tests/observability.rs (1)

194-253: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the shared traversal logic between u64_counter_value and f64_histogram_count.

Both functions repeat the same snapshot → scope-name-filter → metric-name-filter loop, differing only in the AggregatedMetrics variant and the value extracted. Worth factoring into one generic helper to avoid the two implementations drifting apart.

♻️ Sketch of a shared helper
fn matching_data_points<'a, T>(
    snapshots: &'a [ResourceMetrics],
    name: &str,
    wanted: &[(&str, &str)],
    extract: impl Fn(&AggregatedMetrics) -> Option<&dyn Iterator<Item = &'a T>>,
) -> Option<u64> {
    // shared scope/metric-name filtering, delegate value extraction to callers
    // (or simply parameterize on an `impl Fn(&Metric) -> Option<u64>` per data point)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy/tests/observability.rs` around lines 194 - 253, Extract the
duplicated snapshot, scope-name, metric-name, and attribute filtering from
u64_counter_value and f64_histogram_count into a shared helper. Parameterize the
helper with the metric-data/value extraction needed for each AggregatedMetrics
variant, then have both functions reuse it while preserving their existing
cumulative maximum behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/libsy/README.md`:
- Around line 153-154: Update the impl Algorithm for LlmClassifierOrchAlgo
example to implement the required name method, returning the classifier’s stable
telemetry label, so the documented snippet compiles.

In `@crates/libsy/src/lib.rs`:
- Around line 298-300: Update the info method to await
self.driver.info(decision) before calling observability::record_decision, and
only record the decision after the driver returns success; propagate any driver
error without recording it.

---

Nitpick comments:
In `@crates/libsy/tests/observability.rs`:
- Around line 194-253: Extract the duplicated snapshot, scope-name, metric-name,
and attribute filtering from u64_counter_value and f64_histogram_count into a
shared helper. Parameterize the helper with the metric-data/value extraction
needed for each AggregatedMetrics variant, then have both functions reuse it
while preserving their existing cumulative maximum behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 3c15084e-1e09-4aa7-8c59-f212393feaa9

📥 Commits

Reviewing files that changed from the base of the PR and between e62fe46 and 96d2593.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (9)
  • .agents/skills/switchyard-codebase-exploration/SKILL.md
  • crates/libsy-examples/src/ensemble.rs
  • crates/libsy-examples/src/llm_class.rs
  • crates/libsy-examples/src/rand.rs
  • crates/libsy/Cargo.toml
  • crates/libsy/README.md
  • crates/libsy/src/lib.rs
  • crates/libsy/src/observability.rs
  • crates/libsy/tests/observability.rs

Comment thread crates/libsy/README.md
Comment thread crates/libsy/src/lib.rs Outdated
Comment thread crates/libsy/src/lib.rs Outdated
Comment thread crates/libsy/src/lib.rs Outdated
.fulfill_request::<RoutedRequest, Response>(routed)
.await
let selected_model = routed.decision.selected_model().to_string();
let span = observability::llm_call_span(&self.algorithm, &selected_model);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to be careful with labeling here. This is the time, from the algorithms perspective on how long it took to fulfill the model request.

We should also have a span for the api call itself. ie in LlmClient.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs now state libsy.llm_call measures fulfillment as the algorithm observes it (host queueing/serving included), and a new libsy.client_call span wraps the actual provider call in the one place libsy performs it (run's default-client serve), with outcome/error recorded; hosts serving over their own transport are pointed at spanning their LlmClient equivalently.

Comment thread crates/libsy/src/observability.rs
Comment thread crates/libsy/src/lib.rs Outdated
Comment on lines +258 to +274
async {
let started = Instant::now();
let result = self
.driver
.fulfill_request::<RoutedRequest, Response>(routed)
.await;
observability::record_llm_call(
&self.algorithm,
&selected_model,
started.elapsed(),
&result,
&tracing::Span::current(),
);
result
}
.instrument(span)
.await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems ugly to me. Can we have a helper so there is not so much code just to add a span? It's hard to tell what is really going on which is just a simple driver.fulfill_request

Is there not some rusty way of instrumenting the method. Like using an raii object.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new observe_llm_call / observe_run future-wrapping helpers own all span/timing/record plumbing; call_llm is now three lines that read as "fulfill_request, observed". (A literal RAII guard doesn't work here — tracing's entered-guards can't be held across .awaits — so the future adapter is the rusty idiom.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think this could be cleaner. Could we use something like https://crates.io/crates/tracing
and https://github.com/tokio-rs/tracing-opentelemetry

That way it could look something like

use tracing::{info, info_span};

fn process_data() {
    let span = info_span!("process_data");
    let _guard = span.enter(); // RAII guard acquired

    info!("Doing work inside the OTel-backed span");
} // _guard is dropped here, automatically exiting the span

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm I missed the await spans + async thing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we can use `#[instrument] in the methods though.

https://github.com/tokio-rs/tracing#in-asynchronous-code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

span.enter() guards can't be held across .await — the tracing docs call this out directly: "Holding the drop guard returned by Span::enter across .await points will result in incorrect traces." (https://docs.rs/tracing/latest/tracing/struct.Span.html#in-asynchronous-code) For async code, the docs prescribe Future::instrument, which is exactly what observe_llm_call uses — we're already on tracing + the tracing-opentelemetry bridge. I tried the guard version to be sure: our own span test then shows client_call mis-parented under llm_call (thread-state leakage from the suspended task). 1e7dbde adds an assertion that fails red under the guard pattern.

Also, the call site is now three lines after the earlier hoist — this snippet is outdated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — #[instrument] works for the method spans, and it came out nicer: call_llm and serve now declare their spans as attributes, and the three span-constructor helpers are gone (net −23 lines). Two caveats: the libsy.run span keeps explicit Future::instrument because it wraps the spawned run task rather than a method body, and the timing/metrics recording stays in-body since it needs the result and elapsed time. The parentage tests pass unchanged, so the traces are identical. Done in af6f871.

@eric-liu-nvidia
eric-liu-nvidia force-pushed the eric-liu/switch-928-instrument-new-libsy-algorithms-observability branch from da2b662 to 27ab3f4 Compare July 21, 2026 01:33
@grahamking

grahamking commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

I think this PR tries to do too much. Should we start by defining what metrics libsy should report? That's probably a conversation for Slack.

Then we could add one in a small PR (just the new dependency and the one metric line), and go from there.

Signed-off-by: Eric Liu <zengyuanl@nvidia.com>
Signed-off-by: Eric Liu <zengyuanl@nvidia.com>
Signed-off-by: Eric Liu <zengyuanl@nvidia.com>
Signed-off-by: Eric Liu <zengyuanl@nvidia.com>
…e_llm_call

Signed-off-by: Eric Liu <zengyuanl@nvidia.com>
…ming

Signed-off-by: Eric Liu <zengyuanl@nvidia.com>
Signed-off-by: Eric Liu <zengyuanl@nvidia.com>
Signed-off-by: Eric Liu <zengyuanl@nvidia.com>
…kage

Signed-off-by: Eric Liu <zengyuanl@nvidia.com>
Signed-off-by: Eric Liu <zengyuanl@nvidia.com>
@eric-liu-nvidia
eric-liu-nvidia force-pushed the eric-liu/switch-928-instrument-new-libsy-algorithms-observability branch from af6f871 to 3987417 Compare July 21, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants