first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
//! Provides authenticated OTLP trace transport and credential refresh for opted-in cloud agents.
|
||||
//!
|
||||
//! Dispatch bootstraps tracing with a bearer token and expiry in the process environment. The
|
||||
//! exporter is built once around [`AuthenticatedHttpClient`], which reads a shared token snapshot
|
||||
//! immediately before every request so refresh never requires rebuilding the exporter. Processes
|
||||
//! without the endpoint switch or a currently valid dispatch credential never initialize this
|
||||
//! module.
|
||||
//!
|
||||
//! Refresh begins only after the application has an authenticated managed-secrets client. A
|
||||
//! successful mint replaces the dispatch credential only after the returned JWT's unverified
|
||||
//! payload contains a string `run_id` exactly matching the immutable startup `OZ_RUN_ID`. This
|
||||
//! payload inspection is only a rejection gate; the collector remains responsible for verifying
|
||||
//! the token's signature, audience, expiry, and trusted trace resource attributes. Every refresh
|
||||
//! failure preserves the last valid credential and enters bounded jittered backoff.
|
||||
//!
|
||||
//! Tokens must never appear in diagnostics or formatted values. Cached authorization headers are
|
||||
//! marked sensitive, manual `Debug` implementations omit secrets, and token-store locks are always
|
||||
//! released before network I/O.
|
||||
use std::fmt;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as _};
|
||||
use async_channel::{Receiver, Sender};
|
||||
use async_compat::Compat;
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures_util::stream::AbortHandle;
|
||||
use http::header::{HeaderValue, AUTHORIZATION};
|
||||
use instant::Instant;
|
||||
use opentelemetry_http::{Bytes, HttpClient, HttpError, Request, Response};
|
||||
use warp_managed_secrets::client::{IdentityTokenOptions, ManagedSecretsClient, TaskIdentityToken};
|
||||
use warpui::r#async::{FutureExt as _, Timer};
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
/// The environment variables form the immutable dispatch-time authentication bootstrap.
|
||||
const CLOUD_AGENT_OTLP_TOKEN: &str = "WARP_CLOUD_AGENT_OTLP_TOKEN";
|
||||
const CLOUD_AGENT_OTLP_TOKEN_EXPIRES_AT: &str = "WARP_CLOUD_AGENT_OTLP_TOKEN_EXPIRES_AT";
|
||||
const OZ_RUN_ID: &str = "OZ_RUN_ID";
|
||||
/// The collector audience and requested lifetime are fixed by the cloud-agent trace contract.
|
||||
const COLLECTOR_AUDIENCE: &str = "warp-cloud-agent-otel";
|
||||
const REFRESHED_TOKEN_DURATION: Duration = Duration::from_secs(60 * 60);
|
||||
/// Proactive refresh starts roughly twenty minutes before expiry, with jitter to spread load.
|
||||
const PROACTIVE_REFRESH_BUFFER: Duration = Duration::from_secs(20 * 60);
|
||||
const PROACTIVE_REFRESH_JITTER: Duration = Duration::from_secs(2 * 60);
|
||||
const MIN_PROACTIVE_REFRESH_DELAY: Duration = Duration::from_secs(1);
|
||||
/// Failed refreshes use bounded full-jitter exponential backoff and rate-limited diagnostics.
|
||||
const INITIAL_FAILURE_BACKOFF: Duration = Duration::from_secs(1);
|
||||
const MAX_FAILURE_BACKOFF: Duration = Duration::from_secs(5 * 60);
|
||||
const FAILURE_LOG_INTERVAL: Duration = Duration::from_secs(60);
|
||||
/// A stalled identity-token request must release the single in-flight refresh slot.
|
||||
const REFRESH_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Shared dispatch authentication state between the exporter and the later refresh coordinator.
|
||||
///
|
||||
/// The optional expected run ID intentionally does not gate initial tracing: a valid dispatch
|
||||
/// credential remains usable when `OZ_RUN_ID` is missing or empty, but every refreshed credential
|
||||
/// is rejected until an immutable expected run ID is available to the replacement gate.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct AuthContext {
|
||||
token_store: TokenStore,
|
||||
expected_run_id: Option<Arc<str>>,
|
||||
refresh_hint_sender: Sender<()>,
|
||||
refresh_hint_receiver: Arc<Mutex<Option<Receiver<()>>>>,
|
||||
}
|
||||
|
||||
impl AuthContext {
|
||||
/// Seeds authentication from a currently valid dispatch credential in the environment.
|
||||
///
|
||||
/// The caller treats failure as an opt-out so normal processes and partially rolled-out cloud
|
||||
/// agents retain no-op tracing behavior.
|
||||
pub(super) fn from_environment() -> anyhow::Result<Self> {
|
||||
let token =
|
||||
std::env::var(CLOUD_AGENT_OTLP_TOKEN).context("Cloud-agent OTLP token is missing")?;
|
||||
// Remove the bootstrap secret as soon as it is owned so child processes cannot inherit it.
|
||||
std::env::remove_var(CLOUD_AGENT_OTLP_TOKEN);
|
||||
let token = token.trim().to_owned();
|
||||
anyhow::ensure!(!token.is_empty(), "Cloud-agent OTLP token is empty");
|
||||
|
||||
let expires_at = std::env::var(CLOUD_AGENT_OTLP_TOKEN_EXPIRES_AT)
|
||||
.context("Cloud-agent OTLP token expiry is missing")?;
|
||||
let expires_at = DateTime::parse_from_rfc3339(expires_at.trim())
|
||||
.context("Cloud-agent OTLP token expiry is not valid RFC3339")?;
|
||||
anyhow::ensure!(
|
||||
expires_at.offset().local_minus_utc() == 0,
|
||||
"Cloud-agent OTLP token expiry is not UTC"
|
||||
);
|
||||
let expires_at = expires_at.with_timezone(&Utc);
|
||||
anyhow::ensure!(
|
||||
expires_at > Utc::now(),
|
||||
"Cloud-agent OTLP token is already expired"
|
||||
);
|
||||
let expected_run_id = std::env::var(OZ_RUN_ID)
|
||||
.ok()
|
||||
.filter(|run_id| !run_id.trim().is_empty());
|
||||
|
||||
let token_store = TokenStore::new(token, expires_at)?;
|
||||
let (refresh_hint_sender, refresh_hint_receiver) = async_channel::bounded(1);
|
||||
Ok(Self {
|
||||
token_store,
|
||||
expected_run_id: expected_run_id.map(Into::into),
|
||||
refresh_hint_sender,
|
||||
refresh_hint_receiver: Arc::new(Mutex::new(Some(refresh_hint_receiver))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a transport sharing the latest credential while leaving the exporter itself stable.
|
||||
pub(super) fn http_client(&self) -> AuthenticatedHttpClient {
|
||||
AuthenticatedHttpClient {
|
||||
inner: reqwest::Client::new(),
|
||||
token_store: self.token_store.clone(),
|
||||
refresh_hint_sender: self.refresh_hint_sender.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfers the bounded refresh-hint receiver to the one allowed coordinator.
|
||||
fn take_refresh_hint_receiver(&self) -> Option<Receiver<()>> {
|
||||
self.refresh_hint_receiver
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.take()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthContext {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AuthContext")
|
||||
.field("token_store", &self.token_store)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of the latest credential, stored behind a short-lived reader/writer lock.
|
||||
///
|
||||
/// Readers clone only the sensitive authorization header, and no caller holds this lock during
|
||||
/// network I/O. Replacement constructs and validates a complete snapshot before taking the write
|
||||
/// lock so failures preserve the last valid credential.
|
||||
#[derive(Clone)]
|
||||
struct TokenStore {
|
||||
inner: Arc<RwLock<TokenSnapshot>>,
|
||||
}
|
||||
|
||||
impl TokenStore {
|
||||
/// Creates the initial store from the validated dispatch credential.
|
||||
fn new(token: String, expires_at: DateTime<Utc>) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: Arc::new(RwLock::new(TokenSnapshot::new(token, expires_at)?)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a cloned sensitive header only while the current credential remains unexpired.
|
||||
fn valid_authorization_header(&self) -> Option<HeaderValue> {
|
||||
let snapshot = self.inner.read().unwrap_or_else(|err| err.into_inner());
|
||||
(snapshot.expires_at > Utc::now()).then(|| snapshot.authorization_header.clone())
|
||||
}
|
||||
|
||||
/// Atomically replaces the current snapshot only with a usable unexpired credential.
|
||||
fn replace(&self, token: String, expires_at: DateTime<Utc>) -> anyhow::Result<()> {
|
||||
anyhow::ensure!(
|
||||
expires_at > Utc::now(),
|
||||
"Refreshed cloud-agent OTLP token is already expired"
|
||||
);
|
||||
let snapshot = TokenSnapshot::new(token, expires_at)?;
|
||||
*self.inner.write().unwrap_or_else(|err| err.into_inner()) = snapshot;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies the exact-run rejection gate before allowing a refreshed credential to replace the
|
||||
/// dispatch or previous refresh credential.
|
||||
fn replace_refreshed(
|
||||
&self,
|
||||
token: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
expected_run_id: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
validate_refreshed_token_run_id(&token, expected_run_id)?;
|
||||
self.replace(token, expires_at)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TokenStore {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let snapshot = self.inner.read().unwrap_or_else(|err| err.into_inner());
|
||||
formatter
|
||||
.debug_struct("TokenStore")
|
||||
.field("expires_at", &snapshot.expires_at)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// An already-parsed sensitive authorization header and its trusted server expiry.
|
||||
struct TokenSnapshot {
|
||||
authorization_header: HeaderValue,
|
||||
expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TokenSnapshot {
|
||||
/// Constructs a snapshot whose header redacts its value from standard debug formatting.
|
||||
fn new(token: String, expires_at: DateTime<Utc>) -> anyhow::Result<Self> {
|
||||
let mut authorization_header = HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
.map_err(|_| anyhow!("Cloud-agent OTLP token cannot be used as an HTTP header"))?;
|
||||
authorization_header.set_sensitive(true);
|
||||
Ok(Self {
|
||||
authorization_header,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TokenSnapshot {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("TokenSnapshot")
|
||||
.field("authorization_header", &"<redacted>")
|
||||
.field("expires_at", &self.expires_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that the unverified refreshed-token payload names the immutable expected run exactly.
|
||||
///
|
||||
/// This local decode never establishes token authenticity. The collector remains responsible for
|
||||
/// cryptographically verifying the token, while malformed or mismatched tokens fail closed here
|
||||
/// before replacement and leave the existing credential untouched.
|
||||
fn validate_refreshed_token_run_id(
|
||||
token: &str,
|
||||
expected_run_id: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let expected_run_id = expected_run_id
|
||||
.filter(|run_id| !run_id.trim().is_empty())
|
||||
.context("Expected cloud-agent run ID is missing or empty")?;
|
||||
|
||||
let mut segments = token.split('.');
|
||||
let _header = segments
|
||||
.next()
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.context("Refreshed cloud-agent OTLP token is not a valid JWT")?;
|
||||
let payload = segments
|
||||
.next()
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.context("Refreshed cloud-agent OTLP token is not a valid JWT")?;
|
||||
let _signature = segments
|
||||
.next()
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.context("Refreshed cloud-agent OTLP token is not a valid JWT")?;
|
||||
anyhow::ensure!(
|
||||
segments.next().is_none(),
|
||||
"Refreshed cloud-agent OTLP token is not a valid JWT"
|
||||
);
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.map_err(|_| anyhow!("Refreshed cloud-agent OTLP token payload is not valid base64"))?;
|
||||
let payload: serde_json::Value = serde_json::from_slice(&payload)
|
||||
.map_err(|_| anyhow!("Refreshed cloud-agent OTLP token payload is not valid JSON"))?;
|
||||
let run_id = payload
|
||||
.get("run_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.context("Refreshed cloud-agent OTLP token has no string run ID")?;
|
||||
anyhow::ensure!(
|
||||
run_id == expected_run_id,
|
||||
"Refreshed cloud-agent OTLP token run ID does not match"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The set of errors that can occur when making an HTTP request using [`AuthenticatedHttpClient`].
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum AuthenticatedHttpError {
|
||||
#[error("No unexpired cloud-agent OTLP token is available")]
|
||||
NoValidToken,
|
||||
#[error("Cloud-agent OTLP request failed with HTTP status {0}")]
|
||||
HttpStatus(u16),
|
||||
}
|
||||
|
||||
/// An HTTP client that injects the latest valid token immediately before each request.
|
||||
///
|
||||
/// The token-store lock is released before network I/O begins. A manual `Debug` implementation
|
||||
/// prevents the client from formatting cached state, while sensitive [`HeaderValue`] instances
|
||||
/// redact request headers. Expired credentials are removed and refused rather than sent.
|
||||
pub(super) struct AuthenticatedHttpClient {
|
||||
inner: reqwest::Client,
|
||||
token_store: TokenStore,
|
||||
refresh_hint_sender: Sender<()>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthenticatedHttpClient {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AuthenticatedHttpClient")
|
||||
.field("token_store", &self.token_store)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthenticatedHttpClient {
|
||||
/// Overwrites any supplied authorization header with the latest unexpired credential.
|
||||
///
|
||||
/// Removing the supplied header first ensures an expired store fails closed rather than
|
||||
/// accidentally sending a stale or caller-provided credential.
|
||||
fn authorize_request(
|
||||
&self,
|
||||
request: &mut Request<Bytes>,
|
||||
) -> Result<(), AuthenticatedHttpError> {
|
||||
request.headers_mut().remove(AUTHORIZATION);
|
||||
let authorization = self
|
||||
.token_store
|
||||
.valid_authorization_header()
|
||||
.ok_or(AuthenticatedHttpError::NoValidToken)?;
|
||||
request.headers_mut().insert(AUTHORIZATION, authorization);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HttpClient for AuthenticatedHttpClient {
|
||||
async fn send_bytes(&self, mut request: Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
|
||||
self.authorize_request(&mut request)?;
|
||||
|
||||
let request: reqwest::Request = request.try_into()?;
|
||||
// Reqwest requires a Tokio-compatible context, while the exporter may use another executor.
|
||||
let (status, response) = Compat::new(async {
|
||||
let mut response = self.inner.execute(request).await?;
|
||||
let status = response.status();
|
||||
let response = if status.is_success() {
|
||||
let headers = std::mem::take(response.headers_mut());
|
||||
Some((headers, response.bytes().await?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok::<_, reqwest::Error>((status, response))
|
||||
})
|
||||
.await?;
|
||||
if status == http::StatusCode::UNAUTHORIZED {
|
||||
// The bounded nonblocking hint cannot recurse into or delay this export request.
|
||||
let _ = self.refresh_hint_sender.try_send(());
|
||||
}
|
||||
let Some((headers, body)) = response else {
|
||||
return Err(AuthenticatedHttpError::HttpStatus(status.as_u16()).into());
|
||||
};
|
||||
|
||||
let mut response = Response::builder().status(status).body(body)?;
|
||||
*response.headers_mut() = headers;
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the one refresh coordinator after authenticated server connectivity is available.
|
||||
///
|
||||
/// Consuming the bounded hint receiver coalesces concurrent starts, and the coordinator immediately
|
||||
/// mints once so the short-lived dispatch credential is replaced as soon as possible.
|
||||
pub(super) fn start_refresh_coordinator(
|
||||
auth_context: AuthContext,
|
||||
client: Arc<dyn ManagedSecretsClient>,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let Some(refresh_hint_receiver) = auth_context.take_refresh_hint_receiver() else {
|
||||
return;
|
||||
};
|
||||
ctx.add_singleton_model(move |ctx| {
|
||||
AuthRefreshCoordinator::new(
|
||||
auth_context.token_store,
|
||||
auth_context.expected_run_id,
|
||||
refresh_hint_receiver,
|
||||
client,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Owns serialized credential minting, proactive scheduling, failure backoff, and diagnostics.
|
||||
///
|
||||
/// At most one mint is in flight and one scheduled wakeup is retained. A bounded nonblocking 401
|
||||
/// hint can accelerate refresh without recursing into or blocking the export request.
|
||||
struct AuthRefreshCoordinator {
|
||||
token_store: TokenStore,
|
||||
expected_run_id: Option<Arc<str>>,
|
||||
client: Arc<dyn ManagedSecretsClient>,
|
||||
refresh_in_flight: bool,
|
||||
consecutive_failures: u32,
|
||||
scheduled_refresh: Option<AbortHandle>,
|
||||
last_failure_diagnostic: Option<Instant>,
|
||||
}
|
||||
|
||||
impl AuthRefreshCoordinator {
|
||||
/// Installs the hint stream and immediately starts the first bounded refresh request.
|
||||
fn new(
|
||||
token_store: TokenStore,
|
||||
expected_run_id: Option<Arc<str>>,
|
||||
refresh_hint_receiver: Receiver<()>,
|
||||
client: Arc<dyn ManagedSecretsClient>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let mut coordinator = Self {
|
||||
token_store,
|
||||
expected_run_id,
|
||||
client,
|
||||
refresh_in_flight: false,
|
||||
consecutive_failures: 0,
|
||||
scheduled_refresh: None,
|
||||
last_failure_diagnostic: None,
|
||||
};
|
||||
let _ = ctx.spawn_stream_local(
|
||||
refresh_hint_receiver,
|
||||
|coordinator, (), ctx| coordinator.start_refresh(ctx),
|
||||
|_, _| {},
|
||||
);
|
||||
coordinator.start_refresh(ctx);
|
||||
coordinator
|
||||
}
|
||||
|
||||
/// Starts one mint and coalesces all triggers while it remains in flight.
|
||||
///
|
||||
/// Each request asks for the fixed collector audience and principal-only subject, and the
|
||||
/// timeout guarantees a stalled request eventually enters the ordinary failure path.
|
||||
fn start_refresh(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.refresh_in_flight {
|
||||
return;
|
||||
}
|
||||
self.cancel_scheduled_refresh();
|
||||
self.refresh_in_flight = true;
|
||||
let client = self.client.clone();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
client
|
||||
.issue_task_identity_token(IdentityTokenOptions {
|
||||
audience: COLLECTOR_AUDIENCE.to_owned(),
|
||||
requested_duration: REFRESHED_TOKEN_DURATION,
|
||||
subject_template: vec1::vec1!["principal".to_owned()],
|
||||
})
|
||||
.with_timeout(REFRESH_REQUEST_TIMEOUT)
|
||||
.await
|
||||
.map_err(|_| anyhow!("Cloud-agent OTLP authorization refresh timed out"))?
|
||||
},
|
||||
|coordinator, result, ctx| coordinator.finish_refresh(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
/// Accepts a refreshed credential only after all replacement gates succeed.
|
||||
///
|
||||
/// Any mint, timeout, expiry, header, or run-ID failure retains the last valid token and enters
|
||||
/// the same bounded retry path without logging token contents.
|
||||
fn finish_refresh(
|
||||
&mut self,
|
||||
result: anyhow::Result<TaskIdentityToken>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.refresh_in_flight = false;
|
||||
match result {
|
||||
Ok(token) => {
|
||||
let expires_at = token.expires_at;
|
||||
if self
|
||||
.token_store
|
||||
.replace_refreshed(token.token, expires_at, self.expected_run_id.as_deref())
|
||||
.is_ok()
|
||||
{
|
||||
self.consecutive_failures = 0;
|
||||
log::info!("Cloud-agent OTLP authorization refreshed");
|
||||
self.schedule_proactive_refresh(expires_at, ctx);
|
||||
} else {
|
||||
self.warn_refresh_failure();
|
||||
self.schedule_failure_retry(ctx);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
self.warn_refresh_failure();
|
||||
self.schedule_failure_retry(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a refresh to occur before the current token expires.
|
||||
///
|
||||
/// This leaves some buffer for retries in case the refresh fails, but also guarantees
|
||||
/// some minimum amount of time before the first refresh attempt.
|
||||
fn schedule_proactive_refresh(
|
||||
&mut self,
|
||||
expires_at: DateTime<Utc>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let jitter = PROACTIVE_REFRESH_JITTER.mul_f64(rand::random::<f64>());
|
||||
let refresh_buffer = PROACTIVE_REFRESH_BUFFER.saturating_add(jitter);
|
||||
let remaining = (expires_at - Utc::now()).to_std().unwrap_or_default();
|
||||
let delay = remaining
|
||||
.saturating_sub(refresh_buffer)
|
||||
.max(remaining.mul_f64(0.5))
|
||||
.max(MIN_PROACTIVE_REFRESH_DELAY);
|
||||
self.schedule_refresh(delay, ctx);
|
||||
}
|
||||
|
||||
/// Schedules a full-jitter exponential retry capped at five minutes.
|
||||
fn schedule_failure_retry(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let exponent = self.consecutive_failures.min(31);
|
||||
let upper_bound = INITIAL_FAILURE_BACKOFF
|
||||
.saturating_mul(1u32 << exponent)
|
||||
.min(MAX_FAILURE_BACKOFF);
|
||||
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
|
||||
let delay = upper_bound.mul_f64(rand::random::<f64>());
|
||||
self.schedule_refresh(delay, ctx);
|
||||
}
|
||||
|
||||
/// Replaces the one scheduled wakeup so proactive, retry, and hint triggers stay coalesced.
|
||||
fn schedule_refresh(&mut self, delay: Duration, ctx: &mut ModelContext<Self>) {
|
||||
self.cancel_scheduled_refresh();
|
||||
let task = ctx.spawn(
|
||||
async move {
|
||||
Timer::after(delay).await;
|
||||
},
|
||||
|coordinator, _, ctx| {
|
||||
coordinator.scheduled_refresh = None;
|
||||
coordinator.start_refresh(ctx);
|
||||
},
|
||||
);
|
||||
self.scheduled_refresh = Some(task.abort_handle());
|
||||
}
|
||||
|
||||
/// Cancels the prior wakeup without affecting a refresh already in flight.
|
||||
fn cancel_scheduled_refresh(&mut self) {
|
||||
if let Some(handle) = self.scheduled_refresh.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a local token-free failure diagnostic at most once per configured interval.
|
||||
fn warn_refresh_failure(&mut self) {
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.last_failure_diagnostic
|
||||
.is_none_or(|last| now.duration_since(last) >= FAILURE_LOG_INTERVAL)
|
||||
{
|
||||
self.last_failure_diagnostic = Some(now);
|
||||
log::warn!("Cloud-agent OTLP authorization refresh failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AuthRefreshCoordinator {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for AuthRefreshCoordinator {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cloud_agent_auth_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,157 @@
|
||||
use base64::Engine as _;
|
||||
use chrono::TimeDelta;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn jwt_with_payload(payload: serde_json::Value) -> String {
|
||||
let encoder = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
let header = encoder.encode(br#"{"alg":"none"}"#);
|
||||
let payload = encoder.encode(serde_json::to_vec(&payload).unwrap());
|
||||
format!("{header}.{payload}.test-signature")
|
||||
}
|
||||
|
||||
fn client_with_expiry(token: &str, expires_at: DateTime<Utc>) -> AuthenticatedHttpClient {
|
||||
let (refresh_hint_sender, _) = async_channel::bounded(1);
|
||||
AuthenticatedHttpClient {
|
||||
inner: reqwest::Client::new(),
|
||||
token_store: TokenStore::new(token.to_owned(), expires_at).unwrap(),
|
||||
refresh_hint_sender,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_overwrites_supplied_header() {
|
||||
let client = client_with_expiry(
|
||||
"current-test-token",
|
||||
Utc::now() + TimeDelta::try_minutes(5).unwrap(),
|
||||
);
|
||||
let mut request = Request::builder()
|
||||
.header(AUTHORIZATION, "Bearer stale-test-token")
|
||||
.body(Bytes::new())
|
||||
.unwrap();
|
||||
|
||||
client.authorize_request(&mut request).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request.headers().get(AUTHORIZATION).unwrap(),
|
||||
"Bearer current-test-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_token_is_refused_and_supplied_header_is_removed() {
|
||||
let client = client_with_expiry(
|
||||
"expired-test-token",
|
||||
Utc::now() - TimeDelta::try_minutes(5).unwrap(),
|
||||
);
|
||||
let mut request = Request::builder()
|
||||
.header(AUTHORIZATION, "Bearer stale-test-token")
|
||||
.body(Bytes::new())
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.authorize_request(&mut request),
|
||||
Err(AuthenticatedHttpError::NoValidToken)
|
||||
));
|
||||
assert!(!request.headers().contains_key(AUTHORIZATION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_token() {
|
||||
let client = client_with_expiry(
|
||||
"secret-test-token",
|
||||
Utc::now() + TimeDelta::try_minutes(5).unwrap(),
|
||||
);
|
||||
|
||||
let debug_output = format!("{client:?}");
|
||||
|
||||
assert!(!debug_output.contains("secret-test-token"));
|
||||
assert!(debug_output.contains("expires_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorized_request_debug_redacts_token() {
|
||||
let client = client_with_expiry(
|
||||
"secret-request-test-token",
|
||||
Utc::now() + TimeDelta::try_minutes(5).unwrap(),
|
||||
);
|
||||
let mut request = Request::builder().body(Bytes::new()).unwrap();
|
||||
|
||||
client.authorize_request(&mut request).unwrap();
|
||||
let request_debug = format!("{request:?}");
|
||||
let headers_debug = format!("{:?}", request.headers());
|
||||
|
||||
assert!(!request_debug.contains("secret-request-test-token"));
|
||||
assert!(!headers_debug.contains("secret-request-test-token"));
|
||||
assert!(request_debug.contains("Sensitive"));
|
||||
assert!(headers_debug.contains("Sensitive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshed_token_run_id_exactly_matches() {
|
||||
let token = jwt_with_payload(serde_json::json!({ "run_id": "expected-run-id" }));
|
||||
|
||||
validate_refreshed_token_run_id(&token, Some("expected-run-id")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshed_token_run_id_is_required() {
|
||||
let token = jwt_with_payload(serde_json::json!({}));
|
||||
assert!(validate_refreshed_token_run_id(&token, Some("expected-run-id")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_run_id_is_required() {
|
||||
let token = jwt_with_payload(serde_json::json!({ "run_id": "expected-run-id" }));
|
||||
|
||||
assert!(validate_refreshed_token_run_id(&token, None).is_err());
|
||||
assert!(validate_refreshed_token_run_id(&token, Some("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshed_token_run_id_must_match() {
|
||||
let token = jwt_with_payload(serde_json::json!({ "run_id": "wrong-run-id" }));
|
||||
|
||||
assert!(validate_refreshed_token_run_id(&token, Some("expected-run-id")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshed_token_run_id_must_be_a_string() {
|
||||
let token = jwt_with_payload(serde_json::json!({ "run_id": 123 }));
|
||||
assert!(validate_refreshed_token_run_id(&token, Some("expected-run-id")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_refreshed_tokens_are_rejected() {
|
||||
let invalid_json = {
|
||||
let encoder = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
let payload = encoder.encode(b"not-json");
|
||||
format!("header.{payload}.signature")
|
||||
};
|
||||
|
||||
for token in ["not-a-jwt", "header.!!!.signature", &invalid_json] {
|
||||
assert!(validate_refreshed_token_run_id(token, Some("expected-run-id")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_refreshed_token_preserves_previous_token() {
|
||||
let token_store = TokenStore::new(
|
||||
"current-test-token".to_owned(),
|
||||
Utc::now() + TimeDelta::try_minutes(5).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let wrong_run_token = jwt_with_payload(serde_json::json!({ "run_id": "wrong-run-id" }));
|
||||
|
||||
assert!(token_store
|
||||
.replace_refreshed(
|
||||
wrong_run_token,
|
||||
Utc::now() + TimeDelta::try_minutes(5).unwrap(),
|
||||
Some("expected-run-id"),
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
token_store.valid_authorization_header().unwrap(),
|
||||
"Bearer current-test-token"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
//! Configures opt-in OpenTelemetry export for cloud-agent traces on native platforms.
|
||||
//!
|
||||
//! The global `tracing` subscriber observes the whole application, while
|
||||
//! [`CloudAgentSpanExporter`] limits OTLP export to spans explicitly marked with
|
||||
//! [`CLOUD_AGENT_MARKER`]. Keeping this selection at the exporter boundary lets callers use the
|
||||
//! normal `tracing` macros and propagation machinery without installing a second subscriber or
|
||||
//! coupling generic task executors to cloud-agent tracing.
|
||||
//!
|
||||
//! # Why spans must be ended during shutdown
|
||||
//!
|
||||
//! An OpenTelemetry span becomes exportable and passes owned span data to a processor's `on_end`
|
||||
//! callback only after it ends. Shutting down an [`SdkTracerProvider`] flushes spans that have
|
||||
//! reached `on_end`, but it does not end spans that are still active. Some `tracing::Span`
|
||||
//! references are intentionally propagated into asynchronous task machinery and can therefore
|
||||
//! remain alive when the application terminates. Shutting down the provider before those spans end
|
||||
//! would silently discard them.
|
||||
//!
|
||||
//! [`ShutdownAwareTracer`] and [`ShutdownAwareSpan`] wrap the SDK tracer and spans used by
|
||||
//! `tracing-opentelemetry`. This keeps existing `tracing` instrumentation unchanged while allowing
|
||||
//! [`ActiveSpanRegistry`] to explicitly end still-reachable, registered SDK spans before shutting
|
||||
//! down the provider. The standard application lifecycle retains [`Initialization`] in its
|
||||
//! termination callback so this ordering happens before platforms that terminate the process
|
||||
//! without running Rust destructors. [`Initialization`]'s `Drop` implementation remains a fallback
|
||||
//! for ordinary returns. Explicit process exits bypass both forms of cleanup.
|
||||
//!
|
||||
//! # Span ownership and synchronization
|
||||
//!
|
||||
//! `tracing-opentelemetry` creates SDK spans lazily during several `tracing` span lifecycle
|
||||
//! operations, including when it needs a span's context and when a span closes. Every SDK span that
|
||||
//! reaches [`ShutdownAwareTracer::build_with_context`] is wrapped in an `Arc<Mutex<_>>`. Before
|
||||
//! shutdown begins, it is weakly registered; after shutdown begins, it is immediately ended
|
||||
//! instead. The wrapper remains the span's owner; the registry uses weak references so tracking
|
||||
//! does not extend normal span lifetimes. An SDK span that has not yet been built when shutdown
|
||||
//! begins cannot reach `on_end` before provider shutdown. If it materializes later, it is ended too
|
||||
//! late for export.
|
||||
//!
|
||||
//! SDK-span creation and shutdown are serialized by the registry-state mutex. Shutdown keeps that
|
||||
//! mutex locked while it ends every still-upgradeable registered span and shuts down the provider,
|
||||
//! preventing an SDK span from being created in the otherwise-dangerous gap between those
|
||||
//! operations. A final span owner can begin dropping after its weak reference becomes impossible
|
||||
//! to upgrade, so shutdown cannot strictly guarantee that every previously registered span has
|
||||
//! finished ending. The lock order is always registry state followed by an individual SDK span.
|
||||
//! Normal span operations lock only the individual SDK span and never attempt to lock the registry.
|
||||
//! Mutex acquisition recovers poisoned inner values because trace export and shutdown are
|
||||
//! best-effort cleanup that should continue after an unrelated panic.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use anyhow::{anyhow, Context as _};
|
||||
use instant::Instant;
|
||||
use opentelemetry::trace::{
|
||||
Span as _, SpanBuilder, SpanContext, Status, Tracer as _, TracerProvider as _,
|
||||
};
|
||||
use opentelemetry::{Context as OtelContext, KeyValue, Value};
|
||||
use opentelemetry_otlp::{Protocol, WithExportConfig as _, WithHttpConfig as _};
|
||||
use opentelemetry_sdk::error::OTelSdkResult;
|
||||
use opentelemetry_sdk::resource::{EnvResourceDetector, TelemetryResourceDetector};
|
||||
use opentelemetry_sdk::trace::{
|
||||
SdkTracer, SdkTracerProvider, Span as SdkSpan, SpanData, SpanExporter,
|
||||
};
|
||||
use opentelemetry_sdk::Resource;
|
||||
use tracing::subscriber;
|
||||
use tracing_subscriber::layer::SubscriberExt as _;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use url::{Host, Url};
|
||||
use warp_managed_secrets::client::ManagedSecretsClient;
|
||||
use warpui::AppContext;
|
||||
|
||||
use super::cloud_agent_auth::{self, AuthContext};
|
||||
use super::Initialization;
|
||||
use crate::channel::ChannelState;
|
||||
use crate::tracing::install_no_subscriber;
|
||||
|
||||
/// The tag used to mark spans related to cloud agents, which we use to filter out
|
||||
/// spans we don't care about (e.g.: ones from dependencies).
|
||||
const CLOUD_AGENT_MARKER: &str = "tags.cloud_agent";
|
||||
/// The environment variable used to configure the cloud agent OTLP endpoint.
|
||||
const CLOUD_AGENT_OTLP_ENDPOINT: &str = "WARP_CLOUD_AGENT_OTLP_ENDPOINT";
|
||||
/// The environment variable used to configure the OTel service name.
|
||||
const OTEL_SERVICE_NAME: &str = "OTEL_SERVICE_NAME";
|
||||
/// The minimum interval between local export failure diagnostics.
|
||||
const EXPORT_FAILURE_LOG_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Process-global authentication context for cloud-agent OTLP export.
|
||||
///
|
||||
/// The exporter is built once during [`init`], while the stored context later starts dynamic
|
||||
/// credential refresh after authenticated application services become available, and this static
|
||||
/// remains unset for processes that did not opt in.
|
||||
static AUTH_CONTEXT: OnceLock<AuthContext> = OnceLock::new();
|
||||
|
||||
/// Installs the native tracing subscriber and optional cloud-agent OTLP exporter.
|
||||
///
|
||||
/// Export is deliberately opt-in through [`CLOUD_AGENT_OTLP_ENDPOINT`] plus a currently valid
|
||||
/// dispatch token. When either is absent or the exporter cannot be constructed, a no-op subscriber
|
||||
/// is installed so tracing instrumentation remains safe without producing output or partially
|
||||
/// initializing export.
|
||||
pub fn init() -> anyhow::Result<Initialization> {
|
||||
// INFO is the default because this is a global subscriber and DEBUG-level application spans
|
||||
// would otherwise create substantial work even though only marked cloud-agent spans are
|
||||
// exported. RUST_LOG can still override this when deeper tracing is needed.
|
||||
let env_filter = EnvFilter::builder()
|
||||
.with_default_directive(tracing::Level::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
let Some(base_endpoint) = std::env::var(CLOUD_AGENT_OTLP_ENDPOINT)
|
||||
.ok()
|
||||
.filter(|endpoint| !endpoint.trim().is_empty())
|
||||
else {
|
||||
install_no_subscriber()?;
|
||||
return Ok(Initialization::default());
|
||||
};
|
||||
let Ok(auth_context) = AuthContext::from_environment() else {
|
||||
install_no_subscriber()?;
|
||||
return Ok(Initialization::default());
|
||||
};
|
||||
|
||||
let shutdown_timeout = export_timeout();
|
||||
let provider = match build_provider(base_endpoint.trim(), &auth_context) {
|
||||
Ok(provider) => provider,
|
||||
Err(err) => {
|
||||
install_no_subscriber()?;
|
||||
return Ok(Initialization {
|
||||
initialization_warning: Some(err),
|
||||
active_spans: None,
|
||||
provider: None,
|
||||
shutdown_timeout,
|
||||
});
|
||||
}
|
||||
};
|
||||
let _ = AUTH_CONTEXT.set(auth_context);
|
||||
|
||||
let active_spans = ActiveSpanRegistry::default();
|
||||
let tracer =
|
||||
ShutdownAwareTracer::new(provider.tracer("warp-cloud-agent"), active_spans.clone());
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(tracing_opentelemetry::layer().with_tracer(tracer));
|
||||
subscriber::set_global_default(subscriber)?;
|
||||
|
||||
Ok(Initialization {
|
||||
initialization_warning: None,
|
||||
active_spans: Some(active_spans),
|
||||
provider: Some(provider),
|
||||
shutdown_timeout,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the SDK provider and its batch exporter.
|
||||
///
|
||||
/// A batch exporter keeps network export off instrumentation call sites. The provider is retained
|
||||
/// by [`Initialization`] so application termination can explicitly shut it down after attempting
|
||||
/// to end registered active spans. Exported resources include Warp's version and channel alongside
|
||||
/// standard environment-detected OpenTelemetry attributes, with [`OTEL_SERVICE_NAME`] taking
|
||||
/// precedence over the default service name. The exporter is built once with a dynamic HTTP client
|
||||
/// so credential refresh can update requests without reconstructing provider state.
|
||||
fn build_provider(
|
||||
base_endpoint: &str,
|
||||
auth_context: &AuthContext,
|
||||
) -> anyhow::Result<SdkTracerProvider> {
|
||||
let endpoint = traces_endpoint(base_endpoint)?;
|
||||
let exporter = opentelemetry_otlp::SpanExporter::builder()
|
||||
.with_http()
|
||||
.with_http_client(auth_context.http_client())
|
||||
.with_protocol(Protocol::HttpBinary)
|
||||
.with_endpoint(endpoint)
|
||||
.build()
|
||||
.context("Failed to build the OTLP span exporter")?;
|
||||
|
||||
let resource = Resource::builder_empty()
|
||||
.with_service_name("warp-cloud-agent")
|
||||
.with_attribute(KeyValue::new(
|
||||
"service.version",
|
||||
ChannelState::app_version().unwrap_or("<no tag>"),
|
||||
))
|
||||
.with_attribute(KeyValue::new(
|
||||
"warp.channel",
|
||||
ChannelState::channel().to_string(),
|
||||
))
|
||||
.with_detector(Box::new(TelemetryResourceDetector))
|
||||
.with_detector(Box::new(EnvResourceDetector::new()));
|
||||
let resource = match std::env::var(OTEL_SERVICE_NAME) {
|
||||
Ok(service_name) if !service_name.is_empty() => resource.with_service_name(service_name),
|
||||
Ok(_) | Err(_) => resource,
|
||||
}
|
||||
.build();
|
||||
|
||||
Ok(SdkTracerProvider::builder()
|
||||
.with_batch_exporter(CloudAgentSpanExporter {
|
||||
inner: exporter,
|
||||
diagnostics: RateLimitedDiagnostics::default(),
|
||||
})
|
||||
.with_resource(resource)
|
||||
.build())
|
||||
}
|
||||
|
||||
/// Starts the single refresh coordinator after the authenticated server client exists.
|
||||
///
|
||||
/// Processes that did not opt in with both an endpoint and valid dispatch credential have no
|
||||
/// retained [`AUTH_CONTEXT`] and remain no-ops here.
|
||||
pub(super) fn start_auth_refresh(client: Arc<dyn ManagedSecretsClient>, ctx: &mut AppContext) {
|
||||
if let Some(auth_context) = AUTH_CONTEXT.get() {
|
||||
cloud_agent_auth::start_refresh_coordinator(auth_context.clone(), client, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the configured OTLP base URL into the HTTP/protobuf traces endpoint.
|
||||
///
|
||||
/// The configuration is treated as a base URL rather than a complete signal-specific URL, so any
|
||||
/// query or fragment is discarded before appending `v1/traces`. Authenticated export requires
|
||||
/// HTTPS unless the configured host is guaranteed to resolve to the local machine.
|
||||
fn traces_endpoint(base_endpoint: &str) -> anyhow::Result<String> {
|
||||
let mut endpoint = Url::parse(base_endpoint).context("Invalid cloud-agent OTLP endpoint")?;
|
||||
match endpoint.scheme() {
|
||||
"https" => {}
|
||||
"http" if endpoint_host_is_loopback(&endpoint) => {}
|
||||
"http" => {
|
||||
return Err(anyhow!(
|
||||
"Cloud-agent OTLP endpoint must use HTTPS unless its host is loopback"
|
||||
));
|
||||
}
|
||||
_ => return Err(anyhow!("Cloud-agent OTLP endpoint must use HTTP or HTTPS")),
|
||||
}
|
||||
|
||||
endpoint.set_query(None);
|
||||
endpoint.set_fragment(None);
|
||||
endpoint
|
||||
.path_segments_mut()
|
||||
.map_err(|_| anyhow!("Cloud-agent OTLP endpoint cannot be used as a base URL"))?
|
||||
.pop_if_empty()
|
||||
.extend(["v1", "traces"]);
|
||||
Ok(endpoint.into())
|
||||
}
|
||||
/// Returns whether the endpoint host is guaranteed to resolve to the local machine.
|
||||
fn endpoint_host_is_loopback(endpoint: &Url) -> bool {
|
||||
match endpoint.host() {
|
||||
Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
|
||||
Some(Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(Host::Ipv6(address)) => address.is_loopback(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the export shutdown timeout using the standard OpenTelemetry environment variables.
|
||||
fn export_timeout() -> Duration {
|
||||
[
|
||||
"OTEL_EXPORTER_OTLP_TRACES_TIMEOUT",
|
||||
"OTEL_EXPORTER_OTLP_TIMEOUT",
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|name| {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.map(Duration::from_millis)
|
||||
})
|
||||
.unwrap_or(super::DEFAULT_EXPORT_TIMEOUT)
|
||||
}
|
||||
|
||||
/// A registry of started SDK spans used for best-effort ending before provider shutdown.
|
||||
///
|
||||
/// This registry belongs beside the provider in [`Initialization`]. It stores only weak references
|
||||
/// so a span that ends normally can be dropped without first unregistering itself.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct ActiveSpanRegistry {
|
||||
state: Arc<Mutex<ActiveSpanRegistryState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ActiveSpanRegistryState {
|
||||
/// Prevents new spans from remaining active after shutdown begins.
|
||||
shutting_down: bool,
|
||||
/// Weak references avoid extending the lifetime of spans that end normally.
|
||||
spans: Vec<Weak<Mutex<SdkSpan>>>,
|
||||
}
|
||||
|
||||
impl ActiveSpanRegistry {
|
||||
/// Builds an SDK span, registering it before shutdown or ending it after shutdown begins.
|
||||
///
|
||||
/// `tracing-opentelemetry` calls this whenever it materializes an SDK span. If shutdown has
|
||||
/// already begun, the newly built span is ended immediately rather than being allowed to
|
||||
/// remain active.
|
||||
fn build_span(
|
||||
&self,
|
||||
tracer: &SdkTracer,
|
||||
builder: SpanBuilder,
|
||||
parent_cx: &OtelContext,
|
||||
) -> ShutdownAwareSpan {
|
||||
let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner());
|
||||
let span = tracer.build_with_context(builder, parent_cx);
|
||||
let span_context = span.span_context().clone();
|
||||
let span = Arc::new(Mutex::new(span));
|
||||
if state.shutting_down {
|
||||
span.lock().unwrap_or_else(|err| err.into_inner()).end();
|
||||
} else {
|
||||
// Dead weak references are pruned opportunistically to avoid requiring normal span
|
||||
// completion to acquire the registry lock.
|
||||
state.spans.retain(|span| span.strong_count() > 0);
|
||||
state.spans.push(Arc::downgrade(&span));
|
||||
}
|
||||
ShutdownAwareSpan {
|
||||
span_context,
|
||||
inner: span,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends every still-upgradeable registered span and then shuts down the provider.
|
||||
///
|
||||
/// The registry lock intentionally remains held through provider shutdown. This guarantees
|
||||
/// that no SDK span can be built between the final end attempt and the provider becoming unable
|
||||
/// to accept ended spans. It does not synchronize with the final drop of a span whose weak
|
||||
/// reference can no longer be upgraded, so ending previously built spans remains best-effort.
|
||||
pub(super) fn shutdown(
|
||||
&self,
|
||||
provider: &SdkTracerProvider,
|
||||
timeout: Duration,
|
||||
) -> OTelSdkResult {
|
||||
let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner());
|
||||
state.shutting_down = true;
|
||||
let spans = std::mem::take(&mut state.spans);
|
||||
|
||||
for span in spans {
|
||||
if let Some(span) = span.upgrade() {
|
||||
span.lock().unwrap_or_else(|err| err.into_inner()).end();
|
||||
}
|
||||
}
|
||||
let result = provider.shutdown_with_timeout(timeout);
|
||||
drop(state);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// An [`SdkTracer`] adapter that routes spans through shutdown-aware construction.
|
||||
///
|
||||
/// Wrapping the tracer, rather than the span processor, is necessary because processors receive
|
||||
/// only a temporary mutable reference in `on_start` and receive owned exportable data only after
|
||||
/// `on_end`. A processor therefore cannot retain handles to, or end, active spans during shutdown.
|
||||
#[derive(Clone, Debug)]
|
||||
struct ShutdownAwareTracer {
|
||||
inner: SdkTracer,
|
||||
active_spans: ActiveSpanRegistry,
|
||||
}
|
||||
|
||||
impl ShutdownAwareTracer {
|
||||
fn new(inner: SdkTracer, active_spans: ActiveSpanRegistry) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
active_spans,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl opentelemetry::trace::Tracer for ShutdownAwareTracer {
|
||||
type Span = ShutdownAwareSpan;
|
||||
|
||||
fn build_with_context(&self, builder: SpanBuilder, parent_cx: &OtelContext) -> Self::Span {
|
||||
self.active_spans
|
||||
.build_span(&self.inner, builder, parent_cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// A synchronized wrapper around an SDK span shared with [`ActiveSpanRegistry`].
|
||||
///
|
||||
/// The immutable [`SpanContext`] is cached outside the mutex because the OpenTelemetry
|
||||
/// [`opentelemetry::trace::Span`] trait must return it by reference. All mutable SDK-span operations
|
||||
/// are forwarded through the mutex, allowing shutdown to end the same underlying span. Repeated
|
||||
/// end calls are harmless because SDK spans export only once.
|
||||
#[derive(Debug)]
|
||||
struct ShutdownAwareSpan {
|
||||
span_context: SpanContext,
|
||||
inner: Arc<Mutex<SdkSpan>>,
|
||||
}
|
||||
|
||||
impl opentelemetry::trace::Span for ShutdownAwareSpan {
|
||||
fn add_event_with_timestamp<T>(
|
||||
&mut self,
|
||||
name: T,
|
||||
timestamp: SystemTime,
|
||||
attributes: Vec<KeyValue>,
|
||||
) where
|
||||
T: Into<Cow<'static, str>>,
|
||||
{
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.add_event_with_timestamp(name, timestamp, attributes);
|
||||
}
|
||||
|
||||
fn span_context(&self) -> &SpanContext {
|
||||
&self.span_context
|
||||
}
|
||||
|
||||
fn is_recording(&self) -> bool {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.is_recording()
|
||||
}
|
||||
|
||||
fn set_attribute(&mut self, attribute: KeyValue) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.set_attribute(attribute);
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: Status) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.set_status(status);
|
||||
}
|
||||
|
||||
fn update_name<T>(&mut self, new_name: T)
|
||||
where
|
||||
T: Into<Cow<'static, str>>,
|
||||
{
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.update_name(new_name);
|
||||
}
|
||||
|
||||
fn add_link(&mut self, span_context: SpanContext, attributes: Vec<KeyValue>) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.add_link(span_context, attributes);
|
||||
}
|
||||
|
||||
fn end_with_timestamp(&mut self, timestamp: SystemTime) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.end_with_timestamp(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// An exporter that restricts the shared tracing subscriber's output to explicitly marked
|
||||
/// cloud-agent spans.
|
||||
///
|
||||
/// Filtering here preserves normal parent/context propagation inside the application while
|
||||
/// ensuring unrelated application tracing is never sent to the configured cloud-agent endpoint.
|
||||
/// The marker is a per-span routing attribute rather than an inherited property, so every span
|
||||
/// intended for export must set it explicitly.
|
||||
struct CloudAgentSpanExporter {
|
||||
inner: opentelemetry_otlp::SpanExporter,
|
||||
diagnostics: RateLimitedDiagnostics,
|
||||
}
|
||||
impl std::fmt::Debug for CloudAgentSpanExporter {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("CloudAgentSpanExporter")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl SpanExporter for CloudAgentSpanExporter {
|
||||
fn export(
|
||||
&self,
|
||||
batch: Vec<SpanData>,
|
||||
) -> impl std::future::Future<Output = OTelSdkResult> + Send {
|
||||
let batch: Vec<_> = batch
|
||||
.into_iter()
|
||||
.filter_map(filter_cloud_agent_span)
|
||||
.collect();
|
||||
|
||||
async move {
|
||||
if batch.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = self.inner.export(batch).await;
|
||||
if result.is_err() {
|
||||
self.diagnostics.warn_export_failure();
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
|
||||
let result = self.inner.shutdown_with_timeout(timeout);
|
||||
if let Err(err) = &result {
|
||||
log::warn!("Failed to shut down the cloud-agent OpenTelemetry span exporter: {err}");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn force_flush(&self) -> OTelSdkResult {
|
||||
let result = self.inner.force_flush();
|
||||
if let Err(err) = &result {
|
||||
log::warn!("Failed to flush the cloud-agent OpenTelemetry span exporter: {err}");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn set_resource(&mut self, resource: &Resource) {
|
||||
self.inner.set_resource(resource);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate-limits local token-free export diagnostics independently of exporter retries.
|
||||
#[derive(Debug, Default)]
|
||||
struct RateLimitedDiagnostics {
|
||||
last_export_failure: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl RateLimitedDiagnostics {
|
||||
/// Emits at most one local export-failure warning per configured interval.
|
||||
fn warn_export_failure(&self) {
|
||||
let now = Instant::now();
|
||||
let mut last_failure = self
|
||||
.last_export_failure
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner());
|
||||
if last_failure.is_none_or(|last| now.duration_since(last) >= EXPORT_FAILURE_LOG_INTERVAL) {
|
||||
*last_failure = Some(now);
|
||||
log::warn!("Failed to export cloud-agent OpenTelemetry spans");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes unrelated spans and strips the internal routing marker from spans and events before
|
||||
/// export.
|
||||
fn filter_cloud_agent_span(mut span: SpanData) -> Option<SpanData> {
|
||||
let is_cloud_agent_span = span.attributes.iter().any(|attribute| {
|
||||
attribute.key.as_str() == CLOUD_AGENT_MARKER && attribute.value == Value::Bool(true)
|
||||
});
|
||||
if !is_cloud_agent_span {
|
||||
return None;
|
||||
}
|
||||
|
||||
span.attributes
|
||||
.retain(|attribute| attribute.key.as_str() != CLOUD_AGENT_MARKER);
|
||||
for event in &mut span.events.events {
|
||||
event
|
||||
.attributes
|
||||
.retain(|attribute| attribute.key.as_str() != CLOUD_AGENT_MARKER);
|
||||
}
|
||||
Some(span)
|
||||
}
|
||||
Reference in New Issue
Block a user