From 94c784331aa97b6115aadf7edaf3c753ba0a2477 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 9 Dec 2025 12:26:11 +0000 Subject: [PATCH] add span embed --- .../shared/traces/[traceId]/spans/route.ts | 25 +++++ frontend/app/blog/[slug]/page.tsx | 3 + frontend/components/embeds/trace-embed.tsx | 106 ++++++++++++++++++ .../components/shared/traces/trace-view.tsx | 14 ++- 4 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 frontend/app/api/shared/traces/[traceId]/spans/route.ts create mode 100644 frontend/components/embeds/trace-embed.tsx diff --git a/frontend/app/api/shared/traces/[traceId]/spans/route.ts b/frontend/app/api/shared/traces/[traceId]/spans/route.ts new file mode 100644 index 000000000..2b7b6557c --- /dev/null +++ b/frontend/app/api/shared/traces/[traceId]/spans/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/frontend/app/blog/[slug]/page.tsx b/frontend/app/blog/[slug]/page.tsx index 2beb2bb03..6b3d420da 100644 --- a/frontend/app/blog/[slug]/page.tsx +++ b/frontend/app/blog/[slug]/page.tsx @@ -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 ( @@ -56,6 +57,8 @@ export default async function BlogPostPage(props0: { params: Promise<{ slug: str ol: (props) =>
    , li: (props) =>
  1. {props.children}
  2. , img: (props) => , + trace: (props) => , + Trace: (props) => , }} /> diff --git a/frontend/components/embeds/trace-embed.tsx b/frontend/components/embeds/trace-embed.tsx new file mode 100644 index 000000000..ed6d496e7 --- /dev/null +++ b/frontend/components/embeds/trace-embed.tsx @@ -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(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(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."); + } + + 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 ( +
    + {showPlaceholder && ( +
    + Preview your trace embed here. Once the trace is public and IDs are provided, the full trace view will render. +
    + )} + {!showPlaceholder && isLoading && ( +
    Loading shared trace…
    + )} + {!showPlaceholder && error && ( +
    {error}
    + )} + {!showPlaceholder && data && ( +
    + +
    + )} +
    + ); +}; + +export default TraceEmbed; diff --git a/frontend/components/shared/traces/trace-view.tsx b/frontend/components/shared/traces/trace-view.tsx index b028a28d9..5738b1ff3 100644 --- a/frontend/components/shared/traces/trace-view.tsx +++ b/frontend/components/shared/traces/trace-view.tsx @@ -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(); @@ -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( @@ -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]); return (