use std::time::Instant;
use axum::http::StatusCode;
use axum::response::Response;
use serde_json::{Value, json};
use crate::channel::guards::{Admission, CacheStoreCtx};
use crate::channel::registry::EffectiveTraceConfig;
use crate::config::TraceStorageMode;
use crate::errors::OrionError;
use crate::metrics;
use crate::queue::{TracePersistenceQueue, TracePersistenceTask};
use crate::server::state::AppState;
use crate::storage::repositories::traces::TraceCompletedRow;
struct CompletedTrace<'a> {
channel: &'a str,
channel_id: Option<&'a str>,
input_json: Option<&'a str>,
response_json: &'a str,
duration_ms: f64,
has_errors: bool,
task_trace_json: Option<&'a str>,
}
async fn route_store_completed(
cfg: &EffectiveTraceConfig,
trace_repo: &std::sync::Arc<dyn crate::storage::repositories::traces::TraceRepository>,
persistence_queue: &TracePersistenceQueue,
trace: &CompletedTrace<'_>,
) {
if matches!(cfg.mode, TraceStorageMode::Sync) {
if let Err(e) = trace_repo
.store_completed(
trace.channel,
trace.channel_id,
"sync",
trace.input_json,
trace.response_json,
trace.duration_ms,
trace.task_trace_json,
)
.await
{
tracing::warn!(error = %e, "Failed to store sync processing result");
}
} else {
let task = TracePersistenceTask::StoreCompleted(TraceCompletedRow {
channel: trace.channel.to_string(),
channel_id: trace.channel_id.map(str::to_string),
mode: "sync".to_string(),
input_json: trace.input_json.map(str::to_string),
result_json: trace.response_json.to_string(),
duration_ms: trace.duration_ms,
task_trace_json: trace.task_trace_json.map(str::to_string),
});
persistence_queue.submit(task).await;
}
}
enum TracePlan {
Persist,
Drop,
}
impl TracePlan {
fn decide(cfg: &EffectiveTraceConfig, has_errors: bool) -> Self {
match cfg.should_drop(has_errors, cfg.draw_sample()) {
Some(reason) => {
metrics::record_trace_dropped(reason);
Self::Drop
}
None => Self::Persist,
}
}
fn persists(&self) -> bool {
matches!(self, Self::Persist)
}
}
fn json_response(status: StatusCode, body: String) -> Response {
let mut response = Response::new(axum::body::Body::from(body));
*response.status_mut() = status;
response.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
);
response
}
async fn persist_trace_and_cache(
state: &AppState,
channel_config: &std::sync::Arc<crate::channel::ChannelRuntimeConfig>,
plan: &TracePlan,
trace: &CompletedTrace<'_>,
cache_body: &str,
cache_context: &Option<CacheStoreCtx>,
profile: Option<&std::sync::Arc<crate::engine::profile::ProfileCollector>>,
) {
if plan.persists() {
let effective_trace = channel_config.trace_storage;
let trace_store_start = Instant::now();
route_store_completed(
&effective_trace,
&state.repos.traces,
&state.trace_persistence_queue,
trace,
)
.await;
if let Some(p) = profile {
p.set_trace_store(trace_store_start.elapsed());
}
}
if trace.has_errors {
tracing::debug!(
channel = trace.channel,
"Response has task errors; not caching"
);
return;
}
if let Some((key, cache, ttl)) = cache_context
&& let Err(e) = cache.set_ex(key, cache_body, *ttl).await
{
tracing::debug!(channel = trace.channel, error = %e, "Failed to cache response");
}
}
pub(super) fn response_envelope(
id: &str,
data: Value,
errors: Vec<Value>,
request_id: Option<String>,
) -> Value {
let mut envelope = json!({
"id": id,
"status": "ok",
"data": data,
"errors": errors,
});
if let Some(request_id) = request_id {
envelope["request_id"] = json!(request_id);
}
envelope
}
const RESPONSE_CONTROL_KEY: &str = "_orion";
struct ShapedResponse {
status: StatusCode,
headers: Vec<(String, String)>,
body: String,
}
#[derive(serde::Deserialize)]
struct CachedShaped {
#[serde(rename = "_orion_shaped")]
shaped: CachedShapedInner,
}
#[derive(serde::Deserialize)]
struct CachedShapedInner {
status: u16,
headers: Vec<(String, String)>,
body: String,
}
#[derive(serde::Serialize)]
struct CachedShapedRef<'a> {
#[serde(rename = "_orion_shaped")]
shaped: CachedShapedInnerRef<'a>,
}
#[derive(serde::Serialize)]
struct CachedShapedInnerRef<'a> {
status: u16,
headers: &'a [(String, String)],
body: &'a str,
}
fn drain_shaped_response(
data: &mut Value,
cfg: &crate::channel::config::ChannelResponseConfig,
) -> Option<ShapedResponse> {
let obj = data.as_object_mut()?;
let namespace = obj.get_mut(RESPONSE_CONTROL_KEY)?.as_object_mut()?;
let control = namespace.remove("response")?;
let namespace_empty = namespace.is_empty();
if namespace_empty {
obj.remove(RESPONSE_CONTROL_KEY);
}
let status = control
.get("status")
.and_then(Value::as_u64)
.and_then(|s| u16::try_from(s).ok())
.and_then(|s| StatusCode::from_u16(s).ok())
.unwrap_or(StatusCode::OK);
let mut headers = Vec::new();
if let Some(map) = control.get("headers").and_then(Value::as_object) {
for (name, value) in map {
let lower = name.to_ascii_lowercase();
let Some(value) = value.as_str() else {
continue;
};
if !cfg.allows_header(&lower) {
tracing::warn!(
header = %lower,
"workflow set a response header the channel does not allow; dropping it"
);
continue;
}
headers.push((lower, value.to_string()));
}
}
let selected: &Value = match control.get("body_path").and_then(Value::as_str) {
Some(path) => path
.strip_prefix("data.")
.unwrap_or(path)
.split('.')
.try_fold(&*data, |acc, segment| acc.get(segment))
.unwrap_or(&Value::Null),
None => data,
};
let raw = control.get("raw").and_then(Value::as_bool).unwrap_or(false);
let body = match (raw, selected.as_str()) {
(true, Some(s)) => s.to_string(),
_ => serde_json::to_string(selected).unwrap_or_else(|_| "null".to_string()),
};
Some(ShapedResponse {
status,
headers,
body,
})
}
fn shaped_response(shaped: ShapedResponse) -> Response {
let mut response = json_response(shaped.status, shaped.body);
for (name, value) in &shaped.headers {
if let (Ok(name), Ok(value)) = (
axum::http::HeaderName::try_from(name.as_str()),
axum::http::HeaderValue::try_from(value.as_str()),
) {
response.headers_mut().insert(name, value);
} else {
tracing::warn!(header = %name, "workflow response header is not valid HTTP; dropping it");
}
}
response
}
fn serialize_envelope(envelope: &Value) -> Result<String, OrionError> {
serde_json::to_string(envelope)
.map_err(|e| OrionError::internal(format!("Failed to serialize response: {e}")))
}
const SANITIZED_ERROR_MESSAGE: &str =
"Task processing failed; full detail is available in the trace";
fn sanitize_errors(errors: &[dataflow_rs::ErrorInfo], verbose: bool) -> Vec<Value> {
errors
.iter()
.map(|e| {
let mut entry = json!({
"code": e.code,
"message": if verbose { e.message.as_str() } else { SANITIZED_ERROR_MESSAGE },
});
if let Some(ref task_id) = e.task_id {
entry["task_id"] = json!(task_id);
}
entry
})
.collect()
}
pub(super) fn cached_response(body: String, shaped: bool) -> Response {
if shaped && let Ok(cached) = serde_json::from_str::<CachedShaped>(&body) {
return shaped_response(ShapedResponse {
status: StatusCode::from_u16(cached.shaped.status).unwrap_or(StatusCode::OK),
headers: cached.shaped.headers,
body: cached.shaped.body,
});
}
json_response(StatusCode::OK, body)
}
pub(super) async fn process_sync_for_channel(
state: &AppState,
channel: &str,
data: Value,
metadata: Value,
channel_config: std::sync::Arc<crate::channel::ChannelRuntimeConfig>,
profile_requested: bool,
admission: Admission,
) -> Result<Response, OrionError> {
let profile = profile_requested.then(crate::engine::profile::ProfileCollector::new);
let Admission {
backpressure_permit: _backpressure_permit,
cache_store: cache_context,
timeout_ms,
dedup_claim: _dedup_claim,
} = admission;
let start = Instant::now();
let engine = state.engine.load();
let sticky_identity = crate::engine::utils::rollout_identity(
&metadata,
&state.config.engine.rollout_sticky_header,
);
let mut message = dataflow_rs::Message::builder()
.payload_json(&data)
.metadata_json(&metadata)
.routing_bucket(crate::engine::utils::rollout_bucket_for_identity(
sticky_identity,
))
.build();
let capture = channel_config
.trace_storage
.task_details
.then(|| crate::engine::TraceCapture {
max_snapshot_bytes: state.config.trace_queue.max_result_size_bytes,
});
let workflow_start = Instant::now();
let result = crate::engine::run_for_channel(
&engine,
channel,
&mut message,
timeout_ms,
profile.as_ref(),
capture,
)
.await;
if let Some(ref p) = profile {
p.set_workflow_total(workflow_start.elapsed());
}
let (result, task_trace) = match result {
Ok(inner) => inner,
Err(ms) => {
metrics::record_message(channel, "timeout");
metrics::record_error("timeout");
return Err(OrionError::Timeout {
channel: channel.to_string(),
timeout_ms: ms,
});
}
};
match result {
Ok(()) => {
let duration = start.elapsed();
let duration_secs = duration.as_secs_f64();
let duration_ms = duration.as_secs_f64() * 1000.0;
metrics::record_message(channel, "ok");
metrics::record_message_duration(channel, duration_secs);
let mut data_out: Value = message.data().into();
let shaped = channel_config
.parsed_config
.response
.as_ref()
.filter(|cfg| cfg.is_shaped())
.and_then(|cfg| drain_shaped_response(&mut data_out, cfg));
let mut response = response_envelope(
message.id(),
data_out,
message
.errors()
.iter()
.filter_map(|e| serde_json::to_value(e).ok())
.collect(),
None,
);
let response_json = serialize_envelope(&response)?;
let has_errors = message.has_errors();
let public_json = if has_errors {
response["errors"] = Value::Array(sanitize_errors(
message.errors(),
state.config.verbose_errors(),
));
response["request_id"] =
json!(crate::server::request_context::request_id().unwrap_or_default());
Some(serialize_envelope(&response)?)
} else {
None
};
let max_result_size = state.config.trace_queue.max_result_size_bytes;
if max_result_size > 0 && response_json.len() > max_result_size {
metrics::record_error("result_size_exceeded");
return Err(OrionError::ResponseTooLarge(format!(
"Result size {} bytes exceeds trace_queue.max_result_size_bytes ({} bytes)",
response_json.len(),
max_result_size
)));
}
let plan = TracePlan::decide(&channel_config.trace_storage, has_errors);
let (input_json, task_trace_json) = if plan.persists() {
(
serde_json::to_string(&data).ok(),
crate::engine::utils::serialize_task_trace_capped(
task_trace.as_ref(),
max_result_size,
channel,
),
)
} else {
(None, None)
};
let will_cache = cache_context.is_some() && !has_errors;
let shaped_cache_json = shaped.as_ref().filter(|_| will_cache).and_then(|s| {
serde_json::to_string(&CachedShapedRef {
shaped: CachedShapedInnerRef {
status: s.status.as_u16(),
headers: &s.headers,
body: &s.body,
},
})
.ok()
});
let cache_body = shaped_cache_json
.as_deref()
.or(public_json.as_deref())
.unwrap_or(&response_json);
persist_trace_and_cache(
state,
&channel_config,
&plan,
&CompletedTrace {
channel,
channel_id: Some(channel_config.channel.channel_id.as_str()),
input_json: input_json.as_deref(),
response_json: &response_json,
duration_ms,
has_errors,
task_trace_json: task_trace_json.as_deref(),
},
cache_body,
&cache_context,
profile.as_ref(),
)
.await;
if let Some(shaped) = shaped {
return Ok(shaped_response(shaped));
}
if let Some(ref p) = profile {
let mut response_with_profile = response;
response_with_profile["_orion"] = json!({ "profile": p.to_json() });
return Ok(json_response(
StatusCode::OK,
serialize_envelope(&response_with_profile)?,
));
}
Ok(json_response(
StatusCode::OK,
public_json.unwrap_or(response_json),
))
}
Err(e) => {
metrics::record_message(channel, "error");
metrics::record_error("engine");
Err(OrionError::Engine(e))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::routes::data::ProcessResponse;
#[test]
fn the_response_envelope_matches_its_documented_schema() {
let shapes = [
response_envelope("msg-1", json!({"ok": true}), vec![], None),
response_envelope(
"msg-2",
json!({"partial": 1}),
vec![json!({"code": "TASK_FAILED", "message": SANITIZED_ERROR_MESSAGE})],
Some("req-abc".to_string()),
),
response_envelope(
"msg-3",
Value::Null,
vec![json!({
"code": "TASK_FAILED",
"message": SANITIZED_ERROR_MESSAGE,
"task_id": "t1",
})],
Some("req-def".to_string()),
),
];
for shape in shapes {
let parsed = serde_json::from_value::<ProcessResponse>(shape.clone());
assert!(
parsed.is_ok(),
"the documented schema does not describe what we send: {shape} — {:?}",
parsed.err()
);
}
}
#[test]
fn the_profile_variant_also_matches_the_schema() {
let mut shape = response_envelope("msg-4", json!({}), vec![], None);
shape["_orion"] = json!({ "profile": {"version": 2} });
let parsed = serde_json::from_value::<ProcessResponse>(shape.clone());
assert!(
parsed.is_ok(),
"profile variant does not match the schema: {shape} — {:?}",
parsed.err()
);
}
fn info(code: &str, message: &str, task_id: Option<&str>) -> dataflow_rs::ErrorInfo {
let mut b = dataflow_rs::ErrorInfo::builder(code, message);
if let Some(task_id) = task_id {
b = b.task_id(task_id);
}
b.build()
}
fn sample_errors() -> Vec<dataflow_rs::ErrorInfo> {
vec![
info(
"TASK_FAILED",
"raw upstream detail that must not leak",
Some("t1"),
),
info("OTHER", "another", None),
]
}
#[test]
fn error_entries_match_their_documented_schema_either_way() {
use crate::server::routes::data::ProcessTaskError;
for verbose in [false, true] {
for e in &sanitize_errors(&sample_errors(), verbose) {
let parsed = serde_json::from_value::<ProcessTaskError>(e.clone());
assert!(
parsed.is_ok(),
"error entry (verbose={verbose}) does not match the schema: {e} — {:?}",
parsed.err()
);
}
}
}
#[test]
fn sanitized_errors_replace_every_message() {
let errors = sanitize_errors(&sample_errors(), false);
assert!(
errors
.iter()
.all(|e| e["message"] == SANITIZED_ERROR_MESSAGE),
"{errors:?}"
);
}
#[test]
fn verbose_errors_keep_the_engine_message() {
let errors = sanitize_errors(&sample_errors(), true);
assert_eq!(
errors[0]["message"],
"raw upstream detail that must not leak"
);
assert_eq!(errors[1]["message"], "another");
assert_eq!(errors[0]["code"], "TASK_FAILED");
assert_eq!(errors[0]["task_id"], "t1");
}
}