Skip to main content

kaizen/telemetry/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Optional pluggable sinks that receive the same redacted [`IngestExportBatch`] as Kaizen sync.
3//! Fan-out runs in parallel with the primary `POST` (see `sync::engine`); outbox is committed only
4//! when the primary succeeds (and, when `fail_open` is `false`, when the fan-out completes `Ok`).
5
6mod batch_metadata;
7mod file;
8mod resolve;
9
10#[cfg(feature = "telemetry-datadog")]
11pub mod datadog;
12#[cfg(feature = "telemetry-dev")]
13mod dev;
14#[cfg(feature = "telemetry-otlp")]
15mod otlp;
16#[cfg(feature = "telemetry-posthog")]
17mod posthog;
18
19use crate::core::config::{ExporterConfig, TelemetryConfig};
20use crate::sync::IngestExportBatch;
21use anyhow::Result;
22use std::path::Path;
23use std::sync::Arc;
24
25pub use batch_metadata::telemetry_file_line;
26pub use file::{FileExporter, default_ndjson_path, resolve_file_exporter_path};
27
28pub use resolve::DatadogResolved;
29pub use resolve::OtlpResolved;
30pub use resolve::PostHogResolved;
31
32/// Third-party and OTel sinks use the same batch types as the HTTP ingest.
33pub trait TelemetryExporter: Send + Sync {
34    fn name(&self) -> &str;
35    fn export(&self, batch: &IngestExportBatch) -> Result<()>;
36}
37
38/// Built from `TelemetryConfig` via [`load_exporters`]. Empty is a no-op.
39pub struct ExporterRegistry {
40    exporters: Vec<Arc<dyn TelemetryExporter>>,
41}
42
43impl ExporterRegistry {
44    pub fn empty() -> Self {
45        Self {
46            exporters: Vec::new(),
47        }
48    }
49
50    pub fn is_empty(&self) -> bool {
51        self.exporters.is_empty()
52    }
53
54    pub fn from_vec(exporters: Vec<Arc<dyn TelemetryExporter>>) -> Self {
55        Self { exporters }
56    }
57
58    /// When `fail_open` is `true`, log each exporter error and continue. If `false`, return the first error.
59    pub fn fan_out(&self, fail_open: bool, batch: &IngestExportBatch) -> Result<()> {
60        for e in &self.exporters {
61            let r = e.export(batch);
62            if let Err(err) = r {
63                tracing::warn!(exporter = e.name(), error = %err, "telemetry exporter");
64                if !fail_open {
65                    return Err(err);
66                }
67            }
68        }
69        Ok(())
70    }
71
72    /// Per-exporter names in registration order. Used by `kaizen telemetry test` for per-sink reporting.
73    pub fn exporter_names(&self) -> Vec<String> {
74        self.exporters
75            .iter()
76            .map(|e| e.name().to_string())
77            .collect()
78    }
79
80    /// Send `batch` to a single named exporter (first match). `Err` if no exporter has this name.
81    pub fn export_one(&self, name: &str, batch: &IngestExportBatch) -> Result<()> {
82        let exp = self
83            .exporters
84            .iter()
85            .find(|e| e.name() == name)
86            .ok_or_else(|| anyhow::anyhow!("no exporter named `{name}`"))?;
87        exp.export(batch)
88    }
89}
90
91/// Build exporters from TOML + environment. Missing creds for a sink log a warning and skip it.
92/// `workspace` resolves relative `file` paths (see [`resolve_file_exporter_path`]).
93pub fn load_exporters(cfg: &TelemetryConfig, workspace: &Path) -> ExporterRegistry {
94    let mut v: Vec<Arc<dyn TelemetryExporter>> = Vec::new();
95    for entry in &cfg.exporters {
96        if let Some(exp) = build_exporter(entry, workspace) {
97            v.push(exp);
98        }
99    }
100    ExporterRegistry::from_vec(v)
101}
102
103fn build_exporter(c: &ExporterConfig, workspace: &Path) -> Option<Arc<dyn TelemetryExporter>> {
104    if !c.is_enabled() {
105        return None;
106    }
107    match c {
108        ExporterConfig::None => None,
109        ExporterConfig::File { path, .. } => {
110            let p = file::resolve_file_exporter_path(path.as_deref(), workspace);
111            Some(Arc::new(file::FileExporter::new(p)) as _)
112        }
113        ExporterConfig::Dev { .. } => {
114            #[cfg(feature = "telemetry-dev")]
115            {
116                Some(Arc::new(dev::DevExporter) as _)
117            }
118            #[cfg(not(feature = "telemetry-dev"))]
119            {
120                tracing::warn!(
121                    "telemetry `dev` exporter configured but `telemetry-dev` is not enabled"
122                );
123                None
124            }
125        }
126        ExporterConfig::PostHog { .. } => {
127            let r = PostHogResolved::from_config(c)?;
128            #[cfg(feature = "telemetry-posthog")]
129            {
130                Some(Arc::new(posthog::PostHogExporter::new(&r.host, &r.project_api_key)) as _)
131            }
132            #[cfg(not(feature = "telemetry-posthog"))]
133            {
134                let _ = &r;
135                tracing::warn!(
136                    "PostHog configured but the `telemetry-posthog` feature is not enabled"
137                );
138                None
139            }
140        }
141        ExporterConfig::Datadog { .. } => {
142            let r = DatadogResolved::from_config(c)?;
143            #[cfg(feature = "telemetry-datadog")]
144            {
145                Some(Arc::new(datadog::DatadogExporter::new(&r.site, &r.api_key)) as _)
146            }
147            #[cfg(not(feature = "telemetry-datadog"))]
148            {
149                let _ = &r;
150                tracing::warn!(
151                    "Datadog configured but the `telemetry-datadog` feature is not enabled"
152                );
153                None
154            }
155        }
156        ExporterConfig::Otlp { .. } => {
157            let r = OtlpResolved::from_config(c)?;
158            #[cfg(feature = "telemetry-otlp")]
159            {
160                Some(Arc::new(otlp::OtlpExporter::new(&r.endpoint)) as _)
161            }
162            #[cfg(not(feature = "telemetry-otlp"))]
163            {
164                let _ = &r;
165                tracing::warn!("OTLP configured but the `telemetry-otlp` feature is not enabled");
166                None
167            }
168        }
169    }
170}