Skip to content
Open
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
25 changes: 25 additions & 0 deletions frontend/app/api/shared/traces/[traceId]/spans/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { prettifyError, ZodError } from "zod/v4";

import { getSharedSpans } from "@/lib/actions/shared/spans";

export async function GET(
_req: NextRequest,
props: { params: Promise<{ traceId: string }> }
): Promise<NextResponse> {
const { traceId } = await props.params;

try {
const spans = await getSharedSpans({ traceId });
return NextResponse.json(spans);
} catch (e) {
if (e instanceof ZodError) {
return NextResponse.json({ error: prettifyError(e) }, { status: 400 });
}

return NextResponse.json(
{ error: e instanceof Error ? e.message : "Failed to get shared spans." },
{ status: 500 }
);
}
}
3 changes: 3 additions & 0 deletions frontend/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MDXRemote } from "next-mdx-remote/rsc";
import BlogMeta from "@/components/blog/blog-meta";
import MDHeading from "@/components/blog/md-heading";
import PreHighlighter from "@/components/blog/pre-highlighter";
import TraceEmbed from "@/components/embeds/trace-embed";
import { getBlogPost } from "@/lib/blog/utils";

export const generateMetadata = async (
Expand Down Expand Up @@ -56,6 +57,8 @@ export default async function BlogPostPage(props0: { params: Promise<{ slug: str
ol: (props) => <ol className="list-decimal pl-4 pt-4 text-white/85" {...props} />,
li: (props) => <li className="pt-1.5 text-white/85" {...props}>{props.children}</li>,
img: (props) => <img className="md:w-[1000px] relative w-full border rounded-lg mb-8" {...props} />,
trace: (props) => <TraceEmbed {...props} />,
Trace: (props) => <TraceEmbed {...props} />,
}}
/>
</div>
Expand Down
106 changes: 106 additions & 0 deletions frontend/components/embeds/trace-embed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use client";

import React, { useEffect, useMemo, useState } from "react";

import TraceView from "@/components/shared/traces/trace-view";
import { TraceViewSpan, TraceViewTrace } from "@/components/traces/trace-view/trace-view-store";

type TraceEmbedProps = {
id?: string;
traceId?: string;
spanId?: string;
host?: string;
previewOnly?: boolean;
height?: number;
};

type TracePayload = {
trace: TraceViewTrace;
spans: TraceViewSpan[];
};

const TraceEmbed = ({ id, traceId, spanId, host, previewOnly = false, height = 720 }: TraceEmbedProps) => {
const traceIdentifier = traceId || id;
const resolvedHost = useMemo(() => {
const fallback = process.env.NEXT_PUBLIC_APP_URL || "https://laminar.sh";
const detected = typeof window !== "undefined" ? window.location.origin : "";
const base = host || detected || fallback;
return base.endsWith("/") ? base.slice(0, -1) : base;
}, [host]);

const [data, setData] = useState<TracePayload | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(Boolean(traceIdentifier) && !previewOnly);

useEffect(() => {
if (!traceIdentifier || previewOnly) {
return;
}

const controller = new AbortController();
const load = async () => {
setIsLoading(true);
setError(null);

try {
const [traceRes, spansRes] = await Promise.all([
fetch(`${resolvedHost}/api/shared/traces/${traceIdentifier}`, { signal: controller.signal }),
fetch(`${resolvedHost}/api/shared/traces/${traceIdentifier}/spans`, { signal: controller.signal }),
]);

if (!traceRes.ok) {
const text = await traceRes.text();
throw new Error(text || "Failed to load trace.");
}

if (!spansRes.ok) {
const text = await spansRes.text();
throw new Error(text || "Failed to load spans.");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Error responses parsed as text display raw JSON

When API requests fail, the error responses are read using text() but the API endpoints return JSON objects like { error: "message" }. This causes the displayed error message to show raw JSON strings like {"error":"Trace not found"} to users instead of just the error message. The code should parse the JSON response and extract the error field.

Fix in Cursor Fix in Web

}

const [trace, spans] = await Promise.all([traceRes.json(), spansRes.json()]);
setData({ trace, spans });
} catch (e) {
if (controller.signal.aborted) return;
setData(null);
setError(e instanceof Error ? e.message : "Failed to load shared trace.");
} finally {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
};

void load();

return () => controller.abort();
}, [previewOnly, resolvedHost, traceIdentifier]);

const showPlaceholder = previewOnly || !traceIdentifier;

return (
<div
className="w-full border border-white/10 bg-secondary/20 rounded-xl overflow-hidden"
style={{ minHeight: height, height }}
>
{showPlaceholder && (
<div className="p-4 text-sm text-muted-foreground">
Preview your trace embed here. Once the trace is public and IDs are provided, the full trace view will render.
</div>
)}
{!showPlaceholder && isLoading && (
<div className="p-4 text-sm text-muted-foreground">Loading shared trace&hellip;</div>
)}
{!showPlaceholder && error && (
<div className="p-4 text-sm text-destructive bg-destructive/10 border-b border-destructive/40">{error}</div>
)}
{!showPlaceholder && data && (
<div className="h-full min-h-[640px] bg-background" style={{ height: "100%" }}>
<TraceView trace={data.trace} spans={data.spans} initialSpanId={spanId} disableRouting />
</div>
)}
</div>
);
};

export default TraceEmbed;
14 changes: 9 additions & 5 deletions frontend/components/shared/traces/trace-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ import { cn } from "@/lib/utils";
interface TraceViewProps {
trace: TraceViewTrace;
spans: TraceViewSpan[];
initialSpanId?: string;
disableRouting?: boolean;
}

const PureTraceView = ({ trace, spans }: TraceViewProps) => {
const PureTraceView = ({ trace, spans, initialSpanId, disableRouting = false }: TraceViewProps) => {
const searchParams = useSearchParams();
const router = useRouter();
const pathName = usePathname();
Expand Down Expand Up @@ -99,14 +101,14 @@ const PureTraceView = ({ trace, spans }: TraceViewProps) => {

const handleSpanSelect = useCallback(
(span?: TraceViewSpan) => {
if (span) {
if (span && !disableRouting) {
const params = new URLSearchParams(searchParams);
params.set("spanId", span.spanId);
router.push(`${pathName}?${params.toString()}`);
}
setSelectedSpan(span);
},
[pathName, router, searchParams, setSelectedSpan]
[disableRouting, pathName, router, searchParams, setSelectedSpan]
);

const handleResizeTreeView = useCallback(
Expand Down Expand Up @@ -142,14 +144,16 @@ const PureTraceView = ({ trace, spans }: TraceViewProps) => {
const enrichedSpans = enrichSpansWithPending(spans);
setSpans(enrichedSpans);
setTrace(trace);
}, [setSpans, setTrace, spans, trace]);

const spanId = searchParams.get("spanId");
useEffect(() => {
const spanId = initialSpanId || searchParams.get("spanId");
const span = spans?.find((s) => s.spanId === spanId) || spans?.[0];

if (span) {
setSelectedSpan({ ...span, collapsed: false });
}
}, []);
}, [initialSpanId, searchParams, setSelectedSpan, spans]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Span selection reset on URL parameter changes

The second useEffect has searchParams and spans in its dependency array, causing it to re-run whenever URL parameters change. When disableRouting is true (as in the embed component), user span selections are not persisted to the URL, so any searchParams change will reset the selection back to initialSpanId or the first span, losing the user's interactive selection. Previously this logic only ran on mount with an empty dependency array.

Fix in Cursor Fix in Web


return (
<ScrollContextProvider>
Expand Down