Skip to main content

nemo_relay/observability/
plugin_component.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Built-in observability plugin component.
5//!
6//! This module packages NeMo Relay's first-party observability exporters behind
7//! the shared plugin configuration system. Each exporter section is opt-in:
8//! omitted sections and sections with `enabled = false` validate but do not
9//! register subscribers or construct exporters.
10//!
11//! The plugin intentionally infers subscriber names from the component namespace
12//! so configuration remains portable across bindings. Agent Trajectory
13//! Observability Format (ATOF), OpenTelemetry, and OpenInference each register
14//! one global subscriber when enabled. Agent Trajectory Interchange Format
15//! (ATIF) uses a global dispatcher that detects top-level agent scopes and
16//! creates one scope-local exporter for each trajectory run. Coding-agent turns
17//! that need bounded traces are represented as agent scopes with role metadata.
18
19use std::collections::{HashMap, HashSet};
20use std::future::Future;
21use std::path::PathBuf;
22use std::pin::Pin;
23use std::sync::{Arc, Mutex};
24#[cfg(any(feature = "otel", feature = "openinference"))]
25use std::time::Duration;
26
27use serde::{Deserialize, Serialize};
28use serde_json::{Map, Value as Json};
29use uuid::Uuid;
30
31use crate::api::event::{Event, ScopeCategory};
32use crate::api::runtime::{EventSubscriberFn, current_scope_stack};
33use crate::api::scope::ScopeType;
34use crate::api::subscriber::{scope_deregister_subscriber, scope_register_subscriber};
35use crate::observability::atif::{AtifAgentInfo, AtifExporter};
36use crate::observability::atof::{
37    AtofExporter, AtofExporterConfig as CoreAtofExporterConfig, AtofExporterMode,
38};
39#[cfg(feature = "openinference")]
40use crate::observability::openinference::{
41    OpenInferenceConfig as CoreOpenInferenceConfig, OpenInferenceSubscriber,
42    OtlpTransport as OpenInferenceTransport,
43};
44#[cfg(feature = "otel")]
45use crate::observability::otel::{
46    OpenTelemetryConfig as CoreOpenTelemetryConfig, OpenTelemetrySubscriber,
47};
48use crate::plugin::{
49    ConfigDiagnostic, ConfigPolicy, DiagnosticLevel, Plugin, PluginComponentSpec, PluginError,
50    PluginRegistration, PluginRegistrationContext, Result as PluginResult, UnsupportedBehavior,
51    deregister_plugin, register_plugin,
52};
53
54/// The plugin kind registered by the core crate.
55pub const OBSERVABILITY_PLUGIN_KIND: &str = "observability";
56
57/// Top-level observability component wrapper.
58///
59/// Use this wrapper when constructing a [`PluginComponentSpec`] from Rust
60/// instead of hand-writing the generic plugin component shape. The component
61/// kind is always [`OBSERVABILITY_PLUGIN_KIND`].
62#[derive(Debug, Clone)]
63pub struct ComponentSpec {
64    /// Whether the observability component should be activated.
65    pub enabled: bool,
66    /// Observability config for this top-level component.
67    pub config: ObservabilityConfig,
68}
69
70impl ComponentSpec {
71    /// Creates an enabled observability component spec.
72    ///
73    /// The returned component can be converted into the generic plugin config
74    /// entry with `PluginComponentSpec::from(...)`.
75    pub fn new(config: ObservabilityConfig) -> Self {
76        Self {
77            enabled: true,
78            config,
79        }
80    }
81}
82
83impl From<ComponentSpec> for PluginComponentSpec {
84    fn from(value: ComponentSpec) -> Self {
85        let Json::Object(config) = serde_json::to_value(value.config)
86            .expect("observability config should serialize to object")
87        else {
88            unreachable!("observability config must serialize to object");
89        };
90
91        PluginComponentSpec {
92            kind: OBSERVABILITY_PLUGIN_KIND.to_string(),
93            enabled: value.enabled,
94            config,
95        }
96    }
97}
98
99/// Canonical config document for the observability plugin component.
100///
101/// Every section is optional. A missing section has the same activation
102/// behavior as a section with `enabled = false`: it contributes no runtime
103/// subscribers and performs no export work.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct ObservabilityConfig {
107    /// Observability config schema version.
108    #[serde(default = "default_observability_config_version")]
109    pub version: u32,
110    /// Filesystem-backed raw ATOF JSONL export.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub atof: Option<AtofSectionConfig>,
113    /// Per-top-level-agent ATIF trajectory export.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub atif: Option<AtifSectionConfig>,
116    /// OpenTelemetry trace export.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub opentelemetry: Option<OtlpSectionConfig>,
119    /// OpenInference trace export.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub openinference: Option<OtlpSectionConfig>,
122    /// Observability-local unsupported-config policy.
123    #[serde(default)]
124    pub policy: ConfigPolicy,
125}
126
127impl Default for ObservabilityConfig {
128    fn default() -> Self {
129        Self {
130            version: default_observability_config_version(),
131            atof: None,
132            atif: None,
133            opentelemetry: None,
134            openinference: None,
135            policy: ConfigPolicy::default(),
136        }
137    }
138}
139
140/// Filesystem-backed ATOF JSONL exporter config.
141///
142/// When enabled, this section wraps
143/// [`crate::observability::atof::AtofExporter`] and writes the raw ATOF event
144/// stream as JSONL. The exporter uses the current working directory and a
145/// timestamped filename when no explicit path settings are supplied.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
148pub struct AtofSectionConfig {
149    /// Whether ATOF JSONL export is active.
150    #[serde(default)]
151    pub enabled: bool,
152    /// Directory containing the JSONL output file.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub output_directory: Option<PathBuf>,
155    /// Output filename. Defaults to the underlying ATOF exporter timestamped filename.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub filename: Option<String>,
158    /// File open mode: `append` or `overwrite`.
159    #[serde(default = "default_atof_mode")]
160    #[cfg_attr(feature = "schema", schemars(schema_with = "atof_mode_schema"))]
161    pub mode: String,
162}
163
164impl Default for AtofSectionConfig {
165    fn default() -> Self {
166        Self {
167            enabled: false,
168            output_directory: None,
169            filename: None,
170            mode: default_atof_mode(),
171        }
172    }
173}
174
175/// Per-trajectory ATIF exporter config.
176///
177/// When enabled, this section creates a dispatcher that opens a separate
178/// [`crate::observability::atif::AtifExporter`] for each top-level agent or turn scope. The
179/// `{session_id}` placeholder in [`AtifSectionConfig::filename_template`] is required so
180/// concurrent sibling trajectories cannot overwrite each other's files.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
183pub struct AtifSectionConfig {
184    /// Whether ATIF export is active.
185    #[serde(default)]
186    pub enabled: bool,
187    /// Human-readable agent name.
188    #[serde(default = "default_agent_name")]
189    pub agent_name: String,
190    /// Agent version string.
191    #[serde(default = "default_agent_version")]
192    pub agent_version: String,
193    /// Default model name.
194    #[serde(default = "default_model_name")]
195    pub model_name: String,
196    /// Tool definitions available to the agent.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub tool_definitions: Option<Vec<Json>>,
199    /// Extra ATIF agent metadata.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub extra: Option<Json>,
202    /// Directory containing trajectory JSON files. Ignored when [`storage`] is non-empty.
203    ///
204    /// [`storage`]: Self::storage
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub output_directory: Option<PathBuf>,
207    /// Filename template. `{session_id}` is replaced with the top-level trajectory scope UUID.
208    /// When [`storage`] is non-empty, the rendered filename is appended to each backend's key prefix.
209    ///
210    /// [`storage`]: Self::storage
211    #[serde(default = "default_atif_filename_template")]
212    pub filename_template: String,
213    /// Optional list of remote storage destinations. When non-empty, completed
214    /// trajectories are uploaded to every configured backend instead of being
215    /// written locally; the local file write at [`output_directory`] is
216    /// skipped. Backends are independent: an upload failure on one destination
217    /// is recorded against that destination and skipped on subsequent
218    /// trajectories, while the other destinations continue to receive writes.
219    ///
220    /// [`output_directory`]: Self::output_directory
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub storage: Vec<AtifStorageConfig>,
223}
224
225impl Default for AtifSectionConfig {
226    fn default() -> Self {
227        Self {
228            enabled: false,
229            agent_name: default_agent_name(),
230            agent_version: default_agent_version(),
231            model_name: default_model_name(),
232            tool_definitions: None,
233            extra: None,
234            output_directory: None,
235            filename_template: default_atif_filename_template(),
236            storage: Vec::new(),
237        }
238    }
239}
240
241/// Remote storage destination for ATIF trajectory files.
242///
243/// When [`AtifSectionConfig::storage`] is non-empty, the ATIF dispatcher
244/// uploads each completed trajectory to every configured backend instead of
245/// writing it to the local filesystem. The shape is tagged with a `type`
246/// discriminator so additional backends (for example, Azure Blob Storage) can
247/// be added without breaking existing configs.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
250#[serde(tag = "type", rename_all = "snake_case")]
251pub enum AtifStorageConfig {
252    /// S3-compatible object storage.
253    ///
254    /// Non-secret connection settings (`region`, `endpoint_url`, `allow_http`)
255    /// and the static `access_key_id` may be set directly. The secret
256    /// credential fields (`secret_access_key_var`, `session_token_var`) must
257    /// reference the *name* of an environment variable that holds the secret,
258    /// so multiple S3 destinations can coexist in one config without writing
259    /// secrets into checked-in files. Any field left unset falls back to the
260    /// matching `AWS_*` environment variable (`AWS_ACCESS_KEY_ID`,
261    /// `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`,
262    /// `AWS_ENDPOINT_URL`, `AWS_ALLOW_HTTP`).
263    S3(S3StorageConfig),
264}
265
266/// S3-compatible storage settings for ATIF trajectory upload.
267///
268/// Every connection field is optional. Unset fields fall back to the matching
269/// `AWS_*` environment variable, preserving the env-driven workflow while
270/// letting one config file fully describe a destination when needed. Secret
271/// credentials are referenced by env var *name* (the `_var` suffix), so
272/// multiple destinations can each carry their own credentials without leaking
273/// secret material into the config.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
276pub struct S3StorageConfig {
277    /// Destination bucket name. Must be non-empty.
278    pub bucket: String,
279    /// Optional key prefix applied to every uploaded object. A trailing `/` is
280    /// inserted automatically when one is missing.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub key_prefix: Option<String>,
283    /// Static AWS access key ID. When unset, `AWS_ACCESS_KEY_ID` is used.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub access_key_id: Option<String>,
286    /// Name of the environment variable that holds the static secret access
287    /// key. Validated to be non-empty and present at plugin initialization
288    /// time. When unset, `AWS_SECRET_ACCESS_KEY` is used.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub secret_access_key_var: Option<String>,
291    /// Name of the environment variable that holds the optional STS session
292    /// token. Validated to be non-empty and present at plugin initialization
293    /// time. When unset, `AWS_SESSION_TOKEN` is used.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub session_token_var: Option<String>,
296    /// AWS region for the bucket. When unset, `AWS_REGION` is used.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub region: Option<String>,
299    /// Endpoint URL override for S3-compatible storage (for example, MinIO).
300    /// When unset, `AWS_ENDPOINT_URL` is used.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub endpoint_url: Option<String>,
303    /// Allow plain HTTP endpoints. When unset, `AWS_ALLOW_HTTP` is used.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub allow_http: Option<bool>,
306}
307
308/// Shared OTLP exporter config for OpenTelemetry and OpenInference.
309///
310/// The `opentelemetry` and `openinference` sections share the same shape but
311/// construct different subscriber implementations. Both sections are disabled
312/// by default and use `http_binary` transport unless configured otherwise.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315pub struct OtlpSectionConfig {
316    /// Whether the subscriber is active.
317    #[serde(default)]
318    pub enabled: bool,
319    /// OTLP transport: `http_binary` or `grpc`.
320    #[serde(default = "default_otlp_transport")]
321    #[cfg_attr(feature = "schema", schemars(schema_with = "otlp_transport_schema"))]
322    pub transport: String,
323    /// OTLP endpoint.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub endpoint: Option<String>,
326    /// Extra exporter headers or metadata.
327    #[serde(default)]
328    pub headers: HashMap<String, String>,
329    /// Extra resource attributes.
330    #[serde(default)]
331    pub resource_attributes: HashMap<String, String>,
332    /// `service.name` resource attribute.
333    #[serde(default = "default_service_name")]
334    pub service_name: String,
335    /// Optional `service.namespace` resource attribute.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub service_namespace: Option<String>,
338    /// Optional `service.version` resource attribute.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub service_version: Option<String>,
341    /// Instrumentation scope name.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub instrumentation_scope: Option<String>,
344    /// Export timeout in milliseconds.
345    #[serde(default = "default_timeout_millis")]
346    pub timeout_millis: u64,
347}
348
349impl Default for OtlpSectionConfig {
350    fn default() -> Self {
351        Self {
352            enabled: false,
353            transport: default_otlp_transport(),
354            endpoint: None,
355            headers: HashMap::new(),
356            resource_attributes: HashMap::new(),
357            service_name: default_service_name(),
358            service_namespace: None,
359            service_version: None,
360            instrumentation_scope: None,
361            timeout_millis: default_timeout_millis(),
362        }
363    }
364}
365
366crate::editor_config! {
367    impl ObservabilityConfig {
368        atof => {
369            label: "ATOF",
370            kind: Section,
371            optional: true,
372            nested: AtofSectionConfig,
373            default: AtofSectionConfig,
374        },
375        atif => {
376            label: "ATIF",
377            kind: Section,
378            optional: true,
379            nested: AtifSectionConfig,
380            default: AtifSectionConfig,
381        },
382        opentelemetry => {
383            label: "OpenTelemetry",
384            kind: Section,
385            optional: true,
386            nested: OtlpSectionConfig,
387            default: OtlpSectionConfig,
388        },
389        openinference => {
390            label: "OpenInference",
391            kind: Section,
392            optional: true,
393            nested: OtlpSectionConfig,
394            default: OtlpSectionConfig,
395        },
396        policy => {
397            label: "policy",
398            kind: Section,
399            nested: ConfigPolicy,
400            default: ConfigPolicy,
401        },
402    }
403}
404
405crate::editor_config! {
406    impl AtofSectionConfig {
407        enabled => { label: "enabled", kind: Boolean },
408        output_directory => { label: "output_directory", kind: String, optional: true },
409        filename => { label: "filename", kind: String, optional: true },
410        mode => { label: "mode", kind: Enum, values: ["append", "overwrite"] },
411    }
412}
413
414crate::editor_config! {
415    impl AtifSectionConfig {
416        enabled => { label: "enabled", kind: Boolean },
417        agent_name => { label: "agent_name", kind: String },
418        agent_version => { label: "agent_version", kind: String },
419        model_name => { label: "model_name", kind: String },
420        tool_definitions => { label: "tool_definitions", kind: Json, optional: true },
421        extra => { label: "extra", kind: Json, optional: true },
422        output_directory => { label: "output_directory", kind: String, optional: true },
423        filename_template => { label: "filename_template", kind: String },
424        storage => { label: "storage", kind: Json, optional: true },
425    }
426}
427
428crate::editor_config! {
429    impl OtlpSectionConfig {
430        enabled => { label: "enabled", kind: Boolean },
431        transport => { label: "transport", kind: Enum, values: ["http_binary", "grpc"] },
432        endpoint => { label: "endpoint", kind: String, optional: true },
433        headers => { label: "headers", kind: StringMap },
434        resource_attributes => { label: "resource_attributes", kind: StringMap },
435        service_name => { label: "service_name", kind: String },
436        service_namespace => { label: "service_namespace", kind: String, optional: true },
437        service_version => { label: "service_version", kind: String, optional: true },
438        instrumentation_scope => { label: "instrumentation_scope", kind: String, optional: true },
439        timeout_millis => { label: "timeout_millis", kind: Integer },
440    }
441}
442
443struct ObservabilityPlugin;
444
445impl Plugin for ObservabilityPlugin {
446    fn plugin_kind(&self) -> &str {
447        OBSERVABILITY_PLUGIN_KIND
448    }
449
450    fn allows_multiple_components(&self) -> bool {
451        false
452    }
453
454    fn validate(&self, plugin_config: &Map<String, Json>) -> Vec<ConfigDiagnostic> {
455        validate_observability_plugin_config(plugin_config)
456    }
457
458    fn register<'a>(
459        &'a self,
460        plugin_config: &Map<String, Json>,
461        ctx: &'a mut PluginRegistrationContext,
462    ) -> Pin<Box<dyn Future<Output = PluginResult<()>> + Send + 'a>> {
463        let plugin_config = plugin_config.clone();
464        Box::pin(async move {
465            let config = parse_observability_config(&plugin_config)?;
466            register_observability(config, ctx)
467        })
468    }
469}
470
471/// Registers the observability component kind in the core plugin registry.
472///
473/// Calling this function more than once is safe. The core plugin APIs call it
474/// automatically before listing, looking up, validating, or initializing plugin
475/// components, so applications normally do not need to invoke it directly.
476pub fn register_observability_component() -> PluginResult<()> {
477    match register_plugin(Arc::new(ObservabilityPlugin)) {
478        Ok(()) => Ok(()),
479        Err(PluginError::RegistrationFailed(message)) if message.contains("already registered") => {
480            Ok(())
481        }
482        Err(err) => Err(err),
483    }
484}
485
486/// Deregisters the observability component kind from the core plugin registry.
487///
488/// This helper exists primarily for tests and specialized embedding scenarios.
489/// It removes the plugin kind from future registry lookups but does not clear an
490/// already active plugin configuration.
491pub fn deregister_observability_component() -> bool {
492    deregister_plugin(OBSERVABILITY_PLUGIN_KIND)
493}
494
495/// Returns the JSON Schema for the observability component configuration.
496#[cfg(feature = "schema")]
497pub fn observability_config_schema() -> serde_json::Value {
498    serde_json::to_value(schemars::schema_for!(ObservabilityConfig))
499        .expect("observability config schema should serialize")
500}
501
502#[cfg(feature = "schema")]
503fn atof_mode_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
504    string_enum_schema(generator, &["append", "overwrite"], Some("append"))
505}
506
507#[cfg(feature = "schema")]
508fn otlp_transport_schema(
509    generator: &mut schemars::r#gen::SchemaGenerator,
510) -> schemars::schema::Schema {
511    string_enum_schema(generator, &["http_binary", "grpc"], Some("http_binary"))
512}
513
514#[cfg(feature = "schema")]
515fn string_enum_schema(
516    generator: &mut schemars::r#gen::SchemaGenerator,
517    values: &[&str],
518    default: Option<&str>,
519) -> schemars::schema::Schema {
520    let mut schema: schemars::schema::SchemaObject =
521        <String as schemars::JsonSchema>::json_schema(generator).into();
522    schema.enum_values = Some(
523        values
524            .iter()
525            .map(|value| Json::String((*value).into()))
526            .collect(),
527    );
528    if let Some(default) = default {
529        schema.metadata().default = Some(Json::String(default.into()));
530    }
531    schema.into()
532}
533
534fn register_observability(
535    config: ObservabilityConfig,
536    ctx: &mut PluginRegistrationContext,
537) -> PluginResult<()> {
538    if let Some(atof) = config.atof.filter(|section| section.enabled) {
539        register_atof_exporter(atof, ctx)?;
540    }
541    if let Some(atif) = config.atif.filter(|section| section.enabled) {
542        register_atif_dispatcher(atif, ctx)?;
543    }
544    if let Some(otel) = config.opentelemetry.filter(|section| section.enabled) {
545        register_opentelemetry(otel, ctx)?;
546    }
547    if let Some(openinference) = config.openinference.filter(|section| section.enabled) {
548        register_openinference(openinference, ctx)?;
549    }
550    Ok(())
551}
552
553fn register_atof_exporter(
554    section: AtofSectionConfig,
555    ctx: &mut PluginRegistrationContext,
556) -> PluginResult<()> {
557    let mode = AtofExporterMode::parse(&section.mode).ok_or_else(|| {
558        PluginError::InvalidConfig("ATOF mode must be 'append' or 'overwrite'".to_string())
559    })?;
560    let mut config = CoreAtofExporterConfig::new().with_mode(mode);
561    if let Some(output_directory) = section.output_directory {
562        config = config.with_output_directory(output_directory);
563    }
564    if let Some(filename) = section.filename {
565        config = config.with_filename(filename);
566    }
567
568    let exporter = Arc::new(AtofExporter::new(config).map_err(observability_registration_error)?);
569    ctx.register_subscriber("atof", exporter.subscriber())?;
570    ctx.add_registration(PluginRegistration::new(
571        "observability",
572        ctx.qualify_name("atof.shutdown"),
573        Box::new(move || {
574            exporter
575                .shutdown()
576                .map_err(observability_registration_error)
577        }),
578    ));
579    Ok(())
580}
581
582type AtifStorageList = Arc<Vec<Arc<AtifRemoteStorage>>>;
583
584fn register_atif_dispatcher(
585    section: AtifSectionConfig,
586    ctx: &mut PluginRegistrationContext,
587) -> PluginResult<()> {
588    if !section.filename_template.contains("{session_id}") {
589        return Err(PluginError::InvalidConfig(
590            "ATIF filename_template must contain '{session_id}'".to_string(),
591        ));
592    }
593
594    let mut storage_vec = Vec::with_capacity(section.storage.len());
595    for (index, entry) in section.storage.iter().enumerate() {
596        storage_vec.push(build_atif_storage(index, entry)?);
597    }
598    let storage: AtifStorageList = Arc::new(storage_vec);
599
600    let manager = Arc::new(Mutex::new(AtifDispatcher::new(section)));
601    let dispatcher = atif_dispatcher_subscriber(
602        Arc::clone(&manager),
603        ctx.qualify_name("atif-"),
604        Arc::clone(&storage),
605    );
606    ctx.register_subscriber("atif", dispatcher)?;
607    let shutdown_storage = Arc::clone(&storage);
608    ctx.add_registration(PluginRegistration::new(
609        "observability",
610        ctx.qualify_name("atif.shutdown"),
611        Box::new(move || {
612            let work = {
613                let mut guard = manager.lock().map_err(|err| {
614                    PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
615                })?;
616                guard
617                    .flush_open_agents()
618                    .map_err(observability_registration_error)?
619            };
620            for (scope_uuid, name) in work.scope_subscribers {
621                let _ = scope_deregister_subscriber(&scope_uuid, &name);
622            }
623            for write in work.writes {
624                let agent_uuid = write.agent_uuid;
625                let targets = {
626                    let guard = manager.lock().map_err(|err| {
627                        PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
628                    })?;
629                    guard.sink_targets()
630                };
631                let results = write_atif(&write, shutdown_storage.as_slice(), &targets);
632                let mut guard = manager.lock().map_err(|err| {
633                    PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
634                })?;
635                let _ = guard.complete_scope_write(agent_uuid, results);
636            }
637            let guard = manager.lock().map_err(|err| {
638                PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
639            })?;
640            guard
641                .last_error_result()
642                .map_err(observability_registration_error)
643        }),
644    ));
645    Ok(())
646}
647
648#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
649fn build_atif_storage(
650    index: usize,
651    config: &AtifStorageConfig,
652) -> PluginResult<Arc<AtifRemoteStorage>> {
653    AtifRemoteStorage::from_config(index, config)
654        .map(Arc::new)
655        .map_err(observability_registration_error)
656}
657
658#[cfg(not(all(feature = "object-store", not(target_arch = "wasm32"))))]
659fn build_atif_storage(
660    _index: usize,
661    _config: &AtifStorageConfig,
662) -> PluginResult<Arc<AtifRemoteStorage>> {
663    Err(PluginError::InvalidConfig(
664        "ATIF storage support is not enabled in this build".to_string(),
665    ))
666}
667
668#[cfg(feature = "otel")]
669fn register_opentelemetry(
670    section: OtlpSectionConfig,
671    ctx: &mut PluginRegistrationContext,
672) -> PluginResult<()> {
673    let subscriber = Arc::new(
674        OpenTelemetrySubscriber::new(build_otel_config(section)?)
675            .map_err(observability_registration_error)?,
676    );
677    ctx.register_subscriber("opentelemetry", subscriber.subscriber())?;
678    ctx.add_registration(PluginRegistration::new(
679        "observability",
680        ctx.qualify_name("opentelemetry.shutdown"),
681        Box::new(move || {
682            subscriber
683                .shutdown()
684                .map_err(observability_registration_error)
685        }),
686    ));
687    Ok(())
688}
689
690#[cfg(not(feature = "otel"))]
691fn register_opentelemetry(
692    _section: OtlpSectionConfig,
693    _ctx: &mut PluginRegistrationContext,
694) -> PluginResult<()> {
695    Err(PluginError::InvalidConfig(
696        "OpenTelemetry support is not enabled in this build".to_string(),
697    ))
698}
699
700#[cfg(feature = "openinference")]
701fn register_openinference(
702    section: OtlpSectionConfig,
703    ctx: &mut PluginRegistrationContext,
704) -> PluginResult<()> {
705    let subscriber = Arc::new(
706        OpenInferenceSubscriber::new(build_openinference_config(section)?)
707            .map_err(observability_registration_error)?,
708    );
709    ctx.register_subscriber("openinference", subscriber.subscriber())?;
710    ctx.add_registration(PluginRegistration::new(
711        "observability",
712        ctx.qualify_name("openinference.shutdown"),
713        Box::new(move || {
714            subscriber
715                .shutdown()
716                .map_err(observability_registration_error)
717        }),
718    ));
719    Ok(())
720}
721
722#[cfg(not(feature = "openinference"))]
723fn register_openinference(
724    _section: OtlpSectionConfig,
725    _ctx: &mut PluginRegistrationContext,
726) -> PluginResult<()> {
727    Err(PluginError::InvalidConfig(
728        "OpenInference support is not enabled in this build".to_string(),
729    ))
730}
731
732struct AtifDispatcher {
733    config: AtifSectionConfig,
734    agents: HashMap<Uuid, ManagedAtifExporter>,
735    scope_owners: HashMap<Uuid, Uuid>,
736    scope_subscribers: HashMap<Uuid, String>,
737    /// Fatal dispatcher errors (subscriber registration, payload serialization)
738    /// that cannot be isolated to a single sink. Once set, the dispatcher stops
739    /// observing further events.
740    fatal_error: Option<String>,
741    /// Per-sink last error. A sink that recorded an error is skipped on
742    /// subsequent trajectories; other sinks continue to receive writes. Errors
743    /// here are surfaced together by [`last_error_result`] on teardown.
744    sink_errors: HashMap<SinkLabel, String>,
745}
746
747struct ManagedAtifExporter {
748    exporter: AtifExporter,
749    filename: String,
750    local_path: Option<PathBuf>,
751    observed_events: Vec<Event>,
752    observed_event_keys: HashSet<String>,
753    written: bool,
754}
755
756struct PendingAtifWrite {
757    agent_uuid: Uuid,
758    // `filename` is consumed by the remote upload path, which is gated on the
759    // object-store feature; without it, only the local sink reads `local_path`.
760    #[cfg_attr(
761        not(all(feature = "object-store", not(target_arch = "wasm32"))),
762        allow(dead_code)
763    )]
764    filename: String,
765    local_path: Option<PathBuf>,
766    payload: Vec<u8>,
767}
768
769struct AtifFlushWork {
770    writes: Vec<PendingAtifWrite>,
771    scope_subscribers: Vec<(Uuid, String)>,
772}
773
774/// Identifier for a single output sink. `Local` is used when `storage` is empty
775/// (the legacy local-file path); `Remote(i)` indexes into the configured
776/// storage backends.
777#[derive(Clone, Debug, PartialEq, Eq, Hash)]
778enum SinkLabel {
779    Local,
780    Remote(usize),
781}
782
783impl SinkLabel {
784    fn display(&self) -> String {
785        match self {
786            SinkLabel::Local => "local".to_string(),
787            SinkLabel::Remote(index) => format!("storage[{index}]"),
788        }
789    }
790
791    fn sort_key(&self) -> isize {
792        match self {
793            SinkLabel::Local => -1,
794            SinkLabel::Remote(index) => *index as isize,
795        }
796    }
797}
798
799impl AtifDispatcher {
800    fn new(config: AtifSectionConfig) -> Self {
801        Self {
802            config,
803            agents: HashMap::new(),
804            scope_owners: HashMap::new(),
805            scope_subscribers: HashMap::new(),
806            fatal_error: None,
807            sink_errors: HashMap::new(),
808        }
809    }
810
811    fn observe_global(
812        &mut self,
813        event: &Event,
814        subscriber_prefix: &str,
815        state: Arc<Mutex<Self>>,
816        storage: AtifStorageList,
817    ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
818        if self.fatal_error.is_some() {
819            return None;
820        }
821
822        if !is_top_level_trajectory_start(event) {
823            return self.observe_descendant_from_global(event);
824        }
825
826        if self.agents.contains_key(&event.uuid()) {
827            return None;
828        }
829
830        // The top-level trajectory scope UUID is the ATIF session ID. The global
831        // dispatcher records the start event itself because the scope-local
832        // subscriber is attached after that start event has already been
833        // emitted.
834        let session_id = event.uuid().to_string();
835        let exporter = AtifExporter::new(session_id.clone(), self.agent_info());
836        (exporter.subscriber())(event);
837        let (filename, local_path) = self.prepare_destination(&session_id);
838        self.scope_owners.insert(event.uuid(), event.uuid());
839        self.agents.insert(
840            event.uuid(),
841            ManagedAtifExporter {
842                exporter,
843                filename,
844                local_path,
845                observed_events: vec![event.clone()],
846                observed_event_keys: HashSet::from([event_observation_key(event)]),
847                written: false,
848            },
849        );
850
851        let agent_uuid = event.uuid();
852        let name = format!("{subscriber_prefix}{agent_uuid}");
853        let callback = atif_scope_subscriber(state, agent_uuid, storage);
854        // Attach the scoped subscriber to the trajectory root rather than the
855        // global registry so sibling top-level trajectories never share events.
856        // With async subscriber delivery, the root scope may already be closed
857        // when the dispatcher observes this start event; global routing still
858        // handles descendant events by parent UUID in that case.
859        if scope_register_subscriber(&agent_uuid, &name, callback).is_ok() {
860            self.scope_subscribers.insert(agent_uuid, name);
861        }
862        None
863    }
864
865    fn observe_descendant_from_global(
866        &mut self,
867        event: &Event,
868    ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
869        let owner = self.scope_owners.get(&event.uuid()).copied().or_else(|| {
870            event
871                .parent_uuid()
872                .and_then(|uuid| self.scope_owners.get(&uuid).copied())
873        })?;
874
875        if event.scope_category() == Some(ScopeCategory::Start) {
876            self.scope_owners.insert(event.uuid(), owner);
877        }
878
879        let pending_write = self.observe_scope(event, owner);
880
881        if event.scope_category() == Some(ScopeCategory::End) && event.uuid() != owner {
882            self.scope_owners.remove(&event.uuid());
883        }
884
885        pending_write
886    }
887
888    fn observe_scope(
889        &mut self,
890        event: &Event,
891        agent_uuid: Uuid,
892    ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
893        if self.fatal_error.is_some() {
894            return None;
895        }
896        let should_finalize =
897            event.uuid() == agent_uuid && event.scope_category() == Some(ScopeCategory::End);
898        let agent = self.agents.get_mut(&agent_uuid)?;
899        if !agent
900            .observed_event_keys
901            .insert(event_observation_key(event))
902        {
903            return None;
904        }
905        (agent.exporter.subscriber())(event);
906        agent.observed_events.push(event.clone());
907        if !should_finalize || agent.written {
908            return None;
909        }
910        let write = match prepare_atif_file(agent_uuid, agent) {
911            Ok(write) => write,
912            Err(err) => {
913                self.fatal_error = Some(err.to_string());
914                return None;
915            }
916        };
917        let targets = self.sink_targets();
918        Some((write, targets))
919    }
920
921    fn complete_scope_write(
922        &mut self,
923        agent_uuid: Uuid,
924        results: Vec<(SinkLabel, std::io::Result<()>)>,
925    ) -> Option<(Uuid, String)> {
926        for (label, result) in results {
927            if let Err(err) = result {
928                self.sink_errors.insert(label, err.to_string());
929            }
930        }
931        if let Some(agent) = self.agents.get_mut(&agent_uuid) {
932            agent.observed_events.clear();
933        }
934        self.agents.remove(&agent_uuid);
935        self.scope_owners.retain(|_, owner| *owner != agent_uuid);
936        self.scope_subscribers
937            .remove(&agent_uuid)
938            .map(|name| (agent_uuid, name))
939    }
940
941    fn flush_open_agents(&mut self) -> std::io::Result<AtifFlushWork> {
942        // Plugin teardown may run before an agent scope closes. Remove dynamic
943        // scope-local subscribers first so the later scope end event cannot
944        // trigger a second write after the dispatcher has flushed.
945        let scope_subscribers = std::mem::take(&mut self.scope_subscribers)
946            .into_iter()
947            .collect();
948        let agent_uuids = self
949            .agents
950            .iter()
951            .filter_map(|(agent_uuid, agent)| (!agent.written).then_some(*agent_uuid))
952            .collect::<Vec<_>>();
953        let mut writes = Vec::with_capacity(agent_uuids.len());
954        for agent_uuid in agent_uuids {
955            if let Some(agent) = self.agents.get_mut(&agent_uuid) {
956                writes.push(prepare_atif_file(agent_uuid, agent)?);
957            }
958        }
959        Ok(AtifFlushWork {
960            writes,
961            scope_subscribers,
962        })
963    }
964
965    fn last_error_result(&self) -> std::io::Result<()> {
966        let mut parts: Vec<String> = Vec::new();
967        if let Some(message) = &self.fatal_error {
968            parts.push(message.clone());
969        }
970        let mut sink_entries: Vec<_> = self.sink_errors.iter().collect();
971        sink_entries.sort_by_key(|(label, _)| label.sort_key());
972        for (label, message) in sink_entries {
973            parts.push(format!("{}: {message}", label.display()));
974        }
975        if parts.is_empty() {
976            Ok(())
977        } else {
978            Err(std::io::Error::other(parts.join("; ")))
979        }
980    }
981
982    fn agent_info(&self) -> AtifAgentInfo {
983        AtifAgentInfo {
984            name: self.config.agent_name.clone(),
985            version: self.config.agent_version.clone(),
986            model_name: Some(self.config.model_name.clone()),
987            tool_definitions: self.config.tool_definitions.clone(),
988            extra: self.config.extra.clone(),
989        }
990    }
991
992    fn prepare_destination(&self, session_id: &str) -> (String, Option<PathBuf>) {
993        let filename = self
994            .config
995            .filename_template
996            .replace("{session_id}", session_id);
997        if !self.config.storage.is_empty() {
998            return (filename, None);
999        }
1000        let directory = self
1001            .config
1002            .output_directory
1003            .clone()
1004            .unwrap_or_else(default_output_directory);
1005        let path = directory.join(&filename);
1006        (filename, Some(path))
1007    }
1008
1009    fn sink_targets(&self) -> Vec<SinkLabel> {
1010        if self.config.storage.is_empty() {
1011            if self.sink_errors.contains_key(&SinkLabel::Local) {
1012                Vec::new()
1013            } else {
1014                vec![SinkLabel::Local]
1015            }
1016        } else {
1017            (0..self.config.storage.len())
1018                .map(SinkLabel::Remote)
1019                .filter(|label| !self.sink_errors.contains_key(label))
1020                .collect()
1021        }
1022    }
1023}
1024
1025fn atif_dispatcher_subscriber(
1026    manager: Arc<Mutex<AtifDispatcher>>,
1027    subscriber_prefix: String,
1028    storage: AtifStorageList,
1029) -> EventSubscriberFn {
1030    Arc::new(move |event: &Event| {
1031        let pending = {
1032            let Ok(mut guard) = manager.lock() else {
1033                return;
1034            };
1035            guard.observe_global(
1036                event,
1037                &subscriber_prefix,
1038                Arc::clone(&manager),
1039                Arc::clone(&storage),
1040            )
1041        };
1042        let Some((write, targets)) = pending else {
1043            return;
1044        };
1045        let results = write_atif(&write, storage.as_slice(), &targets);
1046        let scope_subscriber = {
1047            let Ok(mut guard) = manager.lock() else {
1048                return;
1049            };
1050            guard.complete_scope_write(write.agent_uuid, results)
1051        };
1052        if let Some((scope_uuid, name)) = scope_subscriber {
1053            let _ = scope_deregister_subscriber(&scope_uuid, &name);
1054        }
1055    })
1056}
1057
1058fn atif_scope_subscriber(
1059    manager: Arc<Mutex<AtifDispatcher>>,
1060    agent_uuid: Uuid,
1061    storage: AtifStorageList,
1062) -> EventSubscriberFn {
1063    Arc::new(move |event: &Event| {
1064        let pending = {
1065            let Ok(mut guard) = manager.lock() else {
1066                return;
1067            };
1068            guard.observe_scope(event, agent_uuid)
1069        };
1070        let Some((write, targets)) = pending else {
1071            return;
1072        };
1073        let results = write_atif(&write, storage.as_slice(), &targets);
1074        let scope_subscriber = {
1075            let Ok(mut guard) = manager.lock() else {
1076                return;
1077            };
1078            guard.complete_scope_write(write.agent_uuid, results)
1079        };
1080        if let Some((scope_uuid, name)) = scope_subscriber {
1081            let _ = scope_deregister_subscriber(&scope_uuid, &name);
1082        }
1083    })
1084}
1085
1086fn prepare_atif_file(
1087    agent_uuid: Uuid,
1088    agent: &mut ManagedAtifExporter,
1089) -> std::io::Result<PendingAtifWrite> {
1090    let trajectory = agent
1091        .exporter
1092        .try_export()
1093        .map_err(|error| std::io::Error::other(error.to_string()))?;
1094    let mut value = serde_json::to_value(trajectory)?;
1095    if let Some(object) = value.as_object_mut() {
1096        object.insert(
1097            "extra".to_string(),
1098            serde_json::json!({
1099                "observed_events": agent.observed_events,
1100            }),
1101        );
1102    }
1103    let payload = serde_json::to_vec_pretty(&value)?;
1104    agent.written = true;
1105    Ok(PendingAtifWrite {
1106        agent_uuid,
1107        filename: agent.filename.clone(),
1108        local_path: agent.local_path.clone(),
1109        payload,
1110    })
1111}
1112
1113fn write_atif(
1114    write: &PendingAtifWrite,
1115    storage: &[Arc<AtifRemoteStorage>],
1116    targets: &[SinkLabel],
1117) -> Vec<(SinkLabel, std::io::Result<()>)> {
1118    targets
1119        .iter()
1120        .map(|label| {
1121            let result = match label {
1122                SinkLabel::Local => match &write.local_path {
1123                    Some(path) => write_atif_local(path, &write.payload),
1124                    None => Err(std::io::Error::other(
1125                        "ATIF local destination has no output path",
1126                    )),
1127                },
1128                SinkLabel::Remote(index) => write_atif_remote(storage, *index, write),
1129            };
1130            (label.clone(), result)
1131        })
1132        .collect()
1133}
1134
1135fn write_atif_local(path: &PathBuf, payload: &[u8]) -> std::io::Result<()> {
1136    if let Some(parent) = path.parent() {
1137        std::fs::create_dir_all(parent)?;
1138    }
1139    std::fs::write(path, payload)
1140}
1141
1142#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1143fn write_atif_remote(
1144    storage: &[Arc<AtifRemoteStorage>],
1145    index: usize,
1146    write: &PendingAtifWrite,
1147) -> std::io::Result<()> {
1148    let sink = storage
1149        .get(index)
1150        .ok_or_else(|| std::io::Error::other(format!("ATIF storage[{index}] is not registered")))?;
1151    sink.put(&write.filename, &write.payload)
1152}
1153
1154#[cfg(not(all(feature = "object-store", not(target_arch = "wasm32"))))]
1155fn write_atif_remote(
1156    _storage: &[Arc<AtifRemoteStorage>],
1157    _index: usize,
1158    _write: &PendingAtifWrite,
1159) -> std::io::Result<()> {
1160    Err(std::io::Error::other(
1161        "ATIF storage support is not enabled in this build",
1162    ))
1163}
1164
1165fn event_observation_key(event: &Event) -> String {
1166    format!(
1167        "{}:{}:{:?}",
1168        event.kind(),
1169        event.uuid(),
1170        event.scope_category()
1171    )
1172}
1173
1174fn is_top_level_trajectory_start(event: &Event) -> bool {
1175    if event.scope_category() != Some(ScopeCategory::Start) {
1176        return false;
1177    }
1178    if event.scope_type() != Some(ScopeType::Agent) {
1179        return false;
1180    }
1181    let Some(parent_uuid) = event.parent_uuid() else {
1182        return false;
1183    };
1184    current_scope_stack()
1185        .read()
1186        .map(|stack| stack.root_uuid() == parent_uuid)
1187        .unwrap_or(false)
1188}
1189
1190#[cfg(feature = "otel")]
1191fn build_otel_config(section: OtlpSectionConfig) -> PluginResult<CoreOpenTelemetryConfig> {
1192    let mut config = match section.transport.as_str() {
1193        "http_binary" => CoreOpenTelemetryConfig::http_binary(section.service_name),
1194        "grpc" => CoreOpenTelemetryConfig::grpc(section.service_name),
1195        other => {
1196            return Err(PluginError::InvalidConfig(format!(
1197                "OpenTelemetry transport must be 'http_binary' or 'grpc', got {other:?}"
1198            )));
1199        }
1200    }
1201    .with_timeout(Duration::from_millis(section.timeout_millis));
1202
1203    if let Some(endpoint) = section.endpoint {
1204        config = config.with_endpoint(endpoint);
1205    }
1206    if let Some(namespace) = section.service_namespace {
1207        config = config.with_service_namespace(namespace);
1208    }
1209    if let Some(version) = section.service_version {
1210        config = config.with_service_version(version);
1211    }
1212    if let Some(scope) = section.instrumentation_scope {
1213        config = config.with_instrumentation_scope(scope);
1214    }
1215    for (key, value) in section.headers {
1216        config = config.with_header(key, value);
1217    }
1218    for (key, value) in section.resource_attributes {
1219        config = config.with_resource_attribute(key, value);
1220    }
1221    Ok(config)
1222}
1223
1224#[cfg(feature = "openinference")]
1225fn build_openinference_config(section: OtlpSectionConfig) -> PluginResult<CoreOpenInferenceConfig> {
1226    let transport = match section.transport.as_str() {
1227        "http_binary" => OpenInferenceTransport::HttpBinary,
1228        "grpc" => OpenInferenceTransport::Grpc,
1229        other => {
1230            return Err(PluginError::InvalidConfig(format!(
1231                "OpenInference transport must be 'http_binary' or 'grpc', got {other:?}"
1232            )));
1233        }
1234    };
1235    let mut config = CoreOpenInferenceConfig::new()
1236        .with_transport(transport)
1237        .with_service_name(section.service_name)
1238        .with_timeout(Duration::from_millis(section.timeout_millis));
1239
1240    if let Some(endpoint) = section.endpoint {
1241        config = config.with_endpoint(endpoint);
1242    }
1243    if let Some(namespace) = section.service_namespace {
1244        config = config.with_service_namespace(namespace);
1245    }
1246    if let Some(version) = section.service_version {
1247        config = config.with_service_version(version);
1248    }
1249    if let Some(scope) = section.instrumentation_scope {
1250        config = config.with_instrumentation_scope(scope);
1251    }
1252    for (key, value) in section.headers {
1253        config = config.with_header(key, value);
1254    }
1255    for (key, value) in section.resource_attributes {
1256        config = config.with_resource_attribute(key, value);
1257    }
1258    Ok(config)
1259}
1260
1261fn parse_observability_config(
1262    plugin_config: &Map<String, Json>,
1263) -> PluginResult<ObservabilityConfig> {
1264    serde_json::from_value(Json::Object(plugin_config.clone())).map_err(|err| {
1265        PluginError::InvalidConfig(format!("invalid observability plugin config: {err}"))
1266    })
1267}
1268
1269fn validate_observability_plugin_config(
1270    plugin_config: &Map<String, Json>,
1271) -> Vec<ConfigDiagnostic> {
1272    let config = match parse_observability_config(plugin_config) {
1273        Ok(config) => config,
1274        Err(err) => {
1275            return vec![ConfigDiagnostic {
1276                level: DiagnosticLevel::Error,
1277                code: "observability.invalid_plugin_config".to_string(),
1278                component: Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
1279                field: None,
1280                message: err.to_string(),
1281            }];
1282        }
1283    };
1284
1285    let mut diagnostics = vec![];
1286    validate_unknown_fields(
1287        &mut diagnostics,
1288        &config.policy,
1289        Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
1290        plugin_config,
1291        &[
1292            "version",
1293            "atof",
1294            "atif",
1295            "opentelemetry",
1296            "openinference",
1297            "policy",
1298        ],
1299    );
1300
1301    validate_version(&mut diagnostics, &config.policy, config.version);
1302    validate_policy_fields(&mut diagnostics, &config.policy, plugin_config);
1303    validate_section_fields(
1304        &mut diagnostics,
1305        &config.policy,
1306        plugin_config,
1307        "atof",
1308        &["enabled", "output_directory", "filename", "mode"],
1309    );
1310    validate_section_fields(
1311        &mut diagnostics,
1312        &config.policy,
1313        plugin_config,
1314        "atif",
1315        &[
1316            "enabled",
1317            "agent_name",
1318            "agent_version",
1319            "model_name",
1320            "tool_definitions",
1321            "extra",
1322            "output_directory",
1323            "filename_template",
1324            "storage",
1325        ],
1326    );
1327    validate_section_fields(
1328        &mut diagnostics,
1329        &config.policy,
1330        plugin_config,
1331        "opentelemetry",
1332        &[
1333            "enabled",
1334            "transport",
1335            "endpoint",
1336            "headers",
1337            "resource_attributes",
1338            "service_name",
1339            "service_namespace",
1340            "service_version",
1341            "instrumentation_scope",
1342            "timeout_millis",
1343        ],
1344    );
1345    validate_section_fields(
1346        &mut diagnostics,
1347        &config.policy,
1348        plugin_config,
1349        "openinference",
1350        &[
1351            "enabled",
1352            "transport",
1353            "endpoint",
1354            "headers",
1355            "resource_attributes",
1356            "service_name",
1357            "service_namespace",
1358            "service_version",
1359            "instrumentation_scope",
1360            "timeout_millis",
1361        ],
1362    );
1363
1364    if let Some(section) = &config.atof {
1365        validate_atof_values(&mut diagnostics, &config.policy, section);
1366        #[cfg(target_arch = "wasm32")]
1367        if section.enabled {
1368            push_policy_diag(
1369                &mut diagnostics,
1370                config.policy.unsupported_value,
1371                "observability.unsupported_value",
1372                Some("atof".to_string()),
1373                Some("enabled".to_string()),
1374                "ATOF file export is not supported on WebAssembly".to_string(),
1375            );
1376        }
1377    }
1378    if let Some(section) = &config.atif {
1379        validate_atif_values(&mut diagnostics, &config.policy, section);
1380        #[cfg(target_arch = "wasm32")]
1381        if section.enabled {
1382            push_policy_diag(
1383                &mut diagnostics,
1384                config.policy.unsupported_value,
1385                "observability.unsupported_value",
1386                Some("atif".to_string()),
1387                Some("enabled".to_string()),
1388                "ATIF file export is not supported on WebAssembly".to_string(),
1389            );
1390        }
1391        #[cfg(not(all(feature = "object-store", not(target_arch = "wasm32"))))]
1392        if !section.storage.is_empty() {
1393            push_policy_diag(
1394                &mut diagnostics,
1395                config.policy.unsupported_value,
1396                "observability.feature_disabled",
1397                Some("atif".to_string()),
1398                Some("storage".to_string()),
1399                "ATIF storage support is not enabled in this build".to_string(),
1400            );
1401        }
1402    }
1403    if let Some(section) = &config.opentelemetry {
1404        validate_otlp_values(&mut diagnostics, &config.policy, "opentelemetry", section);
1405        #[cfg(not(feature = "otel"))]
1406        if section.enabled {
1407            push_policy_diag(
1408                &mut diagnostics,
1409                config.policy.unsupported_value,
1410                "observability.feature_disabled",
1411                Some("opentelemetry".to_string()),
1412                Some("enabled".to_string()),
1413                "OpenTelemetry support is not enabled in this build".to_string(),
1414            );
1415        }
1416    }
1417    if let Some(section) = &config.openinference {
1418        validate_otlp_values(&mut diagnostics, &config.policy, "openinference", section);
1419        #[cfg(not(feature = "openinference"))]
1420        if section.enabled {
1421            push_policy_diag(
1422                &mut diagnostics,
1423                config.policy.unsupported_value,
1424                "observability.feature_disabled",
1425                Some("openinference".to_string()),
1426                Some("enabled".to_string()),
1427                "OpenInference support is not enabled in this build".to_string(),
1428            );
1429        }
1430    }
1431
1432    diagnostics
1433}
1434
1435fn validate_version(diagnostics: &mut Vec<ConfigDiagnostic>, policy: &ConfigPolicy, version: u32) {
1436    if version != 1 {
1437        push_policy_diag(
1438            diagnostics,
1439            policy.unsupported_value,
1440            "observability.unsupported_config_version",
1441            Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
1442            Some("version".to_string()),
1443            format!("observability config version {version} is unsupported"),
1444        );
1445    }
1446}
1447
1448fn validate_policy_fields(
1449    diagnostics: &mut Vec<ConfigDiagnostic>,
1450    policy: &ConfigPolicy,
1451    plugin_config: &Map<String, Json>,
1452) {
1453    if let Some(policy_json) = plugin_config.get("policy").and_then(Json::as_object) {
1454        validate_unknown_fields(
1455            diagnostics,
1456            policy,
1457            Some("policy".to_string()),
1458            policy_json,
1459            &["unknown_component", "unknown_field", "unsupported_value"],
1460        );
1461    }
1462}
1463
1464fn validate_section_fields(
1465    diagnostics: &mut Vec<ConfigDiagnostic>,
1466    policy: &ConfigPolicy,
1467    plugin_config: &Map<String, Json>,
1468    section: &str,
1469    known_fields: &[&str],
1470) {
1471    if let Some(section_json) = plugin_config.get(section).and_then(Json::as_object) {
1472        validate_unknown_fields(
1473            diagnostics,
1474            policy,
1475            Some(section.to_string()),
1476            section_json,
1477            known_fields,
1478        );
1479    }
1480}
1481
1482fn validate_atof_values(
1483    diagnostics: &mut Vec<ConfigDiagnostic>,
1484    policy: &ConfigPolicy,
1485    section: &AtofSectionConfig,
1486) {
1487    if AtofExporterMode::parse(&section.mode).is_none() {
1488        push_policy_diag(
1489            diagnostics,
1490            policy.unsupported_value,
1491            "observability.unsupported_value",
1492            Some("atof".to_string()),
1493            Some("mode".to_string()),
1494            "ATOF mode must be 'append' or 'overwrite'".to_string(),
1495        );
1496    }
1497}
1498
1499fn validate_atif_values(
1500    diagnostics: &mut Vec<ConfigDiagnostic>,
1501    policy: &ConfigPolicy,
1502    section: &AtifSectionConfig,
1503) {
1504    if !section.filename_template.contains("{session_id}") {
1505        push_policy_diag(
1506            diagnostics,
1507            policy.unsupported_value,
1508            "observability.unsupported_value",
1509            Some("atif".to_string()),
1510            Some("filename_template".to_string()),
1511            "ATIF filename_template must contain '{session_id}'".to_string(),
1512        );
1513    }
1514    for (index, storage) in section.storage.iter().enumerate() {
1515        validate_atif_storage_values(diagnostics, policy, index, storage);
1516    }
1517}
1518
1519fn validate_atif_storage_values(
1520    diagnostics: &mut Vec<ConfigDiagnostic>,
1521    policy: &ConfigPolicy,
1522    index: usize,
1523    storage: &AtifStorageConfig,
1524) {
1525    match storage {
1526        AtifStorageConfig::S3(s3) => {
1527            if s3.bucket.trim().is_empty() {
1528                push_policy_diag(
1529                    diagnostics,
1530                    policy.unsupported_value,
1531                    "observability.unsupported_value",
1532                    Some("atif".to_string()),
1533                    Some(format!("storage[{index}].bucket")),
1534                    format!("ATIF storage[{index}].bucket must be non-empty"),
1535                );
1536            }
1537            validate_atif_storage_env_var(
1538                diagnostics,
1539                policy,
1540                &format!("storage[{index}].secret_access_key_var"),
1541                s3.secret_access_key_var.as_deref(),
1542            );
1543            validate_atif_storage_env_var(
1544                diagnostics,
1545                policy,
1546                &format!("storage[{index}].session_token_var"),
1547                s3.session_token_var.as_deref(),
1548            );
1549        }
1550    }
1551}
1552
1553fn validate_atif_storage_env_var(
1554    diagnostics: &mut Vec<ConfigDiagnostic>,
1555    policy: &ConfigPolicy,
1556    field: &str,
1557    var_name: Option<&str>,
1558) {
1559    let Some(var_name) = var_name else {
1560        return;
1561    };
1562    let trimmed = var_name.trim();
1563    if trimmed.is_empty() {
1564        push_policy_diag(
1565            diagnostics,
1566            policy.unsupported_value,
1567            "observability.unsupported_value",
1568            Some("atif".to_string()),
1569            Some(field.to_string()),
1570            format!("ATIF {field} must be the name of an environment variable, not empty"),
1571        );
1572        return;
1573    }
1574    if trimmed != var_name {
1575        push_policy_diag(
1576            diagnostics,
1577            policy.unsupported_value,
1578            "observability.unsupported_value",
1579            Some("atif".to_string()),
1580            Some(field.to_string()),
1581            format!("ATIF {field} must not have surrounding whitespace; got '{var_name}'"),
1582        );
1583        return;
1584    }
1585    match std::env::var(var_name) {
1586        Ok(value) if !value.is_empty() => {}
1587        Ok(_) => {
1588            push_policy_diag(
1589                diagnostics,
1590                policy.unsupported_value,
1591                "observability.unsupported_value",
1592                Some("atif".to_string()),
1593                Some(field.to_string()),
1594                format!(
1595                    "ATIF {field}='{var_name}' references an environment variable that is set but empty"
1596                ),
1597            );
1598        }
1599        Err(_) => {
1600            push_policy_diag(
1601                diagnostics,
1602                policy.unsupported_value,
1603                "observability.unsupported_value",
1604                Some("atif".to_string()),
1605                Some(field.to_string()),
1606                format!(
1607                    "ATIF {field}='{var_name}' references an environment variable that is not set"
1608                ),
1609            );
1610        }
1611    }
1612}
1613
1614fn validate_otlp_values(
1615    diagnostics: &mut Vec<ConfigDiagnostic>,
1616    policy: &ConfigPolicy,
1617    section_name: &str,
1618    section: &OtlpSectionConfig,
1619) {
1620    if !matches!(section.transport.as_str(), "http_binary" | "grpc") {
1621        push_policy_diag(
1622            diagnostics,
1623            policy.unsupported_value,
1624            "observability.unsupported_value",
1625            Some(section_name.to_string()),
1626            Some("transport".to_string()),
1627            format!("{section_name} transport must be 'http_binary' or 'grpc'"),
1628        );
1629    }
1630}
1631
1632fn validate_unknown_fields(
1633    diagnostics: &mut Vec<ConfigDiagnostic>,
1634    policy: &ConfigPolicy,
1635    component: Option<String>,
1636    config: &Map<String, Json>,
1637    known_fields: &[&str],
1638) {
1639    for field in config.keys() {
1640        if !known_fields.contains(&field.as_str()) {
1641            push_policy_diag(
1642                diagnostics,
1643                policy.unknown_field,
1644                "observability.unknown_field",
1645                component.clone(),
1646                Some(field.clone()),
1647                format!(
1648                    "field '{}' is not recognized for '{}'",
1649                    field,
1650                    component.as_deref().unwrap_or("unknown")
1651                ),
1652            );
1653        }
1654    }
1655}
1656
1657fn push_policy_diag(
1658    diagnostics: &mut Vec<ConfigDiagnostic>,
1659    behavior: UnsupportedBehavior,
1660    code: &str,
1661    component: Option<String>,
1662    field: Option<String>,
1663    message: String,
1664) {
1665    let level = match behavior {
1666        UnsupportedBehavior::Ignore => return,
1667        UnsupportedBehavior::Warn => DiagnosticLevel::Warning,
1668        UnsupportedBehavior::Error => DiagnosticLevel::Error,
1669    };
1670    diagnostics.push(ConfigDiagnostic {
1671        level,
1672        code: code.to_string(),
1673        component,
1674        field,
1675        message,
1676    });
1677}
1678
1679fn observability_registration_error(error: impl std::fmt::Display) -> PluginError {
1680    PluginError::RegistrationFailed(error.to_string())
1681}
1682
1683fn default_observability_config_version() -> u32 {
1684    1
1685}
1686
1687fn default_atof_mode() -> String {
1688    "append".to_string()
1689}
1690
1691fn default_agent_name() -> String {
1692    "NeMo Relay".to_string()
1693}
1694
1695fn default_agent_version() -> String {
1696    env!("CARGO_PKG_VERSION").to_string()
1697}
1698
1699fn default_model_name() -> String {
1700    "unknown".to_string()
1701}
1702
1703fn default_atif_filename_template() -> String {
1704    "nemo-relay-atif-{session_id}.json".to_string()
1705}
1706
1707fn default_otlp_transport() -> String {
1708    "http_binary".to_string()
1709}
1710
1711fn default_service_name() -> String {
1712    "nemo-relay".to_string()
1713}
1714
1715fn default_timeout_millis() -> u64 {
1716    3_000
1717}
1718
1719fn default_output_directory() -> PathBuf {
1720    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1721}
1722
1723#[cfg(not(all(feature = "object-store", not(target_arch = "wasm32"))))]
1724struct AtifRemoteStorage;
1725
1726/// Remote storage handle for ATIF trajectory uploads.
1727///
1728/// The handle owns a dedicated OS thread that runs a single-threaded tokio
1729/// runtime. Subscriber callbacks (which run on the runtime that emitted the
1730/// event) submit uploads over a synchronous channel and block on the reply, so
1731/// the handle stays safe to drive from any thread regardless of whether the
1732/// caller is already inside another tokio runtime.
1733#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1734struct AtifRemoteStorage {
1735    sender: std::sync::mpsc::Sender<AtifUploadRequest>,
1736    key_prefix: String,
1737}
1738
1739#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1740struct AtifUploadRequest {
1741    key: String,
1742    payload: Vec<u8>,
1743    reply: std::sync::mpsc::Sender<std::io::Result<()>>,
1744}
1745
1746#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1747#[derive(Default)]
1748struct S3BuilderOverrides {
1749    access_key_id: Option<String>,
1750    secret_access_key: Option<String>,
1751    session_token: Option<String>,
1752    region: Option<String>,
1753    endpoint_url: Option<String>,
1754    allow_http: Option<bool>,
1755}
1756
1757#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1758impl S3BuilderOverrides {
1759    fn resolve(index: usize, s3: &S3StorageConfig) -> std::io::Result<Self> {
1760        Ok(Self {
1761            access_key_id: s3.access_key_id.clone(),
1762            secret_access_key: resolve_env_var_field(
1763                &format!("storage[{index}].secret_access_key_var"),
1764                s3.secret_access_key_var.as_deref(),
1765            )?,
1766            session_token: resolve_env_var_field(
1767                &format!("storage[{index}].session_token_var"),
1768                s3.session_token_var.as_deref(),
1769            )?,
1770            region: s3.region.clone(),
1771            endpoint_url: s3.endpoint_url.clone(),
1772            allow_http: s3.allow_http,
1773        })
1774    }
1775
1776    fn apply(
1777        self,
1778        mut builder: object_store::aws::AmazonS3Builder,
1779    ) -> object_store::aws::AmazonS3Builder {
1780        if let Some(value) = self.access_key_id {
1781            builder = builder.with_access_key_id(value);
1782        }
1783        if let Some(value) = self.secret_access_key {
1784            builder = builder.with_secret_access_key(value);
1785        }
1786        if let Some(value) = self.session_token {
1787            builder = builder.with_token(value);
1788        }
1789        if let Some(value) = self.region {
1790            builder = builder.with_region(value);
1791        }
1792        if let Some(value) = self.endpoint_url {
1793            builder = builder.with_endpoint(value);
1794        }
1795        if let Some(value) = self.allow_http {
1796            builder = builder.with_allow_http(value);
1797        }
1798        builder
1799    }
1800}
1801
1802#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1803fn resolve_env_var_field(field: &str, var_name: Option<&str>) -> std::io::Result<Option<String>> {
1804    let Some(var_name) = var_name else {
1805        return Ok(None);
1806    };
1807    if var_name.trim().is_empty() || var_name.trim() != var_name {
1808        return Err(std::io::Error::other(format!(
1809            "ATIF {field} must be the name of an environment variable, not '{var_name}'"
1810        )));
1811    }
1812    match std::env::var(var_name) {
1813        Ok(value) if !value.is_empty() => Ok(Some(value)),
1814        Ok(_) => Err(std::io::Error::other(format!(
1815            "ATIF {field}='{var_name}' references an environment variable that is set but empty"
1816        ))),
1817        Err(_) => Err(std::io::Error::other(format!(
1818            "ATIF {field}='{var_name}' references an environment variable that is not set"
1819        ))),
1820    }
1821}
1822
1823#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1824impl AtifRemoteStorage {
1825    fn from_config(index: usize, config: &AtifStorageConfig) -> std::io::Result<Self> {
1826        match config {
1827            AtifStorageConfig::S3(s3) => Self::build_s3(index, s3),
1828        }
1829    }
1830
1831    fn build_s3(index: usize, s3: &S3StorageConfig) -> std::io::Result<Self> {
1832        let bucket = s3.bucket.clone();
1833        let key_prefix = normalize_storage_key_prefix(s3.key_prefix.as_deref());
1834        let overrides = S3BuilderOverrides::resolve(index, s3)?;
1835
1836        let (req_tx, req_rx) = std::sync::mpsc::channel::<AtifUploadRequest>();
1837        let (ready_tx, ready_rx) = std::sync::mpsc::channel::<std::io::Result<()>>();
1838
1839        std::thread::Builder::new()
1840            .name("nemo-relay-atif-storage".to_string())
1841            .spawn(move || {
1842                let runtime = match tokio::runtime::Builder::new_current_thread()
1843                    .enable_all()
1844                    .build()
1845                {
1846                    Ok(rt) => rt,
1847                    Err(err) => {
1848                        let _ = ready_tx.send(Err(std::io::Error::other(format!(
1849                            "failed to build ATIF storage runtime: {err}"
1850                        ))));
1851                        return;
1852                    }
1853                };
1854                let store = match overrides
1855                    .apply(object_store::aws::AmazonS3Builder::from_env())
1856                    .with_bucket_name(&bucket)
1857                    .build()
1858                {
1859                    Ok(store) => Arc::new(store) as Arc<dyn object_store::ObjectStore>,
1860                    Err(err) => {
1861                        let _ = ready_tx.send(Err(std::io::Error::other(format!(
1862                            "failed to build S3 client for bucket '{bucket}': {err}"
1863                        ))));
1864                        return;
1865                    }
1866                };
1867                if ready_tx.send(Ok(())).is_err() {
1868                    return;
1869                }
1870                drop(ready_tx);
1871
1872                while let Ok(request) = req_rx.recv() {
1873                    let result = runtime.block_on(async {
1874                        use object_store::ObjectStoreExt as _;
1875                        store
1876                            .put(
1877                                &object_store::path::Path::from(request.key.clone()),
1878                                object_store::PutPayload::from(request.payload),
1879                            )
1880                            .await
1881                            .map(|_| ())
1882                            .map_err(|err| {
1883                                std::io::Error::other(format!(
1884                                    "S3 upload to '{}' failed: {err}",
1885                                    request.key
1886                                ))
1887                            })
1888                    });
1889                    let _ = request.reply.send(result);
1890                }
1891            })
1892            .map_err(|err| {
1893                std::io::Error::other(format!("failed to spawn ATIF storage thread: {err}"))
1894            })?;
1895
1896        match ready_rx.recv() {
1897            Ok(Ok(())) => Ok(Self {
1898                sender: req_tx,
1899                key_prefix,
1900            }),
1901            Ok(Err(err)) => Err(err),
1902            Err(_) => Err(std::io::Error::other(
1903                "ATIF storage thread exited before signalling readiness",
1904            )),
1905        }
1906    }
1907
1908    fn put(&self, filename: &str, payload: &[u8]) -> std::io::Result<()> {
1909        let key = format!("{}{}", self.key_prefix, filename);
1910        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1911        self.sender
1912            .send(AtifUploadRequest {
1913                key,
1914                payload: payload.to_vec(),
1915                reply: reply_tx,
1916            })
1917            .map_err(|_| std::io::Error::other("ATIF storage thread is not running"))?;
1918        reply_rx
1919            .recv()
1920            .map_err(|_| std::io::Error::other("ATIF storage thread dropped the upload reply"))?
1921    }
1922}
1923
1924#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1925fn normalize_storage_key_prefix(raw: Option<&str>) -> String {
1926    let trimmed = raw.unwrap_or("").trim();
1927    if trimmed.is_empty() {
1928        return String::new();
1929    }
1930    if trimmed.ends_with('/') {
1931        trimmed.to_string()
1932    } else {
1933        format!("{trimmed}/")
1934    }
1935}
1936
1937#[cfg(test)]
1938#[path = "../../tests/unit/observability/plugin_component_tests.rs"]
1939mod tests;