1use std::sync::Arc;
27
28use parking_lot::RwLock; use crate::base64_encode;
31use crate::otel::{HostOtelRuntime, HostSpanScope, TraceDirection};
32use crate::trace::TraceCarrier;
33use helix_core::auth::{apply_auth_intent, AuthKind};
34use helix_core::effect::{DomainEventBytes, HttpRequest, HttpResponse};
35use helix_core::ports::{EventSink, HttpRequester};
36use helix_core::PortError;
37
38pub type HttpOutcomeObserver =
42 fn(url: &str, outcome: &Result<HttpResponse, PortError>) -> Option<DomainEventBytes>;
43
44#[derive(Clone, Default)]
49pub struct AuthTokenRegistry {
50 inner: Arc<RwLock<AuthSlots>>,
51}
52
53#[derive(Default)]
54struct AuthSlots {
55 session: Option<(String, String)>,
57 bot: Option<(String, String)>,
59}
60
61impl AuthTokenRegistry {
62 pub fn new() -> Self {
63 Self::default()
64 }
65
66 pub fn set_session(&self, header_name: impl Into<String>, header_value: impl Into<String>) {
68 self.inner.write().session = Some((header_name.into(), header_value.into()));
69 }
70
71 pub fn set_bot(&self, header_name: impl Into<String>, header_value: impl Into<String>) {
73 self.inner.write().bot = Some((header_name.into(), header_value.into()));
74 }
75
76 pub fn clear(&self, kind: AuthKind) {
78 let mut slots = self.inner.write();
79 match kind {
80 AuthKind::Session => slots.session = None,
81 AuthKind::Bot => slots.bot = None,
82 }
83 }
84
85 fn resolve(&self, kind: AuthKind) -> Option<(String, String)> {
87 let slots = self.inner.read();
88 match kind {
89 AuthKind::Session => slots.session.clone(),
90 AuthKind::Bot => slots.bot.clone(),
91 }
92 }
93}
94
95#[derive(Clone)]
97pub struct CrossCuttingHttp<H, E> {
98 inner: H,
99 event_sink: Arc<E>,
100 auth: AuthTokenRegistry,
101 outcome_observer: HttpOutcomeObserver,
102 otel: Option<HostOtelRuntime>,
103}
104
105impl<H, E> CrossCuttingHttp<H, E>
106where
107 H: HttpRequester,
108 E: EventSink,
109{
110 pub fn new(
111 inner: H,
112 event_sink: Arc<E>,
113 auth: AuthTokenRegistry,
114 outcome_observer: HttpOutcomeObserver,
115 ) -> Self {
116 Self {
117 inner,
118 event_sink,
119 auth,
120 outcome_observer,
121 otel: None,
122 }
123 }
124
125 pub fn with_otel(mut self, otel: HostOtelRuntime) -> Self {
126 self.otel = Some(otel).filter(HostOtelRuntime::is_enabled);
127 self
128 }
129
130 pub fn is_otel_enabled(&self) -> bool {
131 self.otel.is_some()
132 }
133
134 fn apply_auth(&self, req: HttpRequest) -> (HttpRequest, Option<AuthKind>) {
139 apply_auth_intent(req, |kind| self.auth.resolve(kind))
140 }
141}
142
143#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
144#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
145impl<H, E> HttpRequester for CrossCuttingHttp<H, E>
146where
147 H: HttpRequester,
148 E: EventSink,
149{
150 async fn request(&self, req: HttpRequest) -> Result<HttpResponse, PortError> {
152 let (prepared, _kind) = self.apply_auth(req);
153 let url = prepared.url.clone();
154 let carrier = TraceCarrier::from_headers(&prepared.headers);
155 let request_scope = self.otel.as_ref().map(|runtime| {
156 runtime.span_with_attributes(
157 "helix.http.request",
158 TraceDirection::Outbound,
159 carrier.as_ref(),
160 full_debug_http_attributes(runtime, &prepared),
161 )
162 });
163 let outcome = self.inner.request(prepared).await;
164 let response_parent = match &outcome {
166 Ok(response) => TraceCarrier::from_response_headers(&response.headers),
167 Err(_) => None,
168 }
169 .or_else(|| {
170 request_scope
171 .as_ref()
172 .and_then(HostSpanScope::child_carrier)
173 })
174 .or_else(|| carrier.clone());
175 let _response_scope = match &outcome {
176 Ok(response) => self.otel.as_ref().map(|runtime| {
177 let mut attributes =
178 vec![("http.response.status_code", response.status.to_string())];
179 if runtime.is_full_debug() {
180 attributes.push((
181 "helix.debug.http.response_headers",
182 bounded_debug_string(
183 &serde_json::to_string(&response.headers)
184 .unwrap_or_else(|_| "[]".to_string()),
185 ),
186 ));
187 attributes.push((
188 "helix.debug.http.response_body_base64",
189 bounded_debug_bytes(&response.body),
190 ));
191 }
192 runtime.span_with_attributes(
193 "helix.http.response",
194 TraceDirection::Inbound,
195 response_parent.as_ref(),
196 attributes,
197 )
198 }),
199 Err(error) => self.otel.as_ref().map(|runtime| {
200 runtime.span_with_attributes(
201 "helix.http.response",
202 TraceDirection::Inbound,
203 response_parent.as_ref(),
204 vec![("helix.http.error_kind", error_kind(error).to_string())],
205 )
206 }),
207 };
208 if let Some(event) = (self.outcome_observer)(&url, &outcome) {
209 self.event_sink.emit(event);
210 }
211 outcome
212 }
213}
214
215fn error_kind(error: &PortError) -> &'static str {
217 match error {
218 PortError::Storage(_) => "storage",
219 PortError::StorageConflict => "storage_conflict",
220 PortError::Transport(_) => "transport",
221 PortError::Http(_) => "http",
222 PortError::Clock(_) => "clock",
223 PortError::IdSource(_) => "id_source",
224 PortError::Other(_) => "other",
225 }
226}
227
228const FULL_DEBUG_PAYLOAD_LIMIT: usize = 64 * 1024;
229
230fn full_debug_http_attributes(
232 runtime: &HostOtelRuntime,
233 request: &HttpRequest,
234) -> Vec<(&'static str, String)> {
235 if !runtime.is_full_debug() {
236 return Vec::new();
237 }
238 let mut attributes = vec![
239 (
240 "helix.debug.http.request_headers",
241 bounded_debug_string(
242 &serde_json::to_string(&request.headers).unwrap_or_else(|_| "[]".to_string()),
243 ),
244 ),
245 (
246 "helix.debug.http.request_url",
247 bounded_debug_string(&request.url),
248 ),
249 (
250 "helix.debug.http.request_method",
251 bounded_debug_string(&request.method),
252 ),
253 ];
254 if let Some(body) = &request.body {
255 attributes.push((
256 "helix.debug.http.request_body_base64",
257 bounded_debug_bytes(body),
258 ));
259 }
260 attributes
261}
262
263fn bounded_debug_string(value: &str) -> String {
265 if value.len() <= FULL_DEBUG_PAYLOAD_LIMIT {
266 return value.to_string();
267 }
268 let boundary = value
269 .char_indices()
270 .map(|(index, _)| index)
271 .take_while(|index| *index <= FULL_DEBUG_PAYLOAD_LIMIT)
272 .last()
273 .unwrap_or(0);
274 format!(
275 "{}...[truncated {} bytes]",
276 &value[..boundary],
277 value.len() - boundary
278 )
279}
280
281fn bounded_debug_bytes(bytes: &[u8]) -> String {
283 let shown = bytes.len().min(FULL_DEBUG_PAYLOAD_LIMIT);
284 let mut value = base64_encode(&bytes[..shown]);
285 if shown < bytes.len() {
286 value.push_str(&format!("...[truncated {} bytes]", bytes.len() - shown));
287 }
288 value
289}