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#[derive(Eq, Clone, Debug, PartialEq, Serialize, Deserialize)]
15#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
16#[typeshare::typeshare]
17pub struct TelemetrySinkStatus {
18 enabled: bool,
20}
21
22#[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#[derive(
65 Eq,
67 Clone,
68 PartialEq,
69 Serialize,
71 Deserialize,
72 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(BetterstackConfig),
92
93 Datadog(DatadogConfig),
95
96 GrafanaCloud(GrafanaCloudConfig),
98
99 Logfire(LogfireConfig),
101
102 GenericOtel(GenericOtelConfig),
104
105 #[doc(hidden)]
107 #[typeshare(skip)]
108 #[strum_discriminants(doc(hidden))]
109 Debug(serde_json::Value),
110 }
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 pub endpoint: String,
221 pub authorization: Option<String>,
223 pub bearer_token: Option<String>,
225 pub compression: Option<String>,
227 pub grpc: bool,
229 pub logs: bool,
231 pub traces: bool,
233 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}