Skip to content

Commit b6ba05a

Browse files
committed
Address PR #177 review feedback
- trace.rs: Use repeated query params (?keys=a&keys=b) instead of comma-separated, since commas are legal in S3 keys - trace.rs: Fetch S3 objects concurrently with join_all instead of sequentially - storage.rs: Use DisplayErrorContext for S3 errors to preserve the full error chain - dev_server.rs: Make port overrideable via PORT env var - Update browser.html, tests, and README to match new keys format
1 parent 9ee78fe commit b6ba05a

9 files changed

Lines changed: 146 additions & 32 deletions

File tree

Cargo.lock

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dial9-viewer/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ required-features = ["dev-server"]
2020

2121
[dependencies]
2222
axum = "0.8"
23+
axum-extra = { version = "0.10", features = ["query"] }
2324
clap = { version = "4", features = ["derive"] }
2425
flate2 = "1"
26+
futures = "0.3"
2527
serde = { version = "1", features = ["derive"] }
2628
serde_json = "1"
2729
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }

dial9-viewer/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Open `http://localhost:3000` to browse traces. Enter a search prefix (e.g. `2026
3434
| Endpoint | Description |
3535
|----------|-------------|
3636
| `GET /api/search?q=<prefix>&bucket=<bucket>` | List S3 objects matching the prefix |
37-
| `GET /api/trace?keys=<k1,k2,...>&bucket=<bucket>` | Fetch, gunzip, and concatenate trace segments |
37+
| `GET /api/trace?keys=<k1>&keys=<k2>&bucket=<bucket>` | Fetch, gunzip, and concatenate trace segments |
3838

3939
The trace endpoint returns raw binary data (`application/octet-stream`) suitable for loading directly in the trace viewer via `?trace=` URL parameter. Maximum response size is 50 MB.
4040

dial9-viewer/src/bin/dev_server.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,14 @@ async fn main() -> anyhow::Result<()> {
8585
let ui_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("ui");
8686
let app = dial9_viewer::server::router(state, &ui_dir);
8787

88-
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
89-
tracing::info!("dial9-viewer dev server listening on http://localhost:3000");
88+
let port: u16 = std::env::var("PORT")
89+
.ok()
90+
.and_then(|p| p.parse().ok())
91+
.unwrap_or(3000);
92+
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await?;
93+
tracing::info!("dial9-viewer dev server listening on http://localhost:{port}");
9094
tracing::info!("bucket={bucket}, prefix=traces");
91-
tracing::info!("try: http://localhost:3000/browser.html");
95+
tracing::info!("try: http://localhost:{port}/browser.html");
9296
tracing::info!("search for: 2026-04-09/");
9397

9498
axum::serve(listener, app)

dial9-viewer/src/server/trace.rs

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
use axum::body::Body;
2-
use axum::extract::{Query, State};
2+
use axum::extract::State;
33
use axum::http::StatusCode;
44
use axum::response::{IntoResponse, Response};
5+
use axum_extra::extract::Query;
56
use flate2::read::GzDecoder;
7+
use futures::future::join_all;
68
use serde::Deserialize;
79
use std::io::Read;
810

@@ -13,8 +15,9 @@ const MAX_KEYS: usize = 100;
1315

1416
#[derive(Deserialize)]
1517
pub struct TraceParams {
16-
/// Comma-separated S3 keys
17-
pub keys: String,
18+
/// S3 keys (repeated query param: ?keys=a&keys=b)
19+
#[serde(default)]
20+
pub keys: Vec<String>,
1821
pub bucket: Option<String>,
1922
}
2023

@@ -27,7 +30,12 @@ pub async fn get_trace(
2730
.or(state.default_bucket.clone())
2831
.ok_or((StatusCode::BAD_REQUEST, "bucket is required".to_string()))?;
2932

30-
let keys: Vec<&str> = params.keys.split(',').filter(|k| !k.is_empty()).collect();
33+
let keys: Vec<&str> = params
34+
.keys
35+
.iter()
36+
.map(|k| k.as_str())
37+
.filter(|k| !k.is_empty())
38+
.collect();
3139
if keys.is_empty() {
3240
return Err((StatusCode::BAD_REQUEST, "keys is required".to_string()));
3341
}
@@ -38,15 +46,14 @@ pub async fn get_trace(
3846
));
3947
}
4048

41-
let mut combined = Vec::new();
42-
43-
for key in &keys {
44-
let data = state
45-
.backend
46-
.get_object(&bucket, key)
47-
.await
48-
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
49+
let fetches = keys
50+
.iter()
51+
.map(|key| state.backend.get_object(&bucket, key));
52+
let results = join_all(fetches).await;
4953

54+
let mut combined = Vec::new();
55+
for result in results {
56+
let data = result.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
5057
let raw = maybe_gunzip(&data);
5158

5259
if combined.len() + raw.len() > MAX_RESPONSE_BYTES {

dial9-viewer/src/storage.rs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,10 @@ impl StorageBackend for S3Backend {
8484
req = req.continuation_token(token);
8585
}
8686

87-
let resp = req
88-
.send()
89-
.await
90-
.map_err(|e| StorageError::Other(e.to_string()))?;
87+
let resp = req.send().await.map_err(|e| {
88+
use aws_sdk_s3::error::DisplayErrorContext;
89+
StorageError::Other(format!("{}", DisplayErrorContext(&e)))
90+
})?;
9191

9292
for obj in resp.contents() {
9393
if let Some(key) = obj.key() {
@@ -123,20 +123,23 @@ impl StorageBackend for S3Backend {
123123
let bucket = bucket.to_string();
124124
let key = key.to_string();
125125
Box::pin(async move {
126-
use aws_sdk_s3::operation::get_object::GetObjectError;
127-
128126
let resp = self
129127
.client
130128
.get_object()
131129
.bucket(&bucket)
132130
.key(&key)
133131
.send()
134132
.await
135-
.map_err(|e| match e.into_service_error() {
136-
GetObjectError::NoSuchKey(_) => {
137-
StorageError::NotFound(format!("{bucket}/{key}"))
133+
.map_err(|e| {
134+
use aws_sdk_s3::error::DisplayErrorContext;
135+
use aws_sdk_s3::operation::get_object::GetObjectError;
136+
137+
match e.into_service_error() {
138+
GetObjectError::NoSuchKey(_) => {
139+
StorageError::NotFound(format!("{bucket}/{key}"))
140+
}
141+
other => StorageError::Other(format!("{}", DisplayErrorContext(&other))),
138142
}
139-
other => StorageError::Other(other.to_string()),
140143
})?;
141144

142145
let bytes = resp

dial9-viewer/tests/server_test.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,9 @@ async fn trace_fetches_and_concatenates() {
305305
put_object(&s3, "test-bucket", "seg2.bin.gz", &gzip_bytes(trace2)).await;
306306

307307
let resp = client
308-
.get(format!("{base}/api/trace?keys=seg1.bin.gz,seg2.bin.gz"))
308+
.get(format!(
309+
"{base}/api/trace?keys=seg1.bin.gz&keys=seg2.bin.gz"
310+
))
309311
.send()
310312
.await
311313
.unwrap();
@@ -398,12 +400,15 @@ async fn e2e_search_then_view() {
398400

399401
// Step 2: Build the trace URL from search results — like the browser's viewSelected()
400402
let keys: Vec<&str> = search_resp.iter().map(|o| o.key.as_str()).collect();
401-
let keys_param = keys.join(",");
403+
let keys_param: String = keys
404+
.iter()
405+
.map(|k| format!("keys={}", urlencoding::encode(k)))
406+
.collect::<Vec<_>>()
407+
.join("&");
402408

403409
let trace_resp = client
404410
.get(format!(
405-
"{base}/api/trace?keys={}&bucket=traces-bucket",
406-
urlencoding::encode(&keys_param)
411+
"{base}/api/trace?{keys_param}&bucket=traces-bucket",
407412
))
408413
.send()
409414
.await

dial9-viewer/ui/browser.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,8 @@ <h1>dial9 Trace Browser</h1>
310310
if (keys.length === 0) return;
311311

312312
const bucket = bucketInput.value.trim();
313-
const keysParam = keys.join(',');
314-
const traceUrl = `/api/trace?bucket=${encodeURIComponent(bucket)}&keys=${encodeURIComponent(keysParam)}`;
313+
const keysParams = keys.map(k => `keys=${encodeURIComponent(k)}`).join('&');
314+
const traceUrl = `/api/trace?bucket=${encodeURIComponent(bucket)}&${keysParams}`;
315315
window.open(`index.html?trace=${encodeURIComponent(traceUrl)}`, '_blank');
316316
}
317317
</script>

progress/dial9-viewer-progress.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# dial9-viewer MVP Progress
2+
3+
## Status: In Progress
4+
5+
## Task 1: Move UI files
6+
- [ ] Move files
7+
- [ ] Update symlink
8+
- [ ] Update all references
9+
- [ ] Verify tests pass
10+
11+
## Task 2: Scaffold crate
12+
- [ ] Cargo.toml + workspace
13+
- [ ] main.rs with clap
14+
- [ ] axum static file serving
15+
- [ ] placeholder browser.html
16+
- [ ] serve.py
17+
- [ ] integration test
18+
19+
## Task 3: StorageBackend + /api/search
20+
- [ ] StorageBackend trait
21+
- [ ] S3Backend impl
22+
- [ ] /api/search endpoint
23+
- [ ] integration test with s3s-fs
24+
25+
## Task 4: /api/trace endpoint
26+
- [ ] get_object on trait
27+
- [ ] gunzip + concat logic
28+
- [ ] 50MB cap
29+
- [ ] integration test
30+
31+
## Task 5: browser.html
32+
- [ ] search box + bucket input
33+
- [ ] results table with checkboxes
34+
- [ ] View Selected → opens viewer
35+
- [ ] fallback when no backend
36+
37+
## Task 6: Cleanup
38+
- [ ] README.md
39+
- [ ] clippy clean
40+
- [ ] fmt clean
41+
- [ ] nextest all green

0 commit comments

Comments
 (0)