Skip to main content

sofie_client/
telemetry.rs

1//! Spans and metrics never record a header, a body or a secret.
2
3use std::time::{Duration, Instant};
4
5use http::Method;
6use sofie_core::{Error, Result};
7use tracing::field::Empty;
8use tracing::{Instrument, Span};
9
10use crate::Shared;
11use crate::breaker;
12use crate::request::Response;
13use crate::target::Target;
14
15#[cfg(feature = "metrics")]
16static GATEWAY_DURATION: sofie_telemetry::metrics::Instrument =
17    sofie_telemetry::metrics::Instrument {
18        name: "sofie.gateway.duration",
19        description: "How long a call of a gateway's adapter or an operation of its vendor took, by vendor, operation and outcome.",
20        kind: sofie_telemetry::metrics::Kind::Duration,
21        labels: &["gateway.vendor", "gateway.operation", "outcome"],
22    };
23#[cfg(feature = "metrics")]
24static OUTGOING_DURATION: sofie_telemetry::metrics::Instrument =
25    sofie_telemetry::metrics::Instrument {
26        name: "sofie.outgoing.duration",
27        description: "How long a request leaving the app took, by gateway.vendor, gateway.operation (the method) and outcome (status class or error).",
28        kind: sofie_telemetry::metrics::Kind::Duration,
29        labels: &["gateway.vendor", "gateway.operation", "outcome"],
30    };
31
32pub fn call<T>(
33    vendor: &'static str,
34    operation: &'static str,
35    work: impl Future<Output = Result<T>>,
36) -> impl Future<Output = Result<T>> {
37    let span = tracing::info_span!(
38        "gateway",
39        otel.name = format!("{vendor}.{operation}"),
40        gateway.vendor = vendor,
41        gateway.operation = operation,
42        outcome = Empty,
43    );
44    measured(span, vendor, operation, work)
45}
46
47pub(crate) fn operation<T>(
48    shared: &Shared,
49    name: &'static str,
50    work: impl Future<Output = Result<T>>,
51) -> impl Future<Output = Result<T>> {
52    let vendor = shared.vendor;
53    let span = tracing::info_span!(
54        "outgoing",
55        otel.name = format!("{vendor}.{name}"),
56        otel.kind = "client",
57        gateway.vendor = vendor,
58        gateway.operation = name,
59        outcome = Empty,
60    );
61    measured(span, vendor, name, bounded(shared, name, work))
62}
63
64async fn bounded<T>(
65    shared: &Shared,
66    name: &'static str,
67    work: impl Future<Output = Result<T>>,
68) -> Result<T> {
69    let vendor = shared.vendor;
70    shared.operations.admit(vendor)?;
71    let total = shared.limits.total;
72    let result = tokio::time::timeout(total, work).await.unwrap_or_else(|_| {
73        Err(Error::Unavailable(format!(
74            "{vendor}'s {name} did not finish within {} ms",
75            total.as_millis()
76        )))
77    });
78    match &result {
79        Err(error) if breaker::refused_open(error) => {}
80        Err(Error::Unavailable(_)) => shared.operations.record(vendor, false, Instant::now()),
81        _ => shared.operations.record(vendor, true, Instant::now()),
82    }
83    result
84}
85
86/// Requests are measured apart, under `sofie.outgoing.duration`, so none is counted twice.
87async fn measured<T>(
88    span: Span,
89    vendor: &'static str,
90    operation: &'static str,
91    work: impl Future<Output = Result<T>>,
92) -> Result<T> {
93    let started = Instant::now();
94    let result = work.instrument(span.clone()).await;
95    let outcome = if result.is_ok() { "ok" } else { "error" };
96    span.record("outcome", outcome);
97    record(
98        Meter::Gateway,
99        [vendor, operation, outcome],
100        started.elapsed(),
101    );
102    result
103}
104
105pub(crate) fn http_span(vendor: &'static str, method: &Method, target: &Target) -> Span {
106    tracing::info_span!(
107        "http.client",
108        otel.name = method.as_str(),
109        otel.kind = "client",
110        otel.status_code = Empty,
111        gateway.vendor = vendor,
112        server.address = target.host.as_str(),
113        server.port = target.port,
114        http.request.method = method.as_str(),
115        http.response.status_code = Empty,
116        http.request.resend_count = Empty,
117        duration_ms = Empty,
118    )
119}
120
121pub(crate) fn finish_http(
122    span: &Span,
123    vendor: &'static str,
124    method: &Method,
125    result: &Result<Response>,
126    started: Instant,
127) {
128    let elapsed = started.elapsed();
129    span.record(
130        "duration_ms",
131        u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
132    );
133    let outcome = match result {
134        Ok(response) => {
135            span.record("http.response.status_code", response.status());
136            if response.status.is_server_error() {
137                span.record("otel.status_code", "ERROR");
138            }
139            status_class(response.status.as_u16())
140        }
141        Err(error) => {
142            span.record("otel.status_code", "ERROR");
143            span.in_scope(|| tracing::warn!(error = %error, "outgoing request failed"));
144            "error"
145        }
146    };
147    record(
148        Meter::Outgoing,
149        [vendor, method_name(method), outcome],
150        elapsed,
151    );
152}
153
154fn status_class(status: u16) -> &'static str {
155    match status {
156        200..=299 => "2xx",
157        300..=399 => "3xx",
158        400..=499 => "4xx",
159        _ => "5xx",
160    }
161}
162
163#[derive(Clone, Copy)]
164enum Meter {
165    Gateway,
166    Outgoing,
167}
168
169fn record(meter: Meter, labels: [&'static str; 3], elapsed: Duration) {
170    #[cfg(feature = "metrics")]
171    {
172        let instrument = match meter {
173            Meter::Gateway => &GATEWAY_DURATION,
174            Meter::Outgoing => &OUTGOING_DURATION,
175        };
176        let labels = labels.map(sofie_telemetry::metrics::Label::from);
177        sofie_telemetry::metrics::record_duration(instrument, &labels, elapsed);
178    }
179    #[cfg(not(feature = "metrics"))]
180    let _ = (meter, labels, elapsed);
181}
182
183/// A label is a `'static` name, which a method made at runtime is not.
184fn method_name(method: &Method) -> &'static str {
185    match *method {
186        Method::GET => "GET",
187        Method::POST => "POST",
188        Method::PUT => "PUT",
189        Method::PATCH => "PATCH",
190        Method::DELETE => "DELETE",
191        Method::HEAD => "HEAD",
192        Method::OPTIONS => "OPTIONS",
193        _ => "_OTHER",
194    }
195}