agent_sdk/observability/langfuse.rs
1//! Native Langfuse attribute helpers.
2//!
3//! Owns every `langfuse.*` attribute key and the
4//! [`tag_observation`] helper that stamps a span with its
5//! `langfuse.observation.type`. A vanilla SDK consumer who installs
6//! OpenTelemetry and points the OTLP exporter at Langfuse therefore
7//! gets a fully-tagged trace tree (Agent / Generation / Tool / Chain /
8//! …) without writing any glue.
9//!
10//! Spec: <https://langfuse.com/integrations/native/opentelemetry#property-mapping>
11//!
12//! These helpers are pure attribute setters. They do **not** apply
13//! redaction — callers are responsible for passing already-redacted
14//! values when the attribute may contain user payload (the
15//! observability boundary's mandatory redactor lives in C1; the
16//! default-deny capture switch lives in C2).
17//!
18//! All setters guard on [`Span::is_recording`] and skip silently when
19//! sampling has dropped the span, mirroring the behaviour of the
20//! sibling baggage helpers.
21
22use opentelemetry::global::BoxedSpan;
23use opentelemetry::trace::Span;
24use opentelemetry::{Array, KeyValue, StringValue, Value};
25
26// ── Trace-level keys ─────────────────────────────────────────────────
27
28/// `langfuse.trace.name` — display name of the trace in the Langfuse UI.
29pub const LANGFUSE_TRACE_NAME: &str = "langfuse.trace.name";
30
31/// `langfuse.trace.input` — JSON or text shown as the trace's user input.
32pub const LANGFUSE_TRACE_INPUT: &str = "langfuse.trace.input";
33
34/// `langfuse.trace.output` — JSON or text shown as the trace's final output.
35pub const LANGFUSE_TRACE_OUTPUT: &str = "langfuse.trace.output";
36
37/// `langfuse.trace.tags` — array of free-form labels attached to the trace.
38pub const LANGFUSE_TRACE_TAGS: &str = "langfuse.trace.tags";
39
40/// `langfuse.trace.public` — boolean controlling Langfuse's "share" toggle.
41pub const LANGFUSE_TRACE_PUBLIC: &str = "langfuse.trace.public";
42
43/// Prefix for trace-level metadata keys (`langfuse.trace.metadata.<key>`).
44pub const LANGFUSE_TRACE_METADATA_PREFIX: &str = "langfuse.trace.metadata.";
45
46/// `langfuse.session.id` — Langfuse session id; mirrors the W3C baggage entry
47/// of the same name when present.
48pub const LANGFUSE_SESSION_ID: &str = "langfuse.session.id";
49
50/// `langfuse.user.id` — Langfuse end-user id; mirrors the W3C baggage entry
51/// of the same name when present.
52pub const LANGFUSE_USER_ID: &str = "langfuse.user.id";
53
54/// `langfuse.release` — release identifier for the trace's build.
55pub const LANGFUSE_RELEASE: &str = "langfuse.release";
56
57/// `langfuse.version` — application or prompt version associated with the trace.
58pub const LANGFUSE_VERSION: &str = "langfuse.version";
59
60/// `langfuse.environment` — Langfuse environment slug (`prod`, `staging`, …).
61pub const LANGFUSE_ENVIRONMENT: &str = "langfuse.environment";
62
63// ── Observation-level keys ───────────────────────────────────────────
64
65/// `langfuse.observation.type` — the observation kind tag (see [`ObservationType`]).
66pub const LANGFUSE_OBSERVATION_TYPE: &str = "langfuse.observation.type";
67
68/// `langfuse.observation.input` — JSON or text input recorded against an observation.
69pub const LANGFUSE_OBSERVATION_INPUT: &str = "langfuse.observation.input";
70
71/// `langfuse.observation.output` — JSON or text output recorded against an observation.
72pub const LANGFUSE_OBSERVATION_OUTPUT: &str = "langfuse.observation.output";
73
74/// `langfuse.observation.level` — severity level (`DEBUG` / `DEFAULT` / `WARNING` / `ERROR`).
75pub const LANGFUSE_OBSERVATION_LEVEL: &str = "langfuse.observation.level";
76
77/// `langfuse.observation.status_message` — free-form failure context for status panels.
78pub const LANGFUSE_OBSERVATION_STATUS_MESSAGE: &str = "langfuse.observation.status_message";
79
80/// `langfuse.observation.usage_details` — JSON token-usage breakdown.
81pub const LANGFUSE_OBSERVATION_USAGE_DETAILS: &str = "langfuse.observation.usage_details";
82
83/// `langfuse.observation.cost_details` — JSON cost breakdown.
84pub const LANGFUSE_OBSERVATION_COST_DETAILS: &str = "langfuse.observation.cost_details";
85
86/// `langfuse.observation.model.name` — Langfuse pricing-model identifier.
87pub const LANGFUSE_OBSERVATION_MODEL_NAME: &str = "langfuse.observation.model.name";
88
89/// `langfuse.observation.prompt.name` — linked Langfuse prompt name.
90pub const LANGFUSE_OBSERVATION_PROMPT_NAME: &str = "langfuse.observation.prompt.name";
91
92/// `langfuse.observation.prompt.version` — linked Langfuse prompt version.
93pub const LANGFUSE_OBSERVATION_PROMPT_VERSION: &str = "langfuse.observation.prompt.version";
94
95/// Prefix for observation-level metadata keys (`langfuse.observation.metadata.<key>`).
96pub const LANGFUSE_OBSERVATION_METADATA_PREFIX: &str = "langfuse.observation.metadata.";
97
98// ── Constants ────────────────────────────────────────────────────────
99
100/// Default character ceiling for trace-level free-text attributes.
101///
102/// Keeps trace-level free text bounded so a single attribute can't bloat a
103/// span; downstream hosts that truncate independently should converge on the
104/// same number.
105pub const DEFAULT_TRACE_TEXT_MAX_CHARS: usize = 10_000;
106
107// ── ObservationType ──────────────────────────────────────────────────
108
109/// Langfuse observation kinds — the value written under
110/// [`LANGFUSE_OBSERVATION_TYPE`].
111///
112/// Catalog: <https://langfuse.com/docs/observability/features/observation-types>
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum ObservationType {
115 /// Generic span (default fallback).
116 Span,
117 /// LLM generation — chat / completion call.
118 Generation,
119 /// Agent invocation — root of an agent loop.
120 Agent,
121 /// Tool execution.
122 Tool,
123 /// Multi-step chain (compaction, MCP control-plane calls, …).
124 Chain,
125 /// Vector / document retriever step.
126 Retriever,
127 /// LLM-as-judge or other automated evaluator.
128 Evaluator,
129 /// Embedding generation step.
130 Embedding,
131 /// Policy / safety guardrail check.
132 Guardrail,
133 /// Discrete event marker.
134 Event,
135}
136
137impl ObservationType {
138 /// The wire string Langfuse expects under `langfuse.observation.type`.
139 #[must_use]
140 pub const fn as_str(self) -> &'static str {
141 match self {
142 Self::Span => "span",
143 Self::Generation => "generation",
144 Self::Agent => "agent",
145 Self::Tool => "tool",
146 Self::Chain => "chain",
147 Self::Retriever => "retriever",
148 Self::Evaluator => "evaluator",
149 Self::Embedding => "embedding",
150 Self::Guardrail => "guardrail",
151 Self::Event => "event",
152 }
153 }
154}
155
156// ── Helpers ──────────────────────────────────────────────────────────
157
158/// Stamp a span with `langfuse.observation.type = <ty.as_str()>`.
159///
160/// Skips when the span is not recording (sampling drop) so callers
161/// don't have to guard.
162pub fn tag_observation(span: &mut BoxedSpan, ty: ObservationType) {
163 if !span.is_recording() {
164 return;
165 }
166 span.set_attribute(KeyValue::new(LANGFUSE_OBSERVATION_TYPE, ty.as_str()));
167}
168
169/// Set `langfuse.trace.name` on the span.
170pub fn set_trace_name(span: &mut BoxedSpan, value: impl Into<String>) {
171 set_string_attribute(span, LANGFUSE_TRACE_NAME, value);
172}
173
174/// Set `langfuse.trace.input` on the span.
175///
176/// The caller is responsible for redaction; this helper is a pure
177/// attribute setter.
178pub fn set_trace_input(span: &mut BoxedSpan, value: impl Into<String>) {
179 set_string_attribute(span, LANGFUSE_TRACE_INPUT, value);
180}
181
182/// Set `langfuse.trace.output` on the span.
183///
184/// The caller is responsible for redaction; this helper is a pure
185/// attribute setter.
186pub fn set_trace_output(span: &mut BoxedSpan, value: impl Into<String>) {
187 set_string_attribute(span, LANGFUSE_TRACE_OUTPUT, value);
188}
189
190/// Set `langfuse.release` on the span.
191pub fn set_release(span: &mut BoxedSpan, value: impl Into<String>) {
192 set_string_attribute(span, LANGFUSE_RELEASE, value);
193}
194
195/// Set `langfuse.environment` on the span.
196pub fn set_environment(span: &mut BoxedSpan, value: impl Into<String>) {
197 set_string_attribute(span, LANGFUSE_ENVIRONMENT, value);
198}
199
200/// Set a `langfuse.trace.metadata.<key>` entry on the span.
201///
202/// `key` must be the unprefixed metadata key. The helper concatenates
203/// `LANGFUSE_TRACE_METADATA_PREFIX + key` so callers cannot
204/// accidentally collide with reserved trace-level keys.
205pub fn set_trace_metadata(span: &mut BoxedSpan, key: &str, value: impl Into<String>) {
206 if !span.is_recording() {
207 return;
208 }
209 let attr_key = format!("{LANGFUSE_TRACE_METADATA_PREFIX}{key}");
210 span.set_attribute(KeyValue::new(attr_key, value.into()));
211}
212
213/// Set `langfuse.trace.tags` as a string array on the span.
214///
215/// Langfuse parses this attribute as a list when it arrives as an
216/// `OTel` string array, so this helper writes
217/// `Value::Array(Array::String(..))` rather than a comma-joined string.
218pub fn set_trace_tags(span: &mut BoxedSpan, tags: &[String]) {
219 if !span.is_recording() {
220 return;
221 }
222 let values: Vec<StringValue> = tags.iter().cloned().map(StringValue::from).collect();
223 span.set_attribute(KeyValue::new(
224 LANGFUSE_TRACE_TAGS,
225 Value::Array(Array::String(values)),
226 ));
227}
228
229/// Truncate a string to a max char count (UTF-8 safe), appending `…` on
230/// overflow.
231///
232/// * `("", _)` returns an empty string.
233/// * `(s, 0)` returns an empty string.
234/// * `(s, n)` where `s.chars().count() <= n` returns `s.to_string()`
235/// unchanged.
236/// * `(s, 1)` for an over-long input returns `"…"`.
237/// * Otherwise the result is `s.chars().take(n - 1).collect::<String>() + "…"`.
238#[must_use]
239pub fn truncate_trace_text(text: &str, max_chars: usize) -> String {
240 if text.is_empty() || max_chars == 0 {
241 return String::new();
242 }
243 if text.chars().count() <= max_chars {
244 return text.to_string();
245 }
246 if max_chars == 1 {
247 return "…".to_string();
248 }
249 let mut out: String = text.chars().take(max_chars - 1).collect();
250 out.push('…');
251 out
252}
253
254fn set_string_attribute(span: &mut BoxedSpan, key: &'static str, value: impl Into<String>) {
255 if !span.is_recording() {
256 return;
257 }
258 span.set_attribute(KeyValue::new(key, value.into()));
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn observation_type_as_str_round_trip() {
267 let cases = [
268 (ObservationType::Span, "span"),
269 (ObservationType::Generation, "generation"),
270 (ObservationType::Agent, "agent"),
271 (ObservationType::Tool, "tool"),
272 (ObservationType::Chain, "chain"),
273 (ObservationType::Retriever, "retriever"),
274 (ObservationType::Evaluator, "evaluator"),
275 (ObservationType::Embedding, "embedding"),
276 (ObservationType::Guardrail, "guardrail"),
277 (ObservationType::Event, "event"),
278 ];
279 for (variant, expected) in cases {
280 assert_eq!(variant.as_str(), expected);
281 }
282 }
283
284 #[test]
285 fn truncate_trace_text_returns_empty_on_empty_input() {
286 assert_eq!(truncate_trace_text("", 100), "");
287 assert_eq!(truncate_trace_text("", 0), "");
288 }
289
290 #[test]
291 fn truncate_trace_text_returns_empty_when_max_is_zero() {
292 assert_eq!(truncate_trace_text("anything", 0), "");
293 }
294
295 #[test]
296 fn truncate_trace_text_no_truncation_when_short() {
297 assert_eq!(truncate_trace_text("hello", 10), "hello");
298 assert_eq!(truncate_trace_text("hello", 5), "hello");
299 }
300
301 #[test]
302 fn truncate_trace_text_max_one_returns_ellipsis_for_overlong_input() {
303 assert_eq!(truncate_trace_text("hello", 1), "…");
304 }
305
306 #[test]
307 fn truncate_trace_text_handles_multibyte_chars() {
308 // Mix of ASCII, emoji, and CJK so that `chars()` and byte-indexing
309 // disagree. A naive byte-slice would panic here.
310 let input = "ab😀汉字cd";
311 assert_eq!(input.chars().count(), 7);
312
313 let truncated = truncate_trace_text(input, 5);
314 // 4 source chars + ellipsis = 5 chars total.
315 assert_eq!(truncated.chars().count(), 5);
316 assert!(truncated.ends_with('…'));
317 assert_eq!(truncated, "ab😀汉…");
318 }
319
320 #[test]
321 fn truncate_trace_text_default_ceiling_is_ten_thousand() {
322 assert_eq!(DEFAULT_TRACE_TEXT_MAX_CHARS, 10_000);
323 }
324}