-
Notifications
You must be signed in to change notification settings - Fork 153
Add Span Embed #1077
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Add Span Embed #1077
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 } | ||
| ); | ||
| } | ||
| } |
| 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."); | ||
| } | ||
|
|
||
| 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…</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; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Span selection reset on URL parameter changesThe second |
||
|
|
||
| return ( | ||
| <ScrollContextProvider> | ||
|
|
||
There was a problem hiding this comment.
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 theerrorfield.