Skip to main content

nemo_relay/observability/
atof.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent Trajectory Observability Format (ATOF) JSONL exporter support for NeMo
5//! Flow.
6//!
7//! The [`AtofExporter`] registers as an event subscriber and writes each
8//! canonical NeMo Relay Agent Trajectory Observability Format (ATOF) event as
9//! one JSON object per JSONL line.
10
11use std::collections::HashMap;
12use std::fs::{File, OpenOptions, create_dir_all};
13use std::io::{BufWriter, Write};
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex, mpsc as std_mpsc};
16use std::time::Duration;
17
18use chrono::Utc;
19#[cfg(feature = "atof-streaming")]
20use futures_util::{SinkExt, stream};
21use serde::{Deserialize, Serialize};
22use serde_json::Value as Json;
23#[cfg(feature = "atof-streaming")]
24use tokio_tungstenite::tungstenite::client::IntoClientRequest;
25
26use crate::api::event::Event;
27use crate::api::runtime::EventSubscriberFn;
28use crate::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber};
29use crate::error::FlowError;
30
31/// Result type for the ATOF JSONL exporter.
32pub type Result<T> = std::result::Result<T, AtofExporterError>;
33
34/// Errors produced while configuring or operating the ATOF JSONL exporter.
35#[derive(Debug, thiserror::Error)]
36pub enum AtofExporterError {
37    /// Failed to resolve the current working directory for default config.
38    #[error("failed to resolve current working directory: {0}")]
39    CurrentDirectory(std::io::Error),
40    /// Failed to open the output file.
41    #[error("failed to open ATOF output file {path:?}: {source}")]
42    OpenFile {
43        /// Output path that failed to open.
44        path: PathBuf,
45        /// Underlying I/O error.
46        source: std::io::Error,
47    },
48    /// Failed while flushing the output file.
49    #[error("failed to flush ATOF output file {path:?}: {source}")]
50    Flush {
51        /// Output path that failed to flush.
52        path: PathBuf,
53        /// Underlying I/O error.
54        source: std::io::Error,
55    },
56    /// The exporter recorded an earlier write or serialization error.
57    #[error("previous ATOF export failed for {path:?}: {message}")]
58    StoredFailure {
59        /// Output path associated with the failure.
60        path: PathBuf,
61        /// Stored failure message.
62        message: String,
63    },
64    /// A streaming endpoint configuration is invalid.
65    #[error("invalid ATOF streaming endpoint: {0}")]
66    InvalidEndpoint(String),
67    /// The internal exporter state lock was poisoned.
68    #[error("the ATOF exporter state lock was poisoned")]
69    LockPoisoned,
70    /// Runtime subscriber registration failed.
71    #[error(transparent)]
72    Runtime(#[from] FlowError),
73}
74
75/// File write behavior for [`AtofExporter`].
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum AtofExporterMode {
79    /// Append events to an existing file or create it if missing.
80    #[default]
81    Append,
82    /// Truncate an existing file when the exporter is created.
83    Overwrite,
84}
85
86impl AtofExporterMode {
87    /// Parse a string mode used by language bindings.
88    pub fn parse(value: &str) -> Option<Self> {
89        match value {
90            "append" => Some(Self::Append),
91            "overwrite" => Some(Self::Overwrite),
92            _ => None,
93        }
94    }
95
96    /// Return the stable string representation used by language bindings.
97    pub fn as_str(self) -> &'static str {
98        match self {
99            Self::Append => "append",
100            Self::Overwrite => "overwrite",
101        }
102    }
103}
104
105/// Streaming transport used by an ATOF endpoint.
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum AtofEndpointTransport {
109    /// POST each event as one JSONL record.
110    #[default]
111    HttpPost,
112    /// Send each event as one WebSocket JSON text message.
113    Websocket,
114    /// Stream events over one long-lived HTTP NDJSON upload.
115    Ndjson,
116}
117
118/// Field name transformation policy used before sending events to an endpoint.
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum AtofEndpointFieldNamePolicy {
122    /// Preserve canonical ATOF field names exactly.
123    #[default]
124    Preserve,
125    /// Replace dots in JSON object keys with underscores, recursively.
126    ReplaceDots,
127}
128
129impl AtofEndpointFieldNamePolicy {
130    /// Parse a string policy used by configuration and bindings.
131    pub fn parse(value: &str) -> Option<Self> {
132        match value {
133            "preserve" => Some(Self::Preserve),
134            "replace_dots" => Some(Self::ReplaceDots),
135            _ => None,
136        }
137    }
138
139    /// Return the stable string representation used by configuration and bindings.
140    pub fn as_str(self) -> &'static str {
141        match self {
142            Self::Preserve => "preserve",
143            Self::ReplaceDots => "replace_dots",
144        }
145    }
146}
147
148impl AtofEndpointTransport {
149    /// Parse a string transport used by configuration and bindings.
150    pub fn parse(value: &str) -> Option<Self> {
151        match value {
152            "http_post" => Some(Self::HttpPost),
153            "websocket" => Some(Self::Websocket),
154            "ndjson" => Some(Self::Ndjson),
155            _ => None,
156        }
157    }
158
159    /// Return the stable string representation used by configuration and bindings.
160    pub fn as_str(self) -> &'static str {
161        match self {
162            Self::HttpPost => "http_post",
163            Self::Websocket => "websocket",
164            Self::Ndjson => "ndjson",
165        }
166    }
167}
168
169/// Streaming sink for raw ATOF events.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct AtofStreamSinkConfig {
172    /// Endpoint URL.
173    pub url: String,
174    /// Endpoint transport.
175    #[serde(default)]
176    pub transport: AtofEndpointTransport,
177    /// Headers applied to endpoint requests or handshakes.
178    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
179    pub headers: HashMap<String, String>,
180    /// Header names mapped to environment variables containing their values.
181    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
182    pub header_env: HashMap<String, String>,
183    /// Per-endpoint timeout in milliseconds.
184    #[serde(default = "default_endpoint_timeout_millis")]
185    pub timeout_millis: u64,
186    /// Field name transformation policy applied before sending events.
187    #[serde(default)]
188    pub field_name_policy: AtofEndpointFieldNamePolicy,
189}
190
191impl AtofStreamSinkConfig {
192    /// Create a streaming endpoint with defaults.
193    pub fn new(url: impl Into<String>, transport: AtofEndpointTransport) -> Self {
194        Self {
195            url: url.into(),
196            transport,
197            headers: HashMap::new(),
198            header_env: HashMap::new(),
199            timeout_millis: default_endpoint_timeout_millis(),
200            field_name_policy: AtofEndpointFieldNamePolicy::Preserve,
201        }
202    }
203
204    /// Add a header to this endpoint config.
205    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
206        self.headers.insert(key.into(), value.into());
207        self
208    }
209
210    /// Add a header whose value is resolved from an environment variable at activation.
211    pub fn with_header_env(
212        mut self,
213        key: impl Into<String>,
214        environment_variable: impl Into<String>,
215    ) -> Self {
216        self.header_env
217            .insert(key.into(), environment_variable.into());
218        self
219    }
220
221    /// Override the endpoint timeout.
222    pub fn with_timeout_millis(mut self, timeout_millis: u64) -> Self {
223        self.timeout_millis = timeout_millis;
224        self
225    }
226
227    /// Override the endpoint field name policy.
228    pub fn with_field_name_policy(
229        mut self,
230        field_name_policy: AtofEndpointFieldNamePolicy,
231    ) -> Self {
232        self.field_name_policy = field_name_policy;
233        self
234    }
235}
236
237/// Backward-compatible name for an ATOF stream sink.
238pub type AtofEndpointConfig = AtofStreamSinkConfig;
239
240/// Filesystem sink for raw ATOF JSONL events.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct AtofFileSinkConfig {
243    /// Directory that contains the JSONL output file.
244    #[serde(default = "default_output_directory")]
245    pub output_directory: PathBuf,
246    /// Append or overwrite behavior used when opening the file.
247    #[serde(default)]
248    pub mode: AtofExporterMode,
249    /// Output filename.
250    #[serde(default = "default_filename")]
251    pub filename: String,
252}
253
254impl Default for AtofFileSinkConfig {
255    fn default() -> Self {
256        Self {
257            output_directory: default_output_directory(),
258            mode: AtofExporterMode::Append,
259            filename: default_filename(),
260        }
261    }
262}
263
264impl AtofFileSinkConfig {
265    /// Create a file sink with native defaults.
266    pub fn new() -> Self {
267        Self::default()
268    }
269
270    /// Return the full output path for this sink.
271    pub fn path(&self) -> PathBuf {
272        self.output_directory.join(&self.filename)
273    }
274}
275
276/// One destination for raw ATOF events.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(tag = "type", rename_all = "snake_case")]
279pub enum AtofSinkConfig {
280    /// Write canonical ATOF records to one JSONL file.
281    File(AtofFileSinkConfig),
282    /// Send canonical ATOF records to one remote stream.
283    Stream(AtofStreamSinkConfig),
284}
285
286/// Configuration for [`AtofExporter`].
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct AtofExporterConfig {
289    /// The one output sink owned by this exporter.
290    #[serde(flatten)]
291    pub sink: AtofSinkConfig,
292}
293
294impl Default for AtofExporterConfig {
295    fn default() -> Self {
296        Self {
297            sink: AtofSinkConfig::File(AtofFileSinkConfig::default()),
298        }
299    }
300}
301
302impl AtofExporterConfig {
303    /// Create a config with defaults.
304    pub fn new() -> Self {
305        Self::default()
306    }
307
308    /// Override the file sink output directory.
309    ///
310    /// This has no effect after selecting a stream sink with
311    /// [`Self::with_stream_sink`] or [`Self::with_endpoint`].
312    pub fn with_output_directory(mut self, output_directory: impl Into<PathBuf>) -> Self {
313        if let AtofSinkConfig::File(file) = &mut self.sink {
314            file.output_directory = output_directory.into();
315        }
316        self
317    }
318
319    /// Override the file sink output mode.
320    ///
321    /// This has no effect after selecting a stream sink with
322    /// [`Self::with_stream_sink`] or [`Self::with_endpoint`].
323    pub fn with_mode(mut self, mode: AtofExporterMode) -> Self {
324        if let AtofSinkConfig::File(file) = &mut self.sink {
325            file.mode = mode;
326        }
327        self
328    }
329
330    /// Override the file sink output filename.
331    ///
332    /// This has no effect after selecting a stream sink with
333    /// [`Self::with_stream_sink`] or [`Self::with_endpoint`].
334    pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
335        if let AtofSinkConfig::File(file) = &mut self.sink {
336            file.filename = filename.into();
337        }
338        self
339    }
340
341    /// Select one stream sink.
342    pub fn with_stream_sink(mut self, sink: AtofStreamSinkConfig) -> Self {
343        self.sink = AtofSinkConfig::Stream(sink);
344        self
345    }
346
347    /// Select one stream sink.
348    ///
349    /// This compatibility spelling replaces the configured file sink; use the
350    /// observability plugin's `sinks` array for file-and-stream fan-out.
351    pub fn with_endpoint(self, endpoint: AtofEndpointConfig) -> Self {
352        self.with_stream_sink(endpoint)
353    }
354
355    /// Return the full output path for this config.
356    pub fn path(&self) -> Option<PathBuf> {
357        match &self.sink {
358            AtofSinkConfig::File(file) => Some(file.path()),
359            AtofSinkConfig::Stream(_) => None,
360        }
361    }
362}
363
364struct AtofExporterState {
365    writer: Option<BufWriter<File>>,
366    last_error: Option<String>,
367    endpoints: Vec<AtofEndpointWorker>,
368    closed: bool,
369}
370
371/// Filesystem-backed Agent Trajectory Observability Format (ATOF) JSONL event exporter.
372pub struct AtofExporter {
373    path: Option<PathBuf>,
374    state: Arc<Mutex<AtofExporterState>>,
375}
376
377impl AtofExporter {
378    /// Create a new exporter from config and open its output file.
379    pub fn new(config: AtofExporterConfig) -> Result<Self> {
380        let (path, writer, endpoints) = match config.sink {
381            AtofSinkConfig::File(file_sink) => {
382                let path = file_sink.path();
383                create_dir_all(&file_sink.output_directory).map_err(|source| {
384                    AtofExporterError::OpenFile {
385                        path: path.clone(),
386                        source,
387                    }
388                })?;
389                let file = open_file(&path, file_sink.mode)?;
390                log::info!(
391                    target: "nemo_relay.observability",
392                    event = "storage_access_validated",
393                    plugin_kind = "observability",
394                    exporter = "atof",
395                    resource_kind = "local_file",
396                    permission = "write";
397                    "ATOF storage access validated"
398                );
399                (Some(path), Some(BufWriter::new(file)), Vec::new())
400            }
401            AtofSinkConfig::Stream(stream_sink) => {
402                let workers = start_endpoint_workers(&[stream_sink])?;
403                (None, None, workers)
404            }
405        };
406        Ok(Self {
407            path,
408            state: Arc::new(Mutex::new(AtofExporterState {
409                writer,
410                last_error: None,
411                endpoints,
412                closed: false,
413            })),
414        })
415    }
416
417    /// Return the output JSONL path.
418    pub fn path(&self) -> Option<&Path> {
419        self.path.as_deref()
420    }
421
422    /// Return an event subscriber that writes one JSONL record per observed event.
423    pub fn subscriber(&self) -> EventSubscriberFn {
424        let state = Arc::clone(&self.state);
425        Arc::new(move |event: &Event| {
426            let Ok(mut state) = state.lock() else {
427                return;
428            };
429            if state.closed || state.last_error.is_some() {
430                return;
431            }
432            let Ok(value) = event.try_to_json_value() else {
433                state.last_error = Some("failed to serialize ATOF event".to_string());
434                return;
435            };
436            if let Some(writer) = &mut state.writer
437                && let Err(error) = write_json_value(writer, &value)
438            {
439                state.last_error = Some(error);
440                return;
441            }
442            let Ok(raw_json) = serde_json::to_string(&value) else {
443                state.last_error = Some("failed to serialize ATOF event".to_string());
444                return;
445            };
446            for endpoint in &state.endpoints {
447                endpoint.enqueue(raw_json.clone());
448            }
449        })
450    }
451
452    /// Register this exporter globally under the given subscriber name.
453    pub fn register(&self, name: &str) -> Result<()> {
454        register_subscriber(name, self.subscriber()).map_err(AtofExporterError::from)?;
455        log::info!(
456            target: "nemo_relay.observability",
457            event = "exporter_registered",
458            exporter = "atof",
459            subscriber = name;
460            "ATOF exporter registered"
461        );
462        Ok(())
463    }
464
465    /// Deregister a global subscriber by name.
466    pub fn deregister(&self, name: &str) -> Result<bool> {
467        let removed = deregister_subscriber(name).map_err(AtofExporterError::from)?;
468        if removed {
469            log::info!(
470                target: "nemo_relay.observability",
471                event = "exporter_deregistered",
472                exporter = "atof",
473                subscriber = name;
474                "ATOF exporter deregistered"
475            );
476        }
477        Ok(removed)
478    }
479
480    /// Outside a native subscriber callback, wait for queued subscriber delivery, then flush the
481    /// configured file sink or ask the configured stream sink to drain for up to its timeout.
482    ///
483    /// A re-entrant call does not establish the delivery barrier. A stream timeout is logged and
484    /// does not by itself return an error.
485    pub fn force_flush(&self) -> Result<()> {
486        flush_subscribers()?;
487        let mut state = self
488            .state
489            .lock()
490            .map_err(|_| AtofExporterError::LockPoisoned)?;
491        if state.closed {
492            return stored_failure_result(
493                self.path
494                    .as_deref()
495                    .unwrap_or_else(|| Path::new("<stream>")),
496                &state,
497            );
498        }
499        state
500            .writer
501            .as_mut()
502            .map(|writer| writer.flush())
503            .transpose()
504            .map_err(|source| AtofExporterError::Flush {
505                path: self
506                    .path
507                    .clone()
508                    .unwrap_or_else(|| PathBuf::from("<stream>")),
509                source,
510            })?;
511        for endpoint in &state.endpoints {
512            endpoint.flush();
513        }
514        stored_failure_result(
515            self.path
516                .as_deref()
517                .unwrap_or_else(|| Path::new("<stream>")),
518            &state,
519        )
520    }
521
522    /// Outside a native subscriber callback, wait for queued subscriber delivery, then flush the
523    /// configured file sink or ask the configured stream sink to drain and close up to its timeout.
524    ///
525    /// A re-entrant call does not establish the delivery barrier. A stream timeout is logged and
526    /// does not by itself return an error.
527    pub fn shutdown(&self) -> Result<()> {
528        flush_subscribers()?;
529        let mut state = self
530            .state
531            .lock()
532            .map_err(|_| AtofExporterError::LockPoisoned)?;
533        if state.closed {
534            return stored_failure_result(
535                self.path
536                    .as_deref()
537                    .unwrap_or_else(|| Path::new("<stream>")),
538                &state,
539            );
540        }
541        state.closed = true;
542        let flush_result = state
543            .writer
544            .as_mut()
545            .map(|writer| writer.flush())
546            .transpose()
547            .map_err(|source| AtofExporterError::Flush {
548                path: self
549                    .path
550                    .clone()
551                    .unwrap_or_else(|| PathBuf::from("<stream>")),
552                source,
553            });
554        for endpoint in &state.endpoints {
555            endpoint.close();
556        }
557        flush_result?;
558        let result = stored_failure_result(
559            self.path
560                .as_deref()
561                .unwrap_or_else(|| Path::new("<stream>")),
562            &state,
563        );
564        if result.is_ok() {
565            log::info!(
566                target: "nemo_relay.observability",
567                event = "exporter_shutdown",
568                exporter = "atof";
569                "ATOF exporter shut down"
570            );
571        }
572        result
573    }
574}
575
576fn default_filename() -> String {
577    format!(
578        "nemo-relay-events-{}.jsonl",
579        Utc::now().format("%Y-%m-%d-%H.%M.%S")
580    )
581}
582
583fn default_output_directory() -> PathBuf {
584    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
585}
586
587fn default_endpoint_timeout_millis() -> u64 {
588    3_000
589}
590
591fn open_file(path: &Path, mode: AtofExporterMode) -> Result<File> {
592    let mut options = OpenOptions::new();
593    options.create(true);
594    match mode {
595        AtofExporterMode::Append => {
596            options.append(true);
597        }
598        AtofExporterMode::Overwrite => {
599            options.write(true).truncate(true);
600        }
601    }
602    options
603        .open(path)
604        .map_err(|source| AtofExporterError::OpenFile {
605            path: path.to_path_buf(),
606            source,
607        })
608}
609
610fn write_json_value(writer: &mut BufWriter<File>, value: &Json) -> std::result::Result<(), String> {
611    serde_json::to_writer(&mut *writer, value).map_err(|error| error.to_string())?;
612    writer.write_all(b"\n").map_err(|error| error.to_string())?;
613    writer.flush().map_err(|error| error.to_string())
614}
615
616fn stored_failure_result(path: &Path, state: &AtofExporterState) -> Result<()> {
617    if let Some(message) = &state.last_error {
618        return Err(AtofExporterError::StoredFailure {
619            path: path.to_path_buf(),
620            message: message.clone(),
621        });
622    }
623    Ok(())
624}
625
626#[cfg_attr(not(feature = "atof-streaming"), allow(dead_code))]
627enum EndpointMessage {
628    Event(String),
629    Flush(std_mpsc::Sender<()>),
630    Close(std_mpsc::Sender<()>),
631}
632
633#[cfg(feature = "atof-streaming")]
634enum NdjsonBodyMessage {
635    Event(Vec<u8>),
636    Flush(std_mpsc::Sender<()>),
637}
638
639#[cfg(feature = "atof-streaming")]
640impl NdjsonBodyMessage {
641    fn acknowledge_if_flush(self) {
642        if let Self::Flush(done) = self {
643            let _ = done.send(());
644        }
645    }
646}
647
648struct AtofEndpointWorker {
649    sender: tokio::sync::mpsc::UnboundedSender<EndpointMessage>,
650    timeout: Duration,
651    index: usize,
652    transport: AtofEndpointTransport,
653}
654
655/// Validated endpoint settings with headers resolved at activation.
656#[cfg(feature = "atof-streaming")]
657#[derive(Debug)]
658struct ActivatedAtofEndpoint {
659    config: AtofEndpointConfig,
660    headers: reqwest::header::HeaderMap,
661}
662
663impl AtofEndpointWorker {
664    fn enqueue(&self, raw_json: String) {
665        let _ = self.sender.send(EndpointMessage::Event(raw_json));
666    }
667
668    fn flush(&self) {
669        let (tx, rx) = std_mpsc::channel();
670        if self.sender.send(EndpointMessage::Flush(tx)).is_ok()
671            && rx.recv_timeout(self.timeout).is_err()
672        {
673            log::warn!(
674                target: "nemo_relay.observability",
675                event = "endpoint_flush_failed",
676                exporter = "atof",
677                endpoint_index = self.index,
678                transport = self.transport.as_str(),
679                reason = "timeout";
680                "ATOF endpoint flush timed out"
681            );
682        }
683    }
684
685    fn close(&self) {
686        let (tx, rx) = std_mpsc::channel();
687        if self.sender.send(EndpointMessage::Close(tx)).is_ok()
688            && rx.recv_timeout(self.timeout).is_err()
689        {
690            log::warn!(
691                target: "nemo_relay.observability",
692                event = "endpoint_close_failed",
693                exporter = "atof",
694                endpoint_index = self.index,
695                transport = self.transport.as_str(),
696                reason = "timeout";
697                "ATOF endpoint close timed out"
698            );
699        }
700    }
701}
702
703#[cfg(feature = "atof-streaming")]
704fn start_endpoint_workers(configs: &[AtofStreamSinkConfig]) -> Result<Vec<AtofEndpointWorker>> {
705    let mut workers = Vec::with_capacity(configs.len());
706    for (index, config) in configs.iter().enumerate() {
707        match start_endpoint_worker(index, config.clone()) {
708            Ok(worker) => workers.push(worker),
709            Err(error) => {
710                return Err(AtofExporterError::InvalidEndpoint(format!(
711                    "endpoints[{index}]: {error}"
712                )));
713            }
714        }
715    }
716    Ok(workers)
717}
718
719#[cfg(not(feature = "atof-streaming"))]
720fn start_endpoint_workers(configs: &[AtofEndpointConfig]) -> Result<Vec<AtofEndpointWorker>> {
721    if configs.is_empty() {
722        return Ok(Vec::new());
723    }
724    let message = "ATOF streaming endpoints are not supported in this build".to_string();
725    Err(AtofExporterError::InvalidEndpoint(message))
726}
727
728#[cfg(feature = "atof-streaming")]
729fn start_endpoint_worker(index: usize, config: AtofEndpointConfig) -> Result<AtofEndpointWorker> {
730    let endpoint = validate_endpoint_config(config)?;
731    let timeout = Duration::from_millis(endpoint.config.timeout_millis);
732    let transport = endpoint.config.transport;
733    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
734    std::thread::Builder::new()
735        .name(format!("nemo-relay-atof-endpoint-{index}"))
736        .spawn(move || run_endpoint_worker(index, endpoint, rx))
737        .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
738    log::info!(
739        target: "nemo_relay.observability",
740        event = "endpoint_started",
741        exporter = "atof",
742        endpoint_index = index,
743        transport = transport.as_str();
744        "ATOF endpoint worker started"
745    );
746    log::info!(
747        target: "nemo_relay.plugin",
748        event = "plugin_resource_access_pending",
749        plugin_kind = "observability",
750        resource_kind = "stream_endpoint",
751        resource_index = index,
752        permission = "write";
753        "Plugin resource access will be validated on first use"
754    );
755    Ok(AtofEndpointWorker {
756        sender: tx,
757        timeout,
758        index,
759        transport,
760    })
761}
762
763#[cfg(feature = "atof-streaming")]
764fn validate_endpoint_config(config: AtofEndpointConfig) -> Result<ActivatedAtofEndpoint> {
765    if config.url.trim().is_empty() {
766        return Err(AtofExporterError::InvalidEndpoint(
767            "endpoint url must be non-empty".to_string(),
768        ));
769    }
770    if config.timeout_millis == 0 {
771        return Err(AtofExporterError::InvalidEndpoint(
772            "endpoint timeout_millis must be greater than 0".to_string(),
773        ));
774    }
775    let url = reqwest::Url::parse(&config.url)
776        .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
777    let valid_scheme = match config.transport {
778        AtofEndpointTransport::HttpPost | AtofEndpointTransport::Ndjson => {
779            matches!(url.scheme(), "http" | "https")
780        }
781        AtofEndpointTransport::Websocket => matches!(url.scheme(), "ws" | "wss"),
782    };
783    if !valid_scheme {
784        return Err(AtofExporterError::InvalidEndpoint(format!(
785            "endpoint {} transport does not support URL scheme {:?}",
786            config.transport.as_str(),
787            url.scheme()
788        )));
789    }
790    let headers = resolved_header_map(&config.headers, &config.header_env)?;
791    Ok(ActivatedAtofEndpoint { config, headers })
792}
793
794#[cfg(feature = "atof-streaming")]
795fn resolved_header_map(
796    headers: &HashMap<String, String>,
797    header_env: &HashMap<String, String>,
798) -> Result<reqwest::header::HeaderMap> {
799    let mut out = reqwest::header::HeaderMap::new();
800    for (key, value) in headers {
801        let name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
802            .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
803        let value = reqwest::header::HeaderValue::from_str(value)
804            .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
805        out.insert(name, value);
806    }
807    for (key, variable) in header_env {
808        let name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
809            .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
810        if out.contains_key(&name) {
811            return Err(AtofExporterError::InvalidEndpoint(format!(
812                "header {key:?} cannot be configured in both headers and header_env"
813            )));
814        }
815        let value = std::env::var(variable).map_err(|_| {
816            AtofExporterError::InvalidEndpoint(format!(
817                "environment variable {variable:?} for header {key:?} is not set"
818            ))
819        })?;
820        if value.trim().is_empty() {
821            return Err(AtofExporterError::InvalidEndpoint(format!(
822                "environment variable {variable:?} for header {key:?} is blank"
823            )));
824        }
825        let value = reqwest::header::HeaderValue::from_str(&value)
826            .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
827        out.insert(name, value);
828    }
829    Ok(out)
830}
831
832#[cfg(feature = "atof-streaming")]
833fn run_endpoint_worker(
834    index: usize,
835    endpoint: ActivatedAtofEndpoint,
836    rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
837) {
838    let runtime = match tokio::runtime::Builder::new_current_thread()
839        .enable_all()
840        .build()
841    {
842        Ok(runtime) => runtime,
843        Err(_) => {
844            log::error!(
845                target: "nemo_relay.observability",
846                event = "endpoint_failed",
847                exporter = "atof",
848                endpoint_index = index,
849                reason = "runtime_initialization";
850                "ATOF endpoint runtime failed"
851            );
852            return;
853        }
854    };
855    runtime.block_on(async move {
856        match endpoint.config.transport {
857            AtofEndpointTransport::HttpPost => run_http_post_endpoint(index, endpoint, rx).await,
858            AtofEndpointTransport::Websocket => run_websocket_endpoint(index, endpoint, rx).await,
859            AtofEndpointTransport::Ndjson => run_ndjson_endpoint(index, endpoint, rx).await,
860        }
861    });
862}
863
864#[cfg(feature = "atof-streaming")]
865async fn run_http_post_endpoint(
866    index: usize,
867    endpoint: ActivatedAtofEndpoint,
868    mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
869) {
870    let client = match reqwest::Client::builder()
871        .timeout(Duration::from_millis(endpoint.config.timeout_millis))
872        .default_headers(endpoint.headers)
873        .build()
874    {
875        Ok(client) => client,
876        Err(_) => {
877            log::error!(
878                target: "nemo_relay.observability",
879                event = "endpoint_disabled",
880                exporter = "atof",
881                endpoint_index = index,
882                transport = "http_post",
883                reason = "client_initialization";
884                "ATOF endpoint disabled"
885            );
886            drain_closed(rx).await;
887            return;
888        }
889    };
890    let mut access_validated = false;
891    while let Some(message) = rx.recv().await {
892        match message {
893            EndpointMessage::Event(raw_json) => {
894                let body = format!("{}\n", endpoint_event_json(&endpoint.config, raw_json));
895                let result = client
896                    .post(&endpoint.config.url)
897                    .header(reqwest::header::CONTENT_TYPE, "application/x-ndjson")
898                    .body(body)
899                    .send()
900                    .await;
901                match result {
902                    Ok(response) if response.status().is_success() => {
903                        if !access_validated {
904                            log::info!(
905                                target: "nemo_relay.observability",
906                                event = "endpoint_access_validated",
907                                plugin_kind = "observability",
908                                exporter = "atof",
909                                endpoint_index = index,
910                                transport = "http_post",
911                                permission = "write";
912                                "ATOF endpoint access validated"
913                            );
914                            access_validated = true;
915                        }
916                    }
917                    Ok(response) => log_http_error(index, "http_post", response),
918                    Err(_) => log::warn!(
919                        target: "nemo_relay.observability",
920                        event = "endpoint_delivery_failed",
921                        exporter = "atof",
922                        endpoint_index = index,
923                        transport = "http_post",
924                        reason = "request_failed";
925                        "ATOF endpoint delivery failed"
926                    ),
927                }
928            }
929            EndpointMessage::Flush(done) => {
930                let _ = done.send(());
931            }
932            EndpointMessage::Close(done) => {
933                let _ = done.send(());
934                return;
935            }
936        }
937    }
938}
939
940#[cfg(feature = "atof-streaming")]
941async fn run_websocket_endpoint(
942    index: usize,
943    endpoint: ActivatedAtofEndpoint,
944    mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
945) {
946    let mut pending = std::collections::VecDeque::new();
947    let mut retry = WebSocketRetryState::default();
948    let mut socket = match connect_websocket(&endpoint).await {
949        Ok(socket) => {
950            retry.record_recovered(index);
951            Some(socket)
952        }
953        Err(_) => {
954            retry.record_failure(index);
955            None
956        }
957    };
958    while let Some(message) = rx.recv().await {
959        match message {
960            EndpointMessage::Event(raw_json) => {
961                pending.push_back(endpoint_event_json(&endpoint.config, raw_json));
962                let _ = drain_websocket_pending(
963                    index,
964                    &endpoint,
965                    &mut socket,
966                    &mut pending,
967                    &mut retry,
968                )
969                .await;
970            }
971            EndpointMessage::Flush(done) => {
972                let _ = drain_websocket_pending(
973                    index,
974                    &endpoint,
975                    &mut socket,
976                    &mut pending,
977                    &mut retry,
978                )
979                .await;
980                let _ = done.send(());
981            }
982            EndpointMessage::Close(done) => {
983                let _ = drain_websocket_pending(
984                    index,
985                    &endpoint,
986                    &mut socket,
987                    &mut pending,
988                    &mut retry,
989                )
990                .await;
991                if let Some(mut ws) = socket.take() {
992                    let _ = ws.close(None).await;
993                }
994                let _ = done.send(());
995                return;
996            }
997        }
998    }
999}
1000
1001#[cfg(feature = "atof-streaming")]
1002type AtofWebSocket =
1003    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
1004
1005#[cfg(feature = "atof-streaming")]
1006#[derive(Default)]
1007struct WebSocketRetryState {
1008    attempts: u64,
1009    warning_emitted: bool,
1010    access_validated: bool,
1011}
1012
1013#[cfg(feature = "atof-streaming")]
1014impl WebSocketRetryState {
1015    fn record_failure(&mut self, index: usize) {
1016        self.attempts = self.attempts.saturating_add(1);
1017        if !self.warning_emitted {
1018            log::warn!(
1019                target: "nemo_relay.observability",
1020                event = "endpoint_reconnecting",
1021                exporter = "atof",
1022                endpoint_index = index,
1023                transport = "websocket";
1024                "ATOF endpoint connection failed; reconnecting"
1025            );
1026            self.warning_emitted = true;
1027        }
1028    }
1029
1030    fn record_recovered(&mut self, index: usize) {
1031        if self.attempts > 0 {
1032            log::info!(
1033                target: "nemo_relay.observability",
1034                event = "endpoint_reconnected",
1035                exporter = "atof",
1036                endpoint_index = index,
1037                transport = "websocket",
1038                attempt_count = self.attempts + 1;
1039                "ATOF endpoint reconnected"
1040            );
1041        }
1042        if !self.access_validated {
1043            log::info!(
1044                target: "nemo_relay.observability",
1045                event = "endpoint_access_validated",
1046                plugin_kind = "observability",
1047                exporter = "atof",
1048                endpoint_index = index,
1049                transport = "websocket",
1050                permission = "connect";
1051                "ATOF endpoint access validated"
1052            );
1053            self.access_validated = true;
1054        }
1055        self.attempts = 0;
1056        self.warning_emitted = false;
1057    }
1058}
1059
1060#[cfg(feature = "atof-streaming")]
1061async fn drain_websocket_pending(
1062    index: usize,
1063    endpoint: &ActivatedAtofEndpoint,
1064    socket: &mut Option<AtofWebSocket>,
1065    pending: &mut std::collections::VecDeque<String>,
1066    retry: &mut WebSocketRetryState,
1067) -> bool {
1068    let timeout = Duration::from_millis(endpoint.config.timeout_millis);
1069    let attempts_before = retry.attempts;
1070    match tokio::time::timeout(
1071        timeout,
1072        drain_websocket_pending_inner(index, endpoint, socket, pending, retry),
1073    )
1074    .await
1075    {
1076        Ok(drained) => drained,
1077        Err(_) => {
1078            if retry.attempts == attempts_before {
1079                retry.record_failure(index);
1080            }
1081            false
1082        }
1083    }
1084}
1085
1086#[cfg(feature = "atof-streaming")]
1087async fn drain_websocket_pending_inner(
1088    index: usize,
1089    endpoint: &ActivatedAtofEndpoint,
1090    socket: &mut Option<AtofWebSocket>,
1091    pending: &mut std::collections::VecDeque<String>,
1092    retry: &mut WebSocketRetryState,
1093) -> bool {
1094    while let Some(raw_json) = pending.front().cloned() {
1095        if socket.is_none() {
1096            match connect_websocket(endpoint).await {
1097                Ok(ws) => {
1098                    *socket = Some(ws);
1099                    retry.record_recovered(index);
1100                }
1101                Err(_) => {
1102                    retry.record_failure(index);
1103                    tokio::time::sleep(Duration::from_millis(50)).await;
1104                    continue;
1105                }
1106            }
1107        }
1108
1109        let Some(ws) = socket.as_mut() else {
1110            continue;
1111        };
1112        match ws
1113            .send(tokio_tungstenite::tungstenite::Message::Text(
1114                raw_json.into(),
1115            ))
1116            .await
1117        {
1118            Ok(()) => {
1119                pending.pop_front();
1120            }
1121            Err(_) => {
1122                retry.record_failure(index);
1123                *socket = None;
1124                tokio::time::sleep(Duration::from_millis(50)).await;
1125            }
1126        }
1127    }
1128    true
1129}
1130
1131#[cfg(feature = "atof-streaming")]
1132async fn connect_websocket(
1133    endpoint: &ActivatedAtofEndpoint,
1134) -> std::result::Result<AtofWebSocket, String> {
1135    let mut request = endpoint
1136        .config
1137        .url
1138        .as_str()
1139        .into_client_request()
1140        .map_err(|error| error.to_string())?;
1141    for (name, value) in endpoint.headers.clone() {
1142        let Some(name) = name else {
1143            continue;
1144        };
1145        request.headers_mut().insert(name, value);
1146    }
1147    tokio::time::timeout(
1148        Duration::from_millis(endpoint.config.timeout_millis),
1149        tokio_tungstenite::connect_async(request),
1150    )
1151    .await
1152    .map_err(|error| error.to_string())?
1153    .map(|(socket, _)| socket)
1154    .map_err(|error| error.to_string())
1155}
1156
1157#[cfg(feature = "atof-streaming")]
1158async fn run_ndjson_endpoint(
1159    index: usize,
1160    endpoint: ActivatedAtofEndpoint,
1161    mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
1162) {
1163    let client = match build_ndjson_client(&endpoint) {
1164        Ok(client) => client,
1165        Err(_) => {
1166            log::error!(
1167                target: "nemo_relay.observability",
1168                event = "endpoint_disabled",
1169                exporter = "atof",
1170                endpoint_index = index,
1171                transport = "ndjson",
1172                reason = "client_initialization";
1173                "ATOF endpoint disabled"
1174            );
1175            drain_closed(rx).await;
1176            return;
1177        }
1178    };
1179
1180    let (body_tx, body) = ndjson_body_channel();
1181    let url = endpoint.config.url.clone();
1182    let request = tokio::spawn(async move {
1183        client
1184            .post(url)
1185            .header(reqwest::header::CONTENT_TYPE, "application/x-ndjson")
1186            .body(body)
1187            .send()
1188            .await
1189    });
1190    let close_timeout = Duration::from_millis(endpoint.config.timeout_millis);
1191
1192    while let Some(message) = rx.recv().await {
1193        match message {
1194            EndpointMessage::Event(raw_json) => send_ndjson_event(
1195                index,
1196                &body_tx,
1197                endpoint_event_json(&endpoint.config, raw_json),
1198            ),
1199            EndpointMessage::Flush(done) => send_ndjson_flush(index, &body_tx, done),
1200            EndpointMessage::Close(done) => {
1201                drop(body_tx);
1202                finish_ndjson_upload(index, request, close_timeout, done).await;
1203                return;
1204            }
1205        }
1206    }
1207}
1208
1209#[cfg(feature = "atof-streaming")]
1210fn build_ndjson_client(
1211    endpoint: &ActivatedAtofEndpoint,
1212) -> std::result::Result<reqwest::Client, String> {
1213    reqwest::Client::builder()
1214        .connect_timeout(Duration::from_millis(endpoint.config.timeout_millis))
1215        .default_headers(endpoint.headers.clone())
1216        .build()
1217        .map_err(|error| format!("client build failed: {error}"))
1218}
1219
1220#[cfg(feature = "atof-streaming")]
1221fn ndjson_body_channel() -> (
1222    tokio::sync::mpsc::UnboundedSender<NdjsonBodyMessage>,
1223    reqwest::Body,
1224) {
1225    let (body_tx, body_rx) = tokio::sync::mpsc::unbounded_channel::<NdjsonBodyMessage>();
1226    let body_stream = stream::unfold(body_rx, |mut body_rx| async {
1227        loop {
1228            match body_rx.recv().await? {
1229                NdjsonBodyMessage::Event(bytes) => {
1230                    return Some((Ok::<_, std::io::Error>(bytes), body_rx));
1231                }
1232                NdjsonBodyMessage::Flush(done) => {
1233                    let _ = done.send(());
1234                }
1235            }
1236        }
1237    });
1238    (body_tx, reqwest::Body::wrap_stream(body_stream))
1239}
1240
1241#[cfg(feature = "atof-streaming")]
1242fn send_ndjson_event(
1243    index: usize,
1244    body_tx: &tokio::sync::mpsc::UnboundedSender<NdjsonBodyMessage>,
1245    raw_json: String,
1246) {
1247    if body_tx
1248        .send(NdjsonBodyMessage::Event(
1249            format!("{raw_json}\n").into_bytes(),
1250        ))
1251        .is_err()
1252    {
1253        log::warn!(
1254            target: "nemo_relay.observability",
1255            event = "endpoint_delivery_failed",
1256            exporter = "atof",
1257            endpoint_index = index,
1258            transport = "ndjson",
1259            reason = "body_channel_closed";
1260            "ATOF endpoint delivery failed"
1261        );
1262    }
1263}
1264
1265#[cfg(feature = "atof-streaming")]
1266fn send_ndjson_flush(
1267    index: usize,
1268    body_tx: &tokio::sync::mpsc::UnboundedSender<NdjsonBodyMessage>,
1269    done: std_mpsc::Sender<()>,
1270) {
1271    if let Err(error) = body_tx.send(NdjsonBodyMessage::Flush(done)) {
1272        log::warn!(
1273            target: "nemo_relay.observability",
1274            event = "endpoint_flush_failed",
1275            exporter = "atof",
1276            endpoint_index = index,
1277            transport = "ndjson",
1278            reason = "body_channel_closed";
1279            "ATOF endpoint flush failed"
1280        );
1281        error.0.acknowledge_if_flush();
1282    }
1283}
1284
1285#[cfg(feature = "atof-streaming")]
1286async fn finish_ndjson_upload(
1287    index: usize,
1288    request: tokio::task::JoinHandle<reqwest::Result<reqwest::Response>>,
1289    close_timeout: Duration,
1290    done: std_mpsc::Sender<()>,
1291) {
1292    match tokio::time::timeout(close_timeout, request).await {
1293        Ok(Ok(Ok(response))) if response.status().is_success() => log::info!(
1294            target: "nemo_relay.observability",
1295            event = "endpoint_access_validated",
1296            plugin_kind = "observability",
1297            exporter = "atof",
1298            endpoint_index = index,
1299            transport = "ndjson",
1300            permission = "write";
1301            "ATOF endpoint access validated"
1302        ),
1303        Ok(Ok(Ok(response))) => log_http_error(index, "ndjson", response),
1304        Ok(Ok(Err(_))) => log::warn!(
1305            target: "nemo_relay.observability",
1306            event = "endpoint_delivery_failed",
1307            exporter = "atof",
1308            endpoint_index = index,
1309            transport = "ndjson",
1310            reason = "upload_failed";
1311            "ATOF endpoint upload failed"
1312        ),
1313        Ok(Err(_)) => log::error!(
1314            target: "nemo_relay.observability",
1315            event = "endpoint_failed",
1316            exporter = "atof",
1317            endpoint_index = index,
1318            transport = "ndjson",
1319            reason = "task_failed";
1320            "ATOF endpoint task failed"
1321        ),
1322        Err(_) => log::warn!(
1323            target: "nemo_relay.observability",
1324            event = "endpoint_close_failed",
1325            exporter = "atof",
1326            endpoint_index = index,
1327            transport = "ndjson",
1328            reason = "timeout";
1329            "ATOF endpoint close timed out"
1330        ),
1331    }
1332    let _ = done.send(());
1333}
1334
1335#[cfg(feature = "atof-streaming")]
1336async fn drain_closed(mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>) {
1337    while let Some(message) = rx.recv().await {
1338        match message {
1339            EndpointMessage::Flush(done) => {
1340                let _ = done.send(());
1341            }
1342            EndpointMessage::Close(done) => {
1343                let _ = done.send(());
1344                return;
1345            }
1346            EndpointMessage::Event(_) => {}
1347        }
1348    }
1349}
1350
1351#[cfg(feature = "atof-streaming")]
1352fn endpoint_event_json(config: &AtofEndpointConfig, raw_json: String) -> String {
1353    match config.field_name_policy {
1354        AtofEndpointFieldNamePolicy::Preserve => raw_json,
1355        AtofEndpointFieldNamePolicy::ReplaceDots => replace_dotted_field_names(&raw_json),
1356    }
1357}
1358
1359#[cfg(feature = "atof-streaming")]
1360fn replace_dotted_field_names(raw_json: &str) -> String {
1361    let Ok(mut value) = serde_json::from_str::<Json>(raw_json) else {
1362        return raw_json.to_string();
1363    };
1364    replace_dotted_value_keys(&mut value);
1365    serde_json::to_string(&value).unwrap_or_else(|_| raw_json.to_string())
1366}
1367
1368#[cfg(feature = "atof-streaming")]
1369fn replace_dotted_value_keys(value: &mut Json) {
1370    match value {
1371        Json::Object(object) => replace_dotted_object_keys(object),
1372        Json::Array(items) => {
1373            for item in items {
1374                replace_dotted_value_keys(item);
1375            }
1376        }
1377        _ => {}
1378    }
1379}
1380
1381#[cfg(feature = "atof-streaming")]
1382fn replace_dotted_object_keys(object: &mut serde_json::Map<String, Json>) {
1383    let mut old = std::mem::take(object)
1384        .into_iter()
1385        .map(|(key, mut value)| {
1386            replace_dotted_value_keys(&mut value);
1387            (key, value)
1388        })
1389        .collect::<Vec<_>>();
1390    old.sort_by_key(|(key, _)| !key.contains('.'));
1391
1392    for (key, value) in old {
1393        let sanitized_key = key.replace('.', "_");
1394        let final_key = collision_free_key(object, sanitized_key);
1395        object.insert(final_key, value);
1396    }
1397}
1398
1399#[cfg(feature = "atof-streaming")]
1400fn collision_free_key(object: &serde_json::Map<String, Json>, key: String) -> String {
1401    if !object.contains_key(&key) {
1402        return key;
1403    }
1404    for suffix in 2.. {
1405        let candidate = format!("{key}_{suffix}");
1406        if !object.contains_key(&candidate) {
1407            return candidate;
1408        }
1409    }
1410    unreachable!("unbounded suffix search must find a key")
1411}
1412
1413#[cfg(feature = "atof-streaming")]
1414fn log_http_error(index: usize, transport: &str, response: reqwest::Response) {
1415    let status = response.status();
1416    log::warn!(
1417        target: "nemo_relay.observability",
1418        event = "endpoint_delivery_failed",
1419        exporter = "atof",
1420        endpoint_index = index,
1421        transport = transport,
1422        status = status.as_u16();
1423        "ATOF endpoint returned an unsuccessful status"
1424    );
1425}
1426
1427// ---------------------------------------------------------------------------
1428// Tests
1429// ---------------------------------------------------------------------------
1430
1431#[cfg(test)]
1432#[path = "../../tests/unit/observability/atof_tests.rs"]
1433mod tests;