fix(lens): show investigation findings for agent traces (#44696)

* fix(lens): show investigation findings for agent traces

Replace child tool-error counts in the trace table with distinct investigation findings. Keep unassessed traces separate from completed clean investigations and use the root status for failure filters and timeline counts

* fix(lens): stabilize findings updates and repair UI checks

* fix(lens): allow viewer findings reads and index trace lookups

* test(lens): cover viewer findings reads with Postgres
This commit is contained in:
moe-berri 2026-10-05 16:27:54 -07:00 • committed by GitHub
parent 65b0557f80
commit c561372a02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 592 additions and 115 deletions

View file

@ -0,0 +1,3 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_LensRun_completed_executions_idx"
ON "LiteLLM_LensRun" USING GIN ((data->'sample'->'executions') jsonb_path_ops)
WHERE data->>'status'='completed';

View file

@ -0,0 +1,2 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_Lens_jobs_idx"
ON "LiteLLM_Lens" USING GIN ((data->'jobs') jsonb_path_ops);

View file

@ -1053,6 +1053,7 @@ class LiteLLMRoutes(enum.Enum):
# updating this list — the default-allow behavior covers it automatically.
admin_viewer_routes = (
[
"/lens/traces/findings",
"/user/list",
"/user/available_users",
"/user/available_roles",

View file

@ -40,6 +40,8 @@ from litellm.proxy.lens.models import (
RunRequest,
Sample,
Scope,
TraceFindingCount,
TraceFindingsRequest,
WatchAllResult,
WatchSkipped,
Worker,
@ -240,6 +242,12 @@ async def list_agents(auth: Auth, storage: StorageDep) -> tuple[str, ...]:
return await source_reader(storage).agents(scope) if storage is not None else ()
@router.post("/traces/findings", response_model=tuple[TraceFindingCount, ...])
async def trace_findings(body: TraceFindingsRequest, auth: Auth) -> tuple[TraceFindingCount, ...]:
user_scope(auth)
return await repository().trace_findings(body.traces)
def watching(lens: Lens) -> Lens:
if lens.settings.enabled:
return lens

View file

@ -193,6 +193,19 @@ class RunAssessment(Record):
cannot_assess: bool = False
class TraceIdentity(Record):
trace_id: str = Field(min_length=1, max_length=128)
trace_ref: str = Field(default="", max_length=512)
class TraceFindingsRequest(Record):
traces: tuple[TraceIdentity, ...] = Field(min_length=1, max_length=500)
class TraceFindingCount(TraceIdentity):
finding_count: int | None = Field(ge=0)
MAX_STEPS = 200

View file

@ -8,7 +8,7 @@ from typing import Final, Protocol
from pydantic import BaseModel, JsonValue, TypeAdapter
from litellm.proxy.db.prisma_client import PrismaWrapper
from litellm.proxy.lens.models import Job, Lens, Scope, Worker
from litellm.proxy.lens.models import Job, Lens, Scope, TraceFindingCount, TraceIdentity, Worker
class Database(Protocol):
@ -125,6 +125,50 @@ class LensRepository:
)
return Job.model_validate(rows[0].data) if rows else None
async def trace_findings(self, traces: tuple[TraceIdentity, ...]) -> tuple[TraceFindingCount, ...]:
rows: Final = _ROWS.validate_python(
await self.db.query_raw(
"""WITH targets AS (
SELECT DISTINCT trace_id, trace_ref,
jsonb_build_array(jsonb_build_object('source', 'traces', 'trace_id', trace_id)) AS executions
FROM jsonb_to_recordset($1::jsonb) AS target(trace_id text, trace_ref text)
), jobs AS (
SELECT target.trace_id, target.trace_ref, run.data AS job
FROM targets AS target JOIN "LiteLLM_LensRun" AS run
ON run.data->'sample'->'executions' @> target.executions
WHERE run.data->>'status'='completed'
UNION ALL
SELECT target.trace_id, target.trace_ref, job
FROM targets AS target JOIN "LiteLLM_Lens" AS lens
ON lens.data->'jobs' @> jsonb_build_array(jsonb_build_object(
'status', 'completed', 'sample', jsonb_build_object('executions', target.executions)))
CROSS JOIN LATERAL jsonb_array_elements(lens.data->'jobs') AS job
WHERE job->>'status'='completed'
), assessed AS (
SELECT jobs.trace_id, jobs.trace_ref, execution->>'id' AS execution_id, job
FROM jobs, jsonb_array_elements(job->'sample'->'executions') AS execution
WHERE execution->>'trace_id'=jobs.trace_id
AND COALESCE(execution->>'trace_ref', '')=jobs.trace_ref
AND execution->>'source'='traces' AND EXISTS (
SELECT 1 FROM jsonb_array_elements(job->'assessments') AS assessment
WHERE assessment->>'execution_id'=execution->>'id'
AND COALESCE((assessment->>'cannot_assess')::boolean, false)=false
)
)
SELECT jsonb_build_object(
'trace_id', target.trace_id, 'trace_ref', target.trace_ref,
'finding_count', CASE WHEN count(assessed.execution_id)=0 THEN NULL
ELSE count(DISTINCT finding->>'id') END
) AS data FROM targets AS target
LEFT JOIN assessed USING (trace_id, trace_ref)
LEFT JOIN LATERAL jsonb_array_elements(NULLIF(assessed.job->'findings', 'null'::jsonb)) AS finding
ON finding->'occurrences' ? assessed.execution_id
GROUP BY target.trace_id, target.trace_ref""",
json.dumps(tuple(trace.model_dump() for trace in traces)),
)
)
return tuple(TraceFindingCount.model_validate(row.data) for row in rows)
async def workers(self) -> tuple[Worker, ...]:
rows: Final = _ROWS.validate_python(await self.db.query_raw('SELECT data FROM "LiteLLM_LensWorker"'))
return tuple(Worker.model_validate(row.data) for row in rows)

View file

@ -3,6 +3,7 @@ import os
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import Final
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from uuid import uuid4
@ -13,8 +14,24 @@ import pytest_asyncio
from prisma import Prisma
from psycopg import sql
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.db.prisma_client import PrismaWrapper
from litellm.proxy.lens.models import Check, Lens, LensSettings, Scope, Worker
from litellm.proxy.lens.models import (
Check,
Evidence,
Execution,
Finding,
Job,
Lens,
LensSettings,
RunAssessment,
Sample,
Scope,
TraceFindingCount,
TraceFindingsRequest,
TraceIdentity,
Worker,
)
from litellm.proxy.lens.repository import LensRepository, WriterDatabase
from litellm.proxy.lens.state import claim_job, queue_job
@ -69,6 +86,109 @@ async def test_heartbeat_never_restores_revoked_access(lens_db: Prisma) -> None:
await lens_db.execute_raw('DELETE FROM "LiteLLM_LensWorker" WHERE id=$1', worker.id)
@pytest.mark.asyncio
async def test_trace_findings_include_archived_assessments_without_counting_retries_or_counterexamples(
lens_db: Prisma,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.proxy import proxy_server
from litellm.proxy.lens.endpoints import trace_findings
monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=lens_db))
now: Final = datetime.now(timezone.utc)
prefix: Final = uuid4().hex
repo: Final = LensRepository(WriterDatabase(PrismaWrapper(lens_db)))
settings: Final = LensSettings(name="Finding counts", model="test", context="Answer the question")
identities: Final = tuple(TraceIdentity(trace_id=prefix, trace_ref=f"{prefix}-{i}") for i in range(7))
executions: Final = tuple(
Execution(
id=f"{prefix}-{i}",
source="traces",
trace_id=identity.trace_id,
trace_ref=identity.trace_ref,
team_id="",
name="Run",
start_time=now.isoformat(),
span_count=1,
)
for i, identity in enumerate(identities)
)
finding: Final = Finding(
id=prefix,
title="Repeated lookup",
description="The agent never answered the question",
check_id="expected_behavior",
first_seen=now,
last_seen=now,
revision=1,
occurrences=(executions[0].id,),
evidence=(
Evidence(execution_id=executions[0].id, span_id="step", quote="no answer"),
Evidence(execution_id=executions[1].id, span_id="step", quote="answered", role="counterexample"),
),
)
completed: Final = Job(
id=f"{prefix}-old",
status="completed",
created_at=now,
start=now,
end=now,
settings=settings,
revision=1,
sample=Sample(executions=executions[:4], eligible=4),
assessments=(
RunAssessment(execution_id=executions[0].id),
RunAssessment(execution_id=executions[1].id),
RunAssessment(execution_id=executions[2].id, cannot_assess=True),
),
findings=(finding,),
)
lens: Final = Lens(
id=prefix,
scope=Scope(all_teams=True),
settings=settings,
created_at=now,
next_run_at=now,
budget_month=now.strftime("%Y-%m"),
jobs=(completed,),
)
await repo.create(lens)
try:
current: Final = completed.model_copy(update={"id": f"{prefix}-current"})
unfinished: Final = tuple(
completed.model_copy(
update={
"id": f"{prefix}-{status}",
"status": status,
"sample": Sample(executions=(executions[index],), eligible=1),
"assessments": (RunAssessment(execution_id=executions[index].id),),
"findings": (),
}
)
for index, status in enumerate(("running", "failed", "cancelled"), start=4)
)
await repo.update(prefix, lambda item: item.model_copy(update={"jobs": (current, *unfinished)}))
archived: Final = await repo.job(prefix, completed.id)
assert archived is not None and archived.status == "completed"
expected: Final = tuple(
TraceFindingCount(**identity.model_dump(), finding_count=1 if i == 0 else 0 if i == 1 else None)
for i, identity in enumerate(identities)
)
counts: Final = await trace_findings(
TraceFindingsRequest(traces=identities), UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY)
)
assert sorted(counts, key=lambda item: item.trace_ref) == list(expected)
await repo.update(prefix, lambda item: item.model_copy(update={"jobs": unfinished}))
archived_counts: Final = await repo.trace_findings(identities)
assert sorted(archived_counts, key=lambda item: item.trace_ref) == list(expected)
assert await repo.trace_findings((TraceIdentity(trace_id=prefix),)) == (
TraceFindingCount(trace_id=prefix, finding_count=None),
)
finally:
await lens_db.execute_raw('DELETE FROM "LiteLLM_LensRun" WHERE lens_id=$1', prefix)
await lens_db.execute_raw('DELETE FROM "LiteLLM_Lens" WHERE id=$1', prefix)
@pytest.mark.parametrize("populated", (False, True))
@pytest.mark.parametrize("preceding_schema", (False, True))
def test_lens_rename_preserves_saved_data_and_worker_credentials(populated: bool, preceding_schema: bool) -> None:

View file

@ -2568,6 +2568,29 @@ def test_proxy_admin_viewer_post_blocked_outside_allowlists(route):
assert exc_info.value.status_code == 403
@pytest.mark.parametrize("route,allowed", (("/lens/traces/findings", True), ("/lens/example/run", False)))
def test_admin_viewer_can_read_trace_findings_but_cannot_start_investigations(route: str, allowed: bool) -> None:
request: Final = Request({"type": "http", "method": "POST", "path": route, "query_string": b""})
auth: Final = UserAPIKeyAuth(user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY)
def check_access() -> None:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=LiteLLM_UserTable(user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY),
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
route=route,
request=request,
valid_token=auth,
request_data={},
)
if allowed:
assert check_access() is None
else:
with pytest.raises(HTTPException) as error:
check_access()
assert error.value.status_code == 403
# ── Admin Viewer: management_routes write endpoints stay blocked ─────────────
#
# `management_routes` is a mix of reads (info/list, handled via the safe-method

View file

@ -15,13 +15,24 @@ from litellm.proxy.lens.endpoints import (
result,
run_settings,
run_window,
trace_findings,
user_scope,
validate_model,
watchable,
watching,
worker_supports_model,
)
from litellm.proxy.lens.models import ActivitySelection, Coverage, Lens, LensSettings, Result, RunRequest, Scope
from litellm.proxy.lens.models import (
ActivitySelection,
Coverage,
Lens,
LensSettings,
Result,
RunRequest,
Scope,
TraceFindingsRequest,
TraceIdentity,
)
from litellm.proxy.lens.repository import Row
from litellm.proxy.lens.state import claim_job, queue_job, replace_job
from tests.unit.proxy.lens.test_state import NOW, lens, worker
@ -194,6 +205,15 @@ async def test_agent_discovery_without_trace_storage_still_requires_admin_access
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_trace_finding_counts_require_investigation_read_access() -> None:
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER)
request: Final = TraceFindingsRequest(traces=(TraceIdentity(trace_id="trace"),))
with pytest.raises(HTTPException) as error:
await trace_findings(request, auth)
assert error.value.status_code == 403
@pytest.mark.parametrize(
"role",
(LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, LitellmUserRoles.TEAM),

View file

@ -27,6 +27,7 @@ function serve({ enabled = false, traces = false, requests = false, connected =
? Response.json({ data: traces ? [data.runs[0].trace.summary] : [] })
: Response.json({ detail: "Tracing is not enabled" }, { status: 501 });
if (path === "/lens/activity/available") return Response.json({ traces, requests });
if (path === "/lens/traces/findings") return Response.json([]);
if (path === "/lens" && method === "POST") {
const saved = { ...data.lenses[0], settings: { ...data.lenses[0].settings, ...(body as object) } };
list.mockResolvedValue({ lenses: [saved], workers: [worker()], tracing_enabled: true });

View file

@ -177,6 +177,7 @@ describe("Lens interactive demo", () => {
if (path.endsWith("/reviews")) return Response.json({ reviews: [], reviewed: saved.jobs[0].reviewed });
if (path.endsWith("/runs")) return Response.json(saved.jobs);
if (path === "/v1/traces") return Response.json({ data: data.runs.map((run) => run.trace.summary) });
if (path === "/lens/traces/findings") return Response.json([]);
return Response.json({ data: [], traces: true, requests: false });
});
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />, {
@ -355,6 +356,7 @@ describe("Lens interactive demo", () => {
if (path === "/lens/agents") return Response.json([]);
if (path.startsWith("/lens/preview")) return Response.json({ eligible: 0, selected: 0, executions: [] });
if (path === "/v1/traces") return Response.json({ data: [createLensDemoData().runs[0].trace.summary] });
if (path === "/lens/traces/findings") return Response.json([]);
return Response.json({ data: [], traces: true, requests: false });
});
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />, {

View file

@ -194,6 +194,7 @@ function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={isAdmin}
canViewFindings={canViewInvestigations}
/>
</TabsContent>
<TabsContent value="findings" className={PANEL}>

View file

@ -60,6 +60,34 @@ function demoTracesApi(data: LensDemoData): TracesApi {
.filter((trace) => Date.parse(trace.start_time) >= startMs && Date.parse(trace.start_time) <= endMs),
next_cursor: null,
}),
findings: async (traces) =>
traces.map((trace) => {
const jobs = data.lenses.flatMap((lens) => lens.jobs).filter((job) => job.status === "completed");
const assessed = jobs.flatMap((job) =>
(job.sample?.executions ?? [])
.filter((execution) => {
const matches =
execution.source === "traces" &&
execution.trace_id === trace.trace_id &&
(execution.trace_ref ?? "") === (trace.trace_ref ?? "");
return (
matches &&
job.assessments.some(
(assessment) => assessment.execution_id === execution.id && !assessment.cannot_assess,
)
);
})
.map((execution) => ({ job, execution })),
);
const findings = new Set(
assessed.flatMap(({ job, execution }) =>
(job.findings ?? [])
.filter((finding) => finding.occurrences.includes(execution.id))
.map((finding) => finding.id),
),
);
return { ...trace, finding_count: assessed.length ? findings.size : null };
}),
anyRecorded: async () => data.runs.length > 0,
trace: (traceId) => found(run(traceId)?.trace),
span: (traceId, spanId) => found(run(traceId)?.details.find((span) => span.span_id === spanId)),

View file

@ -9,7 +9,15 @@ import {
apiClient,
getProxyBaseUrl,
} from "../../networking";
import type { SpanDetail, SpanErrorPage, Trace, TraceListQuery, TracePage } from "./types";
import type {
SpanDetail,
SpanErrorPage,
Trace,
TraceListQuery,
TracePage,
TraceFindingCount,
TraceFindingsRequest,
} from "./types";
export interface TraceWindow {
readonly startMs: number;
@ -27,6 +35,7 @@ export interface TracesApi {
readonly live: boolean;
handoff(traceId: string, spanId?: string | null, traceRef?: string): TraceHandoff;
list(window: TraceWindow): Promise<TracePage>;
findings(traces: TraceFindingsRequest["traces"]): Promise<TraceFindingCount[]>;
anyRecorded(): Promise<boolean>;
trace(traceId: string, traceRef?: string, cursor?: string | null): Promise<Trace>;
span(traceId: string, spanId: string, traceRef?: string): Promise<SpanDetail>;
@ -41,7 +50,7 @@ export interface TracesApi {
export const agentHandoffText = (traceId: string, spanId?: string | null, traceRef?: string): string => {
const url = `${getProxyBaseUrl().replace(/\/$/, "")}/v1/traces/${traceId}?format=md${spanId ? `&span_id=${spanId}` : ""}${traceRef ? `&trace_ref=${traceRef}` : ""}`;
const what = spanId ? "this step of a LiteLLM agent trace" : "this LiteLLM agent trace";
return `Read ${what} and explain what happened and why it failed:\ncurl -s -H "Authorization: Bearer $LITELLM_API_KEY" "${url}"`;
return `Read ${what}, explain what happened, and investigate any issues:\ncurl -s -H "Authorization: Bearer $LITELLM_API_KEY" "${url}"`;
};
export function liveTracesApi(accessToken: string): TracesApi {
@ -52,6 +61,11 @@ export function liveTracesApi(accessToken: string): TracesApi {
copied: "Command copied",
}),
list: (window) => agentTraceListCall({ accessToken, ...window }),
findings: (traces) =>
apiClient.post<TraceFindingCount[]>("/lens/traces/findings", {
accessToken,
body: { traces } satisfies TraceFindingsRequest,
}),
anyRecorded: async () => {
const page = await apiClient.get<TracePage>("/v1/traces", {
accessToken,

View file

@ -14,11 +14,13 @@ export default function AgentTracesPage({
isActive = true,
readOnly = false,
canMintTracingKey = false,
canViewFindings = true,
}: {
accessToken: string;
isActive?: boolean;
readOnly?: boolean;
canMintTracingKey?: boolean;
canViewFindings?: boolean;
}) {
const sourceLive = useTracesLive();
const [rangeHours, setRangeHours] = useRangeHoursRouting();
@ -55,6 +57,7 @@ export default function AgentTracesPage({
isLiveTail={isLiveTail}
readOnly={readOnly}
canMintTracingKey={canMintTracingKey}
canViewFindings={canViewFindings}
timeControls={{ rangeHours, onRangeHoursChange: changeRange, onLiveChange: changeLive }}
/>
</div>

View file

@ -10,7 +10,7 @@ import traceList from "../__fixtures__/trace_list.json";
import AgentTracesPage from "./AgentTracesPage";
import { filterRuns } from "./runSearch/runQuery";
import { AgentTracesSection, type TimeControls } from "./AgentTracesSection";
import type { TracePage, TraceSummary } from "../types";
import type { TraceFindingCount, TracePage, TraceSummary } from "../types";
vi.mock("../../../networking", () => ({
apiClient: { get: vi.fn(), post: vi.fn() },
@ -37,7 +37,7 @@ const runs = (traceList as TracePage).data as TraceSummary[];
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
const renderSection = () =>
const renderSection = (canViewFindings = true) =>
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
@ -46,6 +46,7 @@ const renderSection = () =>
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
canViewFindings={canViewFindings}
/>,
);
@ -88,6 +89,10 @@ describe("AgentTracesSection", () => {
testQueryClient.clear();
vi.mocked(agentTraceListCall).mockReset();
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
vi.mocked(apiClient.post).mockImplementation(async (_path, options) => {
const body = options?.body as { traces: { trace_id: string; trace_ref?: string }[] };
return body.traces.map((trace) => ({ ...trace, finding_count: null }));
});
});
it("loads the next page only once the list scrolls near its end, then stops at the last page", async () => {
@ -296,7 +301,7 @@ describe("AgentTracesSection", () => {
expect(card).toHaveTextContent("url: os.environ/CLICKHOUSE_URL");
});
it("lists every run with its input, counts and failed column", async () => {
it("lists uninvestigated runs without presenting tool errors as failures", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
renderSection();
@ -305,11 +310,73 @@ describe("AgentTracesSection", () => {
const lead = rows.find((row) => row.textContent?.includes("Should we store OTEL agent spans"));
expect(lead).toBeDefined();
const failed = rows.find((row) => row.textContent?.includes("acme-404")) as HTMLElement;
expect(within(failed).getByLabelText("2 errors")).toBeInTheDocument();
expect(within(failed).queryByLabelText("2 errors")).not.toBeInTheDocument();
expect(await within(failed).findByTitle("No conclusive investigation for this trace")).toHaveTextContent("-");
expect(screen.getByRole("columnheader", { name: "Findings" })).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Failed" })).not.toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Cost" })).toBeInTheDocument();
expect(within(failed).getByText("—")).toBeInTheDocument();
});
it("distinguishes uninvestigated traces, completed clean investigations, and findings", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue({ data: runs, next_cursor: null });
vi.mocked(apiClient.post).mockResolvedValue(
runs.map((run, index) => ({
trace_id: run.trace_id,
trace_ref: run.trace_ref ?? "",
finding_count: [null, 0, 3][index],
})),
);
renderSection();
expect(await screen.findByTitle("No conclusive investigation for this trace")).toHaveTextContent("-");
expect(await screen.findByTitle("0 findings from completed investigations")).toHaveTextContent("0");
expect(await screen.findByTitle("3 findings from completed investigations")).toHaveTextContent("3");
});
it("keeps failure labels out of the run totals above the findings table", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue({
data: [{ ...runs[0], status: "error", error_count: 8 }],
next_cursor: null,
});
renderSection();
expect(await screen.findByTestId("agent-trace-row")).toBeVisible();
expect(screen.getByText(/1 run from/)).toBeVisible();
expect(screen.queryByText(/failed runs|with errors/)).not.toBeInTheDocument();
});
it("does not present a failed findings lookup as an uninvestigated or clean trace", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue({ data: runs.slice(0, 1), next_cursor: null });
vi.mocked(apiClient.post).mockRejectedValue(new ApiError("Unavailable", 503, {}));
renderSection();
expect(await screen.findByTitle("Could not load findings")).toHaveTextContent("Unavailable");
expect(screen.queryByTitle("No conclusive investigation for this trace")).not.toBeInTheDocument();
});
it("keeps the column picker open when findings finish loading", async () => {
const user = userEvent.setup();
const pending = Promise.withResolvers<TraceFindingCount[]>();
vi.mocked(agentTraceListCall).mockResolvedValue({ data: runs.slice(0, 1), next_cursor: null });
vi.mocked(apiClient.post).mockReturnValue(pending.promise);
renderSection();
expect(await screen.findByTestId("agent-trace-row")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Columns" }));
expect(await screen.findByTestId("view-option-cost")).toBeVisible();
await act(async () => {
pending.resolve([{ trace_id: runs[0].trace_id, trace_ref: runs[0].trace_ref ?? "", finding_count: 1 }]);
});
expect(await screen.findByTitle("1 findings from completed investigations")).toBeVisible();
expect(screen.getByTestId("view-option-cost")).toBeVisible();
});
it("keeps traces available without requesting investigation data for users without access", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue({ data: runs, next_cursor: null });
vi.mocked(apiClient.post).mockClear();
renderSection(false);
expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(runs.length);
expect(screen.queryByRole("columnheader", { name: "Findings" })).not.toBeInTheDocument();
expect(apiClient.post).not.toHaveBeenCalled();
});
it("shows the spend returned for a run", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue({
...(traceList as TracePage),
@ -517,7 +584,9 @@ describe("AgentTracesSection", () => {
const user = userEvent.setup();
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
const onUrlUpdate = vi.fn();
const failed = filterRuns(runs, "status:error");
const mixed = runs.map((run, index) => (index === 1 ? { ...run, status: "error" as const } : run));
vi.mocked(agentTraceListCall).mockResolvedValue({ data: mixed, next_cursor: null });
const failed = filterRuns(mixed, "status:error");
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"

View file

@ -11,6 +11,7 @@ import { Inspector } from "@/components/shared/Inspector";
import { Button } from "@/components/ui/button";
import { AgentTracesTable } from "./AgentTracesTable";
import { useTraceFindings } from "./useTraceFindings";
import {
type TraceRef,
traceKey,
@ -53,6 +54,7 @@ interface AgentTracesSectionProps {
timeControls?: TimeControls;
readOnly?: boolean;
canMintTracingKey?: boolean;
canViewFindings?: boolean;
}
function useTracingSetup(traces: AgentTracesResult, isActive: boolean, rangeChanged: boolean) {
@ -93,6 +95,7 @@ export function AgentTracesSection({
timeControls,
readOnly = false,
canMintTracingKey = false,
canViewFindings,
}: AgentTracesSectionProps) {
const live = useTracesLive();
const { trace: openTrace, openTrace: openRun, selection, fullScreen, setFullScreen } = useOpenTraceRouting();
@ -123,6 +126,7 @@ export function AgentTracesSection({
);
const runs = useMemo(() => (zoom ? filterByWindow(filtered, zoom) : filtered), [filtered, zoom]);
const runRefs = useMemo(() => runs.map(traceRefOf), [runs]);
const findings = useTraceFindings(accessToken, runs, isActive, canViewFindings);
const changeRange = (hours: number, apply: (hours: number) => void) => {
setZoom(null);
@ -219,6 +223,8 @@ export function AgentTracesSection({
<TracesTimeline runs={filtered} range={range} selection={zoom} onSelect={setZoom} />
<AgentTracesTable
traces={runs}
findings={findings}
canViewFindings={canViewFindings}
isLoading={traces.isLoading || (checkHistory && history.isLoading)}
error={traces.error}
hasMore={traces.hasMore}
@ -244,13 +250,11 @@ function TracesReceived({ received }: { received: boolean }) {
}
function TraceCounts({ runs }: { runs: readonly TraceSummary[] }) {
const failed = runs.filter((run) => run.error_count > 0).length;
return (
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-2 text-xs text-muted-foreground">
<span>
{runs.length} {runs.length === 1 ? "run" : "runs"} from {new Set(runs.flatMap(traceAgentNames)).size} agents
</span>
{failed > 0 && <span className="text-destructive">{failed} with errors</span>}
</div>
);
}

View file

@ -28,6 +28,7 @@ const renderEmpty = (rangeEmpty: boolean) => {
inList(
<AgentTracesTable
traces={[]}
findings={new Map()}
isLoading={false}
error={null}
hasMore={false}
@ -58,6 +59,7 @@ describe("AgentTracesTable loading state", () => {
inList(
<AgentTracesTable
traces={[]}
findings={new Map()}
isLoading
error={null}
hasMore={false}
@ -89,6 +91,7 @@ describe("AgentTracesTable virtualization", () => {
inList(
<AgentTracesTable
traces={manyRuns}
findings={new Map()}
isLoading={false}
error={null}
hasMore={false}
@ -117,6 +120,7 @@ describe("AgentTracesTable column picker", () => {
inList(
<AgentTracesTable
traces={runs}
findings={new Map()}
isLoading={false}
error={null}
hasMore={false}
@ -163,6 +167,7 @@ describe("AgentTracesTable column picker", () => {
inList(
<AgentTracesTable
traces={[]}
findings={new Map()}
isLoading
error={null}
hasMore={false}

View file

@ -2,7 +2,7 @@
import { getCoreRowModel, useReactTable, type ColumnDef, type TableOptions } from "@tanstack/react-table";
import { ArrowDown, ChevronRight } from "lucide-react";
import { useEffect } from "react";
import { createContext, useContext, useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { DataTableViewOptions } from "@/components/shared/DataTable/DataTableViewOptions";
@ -13,7 +13,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation } from "@/utils/activityTimestamp";
import { SpanIcon } from "../ui/SpanIcon";
import { StatusMark } from "../ui/StatusMark";
import type { TraceFindingState } from "./useTraceFindings";
import { FrameworkLogo, traceFramework } from "../ui/TraceFramework";
import type { TraceSummary } from "../types";
import { traceRefOf } from "../routing";
@ -21,6 +21,8 @@ import { fmtMs, previewText, traceDisplayName, traceAgentNames } from "../utils"
interface AgentTracesTableProps {
traces: TraceSummary[];
findings: ReadonlyMap<string, TraceFindingState>;
canViewFindings?: boolean;
isLoading: boolean;
error: Error | null;
hasMore: boolean;
@ -46,6 +48,7 @@ const SKELETON_ROWS = Array.from({ length: 12 }, (_, i) => i);
const ROW_HEIGHT = 36;
const MUTED_NUM = "font-mono text-muted-foreground";
const NUM = "font-mono text-foreground";
const FindingsContext = createContext<ReadonlyMap<string, TraceFindingState>>(new Map());
function AgentCell({ run }: { run: TraceSummary }) {
const framework = traceFramework(run);
@ -62,7 +65,6 @@ function AgentCell({ run }: { run: TraceSummary }) {
function InputCell({ run }: { run: TraceSummary }) {
return (
<div className="flex min-w-0 items-center gap-2">
<StatusMark status={run.error_count > 0 ? "error" : "ok"} subtle />
<span className="truncate text-foreground">
{firstLine(previewText(run.input_preview)) || traceDisplayName(run)}
</span>
@ -79,6 +81,15 @@ function InputCell({ run }: { run: TraceSummary }) {
);
}
function FindingCount({ run }: { run: TraceSummary }) {
const state = useContext(FindingsContext).get(runKey(run));
if (!state || state.status === "pending")
return <Skeleton aria-label="Loading findings" className="ml-auto h-3 w-5" />;
if (state.status === "error") return <span title="Could not load findings">Unavailable</span>;
if (state.count === null) return <span title="No conclusive investigation for this trace">-</span>;
return <span title={`${state.count} findings from completed investigations`}>{state.count.toLocaleString()}</span>;
}
const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
{
id: "time",
@ -148,16 +159,11 @@ const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
meta: { numeric: true, className: NUM },
},
{
id: "failed",
size: 72,
header: "Failed",
cell: ({ row }) =>
row.original.error_count > 0 ? (
<StatusMark status="error" count={row.original.error_count} />
) : (
<span className="font-mono text-muted-foreground/60">0</span>
),
meta: { numeric: true },
id: "findings",
size: 96,
header: "Findings",
cell: ({ row }) => <FindingCount run={row.original} />,
meta: { numeric: true, className: NUM },
},
{
id: "open",
@ -208,6 +214,8 @@ function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetU
/** Devtool-dense runs list: one row per agent run, newest first. */
export function AgentTracesTable({
traces,
findings,
canViewFindings = true,
isLoading,
error,
hasMore,
@ -224,7 +232,7 @@ export function AgentTracesTable({
const { columnVisibility, onColumnVisibilityChange } = usePersistedColumnVisibility("lens-traces");
const tableOptions: TableOptions<TraceSummary> = {
data: traces,
columns: RUN_COLUMNS,
columns: RUN_COLUMNS.filter((column) => canViewFindings || column.id !== "findings"),
defaultColumn: { size: undefined },
getRowId: runKey,
autoResetAll: false,
@ -234,54 +242,56 @@ export function AgentTracesTable({
};
const table = useReactTable(tableOptions);
return (
<InspectorTable.Root table={table} data-testid="runs-table">
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
<InspectorTable.Header />
<InspectorTable.Body<TraceSummary>
rowHeight={() => ROW_HEIGHT}
after={
<>
{isLoading && SKELETON_ROWS.map((row) => <PlaceholderRow key={row} index={row} />)}
{autoContinue && <LoadMoreRows isFetching={isFetching} onLoadMore={onLoadMore} />}
</>
}
>
{(row) => (
<InspectorTable.Row
row={row}
item={traceRefOf(row.original)}
data-testid="agent-trace-row"
className="h-9"
/>
)}
</InspectorTable.Body>
</InspectorTable.Grid>
{isLoading && (
<p role="status" className="sr-only">
Loading runs…
</p>
)}
{error && (
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
<span>
{traces.length ? "Could not load more runs" : "Could not load runs"}: {error.message}
</span>
{onRetry && (
<Button size="xs" variant="outline" disabled={isFetching} onClick={onRetry}>
Retry
<FindingsContext.Provider value={findings}>
<InspectorTable.Root table={table} data-testid="runs-table">
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
<InspectorTable.Header />
<InspectorTable.Body<TraceSummary>
rowHeight={() => ROW_HEIGHT}
after={
<>
{isLoading && SKELETON_ROWS.map((row) => <PlaceholderRow key={row} index={row} />)}
{autoContinue && <LoadMoreRows isFetching={isFetching} onLoadMore={onLoadMore} />}
</>
}
>
{(row) => (
<InspectorTable.Row
row={row}
item={traceRefOf(row.original)}
data-testid="agent-trace-row"
className="h-9"
/>
)}
</InspectorTable.Body>
</InspectorTable.Grid>
{isLoading && (
<p role="status" className="sr-only">
Loading runs…
</p>
)}
{error && (
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
<span>
{traces.length ? "Could not load more runs" : "Could not load runs"}: {error.message}
</span>
{onRetry && (
<Button size="xs" variant="outline" disabled={isFetching} onClick={onRetry}>
Retry
</Button>
)}
</div>
)}
{canContinue && traces.length === 0 && (
<div className="flex items-center justify-center gap-3 py-16 text-xs text-muted-foreground">
<span>No loaded runs match these filters.</span>
<Button size="xs" variant="outline" disabled={isFetching} onClick={onLoadMore}>
Load older runs
</Button>
)}
</div>
)}
{canContinue && traces.length === 0 && (
<div className="flex items-center justify-center gap-3 py-16 text-xs text-muted-foreground">
<span>No loaded runs match these filters.</span>
<Button size="xs" variant="outline" disabled={isFetching} onClick={onLoadMore}>
Load older runs
</Button>
</div>
)}
{isEmpty && <EmptyRuns rangeEmpty={rangeEmpty} onSetUpTracing={onSetUpTracing} />}
</InspectorTable.Root>
</div>
)}
{isEmpty && <EmptyRuns rangeEmpty={rangeEmpty} onSetUpTracing={onSetUpTracing} />}
</InspectorTable.Root>
</FindingsContext.Provider>
);
}

View file

@ -7,11 +7,12 @@ const HOUR = 3600 * 1000;
const START = Date.UTC(2026, 8, 30, 0, 0, 0);
const range = { startMs: START, endMs: START + 10 * HOUR };
const run = (offsetMs: number, errorCount = 0): TraceSummary =>
const run = (offsetMs: number, errorCount = 0, status: TraceSummary["status"] = "ok"): TraceSummary =>
({
trace_id: `t${offsetMs}`,
start_time: new Date(START + offsetMs).toISOString(),
error_count: errorCount,
status,
}) as TraceSummary;
describe("bucketRuns", () => {
@ -35,9 +36,9 @@ describe("bucketRuns", () => {
expect(buckets[1].total).toBe(1);
});
it("counts runs with any errors as failed", () => {
const buckets = bucketRuns([run(HOUR, 3), run(HOUR + 1), run(HOUR + 2, 1), run(5 * HOUR)], range, 10);
expect(buckets[1]).toMatchObject({ total: 3, failed: 2 });
it("counts failed runs without treating recovered tool errors as run failures", () => {
const buckets = bucketRuns([run(HOUR, 8), run(HOUR + 1), run(HOUR + 2, 1, "error"), run(5 * HOUR)], range, 10);
expect(buckets[1]).toMatchObject({ total: 3, failed: 1 });
expect(buckets[5]).toMatchObject({ total: 1, failed: 0 });
});
});

View file

@ -13,7 +13,7 @@ export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buc
const width = (range.endMs - range.startMs) / buckets;
const placed = runs.map((run) => ({
index: Math.floor((moment(run.start_time).valueOf() - range.startMs) / width),
failed: run.error_count > 0,
failed: run.status === "error",
agent: traceAgentNames(run)[0] ?? "",
}));
return Array.from({ length: buckets }, (_, i) => {

View file

@ -36,6 +36,7 @@ const researchOverrides = {
agent_names: ["researcher"],
models: ["claude-opus"],
error_count: 2,
status: "error" as const,
};
const research = run(researchOverrides);
const plain = run({ trace_id: "ccc333", name: "health", service: "cron" });

View file

@ -15,12 +15,22 @@ describe("filterRuns", () => {
expect(ids("gpt-5")).toEqual([]);
});
it("splits runs by status, judged by recorded errors", () => {
it("splits runs by their overall status", () => {
expect(ids("status:error")).toEqual(["bbb222"]);
expect(ids("-status:error")).toEqual(["aaa111", "ccc333"]);
expect(ids("status:OK")).toEqual(["aaa111", "ccc333"]);
});
it.each(["ok", "unset"] as const)("keeps %s runs with recovered tool errors in non-failed filters", (status) => {
const recovered = run({ status, error_count: 8 });
expect(filterRuns([recovered], "status:error")).toEqual([]);
expect(filterRuns([recovered], "", { agent: "", status: "error" })).toEqual([]);
expect(filterRuns([recovered], "status:ok")).toEqual([recovered]);
expect(filterRuns([recovered], "", { agent: "", status: "ok" })).toEqual([recovered]);
expect(filterRuns([recovered], "-status:error")).toEqual([recovered]);
expect(fieldValues(RUN_INDEX, [recovered], "status")).toEqual(["ok"]);
});
it("reads agents from the trace, falling back to the service, and models from the run", () => {
expect(ids("agent:triage")).toEqual(["aaa111"]);
expect(ids("agent:cron")).toEqual(["ccc333"]);

View file

@ -19,12 +19,14 @@ export type RunField = keyof typeof RUN_FIELDS;
export const RUN_QUERY: QueryLanguage<RunField> = { fields: RUN_FIELDS, ops: ALL_OPERATORS };
const runStatus = (run: TraceSummary): "ok" | "error" => (run.status === "error" ? "error" : "ok");
/** Reads the run fields off a loaded page; free text searches trace id, input and name. */
export const RUN_INDEX: ClientIndex<TraceSummary, RunField> = {
read: {
name: (run) => [run.name],
agent: traceAgentNames,
status: (run) => [run.error_count > 0 ? "error" : "ok"],
status: (run) => [runStatus(run)],
model: (run) => run.models,
input: (run) => [previewText(run.input_preview)],
trace_id: (run) => [run.trace_id],
@ -38,7 +40,6 @@ export function filterRuns(
filters: { agent: string; status: "all" | "ok" | "error" } = { agent: "", status: "all" },
): TraceSummary[] {
const matchesAgent = (run: TraceSummary) => !filters.agent || traceAgentNames(run).includes(filters.agent);
const matchesStatus = (run: TraceSummary) =>
filters.status === "all" || (run.error_count > 0 ? "error" : "ok") === filters.status;
const matchesStatus = (run: TraceSummary) => filters.status === "all" || runStatus(run) === filters.status;
return filterItems(RUN_QUERY, RUN_INDEX, runs, query).filter((run) => matchesAgent(run) && matchesStatus(run));
}

View file

@ -13,7 +13,7 @@ describe("runPredicates", () => {
it("turns each run field into a predicate over the per-trace rollup columns", () => {
expect(predicates("agent:researcher status:error model:gpt-5 name:support trace_id:aaa111")).toEqual([
"arrayExists(x -> x ILIKE 'researcher', agents)",
"errors > 0",
"status = 'error'",
"arrayExists(x -> x ILIKE 'gpt-5', models)",
"name ILIKE 'support'",
"trace_id ILIKE 'aaa111'",
@ -33,7 +33,7 @@ describe("runPredicates", () => {
});
it("resolves a status glob statically, since status has two values", () => {
expect(predicates("status:ok")).toEqual(["errors = 0"]);
expect(predicates("status:ok")).toEqual(["status = 'ok'"]);
expect(predicates("status:*")).toEqual(["true"]);
expect(predicates("-status:pending")).toEqual(["NOT (false)"]);
});
@ -52,12 +52,13 @@ describe("runQuerySql", () => {
expect(sql).toBe(
[
"SELECT TraceId AS trace_id, any(RootName) AS name, any(RootInput) AS input, sum(ErrorCount) AS errors,",
" if(ifNull(any(RootStatus), '') IN ('STATUS_CODE_ERROR', 'error'), 'error', 'ok') AS status,",
" groupUniqArrayArray(AgentNames) AS agents, groupUniqArrayArray(Models) AS models",
"FROM agent_traces_by_key",
"GROUP BY TraceId",
`HAVING min(StartTs) >= fromUnixTimestamp64Milli(${RANGE.startMs}) AND min(StartTs) < fromUnixTimestamp64Milli(${RANGE.endMs})`,
" AND arrayExists(x -> x ILIKE 'researcher', agents)",
" AND errors > 0",
" AND status = 'error'",
"ORDER BY min(StartTs) DESC",
"LIMIT 100",
].join("\n"),
@ -87,6 +88,6 @@ describe("runQueryCommand", () => {
const command = runQueryCommand(RANGE)(query("agent:researcher status:error"));
expect(command).toBe(traceQueryCommand(runQuerySql(query("agent:researcher status:error"), RANGE)));
expect(command).toContain("arrayExists(x -> x ILIKE 'researcher', agents)");
expect(command).toContain("errors > 0");
expect(command).toContain("status = 'error'");
});
});

View file

@ -7,6 +7,7 @@ import type { SearchFilter, SearchQuery } from "@/components/shared/search/searc
import type { RunField } from "./runQuery";
const RUN_ROWS = `SELECT TraceId AS trace_id, any(RootName) AS name, any(RootInput) AS input, sum(ErrorCount) AS errors,
if(ifNull(any(RootStatus), '') IN ('STATUS_CODE_ERROR', 'error'), 'error', 'ok') AS status,
groupUniqArrayArray(AgentNames) AS agents, groupUniqArrayArray(Models) AS models
FROM agent_traces_by_key
GROUP BY TraceId`;
@ -21,7 +22,7 @@ const likePattern = (value: string): string => likeLiteral(value).replaceAll("*"
const matches = (column: string, pattern: string): string => `${column} ILIKE ${sqlString(pattern)}`;
const anyMatches = (column: string, pattern: string): string => `arrayExists(x -> ${matches("x", pattern)}, ${column})`;
const STATUS_PREDICATES = { error: "errors > 0", ok: "errors = 0" } as const;
const STATUS_PREDICATES = { error: "status = 'error'", ok: "status = 'ok'" } as const;
/** Status has two values, so a glob over it resolves statically to one, both or neither predicate. */
function statusPredicate(value: string): string {

View file

@ -0,0 +1,39 @@
import { useQueries } from "@tanstack/react-query";
import { chunk } from "es-toolkit";
import { useTracesApi } from "../api";
import type { TraceSummary } from "../types";
export type TraceFindingState = { status: "ready"; count: number | null } | { status: "pending" } | { status: "error" };
export function useTraceFindings(accessToken: string, runs: TraceSummary[], isActive: boolean, canViewFindings = true) {
const api = useTracesApi(accessToken);
const enabled = isActive && canViewFindings;
const batches = chunk(
runs.map(({ trace_id, trace_ref }) => ({ trace_id, trace_ref: trace_ref ?? "" })),
500,
);
const queries = useQueries({
queries: batches.map((traces) => ({
queryKey: ["traceFindings", accessToken, traces],
queryFn: () => api.findings(traces),
enabled,
staleTime: 15000,
refetchInterval: enabled && api.live ? 15000 : false,
retry: false,
})),
});
return new Map<string, TraceFindingState>(
batches.flatMap((traces, index) => {
const query = queries[index];
const counts = new Map(query.data?.map((result) => [result.trace_ref || result.trace_id, result.finding_count]));
return traces.map((trace): [string, TraceFindingState] => {
const key = trace.trace_ref || trace.trace_id;
if (query.isError) return [key, { status: "error" }];
if (query.isPending) return [key, { status: "pending" }];
if (!counts.has(key)) return [key, { status: "error" }];
return [key, { status: "ready", count: counts.get(key) ?? null }];
});
}),
);
}

View file

@ -11,6 +11,8 @@ export type SpanErrorQuery = NonNullable<
paths["/v1/traces/{trace_id}/spans/{span_id}/error"]["get"]["parameters"]["query"]
>;
export type TraceQueryBody = components["schemas"]["TraceQueryRequest"];
export type TraceFindingsRequest = components["schemas"]["TraceFindingsRequest"];
export type TraceFindingCount = components["schemas"]["TraceFindingCount"];
type ApiSpanDetail =
paths["/v1/traces/{trace_id}/spans/{span_id}"]["get"]["responses"][200]["content"]["application/json"];
export type Span = Trace["spans"][number];

View file

@ -1,27 +0,0 @@
import { AlertCircle, Check, Circle } from "lucide-react";
interface StatusMarkProps {
status: "ok" | "error" | "unset";
count?: number;
subtle?: boolean;
}
/** Run / span status glyph: red alert (+ count) for errors, a quiet muted dot or check otherwise. */
export function StatusMark({ status, count, subtle = false }: StatusMarkProps) {
if (status === "error") {
return (
<span
className="inline-flex items-center gap-1 text-xs text-destructive"
aria-label={`${count ?? 1} error${count === 1 ? "" : "s"}`}
>
<AlertCircle className="size-3" />
{count !== undefined && <span className="font-mono tabular-nums">{count}</span>}
</span>
);
}
if (subtle)
return (
<Circle className="size-2 shrink-0 fill-muted-foreground/60 text-muted-foreground/60" aria-label="Success" />
);
return <Check className="size-3 text-muted-foreground" aria-label="Success" />;
}

View file

@ -9000,6 +9000,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/lens/traces/findings": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Trace Findings */
post: operations["trace_findings_lens_traces_findings_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/watch-all": {
parameters: {
query?: never;
@ -46950,6 +46967,33 @@ export interface components {
spans: components["schemas"]["Span"][];
summary: components["schemas"]["TraceSummary"];
};
/** TraceFindingCount */
TraceFindingCount: {
/** Finding Count */
finding_count: number | null;
/** Trace Id */
trace_id: string;
/**
* Trace Ref
* @default
*/
trace_ref: string;
};
/** TraceFindingsRequest */
TraceFindingsRequest: {
/** Traces */
traces: components["schemas"]["TraceIdentity"][];
};
/** TraceIdentity */
TraceIdentity: {
/** Trace Id */
trace_id: string;
/**
* Trace Ref
* @default
*/
trace_ref: string;
};
/** TracePage */
TracePage: {
/** Data */
@ -62253,6 +62297,39 @@ export interface operations {
};
};
};
trace_findings_lens_traces_findings_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TraceFindingsRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TraceFindingCount"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
watch_all_lens_watch_all_post: {
parameters: {
query?: never;