use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use apiplant_core::{LogFormat, ObservabilityConfig, OtlpProtocol, TracesConfig};
use ntex::http::header::{HeaderName, HeaderValue};
use ntex::service::{Middleware, Service, ServiceCtx};
use ntex::web;
use opentelemetry::trace::{TraceContextExt, TracerProvider as _};
use opentelemetry::{global, KeyValue};
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::trace::{Sampler, SdkTracerProvider};
use opentelemetry_sdk::Resource;
use tracing::Span;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer};
const SCOPE: &str = "apiplant";
const HEADER_TRACE_ID: &str = "x-trace-id";
#[must_use = "dropping the guard immediately shuts the exporters down again"]
pub struct Guard {
traces: Option<SdkTracerProvider>,
metrics: Option<SdkMeterProvider>,
}
impl Drop for Guard {
fn drop(&mut self) {
if let Some(provider) = self.traces.take() {
if let Err(e) = provider.shutdown() {
eprintln!("apiplant: flushing traces on shutdown failed: {e}");
}
}
if let Some(provider) = self.metrics.take() {
if let Err(e) = provider.shutdown() {
eprintln!("apiplant: flushing metrics on shutdown failed: {e}");
}
}
}
}
pub fn init(config: &ObservabilityConfig, app_name: &str) -> Guard {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(config.logs.level.clone()));
let stdout = match config.logs.format {
LogFormat::Pretty => tracing_subscriber::fmt::layer().boxed(),
LogFormat::Compact => tracing_subscriber::fmt::layer().compact().boxed(),
LogFormat::Json => tracing_subscriber::fmt::layer()
.json()
.with_current_span(config.logs.span_fields)
.with_span_list(config.logs.span_fields)
.boxed(),
};
if !config.is_active() {
tracing_subscriber::registry()
.with(filter)
.with(stdout)
.init();
return Guard {
traces: None,
metrics: None,
};
}
let _ = rustls::crypto::ring::default_provider().install_default();
let resource = resource(config, app_name);
let endpoint = config.endpoint();
let headers: HashMap<String, String> = config.export_headers().into_iter().collect();
let traces = config
.traces
.enabled
.then(|| tracer_provider(config, &endpoint, &headers, resource.clone()));
let metrics = config
.metrics
.enabled
.then(|| meter_provider(config, &endpoint, &headers, resource))
.flatten();
global::set_text_map_propagator(TraceContextPropagator::new());
let otel = traces.as_ref().map(|provider| {
global::set_tracer_provider(provider.clone());
tracing_opentelemetry::layer()
.with_tracer(provider.tracer(SCOPE))
.with_location(false)
.with_threads(false)
.with_tracked_inactivity(false)
});
if let Some(provider) = &metrics {
global::set_meter_provider(provider.clone());
}
tracing_subscriber::registry()
.with(filter)
.with(stdout)
.with(otel)
.init();
match &endpoint {
Some(endpoint) => tracing::info!(
%endpoint,
traces = config.traces.enabled,
metrics = metrics.is_some(),
sample_ratio = config.traces.sample_ratio,
"observability: exporting over OTLP",
),
None => tracing::info!(
"observability: tracing in-process only — set [observability.otlp] endpoint to export"
),
}
Guard { traces, metrics }
}
fn resource(config: &ObservabilityConfig, app_name: &str) -> Resource {
use opentelemetry_semantic_conventions::resource;
let mut attributes = vec![KeyValue::new(
resource::SERVICE_VERSION,
config
.service_version
.clone()
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()),
)];
if let Some(environment) = &config.environment {
attributes.push(KeyValue::new(
resource::DEPLOYMENT_ENVIRONMENT_NAME,
environment.clone(),
));
}
for (key, value) in &config.resource_attributes {
attributes.push(KeyValue::new(key.clone(), value.clone()));
}
Resource::builder()
.with_service_name(config.service_name(app_name))
.with_attributes(attributes)
.build()
}
fn tracer_provider(
config: &ObservabilityConfig,
endpoint: &Option<String>,
headers: &HashMap<String, String>,
resource: Resource,
) -> SdkTracerProvider {
let builder = SdkTracerProvider::builder()
.with_resource(resource)
.with_sampler(Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(
config.traces.sample_ratio,
))));
let Some(endpoint) = endpoint else {
return builder.build();
};
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_endpoint(format!("{endpoint}/v1/traces"))
.with_protocol(protocol(config.otlp.protocol))
.with_timeout(Duration::from_secs(config.otlp.timeout_secs))
.with_headers(headers.clone())
.build();
match exporter {
Ok(exporter) => builder.with_batch_exporter(exporter).build(),
Err(e) => {
eprintln!("apiplant: OTLP trace exporter disabled: {e}");
builder.build()
}
}
}
fn meter_provider(
config: &ObservabilityConfig,
endpoint: &Option<String>,
headers: &HashMap<String, String>,
resource: Resource,
) -> Option<SdkMeterProvider> {
let endpoint = endpoint.as_ref()?;
let exporter = opentelemetry_otlp::MetricExporter::builder()
.with_http()
.with_endpoint(format!("{endpoint}/v1/metrics"))
.with_protocol(protocol(config.otlp.protocol))
.with_timeout(Duration::from_secs(config.otlp.timeout_secs))
.with_headers(headers.clone())
.build();
match exporter {
Ok(exporter) => Some(
SdkMeterProvider::builder()
.with_reader(
PeriodicReader::builder(exporter)
.with_interval(Duration::from_secs(config.metrics.interval_secs))
.build(),
)
.with_resource(resource)
.build(),
),
Err(e) => {
eprintln!("apiplant: OTLP metric exporter disabled: {e}");
None
}
}
}
fn protocol(protocol: OtlpProtocol) -> opentelemetry_otlp::Protocol {
match protocol {
OtlpProtocol::HttpProtobuf => opentelemetry_otlp::Protocol::HttpBinary,
OtlpProtocol::HttpJson => opentelemetry_otlp::Protocol::HttpJson,
}
}
pub fn record_error(kind: &'static str, detail: impl std::fmt::Display) {
let span = Span::current();
span.record("error.type", kind);
span.record("otel.status_code", "ERROR");
span.record("exception.type", kind);
span.record("exception.message", tracing::field::display(&detail));
}
struct Instruments {
duration: opentelemetry::metrics::Histogram<f64>,
active: opentelemetry::metrics::UpDownCounter<i64>,
}
impl Instruments {
fn new() -> Instruments {
let meter = global::meter(SCOPE);
Instruments {
duration: meter
.f64_histogram("http.server.request.duration")
.with_unit("s")
.with_description("Duration of inbound HTTP requests.")
.build(),
active: meter
.i64_up_down_counter("http.server.active_requests")
.with_unit("{request}")
.with_description("Requests currently being handled.")
.build(),
}
}
}
pub struct TelemetryPolicy {
config: TracesConfig,
base_path: String,
instruments: Option<Instruments>,
}
impl TelemetryPolicy {
pub fn build(config: &ObservabilityConfig, base_path: &str) -> TelemetryPolicy {
TelemetryPolicy {
config: if config.enabled {
config.traces.clone()
} else {
TracesConfig {
enabled: false,
..TracesConfig::default()
}
},
base_path: base_path.to_string(),
instruments: (config.enabled && config.metrics.enabled).then(Instruments::new),
}
}
pub fn is_active(&self) -> bool {
self.config.enabled || self.instruments.is_some()
}
fn excluded(&self, path: &str) -> bool {
let path = path.strip_prefix(&self.base_path).unwrap_or(path);
self.config
.exclude_paths
.iter()
.any(|excluded| path.starts_with(excluded.as_str()))
}
}
fn route_template(path: &str) -> String {
let mut route = String::with_capacity(path.len());
for segment in path.split('/').skip(1) {
route.push('/');
if looks_like_an_id(segment) {
route.push_str("{id}");
} else {
route.push_str(segment);
}
}
if route.is_empty() {
route.push('/');
}
route
}
fn looks_like_an_id(segment: &str) -> bool {
if segment.is_empty() {
return false;
}
if segment.bytes().all(|b| b.is_ascii_digit()) {
return true;
}
let hex = segment.len() == 36
&& segment.bytes().enumerate().all(|(i, b)| match i {
8 | 13 | 18 | 23 => b == b'-',
_ => b.is_ascii_hexdigit(),
});
hex
}
pub struct Telemetry {
policy: Arc<TelemetryPolicy>,
}
impl Telemetry {
pub fn new(policy: Arc<TelemetryPolicy>) -> Telemetry {
Telemetry { policy }
}
}
impl<S> Middleware<S> for Telemetry {
type Service = TelemetryService<S>;
fn create(&self, service: S) -> Self::Service {
TelemetryService {
service,
policy: Arc::clone(&self.policy),
}
}
}
pub struct TelemetryService<S> {
service: S,
policy: Arc<TelemetryPolicy>,
}
impl<S, Err> Service<web::WebRequest<Err>> for TelemetryService<S>
where
S: Service<web::WebRequest<Err>, Response = web::WebResponse, Error = web::Error>,
Err: web::ErrorRenderer,
{
type Response = web::WebResponse;
type Error = web::Error;
ntex::forward_ready!(service);
async fn call(
&self,
req: web::WebRequest<Err>,
ctx: ServiceCtx<'_, Self>,
) -> Result<Self::Response, Self::Error> {
if !self.policy.is_active() || self.policy.excluded(req.path()) {
return ctx.call(&self.service, req).await;
}
let method = req.method().to_string();
let route = route_template(req.path());
let attributes = vec![
KeyValue::new("http.request.method", method.clone()),
KeyValue::new("http.route", route.clone()),
];
if let Some(instruments) = &self.policy.instruments {
instruments.active.add(1, &attributes);
}
let started = Instant::now();
let span = self
.policy
.config
.enabled
.then(|| self.span(&req, &method, &route));
let trace_id = span.as_ref().and_then(trace_id);
if let (Some(span), Some(trace_id)) = (&span, &trace_id) {
span.record("trace_id", trace_id.as_str());
}
let result = match &span {
Some(span) => {
use tracing::Instrument;
ctx.call(&self.service, req).instrument(span.clone()).await
}
None => ctx.call(&self.service, req).await,
};
let elapsed = started.elapsed().as_secs_f64();
let status = match &result {
Ok(response) => response.status().as_u16(),
Err(_) => 500,
};
if let Some(span) = &span {
span.record("http.response.status_code", status);
match &result {
Err(e) => record_error("unhandled", e),
Ok(_) if status >= 500 => record_error("http_server_error", status),
Ok(_) => {}
}
}
if let Some(instruments) = &self.policy.instruments {
instruments.active.add(-1, &attributes);
let mut attributes = attributes;
attributes.push(KeyValue::new("http.response.status_code", status as i64));
instruments.duration.record(elapsed, &attributes);
}
let mut response = result?;
if self.policy.config.response_header {
if let Some(trace_id) = trace_id {
if let Ok(value) = HeaderValue::from_str(&trace_id) {
response
.headers_mut()
.insert(HeaderName::from_static(HEADER_TRACE_ID), value);
}
}
}
Ok(response)
}
}
impl<S> TelemetryService<S> {
fn span<Err>(&self, req: &web::WebRequest<Err>, method: &str, route: &str) -> Span {
let span = tracing::info_span!(
"http.request",
otel.name = %format!("{method} {route}"),
otel.kind = "server",
otel.status_code = tracing::field::Empty,
trace_id = tracing::field::Empty,
"http.request.method" = %method,
"http.route" = %route,
"url.path" = %req.path(),
"url.query" = tracing::field::Empty,
"http.response.status_code" = tracing::field::Empty,
"error.type" = tracing::field::Empty,
"exception.type" = tracing::field::Empty,
"exception.message" = tracing::field::Empty,
);
let query = req.query_string();
if !query.is_empty() {
span.record("url.query", query);
}
for name in &self.policy.config.capture_headers {
if is_sensitive(name) {
continue;
}
if let Some(value) = req
.headers()
.get(name.as_str())
.and_then(|v| v.to_str().ok())
{
span.set_attribute(format!("http.request.header.{name}"), value.to_string());
}
}
let parent = global::get_text_map_propagator(|propagator| {
propagator.extract(&HeaderExtractor(req.headers()))
});
if parent.span().span_context().is_valid() {
let _ = span.set_parent(parent);
}
span
}
}
fn is_sensitive(name: &str) -> bool {
matches!(
name,
"authorization" | "proxy-authorization" | "cookie" | "set-cookie" | "x-api-key"
)
}
fn trace_id(span: &Span) -> Option<String> {
let context = span.context();
let span_context = context.span().span_context().clone();
span_context
.is_valid()
.then(|| span_context.trace_id().to_string())
}
struct HeaderExtractor<'a>(&'a ntex::http::HeaderMap);
impl opentelemetry::propagation::Extractor for HeaderExtractor<'_> {
fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).and_then(|value| value.to_str().ok())
}
fn keys(&self) -> Vec<&str> {
self.0.keys().map(|name| name.as_str()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_id_segment_is_replaced_and_a_name_is_not() {
assert_eq!(route_template("/products/7"), "/products/{id}");
assert_eq!(
route_template("/products/2f8a1c4e-1111-4222-8333-abcdefabcdef"),
"/products/{id}"
);
assert_eq!(route_template("/auth/login"), "/auth/login");
assert_eq!(
route_template("/functions/summarise-text"),
"/functions/summarise-text"
);
assert_eq!(route_template("/orders/7/lines"), "/orders/{id}/lines");
assert_eq!(route_template("/"), "/");
}
#[test]
fn a_near_uuid_is_not_mistaken_for_one() {
assert!(!looks_like_an_id("2f8a1c4e11114222-8333-abcdefabcdefab"));
assert!(!looks_like_an_id("zf8a1c4e-1111-4222-8333-abcdefabcdef"));
assert!(!looks_like_an_id(""));
}
#[test]
fn excluded_paths_are_matched_under_the_base_path() {
let policy = TelemetryPolicy {
config: TracesConfig {
exclude_paths: vec!["/_health".to_string()],
..TracesConfig::default()
},
base_path: "/api".to_string(),
instruments: None,
};
assert!(policy.excluded("/api/_health"));
assert!(!policy.excluded("/api/products"));
}
#[test]
fn credential_headers_are_never_captured() {
assert!(is_sensitive("authorization"));
assert!(is_sensitive("cookie"));
assert!(!is_sensitive("x-request-id"));
}
}