Skip to content

Commit 97036fb

Browse files
authored
feat(webapp,clickhouse): export run traces as log, markdown, or jsonl (#3851)
## Summary Adds a trace export to the run page. From the new **Export trace** menu you can copy a run's full trace to the clipboard as Markdown (for pasting into an AI assistant) or download it as a flat Log, a Markdown table, or JSON Lines. Internal engine-debug events are filtered out by default, and errors are surfaced inline with their message. ## Design The export streams events from the store to the gzipped response one at a time and never materialises the span tree, so a trace of any size exports with bounded memory and without stalling the server. Output is flat and chronological: each line carries its own `spanId ← parentSpanId`, so the hierarchy is reconstructable without nesting. Formats share a single streaming pipeline and are pluggable via `?format=log|jsonl|markdown`, so adding a format is an isolated change. ## Screenshots **Export menu** <img width="370" height="252" alt="trace-export-menu" src="https://github.com/user-attachments/assets/3d10304a-8c49-4606-b15d-2859b137419f" /> **In context** <img width="2400" height="1802" alt="trace-export-run-page" src="https://github.com/user-attachments/assets/46c80b30-303b-47c6-9ace-a2fb06f6cb61" />
1 parent fa4804e commit 97036fb

15 files changed

Lines changed: 956 additions & 181 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Export a run's full trace from the run page as a downloadable Log, Markdown, or JSON Lines file, or copy it to the clipboard for pasting into an AI assistant. The export streams straight from the store, so even very large runs export reliably.

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx

Lines changed: 82 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import {
22
ArrowPathIcon,
3-
ArrowRightIcon,
43
BookOpenIcon,
54
CheckIcon,
65
ChevronUpIcon,
6+
ClipboardDocumentIcon,
77
ClockIcon,
88
CloudArrowDownIcon,
99
EnvelopeIcon,
@@ -42,6 +42,8 @@ import {
4242
PopoverMenuItem,
4343
PopoverTrigger,
4444
} from "~/components/primitives/Popover";
45+
import { ToastUI } from "~/components/primitives/Toast";
46+
import { toast } from "sonner";
4547
import * as Property from "~/components/primitives/PropertyTable";
4648
import { Spinner } from "~/components/primitives/Spinner";
4749
import {
@@ -79,7 +81,6 @@ import { useOrganization } from "~/hooks/useOrganizations";
7981
import { useProject } from "~/hooks/useProject";
8082
import { useSearchParams } from "~/hooks/useSearchParam";
8183
import { useHasAdminAccess } from "~/hooks/useUser";
82-
import { useCanViewLogsPage } from "~/hooks/useCanViewLogsPage";
8384
import { redirectWithErrorMessage } from "~/models/message.server";
8485
import { type Span, SpanPresenter, type SpanRun } from "~/presenters/v3/SpanPresenter.server";
8586
import { logger } from "~/services/logger.server";
@@ -91,7 +92,6 @@ import {
9192
v3BatchPath,
9293
v3SessionPath,
9394
v3DeploymentVersionPath,
94-
v3LogsPath,
9595
v3RunDownloadLogsPath,
9696
v3RunIdempotencyKeyResetPath,
9797
v3RunPath,
@@ -386,7 +386,6 @@ function RunBody({
386386
const { value, replace } = useSearchParams();
387387
const tab = value("tab");
388388
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();
389-
const canViewLogsPage = useCanViewLogsPage();
390389

391390
return (
392391
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_minmax(3.25rem,auto)] overflow-hidden bg-background-bright">
@@ -1105,64 +1104,94 @@ function RunBody({
11051104
</div>
11061105
<div className="flex items-center">
11071106
{run.logsDeletedAt === null ? (
1108-
canViewLogsPage ? (
1109-
<div className="flex">
1110-
<LinkButton
1111-
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
1112-
new Date(run.createdAt).getTime() - 60000
1113-
}`}
1107+
<Popover>
1108+
<PopoverTrigger asChild>
1109+
<Button
11141110
variant="secondary/medium"
1115-
className="rounded-r-none border-r-0"
1111+
LeadingIcon={CloudArrowDownIcon}
1112+
leadingIconClassName="text-indigo-400"
1113+
TrailingIcon={ChevronUpIcon}
11161114
>
1117-
View logs
1118-
</LinkButton>
1119-
<Popover>
1120-
<PopoverTrigger asChild>
1121-
<Button
1122-
variant="secondary/medium"
1123-
className="rounded-l-none border-l-charcoal-700 px-1.5"
1124-
>
1125-
<ChevronUpIcon className="size-4 transition group-hover/button:text-text-bright" />
1126-
</Button>
1127-
</PopoverTrigger>
1128-
<PopoverContent className="min-w-[140px] p-1" align="end">
1129-
<PopoverMenuItem
1130-
to={`${v3LogsPath(
1131-
organization,
1132-
project,
1133-
environment
1134-
)}?runId=${runParam}&from=${new Date(run.createdAt).getTime() - 60000}`}
1135-
title="View logs"
1136-
icon={ArrowRightIcon}
1137-
leadingIconClassName="text-blue-500"
1138-
/>
1139-
<PopoverMenuItem
1140-
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
1141-
title="Download logs"
1142-
icon={CloudArrowDownIcon}
1143-
leadingIconClassName="text-indigo-500"
1144-
openInNewTab
1145-
/>
1146-
</PopoverContent>
1147-
</Popover>
1148-
</div>
1149-
) : (
1150-
<LinkButton
1151-
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
1152-
LeadingIcon={CloudArrowDownIcon}
1153-
leadingIconClassName="text-indigo-400"
1154-
variant="secondary/medium"
1155-
>
1156-
Download logs
1157-
</LinkButton>
1158-
)
1115+
Export trace
1116+
</Button>
1117+
</PopoverTrigger>
1118+
<PopoverContent className="min-w-[180px] p-1" align="end">
1119+
<TraceExportMenuItems runParam={runParam} />
1120+
</PopoverContent>
1121+
</Popover>
11591122
) : null}
11601123
</div>
11611124
</div>
11621125
</div>
11631126
);
11641127
}
11651128

1129+
// Trace export menu items: copy the trace as Markdown (for pasting into an AI
1130+
// assistant) plus a download per format. The export route streams `?format=`
1131+
// server-side.
1132+
function TraceExportMenuItems({ runParam }: { runParam: string }) {
1133+
const downloadPath = v3RunDownloadLogsPath({ friendlyId: runParam });
1134+
1135+
const copyForAI = async () => {
1136+
try {
1137+
// Hand the clipboard a ClipboardItem backed by a promise so access is
1138+
// reserved synchronously during the click. The fetch can then take as long
1139+
// as a large trace needs without the browser revoking the transient user
1140+
// activation, which a fetch-then-writeText sequence trips (notably Safari
1141+
// and Firefox).
1142+
const text = fetch(`${downloadPath}?format=markdown`, { credentials: "include" }).then(
1143+
async (response) => {
1144+
if (!response.ok) {
1145+
throw new Error(`Request failed with ${response.status}`);
1146+
}
1147+
return new Blob([await response.text()], { type: "text/plain" });
1148+
}
1149+
);
1150+
1151+
await navigator.clipboard.write([new ClipboardItem({ "text/plain": text })]);
1152+
toast.custom((t) => (
1153+
<ToastUI variant="success" message="Copied trace as Markdown" t={t as string} />
1154+
));
1155+
} catch {
1156+
toast.custom((t) => (
1157+
<ToastUI variant="error" message="Couldn't copy the trace" t={t as string} />
1158+
));
1159+
}
1160+
};
1161+
1162+
return (
1163+
<>
1164+
<PopoverMenuItem
1165+
title="Copy for AI"
1166+
icon={ClipboardDocumentIcon}
1167+
leadingIconClassName="text-emerald-500"
1168+
onClick={copyForAI}
1169+
/>
1170+
<PopoverMenuItem
1171+
to={`${downloadPath}?format=markdown`}
1172+
title="Download · Markdown"
1173+
icon={CloudArrowDownIcon}
1174+
leadingIconClassName="text-indigo-500"
1175+
openInNewTab
1176+
/>
1177+
<PopoverMenuItem
1178+
to={`${downloadPath}?format=log`}
1179+
title="Download · Log"
1180+
icon={CloudArrowDownIcon}
1181+
leadingIconClassName="text-indigo-500"
1182+
openInNewTab
1183+
/>
1184+
<PopoverMenuItem
1185+
to={`${downloadPath}?format=jsonl`}
1186+
title="Download · JSON Lines"
1187+
icon={CloudArrowDownIcon}
1188+
leadingIconClassName="text-indigo-500"
1189+
openInNewTab
1190+
/>
1191+
</>
1192+
);
1193+
}
1194+
11661195
function RunError({ error }: { error: TaskRunError }) {
11671196
const enhancedError = taskRunErrorEnhancer(error);
11681197

0 commit comments

Comments
 (0)