What is observability?
Observability is how well you can understand what your application is doing from the outside, without adding ad-hoc debug code everywhere.
In practice, this can include:
- Logging messages (for example, model inputs and outputs)
- Printing values to the console while debugging
- Collecting metrics such as latency or token usage
- Capturing structured traces that show how requests flow through your system
Rig focuses primarily on structured traces and logs via the tracing ecosystem.
What observability gives you in Rig
Rig emits structured telemetry that allows you to:
- Inspect model prompts and responses
- Understand agent behaviour across multiple turns
- See tool calls and their outputs
- Debug streaming vs non-streaming completions
- Compare latency and behaviour over time
Rig follows the OpenTelemetry GenAI Semantic Conventions, which makes it compatible with modern GenAI observability platforms such as:
- Langfuse
- Arize Phoenix
- Other OpenTelemetry-compatible backends
The easiest way to get started (recommended)
If you just want to see what Rig is doing, the fastest path is Langfuse.
Rig works out-of-the-box with Langfuse using OpenTelemetry, and you can integrate it without running an OpenTelemetry Collector.
Using opentelemetry_langfuse (no collector required)
This setup is ideal for local development and most production workloads.
Add the following dependencies:
[dependencies]
opentelemetry = "0.31"
opentelemetry_langfuse = "0.6"
tracing-opentelemetry = "0.31"
tracing-subscriber = "0.3"Then initialise tracing like this:
use opentelemetry_langfuse::LangfuseTracer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn init_tracing() {
let langfuse_public_key = std::env::var("LANGFUSE_PUBLIC_KEY").expect("LANGFUSE_PUBLIC_KEY not set");
let langfuse_secret_key = std::env::var("LANGFUSE_SECRET_KEY").expect("LANGFUSE_SECRET_KEY not set");
let tracer = LangfuseTracer::builder()
.with_public_key(langfuse_public_key)
.with_secret_key(langfuse_secret_key)
.with_host("https://cloud.langfuse.com")
.build()
.expect("failed to create Langfuse tracer");
tracing_subscriber::registry()
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(tracing_subscriber::fmt::layer())
.init();
}Once this is set up, model calls, agent invocations, multi-turn agent loops and tool executions will automatically appear in the Langfuse UI.
Agent span naming and customisation
By default, agent spans use a generic name such as invoke_agent.
This is due to a limitation in tracing: span names cannot be changed after creation.
However, Rig attaches attributes like:
gen_ai.agent.namegen_ai.operation.name
You can use these attributes to rename spans in your observability backend.
Setting up an OpenTelemetry Collector (advanced)
You only need an OpenTelemetry Collector if:
- You want to forward telemetry to multiple backends
- You already run OTel infrastructure
- You want custom processing or redaction
Below is a minimal example that receives traces over OTLP HTTP and exports them to Langfuse.
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
otlphttp/langfuse:
endpoint: "https://cloud.langfuse.com/api/public/otel"
headers:
Authorization: "Basic ${AUTH_STRING}"
service:
pipelines:
traces:
receivers: [otlp]
processors: [transform]
exporters: [otlphttp/langfuse]This configuration:
- Accepts OTLP traces on port
4318 - Renames agent spans using their agent name
- Exports traces to Langfuse
I don’t want spans, just logs
That’s completely fine.
Because Rig uses tracing, you can ignore spans entirely and only output log messages.
Below is an example tracing_subscriber layer that prints only the log message, without span metadata.
#[derive(Clone)]
struct MessageOnlyLayer;
impl<S> tracing_subscriber::Layer<S> for MessageOnlyLayer
where
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
{
fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
use tracing::field::{Field, Visit};
struct MessageVisitor {
message: Option<String>,
}
impl Visit for MessageVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = Some(format!("{:?}", value));
}
}
}
let mut visitor = MessageVisitor { message: None };
event.record(&mut visitor);
if let Some(msg) = visitor.message {
let msg = msg.trim_matches('"');
let metadata = event.metadata();
let level = match metadata.level() {
&tracing::Level::TRACE => "TRACE",
&tracing::Level::DEBUG => "DEBUG",
&tracing::Level::INFO => "INFO",
&tracing::Level::WARN => "WARN",
&tracing::Level::ERROR => "ERROR",
};
let _ = writeln!(std::io::stdout(), "{level} {msg}");
}
}
}Use it like this:
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new("info"))
.with(MessageOnlyLayer)
.init();Troubleshooting
- Spans appear out of order If an operation completes in under ~1ms, some backends may display spans slightly out of order due to timestamp resolution. This is usually not an issue in real production workloads.