Skip to content
Merged
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
29 changes: 29 additions & 0 deletions server/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ fn internal(e: impl std::fmt::Display) -> ApiError {
pub struct TraceQuery {
limit: Option<i64>,
service: Option<String>,
/// Substring match on the trace's root span name (operation).
name: Option<String>,
/// Attribute equality filter, `key=value`, matched against any span in the trace.
attr: Option<String>,
/// 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<f64>,
/// Time window (RFC3339); both optional. Absent ends are unbounded.
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
Expand All @@ -44,6 +53,16 @@ pub async fn list_traces(
Query(q): Query<TraceQuery>,
) -> Result<Json<Vec<TraceSummary>>, 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,
Expand All @@ -59,13 +78,23 @@ 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",
)
.bind(q.service)
.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
Expand Down
56 changes: 56 additions & 0 deletions server/tests/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
};

// 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() {
Expand Down
13 changes: 11 additions & 2 deletions ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ async function send<T>(method: string, path: string, body?: unknown): Promise<T
return (await res.json()) as T;
}

function qs(params: Record<string, string | number | undefined>): string {
function qs(params: Record<string, string | number | boolean | undefined>): string {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") q.set(k, String(v));
Expand All @@ -86,7 +86,16 @@ function qs(params: Record<string, string | number | undefined>): 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<TraceSummary[]>(`/api/traces?${qs(p)}`);

export const getTrace = (traceId: string) => get<SpanRow[]>(`/api/traces/${traceId}`);
Expand Down
153 changes: 100 additions & 53 deletions ui/src/components/TraceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p className="muted">Loading traces…</p>;
if (error) return <p className="error">Failed to load: {error}</p>;

const banner = serviceFilter && (
<p className="filter-banner">
traces for <strong>{serviceFilter}</strong>{" "}
<button className="xlink" onClick={() => setParams({})}>
clear
</button>
</p>
const filters = (
<div className="filters">
<input
placeholder="service"
value={service}
onChange={(e) => setService(e.target.value)}
/>
<input
placeholder="root span name…"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
placeholder="attribute key=value"
value={attr}
onChange={(e) => setAttr(e.target.value)}
title="Filter to traces with a span attribute, e.g. http.method=GET"
/>
<input
type="number"
min={0}
placeholder="min ms"
value={minDuration}
onChange={(e) => setMinDuration(e.target.value)}
title="Only traces at least this many milliseconds long"
/>
<label className="checkbox" title="Only traces containing an error span">
<input
type="checkbox"
checked={errorsOnly}
onChange={(e) => setErrorsOnly(e.target.checked)}
/>
errors only
</label>
</div>
);

if (traces.length === 0)
return (
<>
{banner}
<p className="muted">No traces in this window.</p>
</>
);

return (
<>
{banner}
<table>
<thead>
<tr>
<th className="sortable" onClick={() => onSort("service")}>Service{indicator("service")}</th>
<th className="sortable" onClick={() => onSort("root_name")}>Root span{indicator("root_name")}</th>
<th className="sortable" onClick={() => onSort("start_time")}>Started{indicator("start_time")}</th>
<th className="num sortable" onClick={() => onSort("duration_ms")}>Duration{indicator("duration_ms")}</th>
<th className="num sortable" onClick={() => onSort("span_count")}>Spans{indicator("span_count")}</th>
<th className="num sortable" onClick={() => onSort("error_count")}>Errors{indicator("error_count")}</th>
</tr>
</thead>
<tbody>
{sorted.map((t) => (
<tr key={t.trace_id} className="clickable" onClick={() => onSelect(t.trace_id)}>
<td>{t.service ?? "—"}</td>
<td>{t.root_name ?? "—"}</td>
<td>{new Date(t.start_time).toLocaleString()}</td>
<td className="num">{fmtDuration(t.duration_ms)}</td>
<td className="num">{t.span_count}</td>
<td className={"num" + (t.error_count > 0 ? " err" : "")}>{t.error_count}</td>
</tr>
))}
</tbody>
</table>
{filters}
{error && <p className="error">Failed to load: {error}</p>}
{!loaded && !error && <p className="muted">Loading traces…</p>}
{loaded && traces.length === 0 && !error && (
<p className="muted">No traces match.</p>
)}
{traces.length > 0 && (
<table>
<thead>
<tr>
<th className="sortable" onClick={() => onSort("service")}>Service{indicator("service")}</th>
<th className="sortable" onClick={() => onSort("root_name")}>Root span{indicator("root_name")}</th>
<th className="sortable" onClick={() => onSort("start_time")}>Started{indicator("start_time")}</th>
<th className="num sortable" onClick={() => onSort("duration_ms")}>Duration{indicator("duration_ms")}</th>
<th className="num sortable" onClick={() => onSort("span_count")}>Spans{indicator("span_count")}</th>
<th className="num sortable" onClick={() => onSort("error_count")}>Errors{indicator("error_count")}</th>
</tr>
</thead>
<tbody>
{sorted.map((t) => (
<tr key={t.trace_id} className="clickable" onClick={() => onSelect(t.trace_id)}>
<td>{t.service ?? "—"}</td>
<td>{t.root_name ?? "—"}</td>
<td>{new Date(t.start_time).toLocaleString()}</td>
<td className="num">{fmtDuration(t.duration_ms)}</td>
<td className="num">{t.span_count}</td>
<td className={"num" + (t.error_count > 0 ? " err" : "")}>{t.error_count}</td>
</tr>
))}
</tbody>
</table>
)}
</>
);
}
18 changes: 18 additions & 0 deletions ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down