diff --git a/server/src/api.rs b/server/src/api.rs index 3a90eba..652dbd9 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -20,6 +20,15 @@ fn internal(e: impl std::fmt::Display) -> ApiError { pub struct TraceQuery { limit: Option, service: Option, + /// Substring match on the trace's root span name (operation). + name: Option, + /// Attribute equality filter, `key=value`, matched against any span in the trace. + attr: Option, + /// Only traces that contain at least one error span. + #[serde(default)] + errors_only: bool, + /// Only traces at least this long (ms) — for finding slow traces. + min_duration_ms: Option, /// Time window (RFC3339); both optional. Absent ends are unbounded. from: Option>, to: Option>, @@ -44,6 +53,16 @@ pub async fn list_traces( Query(q): Query, ) -> Result>, ApiError> { let limit = q.limit.unwrap_or(100).clamp(1, 1000); + // `key=value` → JSONB containment, matched against any span in the trace. + let attr_json = q + .attr + .as_deref() + .and_then(|s| s.split_once('=')) + .filter(|(k, _)| !k.is_empty()) + .map(|(k, v)| serde_json::json!({ k: v })); + // service + time bound the spans scanned (index-friendly); the trace-level + // filters (name / attribute / errors / duration) are applied with HAVING so + // the per-trace aggregates stay computed over the whole trace. let rows = sqlx::query_as::<_, TraceSummary>( "SELECT trace_id, max(service) AS service, @@ -59,6 +78,12 @@ pub async fn list_traces( AND ($2::timestamptz IS NULL OR start_time >= $2) AND ($3::timestamptz IS NULL OR start_time <= $3) GROUP BY trace_id + HAVING ($5::text IS NULL + OR (array_agg(name ORDER BY start_time))[1] ILIKE '%' || $5 || '%') + AND ($6::jsonb IS NULL OR bool_or(attributes @> $6)) + AND (NOT $7::bool OR count(*) FILTER (WHERE status_code = 2) > 0) + AND ($8::float8 IS NULL + OR extract(epoch FROM (max(end_time) - min(start_time))) * 1000.0 >= $8) ORDER BY start_time DESC LIMIT $4", ) @@ -66,6 +91,10 @@ pub async fn list_traces( .bind(q.from) .bind(q.to) .bind(limit) + .bind(q.name) + .bind(attr_json) + .bind(q.errors_only) + .bind(q.min_duration_ms) .fetch_all(&pool) .instrument(tracing::info_span!("db.query")) .await diff --git a/server/tests/smoke.rs b/server/tests/smoke.rs index 087b2c9..e76f0ef 100644 --- a/server/tests/smoke.rs +++ b/server/tests/smoke.rs @@ -748,6 +748,62 @@ async fn traces_service_filter_and_limit() { assert_eq!(capped.as_array().unwrap().len(), 1); } +#[tokio::test] +#[serial] +async fn traces_filter_by_name_attr_errors_and_duration() { + let Some(pool) = pool_or_skip().await else { + return; + }; + // Three single-span traces with distinct name / attr / duration / status. + sqlx::query( + "INSERT INTO spans (trace_id, span_id, service, name, start_time, end_time, duration_ms, status_code, attributes) VALUES + ('tf','s','api','GET /health', now()-interval '60 s', now()-interval '60 s' + interval '10 ms', 10, 0, '{\"http.method\":\"GET\"}'), + ('ts','s','api','POST /checkout', now()-interval '60 s', now()-interval '60 s' + interval '500 ms', 500,0, '{\"http.method\":\"POST\"}'), + ('te','s','worker','process job', now()-interval '60 s', now()-interval '60 s' + interval '50 ms', 50, 2, '{\"http.method\":\"POST\"}')", + ) + .execute(&pool) + .await + .unwrap(); + let router = app(pool); + + let ids = |v: &serde_json::Value| { + v.as_array() + .unwrap() + .iter() + .map(|t| t["trace_id"].as_str().unwrap().to_string()) + .collect::>() + }; + + // Baseline: all three. + let (_, all) = get_json(&router, "/api/traces").await; + assert_eq!(all.as_array().unwrap().len(), 3); + + // Root-name substring. + let (_, byname) = get_json(&router, "/api/traces?name=checkout").await; + assert_eq!(ids(&byname), vec!["ts"]); + + // Errors only. + let (_, errs) = get_json(&router, "/api/traces?errors_only=true").await; + assert_eq!(ids(&errs), vec!["te"]); + assert_eq!(errs[0]["error_count"], 1); + + // Min duration — only the 500ms trace clears 100ms. + let (_, slow) = get_json(&router, "/api/traces?min_duration_ms=100").await; + assert_eq!(ids(&slow), vec!["ts"]); + + // Attribute equality (value's '=' is %3D-encoded in the query string). + let (_, get_only) = get_json(&router, "/api/traces?attr=http.method%3DGET").await; + assert_eq!(ids(&get_only), vec!["tf"]); + + // Filters compose: POST traces at least 100ms → just the checkout one. + let (_, post_slow) = get_json( + &router, + "/api/traces?attr=http.method%3DPOST&min_duration_ms=100", + ) + .await; + assert_eq!(ids(&post_slow), vec!["ts"]); +} + #[tokio::test] #[serial] async fn get_trace_returns_spans_in_order() { diff --git a/ui/src/api.ts b/ui/src/api.ts index 62a5b10..fd00275 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -77,7 +77,7 @@ async function send(method: string, path: string, body?: unknown): Promise): string { +function qs(params: Record): string { const q = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== "") q.set(k, String(v)); @@ -86,7 +86,16 @@ function qs(params: Record): string { } export const listTraces = ( - p: { service?: string; limit?: number; from?: string; to?: string } = {}, + p: { + service?: string; + name?: string; + attr?: string; + errors_only?: boolean; + min_duration_ms?: number; + limit?: number; + from?: string; + to?: string; + } = {}, ) => get(`/api/traces?${qs(p)}`); export const getTrace = (traceId: string) => get(`/api/traces/${traceId}`); diff --git a/ui/src/components/TraceList.tsx b/ui/src/components/TraceList.tsx index bb47d1f..8e243a5 100644 --- a/ui/src/components/TraceList.tsx +++ b/ui/src/components/TraceList.tsx @@ -16,73 +16,120 @@ export default function TraceList({ onSelect }: { onSelect: (traceId: string) => const [loaded, setLoaded] = useState(false); const { rangeKey, tick } = useControls(); const [params, setParams] = useSearchParams(); - const serviceFilter = params.get("service"); + // Service lives in the URL so service-map drill-downs (?service=X) land here. + const service = params.get("service") ?? ""; + const setService = (v: string) => { + const next = new URLSearchParams(params); + if (v) next.set("service", v); + else next.delete("service"); + setParams(next, { replace: true }); + }; + const [name, setName] = useState(""); + const [attr, setAttr] = useState(""); + const [errorsOnly, setErrorsOnly] = useState(false); + const [minDuration, setMinDuration] = useState(""); useEffect(() => { let active = true; - listTraces({ limit: 100, service: serviceFilter ?? undefined, ...rangeParams(rangeKey) }) - .then((t) => { - if (active) { - setTraces(t); - setError(null); - } + const handle = setTimeout(() => { + const min = Number(minDuration); + listTraces({ + limit: 100, + service: service || undefined, + name: name || undefined, + attr: attr.includes("=") ? attr : undefined, + errors_only: errorsOnly || undefined, + min_duration_ms: minDuration && !Number.isNaN(min) ? min : undefined, + ...rangeParams(rangeKey), }) - .catch((e: unknown) => active && setError(String(e))) - .finally(() => active && setLoaded(true)); + .then((t) => { + if (active) { + setTraces(t); + setError(null); + } + }) + .catch((e: unknown) => active && setError(String(e))) + .finally(() => active && setLoaded(true)); + }, 250); return () => { active = false; + clearTimeout(handle); }; - }, [serviceFilter, rangeKey, tick]); + }, [service, name, attr, errorsOnly, minDuration, rangeKey, tick]); const { sorted, onSort, indicator } = useSort(traces, "start_time"); - if (!loaded) return

Loading traces…

; - if (error) return

Failed to load: {error}

; - - const banner = serviceFilter && ( -

- traces for {serviceFilter}{" "} - -

+ const filters = ( +
+ setService(e.target.value)} + /> + setName(e.target.value)} + /> + setAttr(e.target.value)} + title="Filter to traces with a span attribute, e.g. http.method=GET" + /> + setMinDuration(e.target.value)} + title="Only traces at least this many milliseconds long" + /> + +
); - if (traces.length === 0) - return ( - <> - {banner} -

No traces in this window.

- - ); - return ( <> - {banner} - - - - - - - - - - - - - {sorted.map((t) => ( - onSelect(t.trace_id)}> - - - - - - - - ))} - -
onSort("service")}>Service{indicator("service")} onSort("root_name")}>Root span{indicator("root_name")} onSort("start_time")}>Started{indicator("start_time")} onSort("duration_ms")}>Duration{indicator("duration_ms")} onSort("span_count")}>Spans{indicator("span_count")} onSort("error_count")}>Errors{indicator("error_count")}
{t.service ?? "—"}{t.root_name ?? "—"}{new Date(t.start_time).toLocaleString()}{fmtDuration(t.duration_ms)}{t.span_count} 0 ? " err" : "")}>{t.error_count}
+ {filters} + {error &&

Failed to load: {error}

} + {!loaded && !error &&

Loading traces…

} + {loaded && traces.length === 0 && !error && ( +

No traces match.

+ )} + {traces.length > 0 && ( + + + + + + + + + + + + + {sorted.map((t) => ( + onSelect(t.trace_id)}> + + + + + + + + ))} + +
onSort("service")}>Service{indicator("service")} onSort("root_name")}>Root span{indicator("root_name")} onSort("start_time")}>Started{indicator("start_time")} onSort("duration_ms")}>Duration{indicator("duration_ms")} onSort("span_count")}>Spans{indicator("span_count")} onSort("error_count")}>Errors{indicator("error_count")}
{t.service ?? "—"}{t.root_name ?? "—"}{new Date(t.start_time).toLocaleString()}{fmtDuration(t.duration_ms)}{t.span_count} 0 ? " err" : "")}>{t.error_count}
+ )} ); } diff --git a/ui/src/styles.css b/ui/src/styles.css index 3564a39..aeafe9f 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -256,6 +256,24 @@ tr.clickable:hover td { border-bottom-color: var(--text); } +.filters .checkbox { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9rem; + color: var(--muted); + white-space: nowrap; +} + +.filters .checkbox input { + border: none; + padding: 0; +} + +.filters input[type="number"] { + width: 5rem; +} + /* Trace waterfall — bars are the data; grey ink, red only for errors. */ .back { background: none;