use axum::Json;
use axum::extract::{Path, State};
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>,
}
#[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, via the `x-trace-token` header or `?token=` query \
parameter. Traces without a token (sync traces, DLQ retries, rows from \
before 1.0.0) are admin-plane only when admin auth is enabled.",
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<Json<Value>, 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);
if !is_admin {
let presented = headers
.get("x-trace-token")
.and_then(|v| v.to_str().ok())
.map(str::to_string)
.or_else(|| 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 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;
}
Ok(data_response(response))
}
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));
}
}
}
}