use axum::Json;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::errors::OrionError;
use crate::server::extract::{OrionQuery, PeerAddr};
use crate::server::routes::openapi::ErrorResponse;
use crate::server::routes::openapi::{DataEnvelope, TraceDetail, TracePageEnvelope};
use crate::server::routes::response_helpers::data_response;
use crate::server::state::AppState;
use crate::storage::models::TraceListItemResponse;
use crate::storage::repositories::traces::TraceFilter;
#[utoipa::path(
get,
path = "/api/v1/admin/traces",
tag = "Traces",
params(TraceFilter),
responses(
(status = 200, description = "Page of traces", body = TracePageEnvelope),
(status = 400, description = "Malformed cursor, or cursor combined with offset or a non-default sort", body = ErrorResponse),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn list_traces(
State(state): State<AppState>,
OrionQuery(filter): OrionQuery<TraceFilter>,
) -> Result<Json<Value>, OrionError> {
let result = state.repos.traces.list_paginated(&filter).await?;
let rows: Vec<TraceListItemResponse> = result
.data
.iter()
.map(TraceListItemResponse::from)
.collect();
let mut body = json!({
"data": rows,
"limit": result.limit,
"offset": result.offset,
});
if let Some(total) = result.total {
body["total"] = json!(total);
}
if let Some(cursor) = result.next_cursor {
body["next_cursor"] = json!(cursor);
}
Ok(Json(body))
}
#[derive(Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub(crate) struct TraceAccessQuery {
token: Option<String>,
}
const NO_STORE: (axum::http::header::HeaderName, &str) =
(axum::http::header::CACHE_CONTROL, "no-store");
#[derive(Clone, Copy, PartialEq)]
enum TraceLane {
Supported,
QueryToken,
}
#[utoipa::path(
get,
path = "/api/v1/admin/traces/{id}",
tag = "Traces",
description = "\
Fetch one trace. Access follows a two-lane rule (R12): present either a \
valid admin credential, or — for async submissions — the `trace_token` \
returned with the 202, in the `x-trace-token` header. Traces without a token \
(sync traces, DLQ retries, rows from before 1.0.0) are admin-plane only when \
admin auth is enabled.\n\n\
The `?token=` query parameter is a **deprecated** alternative for clients \
that cannot set headers. Prefer the header: a URL is not a private place, \
and the token leaks into browser history, proxy and CDN logs, `Referer` \
headers and anywhere a link is pasted. Reads authorised that way answer with \
a `Deprecation` header.\n\n\
Every response carries `Cache-Control: no-store` — the body is the \
submission's result and the capability is not an `Authorization` header, so \
nothing else stops a shared cache storing it.",
params(
("id" = String, Path, description = "Trace ID"),
TraceAccessQuery,
),
responses(
(status = 200, description = "Trace status and result", body = DataEnvelope<TraceDetail>),
(status = 401, description = "Missing or wrong trace token / admin credential", body = ErrorResponse),
(status = 404, description = "Trace not found", body = ErrorResponse),
)
)]
#[tracing::instrument(skip(state, headers, query))]
pub(crate) async fn get_trace(
State(state): State<AppState>,
Path(id): Path<String>,
OrionQuery(query): OrionQuery<TraceAccessQuery>,
PeerAddr(peer): PeerAddr,
headers: axum::http::HeaderMap,
) -> Result<Response, OrionError> {
let client = crate::server::rate_limit::client_ip_from_parts(
peer.as_ref(),
&headers,
state.trusted_proxies(),
);
if state.config.admin_auth.enabled
&& let Some(remaining) = state.admin_auth_failures.locked_for(&client)
{
crate::metrics::record_admin_auth_failure("locked_out");
tracing::warn!(
client = %client,
remaining_ms = remaining.as_millis() as u64,
"Trace read refused: client is in failed-auth backoff"
);
return Err(OrionError::Unauthorized(
"This trace requires its trace_token (returned with the async 202) or an admin credential".into(),
));
}
let trace = state.repos.traces.get_by_id(&id).await?;
let auth_cfg = &state.config.admin_auth;
let is_admin = auth_cfg.enabled
&& crate::server::admin_auth::headers_present_valid_key(&headers, auth_cfg);
let mut lane = TraceLane::Supported;
if !is_admin {
let header_token = headers
.get("x-trace-token")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let presented = match header_token {
Some(token) => Some(token),
None => {
if query.token.is_some() {
lane = TraceLane::QueryToken;
}
query.token.clone()
}
};
let allowed = match trace.access_token_hash.as_deref() {
Some(stored) => presented
.as_deref()
.is_some_and(|t| crate::server::admin_auth::trace_token_matches(t, stored)),
None => !auth_cfg.enabled,
};
if !allowed {
if auth_cfg.enabled {
let lockout = state.admin_auth_failures.record_failure(&client);
crate::metrics::record_admin_auth_failure("invalid_key");
tracing::warn!(
client = %client,
lockout_ms = lockout.map(|d| d.as_millis() as u64),
"Trace read refused: neither a valid trace token nor an admin credential"
);
}
return Err(OrionError::Unauthorized(
"This trace requires its trace_token (returned with the async 202) or an admin credential".into(),
));
}
if lane == TraceLane::QueryToken {
crate::metrics::record_trace_token_query_read();
tracing::debug!(
trace_id = %id,
"Trace read authorised by the deprecated `?token=` query parameter"
);
}
if auth_cfg.enabled {
state.admin_auth_failures.record_success(&client);
}
} else {
state.admin_auth_failures.record_success(&client);
}
let mut response = json!({
"id": trace.id,
"status": trace.status,
"mode": trace.mode,
"channel": trace.channel,
"channel_id": trace.channel_id,
"created_at": trace.created_at,
});
use crate::storage::models;
if trace.status == models::TRACE_STATUS_COMPLETED {
if let Some(ref result_str) = trace.result_json
&& let Ok(mut result_val) = serde_json::from_str::<Value>(result_str)
{
if let Some(ctx) = result_val.get_mut("context").and_then(Value::as_object_mut) {
ctx.remove("metadata");
}
response["message"] = result_val;
}
} else if trace.status == models::TRACE_STATUS_FAILED
&& let Some(ref err) = trace.error_message
{
response["error"] = json!(err);
}
if let Some(ref started) = trace.started_at {
response["started_at"] = json!(started);
}
if let Some(ref completed) = trace.completed_at {
response["completed_at"] = json!(completed);
}
if let Some(duration) = trace.duration_ms {
response["duration_ms"] = json!(duration);
}
if let Some(ref tt) = trace.task_trace_json
&& let Ok(mut v) = serde_json::from_str::<Value>(tt)
{
strip_step_metadata(&mut v);
response["task_trace_json"] = v;
}
let mut out = data_response(response).into_response();
let headers = out.headers_mut();
headers.insert(NO_STORE.0, axum::http::HeaderValue::from_static(NO_STORE.1));
if lane == TraceLane::QueryToken {
headers.insert(
axum::http::HeaderName::from_static("deprecation"),
axum::http::HeaderValue::from_static("true"),
);
}
Ok(out)
}
fn strip_step_metadata(trace: &mut Value) {
fn drop_metadata(context: Option<&mut Value>) {
if let Some(obj) = context.and_then(Value::as_object_mut) {
obj.remove("metadata");
}
}
let Some(steps) = trace.get_mut("steps").and_then(Value::as_array_mut) else {
return;
};
for step in steps {
drop_metadata(step.get_mut("message").and_then(|m| m.get_mut("context")));
if let Some(contexts) = step
.get_mut("mapping_contexts")
.and_then(Value::as_array_mut)
{
for context in contexts {
drop_metadata(Some(context));
}
}
}
}