use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::{Body, to_bytes};
use axum::extract::{Request, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::Response;
#[derive(Debug, Clone)]
pub struct RecordOptions {
pub listen: SocketAddr,
pub target: String,
pub out: PathBuf,
pub qql_out: Option<PathBuf>,
}
pub async fn run(opts: RecordOptions) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind(opts.listen)
.await
.map_err(|e| format!("cannot listen on {}: {e}", opts.listen))?;
serve(listener, opts, async {
let _ = tokio::signal::ctrl_c().await;
})
.await
}
pub(crate) async fn serve(
listener: tokio::net::TcpListener,
opts: RecordOptions,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let target = opts.target.trim_end_matches('/').to_string();
if !target.starts_with("http://") && !target.starts_with("https://") {
return Err(format!("--target must start with http:// or https://, got '{target}'").into());
}
if reqwest::Url::parse(&target).is_err() {
return Err(format!("--target is not a valid URL, got '{target}'").into());
}
if let Some(qql_out) = opts.qql_out.as_ref()
&& same_file(&opts.out, qql_out)
{
return Err("--out and --qql-out must be different files".into());
}
let base_lines = count_lines(&opts.out).await.unwrap_or(0);
let jsonl = open_append(&opts.out).await?;
let qql = match &opts.qql_out {
Some(path) => Some(open_append(path).await?),
None => None,
};
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build()?;
let rec = Arc::new(Recorder {
client,
target,
out_label: opts.out.display().to_string(),
files: tokio::sync::Mutex::new(OutFiles {
jsonl,
qql,
lines: base_lines,
}),
});
let app = axum::Router::new().fallback(proxy).with_state(rec.clone());
let addr = listener.local_addr()?;
eprintln!("qql record: listening on {addr} -> {}", rec.target);
match &opts.qql_out {
Some(qql) => eprintln!(
"qql record: appending JSONL to {} + QQL to {}",
rec.out_label,
qql.display()
),
None => eprintln!("qql record: appending JSONL to {}", rec.out_label),
}
eprintln!("qql record: press Ctrl-C to stop (files are fsynced per line)");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await?;
let total = rec.files.lock().await.lines - base_lines;
eprintln!("qql record: stopped, recorded {total} request(s)");
Ok(())
}
struct Recorder {
client: reqwest::Client,
target: String,
out_label: String,
files: tokio::sync::Mutex<OutFiles>,
}
struct OutFiles {
jsonl: tokio::fs::File,
qql: Option<tokio::fs::File>,
lines: u64,
}
const HOP_BY_HOP: [&str; 11] = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"host",
"content-length",
];
fn forwarded(headers: &HeaderMap) -> HeaderMap {
let mut out = HeaderMap::new();
for (name, value) in headers {
if HOP_BY_HOP.contains(&name.as_str()) {
continue;
}
out.append(name.clone(), value.clone());
}
out
}
fn record_path(path: &str) -> String {
let stripped = path.strip_suffix('/').unwrap_or(path);
if stripped.is_empty() {
"/".to_string()
} else {
stripped.to_string()
}
}
fn in_record_scope(path: &str) -> bool {
let trimmed = path.trim_start_matches('/');
trimmed == "collections"
|| trimmed.starts_with("collections/")
|| trimmed == "quotas"
|| trimmed.starts_with("quotas/")
}
fn should_record(path: &str, body: &[u8]) -> bool {
if !in_record_scope(path) {
return false;
}
body.is_empty() || serde_json::from_slice::<serde_json::Value>(body).is_ok()
}
async fn proxy(State(rec): State<Arc<Recorder>>, req: Request) -> Response {
let method = req.method().clone();
let path_and_query = req
.uri()
.path_and_query()
.map(|pq| pq.to_string())
.unwrap_or_else(|| "/".to_string());
let path = req.uri().path().to_string();
let query = req.uri().query().unwrap_or("").to_string();
let headers = forwarded(req.headers());
let body = match to_bytes(req.into_body(), usize::MAX).await {
Ok(body) => body,
Err(e) => {
return error_response(StatusCode::BAD_GATEWAY, &format!("cannot read body: {e}"));
}
};
let url = format!("{}{}", rec.target, path_and_query);
let upstream_method = match reqwest::Method::from_bytes(method.as_str().as_bytes()) {
Ok(upstream_method) => upstream_method,
Err(_) => return error_response(StatusCode::BAD_GATEWAY, "unsupported method"),
};
let upstream = match rec
.client
.request(upstream_method, url)
.headers(headers)
.body(body.clone())
.send()
.await
{
Ok(upstream) => upstream,
Err(e) => {
eprintln!("{method} {path} -> upstream error: {e}");
return error_response(StatusCode::BAD_GATEWAY, &format!("upstream error: {e}"));
}
};
let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let resp_headers = forwarded(upstream.headers());
let resp_body = match upstream.bytes().await {
Ok(resp_body) => resp_body,
Err(e) => return error_response(StatusCode::BAD_GATEWAY, &format!("upstream error: {e}")),
};
let recorded_path = record_path(&path);
if should_record(&recorded_path, &body) {
let json = if body.is_empty() {
None
} else {
Some(serde_json::from_slice(&body).unwrap_or_else(|_| serde_json::json!({})))
};
record_request(&rec, method.as_str(), &recorded_path, &query, json.as_ref()).await;
eprintln!("{method} {recorded_path} -> {}", status.as_u16());
} else if !body.is_empty() && in_record_scope(&recorded_path) {
eprintln!(
"{method} {recorded_path} -> {} (not recorded: body is not JSON)",
status.as_u16()
);
}
let mut out = Response::new(Body::from(resp_body));
*out.status_mut() = status;
for (name, value) in resp_headers {
if let Some(name) = name {
out.headers_mut().append(name, value);
}
}
out
}
async fn record_request(
rec: &Arc<Recorder>,
method: &str,
path: &str,
query: &str,
body: Option<&serde_json::Value>,
) {
let mut line = serde_json::json!({"method": method, "path": path});
if !query.is_empty() {
line["query"] = query_object(query);
}
if let Some(body) = body {
line["body"] = body.clone();
}
let line = line.to_string();
let mut files = rec.files.lock().await;
if let Err(e) = write_line(&mut files.jsonl, line.as_str()).await {
eprintln!("qql record: cannot write {}: {e}", rec.out_label);
return;
}
files.lines += 1;
let lineno = files.lines;
let Some(qql) = files.qql.as_mut() else {
return;
};
match qql_convert::convert(&line, None) {
Ok(stmts) => {
for stmt in &stmts {
if let Err(e) = write_line(qql, &format!("{stmt};")).await {
eprintln!("qql record: cannot write QQL capture: {e}");
return;
}
}
}
Err(e) => {
if let Err(e) =
write_line(qql, &format!("-- ERROR {}:{lineno} {e}", rec.out_label)).await
{
eprintln!("qql record: cannot write QQL capture: {e}");
}
}
}
}
fn query_object(raw: &str) -> serde_json::Value {
let mut obj = serde_json::Map::new();
for pair in raw.split('&') {
if pair.is_empty() {
continue;
}
let (key, value) = match pair.split_once('=') {
Some((key, value)) => (key, value),
None => (pair, ""),
};
obj.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}
serde_json::Value::Object(obj)
}
async fn write_line(
file: &mut tokio::fs::File,
line: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use tokio::io::AsyncWriteExt;
file.write_all(line.as_bytes()).await?;
file.write_all(b"\n").await?;
file.flush().await?;
file.sync_all().await?;
Ok(())
}
async fn count_lines(path: &std::path::Path) -> Result<u64, std::io::Error> {
use tokio::io::AsyncReadExt;
let mut file = match tokio::fs::File::open(path).await {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(e),
};
let mut buf = [0u8; 8192];
let mut lines = 0u64;
loop {
let n = file.read(&mut buf).await?;
if n == 0 {
break;
}
lines += buf[..n].iter().filter(|b| **b == b'\n').count() as u64;
}
Ok(lines)
}
fn same_file(a: &std::path::Path, b: &std::path::Path) -> bool {
if a == b {
return true;
}
if let (Ok(ca), Ok(cb)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
return ca == cb;
}
absolutize(a) == absolutize(b)
}
fn absolutize(path: &std::path::Path) -> std::path::PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
let mut cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
cwd.push(path);
cwd
}
async fn open_append(
path: &std::path::Path,
) -> Result<tokio::fs::File, Box<dyn std::error::Error + Send + Sync>> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
tokio::fs::create_dir_all(parent).await?;
}
Ok(tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await?)
}
fn error_response(status: StatusCode, message: &str) -> Response {
let mut out = Response::new(Body::from(message.to_string()));
*out.status_mut() = status;
out
}
#[cfg(test)]
mod tests {
use super::{forwarded, record_path, same_file, should_record};
#[test]
fn forwarded_preserves_repeated_headers() {
let mut headers = axum::http::HeaderMap::new();
headers.append("x-multi", "a".parse().unwrap());
headers.append("x-multi", "b".parse().unwrap());
let out = forwarded(&headers);
let values: Vec<_> = out.get_all("x-multi").iter().collect();
assert_eq!(values.len(), 2);
}
#[test]
fn same_file_matches_dot_qualified_paths() {
assert!(same_file(
std::path::Path::new("capture.jsonl"),
std::path::Path::new("./capture.jsonl")
));
assert!(!same_file(
std::path::Path::new("capture.jsonl"),
std::path::Path::new("other.jsonl")
));
}
#[test]
fn record_path_strips_query_context_and_trailing_slash() {
assert_eq!(
record_path("/collections/docs/points/query"),
"/collections/docs/points/query"
);
assert_eq!(record_path("/collections/docs/"), "/collections/docs");
assert_eq!(record_path("/"), "/");
}
#[test]
fn should_record_covers_collection_and_quota_routes() {
let body = br#"{"vector": [0.1], "limit": 1}"#;
assert!(should_record("/collections/docs/points/query", body));
assert!(should_record("/collections/docs/points", body));
assert!(should_record("/collections/docs", b""));
assert!(should_record("/collections", b""));
assert!(should_record("/quotas", b""));
assert!(!should_record("/cluster", body));
assert!(!should_record("/healthz", b""));
assert!(!should_record("/collections/docs/points", b"\x00\x01"));
}
}