shuttle_common/models/
telemetry.rs

1use std::borrow::Cow;
2
3use serde::{Deserialize, Serialize};
4
5const fn default_betterstack_host() -> Cow<'static, str> {
6    Cow::Borrowed("in-otel.logs.betterstack.com")
7}
8
9const fn default_logfire_host() -> Cow<'static, str> {
10    Cow::Borrowed("logfire-api.pydantic.dev")
11}
12
13/// Status of a telemetry export configuration for an external sink
14#[derive(Eq, Clone, Debug, PartialEq, Serialize, Deserialize)]
15#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
16#[typeshare::typeshare]
17pub struct TelemetrySinkStatus {
18    /// Indicates that the associated project is configured to export telemetry data to this sink
19    enabled: bool,
20}
21
22/// A safe-for-display representation of the current telemetry export configuration for a given project
23#[derive(Eq, Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
24#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
25#[typeshare::typeshare]
26pub struct TelemetryConfigResponse {
27    betterstack: Option<TelemetrySinkStatus>,
28    datadog: Option<TelemetrySinkStatus>,
29    grafana_cloud: Option<TelemetrySinkStatus>,
30    logfire: Option<TelemetrySinkStatus>,
31    generic: Option<TelemetrySinkStatus>,
32}
33
34impl From<Vec<TelemetrySinkConfig>> for TelemetryConfigResponse {
35    fn from(value: Vec<TelemetrySinkConfig>) -> Self {
36        let mut instance = Self::default();
37
38        for sink in value {
39            match sink {
40                TelemetrySinkConfig::Betterstack(_) => {
41                    instance.betterstack = Some(TelemetrySinkStatus { enabled: true })
42                }
43                TelemetrySinkConfig::Datadog(_) => {
44                    instance.datadog = Some(TelemetrySinkStatus { enabled: true })
45                }
46                TelemetrySinkConfig::GrafanaCloud(_) => {
47                    instance.grafana_cloud = Some(TelemetrySinkStatus { enabled: true })
48                }
49                TelemetrySinkConfig::Logfire(_) => {
50                    instance.logfire = Some(TelemetrySinkStatus { enabled: true })
51                }
52                TelemetrySinkConfig::GenericOtel(_) => {
53                    instance.generic = Some(TelemetrySinkStatus { enabled: true })
54                }
55                TelemetrySinkConfig::Debug(_) => {}
56            }
57        }
58
59        instance
60    }
61}
62
63/// The user-supplied config required to export telemetry to a given external sink
64#[derive(
65    // std
66    Eq,
67    Clone,
68    PartialEq,
69    // serde
70    Serialize,
71    Deserialize,
72    // strum
73    strum::AsRefStr,
74    strum::EnumDiscriminants,
75)]
76#[cfg_attr(feature = "integration-tests", derive(Debug))]
77#[cfg_attr(any(test, feature = "integration-tests"), derive(strum::EnumIter))]
78#[serde(tag = "type", content = "content", rename_all = "snake_case")]
79#[strum(serialize_all = "snake_case")]
80#[cfg_attr(
81    any(test, feature = "integration-tests"),
82    strum_discriminants(derive(strum::EnumIter))
83)]
84#[strum_discriminants(derive(Serialize, Deserialize, strum::AsRefStr))]
85#[strum_discriminants(serde(rename_all = "snake_case"))]
86#[strum_discriminants(strum(serialize_all = "snake_case"))]
87#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
88#[typeshare::typeshare]
89pub enum TelemetrySinkConfig {
90    /// [Betterstack](https://betterstack.com/docs/logs/open-telemetry/)
91    Betterstack(BetterstackConfig),
92
93    /// [Datadog](https://docs.datadoghq.com/opentelemetry/collector_exporter/otel_collector_datadog_exporter)
94    Datadog(DatadogConfig),
95
96    /// [Grafana Cloud](https://grafana.com/docs/grafana-cloud/send-data/otlp/)
97    GrafanaCloud(GrafanaCloudConfig),
98
99    /// [Logfire](https://logfire.pydantic.dev/docs/how-to-guides/alternative-clients/)
100    Logfire(LogfireConfig),
101
102    /// Generic config for Otel sinks that only need a host and Bearer token
103    GenericOtel(GenericOtelConfig),
104
105    /// Internal Debugging
106    #[doc(hidden)]
107    #[typeshare(skip)]
108    #[strum_discriminants(doc(hidden))]
109    Debug(serde_json::Value),
110    //
111    // No Unknown variant: is not deserialized in user facing libraries
112    // (this is what it would look like 💀):
113    //   #[cfg(feature = "unknown-variants")]
114    //   #[doc(hidden)]
115    //   #[typeshare(skip)]
116    //   #[serde(untagged, skip_serializing)]
117    //   #[strum(default, to_string = "Unknown: {0}")]
118    //   #[strum_discriminants(doc(hidden))]
119    //   #[strum_discriminants(serde(untagged, skip_serializing))]
120    //   #[strum_discriminants(strum(default, to_string = "Unknown: {0}"))]
121    //   Unknown(String),
122}
123
124impl TelemetrySinkConfig {
125    pub fn as_db_type(&self) -> String {
126        format!("project::telemetry::{}::config", self.as_ref())
127    }
128}
129
130impl TelemetrySinkConfigDiscriminants {
131    pub fn as_db_type(&self) -> String {
132        format!("project::telemetry::{}::config", self.as_ref())
133    }
134}
135
136#[derive(Eq, Clone, PartialEq, Serialize, Deserialize)]
137#[cfg_attr(feature = "integration-tests", derive(Debug))]
138#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
139#[typeshare::typeshare]
140pub struct BetterstackConfig {
141    #[serde(default = "default_betterstack_host")]
142    pub ingesting_host: Cow<'static, str>,
143    pub source_token: String,
144}
145
146#[cfg(any(test, feature = "integration-tests"))]
147impl Default for BetterstackConfig {
148    fn default() -> Self {
149        Self {
150            source_token: "some-source-token".into(),
151            ingesting_host: default_betterstack_host(),
152        }
153    }
154}
155
156#[derive(Eq, Clone, PartialEq, Serialize, Deserialize)]
157#[cfg_attr(feature = "integration-tests", derive(Debug))]
158#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
159#[typeshare::typeshare]
160pub struct DatadogConfig {
161    pub api_key: String,
162}
163
164#[cfg(any(test, feature = "integration-tests"))]
165impl Default for DatadogConfig {
166    fn default() -> Self {
167        Self {
168            api_key: "some-api-key".into(),
169        }
170    }
171}
172
173#[derive(Eq, Clone, PartialEq, Serialize, Deserialize)]
174#[cfg_attr(feature = "integration-tests", derive(Debug))]
175#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
176#[typeshare::typeshare]
177pub struct GrafanaCloudConfig {
178    pub token: String,
179    pub endpoint: String,
180    pub instance_id: String,
181}
182
183#[cfg(any(test, feature = "integration-tests"))]
184impl Default for GrafanaCloudConfig {
185    fn default() -> Self {
186        Self {
187            token: "some-auth-token".into(),
188            instance_id: String::from("0000000"),
189            endpoint: "https://prometheus-env-id-env-region.grafana.net/api/prom/push".into(),
190        }
191    }
192}
193
194#[derive(Eq, Clone, PartialEq, Serialize, Deserialize)]
195#[cfg_attr(feature = "integration-tests", derive(Debug))]
196#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
197#[typeshare::typeshare]
198pub struct LogfireConfig {
199    #[serde(default = "default_logfire_host")]
200    pub endpoint: Cow<'static, str>,
201    pub write_token: String,
202}
203
204#[cfg(any(test, feature = "integration-tests"))]
205impl Default for LogfireConfig {
206    fn default() -> Self {
207        Self {
208            endpoint: default_logfire_host(),
209            write_token: "some-write-token".into(),
210        }
211    }
212}
213
214#[derive(Eq, Clone, PartialEq, Serialize, Deserialize)]
215#[cfg_attr(feature = "integration-tests", derive(Debug))]
216#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
217#[typeshare::typeshare]
218pub struct GenericOtelConfig {
219    /// Endpoint for the exporter to target
220    pub endpoint: String,
221    /// Set either the full authorization header or just the bearer token
222    pub authorization: Option<String>,
223    /// Set either the full authorization header or just the bearer token
224    pub bearer_token: Option<String>,
225    /// Add http/grpc compression to exporter. Example: gzip
226    pub compression: Option<String>,
227    /// true: otlp, false: otlphttp
228    pub grpc: bool,
229    /// Enable logs pipeline (if included in tier)
230    pub logs: bool,
231    /// Enable traces pipeline (if included in tier)
232    pub traces: bool,
233    /// Enable metrics pipeline
234    pub metrics: bool,
235}
236
237#[cfg(any(test, feature = "integration-tests"))]
238impl Default for GenericOtelConfig {
239    fn default() -> Self {
240        Self {
241            endpoint: "https://host.host/".into(),
242            authorization: None,
243            bearer_token: Some("bearer".into()),
244            compression: Some("gzip".into()),
245            grpc: true,
246            logs: true,
247            traces: true,
248            metrics: true,
249        }
250    }
251}
252
253#[cfg(feature = "integration-tests")]
254impl From<BetterstackConfig> for TelemetrySinkConfig {
255    fn from(value: BetterstackConfig) -> Self {
256        TelemetrySinkConfig::Betterstack(value)
257    }
258}
259
260#[cfg(feature = "integration-tests")]
261impl From<DatadogConfig> for TelemetrySinkConfig {
262    fn from(value: DatadogConfig) -> Self {
263        TelemetrySinkConfig::Datadog(value)
264    }
265}
266
267#[cfg(feature = "integration-tests")]
268impl From<GrafanaCloudConfig> for TelemetrySinkConfig {
269    fn from(value: GrafanaCloudConfig) -> Self {
270        TelemetrySinkConfig::GrafanaCloud(value)
271    }
272}
273
274#[cfg(feature = "integration-tests")]
275impl From<LogfireConfig> for TelemetrySinkConfig {
276    fn from(value: LogfireConfig) -> Self {
277        TelemetrySinkConfig::Logfire(value)
278    }
279}
280
281#[cfg(feature = "integration-tests")]
282impl From<GenericOtelConfig> for TelemetrySinkConfig {
283    fn from(value: GenericOtelConfig) -> Self {
284        TelemetrySinkConfig::GenericOtel(value)
285    }
286}
287
288#[cfg(feature = "integration-tests")]
289impl std::str::FromStr for TelemetrySinkConfig {
290    type Err = serde_json::Error;
291
292    fn from_str(config: &str) -> Result<Self, Self::Err> {
293        serde_json::from_str::<BetterstackConfig>(config)
294            .map(Self::from)
295            .inspect_err(|error| {
296                tracing::debug!(
297                    %config,
298                    %error,
299                    "cannot deserialize config as valid Betterstack configuration",
300                )
301            })
302            .or(serde_json::from_str::<DatadogConfig>(config)
303                .map(Self::from)
304                .inspect_err(|error| {
305                    tracing::debug!(
306                        %config,
307                        %error,
308                        "cannot deserialize config as valid DataDog configuration",
309                    )
310                }))
311            .or(serde_json::from_str::<GrafanaCloudConfig>(config)
312                .map(Self::from)
313                .inspect_err(|error| {
314                    tracing::debug!(
315                        %config,
316                        %error,
317                        "cannot deserialize config as valid GrafanaCloud configuration",
318                    )
319                }))
320            .or(serde_json::from_str::<LogfireConfig>(config)
321                .map(Self::from)
322                .inspect_err(|error| {
323                    tracing::debug!(
324                        %config,
325                        %error,
326                        "cannot deserialize config as valid Logfire configuration",
327                    )
328                }))
329            .or(serde_json::from_str::<GenericOtelConfig>(config)
330                .map(Self::from)
331                .inspect_err(|error| {
332                    tracing::debug!(
333                        %config,
334                        %error,
335                        "cannot deserialize config as valid Generic configuration",
336                    )
337                }))
338            .map_err(|_| {
339                <serde_json::Error as serde::de::Error>::custom(format!(
340                    "configuration does not match any known external telemetry sink: {}",
341                    config
342                ))
343            })
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn sink_config_enum() {
353        for variant in <TelemetrySinkConfig as strum::IntoEnumIterator>::iter() {
354            match variant {
355                sink @ TelemetrySinkConfig::Betterstack(_) => {
356                    assert_eq!("betterstack", sink.as_ref());
357                    assert_eq!("project::telemetry::betterstack::config", sink.as_db_type());
358                }
359                sink @ TelemetrySinkConfig::Datadog(_) => {
360                    assert_eq!("datadog", sink.as_ref());
361                    assert_eq!("project::telemetry::datadog::config", sink.as_db_type());
362                }
363                sink @ TelemetrySinkConfig::GrafanaCloud(_) => {
364                    assert_eq!("grafana_cloud", sink.as_ref());
365                    assert_eq!(
366                        "project::telemetry::grafana_cloud::config",
367                        sink.as_db_type()
368                    );
369                }
370                sink @ TelemetrySinkConfig::Logfire(_) => {
371                    assert_eq!("logfire", sink.as_ref());
372                    assert_eq!("project::telemetry::logfire::config", sink.as_db_type());
373                }
374                sink @ TelemetrySinkConfig::GenericOtel(_) => {
375                    assert_eq!("generic_otel", sink.as_ref());
376                    assert_eq!(
377                        "project::telemetry::generic_otel::config",
378                        sink.as_db_type()
379                    );
380                }
381                sink @ TelemetrySinkConfig::Debug(_) => {
382                    assert_eq!("debug", sink.as_ref());
383                    assert_eq!("project::telemetry::debug::config", sink.as_db_type());
384                }
385            }
386        }
387
388        for variant in <TelemetrySinkConfigDiscriminants as strum::IntoEnumIterator>::iter() {
389            match variant {
390                discriminant @ TelemetrySinkConfigDiscriminants::Betterstack => {
391                    assert_eq!("betterstack", discriminant.as_ref());
392                    assert_eq!(
393                        r#""betterstack""#,
394                        serde_json::to_string(&discriminant).unwrap()
395                    );
396                }
397                discriminant @ TelemetrySinkConfigDiscriminants::Datadog => {
398                    assert_eq!("datadog", discriminant.as_ref());
399                    assert_eq!(
400                        r#""datadog""#,
401                        serde_json::to_string(&discriminant).unwrap()
402                    );
403                }
404                discriminant @ TelemetrySinkConfigDiscriminants::GrafanaCloud => {
405                    assert_eq!("grafana_cloud", discriminant.as_ref());
406                    assert_eq!(
407                        r#""grafana_cloud""#,
408                        serde_json::to_string(&discriminant).unwrap()
409                    );
410                }
411                discriminant @ TelemetrySinkConfigDiscriminants::Logfire => {
412                    assert_eq!("logfire", discriminant.as_ref());
413                    assert_eq!(
414                        r#""logfire""#,
415                        serde_json::to_string(&discriminant).unwrap()
416                    );
417                }
418                discriminant @ TelemetrySinkConfigDiscriminants::GenericOtel => {
419                    assert_eq!("generic_otel", discriminant.as_ref());
420                    assert_eq!(
421                        r#""generic_otel""#,
422                        serde_json::to_string(&discriminant).unwrap()
423                    );
424                }
425                discriminant @ TelemetrySinkConfigDiscriminants::Debug => {
426                    assert_eq!("debug", discriminant.as_ref());
427                    assert_eq!(r#""debug""#, serde_json::to_string(&discriminant).unwrap());
428                }
429            }
430        }
431    }
432}