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
9/// Status of a telemetry export configuration for an external sink
10#[derive(Eq, Clone, Debug, PartialEq, Serialize, Deserialize)]
11#[typeshare::typeshare]
12pub struct TelemetrySinkStatus {
13    /// Indicates that the associated project is configured to export telemetry data to this sink
14    enabled: bool,
15}
16
17/// A safe-for-display representation of the current telemetry export configuration for a given project
18#[derive(Eq, Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
19#[typeshare::typeshare]
20pub struct TelemetryConfigResponse {
21    betterstack: Option<TelemetrySinkStatus>,
22    datadog: Option<TelemetrySinkStatus>,
23    grafana_cloud: Option<TelemetrySinkStatus>,
24}
25
26impl From<Vec<TelemetrySinkConfig>> for TelemetryConfigResponse {
27    fn from(value: Vec<TelemetrySinkConfig>) -> Self {
28        let mut instance = Self::default();
29
30        for sink in value {
31            match sink {
32                TelemetrySinkConfig::Debug(_) => {}
33                TelemetrySinkConfig::Betterstack(_) => {
34                    instance.betterstack = Some(TelemetrySinkStatus { enabled: true })
35                }
36                TelemetrySinkConfig::Datadog(_) => {
37                    instance.datadog = Some(TelemetrySinkStatus { enabled: true })
38                }
39                TelemetrySinkConfig::GrafanaCloud(_) => {
40                    instance.grafana_cloud = Some(TelemetrySinkStatus { enabled: true })
41                }
42            }
43        }
44
45        instance
46    }
47}
48
49/// The user-supplied config required to export telemetry to a given external sink
50#[derive(
51    // std
52    Eq,
53    Clone,
54    PartialEq,
55    // serde
56    Serialize,
57    Deserialize,
58    // strum
59    strum::AsRefStr,
60    strum::EnumDiscriminants,
61)]
62#[cfg_attr(feature = "integration-tests", derive(Debug))]
63#[cfg_attr(any(test, feature = "integration-tests"), derive(strum::EnumIter))]
64#[serde(tag = "type", content = "content", rename_all = "snake_case")]
65#[strum(serialize_all = "snake_case")]
66#[typeshare::typeshare]
67#[cfg_attr(
68    any(test, feature = "integration-tests"),
69    strum_discriminants(derive(strum::EnumIter))
70)]
71#[strum_discriminants(derive(Serialize, Deserialize, strum::AsRefStr))]
72#[strum_discriminants(serde(rename_all = "snake_case"))]
73#[strum_discriminants(strum(serialize_all = "snake_case"))]
74pub enum TelemetrySinkConfig {
75    /// [Betterstack](https://betterstack.com/docs/logs/open-telemetry/)
76    Betterstack(BetterstackConfig),
77
78    /// [Datadog](https://docs.datadoghq.com/opentelemetry/collector_exporter/otel_collector_datadog_exporter)
79    Datadog(DatadogConfig),
80
81    /// [Grafana Cloud](https://grafana.com/docs/grafana-cloud/send-data/otlp/)
82    GrafanaCloud(GrafanaCloudConfig),
83
84    /// Internal Debugging
85    #[doc(hidden)]
86    #[typeshare(skip)]
87    #[strum_discriminants(doc(hidden))]
88    Debug(serde_json::Value),
89}
90
91impl TelemetrySinkConfig {
92    pub fn as_db_type(&self) -> String {
93        format!("project::telemetry::{}::config", self.as_ref())
94    }
95}
96
97impl TelemetrySinkConfigDiscriminants {
98    pub fn as_db_type(&self) -> String {
99        format!("project::telemetry::{}::config", self.as_ref())
100    }
101}
102
103#[derive(Eq, Clone, PartialEq, Serialize, Deserialize)]
104#[cfg_attr(feature = "integration-tests", derive(Debug))]
105#[typeshare::typeshare]
106pub struct BetterstackConfig {
107    #[serde(default = "default_betterstack_host")]
108    pub ingesting_host: Cow<'static, str>,
109    pub source_token: String,
110}
111
112#[cfg(any(test, feature = "integration-tests"))]
113impl Default for BetterstackConfig {
114    fn default() -> Self {
115        Self {
116            source_token: "some-source-token".into(),
117            ingesting_host: default_betterstack_host(),
118        }
119    }
120}
121
122#[derive(Eq, Clone, PartialEq, Serialize, Deserialize)]
123#[cfg_attr(feature = "integration-tests", derive(Debug))]
124#[typeshare::typeshare]
125pub struct DatadogConfig {
126    pub api_key: String,
127}
128
129#[cfg(any(test, feature = "integration-tests"))]
130impl Default for DatadogConfig {
131    fn default() -> Self {
132        Self {
133            api_key: "some-api-key".into(),
134        }
135    }
136}
137
138#[derive(Eq, Clone, PartialEq, Serialize, Deserialize)]
139#[cfg_attr(feature = "integration-tests", derive(Debug))]
140#[typeshare::typeshare]
141pub struct GrafanaCloudConfig {
142    pub token: String,
143    pub endpoint: String,
144    pub instance_id: String,
145}
146
147#[cfg(any(test, feature = "integration-tests"))]
148impl Default for GrafanaCloudConfig {
149    fn default() -> Self {
150        Self {
151            token: "some-auth-token".into(),
152            instance_id: String::from("0000000"),
153            endpoint: "https://prometheus-env-id-env-region.grafana.net/api/prom/push".into(),
154        }
155    }
156}
157
158#[cfg(feature = "integration-tests")]
159impl From<BetterstackConfig> for TelemetrySinkConfig {
160    fn from(value: BetterstackConfig) -> Self {
161        TelemetrySinkConfig::Betterstack(value)
162    }
163}
164
165#[cfg(feature = "integration-tests")]
166impl From<DatadogConfig> for TelemetrySinkConfig {
167    fn from(value: DatadogConfig) -> Self {
168        TelemetrySinkConfig::Datadog(value)
169    }
170}
171
172#[cfg(feature = "integration-tests")]
173impl From<GrafanaCloudConfig> for TelemetrySinkConfig {
174    fn from(value: GrafanaCloudConfig) -> Self {
175        TelemetrySinkConfig::GrafanaCloud(value)
176    }
177}
178
179#[cfg(feature = "integration-tests")]
180impl std::str::FromStr for TelemetrySinkConfig {
181    type Err = serde_json::Error;
182
183    fn from_str(config: &str) -> Result<Self, Self::Err> {
184        serde_json::from_str::<BetterstackConfig>(config)
185            .map(Self::from)
186            .inspect_err(|error| {
187                tracing::debug!(
188                    %config,
189                    %error,
190                    "cannot deserialize config as valid Betterstack configuration",
191                )
192            })
193            .or(serde_json::from_str::<DatadogConfig>(config)
194                .map(Self::from)
195                .inspect_err(|error| {
196                    tracing::debug!(
197                        %config,
198                        %error,
199                        "cannot deserialize config as valid DataDog configuration",
200                    )
201                }))
202            .or(serde_json::from_str::<GrafanaCloudConfig>(config)
203                .map(Self::from)
204                .inspect_err(|error| {
205                    tracing::debug!(
206                        %config,
207                        %error,
208                        "cannot deserialize config as valid GrafanaCloud configuration",
209                    )
210                }))
211            .map_err(|_| {
212                <serde_json::Error as serde::de::Error>::custom(format!(
213                    "configuration does not match any known external telemetry sink: {}",
214                    config
215                ))
216            })
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn sink_config_enum() {
226        for variant in <TelemetrySinkConfig as strum::IntoEnumIterator>::iter() {
227            match variant {
228                sink @ TelemetrySinkConfig::Betterstack(_) => {
229                    assert_eq!("betterstack", sink.as_ref());
230                    assert_eq!("project::telemetry::betterstack::config", sink.as_db_type());
231                }
232                sink @ TelemetrySinkConfig::Datadog(_) => {
233                    assert_eq!("datadog", sink.as_ref());
234                    assert_eq!("project::telemetry::datadog::config", sink.as_db_type());
235                }
236                sink @ TelemetrySinkConfig::GrafanaCloud(_) => {
237                    assert_eq!("grafana_cloud", sink.as_ref());
238                    assert_eq!(
239                        "project::telemetry::grafana_cloud::config",
240                        sink.as_db_type()
241                    );
242                }
243                sink @ TelemetrySinkConfig::Debug(_) => {
244                    assert_eq!("debug", sink.as_ref());
245                    assert_eq!("project::telemetry::debug::config", sink.as_db_type());
246                }
247            }
248        }
249
250        for variant in <TelemetrySinkConfigDiscriminants as strum::IntoEnumIterator>::iter() {
251            match variant {
252                discriminant @ TelemetrySinkConfigDiscriminants::Betterstack => {
253                    assert_eq!("betterstack", discriminant.as_ref());
254                    assert_eq!(
255                        r#""betterstack""#,
256                        serde_json::to_string(&discriminant).unwrap()
257                    );
258                }
259                discriminant @ TelemetrySinkConfigDiscriminants::Datadog => {
260                    assert_eq!("datadog", discriminant.as_ref());
261                    assert_eq!(
262                        r#""datadog""#,
263                        serde_json::to_string(&discriminant).unwrap()
264                    );
265                }
266                discriminant @ TelemetrySinkConfigDiscriminants::GrafanaCloud => {
267                    assert_eq!("grafana_cloud", discriminant.as_ref());
268                    assert_eq!(
269                        r#""grafana_cloud""#,
270                        serde_json::to_string(&discriminant).unwrap()
271                    );
272                }
273                discriminant @ TelemetrySinkConfigDiscriminants::Debug => {
274                    assert_eq!("debug", discriminant.as_ref());
275                    assert_eq!(r#""debug""#, serde_json::to_string(&discriminant).unwrap());
276                }
277            }
278        }
279    }
280}