Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions rig-core/src/http_client/escape.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use std::fmt;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename this file to escape.rs rather than util.rs.

Utility/helper files can generally lead to code smells if not well-maintained. I would like to be optimistic about this, but realistically it'll be a matter of when it will be a code smell and not if - so the best solution is to eliminate the problem entirely.

use std::str;

/// A helper struct to escape bytes for logging.
pub(crate) struct Escape<'a>(&'a [u8]);

impl<'a> Escape<'a> {
pub(crate) fn new(bytes: &'a [u8]) -> Self {
Escape(bytes)
}
}

impl fmt::Debug for Escape<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// For valid UTF-8 strings, output directly for better readability
if let Ok(s) = str::from_utf8(self.0) {
return write!(f, "{}", s);
}
write!(f, "{}", self)
}
}

impl fmt::Display for Escape<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for &c in self.0 {
match c {
b'\n' => write!(f, "\\n")?,
b'\r' => write!(f, "\\r")?,
b'\t' => write!(f, "\\t")?,
b'\\' => write!(f, "\\\\")?,
b'"' => write!(f, "\\\"")?,
b'\0' => write!(f, "\\0")?,
// ASCII printable (0x20-0x7e, excluding space which is 0x20)
c if (0x20..0x7f).contains(&c) => write!(f, "{}", c as char)?,
// Non-printable bytes
c => write!(f, "\\x{c:02x}")?,
}
}
Ok(())
}
}
158 changes: 154 additions & 4 deletions rig-core/src/http_client/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
use crate::http_client::sse::BoxedStream;
use crate::http_client::{escape::Escape, sse::BoxedStream};
use bytes::Bytes;
use http::StatusCode;
pub use http::{HeaderMap, HeaderValue, Method, Request, Response, Uri, request::Builder};
use http::{StatusCode, request::Parts};
use reqwest::{Body, multipart::Form};
use std::sync::{
Arc, OnceLock,
atomic::{AtomicBool, AtomicUsize, Ordering},
};

mod escape;
pub mod retry;
pub mod sse;

Expand Down Expand Up @@ -75,6 +80,73 @@ pub fn with_bearer_auth(req: Builder, auth: &str) -> Result<Builder> {
Ok(req.header("Authorization", auth_header))
}

#[derive(Clone, Debug)]
pub struct HttpLogSettings {
max_body_preview: Arc<AtomicUsize>,
headers_enabled: Arc<AtomicBool>,
}

impl Default for HttpLogSettings {
fn default() -> Self {
Self {
max_body_preview: Arc::new(AtomicUsize::new(8 * 1024)),
headers_enabled: Arc::new(AtomicBool::new(true)),
}
}
}

impl HttpLogSettings {
pub fn new(max_preview_bytes: usize) -> Self {
Self {
max_body_preview: Arc::new(AtomicUsize::new(max_preview_bytes)),
headers_enabled: Arc::new(AtomicBool::new(true)),
}
}

pub fn max_body_preview(&self) -> usize {
self.max_body_preview.load(Ordering::Relaxed)
}

pub fn set_max_body_preview(&self, max_preview_bytes: usize) {
self.max_body_preview
.store(max_preview_bytes, Ordering::Relaxed);
}

pub fn log_headers_enabled(&self) -> bool {
self.headers_enabled.load(Ordering::Relaxed)
}

pub fn set_log_headers_enabled(&self, enabled: bool) {
self.headers_enabled.store(enabled, Ordering::Relaxed);
}
}

fn http_log_settings() -> &'static HttpLogSettings {
static SETTINGS: OnceLock<HttpLogSettings> = OnceLock::new();
SETTINGS.get_or_init(HttpLogSettings::default)
}

/// Set the maximum number of bytes to preview from the body when logging at the `TRACE` level.
/// Defaults to 8192 bytes. Set to 0 to disable body preview logging.
pub fn set_max_log_body_preview(max_preview_bytes: usize) {
http_log_settings().set_max_body_preview(max_preview_bytes);
}

/// Get the current maximum number of bytes previewed from the body when logging at the `TRACE` level.
fn max_log_body_preview() -> usize {
http_log_settings().max_body_preview()
}

/// Enable or disable header logging when tracing HTTP requests/responses.
pub fn set_log_headers_enabled(enabled: bool) {
http_log_settings().set_log_headers_enabled(enabled);
}

/// Returns whether header logging is currently enabled.
fn log_headers_enabled() -> bool {
http_log_settings().log_headers_enabled()
}

/// A helper trait to make generic requests (both regular and SSE) possible.
pub trait HttpClientExt: WasmCompatSend + WasmCompatSync {
/// Send a HTTP request, get a response back (as bytes). Response must be able to be turned back into Bytes.
Expand Down Expand Up @@ -116,10 +188,14 @@ impl HttpClientExt for reqwest::Client {
U: From<Bytes> + WasmCompatSend,
{
let (parts, body) = req.into_parts();

let body_bytes: Bytes = body.into();
log_request(&parts, &body_bytes);

let req = self
.request(parts.method, parts.uri.to_string())
.headers(parts.headers)
.body(body.into());
.body(body_bytes);

async move {
let response = req.send().await.map_err(instance_error)?;
Expand Down Expand Up @@ -159,6 +235,9 @@ impl HttpClientExt for reqwest::Client {
U: WasmCompatSend + 'static,
{
let (parts, body) = req.into_parts();

log_headers(&parts);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to make an on/off toggle for this?


let req = self
.request(parts.method, parts.uri.to_string())
.headers(parts.headers)
Expand Down Expand Up @@ -202,10 +281,13 @@ impl HttpClientExt for reqwest::Client {
{
let (parts, body) = req.into_parts();

let body_bytes: Bytes = body.into();
log_request(&parts, &body_bytes);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to make an on/off toggle for this?


let req = self
.request(parts.method, parts.uri.to_string())
.headers(parts.headers)
.body(body.into())
.body(body_bytes)
.build()
.map_err(|x| Error::Instance(x.into()))
.unwrap();
Expand Down Expand Up @@ -246,3 +328,71 @@ impl HttpClientExt for reqwest::Client {
}
}
}

fn log_request(parts: &Parts, body: &Bytes) {
if !log_headers_enabled() {
return;
}

let redacted_headers = redact_sensitive_headers(&parts.headers);
let preview_len = max_log_body_preview();

if preview_len > 0 {
let shown = std::cmp::min(preview_len, body.len());
let preview = Escape::new(&body[..shown]);
tracing::trace!(
target: "rig::http",
method = %parts.method,
uri = %parts.uri,
body_len = body.len(),
body_preview_len = shown,
headers = ?redacted_headers,
body_preview = ?preview,
"sending HTTP request"
);
} else {
tracing::trace!(
target: "rig::http",
method = %parts.method,
uri = %parts.uri,
body_len = body.len(),
headers = ?redacted_headers,
"sending HTTP request"
);
}
}

fn log_headers(parts: &Parts) {
if !log_headers_enabled() {
return;
}

let redacted_headers = redact_sensitive_headers(&parts.headers);
tracing::trace!(
target: "rig::http",
method = %parts.method,
uri = %parts.uri,
headers = ?redacted_headers,
"sending HTTP request"
);
}

/// Redact sensitive headers (e.g., Authorization) for logging
fn redact_sensitive_headers(headers: &HeaderMap) -> HeaderMap {
let is_sensitive_header = |name: &str| {
let trimmed = name.trim();
match trimmed.len() {
13 => trimmed.eq_ignore_ascii_case("authorization"),
len if len > 4 && trimmed[len - 4..].eq_ignore_ascii_case("-key") => true,
_ => false,
}
};
let mut filtered = HeaderMap::with_capacity(headers.len());
for (name, value) in headers.iter() {
// avoid the closure allocation, inline check, avoid clones if possible
if !is_sensitive_header(name.as_str()) {
filtered.append(name, value.clone());
}
}
filtered
}