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
3 changes: 2 additions & 1 deletion ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import Services from "./components/Services";
import Alerts from "./components/Alerts";
import { listAlerts, listServices } from "./api";
import { useControls, rangeParams, RANGES, INTERVALS } from "./timerange";
import { traceHref } from "./links";

const TABS: { to: string; label: string }[] = [
{ to: "/traces", label: "Traces" },
Expand Down Expand Up @@ -57,7 +58,7 @@ function TracesRoute() {
const { service } = useControls();
// Keep the focus in the URL through the drill-in so the header + tabs still show it.
// A real <Link> per row (primary cell) makes rows keyboard-operable + open-in-new-tab.
return <TraceList to={(id) => `/traces/${id}${serviceSearch(service)}`} />;
return <TraceList to={(id) => traceHref(id, { service })} />;
}

function TraceRoute() {
Expand Down
12 changes: 5 additions & 7 deletions ui/src/components/LogView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Fragment, useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { listLogs, type LogRow } from "../api";
import { firstRunHint } from "../empty";
import { traceHref } from "../links";
import { useControls, rangeParams } from "../timerange";

function sevClass(n: number | null): string {
Expand Down Expand Up @@ -70,12 +71,9 @@ export default function LogView() {
});

// Preserve the service focus when drilling into a trace/span from a log row.
const svc = service ? `service=${encodeURIComponent(service)}` : "";
const traceLink = (l: LogRow) => `/traces/${l.trace_id}${svc ? `?${svc}` : ""}`;
const spanInTraceLink = (l: LogRow) => {
const parts = [l.span_id ? `span=${l.span_id}` : "", svc].filter(Boolean);
return `/traces/${l.trace_id}${parts.length ? `?${parts.join("&")}` : ""}`;
};
const traceLink = (l: LogRow) => traceHref(l.trace_id ?? "", { service: service || undefined });
const spanInTraceLink = (l: LogRow) =>
traceHref(l.trace_id ?? "", { spanId: l.span_id, service: service || undefined });

const emptyMessage = spanFilter
? "No logs recorded for this span."
Expand Down Expand Up @@ -172,7 +170,7 @@ export default function LogView() {
<td>{l.service ?? "—"}</td>
<td className="mono">
{l.trace_id ? (
<Link className="xlink" to={`/traces/${l.trace_id}`} title={l.trace_id}>
<Link className="xlink" to={traceHref(l.trace_id)} title={l.trace_id}>
{l.trace_id.slice(0, 8)}
</Link>
) : (
Expand Down
5 changes: 2 additions & 3 deletions ui/src/components/MetricChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "../api";
import { formatValue } from "../format";
import { facetLabels } from "../metricLabels";
import { traceHref } from "../links";

const RANGES: { label: string; hours: number }[] = [
{ label: "1h", hours: 1 },
Expand Down Expand Up @@ -358,9 +359,7 @@ function FacetView({
firingFrom={firingFrom}
desc={`${name} ${data.rated ? "per-second rate" : "value"}, ${rangeLabel}`}
exemplars={exemplars.map((e) => ({ t: e.t, v: e.v, traceId: e.trace_id }))}
onExemplarClick={(traceId) =>
navigate(`/traces/${traceId}${service ? `?service=${encodeURIComponent(service)}` : ""}`)
}
onExemplarClick={(traceId) => navigate(traceHref(traceId, { service }))}
/>
</div>
);
Expand Down
8 changes: 3 additions & 5 deletions ui/src/components/TraceWaterfall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { getTrace, type SpanRow } from "../api";
import { rowKeyActivate } from "../a11y";
import { logsHref } from "../links";
import { fmtDuration } from "./TraceList";

export interface Row {
Expand Down Expand Up @@ -145,10 +146,7 @@ function SpanDetail({ span, onClose }: { span: SpanRow; onClose: () => void }) {
<strong className="mono">{span.name}</strong>
{/* Cross-signal drill: this span's logs (trace_id + span_id), or the
whole service's logs (which also sets the global service focus). */}
<Link
className="xlink"
to={`/logs?trace_id=${span.trace_id}&span_id=${span.span_id}`}
>
<Link className="xlink" to={logsHref(span.trace_id, span.span_id)}>
logs for this span ↗
</Link>
{span.service && (
Expand Down Expand Up @@ -379,7 +377,7 @@ export default function TraceWaterfall({
<h2>
{spans[0].service ?? "trace"} · <code>{traceId.slice(0, 16)}…</code> ·{" "}
{fmtDuration(totalMs)} ·{" "}
<Link className="xlink" to={`/logs?trace_id=${traceId}`}>
<Link className="xlink" to={logsHref(traceId)}>
logs ↗
</Link>
{errorSpans.length > 0 && (
Expand Down
51 changes: 51 additions & 0 deletions ui/src/links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { logsHref, traceHref } from "./links";

// JEF-534: trace_id/span_id are ingested OTLP data (attacker-shapeable) and were
// interpolated raw into these deep-links, unlike the neighboring `service` focus
// which was already `encodeURIComponent`-ed. These tests pin the well-formed-id
// shape (no behavior change vs. the pre-fix raw interpolation) and lock the
// encoding for ids with special characters.
describe("traceHref", () => {
it("links to just the trace for a well-formed hex id (no behavior change)", () => {
expect(traceHref("abcdef0123456789")).toBe("/traces/abcdef0123456789");
});

it("carries the service focus", () => {
expect(traceHref("abcdef0123456789", { service: "api" })).toBe(
"/traces/abcdef0123456789?service=api",
);
});

it("carries span + service, span first (matches the pre-fix param order)", () => {
expect(traceHref("abcdef0123456789", { spanId: "0123456789abcdef", service: "api" })).toBe(
"/traces/abcdef0123456789?span=0123456789abcdef&service=api",
);
});

it("encodes a trace id with special characters into the path segment", () => {
expect(traceHref("dead&beef#1")).toBe("/traces/dead%26beef%231");
});

it("encodes a span id with special characters into the query string", () => {
expect(traceHref("t1", { spanId: "span&1" })).toBe("/traces/t1?span=span%261");
});
});

describe("logsHref", () => {
it("carries just the trace id when no span is given", () => {
expect(logsHref("abcdef0123456789")).toBe("/logs?trace_id=abcdef0123456789");
});

it("carries trace_id and span_id for a well-formed hex id (no behavior change)", () => {
expect(logsHref("abcdef0123456789", "0123456789abcdef")).toBe(
"/logs?trace_id=abcdef0123456789&span_id=0123456789abcdef",
);
});

it("encodes special characters instead of injecting an extra query param", () => {
expect(logsHref("dead&beef", "span#1")).toBe(
"/logs?trace_id=dead%26beef&span_id=span%231",
);
});
});
26 changes: 26 additions & 0 deletions ui/src/links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Deep-link builders shared by every cross-signal drill-in (trace ↔ logs ↔
// metrics). Trace/span ids come from ingested OTLP data — attacker-shapeable —
// so every id here is either `encodeURIComponent`-ed as a path segment or set
// via URLSearchParams: a stray `&`/`#`/`?` must stay inside its own param,
// never land on an unexpected route or inject an extra one (JEF-534).

// `/traces/:traceId`, optionally scoped to a span and/or the global service
// focus. Used by the trace list row link, a log row's drill-in, and a metric
// chart exemplar click.
export function traceHref(
traceId: string,
opts: { spanId?: string | null; service?: string | null } = {},
): string {
const p = new URLSearchParams();
if (opts.spanId) p.set("span", opts.spanId);
if (opts.service) p.set("service", opts.service);
const q = p.toString();
return `/traces/${encodeURIComponent(traceId)}${q ? `?${q}` : ""}`;
}

// `/logs`, scoped to a trace and (optionally) a span.
export function logsHref(traceId: string, spanId?: string): string {
const p = new URLSearchParams({ trace_id: traceId });
if (spanId) p.set("span_id", spanId);
return `/logs?${p.toString()}`;
}