diff --git a/apps/local/README.md b/apps/local/README.md index acc8d5dca..dee7790fe 100644 --- a/apps/local/README.md +++ b/apps/local/README.md @@ -55,7 +55,8 @@ receiver's envelope retention limit when replaying a large corpus. - Errors include exception causes, captured source lines and local variables, breadcrumbs, request/context data, tags, modules, VS Code links for absolute - local paths, and a copyable Markdown summary for AI assistants. + local paths, a copyable Markdown summary for AI assistants, and the + attachments the SDK sent for the event (collapsed until previewed). - AI inspection reads standard `gen_ai.*` and Vercel AI fields from both embedded transaction spans and standalone spans. Prompts, responses, token usage and tool arguments/results appear when the SDK captured them. Child AI steps stay diff --git a/apps/local/src/components/attachment-detail.tsx b/apps/local/src/components/attachment-detail.tsx index 3e3381b8b..b6e14f10f 100644 --- a/apps/local/src/components/attachment-detail.tsx +++ b/apps/local/src/components/attachment-detail.tsx @@ -129,14 +129,16 @@ export function AttachmentDetail({ ); } -export function EnvelopeAttachments({ +export function AttachmentList({ + label, attachments, }: { + label: string; attachments: readonly LocalFeedItem[]; }) { if (!attachments.length) return null; return ( -
+
{attachments.map((attachment) => ( ))} diff --git a/apps/local/src/components/event-detail.test.tsx b/apps/local/src/components/event-detail.test.tsx index 2cb389db4..901bdab1e 100644 --- a/apps/local/src/components/event-detail.test.tsx +++ b/apps/local/src/components/event-detail.test.tsx @@ -229,3 +229,63 @@ test("a related trace suppresses the empty-details message", () => { expect(screen.getByRole("region", { name: "Trace waterfall" })).toBeDefined(); expect(screen.queryByText("No additional event details.")).toBeNull(); }); + +test("errors list their captured attachments collapsed until previewed", () => { + const eventId = "a".repeat(32); + const attachment = (filename: string, data: string) => [ + { type: "attachment", filename, content_type: "text/plain" }, + { data }, + ]; + // The bundled fixture shows envelope and payload IDs can differ; match either. + const sameEnvelope = decodeEnvelope( + JSON.stringify([ + { event_id: "d".repeat(32) }, + [ + [{ type: "event" }, { event_id: eventId, message: "Checkout failed" }], + attachment("screen.txt", "captured screen"), + ], + ]), + "error", + ); + const [laterAttachment] = decodeEnvelope( + JSON.stringify([ + { event_id: eventId }, + [attachment("hierarchy.txt", "view tree")], + ]), + "later", + ); + const [otherAttachment] = decodeEnvelope( + JSON.stringify([ + { event_id: "b".repeat(32) }, + [attachment("other.txt", "another event")], + ]), + "other", + ); + const event = sameEnvelope[0]!; + render( + , + ); + const attachments = screen.getByRole("region", { name: "Event attachments" }); + expect(attachments.textContent).toContain("screen.txt"); + expect(attachments.textContent).toContain("hierarchy.txt"); + expect(attachments.textContent).not.toContain("other.txt"); + expect(attachments.textContent).not.toContain("captured screen"); + + fireEvent.click( + screen.getByRole("button", { name: "Show screen.txt preview" }), + ); + expect(attachments.textContent).toContain("captured screen"); + expect(attachments.textContent).not.toContain("view tree"); +}); + +test("errors without captured attachments show no attachment section", () => { + const event = item("event", { event_id: "c".repeat(32), message: "Boom" }); + render(); + expect( + screen.queryByRole("region", { name: "Event attachments" }), + ).toBeNull(); + expect(screen.queryByRole("heading", { name: "Attachments" })).toBeNull(); +}); diff --git a/apps/local/src/components/event-detail.tsx b/apps/local/src/components/event-detail.tsx index 6c0566551..60c5d6c2c 100644 --- a/apps/local/src/components/event-detail.tsx +++ b/apps/local/src/components/event-detail.tsx @@ -1,7 +1,7 @@ -import { envelopeAttachments } from "@/lib/attachments.ts"; +import { envelopeAttachments, eventAttachments } from "@/lib/attachments.ts"; import { EventContext, FeedbackInspector } from "./event-context.tsx"; import { AiInspector } from "./ai-inspector.tsx"; -import { AttachmentDetail, EnvelopeAttachments } from "./attachment-detail.tsx"; +import { AttachmentDetail, AttachmentList } from "./attachment-detail.tsx"; import { errorMarkdown, inspectAi, hasEventContext } from "@/lib/inspection.ts"; import { Button } from "./ui/button.tsx"; import { useCopyToClipboard } from "@uidotdev/usehooks"; @@ -107,8 +107,13 @@ export function EventDetail({ const [, copyForAi] = useCopyToClipboard(); const ai = inspectAi(item.payload); const attachments = useMemo( - () => (item.type === "envelope" ? envelopeAttachments(item) : []), - [item], + () => + item.type === "envelope" + ? envelopeAttachments(item) + : item.type === "event" + ? eventAttachments(item, relatedItems ?? []) + : [], + [item, relatedItems], ); const hasContext = (item.type === "event" || item.type === "transaction") && @@ -226,7 +231,10 @@ export function EventDetail({ ) : null} {item.type === "envelope" ? ( - + ) : null} {item.type === "feedback" || item.type === "user_report" ? (
) : null} + {item.type === "event" && attachments.length ? ( +
+

Attachments

+ +
+ ) : null} {!fields.length && !(isRequestOrSpan && summaryFields.length) && !exceptions.length && !attributes.length && + !attachments.length && !ai && !hasContext && !trace && diff --git a/apps/local/src/lib/attachments.ts b/apps/local/src/lib/attachments.ts index 8b25e7738..80dd52451 100644 --- a/apps/local/src/lib/attachments.ts +++ b/apps/local/src/lib/attachments.ts @@ -91,3 +91,24 @@ export function envelopeAttachments(item: LocalFeedItem): LocalFeedItem[] { : [], ); } + +/** + * Attachments captured for an event. SDKs send them in the event's envelope or + * in a later envelope whose header names the event. + */ +export function eventAttachments( + event: LocalFeedItem, + items: readonly LocalFeedItem[], +): LocalFeedItem[] { + const ids = [ + text(event.envelopeHeader?.event_id), + text(record(event.payload).event_id), + ].filter((id): id is string => id !== undefined); + if (!ids.length) return []; + return items.filter((candidate) => { + const id = text(candidate.envelopeHeader?.event_id); + return ( + candidate.type === "attachment" && id !== undefined && ids.includes(id) + ); + }); +}