1use axum::body::Body;
2use axum::extract::ConnectInfo;
3use axum::extract::State;
4use axum::extract::{FromRequestParts, Request};
5use axum::http::header;
6use axum::http::header::HeaderName;
7use axum::http::request::Parts;
8use axum::middleware::Next;
9use axum::response::Response;
10use chrono::{DateTime, Utc};
11use platform_core::{
12 ActorResolutionRequest, AppContext, ClientRequestMetadata, CorrelationId, IdGenerator,
13 RequestContext, RequestId, UuidGenerator, generate_trace_context,
14 story_events::{
15 HttpRequestStoryEventRecord, http_request_story_creation, http_request_story_event_id,
16 insert_http_request_story_projection,
17 },
18 trace_context_from_traceparent,
19};
20use std::net::SocketAddr;
21use std::time::Instant;
22use tracing::Instrument;
23
24const REQUEST_ID_HEADER: &str = "x-request-id";
25const CORRELATION_ID_HEADER: &str = "x-correlation-id";
26const AUTHORIZATION_HEADER: &str = "authorization";
27const COOKIE_HEADER: &str = "cookie";
28const TRACEPARENT_HEADER: &str = "traceparent";
29
30#[derive(Debug, Clone)]
31pub struct HttpRequestContext(pub RequestContext);
32
33impl std::ops::Deref for HttpRequestContext {
34 type Target = RequestContext;
35
36 fn deref(&self) -> &Self::Target {
37 &self.0
38 }
39}
40
41pub async fn request_context_middleware(
42 State(ctx): State<AppContext>,
43 mut request: Request<Body>,
44 next: Next,
45) -> Response {
46 let started_at = Instant::now();
47 let started_at_utc = Utc::now();
48 let request_id = header_value(request.headers(), REQUEST_ID_HEADER)
49 .unwrap_or_else(|| UuidGenerator.new_id("req"));
50 let correlation_id = header_value(request.headers(), CORRELATION_ID_HEADER)
51 .unwrap_or_else(|| UuidGenerator.new_id("corr"));
52 let actor = ctx
53 .actor_resolver
54 .resolve_actor(ActorResolutionRequest {
55 authorization: authorization_header(request.headers()),
56 cookie: cookie_header(request.headers()),
57 })
58 .await;
59 let trace = traceparent_header(request.headers())
60 .and_then(|value| trace_context_from_traceparent(&value))
61 .unwrap_or_else(generate_trace_context);
62 let method = request.method().clone();
63 let path = request.uri().path().to_owned();
64 let client = ClientRequestMetadata {
65 ip: request
66 .extensions()
67 .get::<ConnectInfo<SocketAddr>>()
68 .map(|ConnectInfo(address)| address.ip().to_string()),
69 user_agent: header_value_by_name(request.headers(), header::USER_AGENT),
70 };
71
72 let mut context = RequestContext {
73 request_id: RequestId::new(request_id),
74 correlation_id: CorrelationId::new(correlation_id),
75 trace,
76 actor,
77 tenant_id: None,
78 causation_id: None,
79 client,
80 };
81 context.causation_id = Some(http_request_story_event_id(&context));
82
83 request
84 .extensions_mut()
85 .insert(HttpRequestContext(context.clone()));
86
87 let span = tracing::info_span!(
88 "http_request",
89 request_id = %context.request_id.0,
90 correlation_id = %context.correlation_id.0,
91 lenso.correlation_id = %context.correlation_id.0,
92 lenso.story_id = %context.correlation_id.0,
93 lenso.execution.kind = "http_request",
94 lenso.execution.name = %format!("{} {}", method.as_str(), path.as_str()),
95 otel.trace_id = context.trace.trace_id.as_deref().unwrap_or(""),
96 otel.parent_span_id = context.trace.span_id.as_deref().unwrap_or(""),
97 http_method = %method,
98 http_path = %path,
99 );
100
101 let mut response = next.run(request).instrument(span).await;
102 record_http_request_story(
103 ctx.db.clone(),
104 context.clone(),
105 method.as_str(),
106 path.as_str(),
107 response.status().as_u16(),
108 response.headers().get("x-lenso-error-code"),
109 started_at,
110 started_at_utc,
111 );
112 response.headers_mut().insert(
113 REQUEST_ID_HEADER,
114 context.request_id.0.parse().expect("valid request id"),
115 );
116 response.headers_mut().insert(
117 CORRELATION_ID_HEADER,
118 context
119 .correlation_id
120 .0
121 .parse()
122 .expect("valid correlation id"),
123 );
124 response
125}
126
127fn record_http_request_story(
128 pool: platform_core::DbPool,
129 request_ctx: RequestContext,
130 method: &str,
131 path: &str,
132 status_code: u16,
133 error_code_header: Option<&axum::http::HeaderValue>,
134 started_at: Instant,
135 started_at_utc: DateTime<Utc>,
136) {
137 let error_code = error_code_header
138 .and_then(|value| value.to_str().ok())
139 .filter(|value| !value.is_empty())
140 .map(ToOwned::to_owned);
141 let duration_ms = started_at.elapsed().as_millis().min(i64::MAX as u128) as i64;
142 let record = HttpRequestStoryEventRecord {
143 method: method.to_owned(),
144 path: path.to_owned(),
145 status_code,
146 error_code,
147 creation: http_request_story_creation(path, status_code),
148 started_at: started_at_utc,
149 completed_at: Utc::now(),
150 duration_ms,
151 };
152
153 tokio::spawn(async move {
154 if let Err(error) = insert_http_request_story_projection(&pool, &request_ctx, record).await
155 {
156 tracing::warn!(
157 error = ?error,
158 request_id = %request_ctx.request_id.0,
159 correlation_id = %request_ctx.correlation_id.0,
160 "failed to write HTTP request story projection"
161 );
162 }
163 });
164}
165
166impl<S> FromRequestParts<S> for HttpRequestContext
167where
168 S: Send + Sync,
169{
170 type Rejection = crate::ApiErrorResponse;
171
172 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
173 parts
174 .extensions
175 .get::<HttpRequestContext>()
176 .cloned()
177 .ok_or_else(|| {
178 platform_core::AppError::new(
179 platform_core::ErrorCode::Internal,
180 "Request context is missing",
181 )
182 .into()
183 })
184 }
185}
186
187fn header_value(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
188 let name = HeaderName::from_static(match name {
189 REQUEST_ID_HEADER => REQUEST_ID_HEADER,
190 CORRELATION_ID_HEADER => CORRELATION_ID_HEADER,
191 _ => unreachable!("known request context header"),
192 });
193
194 headers
195 .get(name)
196 .and_then(|value| value.to_str().ok())
197 .filter(|value| !value.is_empty())
198 .map(ToOwned::to_owned)
199}
200
201fn header_value_by_name(headers: &axum::http::HeaderMap, name: HeaderName) -> Option<String> {
202 headers
203 .get(name)
204 .and_then(|value| value.to_str().ok())
205 .filter(|value| !value.is_empty())
206 .map(ToOwned::to_owned)
207}
208
209fn authorization_header(headers: &axum::http::HeaderMap) -> Option<String> {
210 let name = HeaderName::from_static(AUTHORIZATION_HEADER);
211 headers
212 .get(name)
213 .and_then(|value| value.to_str().ok())
214 .filter(|value| !value.is_empty())
215 .map(ToOwned::to_owned)
216}
217
218fn cookie_header(headers: &axum::http::HeaderMap) -> Option<String> {
219 let name = HeaderName::from_static(COOKIE_HEADER);
220 headers
221 .get(name)
222 .and_then(|value| value.to_str().ok())
223 .filter(|value| !value.is_empty())
224 .map(ToOwned::to_owned)
225}
226
227fn traceparent_header(headers: &axum::http::HeaderMap) -> Option<String> {
228 let name = HeaderName::from_static(TRACEPARENT_HEADER);
229 headers
230 .get(name)
231 .and_then(|value| value.to_str().ok())
232 .filter(|value| !value.is_empty())
233 .map(ToOwned::to_owned)
234}