Telemetry

In one line
Records request, token, and latency metrics on a caller-supplied OpenTelemetry meter provider, and emits structured logs.
You need
The plugin-telemetry feature and a &dyn opentelemetry::metrics::MeterProvider.
Read this if
You are registering OpenTelemetryPlugin or wiring its metrics into an exporter.

Feature and entry type

ItemValue
Feature flagplugin-telemetry (enables opentelemetry, opentelemetry_sdk, tracing)
Entry typeOpenTelemetryPlugin, built with OpenTelemetryPlugin::new(&dyn MeterProvider)
Plugin name"opentelemetry-observability"
Attachmentregister_plugin
NeedsA meter provider the caller owns; the plugin installs no global provider
Cost bridgeOtelCostObserver, compiled when plugin-cost and plugin-telemetry are both enabled

Hooks

OpenTelemetryPlugin overrides on_request, on_stream_chunk, and on_response_complete. execute_local_tool uses the trait default.

  • on_request adds 1 to cuca_requests_total with the attributes model and provider, then logs the dispatch.
  • on_stream_chunk adds 1 to cuca_streamed_tokens_total with no attributes, once per streamed content block. It is an approximation of a token count, because a block can span several tokens.
  • on_response_complete records UnifiedResponse::duration_secs into cuca_request_duration_seconds with the attribute status="success", then logs the completion.

Instruments

The plugin creates all three once in new, from the meter named "cuca_client".

InstrumentKindAttributes
cuca_requests_totalCounter<u64>model, provider
cuca_streamed_tokens_totalCounter<u64>none
cuca_request_duration_secondsHistogram<f64>status

Logs

tracing::info! events under the target cuca::telemetry:

  • On request dispatch: model and provider.
  • On response completion: duration, prompt_tokens, and completion_tokens.

A no-op meter provider yields a plugin that only emits these logs.

Cost bridge

OtelCostObserver implements CostObserver and records the cost ledger to the same "cuca_client" meter. It is built with OtelCostObserver::new(&dyn MeterProvider) and attached through CostConfig::observers (see Cost accounting). It lives in core, at cuca::cost_otel, because neither plugin may name the other.

Its ten instruments are Gauge<u64>, recorded with no attributes after every on_request charge and every on_response_complete commit, one per CostUsage field. They are gauges because each reading is a cumulative snapshot and the observer seam carries no deltas.

InstrumentCostUsage field
cuca_cost_spent_microsspent_micros
cuca_cost_prompt_tokensprompt_tokens
cuca_cost_completion_tokenscompletion_tokens
cuca_cost_cache_read_tokenscache_read_tokens
cuca_cost_cache_write_tokenscache_write_tokens
cuca_cost_turnsturns
cuca_cost_unpriced_turnsunpriced_turns
cuca_cost_overflow_turnsoverflow_turns
cuca_cost_untokenized_image_blocksuntokenized_image_blocks
cuca_cost_near_capnear_cap, recorded as 1 or 0

The two configured caps are not recorded: they are construction-time constants.

Configuration

OpenTelemetryPlugin::new takes only the meter provider. It has no config struct and no keys.

Fixed valueSetting
Meter name"cuca_client", shared with OtelCostObserver
Log targetcuca::telemetry

Export, aggregation, and temporality belong to the caller's meter provider and reader.

Caps and at-cap policy

No cap: the plugin holds three instrument handles and nothing grows with traffic. Accumulation is the caller's meter provider's job. OtelCostObserver holds ten gauge handles, no state, and takes no lock of its own. There is no usage gauge.

Errors

None. Every hook returns Ok(()), and OtelCostObserver::observe always returns Ok(()), so neither aborts a turn.

Example

examples/telemetry.rs registers the plugin on an in-memory exporter, runs two live turns, and prints every exported series after one force_flush.

let exporter = InMemoryMetricExporter::default();
let meter_provider = SdkMeterProvider::builder()
    .with_reader(PeriodicReader::builder(exporter.clone()).build())
    .build();
let otel = Arc::new(OpenTelemetryPlugin::new(&meter_provider));
let client = CucaClient::builder()
    .with_provider(ProviderEndpoint::LlamaCpp)
    .with_base_url(base_url)
    .register_plugin(Arc::clone(&otel) as Arc<dyn CucaPlugin>)
    .build()?;
cargo run --example telemetry --features "provider-llamacpp plugin-telemetry"

It needs a llama-server on port 1234. CUCA_BASE_URL and CUCA_MODEL retarget it at another OpenAI-compatible server.

Meter "cuca_client" on an SdkMeterProvider reading into InMemoryMetricExporter

Turn 1: "Name one bird. Reply with the name only."
  reply: Sparrow
  blocks: 2 text, 78 thinking

Turn 2: "Name one fish. Reply with the name only."
  reply: Salmon
  blocks: 1 text, 72 thinking

Exported after force_flush, scope "cuca_client"
  cuca_requests_total  Sum<u64>  Total LLM requests dispatched by CUCA
    2  model="google/gemma-4-12b-qat" provider="LlamaCpp"
  cuca_request_duration_seconds  Histogram<f64>  Latency distribution of LLM execution turns
    count 2, sum 57.034, min 25.250, max 31.784  status="success"
  cuca_streamed_tokens_total  Sum<u64>  Total streaming tokens processed across sessions
    153  no attributes

cuca_streamed_tokens_total reaches 153 because it counts every streamed block, Thinking blocks included: 2 + 78 + 1 + 72. The counts and durations come from google/gemma-4-12b-qat on one machine and change with both. The series names, kinds, and attribute keys do not.

examples/cost_otel.rs registers CostPlugin, OpenTelemetryPlugin, and OtelCostObserver on one meter provider and prints all ten gauges after a live priced turn.

cargo run --example cost_otel --features "provider-llamacpp plugin-cost plugin-telemetry"
↑↓ to navigate ↵ to open esc to close