use crate::state::AppState;
use opentelemetry::metrics::{Histogram, Meter, MeterProvider as _, UpDownCounter};
use opentelemetry::trace::{
SpanContext, TraceContextExt as _, TraceFlags, TraceState, TracerProvider as _,
};
use opentelemetry::KeyValue;
use opentelemetry_otlp::WithHttpConfig as _;
use opentelemetry_sdk::metrics::SdkMeterProvider;
use opentelemetry_sdk::trace::{IdGenerator, RandomIdGenerator, SdkTracerProvider};
use opentelemetry_sdk::Resource;
use postrust_core::trace::TraceContext;
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::{Duration, Instant};
use tracing::Span;
use tracing_opentelemetry::OpenTelemetrySpanExt as _;
use tracing_subscriber::filter::filter_fn;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::Layer;
pub mod names {
pub const HTTP_SERVER_REQUEST_DURATION: &str = "http.server.request.duration";
pub const HTTP_SERVER_ACTIVE_REQUESTS: &str = "http.server.active_requests";
pub const DB_CLIENT_CONNECTION_COUNT: &str = "db.client.connection.count";
pub const DB_CLIENT_CONNECTION_MAX: &str = "db.client.connection.max";
pub const DB_CLIENT_CONNECTION_WAIT_TIME: &str = "db.client.connection.wait_time";
pub const JWT_CACHE_LOOKUPS: &str = "postrust.jwt.cache.lookups";
pub const SCHEMA_CACHE_RELOADS: &str = "postrust.schema_cache.reloads";
pub const GRAPHQL_SUBSCRIPTIONS_ACTIVE: &str = "postrust.graphql.subscriptions.active";
pub const JWT_JWKS_REFRESHES: &str = "postrust.jwt.jwks.refreshes";
pub const JWT_JWKS_KEY_SET_AGE: &str = "postrust.jwt.jwks.key_set.age";
pub const SCOPE: &str = "postrust";
}
fn env_set(name: &str) -> bool {
std::env::var_os(name).is_some_and(|value| !value.is_empty())
}
fn env_is(name: &str, expected: &str) -> bool {
std::env::var(name).is_ok_and(|value| value.trim().eq_ignore_ascii_case(expected))
}
fn otlp_configured(signal_endpoint: &str, signal_exporter: &str) -> bool {
!env_is("OTEL_SDK_DISABLED", "true")
&& !env_is(signal_exporter, "none")
&& (env_set("OTEL_EXPORTER_OTLP_ENDPOINT") || env_set(signal_endpoint))
}
pub fn traces_configured() -> bool {
otlp_configured("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_TRACES_EXPORTER")
}
pub fn metrics_otlp_configured() -> bool {
otlp_configured(
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_METRICS_EXPORTER",
)
}
fn warn_on_protocol() {
for var in [
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
"OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
] {
if let Ok(value) = std::env::var(var) {
if value.trim() != "http/protobuf" {
tracing::warn!(
"{var}={value} is not supported: Postrust exports OTLP over \
http/protobuf only, usually on port 4318"
);
}
}
}
}
pub fn resource() -> Resource {
let detected = Resource::builder().build();
let unnamed = detected
.get(&opentelemetry::Key::new("service.name"))
.is_none_or(|name| name.as_str().starts_with("unknown_service"));
let builder = Resource::builder()
.with_attribute(KeyValue::new("service.version", env!("CARGO_PKG_VERSION")));
match unnamed {
true => builder.with_service_name("postrust").build(),
false => builder.build(),
}
}
static TRACER_PROVIDER: OnceLock<SdkTracerProvider> = OnceLock::new();
static TRACING: AtomicBool = AtomicBool::new(false);
pub fn layer<S>() -> Option<impl Layer<S> + Send + Sync + 'static>
where
S: tracing::Subscriber + for<'span> LookupSpan<'span> + Send + Sync,
{
if !traces_configured() {
return None;
}
let client = match ReqwestClient::from_env() {
Ok(client) => client,
Err(e) => {
startup_warning(format!("OpenTelemetry traces are off: {e}"));
return None;
}
};
let exporter = match opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_http_client(client)
.build()
{
Ok(exporter) => exporter,
Err(e) => {
startup_warning(format!("OpenTelemetry traces are off: {e}"));
return None;
}
};
let provider = tracer_provider_builder()
.with_batch_exporter(exporter)
.build();
opentelemetry::global::set_tracer_provider(provider.clone());
let layer = layer_with(&provider);
let _ = TRACER_PROVIDER.set(provider);
Some(layer)
}
pub fn tracer_provider_builder() -> opentelemetry_sdk::trace::TracerProviderBuilder {
SdkTracerProvider::builder()
.with_resource(resource())
.with_id_generator(RequestTraceIds::default())
}
pub fn layer_with<S>(provider: &SdkTracerProvider) -> impl Layer<S> + Send + Sync + 'static
where
S: tracing::Subscriber + for<'span> LookupSpan<'span> + Send + Sync,
{
TRACING.store(true, Ordering::Release);
tracing_opentelemetry::layer()
.with_tracer(provider.tracer(names::SCOPE))
.with_threads(false)
.with_location(false)
.with_target(false)
.with_tracked_inactivity(false)
.with_filter(export_filter())
}
fn export_filter<S>() -> impl tracing_subscriber::layer::Filter<S> + Send + Sync + 'static {
const BENEATH: &[&str] = &[
"opentelemetry",
"tracing_opentelemetry",
"h2",
"hyper",
"reqwest",
"sqlx",
"tower",
"tokio",
"rustls",
];
filter_fn(|meta| {
let target = meta.target();
if meta.is_span() {
*meta.level() <= tracing::Level::DEBUG
&& !BENEATH.iter().any(|crate_name| {
target.starts_with(crate_name)
&& matches!(
target.as_bytes().get(crate_name.len()),
None | Some(b':' | b'_' | b'-')
)
})
} else {
*meta.level() <= tracing::Level::WARN && target.starts_with("postrust")
}
})
}
thread_local! {
static NEXT_TRACE_ID: Cell<Option<[u8; 16]>> = const { Cell::new(None) };
}
#[derive(Debug, Default)]
pub struct RequestTraceIds(RandomIdGenerator);
impl IdGenerator for RequestTraceIds {
fn new_trace_id(&self) -> opentelemetry::trace::TraceId {
match NEXT_TRACE_ID.with(Cell::take) {
Some(bytes) => opentelemetry::trace::TraceId::from_bytes(bytes),
None => self.0.new_trace_id(),
}
}
fn new_span_id(&self) -> opentelemetry::trace::SpanId {
self.0.new_span_id()
}
}
pub(crate) fn join_trace(span: &Span, trace: &TraceContext) {
if !TRACING.load(Ordering::Acquire) || span.is_disabled() {
return;
}
match trace.parent_id {
Some(parent) => {
let state = trace
.tracestate
.as_ref()
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<TraceState>().ok())
.unwrap_or_default();
let remote = SpanContext::new(
opentelemetry::trace::TraceId::from_bytes(trace.trace_id.to_bytes()),
opentelemetry::trace::SpanId::from_bytes(parent.to_bytes()),
TraceFlags::new(trace.flags),
true,
state,
);
let _ = span.set_parent(opentelemetry::Context::new().with_remote_span_context(remote));
}
None => {
NEXT_TRACE_ID.with(|next| next.set(Some(trace.trace_id.to_bytes())));
let _ = span.context();
NEXT_TRACE_ID.with(|next| next.set(None));
}
}
}
pub(crate) fn name_request_span(span: &Span, method: &http::Method, route: Option<&str>) {
if !TRACING.load(Ordering::Acquire) || span.is_disabled() {
return;
}
let method = known_method(method);
match route {
Some(route) => span.record("otel.name", format!("{method} {route}")),
None => span.record("otel.name", method),
};
}
fn known_method(method: &http::Method) -> &'static str {
match *method {
http::Method::GET => "GET",
http::Method::POST => "POST",
http::Method::PUT => "PUT",
http::Method::PATCH => "PATCH",
http::Method::DELETE => "DELETE",
http::Method::HEAD => "HEAD",
http::Method::OPTIONS => "OPTIONS",
http::Method::CONNECT => "CONNECT",
http::Method::TRACE => "TRACE",
_ => "_OTHER",
}
}
struct Instruments {
request_duration: Histogram<f64>,
active_requests: UpDownCounter<i64>,
transaction_wait: Histogram<f64>,
schema_reloads: opentelemetry::metrics::Counter<u64>,
}
static METER_PROVIDER: OnceLock<SdkMeterProvider> = OnceLock::new();
static INSTRUMENTS: OnceLock<Instruments> = OnceLock::new();
static PROMETHEUS: OnceLock<prometheus::Registry> = OnceLock::new();
static OBSERVED: Mutex<Option<Weak<AppState>>> = Mutex::new(None);
const DURATION_BOUNDARIES: &[f64] = &[
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0,
];
pub fn init_metrics(prometheus: bool) -> bool {
if METER_PROVIDER.get().is_some() {
return true;
}
let otlp = metrics_otlp_configured();
if !otlp && !prometheus {
return false;
}
let mut builder = SdkMeterProvider::builder().with_resource(resource());
if otlp {
match ReqwestClient::from_env().and_then(|client| {
opentelemetry_otlp::MetricExporter::builder()
.with_http()
.with_http_client(client)
.build()
.map_err(|e| e.to_string())
}) {
Ok(exporter) => {
builder = builder.with_reader(
opentelemetry_sdk::metrics::PeriodicReader::builder(exporter).build(),
);
}
Err(e) => tracing::warn!("OpenTelemetry metrics are not exported over OTLP: {e}"),
}
}
if prometheus {
let registry = prometheus::Registry::new();
match opentelemetry_prometheus::exporter()
.with_registry(registry.clone())
.scope_info_enabled(false)
.build()
{
Ok(exporter) => {
builder = builder.with_reader(exporter);
let _ = PROMETHEUS.set(registry);
}
Err(e) => tracing::warn!("/metrics is unavailable: {e}"),
}
}
let provider = builder.build();
let meter = provider.meter(names::SCOPE);
if INSTRUMENTS.set(instruments(&meter)).is_err() {
return true;
}
register_observers(&meter);
let _ = postrust_engine::observe::set_observer(EngineObserver);
opentelemetry::global::set_meter_provider(provider.clone());
let _ = METER_PROVIDER.set(provider);
true
}
pub fn metrics_enabled() -> bool {
INSTRUMENTS.get().is_some()
}
fn instruments(meter: &Meter) -> Instruments {
Instruments {
request_duration: meter
.f64_histogram(names::HTTP_SERVER_REQUEST_DURATION)
.with_unit("s")
.with_description("Duration of HTTP server requests.")
.with_boundaries(DURATION_BOUNDARIES.to_vec())
.build(),
active_requests: meter
.i64_up_down_counter(names::HTTP_SERVER_ACTIVE_REQUESTS)
.with_unit("{request}")
.with_description("Number of active HTTP server requests.")
.build(),
transaction_wait: meter
.f64_histogram(names::DB_CLIENT_CONNECTION_WAIT_TIME)
.with_unit("s")
.with_description(
"The time it took to obtain a connection from the pool and begin a transaction.",
)
.with_boundaries(vec![
0.0001, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5,
5.0, 10.0,
])
.build(),
schema_reloads: meter
.u64_counter(names::SCHEMA_CACHE_RELOADS)
.with_description("Schema cache reloads requested by NOTIFY.")
.build(),
}
}
const POOL_NAME: &str = "postrust";
fn register_observers(meter: &Meter) {
meter
.i64_observable_up_down_counter(names::DB_CLIENT_CONNECTION_COUNT)
.with_unit("{connection}")
.with_description("The number of connections that are currently in the state described.")
.with_callback(|observer| {
if let Some(state) = observed() {
let open = i64::from(state.pool.size());
let idle = state.pool.num_idle() as i64;
let pool = KeyValue::new("db.client.connection.pool.name", POOL_NAME);
observer.observe(
idle,
&[
pool.clone(),
KeyValue::new("db.client.connection.state", "idle"),
],
);
observer.observe(
(open - idle).max(0),
&[pool, KeyValue::new("db.client.connection.state", "used")],
);
}
})
.build();
meter
.i64_observable_up_down_counter(names::DB_CLIENT_CONNECTION_MAX)
.with_unit("{connection}")
.with_description("The maximum number of open connections allowed.")
.with_callback(|observer| {
if let Some(state) = observed() {
observer.observe(
i64::from(state.pool.options().get_max_connections()),
&[KeyValue::new("db.client.connection.pool.name", POOL_NAME)],
);
}
})
.build();
meter
.u64_observable_counter(names::JWT_CACHE_LOOKUPS)
.with_unit("{lookup}")
.with_description("Token cache lookups, by whether the cache answered.")
.with_callback(|observer| {
if let Some(stats) = observed()
.as_deref()
.and_then(|state| state.jwt_cache.as_ref())
.map(postrust_auth::JwtCache::stats)
{
observer.observe(stats.hits, &[KeyValue::new("outcome", "hit")]);
observer.observe(stats.misses, &[KeyValue::new("outcome", "miss")]);
}
})
.build();
meter
.u64_observable_counter(names::JWT_JWKS_REFRESHES)
.with_unit("{fetch}")
.with_description("JWKS fetches from PGRST_JWT_JWKS_URL, by outcome.")
.with_callback(|observer| {
if let Some(status) = jwks_status() {
observer.observe(status.refreshes, &[KeyValue::new("outcome", "success")]);
observer.observe(status.failures, &[KeyValue::new("outcome", "failure")]);
}
})
.build();
meter
.f64_observable_gauge(names::JWT_JWKS_KEY_SET_AGE)
.with_unit("s")
.with_description("How long ago the JWKS in use was last fetched.")
.with_callback(|observer| {
if let Some(age) = jwks_status().and_then(|status| status.age) {
observer.observe(age.as_secs_f64(), &[]);
}
})
.build();
#[cfg(feature = "graphql")]
meter
.i64_observable_up_down_counter(names::GRAPHQL_SUBSCRIPTIONS_ACTIVE)
.with_unit("{subscription}")
.with_description("GraphQL subscriptions open right now.")
.with_callback(|observer| {
observer.observe(postrust_graphql::subscription::active_subscriptions(), &[]);
})
.build();
}
fn observed() -> Option<Arc<AppState>> {
OBSERVED
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.and_then(Weak::upgrade)
}
fn jwks_status() -> Option<postrust_auth::jwks::JwksStatus> {
observed()?
.jwt_config
.jwks
.as_ref()
.map(postrust_auth::Jwks::status)
}
pub(crate) fn observe(state: &Arc<AppState>) {
*OBSERVED.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::downgrade(state));
}
pub(crate) fn schema_reloaded(succeeded: bool) {
if let Some(instruments) = INSTRUMENTS.get() {
let outcome = match succeeded {
true => "success",
false => "failure",
};
instruments
.schema_reloads
.add(1, &[KeyValue::new("outcome", outcome)]);
}
}
struct EngineObserver;
impl postrust_engine::observe::Observer for EngineObserver {
fn transaction_begun(&self, waited: Duration) {
if let Some(instruments) = INSTRUMENTS.get() {
instruments.transaction_wait.record(
waited.as_secs_f64(),
&[KeyValue::new("db.client.connection.pool.name", POOL_NAME)],
);
}
}
}
pub(crate) struct RequestMeter(Option<MeteredRequest>);
struct MeteredRequest {
instruments: &'static Instruments,
method: &'static str,
route: Option<Arc<str>>,
}
impl RequestMeter {
pub(crate) fn start(method: &http::Method, route: Option<&str>) -> Self {
let Some(instruments) = INSTRUMENTS.get() else {
return Self(None);
};
let method = known_method(method);
instruments
.active_requests
.add(1, &[KeyValue::new("http.request.method", method)]);
Self(Some(MeteredRequest {
instruments,
method,
route: route.map(Arc::from),
}))
}
pub(crate) fn finish(&self, status: http::StatusCode, started: Instant) {
let Some(request) = &self.0 else {
return;
};
let mut attributes = Vec::with_capacity(3);
attributes.push(KeyValue::new("http.request.method", request.method));
if let Some(route) = &request.route {
attributes.push(KeyValue::new("http.route", route.clone()));
}
attributes.push(KeyValue::new(
"http.response.status_code",
i64::from(status.as_u16()),
));
request
.instruments
.request_duration
.record(started.elapsed().as_secs_f64(), &attributes);
}
}
impl Drop for RequestMeter {
fn drop(&mut self) {
if let Some(request) = &self.0 {
request
.instruments
.active_requests
.add(-1, &[KeyValue::new("http.request.method", request.method)]);
}
}
}
pub fn prometheus_text() -> Option<String> {
let registry = PROMETHEUS.get()?;
let encoder = prometheus::TextEncoder::new();
match encoder.encode_to_string(®istry.gather()) {
Ok(text) => Some(text),
Err(e) => {
tracing::error!("Could not render /metrics: {e}");
Some(String::new())
}
}
}
pub(crate) async fn metrics_handler() -> axum::response::Response {
use axum::response::IntoResponse;
match prometheus_text() {
Some(text) => (
[(
http::header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
text,
)
.into_response(),
None => (
http::StatusCode::NOT_FOUND,
"metrics are not enabled: call postrust_server::otel::init_metrics(true)\n",
)
.into_response(),
}
}
pub fn force_flush() {
if let Some(provider) = TRACER_PROVIDER.get() {
if let Err(e) = provider.force_flush() {
tracing::warn!("Could not flush spans: {e}");
}
}
if let Some(provider) = METER_PROVIDER.get() {
if let Err(e) = provider.force_flush() {
tracing::warn!("Could not flush metrics: {e}");
}
}
}
pub fn exporting() -> bool {
TRACER_PROVIDER.get().is_some() || METER_PROVIDER.get().is_some()
}
pub async fn flush() {
if !exporting() {
return;
}
if let Err(e) = tokio::task::spawn_blocking(force_flush).await {
tracing::warn!("Could not flush telemetry: {e}");
}
}
pub async fn shutdown() {
if !exporting() {
return;
}
let _ = tokio::task::spawn_blocking(|| {
if let Some(provider) = TRACER_PROVIDER.get() {
let _ = provider.shutdown();
}
if let Some(provider) = METER_PROVIDER.get() {
let _ = provider.shutdown();
}
})
.await;
}
static STARTUP_WARNINGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
fn startup_warning(message: String) {
STARTUP_WARNINGS
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(message);
}
pub fn log_startup() {
warn_on_protocol();
for message in std::mem::take(&mut *STARTUP_WARNINGS.lock().unwrap_or_else(|e| e.into_inner()))
{
tracing::warn!("{message}");
}
if TRACER_PROVIDER.get().is_some() {
tracing::info!("OpenTelemetry: exporting traces over OTLP");
}
}
#[derive(Debug, Clone)]
pub struct ReqwestClient {
client: reqwest::Client,
runtime: tokio::runtime::Handle,
}
impl ReqwestClient {
pub fn from_env() -> Result<Self, String> {
let runtime = tokio::runtime::Handle::try_current()
.map_err(|_| "the exporter must be set up within the tokio runtime".to_string())?;
let timeout = std::env::var("OTEL_EXPORTER_OTLP_TIMEOUT")
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(Duration::from_secs(10));
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("could not build the HTTP client: {e}"))?;
Ok(Self { client, runtime })
}
}
#[async_trait::async_trait]
impl opentelemetry_http::HttpClient for ReqwestClient {
async fn send_bytes(
&self,
request: http::Request<bytes::Bytes>,
) -> Result<http::Response<bytes::Bytes>, opentelemetry_http::HttpError> {
let client = self.client.clone();
let request = reqwest::Request::try_from(request)?;
self.runtime
.spawn(async move {
let response = client.execute(request).await?;
let mut answer = http::Response::builder().status(response.status());
if let Some(headers) = answer.headers_mut() {
*headers = response.headers().clone();
}
let body = response.bytes().await?;
Ok::<_, opentelemetry_http::HttpError>(answer.body(body)?)
})
.await?
}
}