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) registers one global subscriber when enabled.
14//! Typed OpenTelemetry endpoints share one global fan-out subscriber. Agent
15//! Trajectory Interchange Format (ATIF) uses a global dispatcher that detects
16//! top-level agent or turn scopes and creates one scope-local exporter for each
17//! trajectory run. Coding-agent turns that need bounded traces carry role
18//! metadata; their declared scope type is preserved in the exported event
19//! stream.
20
21use std::borrow::Cow;
22use std::collections::{HashMap, HashSet};
23use std::future::Future;
24use std::net::IpAddr;
25use std::panic::{AssertUnwindSafe, catch_unwind};
26use std::path::{Component, Path, PathBuf};
27use std::pin::Pin;
28#[cfg(feature = "object-store")]
29use std::sync::atomic::{AtomicU8, Ordering};
30use std::sync::{Arc, Mutex};
31use std::time::Duration;
32
33use serde::{Deserialize, Serialize};
34use serde_json::{Map, Value as Json};
35use uuid::Uuid;
36
37use crate::api::event::{Event, ScopeCategory};
38use crate::api::runtime::{EventSubscriberFn, current_scope_stack};
39use crate::api::scope::ScopeType;
40use crate::api::subscriber::{
41    flush_subscribers, scope_deregister_subscriber, try_scope_deregister_subscriber,
42    try_scope_register_subscriber,
43};
44use crate::config_editor::{
45    EditorConfig, EditorFieldKind, EditorFieldSpec, EditorListItemSpec, EditorSchema,
46    EditorTaggedUnionSpec, EditorVariantSpec,
47};
48use crate::error::FlowError;
49use crate::observability::atif::{AtifAgentInfo, AtifExporter};
50use crate::observability::atof::{
51    AtofEndpointFieldNamePolicy, AtofEndpointTransport, AtofExporter,
52    AtofExporterConfig as CoreAtofExporterConfig, AtofExporterMode, AtofFileSinkConfig,
53    AtofSinkConfig as CoreAtofSinkConfig, AtofStreamSinkConfig,
54};
55use crate::observability::otel::{
56    OpenTelemetryConfig as CoreOpenTelemetryConfig, OpenTelemetrySubscriber, OtlpTransport,
57    resolve_http_trace_endpoint,
58};
59use crate::observability::{
60    MarkProjection, OpenTelemetryType, OtlpAttributeMapping, default_mark_exclude_names,
61    validate_attribute_mappings,
62};
63use crate::plugin::{
64    ATIF_RUNTIME_DELIVERY_FAILURE_MARKER, ConfigDiagnostic, ConfigPolicy, DiagnosticLevel,
65    OTEL_RUNTIME_DELIVERY_FAILURE_MARKER, Plugin, PluginComponentSpec, PluginError,
66    PluginRegistration, PluginRegistrationCleanupOutcome, PluginRegistrationContext,
67    Result as PluginResult, UnsupportedBehavior, apply_global_config_policy, deregister_plugin,
68    register_builtin_plugin,
69};
70use crate::plugin::{RuntimeDiagnostic, record_active_plugin_runtime_diagnostic};
71
72/// The plugin kind registered by the core crate.
73pub const OBSERVABILITY_PLUGIN_KIND: &str = "observability";
74/// Top-level observability component wrapper.
75///
76/// Use this wrapper when constructing a [`PluginComponentSpec`] from Rust
77/// instead of hand-writing the generic plugin component shape. The component
78/// kind is always [`OBSERVABILITY_PLUGIN_KIND`].
79#[derive(Debug, Clone)]
80pub struct ComponentSpec {
81    /// Whether the observability component should be activated.
82    pub enabled: bool,
83    /// Observability config for this top-level component.
84    pub config: ObservabilityConfig,
85}
86
87impl ComponentSpec {
88    /// Creates an enabled observability component spec.
89    ///
90    /// The returned component can be converted into the generic plugin config
91    /// entry with `PluginComponentSpec::from(...)`.
92    pub fn new(config: ObservabilityConfig) -> Self {
93        Self {
94            enabled: true,
95            config,
96        }
97    }
98}
99
100impl From<ComponentSpec> for PluginComponentSpec {
101    fn from(value: ComponentSpec) -> Self {
102        let Json::Object(config) = serde_json::to_value(value.config)
103            .expect("observability config should serialize to object")
104        else {
105            unreachable!("observability config must serialize to object");
106        };
107
108        PluginComponentSpec {
109            kind: OBSERVABILITY_PLUGIN_KIND.to_string(),
110            enabled: value.enabled,
111            config,
112        }
113    }
114}
115
116/// Canonical config document for the observability plugin component.
117///
118/// Every section is optional. A missing section has the same activation
119/// behavior as a section with `enabled = false`: it contributes no runtime
120/// subscribers and performs no export work.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct ObservabilityConfig {
124    /// Observability config schema version.
125    #[serde(default = "default_observability_config_version")]
126    pub version: u32,
127    /// Filesystem-backed raw ATOF JSONL export.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub atof: Option<AtofSectionConfig>,
130    /// Per-top-level-agent ATIF trajectory export.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub atif: Option<AtifSectionConfig>,
133    /// OpenTelemetry trace export.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub opentelemetry: Option<OpenTelemetrySectionConfig>,
136    /// Observability-local unsupported-config policy.
137    #[serde(default)]
138    pub policy: ConfigPolicy,
139}
140
141impl Default for ObservabilityConfig {
142    fn default() -> Self {
143        Self {
144            version: default_observability_config_version(),
145            atof: None,
146            atif: None,
147            opentelemetry: None,
148            policy: ConfigPolicy::default(),
149        }
150    }
151}
152
153/// Multi-endpoint OpenTelemetry export settings.
154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156pub struct OpenTelemetrySectionConfig {
157    /// Whether OpenTelemetry export is active.
158    #[serde(default)]
159    pub enabled: bool,
160    /// Independently configured OTLP destinations.
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub endpoints: Vec<OpenTelemetryEndpointConfig>,
163}
164
165/// One typed OTLP destination.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
168pub struct OpenTelemetryEndpointConfig {
169    /// Semantic projection emitted by this endpoint.
170    #[serde(rename = "type")]
171    pub otel_type: OpenTelemetryType,
172    /// Required OTLP endpoint.
173    pub endpoint: String,
174    /// Representation used for point-in-time marks.
175    #[serde(default)]
176    #[cfg_attr(feature = "schema", schemars(schema_with = "mark_projection_schema"))]
177    pub mark_projection: MarkProjection,
178    /// Mark names excluded from tool projection.
179    #[serde(default = "default_mark_exclude_names")]
180    pub mark_exclude_names: Vec<String>,
181    /// Projected attributes copied to aliases.
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub attribute_mappings: Vec<OtlpAttributeMapping>,
184    /// OTLP transport: `http_binary` or `grpc`.
185    #[serde(default = "default_otlp_transport")]
186    #[cfg_attr(feature = "schema", schemars(schema_with = "otlp_transport_schema"))]
187    pub transport: String,
188    /// Extra exporter headers or metadata.
189    #[serde(default)]
190    pub headers: HashMap<String, String>,
191    /// Exporter headers mapped to environment variable names.
192    #[serde(default)]
193    pub header_env: HashMap<String, String>,
194    /// Extra resource attributes.
195    #[serde(default)]
196    pub resource_attributes: HashMap<String, String>,
197    /// `service.name` resource attribute.
198    #[serde(default = "default_otel_service_name")]
199    pub service_name: String,
200    /// Optional `service.namespace` resource attribute.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub service_namespace: Option<String>,
203    /// Optional `service.version` resource attribute.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub service_version: Option<String>,
206    /// Instrumentation scope name.
207    #[serde(default = "default_otel_instrumentation_scope")]
208    pub instrumentation_scope: String,
209    /// Export timeout in milliseconds.
210    #[serde(default = "default_timeout_millis")]
211    pub timeout_millis: u64,
212}
213
214/// Multi-sink ATOF JSONL exporter config.
215///
216/// When enabled, this section wraps
217/// [`crate::observability::atof::AtofExporter`] and writes the raw ATOF event
218/// stream to one or more explicitly configured file or stream sinks.
219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
221pub struct AtofSectionConfig {
222    /// Whether ATOF JSONL export is active.
223    #[serde(default)]
224    pub enabled: bool,
225    /// Destinations that each receive every raw ATOF event.
226    #[serde(default, skip_serializing_if = "Vec::is_empty")]
227    pub sinks: Vec<AtofSinkSectionConfig>,
228}
229
230/// One plugin-managed destination for raw ATOF events.
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
233#[serde(tag = "type", rename_all = "snake_case")]
234pub enum AtofSinkSectionConfig {
235    /// A local JSONL file.
236    File(AtofFileSinkSectionConfig),
237    /// A remote stream.
238    Stream(AtofStreamSinkSectionConfig),
239}
240
241/// File sink settings for the ATOF plugin section.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
244pub struct AtofFileSinkSectionConfig {
245    /// Directory containing the JSONL output file.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub output_directory: Option<PathBuf>,
248    /// Output filename. Defaults to the native timestamped filename.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub filename: Option<String>,
251    /// File open mode: `append` or `overwrite`.
252    #[serde(default = "default_atof_mode")]
253    #[cfg_attr(feature = "schema", schemars(schema_with = "atof_mode_schema"))]
254    pub mode: String,
255}
256
257/// Stream sink settings for the ATOF plugin section.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
260pub struct AtofStreamSinkSectionConfig {
261    /// Endpoint URL.
262    pub url: String,
263    /// Transport: `http_post`, `websocket`, or `ndjson`.
264    #[serde(default = "default_atof_endpoint_transport")]
265    #[cfg_attr(
266        feature = "schema",
267        schemars(schema_with = "atof_endpoint_transport_schema")
268    )]
269    pub transport: String,
270    /// Headers applied to endpoint requests or handshakes.
271    #[serde(default)]
272    pub headers: HashMap<String, String>,
273    /// Header names mapped to environment variables containing their values.
274    #[serde(default)]
275    pub header_env: HashMap<String, String>,
276    /// Per-endpoint timeout in milliseconds.
277    #[serde(default = "default_timeout_millis")]
278    pub timeout_millis: u64,
279    /// Field name policy applied before sending events: `preserve` or `replace_dots`.
280    #[serde(default = "default_atof_endpoint_field_name_policy")]
281    pub field_name_policy: String,
282    /// Optional stable name used by other components to reference this endpoint.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub name: Option<String>,
285}
286
287/// Per-trajectory ATIF exporter config.
288///
289/// When enabled, this section creates a dispatcher that opens a separate
290/// [`crate::observability::atif::AtifExporter`] for each top-level agent or turn scope. The
291/// `{session_id}` placeholder in [`AtifSectionConfig::filename_template`] is required so
292/// concurrent sibling trajectories cannot overwrite each other's files.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
295pub struct AtifSectionConfig {
296    /// Whether ATIF export is active.
297    #[serde(default)]
298    pub enabled: bool,
299    /// Human-readable agent name.
300    #[serde(default = "default_agent_name")]
301    pub agent_name: String,
302    /// Agent version string.
303    #[serde(default = "default_agent_version")]
304    pub agent_version: String,
305    /// Default model name.
306    #[serde(default = "default_model_name")]
307    pub model_name: String,
308    /// Tool definitions available to the agent.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub tool_definitions: Option<Vec<Json>>,
311    /// Extra ATIF agent metadata.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub extra: Option<Json>,
314    /// Directory containing trajectory JSON files. Ignored when [`storage`] is non-empty.
315    ///
316    /// [`storage`]: Self::storage
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub output_directory: Option<PathBuf>,
319    /// Filename template. `{session_id}` is replaced with the top-level trajectory scope UUID, and
320    /// `{metadata.<path>:-fallback}` placeholders use path-safe strings from the top-level scope
321    /// metadata or the optional literal fallback. When [`storage`] is non-empty, the rendered
322    /// filename is appended to each backend's key prefix.
323    ///
324    /// [`storage`]: Self::storage
325    #[serde(default = "default_atif_filename_template")]
326    pub filename_template: String,
327    /// Optional list of remote storage destinations. When non-empty, completed
328    /// trajectories are uploaded to every configured backend instead of being
329    /// written locally; the local file write at [`output_directory`] is
330    /// skipped. Backends are independent: an upload failure on one destination
331    /// is recorded against that destination and skipped on subsequent
332    /// trajectories, while the other destinations continue to receive writes.
333    ///
334    /// [`output_directory`]: Self::output_directory
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub storage: Vec<AtifStorageConfig>,
337}
338
339impl Default for AtifSectionConfig {
340    fn default() -> Self {
341        Self {
342            enabled: false,
343            agent_name: default_agent_name(),
344            agent_version: default_agent_version(),
345            model_name: default_model_name(),
346            tool_definitions: None,
347            extra: None,
348            output_directory: None,
349            filename_template: default_atif_filename_template(),
350            storage: Vec::new(),
351        }
352    }
353}
354
355/// Remote storage destination for ATIF trajectory files.
356///
357/// When [`AtifSectionConfig::storage`] is non-empty, the ATIF dispatcher
358/// uploads each completed trajectory to every configured backend instead of
359/// writing it to the local filesystem. The shape is tagged with a `type`
360/// discriminator so additional backends (for example, Azure Blob Storage) can
361/// be added without breaking existing configs.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
364#[serde(tag = "type", rename_all = "snake_case")]
365pub enum AtifStorageConfig {
366    /// HTTP endpoint storage.
367    Http(HttpStorageConfig),
368    /// S3-compatible object storage.
369    ///
370    /// Non-secret connection settings (`region`, `endpoint_url`, `allow_http`)
371    /// and the static `access_key_id` may be set directly. The secret
372    /// credential fields (`secret_access_key_var`, `session_token_var`) must
373    /// reference the *name* of an environment variable that holds the secret,
374    /// so multiple S3 destinations can coexist in one config without writing
375    /// secrets into checked-in files. Any field left unset falls back to the
376    /// matching `AWS_*` environment variable (`AWS_ACCESS_KEY_ID`,
377    /// `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`,
378    /// `AWS_ENDPOINT_URL`, `AWS_ALLOW_HTTP`).
379    S3(S3StorageConfig),
380}
381
382/// S3-compatible storage settings for ATIF trajectory upload.
383///
384/// Every connection field is optional. Unset fields fall back to the matching
385/// `AWS_*` environment variable, preserving the env-driven workflow while
386/// letting one config file fully describe a destination when needed. Secret
387/// credentials are referenced by env var *name* (the `_var` suffix), so
388/// multiple destinations can each carry their own credentials without leaking
389/// secret material into the config.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
392pub struct S3StorageConfig {
393    /// Destination bucket name. Must be non-empty.
394    pub bucket: String,
395    /// Optional key prefix applied to every uploaded object. A trailing `/` is
396    /// inserted automatically when one is missing.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub key_prefix: Option<String>,
399    /// Static AWS access key ID. When unset, `AWS_ACCESS_KEY_ID` is used.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub access_key_id: Option<String>,
402    /// Name of the environment variable that holds the static secret access
403    /// key. Validated to be non-empty and present at plugin initialization
404    /// time. When unset, `AWS_SECRET_ACCESS_KEY` is used.
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub secret_access_key_var: Option<String>,
407    /// Name of the environment variable that holds the optional STS session
408    /// token. Validated to be non-empty and present at plugin initialization
409    /// time. When unset, `AWS_SESSION_TOKEN` is used.
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub session_token_var: Option<String>,
412    /// AWS region for the bucket. When unset, `AWS_REGION` is used.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub region: Option<String>,
415    /// Endpoint URL override for S3-compatible storage (for example, MinIO).
416    /// When unset, `AWS_ENDPOINT_URL` is used.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub endpoint_url: Option<String>,
419    /// Allow plain HTTP endpoints. When unset, `AWS_ALLOW_HTTP` is used.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub allow_http: Option<bool>,
422}
423
424/// HTTP endpoint settings for ATIF trajectory upload.
425///
426/// Completed trajectories are uploaded with `POST` and an
427/// `application/json` body. Inline `headers` are merged with values resolved
428/// from `header_env`; `header_env` values are environment variable names, not
429/// secret values.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
432pub struct HttpStorageConfig {
433    /// Destination endpoint URL. Must use `http://` or `https://`.
434    pub endpoint: String,
435    /// Static request headers.
436    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
437    pub headers: HashMap<String, String>,
438    /// Request headers whose values are read from environment variables.
439    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
440    pub header_env: HashMap<String, String>,
441    /// Request timeout in milliseconds.
442    #[serde(default = "default_timeout_millis")]
443    pub timeout_millis: u64,
444}
445
446crate::editor_config! {
447    impl ObservabilityConfig {
448        atof => {
449            label: "ATOF",
450            kind: Section,
451            optional: true,
452            nested: AtofSectionConfig,
453            default: AtofSectionConfig,
454        },
455        atif => {
456            label: "ATIF",
457            kind: Section,
458            optional: true,
459            nested: AtifSectionConfig,
460            default: AtifSectionConfig,
461        },
462        opentelemetry => {
463            label: "OpenTelemetry",
464            kind: Section,
465            optional: true,
466            nested: OpenTelemetrySectionConfig,
467            default: OpenTelemetrySectionConfig,
468        },
469        policy => {
470            label: "policy",
471            kind: Section,
472            nested: ConfigPolicy,
473            default: ConfigPolicy,
474        },
475    }
476}
477
478crate::editor_config! {
479    impl OpenTelemetrySectionConfig {
480        enabled => { label: "enabled", kind: Boolean },
481        endpoints => { label: "endpoints", kind: List, list: &OPENTELEMETRY_ENDPOINT_LIST },
482    }
483}
484
485const fn otel_editor_field(
486    name: &'static str,
487    kind: EditorFieldKind,
488    enum_values: &'static [&'static str],
489    optional: bool,
490) -> EditorFieldSpec {
491    EditorFieldSpec {
492        name,
493        label: name,
494        kind,
495        enum_values,
496        optional,
497        nested_schema: None,
498        nested_default: None,
499        list_item: None,
500        tagged_union: None,
501    }
502}
503
504impl EditorConfig for OpenTelemetryEndpointConfig {
505    fn editor_schema() -> &'static EditorSchema {
506        static SCHEMA: EditorSchema = EditorSchema {
507            fields: &[
508                otel_editor_field(
509                    "type",
510                    EditorFieldKind::Enum,
511                    &["full", "gen_ai", "openinference"],
512                    false,
513                ),
514                otel_editor_field("endpoint", EditorFieldKind::String, &[], false),
515                otel_editor_field(
516                    "mark_projection",
517                    EditorFieldKind::Enum,
518                    &["inherit", "event", "tool"],
519                    false,
520                ),
521                otel_editor_field("mark_exclude_names", EditorFieldKind::Json, &[], false),
522                otel_editor_field("attribute_mappings", EditorFieldKind::List, &[], false),
523                otel_editor_field(
524                    "transport",
525                    EditorFieldKind::Enum,
526                    &["http_binary", "grpc"],
527                    false,
528                ),
529                otel_editor_field("service_name", EditorFieldKind::String, &[], false),
530                otel_editor_field("service_namespace", EditorFieldKind::String, &[], true),
531                otel_editor_field("service_version", EditorFieldKind::String, &[], true),
532                otel_editor_field("instrumentation_scope", EditorFieldKind::String, &[], false),
533                otel_editor_field("timeout_millis", EditorFieldKind::Integer, &[], false),
534                otel_editor_field("headers", EditorFieldKind::StringMap, &[], false),
535                otel_editor_field("header_env", EditorFieldKind::StringMap, &[], false),
536                otel_editor_field(
537                    "resource_attributes",
538                    EditorFieldKind::StringMap,
539                    &[],
540                    false,
541                ),
542            ],
543        };
544        &SCHEMA
545    }
546}
547
548fn default_opentelemetry_endpoint_editor_value() -> Json {
549    serde_json::json!({
550        "type": "full",
551        "endpoint": "",
552        "transport": "http_binary",
553        "service_name": "unknown_service",
554        "instrumentation_scope": "opentelemetry",
555        "timeout_millis": 3000,
556        "headers": {},
557        "header_env": {},
558        "resource_attributes": {},
559    })
560}
561
562static OPENTELEMETRY_ENDPOINT_LIST: EditorListItemSpec = EditorListItemSpec {
563    kind: EditorFieldKind::Section,
564    schema: Some(<OpenTelemetryEndpointConfig as EditorConfig>::editor_schema),
565    default: Some(default_opentelemetry_endpoint_editor_value),
566    tagged_union: None,
567    list_item: None,
568};
569
570crate::editor_config! {
571    impl AtofSectionConfig {
572        enabled => { label: "enabled", kind: Boolean },
573        sinks => { label: "sinks", kind: List, list: &ATOF_SINK_LIST },
574    }
575}
576
577crate::editor_config! {
578    impl AtofFileSinkSectionConfig {
579        output_directory => { label: "output_directory", kind: String, optional: true },
580        filename => { label: "filename", kind: String, optional: true },
581        mode => { label: "mode", kind: Enum, values: ["append", "overwrite"] },
582    }
583}
584
585crate::editor_config! {
586    impl AtofStreamSinkSectionConfig {
587        url => { label: "url", kind: String },
588        transport => { label: "transport", kind: Enum, values: ["http_post", "websocket", "ndjson"] },
589        headers => { label: "headers", kind: StringMap },
590        header_env => { label: "header_env", kind: StringMap },
591        timeout_millis => { label: "timeout_millis", kind: Integer },
592        field_name_policy => { label: "field_name_policy", kind: Enum, values: ["preserve", "replace_dots"] },
593        name => { label: "name", kind: String, optional: true },
594    }
595}
596
597crate::editor_config! {
598    impl AtifSectionConfig {
599        enabled => { label: "enabled", kind: Boolean },
600        agent_name => { label: "agent_name", kind: String },
601        agent_version => { label: "agent_version", kind: String },
602        model_name => { label: "model_name", kind: String },
603        tool_definitions => { label: "tool_definitions", kind: Json, optional: true },
604        extra => { label: "extra", kind: Json, optional: true },
605        output_directory => { label: "output_directory", kind: String, optional: true },
606        filename_template => { label: "filename_template", kind: String },
607        storage => { label: "storage", kind: Json, optional: true },
608    }
609}
610
611fn default_atof_file_sink_editor_value() -> Json {
612    serde_json::json!({"type": "file", "mode": "append"})
613}
614
615fn default_atof_stream_sink_editor_value() -> Json {
616    serde_json::json!({
617        "type": "stream",
618        "url": "",
619        "transport": "http_post",
620        "headers": {},
621        "header_env": {},
622        "timeout_millis": 3000,
623        "field_name_policy": "preserve",
624    })
625}
626
627static ATOF_SINK_VARIANTS: [EditorVariantSpec; 2] = [
628    EditorVariantSpec {
629        label: "File",
630        tag: "file",
631        schema: <AtofFileSinkSectionConfig as EditorConfig>::editor_schema,
632        default: default_atof_file_sink_editor_value,
633    },
634    EditorVariantSpec {
635        label: "Stream",
636        tag: "stream",
637        schema: <AtofStreamSinkSectionConfig as EditorConfig>::editor_schema,
638        default: default_atof_stream_sink_editor_value,
639    },
640];
641
642static ATOF_SINK_TAGGED_UNION: EditorTaggedUnionSpec = EditorTaggedUnionSpec {
643    discriminator: "type",
644    variants: &ATOF_SINK_VARIANTS,
645};
646
647static ATOF_SINK_LIST: EditorListItemSpec = EditorListItemSpec {
648    kind: EditorFieldKind::Section,
649    schema: None,
650    default: None,
651    tagged_union: Some(&ATOF_SINK_TAGGED_UNION),
652    list_item: None,
653};
654
655struct ObservabilityPlugin;
656
657impl Plugin for ObservabilityPlugin {
658    fn plugin_kind(&self) -> &str {
659        OBSERVABILITY_PLUGIN_KIND
660    }
661
662    fn allows_multiple_components(&self) -> bool {
663        false
664    }
665
666    fn validate(&self, plugin_config: &Map<String, Json>) -> Vec<ConfigDiagnostic> {
667        validate_observability_plugin_config(plugin_config)
668    }
669
670    fn validate_with_policy(
671        &self,
672        plugin_config: &Map<String, Json>,
673        policy: &ConfigPolicy,
674    ) -> Vec<ConfigDiagnostic> {
675        validate_observability_plugin_config_with_policy(plugin_config, Some(policy))
676    }
677
678    fn register<'a>(
679        &'a self,
680        plugin_config: &Map<String, Json>,
681        ctx: &'a mut PluginRegistrationContext,
682    ) -> Pin<Box<dyn Future<Output = PluginResult<()>> + Send + 'a>> {
683        let plugin_config = plugin_config.clone();
684        Box::pin(async move {
685            let config = parse_observability_config(&plugin_config)?;
686            register_observability(config, ctx)
687        })
688    }
689}
690
691/// Registers the observability component kind in the core plugin registry.
692///
693/// Calling this function more than once is safe. The core plugin APIs call it
694/// automatically before listing, looking up, validating, or initializing plugin
695/// components, so applications normally do not need to invoke it directly.
696pub fn register_observability_component() -> PluginResult<()> {
697    register_builtin_plugin(Arc::new(ObservabilityPlugin))
698}
699
700/// Deregisters the observability component kind from the core plugin registry.
701///
702/// This helper exists primarily for tests and specialized embedding scenarios.
703/// It removes the plugin kind from future registry lookups but does not clear an
704/// already active plugin configuration.
705pub fn deregister_observability_component() -> bool {
706    deregister_plugin(OBSERVABILITY_PLUGIN_KIND)
707}
708
709/// Returns the JSON Schema for the observability component configuration.
710#[cfg(feature = "schema")]
711pub fn observability_config_schema() -> serde_json::Value {
712    serde_json::to_value(schemars::schema_for!(ObservabilityConfig))
713        .expect("observability config schema should serialize")
714}
715
716#[cfg(feature = "schema")]
717fn atof_mode_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
718    string_enum_schema(generator, &["append", "overwrite"], Some("append"))
719}
720
721#[cfg(feature = "schema")]
722fn atof_endpoint_transport_schema(
723    generator: &mut schemars::r#gen::SchemaGenerator,
724) -> schemars::schema::Schema {
725    string_enum_schema(
726        generator,
727        &["http_post", "websocket", "ndjson"],
728        Some("http_post"),
729    )
730}
731
732#[cfg(feature = "schema")]
733fn otlp_transport_schema(
734    generator: &mut schemars::r#gen::SchemaGenerator,
735) -> schemars::schema::Schema {
736    string_enum_schema(generator, &["http_binary", "grpc"], Some("http_binary"))
737}
738
739#[cfg(feature = "schema")]
740fn mark_projection_schema(
741    generator: &mut schemars::r#gen::SchemaGenerator,
742) -> schemars::schema::Schema {
743    string_enum_schema(generator, &["inherit", "event", "tool"], Some("inherit"))
744}
745
746#[cfg(feature = "schema")]
747fn string_enum_schema(
748    generator: &mut schemars::r#gen::SchemaGenerator,
749    values: &[&str],
750    default: Option<&str>,
751) -> schemars::schema::Schema {
752    let mut schema: schemars::schema::SchemaObject =
753        <String as schemars::JsonSchema>::json_schema(generator).into();
754    schema.enum_values = Some(
755        values
756            .iter()
757            .map(|value| Json::String((*value).into()))
758            .collect(),
759    );
760    if let Some(default) = default {
761        schema.metadata().default = Some(Json::String(default.into()));
762    }
763    schema.into()
764}
765
766fn register_observability(
767    config: ObservabilityConfig,
768    ctx: &mut PluginRegistrationContext,
769) -> PluginResult<()> {
770    if let Some(atof) = config.atof.filter(|section| section.enabled) {
771        register_atof_exporter(atof, ctx)?;
772    }
773    if let Some(atif) = config.atif.filter(|section| section.enabled) {
774        register_atif_dispatcher(atif, ctx)?;
775    }
776    if let Some(otel) = config.opentelemetry.filter(|section| section.enabled) {
777        register_opentelemetry(otel, ctx)?;
778    }
779    Ok(())
780}
781
782fn register_atof_exporter(
783    section: AtofSectionConfig,
784    ctx: &mut PluginRegistrationContext,
785) -> PluginResult<()> {
786    let exporters = section
787        .sinks
788        .into_iter()
789        .enumerate()
790        .map(|(index, sink)| {
791            let config = CoreAtofExporterConfig {
792                sink: build_atof_sink_config(index, sink)?,
793            };
794            AtofExporter::new(config)
795                .map(Arc::new)
796                .map_err(observability_registration_error)
797        })
798        .collect::<PluginResult<Vec<_>>>()?;
799    let subscribers = exporters
800        .iter()
801        .map(|exporter| exporter.subscriber())
802        .collect::<Vec<_>>();
803    let subscriber: EventSubscriberFn = Arc::new(move |event| {
804        for subscriber in &subscribers {
805            subscriber(event);
806        }
807    });
808
809    ctx.register_subscriber("atof", subscriber)?;
810    ctx.add_registration(PluginRegistration::new(
811        "observability",
812        ctx.qualify_name("atof.shutdown"),
813        Box::new(move || {
814            let mut first_error = None;
815            for exporter in &exporters {
816                if let Err(error) = exporter.shutdown() {
817                    first_error.get_or_insert_with(|| observability_registration_error(error));
818                }
819            }
820            first_error.map_or(Ok(()), Err)
821        }),
822    ));
823    Ok(())
824}
825
826fn build_atof_sink_config(
827    index: usize,
828    sink: AtofSinkSectionConfig,
829) -> PluginResult<CoreAtofSinkConfig> {
830    match sink {
831        AtofSinkSectionConfig::File(file) => {
832            let mode = AtofExporterMode::parse(&file.mode).ok_or_else(|| {
833                PluginError::InvalidConfig(format!(
834                    "ATOF sinks[{index}].mode must be 'append' or 'overwrite'"
835                ))
836            })?;
837            let mut sink = AtofFileSinkConfig::new();
838            sink.mode = mode;
839            if let Some(output_directory) = file.output_directory {
840                sink.output_directory = output_directory;
841            }
842            if let Some(filename) = file.filename {
843                sink.filename = filename;
844            }
845            Ok(CoreAtofSinkConfig::File(sink))
846        }
847        AtofSinkSectionConfig::Stream(stream) => {
848            let transport = AtofEndpointTransport::parse(&stream.transport).ok_or_else(|| {
849                PluginError::InvalidConfig(format!(
850                    "ATOF sinks[{index}].transport must be 'http_post', 'websocket', or 'ndjson'"
851                ))
852            })?;
853            let field_name_policy = AtofEndpointFieldNamePolicy::parse(&stream.field_name_policy)
854                .ok_or_else(|| {
855                PluginError::InvalidConfig(format!(
856                    "ATOF sinks[{index}].field_name_policy must be 'preserve' or 'replace_dots'"
857                ))
858            })?;
859            let mut config = AtofStreamSinkConfig::new(stream.url, transport)
860                .with_timeout_millis(stream.timeout_millis)
861                .with_field_name_policy(field_name_policy);
862            for (key, value) in stream.headers {
863                config = config.with_header(key, value);
864            }
865            for (key, variable) in stream.header_env {
866                config = config.with_header_env(key, variable);
867            }
868            Ok(CoreAtofSinkConfig::Stream(config))
869        }
870    }
871}
872
873type AtifStorageList = Arc<Vec<Arc<AtifRemoteStorage>>>;
874
875fn register_atif_dispatcher(
876    section: AtifSectionConfig,
877    ctx: &mut PluginRegistrationContext,
878) -> PluginResult<()> {
879    validate_atif_filename_template(&section.filename_template)
880        .map_err(PluginError::InvalidConfig)?;
881
882    let mut storage_vec = Vec::with_capacity(section.storage.len());
883    for (index, entry) in section.storage.iter().enumerate() {
884        storage_vec.push(build_atif_storage(index, entry)?);
885    }
886    let storage: AtifStorageList = Arc::new(storage_vec);
887
888    let manager = Arc::new(Mutex::new(AtifDispatcher::new(section)));
889    let dispatcher = atif_dispatcher_subscriber(
890        Arc::clone(&manager),
891        ctx.qualify_name("atif-"),
892        Arc::clone(&storage),
893    );
894    ctx.register_subscriber("atif", dispatcher)?;
895    let shutdown_storage = Arc::clone(&storage);
896    ctx.add_registration(PluginRegistration::new_with_outcome(
897        "observability",
898        ctx.qualify_name("atif.shutdown"),
899        Box::new(move || {
900            let work = match (|| -> PluginResult<_> {
901                let work = {
902                    let mut guard = manager.lock().map_err(|err| {
903                        PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
904                    })?;
905                    guard.flush_open_agents()
906                };
907                for (scope_uuid, name) in &work.scope_subscribers {
908                    deregister_atif_shutdown_subscriber(scope_uuid, name)?;
909                }
910                Ok(work)
911            })() {
912                Ok(work) => work,
913                Err(error) => return PluginRegistrationCleanupOutcome::NotRemoved(error),
914            };
915
916            let delivery = (|| -> PluginResult<()> {
917                for export in work.exports {
918                    let write = prepare_atif_shutdown_file(&export, Arc::clone(&manager))
919                        .map_err(observability_registration_error)?;
920                    let agent_uuid = write.agent_uuid;
921                    let targets = {
922                        let guard = manager.lock().map_err(|err| {
923                            PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
924                        })?;
925                        guard.sink_targets()
926                    };
927                    let results = write_atif(&write, shutdown_storage.as_slice(), &targets);
928                    let mut guard = manager.lock().map_err(|err| {
929                        PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
930                    })?;
931                    let _ = guard.complete_scope_write(agent_uuid, results);
932                }
933                Ok(())
934            })();
935            if let Err(error) = delivery {
936                return PluginRegistrationCleanupOutcome::RemovedWithError(error);
937            }
938            let guard = match manager.lock() {
939                Ok(guard) => guard,
940                Err(error) => {
941                    return PluginRegistrationCleanupOutcome::RemovedWithError(
942                        PluginError::Internal(format!("ATIF dispatcher lock poisoned: {error}")),
943                    );
944                }
945            };
946            match guard.last_error_result() {
947                Ok(()) => PluginRegistrationCleanupOutcome::Removed,
948                Err(error) => PluginRegistrationCleanupOutcome::RemovedWithError(
949                    observability_registration_error(error),
950                ),
951            }
952        }),
953    ));
954    Ok(())
955}
956
957fn deregister_atif_shutdown_subscriber(scope_uuid: &Uuid, name: &str) -> PluginResult<()> {
958    match scope_deregister_subscriber(scope_uuid, name) {
959        Ok(_) | Err(FlowError::NotFound(_)) => Ok(()),
960        Err(error) => Err(observability_registration_error(error)),
961    }
962}
963
964#[cfg(feature = "object-store")]
965fn build_atif_storage(
966    index: usize,
967    config: &AtifStorageConfig,
968) -> PluginResult<Arc<AtifRemoteStorage>> {
969    let storage = AtifRemoteStorage::from_config(index, config)
970        .map(Arc::new)
971        .map_err(observability_registration_error)?;
972    log::info!(
973        target: "nemo_relay.plugin",
974        event = "plugin_resource_access_pending",
975        plugin_kind = OBSERVABILITY_PLUGIN_KIND,
976        resource_kind = storage.resource_kind,
977        resource_index = index,
978        permission = "write";
979        "Plugin resource access will be validated on first use"
980    );
981    Ok(storage)
982}
983
984#[cfg(not(feature = "object-store"))]
985fn build_atif_storage(
986    _index: usize,
987    _config: &AtifStorageConfig,
988) -> PluginResult<Arc<AtifRemoteStorage>> {
989    Err(PluginError::InvalidConfig(
990        "ATIF storage support is not enabled in this build".to_string(),
991    ))
992}
993
994fn register_opentelemetry(
995    section: OpenTelemetrySectionConfig,
996    ctx: &mut PluginRegistrationContext,
997) -> PluginResult<()> {
998    if section.endpoints.is_empty() {
999        return Err(PluginError::InvalidConfig(
1000            "enabled OpenTelemetry section requires at least one endpoint".to_string(),
1001        ));
1002    }
1003    validate_distinct_opentelemetry_destinations(&section.endpoints)?;
1004    let subscribers = build_opentelemetry_subscribers(section.endpoints)?;
1005    for (index, _) in subscribers.iter().enumerate() {
1006        log::info!(
1007            target: "nemo_relay.plugin",
1008            event = "plugin_resource_access_pending",
1009            plugin_kind = OBSERVABILITY_PLUGIN_KIND,
1010            resource_kind = "otlp_endpoint",
1011            exporter = "opentelemetry",
1012            resource_index = index,
1013            permission = "write";
1014            "Plugin resource access will be validated during export"
1015        );
1016    }
1017    let callbacks = subscribers
1018        .iter()
1019        .map(|subscriber| subscriber.subscriber())
1020        .collect::<Vec<_>>();
1021    // Retain the subscribers as long as the registered fan-out callback exists.
1022    // Their tracer providers and exporter runtimes must outlive event delivery.
1023    let delivery_subscribers = subscribers.clone();
1024    ctx.add_registration(PluginRegistration::new_with_outcome(
1025        "observability",
1026        ctx.qualify_name("opentelemetry.shutdown"),
1027        Box::new(
1028            move || match shutdown_opentelemetry_subscribers(&subscribers) {
1029                None => PluginRegistrationCleanupOutcome::Removed,
1030                Some(OpenTelemetryShutdownFailure::Delivery(error)) => {
1031                    PluginRegistrationCleanupOutcome::RemovedWithError(error)
1032                }
1033                Some(OpenTelemetryShutdownFailure::Other(error)) => {
1034                    PluginRegistrationCleanupOutcome::NotRemoved(error)
1035                }
1036            },
1037        ),
1038    ));
1039    ctx.register_subscriber(
1040        "opentelemetry",
1041        Arc::new(move |event| {
1042            let _keep_exporters_alive = &delivery_subscribers;
1043            deliver_opentelemetry_event(&callbacks, event);
1044        }),
1045    )?;
1046    Ok(())
1047}
1048
1049fn deliver_opentelemetry_event(callbacks: &[EventSubscriberFn], event: &Event) {
1050    for (index, callback) in callbacks.iter().enumerate() {
1051        if catch_unwind(AssertUnwindSafe(|| callback(event))).is_err() {
1052            log::error!(
1053                target: "nemo_relay.plugin",
1054                event = "opentelemetry_endpoint_callback_panicked",
1055                plugin_kind = OBSERVABILITY_PLUGIN_KIND,
1056                resource_kind = "otlp_endpoint",
1057                resource_index = index;
1058                "OpenTelemetry endpoint callback panicked; delivery continued to remaining endpoints"
1059            );
1060        }
1061    }
1062}
1063
1064fn build_opentelemetry_subscribers(
1065    endpoints: Vec<OpenTelemetryEndpointConfig>,
1066) -> PluginResult<Vec<Arc<OpenTelemetrySubscriber>>> {
1067    let mut subscribers = Vec::with_capacity(endpoints.len());
1068    for (index, endpoint) in endpoints.into_iter().enumerate() {
1069        let subscriber = build_otel_config(index, endpoint).and_then(|config| {
1070            OpenTelemetrySubscriber::new_for_plugin(config, index)
1071                .map(Arc::new)
1072                .map_err(observability_registration_error)
1073        });
1074        match subscriber {
1075            Ok(subscriber) => subscribers.push(subscriber),
1076            Err(error) => {
1077                if !shutdown_opentelemetry_providers(&subscribers).is_empty() {
1078                    log::warn!(
1079                        target: "nemo_relay.plugin",
1080                        event = "plugin_resource_rollback_failed",
1081                        plugin_kind = OBSERVABILITY_PLUGIN_KIND,
1082                        resource_kind = "otlp_endpoint",
1083                        reason = "shutdown_failed";
1084                        "OpenTelemetry construction rollback could not shut down every endpoint"
1085                    );
1086                }
1087                return Err(error);
1088            }
1089        }
1090    }
1091    Ok(subscribers)
1092}
1093
1094enum OpenTelemetryShutdownFailure {
1095    Delivery(PluginError),
1096    Other(PluginError),
1097}
1098
1099fn shutdown_opentelemetry_subscribers(
1100    subscribers: &[Arc<OpenTelemetrySubscriber>],
1101) -> Option<OpenTelemetryShutdownFailure> {
1102    let mut errors = Vec::new();
1103    if let Err(error) = flush_subscribers() {
1104        errors.push(crate::observability::otel::OpenTelemetryError::Core(error));
1105    }
1106    errors.extend(shutdown_opentelemetry_providers(subscribers));
1107    if errors.is_empty() {
1108        return None;
1109    }
1110
1111    let all_delivery_failures = errors.iter().all(|error| {
1112        error
1113            .to_string()
1114            .contains(OTEL_RUNTIME_DELIVERY_FAILURE_MARKER)
1115    });
1116    let summary = errors
1117        .into_iter()
1118        .map(|error| error.to_string())
1119        .collect::<Vec<_>>()
1120        .join("; ");
1121    let message = if all_delivery_failures {
1122        format!("{OTEL_RUNTIME_DELIVERY_FAILURE_MARKER}: {summary}")
1123    } else {
1124        format!("OpenTelemetry shutdown failures: {summary}")
1125    };
1126    let error = PluginError::RegistrationFailed(message);
1127    Some(if all_delivery_failures {
1128        OpenTelemetryShutdownFailure::Delivery(error)
1129    } else {
1130        OpenTelemetryShutdownFailure::Other(error)
1131    })
1132}
1133
1134fn shutdown_opentelemetry_providers(
1135    subscribers: &[Arc<OpenTelemetrySubscriber>],
1136) -> Vec<crate::observability::otel::OpenTelemetryError> {
1137    let mut errors = Vec::new();
1138    for subscriber in subscribers {
1139        if let Err(error) = subscriber.shutdown_provider() {
1140            errors.push(error);
1141        }
1142    }
1143    errors
1144}
1145
1146struct AtifDispatcher {
1147    config: AtifSectionConfig,
1148    agents: HashMap<Uuid, ManagedAtifExporter>,
1149    scope_owners: HashMap<Uuid, Uuid>,
1150    scope_subscribers: HashMap<Uuid, String>,
1151    /// Fatal dispatcher errors (subscriber registration, payload serialization)
1152    /// that cannot be isolated to a single sink. Once set, the dispatcher stops
1153    /// observing further events.
1154    fatal_error: Option<String>,
1155    runtime_failures: Vec<RuntimeDiagnostic>,
1156    validated_sinks: HashSet<SinkLabel>,
1157}
1158
1159struct ManagedAtifExporter {
1160    exporter: AtifExporter,
1161    filename: String,
1162    local_path: Option<PathBuf>,
1163    correlation: AtifCorrelation,
1164    observed_events: Vec<Event>,
1165    observed_event_keys: HashSet<String>,
1166    written: bool,
1167}
1168
1169struct PendingAtifWrite {
1170    agent_uuid: Uuid,
1171    #[cfg_attr(not(feature = "object-store"), allow(dead_code))]
1172    session_id: String,
1173    // `filename` is consumed by the remote upload path, which is gated on the
1174    // object-store feature; without it, only the local sink reads `local_path`.
1175    #[cfg_attr(not(feature = "object-store"), allow(dead_code))]
1176    filename: String,
1177    local_path: Option<PathBuf>,
1178    payload: Vec<u8>,
1179}
1180
1181struct AtifFlushWork {
1182    exports: Vec<PendingAtifExport>,
1183    scope_subscribers: Vec<(Uuid, String)>,
1184}
1185
1186struct PendingAtifExport {
1187    agent_uuid: Uuid,
1188    exporter: AtifExporter,
1189    filename: String,
1190    local_path: Option<PathBuf>,
1191    correlation: AtifCorrelation,
1192}
1193
1194#[derive(Clone)]
1195struct AtifCorrelation {
1196    session_id: Option<String>,
1197    session_instance_id: Option<String>,
1198    user_id: Option<String>,
1199}
1200
1201impl AtifCorrelation {
1202    fn from_event(event: &Event) -> Self {
1203        let metadata = event.metadata();
1204        Self {
1205            session_id: metadata
1206                .and_then(|value| value.get("session_id"))
1207                .and_then(Json::as_str)
1208                .map(ToString::to_string),
1209            session_instance_id: current_scope_stack()
1210                .read()
1211                .ok()
1212                .map(|stack| stack.root_uuid().to_string()),
1213            user_id: metadata
1214                .and_then(|value| value.get("user_id"))
1215                .and_then(Json::as_str)
1216                .map(ToString::to_string),
1217        }
1218    }
1219
1220    fn to_json(&self) -> Json {
1221        let mut fields = Map::new();
1222        if let Some(session_id) = &self.session_id {
1223            fields.insert("session_id".to_string(), Json::String(session_id.clone()));
1224        }
1225        if let Some(session_instance_id) = &self.session_instance_id {
1226            fields.insert(
1227                "session_instance_id".to_string(),
1228                Json::String(session_instance_id.clone()),
1229            );
1230        }
1231        if let Some(user_id) = &self.user_id {
1232            fields.insert("user_id".to_string(), Json::String(user_id.clone()));
1233        }
1234        Json::Object(fields)
1235    }
1236}
1237
1238/// Identifier for a single output sink. `Local` is used when `storage` is empty
1239/// (the legacy local-file path); `Remote(i)` indexes into the configured
1240/// storage backends.
1241#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1242enum SinkLabel {
1243    Local,
1244    Remote(usize),
1245}
1246
1247impl AtifDispatcher {
1248    fn new(config: AtifSectionConfig) -> Self {
1249        Self {
1250            config,
1251            agents: HashMap::new(),
1252            scope_owners: HashMap::new(),
1253            scope_subscribers: HashMap::new(),
1254            fatal_error: None,
1255            runtime_failures: Vec::new(),
1256            validated_sinks: HashSet::new(),
1257        }
1258    }
1259
1260    fn observe_global(
1261        &mut self,
1262        event: &Event,
1263        subscriber_prefix: &str,
1264        state: Arc<Mutex<Self>>,
1265        storage: AtifStorageList,
1266    ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
1267        if self.fatal_error.is_some() {
1268            return None;
1269        }
1270
1271        if !is_top_level_trajectory_start(event) {
1272            return self.observe_descendant_from_global(event);
1273        }
1274
1275        if self.agents.contains_key(&event.uuid()) {
1276            return None;
1277        }
1278
1279        // The top-level trajectory scope UUID is the ATIF session ID. The global
1280        // dispatcher records the start event itself because the scope-local
1281        // subscriber is attached after that start event has already been
1282        // emitted.
1283        let session_id = event.uuid().to_string();
1284        let (filename, local_path) = match self.prepare_destination(&session_id, event.metadata()) {
1285            Ok(destination) => destination,
1286            Err(error) => {
1287                self.record_runtime_failure(
1288                    "atif.destination_render_failed",
1289                    Some("filename_template".into()),
1290                    error.clone(),
1291                    Some(session_id.clone()),
1292                );
1293                log::warn!(
1294                    target: "nemo_relay.observability",
1295                    event = "atif_destination_render_failed",
1296                    plugin_kind = OBSERVABILITY_PLUGIN_KIND,
1297                    exporter = "atif",
1298                    session_id = session_id.as_str();
1299                    "ATIF destination rendering failed: {error}"
1300                );
1301                return None;
1302            }
1303        };
1304        let exporter = AtifExporter::new(session_id.clone(), self.agent_info());
1305        (exporter.subscriber())(event);
1306        let correlation = AtifCorrelation::from_event(event);
1307        self.scope_owners.insert(event.uuid(), event.uuid());
1308        self.agents.insert(
1309            event.uuid(),
1310            ManagedAtifExporter {
1311                exporter,
1312                filename,
1313                local_path,
1314                correlation,
1315                observed_events: vec![event.clone()],
1316                observed_event_keys: HashSet::from([event_observation_key(event)]),
1317                written: false,
1318            },
1319        );
1320
1321        let agent_uuid = event.uuid();
1322        let name = format!("{subscriber_prefix}{agent_uuid}");
1323        let callback = atif_scope_subscriber(state, agent_uuid, storage);
1324        // Attach the scoped subscriber to the trajectory root rather than the
1325        // global registry so sibling top-level trajectories never share events.
1326        // With async subscriber delivery, the root scope may already be closed
1327        // when the dispatcher observes this start event; global routing still
1328        // handles descendant events by parent UUID in that case.
1329        if try_scope_register_subscriber(&agent_uuid, &name, callback).is_ok() {
1330            self.scope_subscribers.insert(agent_uuid, name);
1331        }
1332        None
1333    }
1334
1335    fn observe_descendant_from_global(
1336        &mut self,
1337        event: &Event,
1338    ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
1339        let owner = self.scope_owners.get(&event.uuid()).copied().or_else(|| {
1340            event
1341                .parent_uuid()
1342                .and_then(|uuid| self.scope_owners.get(&uuid).copied())
1343        })?;
1344
1345        if event.scope_category() == Some(ScopeCategory::Start) {
1346            self.scope_owners.insert(event.uuid(), owner);
1347        }
1348
1349        let pending_write = self.observe_scope(event, owner);
1350
1351        if event.scope_category() == Some(ScopeCategory::End) && event.uuid() != owner {
1352            self.scope_owners.remove(&event.uuid());
1353        }
1354
1355        pending_write
1356    }
1357
1358    fn observe_scope(
1359        &mut self,
1360        event: &Event,
1361        agent_uuid: Uuid,
1362    ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
1363        if self.fatal_error.is_some() {
1364            return None;
1365        }
1366        let should_finalize =
1367            event.uuid() == agent_uuid && event.scope_category() == Some(ScopeCategory::End);
1368        let agent = self.agents.get_mut(&agent_uuid)?;
1369        if !agent
1370            .observed_event_keys
1371            .insert(event_observation_key(event))
1372        {
1373            return None;
1374        }
1375        (agent.exporter.subscriber())(event);
1376        agent.observed_events.push(event.clone());
1377        if !should_finalize || agent.written {
1378            return None;
1379        }
1380        let write = match prepare_atif_file(agent_uuid, agent) {
1381            Ok(write) => write,
1382            Err(err) => {
1383                self.fatal_error = Some(err.to_string());
1384                return None;
1385            }
1386        };
1387        let targets = self.sink_targets();
1388        Some((write, targets))
1389    }
1390
1391    fn complete_scope_write(
1392        &mut self,
1393        agent_uuid: Uuid,
1394        results: Vec<(SinkLabel, std::io::Result<()>)>,
1395    ) -> Option<(Uuid, String)> {
1396        let is_remote_fallback = results
1397            .iter()
1398            .any(|(label, _)| matches!(label, SinkLabel::Remote(_)));
1399        for (label, result) in results {
1400            if result.is_ok()
1401                && label == SinkLabel::Local
1402                && self.validated_sinks.insert(label.clone())
1403            {
1404                log::info!(
1405                    target: "nemo_relay.observability",
1406                    event = "storage_access_validated",
1407                    plugin_kind = "observability",
1408                    exporter = "atif",
1409                    resource_kind = "local_file",
1410                    permission = "write";
1411                    "ATIF storage access validated"
1412                );
1413            } else if let Err(err) = result {
1414                let (field, message) = match &label {
1415                    SinkLabel::Local => ("output_directory".to_string(), err.to_string()),
1416                    SinkLabel::Remote(index) => (format!("storage[{index}]"), err.to_string()),
1417                };
1418                match &label {
1419                    SinkLabel::Local => log::warn!(
1420                        target: "nemo_relay.observability",
1421                        event = "storage_access_failed",
1422                        plugin_kind = "observability",
1423                        exporter = "atif",
1424                        resource_kind = "local_file",
1425                        permission = "write",
1426                        reason = "write_failed";
1427                        "ATIF storage access failed"
1428                    ),
1429                    SinkLabel::Remote(index) => log::warn!(
1430                        target: "nemo_relay.observability",
1431                        event = "atif_remote_delivery_failed",
1432                        plugin_kind = OBSERVABILITY_PLUGIN_KIND,
1433                        exporter = "atif",
1434                        storage_index = *index;
1435                        "ATIF remote storage upload failed"
1436                    ),
1437                }
1438                self.record_runtime_failure(
1439                    match &label {
1440                        SinkLabel::Local if is_remote_fallback => "atif.local_fallback_failed",
1441                        SinkLabel::Local => "atif.local_write_failed",
1442                        SinkLabel::Remote(_) => "atif.remote_delivery_failed",
1443                    },
1444                    Some(field),
1445                    message,
1446                    Some(agent_uuid.to_string()),
1447                );
1448            }
1449        }
1450        if let Some(agent) = self.agents.get_mut(&agent_uuid) {
1451            agent.observed_events.clear();
1452        }
1453        self.agents.remove(&agent_uuid);
1454        self.scope_owners.retain(|_, owner| *owner != agent_uuid);
1455        self.scope_subscribers
1456            .remove(&agent_uuid)
1457            .map(|name| (agent_uuid, name))
1458    }
1459
1460    fn flush_open_agents(&mut self) -> AtifFlushWork {
1461        // Plugin teardown may run before an agent scope closes. Remove dynamic
1462        // scope-local subscribers first so the later scope end event cannot
1463        // trigger a second write after the dispatcher has flushed.
1464        let scope_subscribers = std::mem::take(&mut self.scope_subscribers)
1465            .into_iter()
1466            .collect();
1467        let agent_uuids = self
1468            .agents
1469            .iter()
1470            .filter_map(|(agent_uuid, agent)| (!agent.written).then_some(*agent_uuid))
1471            .collect::<Vec<_>>();
1472        let mut exports = Vec::with_capacity(agent_uuids.len());
1473        for agent_uuid in agent_uuids {
1474            if let Some(agent) = self.agents.get_mut(&agent_uuid) {
1475                agent.written = true;
1476                exports.push(PendingAtifExport {
1477                    agent_uuid,
1478                    exporter: agent.exporter.clone(),
1479                    filename: agent.filename.clone(),
1480                    local_path: agent.local_path.clone(),
1481                    correlation: agent.correlation.clone(),
1482                });
1483            }
1484        }
1485        AtifFlushWork {
1486            exports,
1487            scope_subscribers,
1488        }
1489    }
1490
1491    fn observed_events(&self, agent_uuid: Uuid) -> Vec<Event> {
1492        self.agents
1493            .get(&agent_uuid)
1494            .map(|agent| agent.observed_events.clone())
1495            .unwrap_or_default()
1496    }
1497
1498    fn last_error_result(&self) -> std::io::Result<()> {
1499        if let Some(message) = &self.fatal_error {
1500            return Err(std::io::Error::other(message.clone()));
1501        }
1502        if !self.runtime_failures.is_empty() {
1503            return Err(std::io::Error::other(format!(
1504                "{ATIF_RUNTIME_DELIVERY_FAILURE_MARKER}: {}",
1505                self.runtime_failures
1506                    .iter()
1507                    .map(|diagnostic| format!("{} ({})", diagnostic.code, diagnostic.count))
1508                    .collect::<Vec<_>>()
1509                    .join(", ")
1510            )));
1511        }
1512        Ok(())
1513    }
1514
1515    fn agent_info(&self) -> AtifAgentInfo {
1516        AtifAgentInfo {
1517            name: self.config.agent_name.clone(),
1518            version: self.config.agent_version.clone(),
1519            model_name: Some(self.config.model_name.clone()),
1520            tool_definitions: self.config.tool_definitions.clone(),
1521            extra: self.config.extra.clone(),
1522        }
1523    }
1524
1525    fn prepare_destination(
1526        &self,
1527        session_id: &str,
1528        metadata: Option<&Json>,
1529    ) -> Result<(String, Option<PathBuf>), String> {
1530        validate_atif_filename_template(&self.config.filename_template)?;
1531        let filename = render_atif_filename(&self.config.filename_template, session_id, metadata)?;
1532        let directory = self
1533            .config
1534            .output_directory
1535            .clone()
1536            .unwrap_or_else(default_output_directory);
1537        let path = directory.join(&filename);
1538        Ok((filename, Some(path)))
1539    }
1540
1541    fn sink_targets(&self) -> Vec<SinkLabel> {
1542        if self.config.storage.is_empty() {
1543            vec![SinkLabel::Local]
1544        } else {
1545            (0..self.config.storage.len())
1546                .map(SinkLabel::Remote)
1547                .collect()
1548        }
1549    }
1550
1551    fn record_runtime_failure(
1552        &mut self,
1553        code: &str,
1554        field: Option<String>,
1555        message: String,
1556        session_id: Option<String>,
1557    ) {
1558        let diagnostic = RuntimeDiagnostic {
1559            code: code.to_string(),
1560            component: OBSERVABILITY_PLUGIN_KIND.to_string(),
1561            field,
1562            message,
1563            session_id,
1564            count: 1,
1565        };
1566        if let Some(existing) = self
1567            .runtime_failures
1568            .iter_mut()
1569            .find(|existing| existing.code == diagnostic.code && existing.field == diagnostic.field)
1570        {
1571            existing.message = diagnostic.message.clone();
1572            existing.session_id = diagnostic.session_id.clone();
1573            existing.count += 1;
1574        } else {
1575            self.runtime_failures.push(diagnostic.clone());
1576        }
1577        record_active_plugin_runtime_diagnostic(diagnostic);
1578    }
1579}
1580
1581fn is_valid_atif_metadata_selector(selector: &str) -> bool {
1582    !selector.is_empty()
1583        && selector.split('.').all(|segment| {
1584            !segment.is_empty()
1585                && segment
1586                    .bytes()
1587                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1588        })
1589}
1590
1591fn parse_atif_metadata_expression(expression: &str) -> Result<(&str, Option<&str>), String> {
1592    let (selector, fallback) = expression
1593        .split_once(":-")
1594        .map_or((expression, None), |(key, value)| (key, Some(value)));
1595    if !is_valid_atif_metadata_selector(selector) {
1596        return Err(format!(
1597            "ATIF filename_template metadata placeholder '{{metadata.{selector}}}' must contain a dot-separated path of ASCII letters, digits, '-' or '_'"
1598        ));
1599    }
1600    Ok((selector, fallback))
1601}
1602
1603fn validate_atif_filename_template(template: &str) -> Result<(), String> {
1604    const PREFIX: &str = "{metadata.";
1605
1606    if !template.contains("{session_id}") {
1607        return Err("ATIF filename_template must contain '{session_id}'".to_string());
1608    }
1609
1610    let literal_path = template
1611        .replace("{session_id}", "session")
1612        .replace("{metadata.", "metadata.");
1613    if Path::new(&literal_path).is_absolute()
1614        || Path::new(&literal_path).components().any(|component| {
1615            matches!(
1616                component,
1617                Component::ParentDir
1618                    | Component::CurDir
1619                    | Component::RootDir
1620                    | Component::Prefix(_)
1621            )
1622        })
1623    {
1624        return Err("ATIF filename_template must be a path-safe relative path".to_string());
1625    }
1626
1627    let mut cursor = 0;
1628    while let Some(relative_start) = template[cursor..].find(PREFIX) {
1629        let selector_start = cursor + relative_start + PREFIX.len();
1630        let end = template[selector_start..]
1631            .find('}')
1632            .map(|relative_end| selector_start + relative_end)
1633            .ok_or_else(|| {
1634                "ATIF filename_template contains an unclosed metadata placeholder".to_string()
1635            })?;
1636        let (_, fallback) = parse_atif_metadata_expression(&template[selector_start..end])?;
1637        if let Some(fallback) = fallback
1638            && !is_safe_atif_metadata_path(fallback)
1639        {
1640            return Err(format!(
1641                "ATIF filename_template fallback '{fallback}' must be a path-safe relative fragment"
1642            ));
1643        }
1644        cursor = end + 1;
1645    }
1646    Ok(())
1647}
1648
1649fn render_atif_filename(
1650    template: &str,
1651    session_id: &str,
1652    metadata: Option<&Json>,
1653) -> Result<String, String> {
1654    const PREFIX: &str = "{metadata.";
1655
1656    let mut rendered = template.replace("{session_id}", session_id);
1657    let mut cursor = 0;
1658    while let Some(relative_start) = rendered[cursor..].find(PREFIX) {
1659        let start = cursor + relative_start;
1660        let selector_start = start + PREFIX.len();
1661        let end = rendered[selector_start..]
1662            .find('}')
1663            .map(|relative_end| selector_start + relative_end)
1664            .ok_or_else(|| {
1665                "ATIF filename_template contains an unclosed metadata placeholder".to_string()
1666            })?;
1667        let expression = rendered[selector_start..end].to_string();
1668        let (selector, fallback) = parse_atif_metadata_expression(&expression)?;
1669        let mut resolved = metadata;
1670        for segment in selector.split('.') {
1671            resolved = match resolved {
1672                Some(Json::Object(object)) => object.get(segment),
1673                None | Some(Json::Null) => break,
1674                Some(_) => {
1675                    return Err(format!(
1676                        "filename_template placeholder '{{metadata.{selector}}}' traversed a non-object value"
1677                    ));
1678                }
1679            };
1680        }
1681        let value = match resolved {
1682            Some(Json::String(value)) => value.as_str(),
1683            None | Some(Json::Null) => fallback.ok_or_else(|| {
1684                format!(
1685                    "filename_template placeholder '{{metadata.{selector}}}' must resolve to a string"
1686                )
1687            })?,
1688            Some(_) => {
1689                return Err(format!(
1690                    "filename_template placeholder '{{metadata.{selector}}}' resolved to a non-string value"
1691                ));
1692            }
1693        };
1694        if !is_safe_atif_metadata_path(value) {
1695            return Err(format!(
1696                "metadata path '{selector}' must be a path-safe relative fragment"
1697            ));
1698        }
1699        rendered.replace_range(start..=end, value);
1700        cursor = start + value.len();
1701    }
1702    Ok(rendered)
1703}
1704
1705fn is_safe_atif_metadata_path(value: &str) -> bool {
1706    !value.is_empty()
1707        && value.split('/').all(|segment| {
1708            !matches!(segment, "" | "." | "..")
1709                && segment.bytes().all(|byte| {
1710                    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~')
1711                })
1712        })
1713}
1714
1715fn atif_dispatcher_subscriber(
1716    manager: Arc<Mutex<AtifDispatcher>>,
1717    subscriber_prefix: String,
1718    storage: AtifStorageList,
1719) -> EventSubscriberFn {
1720    Arc::new(move |event: &Event| {
1721        let pending = {
1722            let Ok(mut guard) = manager.lock() else {
1723                return;
1724            };
1725            guard.observe_global(
1726                event,
1727                &subscriber_prefix,
1728                Arc::clone(&manager),
1729                Arc::clone(&storage),
1730            )
1731        };
1732        let Some((write, targets)) = pending else {
1733            return;
1734        };
1735        let results = write_atif(&write, storage.as_slice(), &targets);
1736        let scope_subscriber = {
1737            let Ok(mut guard) = manager.lock() else {
1738                return;
1739            };
1740            guard.complete_scope_write(write.agent_uuid, results)
1741        };
1742        if let Some((scope_uuid, name)) = scope_subscriber {
1743            let _ = try_scope_deregister_subscriber(&scope_uuid, &name);
1744        }
1745    })
1746}
1747
1748fn atif_scope_subscriber(
1749    manager: Arc<Mutex<AtifDispatcher>>,
1750    agent_uuid: Uuid,
1751    storage: AtifStorageList,
1752) -> EventSubscriberFn {
1753    Arc::new(move |event: &Event| {
1754        let pending = {
1755            let Ok(mut guard) = manager.lock() else {
1756                return;
1757            };
1758            guard.observe_scope(event, agent_uuid)
1759        };
1760        let Some((write, targets)) = pending else {
1761            return;
1762        };
1763        let results = write_atif(&write, storage.as_slice(), &targets);
1764        let scope_subscriber = {
1765            let Ok(mut guard) = manager.lock() else {
1766                return;
1767            };
1768            guard.complete_scope_write(write.agent_uuid, results)
1769        };
1770        if let Some((scope_uuid, name)) = scope_subscriber {
1771            let _ = try_scope_deregister_subscriber(&scope_uuid, &name);
1772        }
1773    })
1774}
1775
1776fn prepare_atif_file(
1777    agent_uuid: Uuid,
1778    agent: &mut ManagedAtifExporter,
1779) -> std::io::Result<PendingAtifWrite> {
1780    let trajectory = agent
1781        .exporter
1782        .try_export()
1783        .map_err(|error| std::io::Error::other(error.to_string()))?;
1784    let observed_events = agent.observed_events.clone();
1785    agent.written = true;
1786    prepare_atif_payload(
1787        agent_uuid,
1788        agent.filename.clone(),
1789        agent.local_path.clone(),
1790        trajectory,
1791        observed_events,
1792        agent.correlation.clone(),
1793    )
1794}
1795
1796fn prepare_atif_shutdown_file(
1797    export: &PendingAtifExport,
1798    manager: Arc<Mutex<AtifDispatcher>>,
1799) -> std::io::Result<PendingAtifWrite> {
1800    let trajectory = export
1801        .exporter
1802        .try_export()
1803        .map_err(|error| std::io::Error::other(error.to_string()))?;
1804    let observed_events = {
1805        let guard = manager.lock().map_err(|err| {
1806            std::io::Error::other(format!("ATIF dispatcher lock poisoned: {err}"))
1807        })?;
1808        guard.observed_events(export.agent_uuid)
1809    };
1810    prepare_atif_payload(
1811        export.agent_uuid,
1812        export.filename.clone(),
1813        export.local_path.clone(),
1814        trajectory,
1815        observed_events,
1816        export.correlation.clone(),
1817    )
1818}
1819
1820fn prepare_atif_payload(
1821    agent_uuid: Uuid,
1822    filename: String,
1823    local_path: Option<PathBuf>,
1824    trajectory: crate::observability::atif::AtifTrajectory,
1825    observed_events: Vec<Event>,
1826    correlation: AtifCorrelation,
1827) -> std::io::Result<PendingAtifWrite> {
1828    let mut value = serde_json::to_value(trajectory)?;
1829    if let Some(object) = value.as_object_mut() {
1830        let existing_extra = object.remove("extra");
1831        let mut extra = match existing_extra {
1832            Some(Json::Object(fields)) => fields,
1833            Some(value) => Map::from_iter([("trajectory_extra".to_string(), value)]),
1834            None => Map::new(),
1835        };
1836        extra.insert(
1837            "observed_events".to_string(),
1838            serde_json::to_value(observed_events)?,
1839        );
1840        let mut nemo_relay = match extra.remove("nemo_relay") {
1841            Some(Json::Object(fields)) => fields,
1842            Some(value) => Map::from_iter([("trajectory_extra".to_string(), value)]),
1843            None => Map::new(),
1844        };
1845        if let Json::Object(correlation_fields) = correlation.to_json() {
1846            nemo_relay.extend(correlation_fields);
1847        }
1848        extra.insert("nemo_relay".to_string(), Json::Object(nemo_relay));
1849        object.insert("extra".to_string(), Json::Object(extra));
1850    }
1851    let payload = serde_json::to_vec_pretty(&value)?;
1852    Ok(PendingAtifWrite {
1853        agent_uuid,
1854        session_id: agent_uuid.to_string(),
1855        filename,
1856        local_path,
1857        payload,
1858    })
1859}
1860
1861fn write_atif(
1862    write: &PendingAtifWrite,
1863    storage: &[Arc<AtifRemoteStorage>],
1864    targets: &[SinkLabel],
1865) -> Vec<(SinkLabel, std::io::Result<()>)> {
1866    let mut results = targets
1867        .iter()
1868        .map(|label| {
1869            let result = match label {
1870                SinkLabel::Local => match &write.local_path {
1871                    Some(path) => write_atif_local(path, &write.payload),
1872                    None => Err(std::io::Error::other(
1873                        "ATIF local destination has no output path",
1874                    )),
1875                },
1876                SinkLabel::Remote(index) => write_atif_remote(storage, *index, write),
1877            };
1878            (label.clone(), result)
1879        })
1880        .collect::<Vec<_>>();
1881    if !targets.is_empty()
1882        && targets
1883            .iter()
1884            .all(|label| matches!(label, SinkLabel::Remote(_)))
1885        && results.iter().all(|(_, result)| result.is_err())
1886    {
1887        let fallback = match &write.local_path {
1888            Some(path) => write_atif_local(path, &write.payload),
1889            None => Err(std::io::Error::other(
1890                "ATIF local fallback has no output path",
1891            )),
1892        };
1893        results.push((SinkLabel::Local, fallback));
1894    }
1895    results
1896}
1897
1898fn write_atif_local(path: &PathBuf, payload: &[u8]) -> std::io::Result<()> {
1899    if let Some(parent) = path.parent() {
1900        std::fs::create_dir_all(parent)?;
1901    }
1902    std::fs::write(path, payload)
1903}
1904
1905#[cfg(feature = "object-store")]
1906fn write_atif_remote(
1907    storage: &[Arc<AtifRemoteStorage>],
1908    index: usize,
1909    write: &PendingAtifWrite,
1910) -> std::io::Result<()> {
1911    let sink = storage
1912        .get(index)
1913        .ok_or_else(|| std::io::Error::other(format!("ATIF storage[{index}] is not registered")))?;
1914    sink.put(&write.filename, &write.session_id, &write.payload)
1915}
1916
1917#[cfg(not(feature = "object-store"))]
1918fn write_atif_remote(
1919    _storage: &[Arc<AtifRemoteStorage>],
1920    _index: usize,
1921    _write: &PendingAtifWrite,
1922) -> std::io::Result<()> {
1923    Err(std::io::Error::other(
1924        "ATIF storage support is not enabled in this build",
1925    ))
1926}
1927
1928fn event_observation_key(event: &Event) -> String {
1929    format!(
1930        "{}:{}:{:?}",
1931        event.kind(),
1932        event.uuid(),
1933        event.scope_category()
1934    )
1935}
1936
1937fn is_top_level_trajectory_start(event: &Event) -> bool {
1938    if event.scope_category() != Some(ScopeCategory::Start) {
1939        return false;
1940    }
1941    let is_agent_scope = event.scope_type() == Some(ScopeType::Agent);
1942    let is_turn_scope = event.scope_type() == Some(ScopeType::Custom)
1943        && event
1944            .metadata()
1945            .and_then(|metadata| metadata.get("nemo_relay_scope_role"))
1946            .and_then(Json::as_str)
1947            == Some("turn");
1948    if !is_agent_scope && !is_turn_scope {
1949        return false;
1950    }
1951    let Some(parent_uuid) = event.parent_uuid() else {
1952        return false;
1953    };
1954    current_scope_stack()
1955        .read()
1956        .map(|stack| stack.root_uuid() == parent_uuid)
1957        .unwrap_or(false)
1958}
1959
1960fn build_otel_config(
1961    index: usize,
1962    section: OpenTelemetryEndpointConfig,
1963) -> PluginResult<CoreOpenTelemetryConfig> {
1964    if section.endpoint.trim().is_empty() {
1965        return Err(PluginError::InvalidConfig(
1966            "OpenTelemetry endpoint must be a nonblank string".to_string(),
1967        ));
1968    }
1969    let transport = match section.transport.as_str() {
1970        "http_binary" => OtlpTransport::HttpBinary,
1971        "grpc" => OtlpTransport::Grpc,
1972        other => {
1973            return Err(PluginError::InvalidConfig(format!(
1974                "OpenTelemetry transport must be 'http_binary' or 'grpc', got {other:?}"
1975            )));
1976        }
1977    };
1978    validate_otel_header_env(index, &section)?;
1979    let mut config = CoreOpenTelemetryConfig::new(section.otel_type, section.endpoint)
1980        .with_transport(transport)
1981        .with_service_name(section.service_name)
1982        .with_timeout(Duration::from_millis(section.timeout_millis))
1983        .with_instrumentation_scope(section.instrumentation_scope)
1984        .with_mark_projection(section.mark_projection)
1985        .with_mark_exclude_names(section.mark_exclude_names)
1986        .with_attribute_mappings(section.attribute_mappings);
1987    if let Some(namespace) = section.service_namespace {
1988        config = config.with_service_namespace(namespace);
1989    }
1990    if let Some(version) = section.service_version {
1991        config = config.with_service_version(version);
1992    }
1993    for (key, value) in section.headers {
1994        config = config.with_header(key, value);
1995    }
1996    config = apply_otel_environment_headers(config, index, section.header_env)?;
1997    for (key, value) in section.resource_attributes {
1998        config = config.with_resource_attribute(key, value);
1999    }
2000    Ok(config)
2001}
2002
2003fn validate_otel_header_env(
2004    index: usize,
2005    section: &OpenTelemetryEndpointConfig,
2006) -> PluginResult<()> {
2007    for (header, variable) in &section.header_env {
2008        if variable.trim().is_empty() || variable.trim() != variable {
2009            return Err(PluginError::InvalidConfig(format!(
2010                "OpenTelemetry endpoints[{index}].header_env.{header} must name a nonblank environment variable without surrounding whitespace"
2011            )));
2012        }
2013        if section
2014            .headers
2015            .keys()
2016            .any(|configured| configured.eq_ignore_ascii_case(header))
2017        {
2018            return Err(PluginError::InvalidConfig(format!(
2019                "OpenTelemetry endpoints[{index}] header {header:?} cannot appear in both headers and header_env"
2020            )));
2021        }
2022    }
2023    Ok(())
2024}
2025
2026fn apply_otel_environment_headers(
2027    mut config: CoreOpenTelemetryConfig,
2028    index: usize,
2029    header_env: HashMap<String, String>,
2030) -> PluginResult<CoreOpenTelemetryConfig> {
2031    for (key, variable) in header_env {
2032        let value = std::env::var(&variable).map_err(|error| {
2033            PluginError::InvalidConfig(format!(
2034                "OpenTelemetry endpoints[{index}].header_env.{key} could not read environment variable {variable:?}: {error}"
2035            ))
2036        })?;
2037        if value.trim().is_empty() || value.trim() != value {
2038            return Err(PluginError::InvalidConfig(format!(
2039                "OpenTelemetry endpoints[{index}].header_env.{key} references a blank or padded environment variable {variable:?}"
2040            )));
2041        }
2042        config = config.with_header(key, value);
2043    }
2044    Ok(config)
2045}
2046
2047fn parse_observability_config(
2048    plugin_config: &Map<String, Json>,
2049) -> PluginResult<ObservabilityConfig> {
2050    serde_json::from_value(Json::Object(plugin_config.clone())).map_err(|err| {
2051        PluginError::InvalidConfig(format!("invalid observability plugin config: {err}"))
2052    })
2053}
2054
2055fn validate_observability_plugin_config(
2056    plugin_config: &Map<String, Json>,
2057) -> Vec<ConfigDiagnostic> {
2058    validate_observability_plugin_config_with_policy(plugin_config, None)
2059}
2060
2061fn validate_observability_plugin_config_with_policy(
2062    plugin_config: &Map<String, Json>,
2063    policy: Option<&ConfigPolicy>,
2064) -> Vec<ConfigDiagnostic> {
2065    let mut config = match parse_observability_config(plugin_config) {
2066        Ok(config) => config,
2067        Err(err) => {
2068            return vec![ConfigDiagnostic {
2069                level: DiagnosticLevel::Error,
2070                code: "observability.invalid_plugin_config".to_string(),
2071                component: Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
2072                field: None,
2073                message: err.to_string(),
2074            }];
2075        }
2076    };
2077    if let Some(policy) = policy {
2078        config.policy = apply_global_config_policy(config.policy, policy);
2079    }
2080
2081    let mut diagnostics = vec![];
2082    validate_top_level_observability_fields(&mut diagnostics, &config.policy, plugin_config);
2083    validate_version(&mut diagnostics, &config.policy, config.version);
2084    validate_policy_fields(&mut diagnostics, &config.policy, plugin_config);
2085    validate_observability_section_fields(&mut diagnostics, &config.policy, plugin_config);
2086    validate_observability_section_values(&mut diagnostics, &config);
2087
2088    diagnostics
2089}
2090
2091fn validate_top_level_observability_fields(
2092    diagnostics: &mut Vec<ConfigDiagnostic>,
2093    policy: &ConfigPolicy,
2094    plugin_config: &Map<String, Json>,
2095) {
2096    validate_unknown_fields(
2097        diagnostics,
2098        policy,
2099        Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
2100        plugin_config,
2101        &[
2102            "version",
2103            "atof",
2104            "atif",
2105            "opentelemetry",
2106            "openinference",
2107            "policy",
2108        ],
2109    );
2110    if plugin_config.contains_key("openinference") {
2111        push_policy_diag(
2112            diagnostics,
2113            UnsupportedBehavior::Error,
2114            "observability.legacy_openinference_section",
2115            Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
2116            Some("openinference".to_string()),
2117            "the standalone OpenInference section was removed in observability config version 3; configure an opentelemetry endpoint with type = \"openinference\"".to_string(),
2118        );
2119    }
2120}
2121
2122fn validate_observability_section_fields(
2123    diagnostics: &mut Vec<ConfigDiagnostic>,
2124    policy: &ConfigPolicy,
2125    plugin_config: &Map<String, Json>,
2126) {
2127    validate_section_fields(
2128        diagnostics,
2129        policy,
2130        plugin_config,
2131        "atof",
2132        &["enabled", "sinks"],
2133    );
2134    if let Some(atof) = plugin_config.get("atof").and_then(Json::as_object) {
2135        for legacy_field in ["output_directory", "filename", "mode", "endpoints"] {
2136            if atof.contains_key(legacy_field) {
2137                push_policy_diag(
2138                    diagnostics,
2139                    UnsupportedBehavior::Error,
2140                    "observability.legacy_atof_field",
2141                    Some("atof".to_string()),
2142                    Some(legacy_field.to_string()),
2143                    format!(
2144                        "ATOF {legacy_field} was removed in observability config version 2; configure typed ATOF sinks instead"
2145                    ),
2146                );
2147            }
2148        }
2149    }
2150    validate_section_fields(
2151        diagnostics,
2152        policy,
2153        plugin_config,
2154        "atif",
2155        &[
2156            "enabled",
2157            "agent_name",
2158            "agent_version",
2159            "model_name",
2160            "tool_definitions",
2161            "extra",
2162            "output_directory",
2163            "filename_template",
2164            "storage",
2165        ],
2166    );
2167    validate_section_fields(
2168        diagnostics,
2169        policy,
2170        plugin_config,
2171        "opentelemetry",
2172        &[
2173            "enabled",
2174            "endpoints",
2175            "mark_projection",
2176            "mark_exclude_names",
2177            "attribute_mappings",
2178            "transport",
2179            "endpoint",
2180            "headers",
2181            "resource_attributes",
2182            "service_name",
2183            "service_namespace",
2184            "service_version",
2185            "instrumentation_scope",
2186            "timeout_millis",
2187        ],
2188    );
2189    if let Some(opentelemetry) = plugin_config.get("opentelemetry").and_then(Json::as_object) {
2190        validate_opentelemetry_endpoint_fields(diagnostics, policy, opentelemetry);
2191        for legacy_field in [
2192            "mark_projection",
2193            "mark_exclude_names",
2194            "attribute_mappings",
2195            "transport",
2196            "endpoint",
2197            "headers",
2198            "resource_attributes",
2199            "service_name",
2200            "service_namespace",
2201            "service_version",
2202            "instrumentation_scope",
2203            "timeout_millis",
2204        ] {
2205            if opentelemetry.contains_key(legacy_field) {
2206                push_policy_diag(
2207                    diagnostics,
2208                    UnsupportedBehavior::Error,
2209                    "observability.legacy_opentelemetry_field",
2210                    Some("opentelemetry".to_string()),
2211                    Some(legacy_field.to_string()),
2212                    format!(
2213                        "OpenTelemetry {legacy_field} moved into each typed endpoint in observability config version 3"
2214                    ),
2215                );
2216            }
2217        }
2218    }
2219}
2220
2221fn validate_opentelemetry_endpoint_fields(
2222    diagnostics: &mut Vec<ConfigDiagnostic>,
2223    policy: &ConfigPolicy,
2224    opentelemetry: &Map<String, Json>,
2225) {
2226    const ALLOWED: &[&str] = &[
2227        "type",
2228        "endpoint",
2229        "mark_projection",
2230        "mark_exclude_names",
2231        "attribute_mappings",
2232        "transport",
2233        "headers",
2234        "header_env",
2235        "resource_attributes",
2236        "service_name",
2237        "service_namespace",
2238        "service_version",
2239        "instrumentation_scope",
2240        "timeout_millis",
2241    ];
2242    const REMOVED: &[&str] = &["semantic_selector", "capture_content"];
2243    let Some(endpoints) = opentelemetry.get("endpoints").and_then(Json::as_array) else {
2244        return;
2245    };
2246    for (index, endpoint) in endpoints.iter().enumerate() {
2247        let Some(endpoint) = endpoint.as_object() else {
2248            continue;
2249        };
2250        for field in endpoint
2251            .keys()
2252            .filter(|field| !ALLOWED.contains(&field.as_str()))
2253        {
2254            let behavior = if REMOVED.contains(&field.as_str()) {
2255                UnsupportedBehavior::Error
2256            } else {
2257                policy.unknown_field
2258            };
2259            push_policy_diag(
2260                diagnostics,
2261                behavior,
2262                if REMOVED.contains(&field.as_str()) {
2263                    "observability.legacy_opentelemetry_field"
2264                } else {
2265                    "observability.unknown_field"
2266                },
2267                Some("opentelemetry".to_string()),
2268                Some(format!("endpoints[{index}].{field}")),
2269                format!("unknown OpenTelemetry endpoint field {field:?}"),
2270            );
2271        }
2272    }
2273}
2274
2275fn validate_observability_section_values(
2276    diagnostics: &mut Vec<ConfigDiagnostic>,
2277    config: &ObservabilityConfig,
2278) {
2279    if let Some(section) = &config.atof {
2280        validate_atof_section(diagnostics, &config.policy, section);
2281    }
2282    if let Some(section) = &config.atif {
2283        validate_atif_section(diagnostics, &config.policy, section);
2284    }
2285    if let Some(section) = &config.opentelemetry {
2286        validate_opentelemetry_section(diagnostics, &config.policy, section);
2287    }
2288}
2289
2290fn validate_atof_section(
2291    diagnostics: &mut Vec<ConfigDiagnostic>,
2292    policy: &ConfigPolicy,
2293    section: &AtofSectionConfig,
2294) {
2295    validate_atof_values(diagnostics, policy, section);
2296    validate_atof_feature_support(diagnostics, policy, section);
2297}
2298
2299#[cfg(not(feature = "atof-streaming"))]
2300fn validate_atof_feature_support(
2301    diagnostics: &mut Vec<ConfigDiagnostic>,
2302    policy: &ConfigPolicy,
2303    section: &AtofSectionConfig,
2304) {
2305    if section.enabled
2306        && section
2307            .sinks
2308            .iter()
2309            .any(|sink| matches!(sink, AtofSinkSectionConfig::Stream(_)))
2310    {
2311        push_policy_diag(
2312            diagnostics,
2313            policy.unsupported_value,
2314            "observability.unsupported_value",
2315            Some("atof".to_string()),
2316            Some("sinks".to_string()),
2317            "ATOF stream sinks are not enabled in this build".to_string(),
2318        );
2319    }
2320}
2321
2322#[cfg(feature = "atof-streaming")]
2323fn validate_atof_feature_support(
2324    _diagnostics: &mut Vec<ConfigDiagnostic>,
2325    _policy: &ConfigPolicy,
2326    _section: &AtofSectionConfig,
2327) {
2328}
2329
2330fn validate_atif_section(
2331    diagnostics: &mut Vec<ConfigDiagnostic>,
2332    policy: &ConfigPolicy,
2333    section: &AtifSectionConfig,
2334) {
2335    validate_atif_values(diagnostics, policy, section);
2336    validate_atif_file_export_support(diagnostics, policy, section);
2337    validate_atif_storage_support(diagnostics, policy, section);
2338}
2339
2340fn validate_atif_file_export_support(
2341    _diagnostics: &mut Vec<ConfigDiagnostic>,
2342    _policy: &ConfigPolicy,
2343    _section: &AtifSectionConfig,
2344) {
2345}
2346
2347#[cfg(not(feature = "object-store"))]
2348fn validate_atif_storage_support(
2349    diagnostics: &mut Vec<ConfigDiagnostic>,
2350    policy: &ConfigPolicy,
2351    section: &AtifSectionConfig,
2352) {
2353    if section.enabled && !section.storage.is_empty() {
2354        push_policy_diag(
2355            diagnostics,
2356            policy.unsupported_value,
2357            "observability.feature_disabled",
2358            Some("atif".to_string()),
2359            Some("storage".to_string()),
2360            "ATIF storage support is not enabled in this build".to_string(),
2361        );
2362    }
2363}
2364
2365#[cfg(feature = "object-store")]
2366fn validate_atif_storage_support(
2367    _diagnostics: &mut Vec<ConfigDiagnostic>,
2368    _policy: &ConfigPolicy,
2369    _section: &AtifSectionConfig,
2370) {
2371}
2372
2373fn validate_opentelemetry_section(
2374    diagnostics: &mut Vec<ConfigDiagnostic>,
2375    policy: &ConfigPolicy,
2376    section: &OpenTelemetrySectionConfig,
2377) {
2378    if section.enabled && section.endpoints.is_empty() {
2379        push_policy_diag(
2380            diagnostics,
2381            policy.unsupported_value,
2382            "observability.unsupported_value",
2383            Some("opentelemetry".to_string()),
2384            Some("endpoints".to_string()),
2385            "enabled OpenTelemetry section requires at least one endpoint".to_string(),
2386        );
2387    }
2388    for (index, endpoint) in section.endpoints.iter().enumerate() {
2389        if endpoint.endpoint.trim().is_empty() {
2390            push_policy_diag(
2391                diagnostics,
2392                policy.unsupported_value,
2393                "observability.unsupported_value",
2394                Some("opentelemetry".to_string()),
2395                Some(format!("endpoints[{index}].endpoint")),
2396                "OpenTelemetry endpoint must be a nonblank string".to_string(),
2397            );
2398        }
2399        if !matches!(endpoint.transport.as_str(), "http_binary" | "grpc") {
2400            push_policy_diag(
2401                diagnostics,
2402                policy.unsupported_value,
2403                "observability.unsupported_value",
2404                Some("opentelemetry".to_string()),
2405                Some(format!("endpoints[{index}].transport")),
2406                "OpenTelemetry endpoint transport must be 'http_binary' or 'grpc'".to_string(),
2407            );
2408        }
2409        if let Err(error) = validate_attribute_mappings(&endpoint.attribute_mappings) {
2410            push_policy_diag(
2411                diagnostics,
2412                policy.unsupported_value,
2413                "observability.unsupported_value",
2414                Some("opentelemetry".to_string()),
2415                Some(format!("endpoints[{index}].attribute_mappings")),
2416                error,
2417            );
2418        }
2419        validate_opentelemetry_headers(diagnostics, policy, index, endpoint);
2420    }
2421    for error in opentelemetry_destination_collision_errors(&section.endpoints) {
2422        diagnostics.push(ConfigDiagnostic {
2423            level: DiagnosticLevel::Error,
2424            code: "observability.unsafe_otel_destination_collision".to_string(),
2425            component: Some("opentelemetry".to_string()),
2426            field: Some(format!("endpoints[{}].endpoint", error.index)),
2427            message: error.message,
2428        });
2429    }
2430    validate_opentelemetry_feature_support(diagnostics, policy, section);
2431}
2432
2433struct OpenTelemetryDestinationCollision {
2434    index: usize,
2435    message: String,
2436}
2437
2438#[derive(Debug, PartialEq, Eq)]
2439enum OpenTelemetryDestinationKey {
2440    Url {
2441        scheme: String,
2442        host: String,
2443        port: Option<u16>,
2444        path: String,
2445        query: Option<String>,
2446    },
2447    Raw(String),
2448}
2449
2450struct OpenTelemetryDestination {
2451    key: OpenTelemetryDestinationKey,
2452    display: String,
2453}
2454
2455fn validate_distinct_opentelemetry_destinations(
2456    endpoints: &[OpenTelemetryEndpointConfig],
2457) -> PluginResult<()> {
2458    if let Some(error) = opentelemetry_destination_collision_errors(endpoints)
2459        .into_iter()
2460        .next()
2461    {
2462        return Err(PluginError::InvalidConfig(error.message));
2463    }
2464    Ok(())
2465}
2466
2467fn opentelemetry_destination_collision_errors(
2468    endpoints: &[OpenTelemetryEndpointConfig],
2469) -> Vec<OpenTelemetryDestinationCollision> {
2470    let mut errors = Vec::new();
2471    for (index, endpoint) in endpoints.iter().enumerate() {
2472        for (other_index, other) in endpoints[..index].iter().enumerate() {
2473            let endpoint_destination = opentelemetry_destination(endpoint);
2474            let other_destination = opentelemetry_destination(other);
2475            if endpoint.transport == other.transport
2476                && endpoint_destination.key == other_destination.key
2477                && endpoint.otel_type != other.otel_type
2478            {
2479                errors.push(OpenTelemetryDestinationCollision {
2480                    index,
2481                    message: format!(
2482                        "OpenTelemetry endpoints[{other_index}] ({}) and endpoints[{index}] ({}) use the same {} destination {:?}; different projection types must use independent destinations",
2483                        opentelemetry_type_name(other.otel_type),
2484                        opentelemetry_type_name(endpoint.otel_type),
2485                        endpoint.transport,
2486                        endpoint_destination.display,
2487                    ),
2488                });
2489            }
2490        }
2491    }
2492    errors
2493}
2494
2495fn opentelemetry_destination(endpoint: &OpenTelemetryEndpointConfig) -> OpenTelemetryDestination {
2496    let configured_endpoint = endpoint.endpoint.trim();
2497    let effective_endpoint = if endpoint.transport == "http_binary" {
2498        resolve_http_trace_endpoint(configured_endpoint)
2499    } else {
2500        Cow::Borrowed(configured_endpoint)
2501    };
2502    canonicalize_opentelemetry_destination(&effective_endpoint)
2503}
2504
2505fn canonicalize_opentelemetry_destination(endpoint: &str) -> OpenTelemetryDestination {
2506    let Ok(url) = reqwest::Url::parse(endpoint) else {
2507        return raw_opentelemetry_destination(endpoint);
2508    };
2509    if !matches!(url.scheme(), "http" | "https") {
2510        return raw_opentelemetry_destination(endpoint);
2511    }
2512    let Some(url_host) = url.host_str() else {
2513        return raw_opentelemetry_destination(endpoint);
2514    };
2515
2516    let scheme = url.scheme().to_string();
2517    let host = canonical_opentelemetry_host(url_host);
2518    let port = url.port_or_known_default();
2519    let path = normalize_opentelemetry_path(url.path());
2520    let query = url.query().map(str::to_string);
2521    let display = format!(
2522        "{scheme}://{host}{}{path}{}",
2523        port.map(|port| format!(":{port}")).unwrap_or_default(),
2524        query
2525            .as_deref()
2526            .map(|query| format!("?{query}"))
2527            .unwrap_or_default(),
2528    );
2529    OpenTelemetryDestination {
2530        key: OpenTelemetryDestinationKey::Url {
2531            scheme,
2532            host,
2533            port,
2534            path,
2535            query,
2536        },
2537        display,
2538    }
2539}
2540
2541fn raw_opentelemetry_destination(endpoint: &str) -> OpenTelemetryDestination {
2542    OpenTelemetryDestination {
2543        key: OpenTelemetryDestinationKey::Raw(endpoint.to_string()),
2544        display: endpoint.to_string(),
2545    }
2546}
2547
2548fn canonical_opentelemetry_host(host: &str) -> String {
2549    let domain = host.strip_suffix('.').unwrap_or(host);
2550    let unbracketed = host
2551        .strip_prefix('[')
2552        .and_then(|host| host.strip_suffix(']'))
2553        .unwrap_or(host);
2554    let is_loopback_domain = domain == "localhost" || domain.ends_with(".localhost");
2555    let is_loopback_address = unbracketed
2556        .parse::<IpAddr>()
2557        .is_ok_and(|address| address.is_loopback());
2558    if is_loopback_domain || is_loopback_address {
2559        "<loopback>".to_string()
2560    } else {
2561        host.to_string()
2562    }
2563}
2564
2565fn normalize_opentelemetry_path(path: &str) -> String {
2566    let mut normalized = String::with_capacity(path.len());
2567    let mut previous_was_slash = false;
2568    for character in path.chars() {
2569        if character == '/' {
2570            if !previous_was_slash {
2571                normalized.push(character);
2572            }
2573            previous_was_slash = true;
2574        } else {
2575            normalized.push(character);
2576            previous_was_slash = false;
2577        }
2578    }
2579    while normalized.len() > 1 && normalized.ends_with('/') {
2580        normalized.pop();
2581    }
2582    normalized
2583}
2584
2585const fn opentelemetry_type_name(otel_type: OpenTelemetryType) -> &'static str {
2586    match otel_type {
2587        OpenTelemetryType::Full => "full",
2588        OpenTelemetryType::GenAi => "gen_ai",
2589        OpenTelemetryType::OpenInference => "openinference",
2590    }
2591}
2592
2593fn validate_opentelemetry_headers(
2594    diagnostics: &mut Vec<ConfigDiagnostic>,
2595    policy: &ConfigPolicy,
2596    index: usize,
2597    endpoint: &OpenTelemetryEndpointConfig,
2598) {
2599    validate_case_insensitive_header_duplicates(
2600        diagnostics,
2601        policy,
2602        index,
2603        "headers",
2604        endpoint.headers.keys(),
2605    );
2606    validate_case_insensitive_header_duplicates(
2607        diagnostics,
2608        policy,
2609        index,
2610        "header_env",
2611        endpoint.header_env.keys(),
2612    );
2613    for (header, value) in &endpoint.headers {
2614        let field = format!("endpoints[{index}].headers.{header}");
2615        validate_opentelemetry_header_name(diagnostics, policy, &field, header);
2616        validate_opentelemetry_header_value(diagnostics, policy, &field, header, value);
2617    }
2618    for (header, variable) in &endpoint.header_env {
2619        let field = format!("endpoints[{index}].header_env.{header}");
2620        validate_opentelemetry_header_name(diagnostics, policy, &field, header);
2621        if endpoint
2622            .headers
2623            .keys()
2624            .any(|configured| configured.eq_ignore_ascii_case(header))
2625        {
2626            push_policy_diag(
2627                diagnostics,
2628                policy.unsupported_value,
2629                "observability.unsupported_value",
2630                Some("opentelemetry".to_string()),
2631                Some(field.clone()),
2632                format!(
2633                    "OpenTelemetry endpoints[{index}] header {header:?} cannot appear in both headers and header_env"
2634                ),
2635            );
2636        }
2637        validate_opentelemetry_header_env(diagnostics, policy, &field, variable);
2638    }
2639}
2640
2641fn validate_case_insensitive_header_duplicates<'a>(
2642    diagnostics: &mut Vec<ConfigDiagnostic>,
2643    policy: &ConfigPolicy,
2644    index: usize,
2645    map_name: &str,
2646    headers: impl Iterator<Item = &'a String>,
2647) {
2648    let mut normalized = HashSet::new();
2649    for header in headers {
2650        if !normalized.insert(header.to_ascii_lowercase()) {
2651            push_policy_diag(
2652                diagnostics,
2653                policy.unsupported_value,
2654                "observability.unsupported_value",
2655                Some("opentelemetry".to_string()),
2656                Some(format!("endpoints[{index}].{map_name}.{header}")),
2657                format!(
2658                    "OpenTelemetry endpoints[{index}].{map_name} contains duplicate header {header:?} ignoring ASCII case"
2659                ),
2660            );
2661        }
2662    }
2663}
2664
2665fn validate_opentelemetry_header_name(
2666    diagnostics: &mut Vec<ConfigDiagnostic>,
2667    policy: &ConfigPolicy,
2668    field: &str,
2669    header: &str,
2670) {
2671    if header.trim().is_empty()
2672        || header.trim() != header
2673        || reqwest::header::HeaderName::from_bytes(header.as_bytes()).is_err()
2674    {
2675        push_policy_diag(
2676            diagnostics,
2677            policy.unsupported_value,
2678            "observability.unsupported_value",
2679            Some("opentelemetry".to_string()),
2680            Some(field.to_string()),
2681            format!("OpenTelemetry {field} header name {header:?} is invalid"),
2682        );
2683    }
2684}
2685
2686fn validate_opentelemetry_header_value(
2687    diagnostics: &mut Vec<ConfigDiagnostic>,
2688    policy: &ConfigPolicy,
2689    field: &str,
2690    header: &str,
2691    value: &str,
2692) {
2693    if reqwest::header::HeaderValue::from_str(value).is_err() {
2694        push_policy_diag(
2695            diagnostics,
2696            policy.unsupported_value,
2697            "observability.unsupported_value",
2698            Some("opentelemetry".to_string()),
2699            Some(field.to_string()),
2700            format!("OpenTelemetry header {header:?} has an invalid value"),
2701        );
2702    }
2703}
2704
2705fn validate_opentelemetry_header_env(
2706    diagnostics: &mut Vec<ConfigDiagnostic>,
2707    policy: &ConfigPolicy,
2708    field: &str,
2709    variable: &str,
2710) {
2711    let trimmed = variable.trim();
2712    let error = if trimmed.is_empty() {
2713        Some("must name a non-empty environment variable".to_string())
2714    } else if trimmed != variable {
2715        Some(format!(
2716            "must not have surrounding whitespace; got {variable:?}"
2717        ))
2718    } else {
2719        None
2720    };
2721    if let Some(error) = error {
2722        push_policy_diag(
2723            diagnostics,
2724            policy.unsupported_value,
2725            "observability.unsupported_value",
2726            Some("opentelemetry".to_string()),
2727            Some(field.to_string()),
2728            format!("OpenTelemetry {field} {error}"),
2729        );
2730    }
2731}
2732
2733fn validate_opentelemetry_feature_support(
2734    _diagnostics: &mut Vec<ConfigDiagnostic>,
2735    _policy: &ConfigPolicy,
2736    _section: &OpenTelemetrySectionConfig,
2737) {
2738}
2739
2740fn validate_version(diagnostics: &mut Vec<ConfigDiagnostic>, policy: &ConfigPolicy, version: u32) {
2741    if version != 3 {
2742        push_policy_diag(
2743            diagnostics,
2744            policy.unsupported_value,
2745            "observability.unsupported_config_version",
2746            Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
2747            Some("version".to_string()),
2748            format!(
2749                "observability config version {version} is unsupported; use version 3 and migrate OpenTelemetry and OpenInference exporters into opentelemetry.endpoints"
2750            ),
2751        );
2752    }
2753}
2754
2755fn validate_policy_fields(
2756    diagnostics: &mut Vec<ConfigDiagnostic>,
2757    policy: &ConfigPolicy,
2758    plugin_config: &Map<String, Json>,
2759) {
2760    if let Some(policy_json) = plugin_config.get("policy").and_then(Json::as_object) {
2761        validate_unknown_fields(
2762            diagnostics,
2763            policy,
2764            Some("policy".to_string()),
2765            policy_json,
2766            &["unknown_component", "unknown_field", "unsupported_value"],
2767        );
2768    }
2769}
2770
2771fn validate_section_fields(
2772    diagnostics: &mut Vec<ConfigDiagnostic>,
2773    policy: &ConfigPolicy,
2774    plugin_config: &Map<String, Json>,
2775    section: &str,
2776    known_fields: &[&str],
2777) {
2778    if let Some(section_json) = plugin_config.get(section).and_then(Json::as_object) {
2779        validate_unknown_fields(
2780            diagnostics,
2781            policy,
2782            Some(section.to_string()),
2783            section_json,
2784            known_fields,
2785        );
2786    }
2787}
2788
2789fn validate_atof_values(
2790    diagnostics: &mut Vec<ConfigDiagnostic>,
2791    policy: &ConfigPolicy,
2792    section: &AtofSectionConfig,
2793) {
2794    if section.enabled && section.sinks.is_empty() {
2795        push_policy_diag(
2796            diagnostics,
2797            policy.unsupported_value,
2798            "observability.unsupported_value",
2799            Some("atof".to_string()),
2800            Some("sinks".to_string()),
2801            "ATOF requires at least one configured sink when enabled".to_string(),
2802        );
2803    }
2804    let mut stream_sink_names = HashSet::new();
2805    for (index, sink) in section.sinks.iter().enumerate() {
2806        validate_atof_sink(diagnostics, policy, index, sink, &mut stream_sink_names);
2807    }
2808}
2809
2810fn validate_atof_sink<'a>(
2811    diagnostics: &mut Vec<ConfigDiagnostic>,
2812    policy: &ConfigPolicy,
2813    index: usize,
2814    sink: &'a AtofSinkSectionConfig,
2815    stream_sink_names: &mut HashSet<&'a str>,
2816) {
2817    match sink {
2818        AtofSinkSectionConfig::File(file) => {
2819            if AtofExporterMode::parse(&file.mode).is_none() {
2820                push_policy_diag(
2821                    diagnostics,
2822                    policy.unsupported_value,
2823                    "observability.unsupported_value",
2824                    Some("atof".to_string()),
2825                    Some(format!("sinks[{index}].mode")),
2826                    format!("ATOF sinks[{index}].mode must be 'append' or 'overwrite'"),
2827                );
2828            }
2829        }
2830        AtofSinkSectionConfig::Stream(stream) => {
2831            validate_atof_stream_sink_name(diagnostics, policy, index, stream, stream_sink_names);
2832            validate_atof_stream_sink_values(diagnostics, policy, index, stream);
2833        }
2834    }
2835}
2836
2837fn validate_atof_stream_sink_name<'a>(
2838    diagnostics: &mut Vec<ConfigDiagnostic>,
2839    policy: &ConfigPolicy,
2840    index: usize,
2841    stream: &'a AtofStreamSinkSectionConfig,
2842    stream_sink_names: &mut HashSet<&'a str>,
2843) {
2844    let Some(name) = stream.name.as_deref() else {
2845        return;
2846    };
2847    let trimmed = name.trim();
2848    let message = if trimmed.is_empty() {
2849        Some(format!("ATOF sinks[{index}].name must be non-empty"))
2850    } else if name != trimmed {
2851        Some(format!(
2852            "ATOF sinks[{index}].name must not have leading or trailing whitespace"
2853        ))
2854    } else if !stream_sink_names.insert(name) {
2855        Some(format!("ATOF stream sink name {name:?} must be unique"))
2856    } else {
2857        None
2858    };
2859    if let Some(message) = message {
2860        push_policy_diag(
2861            diagnostics,
2862            policy.unsupported_value,
2863            "observability.unsupported_value",
2864            Some("atof".to_string()),
2865            Some(format!("sinks[{index}].name")),
2866            message,
2867        );
2868    }
2869}
2870
2871fn validate_atof_stream_sink_values(
2872    diagnostics: &mut Vec<ConfigDiagnostic>,
2873    policy: &ConfigPolicy,
2874    index: usize,
2875    endpoint: &AtofStreamSinkSectionConfig,
2876) {
2877    let transport = AtofEndpointTransport::parse(&endpoint.transport);
2878    if endpoint.url.trim().is_empty() {
2879        push_policy_diag(
2880            diagnostics,
2881            policy.unsupported_value,
2882            "observability.unsupported_value",
2883            Some("atof".to_string()),
2884            Some(format!("sinks[{index}].url")),
2885            format!("ATOF sinks[{index}].url must be non-empty"),
2886        );
2887    } else if transport.is_some_and(|transport| !is_valid_atof_stream_url(&endpoint.url, transport))
2888    {
2889        push_policy_diag(
2890            diagnostics,
2891            policy.unsupported_value,
2892            "observability.unsupported_value",
2893            Some("atof".to_string()),
2894            Some(format!("sinks[{index}].url")),
2895            format!(
2896                "ATOF sinks[{index}].url must be a valid URL for transport {:?}",
2897                endpoint.transport
2898            ),
2899        );
2900    }
2901    if transport.is_none() {
2902        push_policy_diag(
2903            diagnostics,
2904            policy.unsupported_value,
2905            "observability.unsupported_value",
2906            Some("atof".to_string()),
2907            Some(format!("sinks[{index}].transport")),
2908            format!("ATOF sinks[{index}].transport must be 'http_post', 'websocket', or 'ndjson'"),
2909        );
2910    }
2911    if endpoint.timeout_millis == 0 {
2912        push_policy_diag(
2913            diagnostics,
2914            policy.unsupported_value,
2915            "observability.unsupported_value",
2916            Some("atof".to_string()),
2917            Some(format!("sinks[{index}].timeout_millis")),
2918            format!("ATOF sinks[{index}].timeout_millis must be greater than 0"),
2919        );
2920    }
2921    if AtofEndpointFieldNamePolicy::parse(&endpoint.field_name_policy).is_none() {
2922        push_policy_diag(
2923            diagnostics,
2924            policy.unsupported_value,
2925            "observability.unsupported_value",
2926            Some("atof".to_string()),
2927            Some(format!("sinks[{index}].field_name_policy")),
2928            format!("ATOF sinks[{index}].field_name_policy must be 'preserve' or 'replace_dots'"),
2929        );
2930    }
2931    for (header, value) in &endpoint.headers {
2932        validate_atof_stream_header(
2933            diagnostics,
2934            policy,
2935            &format!("sinks[{index}].headers.{header}"),
2936            header,
2937            value,
2938        );
2939    }
2940    for (header, variable) in &endpoint.header_env {
2941        let field = format!("sinks[{index}].header_env.{header}");
2942        validate_atof_stream_header_name(diagnostics, policy, &field, header);
2943        if endpoint
2944            .headers
2945            .keys()
2946            .any(|configured| configured.eq_ignore_ascii_case(header))
2947        {
2948            push_policy_diag(
2949                diagnostics,
2950                policy.unsupported_value,
2951                "observability.unsupported_value",
2952                Some("atof".to_string()),
2953                Some(field.clone()),
2954                format!(
2955                    "ATOF sinks[{index}] header {header:?} cannot appear in both headers and header_env"
2956                ),
2957            );
2958        }
2959        validate_atof_stream_header_env(diagnostics, policy, &field, variable);
2960    }
2961}
2962
2963#[cfg(feature = "atof-streaming")]
2964fn is_valid_atof_stream_url(url: &str, transport: AtofEndpointTransport) -> bool {
2965    let Ok(url) = reqwest::Url::parse(url) else {
2966        return false;
2967    };
2968    url.host_str().is_some()
2969        && match transport {
2970            AtofEndpointTransport::HttpPost | AtofEndpointTransport::Ndjson => {
2971                matches!(url.scheme(), "http" | "https")
2972            }
2973            AtofEndpointTransport::Websocket => matches!(url.scheme(), "ws" | "wss"),
2974        }
2975}
2976
2977#[cfg(not(feature = "atof-streaming"))]
2978fn is_valid_atof_stream_url(url: &str, transport: AtofEndpointTransport) -> bool {
2979    let Some((scheme, rest)) = url.split_once("://") else {
2980        return false;
2981    };
2982    !rest.is_empty()
2983        && !rest.starts_with('/')
2984        && match transport {
2985            AtofEndpointTransport::HttpPost | AtofEndpointTransport::Ndjson => {
2986                matches!(scheme, "http" | "https")
2987            }
2988            AtofEndpointTransport::Websocket => matches!(scheme, "ws" | "wss"),
2989        }
2990}
2991
2992fn validate_atof_stream_header(
2993    diagnostics: &mut Vec<ConfigDiagnostic>,
2994    policy: &ConfigPolicy,
2995    field: &str,
2996    header: &str,
2997    value: &str,
2998) {
2999    validate_atof_stream_header_name(diagnostics, policy, field, header);
3000    #[cfg(not(feature = "atof-streaming"))]
3001    let _ = value;
3002    #[cfg(feature = "atof-streaming")]
3003    if let Err(error) = reqwest::header::HeaderValue::from_str(value) {
3004        push_policy_diag(
3005            diagnostics,
3006            policy.unsupported_value,
3007            "observability.unsupported_value",
3008            Some("atof".to_string()),
3009            Some(field.to_string()),
3010            format!("ATOF {field} value is invalid: {error}"),
3011        );
3012    }
3013}
3014
3015fn validate_atof_stream_header_name(
3016    diagnostics: &mut Vec<ConfigDiagnostic>,
3017    policy: &ConfigPolicy,
3018    field: &str,
3019    header: &str,
3020) {
3021    #[cfg(feature = "atof-streaming")]
3022    let is_valid = reqwest::header::HeaderName::from_bytes(header.as_bytes()).is_ok();
3023    #[cfg(not(feature = "atof-streaming"))]
3024    let is_valid = !header.trim().is_empty() && header.trim() == header;
3025    if !is_valid {
3026        push_policy_diag(
3027            diagnostics,
3028            policy.unsupported_value,
3029            "observability.unsupported_value",
3030            Some("atof".to_string()),
3031            Some(field.to_string()),
3032            format!("ATOF {field} header name '{header}' is invalid"),
3033        );
3034    }
3035}
3036
3037fn validate_atof_stream_header_env(
3038    diagnostics: &mut Vec<ConfigDiagnostic>,
3039    policy: &ConfigPolicy,
3040    field: &str,
3041    variable: &str,
3042) {
3043    let trimmed = variable.trim();
3044    if trimmed.is_empty() {
3045        push_policy_diag(
3046            diagnostics,
3047            policy.unsupported_value,
3048            "observability.unsupported_value",
3049            Some("atof".to_string()),
3050            Some(field.to_string()),
3051            format!("ATOF {field} must name a non-empty environment variable"),
3052        );
3053    } else if trimmed != variable {
3054        push_policy_diag(
3055            diagnostics,
3056            policy.unsupported_value,
3057            "observability.unsupported_value",
3058            Some("atof".to_string()),
3059            Some(field.to_string()),
3060            format!("ATOF {field} must not have surrounding whitespace; got '{variable}'"),
3061        );
3062    } else {
3063        match std::env::var(variable) {
3064            Ok(value) if value.trim().is_empty() => push_policy_diag(
3065                diagnostics,
3066                policy.unsupported_value,
3067                "observability.unsupported_value",
3068                Some("atof".to_string()),
3069                Some(field.to_string()),
3070                format!("ATOF {field} references an environment variable that is blank"),
3071            ),
3072            Ok(_) => {}
3073            Err(error) => push_policy_diag(
3074                diagnostics,
3075                policy.unsupported_value,
3076                "observability.unsupported_value",
3077                Some("atof".to_string()),
3078                Some(field.to_string()),
3079                format!("ATOF {field} references an environment variable that is not set: {error}"),
3080            ),
3081        }
3082    }
3083}
3084
3085fn validate_atif_values(
3086    diagnostics: &mut Vec<ConfigDiagnostic>,
3087    policy: &ConfigPolicy,
3088    section: &AtifSectionConfig,
3089) {
3090    if let Err(message) = validate_atif_filename_template(&section.filename_template) {
3091        push_policy_diag(
3092            diagnostics,
3093            policy.unsupported_value,
3094            "observability.unsupported_value",
3095            Some("atif".to_string()),
3096            Some("filename_template".to_string()),
3097            message,
3098        );
3099    }
3100    for (index, storage) in section.storage.iter().enumerate() {
3101        validate_atif_storage_values(diagnostics, policy, index, storage);
3102    }
3103}
3104
3105fn validate_atif_storage_values(
3106    diagnostics: &mut Vec<ConfigDiagnostic>,
3107    policy: &ConfigPolicy,
3108    index: usize,
3109    storage: &AtifStorageConfig,
3110) {
3111    match storage {
3112        AtifStorageConfig::Http(http) => {
3113            validate_atif_http_endpoint(
3114                diagnostics,
3115                policy,
3116                &format!("storage[{index}].endpoint"),
3117                &http.endpoint,
3118            );
3119            if http.timeout_millis == 0 {
3120                push_policy_diag(
3121                    diagnostics,
3122                    policy.unsupported_value,
3123                    "observability.unsupported_value",
3124                    Some("atif".to_string()),
3125                    Some(format!("storage[{index}].timeout_millis")),
3126                    format!("ATIF storage[{index}].timeout_millis must be positive"),
3127                );
3128            }
3129            for (header, value) in &http.headers {
3130                validate_atif_http_header(
3131                    diagnostics,
3132                    policy,
3133                    &format!("storage[{index}].headers.{header}"),
3134                    header,
3135                    value,
3136                );
3137            }
3138            for (header, var_name) in &http.header_env {
3139                validate_atif_http_header_name(
3140                    diagnostics,
3141                    policy,
3142                    &format!("storage[{index}].header_env.{header}"),
3143                    header,
3144                );
3145                validate_atif_storage_env_var(
3146                    diagnostics,
3147                    policy,
3148                    &format!("storage[{index}].header_env.{header}"),
3149                    Some(var_name.as_str()),
3150                );
3151            }
3152        }
3153        AtifStorageConfig::S3(s3) => {
3154            if s3.bucket.trim().is_empty() {
3155                push_policy_diag(
3156                    diagnostics,
3157                    policy.unsupported_value,
3158                    "observability.unsupported_value",
3159                    Some("atif".to_string()),
3160                    Some(format!("storage[{index}].bucket")),
3161                    format!("ATIF storage[{index}].bucket must be non-empty"),
3162                );
3163            }
3164            validate_atif_storage_env_var(
3165                diagnostics,
3166                policy,
3167                &format!("storage[{index}].secret_access_key_var"),
3168                s3.secret_access_key_var.as_deref(),
3169            );
3170            validate_atif_storage_env_var(
3171                diagnostics,
3172                policy,
3173                &format!("storage[{index}].session_token_var"),
3174                s3.session_token_var.as_deref(),
3175            );
3176        }
3177    }
3178}
3179
3180fn validate_atif_http_header(
3181    diagnostics: &mut Vec<ConfigDiagnostic>,
3182    policy: &ConfigPolicy,
3183    field: &str,
3184    header: &str,
3185    _value: &str,
3186) {
3187    validate_atif_http_header_name(diagnostics, policy, field, header);
3188    #[cfg(feature = "object-store")]
3189    if let Err(err) = reqwest::header::HeaderValue::from_str(_value) {
3190        push_policy_diag(
3191            diagnostics,
3192            policy.unsupported_value,
3193            "observability.unsupported_value",
3194            Some("atif".to_string()),
3195            Some(field.to_string()),
3196            format!("ATIF {field} value is invalid: {err}"),
3197        );
3198    }
3199}
3200
3201fn validate_atif_http_header_name(
3202    diagnostics: &mut Vec<ConfigDiagnostic>,
3203    policy: &ConfigPolicy,
3204    field: &str,
3205    header: &str,
3206) {
3207    #[cfg(feature = "object-store")]
3208    let is_valid = reqwest::header::HeaderName::from_bytes(header.as_bytes()).is_ok();
3209    #[cfg(not(feature = "object-store"))]
3210    let is_valid = !header.trim().is_empty() && header.trim() == header;
3211    if !is_valid {
3212        push_policy_diag(
3213            diagnostics,
3214            policy.unsupported_value,
3215            "observability.unsupported_value",
3216            Some("atif".to_string()),
3217            Some(field.to_string()),
3218            format!("ATIF {field} header name '{header}' is invalid"),
3219        );
3220    }
3221}
3222
3223fn validate_atif_http_endpoint(
3224    diagnostics: &mut Vec<ConfigDiagnostic>,
3225    policy: &ConfigPolicy,
3226    field: &str,
3227    endpoint: &str,
3228) {
3229    let trimmed = endpoint.trim();
3230    let mut is_valid = !trimmed.is_empty() && trimmed == endpoint;
3231    #[cfg(feature = "object-store")]
3232    {
3233        is_valid = is_valid
3234            && reqwest::Url::parse(endpoint)
3235                .map(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
3236                .unwrap_or(false);
3237    }
3238    #[cfg(not(feature = "object-store"))]
3239    {
3240        let valid_scheme = trimmed.starts_with("http://") || trimmed.starts_with("https://");
3241        let has_host = trimmed
3242            .split_once("://")
3243            .map(|(_, rest)| !rest.is_empty() && !rest.starts_with('/'))
3244            .unwrap_or(false);
3245        is_valid = is_valid && valid_scheme && has_host;
3246    }
3247    if !is_valid {
3248        push_policy_diag(
3249            diagnostics,
3250            policy.unsupported_value,
3251            "observability.unsupported_value",
3252            Some("atif".to_string()),
3253            Some(field.to_string()),
3254            format!("ATIF {field} must be a valid http:// or https:// URL"),
3255        );
3256    }
3257}
3258
3259fn validate_atif_storage_env_var(
3260    diagnostics: &mut Vec<ConfigDiagnostic>,
3261    policy: &ConfigPolicy,
3262    field: &str,
3263    var_name: Option<&str>,
3264) {
3265    let Some(var_name) = var_name else {
3266        return;
3267    };
3268    let trimmed = var_name.trim();
3269    if trimmed.is_empty() {
3270        push_policy_diag(
3271            diagnostics,
3272            policy.unsupported_value,
3273            "observability.unsupported_value",
3274            Some("atif".to_string()),
3275            Some(field.to_string()),
3276            format!("ATIF {field} must be the name of an environment variable, not empty"),
3277        );
3278        return;
3279    }
3280    if trimmed != var_name {
3281        push_policy_diag(
3282            diagnostics,
3283            policy.unsupported_value,
3284            "observability.unsupported_value",
3285            Some("atif".to_string()),
3286            Some(field.to_string()),
3287            format!("ATIF {field} must not have surrounding whitespace; got '{var_name}'"),
3288        );
3289        return;
3290    }
3291    match std::env::var(var_name) {
3292        Ok(value) if !value.is_empty() => {}
3293        Ok(_) => {
3294            push_policy_diag(
3295                diagnostics,
3296                policy.unsupported_value,
3297                "observability.unsupported_value",
3298                Some("atif".to_string()),
3299                Some(field.to_string()),
3300                format!(
3301                    "ATIF {field}='{var_name}' references an environment variable that is set but empty"
3302                ),
3303            );
3304        }
3305        Err(_) => {
3306            push_policy_diag(
3307                diagnostics,
3308                policy.unsupported_value,
3309                "observability.unsupported_value",
3310                Some("atif".to_string()),
3311                Some(field.to_string()),
3312                format!(
3313                    "ATIF {field}='{var_name}' references an environment variable that is not set"
3314                ),
3315            );
3316        }
3317    }
3318}
3319
3320fn validate_unknown_fields(
3321    diagnostics: &mut Vec<ConfigDiagnostic>,
3322    policy: &ConfigPolicy,
3323    component: Option<String>,
3324    config: &Map<String, Json>,
3325    known_fields: &[&str],
3326) {
3327    for field in config.keys() {
3328        if !known_fields.contains(&field.as_str()) {
3329            push_policy_diag(
3330                diagnostics,
3331                policy.unknown_field,
3332                "observability.unknown_field",
3333                component.clone(),
3334                Some(field.clone()),
3335                format!(
3336                    "field '{}' is not recognized for '{}'",
3337                    field,
3338                    component.as_deref().unwrap_or("unknown")
3339                ),
3340            );
3341        }
3342    }
3343}
3344
3345fn push_policy_diag(
3346    diagnostics: &mut Vec<ConfigDiagnostic>,
3347    behavior: UnsupportedBehavior,
3348    code: &str,
3349    component: Option<String>,
3350    field: Option<String>,
3351    message: String,
3352) {
3353    let level = match behavior {
3354        UnsupportedBehavior::Ignore => return,
3355        UnsupportedBehavior::Warn => DiagnosticLevel::Warning,
3356        UnsupportedBehavior::Error => DiagnosticLevel::Error,
3357    };
3358    diagnostics.push(ConfigDiagnostic {
3359        level,
3360        code: code.to_string(),
3361        component,
3362        field,
3363        message,
3364    });
3365}
3366
3367fn observability_registration_error(error: impl std::fmt::Display) -> PluginError {
3368    PluginError::RegistrationFailed(error.to_string())
3369}
3370
3371fn default_observability_config_version() -> u32 {
3372    3
3373}
3374
3375fn default_atof_mode() -> String {
3376    "append".to_string()
3377}
3378
3379fn default_atof_endpoint_transport() -> String {
3380    AtofEndpointTransport::default().as_str().to_string()
3381}
3382
3383fn default_atof_endpoint_field_name_policy() -> String {
3384    AtofEndpointFieldNamePolicy::default().as_str().to_string()
3385}
3386
3387fn default_agent_name() -> String {
3388    "NeMo Relay".to_string()
3389}
3390
3391fn default_agent_version() -> String {
3392    env!("CARGO_PKG_VERSION").to_string()
3393}
3394
3395fn default_model_name() -> String {
3396    "unknown".to_string()
3397}
3398
3399fn default_atif_filename_template() -> String {
3400    "nemo-relay-atif-{session_id}.json".to_string()
3401}
3402
3403fn default_otlp_transport() -> String {
3404    "http_binary".to_string()
3405}
3406
3407fn default_otel_service_name() -> String {
3408    "unknown_service".to_string()
3409}
3410
3411fn default_otel_instrumentation_scope() -> String {
3412    "opentelemetry".to_string()
3413}
3414
3415fn default_timeout_millis() -> u64 {
3416    3_000
3417}
3418
3419fn default_output_directory() -> PathBuf {
3420    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
3421}
3422
3423#[cfg(not(feature = "object-store"))]
3424struct AtifRemoteStorage;
3425
3426/// Remote storage handle for ATIF trajectory uploads.
3427///
3428/// The handle owns a dedicated OS thread that runs a single-threaded tokio
3429/// runtime. Subscriber callbacks (which run on the runtime that emitted the
3430/// event) submit uploads over a synchronous channel and block on the reply, so
3431/// the handle stays safe to drive from any thread regardless of whether the
3432/// caller is already inside another tokio runtime.
3433#[cfg(feature = "object-store")]
3434struct AtifRemoteStorage {
3435    sender: std::sync::mpsc::Sender<AtifUploadRequest>,
3436    key_prefix: String,
3437    index: usize,
3438    resource_kind: &'static str,
3439    access_state: AtomicU8,
3440}
3441
3442#[cfg(feature = "object-store")]
3443struct AtifUploadRequest {
3444    key: String,
3445    filename: String,
3446    session_id: String,
3447    payload: Vec<u8>,
3448    reply: std::sync::mpsc::Sender<std::io::Result<()>>,
3449}
3450
3451#[cfg(feature = "object-store")]
3452#[derive(Clone)]
3453struct HttpUploadConfig {
3454    endpoint: String,
3455    headers: HashMap<String, String>,
3456    timeout: Duration,
3457}
3458
3459#[cfg(feature = "object-store")]
3460#[derive(Default)]
3461struct S3BuilderOverrides {
3462    access_key_id: Option<String>,
3463    secret_access_key: Option<String>,
3464    session_token: Option<String>,
3465    region: Option<String>,
3466    endpoint_url: Option<String>,
3467    allow_http: Option<bool>,
3468}
3469
3470#[cfg(feature = "object-store")]
3471impl S3BuilderOverrides {
3472    fn resolve(index: usize, s3: &S3StorageConfig) -> std::io::Result<Self> {
3473        Ok(Self {
3474            access_key_id: s3.access_key_id.clone(),
3475            secret_access_key: resolve_env_var_field(
3476                &format!("storage[{index}].secret_access_key_var"),
3477                s3.secret_access_key_var.as_deref(),
3478            )?,
3479            session_token: resolve_env_var_field(
3480                &format!("storage[{index}].session_token_var"),
3481                s3.session_token_var.as_deref(),
3482            )?,
3483            region: s3.region.clone(),
3484            endpoint_url: s3.endpoint_url.clone(),
3485            allow_http: s3.allow_http,
3486        })
3487    }
3488
3489    fn apply(
3490        self,
3491        mut builder: object_store::aws::AmazonS3Builder,
3492    ) -> object_store::aws::AmazonS3Builder {
3493        if let Some(value) = self.access_key_id {
3494            builder = builder.with_access_key_id(value);
3495        }
3496        if let Some(value) = self.secret_access_key {
3497            builder = builder.with_secret_access_key(value);
3498        }
3499        if let Some(value) = self.session_token {
3500            builder = builder.with_token(value);
3501        }
3502        if let Some(value) = self.region {
3503            builder = builder.with_region(value);
3504        }
3505        if let Some(value) = self.endpoint_url {
3506            builder = builder.with_endpoint(value);
3507        }
3508        if let Some(value) = self.allow_http {
3509            builder = builder.with_allow_http(value);
3510        }
3511        builder
3512    }
3513}
3514
3515#[cfg(feature = "object-store")]
3516fn resolve_env_var_field(field: &str, var_name: Option<&str>) -> std::io::Result<Option<String>> {
3517    let Some(var_name) = var_name else {
3518        return Ok(None);
3519    };
3520    if var_name.trim().is_empty() || var_name.trim() != var_name {
3521        return Err(std::io::Error::other(format!(
3522            "ATIF {field} must be the name of an environment variable, not '{var_name}'"
3523        )));
3524    }
3525    match std::env::var(var_name) {
3526        Ok(value) if !value.is_empty() => Ok(Some(value)),
3527        Ok(_) => Err(std::io::Error::other(format!(
3528            "ATIF {field}='{var_name}' references an environment variable that is set but empty"
3529        ))),
3530        Err(_) => Err(std::io::Error::other(format!(
3531            "ATIF {field}='{var_name}' references an environment variable that is not set"
3532        ))),
3533    }
3534}
3535
3536#[cfg(feature = "object-store")]
3537impl AtifRemoteStorage {
3538    fn from_config(index: usize, config: &AtifStorageConfig) -> std::io::Result<Self> {
3539        match config {
3540            AtifStorageConfig::Http(http) => Self::build_http(index, http),
3541            AtifStorageConfig::S3(s3) => Self::build_s3(index, s3),
3542        }
3543    }
3544
3545    fn build_http(index: usize, http: &HttpStorageConfig) -> std::io::Result<Self> {
3546        let upload_config = HttpUploadConfig::resolve(index, http)?;
3547        let (req_tx, req_rx) = std::sync::mpsc::channel::<AtifUploadRequest>();
3548        let (ready_tx, ready_rx) = std::sync::mpsc::channel::<std::io::Result<()>>();
3549
3550        std::thread::Builder::new()
3551            .name("nemo-relay-atif-storage".to_string())
3552            .spawn(move || {
3553                let runtime = match tokio::runtime::Builder::new_current_thread()
3554                    .enable_all()
3555                    .build()
3556                {
3557                    Ok(rt) => rt,
3558                    Err(err) => {
3559                        let _ = ready_tx.send(Err(std::io::Error::other(format!(
3560                            "failed to build ATIF storage runtime: {err}"
3561                        ))));
3562                        return;
3563                    }
3564                };
3565                let client = match reqwest::Client::builder()
3566                    .timeout(upload_config.timeout)
3567                    .build()
3568                {
3569                    Ok(client) => client,
3570                    Err(err) => {
3571                        let _ = ready_tx.send(Err(std::io::Error::other(format!(
3572                            "failed to build HTTP client for ATIF storage[{}]: {err}",
3573                            index
3574                        ))));
3575                        return;
3576                    }
3577                };
3578                if ready_tx.send(Ok(())).is_err() {
3579                    return;
3580                }
3581                drop(ready_tx);
3582
3583                while let Ok(request) = req_rx.recv() {
3584                    let result = runtime.block_on(post_atif_http(
3585                        &client,
3586                        &upload_config,
3587                        request.filename,
3588                        request.session_id,
3589                        request.payload,
3590                    ));
3591                    let _ = request.reply.send(result);
3592                }
3593            })
3594            .map_err(|err| {
3595                std::io::Error::other(format!("failed to spawn ATIF storage thread: {err}"))
3596            })?;
3597
3598        match ready_rx.recv() {
3599            Ok(Ok(())) => Ok(Self {
3600                sender: req_tx,
3601                key_prefix: String::new(),
3602                index,
3603                resource_kind: "http_endpoint",
3604                access_state: AtomicU8::new(0),
3605            }),
3606            Ok(Err(err)) => Err(err),
3607            Err(_) => Err(std::io::Error::other(
3608                "ATIF storage thread exited before signalling readiness",
3609            )),
3610        }
3611    }
3612
3613    fn build_s3(index: usize, s3: &S3StorageConfig) -> std::io::Result<Self> {
3614        let bucket = s3.bucket.clone();
3615        let key_prefix = normalize_storage_key_prefix(s3.key_prefix.as_deref());
3616        let overrides = S3BuilderOverrides::resolve(index, s3)?;
3617
3618        let (req_tx, req_rx) = std::sync::mpsc::channel::<AtifUploadRequest>();
3619        let (ready_tx, ready_rx) = std::sync::mpsc::channel::<std::io::Result<()>>();
3620
3621        std::thread::Builder::new()
3622            .name("nemo-relay-atif-storage".to_string())
3623            .spawn(move || {
3624                let runtime = match tokio::runtime::Builder::new_current_thread()
3625                    .enable_all()
3626                    .build()
3627                {
3628                    Ok(rt) => rt,
3629                    Err(err) => {
3630                        let _ = ready_tx.send(Err(std::io::Error::other(format!(
3631                            "failed to build ATIF storage runtime: {err}"
3632                        ))));
3633                        return;
3634                    }
3635                };
3636                let store = match overrides
3637                    .apply(object_store::aws::AmazonS3Builder::from_env())
3638                    .with_bucket_name(&bucket)
3639                    .build()
3640                {
3641                    Ok(store) => Arc::new(store) as Arc<dyn object_store::ObjectStore>,
3642                    Err(err) => {
3643                        let _ = ready_tx.send(Err(std::io::Error::other(format!(
3644                            "failed to build S3 client for bucket '{bucket}': {err}"
3645                        ))));
3646                        return;
3647                    }
3648                };
3649                if ready_tx.send(Ok(())).is_err() {
3650                    return;
3651                }
3652                drop(ready_tx);
3653
3654                while let Ok(request) = req_rx.recv() {
3655                    let result = runtime.block_on(async {
3656                        use object_store::ObjectStoreExt as _;
3657                        store
3658                            .put(
3659                                &object_store::path::Path::from(request.key.clone()),
3660                                object_store::PutPayload::from(request.payload),
3661                            )
3662                            .await
3663                            .map(|_| ())
3664                            .map_err(|err| {
3665                                std::io::Error::other(format!(
3666                                    "S3 upload to '{}' failed: {err}",
3667                                    request.key
3668                                ))
3669                            })
3670                    });
3671                    let _ = request.reply.send(result);
3672                }
3673            })
3674            .map_err(|err| {
3675                std::io::Error::other(format!("failed to spawn ATIF storage thread: {err}"))
3676            })?;
3677
3678        match ready_rx.recv() {
3679            Ok(Ok(())) => Ok(Self {
3680                sender: req_tx,
3681                key_prefix,
3682                index,
3683                resource_kind: "s3_bucket",
3684                access_state: AtomicU8::new(0),
3685            }),
3686            Ok(Err(err)) => Err(err),
3687            Err(_) => Err(std::io::Error::other(
3688                "ATIF storage thread exited before signalling readiness",
3689            )),
3690        }
3691    }
3692
3693    fn put(&self, filename: &str, session_id: &str, payload: &[u8]) -> std::io::Result<()> {
3694        let key = format!("{}{}", self.key_prefix, filename);
3695        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
3696        self.sender
3697            .send(AtifUploadRequest {
3698                key,
3699                filename: filename.to_string(),
3700                session_id: session_id.to_string(),
3701                payload: payload.to_vec(),
3702                reply: reply_tx,
3703            })
3704            .map_err(|_| std::io::Error::other("ATIF storage thread is not running"))?;
3705        let (result, failure_reason) = match reply_rx.recv() {
3706            Ok(result) => (result, "upload_failed"),
3707            Err(_) => (
3708                Err(std::io::Error::other(
3709                    "ATIF storage thread dropped the upload reply",
3710                )),
3711                "reply_channel_closed",
3712            ),
3713        };
3714        match &result {
3715            Ok(()) => {
3716                if self.access_state.swap(2, Ordering::AcqRel) != 2 {
3717                    log::info!(
3718                        target: "nemo_relay.observability",
3719                        event = "storage_access_validated",
3720                        plugin_kind = "observability",
3721                        exporter = "atif",
3722                        resource_index = self.index,
3723                        resource_kind = self.resource_kind,
3724                        permission = "write";
3725                        "ATIF storage access validated"
3726                    );
3727                }
3728            }
3729            Err(_) => {
3730                if self.access_state.swap(1, Ordering::AcqRel) != 1 {
3731                    log::warn!(
3732                        target: "nemo_relay.observability",
3733                        event = "storage_access_failed",
3734                        plugin_kind = "observability",
3735                        exporter = "atif",
3736                        resource_index = self.index,
3737                        resource_kind = self.resource_kind,
3738                        permission = "write",
3739                        reason = failure_reason;
3740                        "ATIF storage access failed"
3741                    );
3742                }
3743            }
3744        }
3745        result
3746    }
3747}
3748
3749#[cfg(feature = "object-store")]
3750impl HttpUploadConfig {
3751    fn resolve(index: usize, http: &HttpStorageConfig) -> std::io::Result<Self> {
3752        let endpoint = http.endpoint.trim();
3753        if endpoint.is_empty() || endpoint != http.endpoint {
3754            return Err(std::io::Error::other(format!(
3755                "ATIF storage[{index}].endpoint must be non-empty and must not have surrounding whitespace"
3756            )));
3757        }
3758        let parsed = reqwest::Url::parse(endpoint).map_err(|err| {
3759            std::io::Error::other(format!(
3760                "ATIF storage[{index}].endpoint must be a valid URL: {err}"
3761            ))
3762        })?;
3763        if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
3764            return Err(std::io::Error::other(format!(
3765                "ATIF storage[{index}].endpoint must be a valid http:// or https:// URL"
3766            )));
3767        }
3768        if http.timeout_millis == 0 {
3769            return Err(std::io::Error::other(format!(
3770                "ATIF storage[{index}].timeout_millis must be positive"
3771            )));
3772        }
3773
3774        let mut headers = http.headers.clone();
3775        for (header, var_name) in &http.header_env {
3776            let value = resolve_env_var_field(
3777                &format!("storage[{index}].header_env.{header}"),
3778                Some(var_name.as_str()),
3779            )?
3780            .expect("resolve_env_var_field returns Some when var_name is Some");
3781            headers.insert(header.clone(), value);
3782        }
3783        validate_http_headers(index, &headers)?;
3784
3785        Ok(Self {
3786            endpoint: parsed.to_string(),
3787            headers,
3788            timeout: Duration::from_millis(http.timeout_millis),
3789        })
3790    }
3791}
3792
3793#[cfg(feature = "object-store")]
3794fn validate_http_headers(index: usize, headers: &HashMap<String, String>) -> std::io::Result<()> {
3795    for (header, value) in headers {
3796        reqwest::header::HeaderName::from_bytes(header.as_bytes()).map_err(|err| {
3797            std::io::Error::other(format!(
3798                "ATIF storage[{index}] header name '{header}' is invalid: {err}"
3799            ))
3800        })?;
3801        reqwest::header::HeaderValue::from_str(value).map_err(|err| {
3802            std::io::Error::other(format!(
3803                "ATIF storage[{index}] value for header '{header}' is invalid: {err}"
3804            ))
3805        })?;
3806    }
3807    Ok(())
3808}
3809
3810#[cfg(feature = "object-store")]
3811async fn post_atif_http(
3812    client: &reqwest::Client,
3813    config: &HttpUploadConfig,
3814    filename: String,
3815    session_id: String,
3816    payload: Vec<u8>,
3817) -> std::io::Result<()> {
3818    let mut request = client.post(&config.endpoint);
3819    for (header, value) in &config.headers {
3820        request = request.header(header.as_str(), value.as_str());
3821    }
3822    let response = request
3823        .header(reqwest::header::CONTENT_TYPE, "application/json")
3824        .header("x-nemo-relay-atif-filename", filename.clone())
3825        .header("x-nemo-relay-atif-session-id", session_id)
3826        .body(payload)
3827        .send()
3828        .await
3829        .map_err(|err| {
3830            std::io::Error::other(format!(
3831                "HTTP ATIF upload to '{}' failed: {err}",
3832                config.endpoint
3833            ))
3834        })?;
3835    if response.status().is_success() {
3836        Ok(())
3837    } else {
3838        Err(std::io::Error::other(format!(
3839            "HTTP ATIF upload to '{}' for '{}' failed with status {}",
3840            config.endpoint,
3841            filename,
3842            response.status()
3843        )))
3844    }
3845}
3846
3847#[cfg(feature = "object-store")]
3848fn normalize_storage_key_prefix(raw: Option<&str>) -> String {
3849    let trimmed = raw.unwrap_or("").trim();
3850    if trimmed.is_empty() {
3851        return String::new();
3852    }
3853    if trimmed.ends_with('/') {
3854        trimmed.to_string()
3855    } else {
3856        format!("{trimmed}/")
3857    }
3858}
3859
3860#[cfg(test)]
3861#[path = "../../tests/unit/observability/plugin_component_tests.rs"]
3862mod tests;