turnframe_telemetry/lib.rs
1//! `turnframe-telemetry`: metrics, tracing and dashboards for a Turnframe runtime.
2//!
3//! The runtime reports what it did through one small contract in
4//! [`turnframe_core::observe`]; this crate ships the implementations of it an
5//! application wants in production, plus the data to build a dashboard from
6//! them. Every metric of §26.2, the latencies of §28, the identifiers of §26.1
7//! as tracing fields, one span per provider call described with the
8//! OpenTelemetry GenAI conventions, and a vendor-neutral [`TraceGrouping`] that
9//! reaches every span rather than only the trace root.
10//!
11//! Two rules decide what it will not do, and
12//! [`docs/telemetry.md`](https://github.com/turnframe-rs/turnframe/blob/main/docs/telemetry.md)
13//! says why. **Prompt and completion text stays out by default** —
14//! [`ContentRecorder`] is the only way in, is disabled unless an application
15//! enables it, and passes every string through a redactor the application
16//! wrote. **No user text and no case text ever becomes a metric label**, which
17//! is enforced three ways rather than asked for.
18//!
19//! ```rust
20//! use std::sync::Arc;
21//! use std::time::Duration;
22//!
23//! use turnframe_core::ids::WorkflowKey;
24//! use turnframe_core::observe::{Observer, Signal, SignalLabels};
25//! use turnframe_telemetry::{CompositeObserver, MetricsObserver, TracingObserver, metrics};
26//!
27//! // Once, after the metrics exporter is installed.
28//! metrics::describe_all();
29//!
30//! let observer: Arc<dyn Observer> = Arc::new(
31//! CompositeObserver::new()
32//! .with(MetricsObserver::new())
33//! .with(TracingObserver::new()),
34//! );
35//!
36//! // The runtime then reports what it did:
37//! let labels = SignalLabels::workflow(WorkflowKey::from("trip"));
38//! observer.observe_labeled(&Signal::TurnReceived, &labels);
39//! observer.observe_duration(&Signal::TurnDuration, Duration::from_millis(412), &labels);
40//! ```
41//!
42//! In tests, swap the composite for a [`RecordingObserver`] and assert on the
43//! signals the code under test produced.
44//!
45//! # Modules
46//!
47//! * [`crate::metrics`]: the [`MetricsObserver`], the label rules and the metric
48//! catalogue that generates this crate's README table.
49//! * [`crate::tracing`]: the [`TracingObserver`], the §26.1 identifier fields and
50//! the turn and stage span helpers.
51//! * [`crate::attrs`]: every attribute key in one place, so an adapter and an
52//! application spell them identically.
53//! * [`crate::composite`]: [`CompositeObserver`] and [`RecordingObserver`].
54//! * [`crate::dashboard`]: a data-only description of the §26.3 dashboard.
55//! * [`crate::otel`] (feature `otel`): the same signals as OpenTelemetry
56//! instruments, and the grouping carried in baggage and stamped onto every
57//! span by [`GroupingSpanProcessor`].
58#![forbid(unsafe_code)]
59#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
60
61pub mod attrs;
62pub mod composite;
63pub mod dashboard;
64pub mod metrics;
65#[cfg(feature = "otel")]
66pub mod otel;
67pub mod tracing;
68
69pub use crate::composite::{CompositeObserver, RecordedSignal, RecordingObserver};
70pub use crate::dashboard::{Dashboard, Panel, PanelId, PanelSource};
71pub use crate::metrics::{LabelKey, MetricDoc, MetricsObserver};
72pub use crate::tracing::{
73 ContentRecorder, ContentRedactor, ContentRole, PipelineStage, ProviderCall, TraceGrouping,
74 TracingObserver, TurnIdentifiers,
75};
76
77#[cfg(feature = "otel")]
78pub use crate::otel::{GroupingSpanProcessor, OtelObserver};
79
80/// Borrows an optional string-like identifier as a `&str`.
81///
82/// Written against [`AsRef<str>`] rather than a concrete type so it keeps
83/// working whether a `turnframe-core` label field is a `String` or one of the
84/// string newtypes of [`turnframe_core::ids`].
85pub(crate) fn opt_str<T: AsRef<str>>(value: &Option<T>) -> Option<&str> {
86 value.as_ref().map(AsRef::as_ref)
87}
88
89/// Renders a unit-variant enum as its serde name (e.g. `RiskClass::ReadOnly` →
90/// `"read_only"`).
91///
92/// Deriving the label from the serialization instead of a `match` means a new
93/// variant in a `turnframe-core` enum gets a correct label without a change
94/// here, and no `#[non_exhaustive]` wildcard can silently mislabel one.
95/// Anything that does not serialize to a plain string yields `None` and is
96/// dropped.
97pub(crate) fn enum_label<T: serde::Serialize + ?Sized>(value: &T) -> Option<String> {
98 match serde_json::to_value(value) {
99 Ok(serde_json::Value::String(name)) => Some(name),
100 _ => None,
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use turnframe_core::command::RiskClass;
107 use turnframe_core::ids::WorkflowKey;
108 use turnframe_core::interaction::InteractionKind;
109
110 use super::{enum_label, opt_str};
111
112 #[test]
113 fn opt_str_borrows_strings_and_newtypes() {
114 assert_eq!(opt_str(&Some(String::from("openai"))), Some("openai"));
115 assert_eq!(opt_str(&Some(WorkflowKey::from("trip"))), Some("trip"));
116 assert_eq!(opt_str::<String>(&None), None);
117 }
118
119 #[test]
120 fn enum_label_uses_the_serde_name() {
121 assert_eq!(
122 enum_label(&RiskClass::ReadOnly).as_deref(),
123 Some("read_only")
124 );
125 assert_eq!(
126 enum_label(&RiskClass::ExternalRegulated).as_deref(),
127 Some("external_regulated")
128 );
129 assert_eq!(
130 enum_label(&InteractionKind::ConfirmCommand).as_deref(),
131 Some("confirm_command")
132 );
133 }
134
135 #[test]
136 fn enum_label_rejects_non_string_shapes() {
137 assert_eq!(enum_label(&42_u32), None);
138 assert_eq!(enum_label(&vec!["a"]), None);
139 }
140}