studio/src/components/analytics/trace.tsx (532 lines of code) (raw):

import { nsToTime } from "@/lib/insights-helpers"; import { Service, mapServiceName, mapSpanKind, mapStatusCode, selectColor, } from "@/lib/trace-utils"; import { CubeIcon, ExclamationTriangleIcon, MinusIcon, PlusIcon, } from "@heroicons/react/24/outline"; import clsx from "clsx"; import { Span } from "@wundergraph/cosmo-connect/dist/platform/v1/platform_pb"; import { useCallback, useEffect, useState } from "react"; import { useMovable } from "react-move-hook"; import { Button } from "../ui/button"; import { Card } from "../ui/card"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../ui/tooltip"; import { docsBaseURL } from "@/lib/constants"; interface SpanNode extends Span { children?: SpanNode[]; } const initialPaneWidth = 360; const initialCollapsedSpanDepth = 4; const Attribute = ({ name, value }: { name: string; value: any }) => { return ( <TooltipProvider> <Tooltip delayDuration={300}> <TooltipTrigger> <div className="flex items-center gap-x-1"> <span className="text-accent-foreground">{name}</span>{" "} <span className="text-accent-foreground">=</span>{" "} <span className="truncate text-accent-foreground/80">{value}</span> </div> </TooltipTrigger> <TooltipContent className="max-w-lg">{value}</TooltipContent> </Tooltip> </TooltipProvider> ); }; const bigintE3 = BigInt(1e3); const bigintE2 = BigInt(1e2); function Node({ span, parentSpan, level, globalDuration, globalStartTime, isParentDetailsOpen, services, paneWidth, }: { span: SpanNode; parentSpan?: SpanNode; level: number; globalDuration: bigint; globalStartTime: bigint; isParentDetailsOpen: boolean; services: Service[]; paneWidth: number; }) { const [showDetails, setShowDetails] = useState(false); const hasChildren = span.children && span.children.length > 0; const parentChildrenCount = parentSpan?.children ? parentSpan.children.length : 0; // Work with smaller units (picosecond) on numerator to circumvent bigint division const elapsedDurationPs = (span.timestamp - globalStartTime) * bigintE3; const spanDurationPs = span.duration * bigintE3; const visualOffsetPercentage = Number( ((elapsedDurationPs / globalDuration) * bigintE2) / bigintE3, ); const visualWidthPercentage = Number( ((spanDurationPs / globalDuration) * bigintE2) / bigintE3, ); const [isOpen, setIsOpen] = useState( () => level <= initialCollapsedSpanDepth, ); const service = services.find( (service) => service.name === mapServiceName(span.serviceName), ); const hasChildrenError = (span: SpanNode) => { if ( span.statusCode === "STATUS_CODE_ERROR" || !!span.attributes?.httpStatusCode.startsWith("4") ) { return true; } if (span.children) { return span.children.some(hasChildrenError); } return false; }; const [isError, setIsError] = useState<boolean>( () => span.statusCode === "STATUS_CODE_ERROR" || !!span.attributes?.httpStatusCode.startsWith("4") || (!isOpen && hasChildrenError(span)), ); const getDurationOffset = () => { const durationCharCount = (nsToTime(span.duration) as string).length; if (visualWidthPercentage < 8 && durationCharCount < 9) { if (visualOffsetPercentage < 90) { return `calc(${visualOffsetPercentage + visualWidthPercentage + 2}%)`; } if (visualOffsetPercentage >= 90) { return `calc(${visualOffsetPercentage - visualWidthPercentage - 10}%)`; } } return `${visualOffsetPercentage + 2}%`; }; const toggleTree = () => { setIsOpen((prevOpen) => { if (hasChildren) { if (prevOpen) { setIsError(hasChildrenError(span)); } else { setIsError( span.statusCode === "STATUS_CODE_ERROR" || !!span.attributes?.httpStatusCode.startsWith("4"), ); } } return !prevOpen; }); }; return ( <ul style={{ marginLeft: `${16}px`, minWidth: `${1024 - level * 32}px`, }} className={clsx( `trace-ul relative before:-top-4 before:h-[34px] lg:max-w-none`, { "before:top-0 before:h-[18px]": isParentDetailsOpen, "before:!h-full": parentChildrenCount > 1, "pl-4": level > 1, }, )} > <li className={clsx("group relative", { "bg-accent pb-2": showDetails, })} > <div className="relative flex w-full flex-wrap"> <div className="ml-2 flex flex-shrink-0 items-start gap-x-1 border-r border-input py-1" style={{ width: `${paneWidth - level * 32}px`, }} > <Button size="icon" variant="outline" onClick={toggleTree} disabled={!hasChildren} className={clsx( "mt-1.5 h-min w-min rounded-sm border border-input p-px", { "border-none": !hasChildren, }, )} > <> {hasChildren && isOpen && ( <MinusIcon className="h-3 w-3 flex-shrink-0" /> )} {hasChildren && !isOpen && ( <PlusIcon className="h-3 w-3 flex-shrink-0" /> )} {!hasChildren && <CubeIcon className="h-4 w-4 flex-shrink-0" />} </> </Button> <button type="button" onClick={() => setShowDetails(!showDetails)} className="flex flex-nowrap items-start gap-x-2 overflow-hidden rounded-md px-2 py-1 text-left text-sm group-hover:bg-accent group-hover:text-accent-foreground" > <TooltipProvider> <Tooltip delayDuration={500}> <TooltipTrigger className="space-y-1"> <div className="flex flex-col"> <div className="flex items-center gap-x-1"> {isError && ( <ExclamationTriangleIcon className="mt-1 h-4 w-4 text-destructive" /> )} <div className="flex flex-1 items-center gap-x-1.5 truncate font-medium"> <div>{service?.name}</div>{" "} <div className="text-xs text-muted-foreground"> {mapSpanKind[span.spanKind]} </div> </div> </div> </div> <div style={{ width: `${paneWidth - level * 32 - 44}px`, }} className="truncate text-start text-xs" > {span.spanName} </div> </TooltipTrigger> <TooltipContent className="max-w-lg"> <div className="flex flex-col space-y-3"> <div>{span.spanName}</div> </div> </TooltipContent> </Tooltip> </TooltipProvider> </button> </div> <button type="button" onClick={() => setShowDetails(!showDetails)} className="group relative flex flex-1 items-center group-hover:brightness-90" > <div className="absolute h-px w-full bg-input" /> <div style={{ minWidth: "2px", maxWidth: "500px !important", width: `${visualWidthPercentage}%`, left: `${visualOffsetPercentage}%`, backgroundColor: service?.color, }} className="z-8 absolute mx-2 h-3/5 max-h-6 rounded" /> <div style={{ left: getDurationOffset(), }} className={clsx("z-8 absolute bg-transparent text-xs", { "px-2": visualWidthPercentage < 8, "!text-white": visualWidthPercentage >= 8, })} > {nsToTime(span.duration)} </div> </button> </div> {showDetails && ( <div className="my-2 flex px-4 pr-6"> <div style={{ width: `${paneWidth - level * 32}px`, }} className="flex flex-none flex-col gap-x-4 gap-y-1 overflow-hidden border-0 px-4 text-xs" > <Attribute key="spanID" name="spanID" value={span.spanID} /> <Attribute key="timing" name="startTime" value={ parentSpan ? nsToTime(span.timestamp - parentSpan.timestamp) : nsToTime(BigInt(0)) } /> {span.statusCode && ( <Attribute key="status" name="status" value={mapStatusCode[span.statusCode]} /> )} </div> <div className="grid grow grid-cols-2 place-content-between gap-x-4 gap-y-1 overflow-hidden border-0 px-4 text-xs"> {span.statusMessage && ( <Attribute key="statusMessage" name="statusMessage" value={span.statusMessage} /> )} {Object.entries(span.attributes ?? {}) .sort((a, b) => a[0].toUpperCase().localeCompare(b[0].toUpperCase()), ) .filter(([key, value], _) => !!value) .map(([key, value]) => { return <Attribute key={key} name={key} value={value} />; })} </div> </div> )} </li> {hasChildren && isOpen && ( <> {span.children?.map((child) => ( <Node key={child.spanID} span={child} parentSpan={span} level={level + 1} globalDuration={globalDuration} globalStartTime={globalStartTime} isParentDetailsOpen={showDetails} paneWidth={paneWidth} services={services} /> ))} </> )} </ul> ); } const Trace = ({ spans }: { spans: Span[] }) => { const [traceTree, setTraceTree] = useState<SpanNode>(); const [missingRootSpan, setMissingRootSpan] = useState<boolean>(); const [globalDuration, setGlobalDuration] = useState(BigInt(0)); const [globalStartTime, setGlobalStartTime] = useState(BigInt(0)); const [detectedService, setDetectedServices] = useState<Service[]>([]); const [paneWidth, setPaneWidth] = useState(initialPaneWidth); const [mouseState, setMouseState] = useState({ moving: false, position: { x: initialPaneWidth, y: 0 }, delta: { x: 0, y: 0 }, }); const handleChange = useCallback((moveData: any) => { setMouseState((state) => ({ moving: moveData.moving, position: moveData.stoppedMoving ? { ...state.position, x: state.position.x + moveData.delta.x, y: state.position.y + moveData.delta.y, } : state.position, delta: moveData.moving ? moveData.delta : undefined, })); if (!moveData.moving) { setPaneWidth((width) => width + moveData.delta.x); document.body.classList.remove("select-none"); } else { document.body.classList.add("select-none"); } }, []); const ref = useMovable({ onChange: handleChange, axis: "x", bounds: "parent", }); useEffect(() => { const services = new Set<string>(); spans.forEach((s) => { services.add(s.serviceName); }); const serviceArray: Service[] = []; let i = 1; services.forEach((serviceName) => { serviceArray.push({ // The order is stable because spans are sorted by start time // and spans for the edge and node are always created in the same order color: selectColor(i), name: mapServiceName(serviceName), }); i += 1; }); setDetectedServices(serviceArray); }, [spans]); useEffect(() => { let gStartTimeNano = BigInt(Number.MAX_VALUE); let gEndTimeNano = BigInt(0); const buildSpanTree = (spans: SpanNode[]): SpanNode => { const spanMap = new Map<string, SpanNode>(); for (const span of spans) { span.children = []; spanMap.set(span.spanID, span); // Figure out the min and max start and end time to draw the timeline if (span.timestamp < gStartTimeNano) { gStartTimeNano = span.timestamp; } const spanEndTimeNs = span.timestamp + span.duration; if (spanEndTimeNs > gEndTimeNano) { gEndTimeNano = spanEndTimeNs; } } // Add spans to the parent node children array for (const span of spans) { const parent = spanMap.get(span.parentSpanID); if (parent) { parent.children?.push(span); } } let rootSpan = null; // Spans are already sorted by start time const rootSpans = spans.filter((span) => !span.parentSpanID); // check if there is a root span if (rootSpans.length) { rootSpan = rootSpans[0]; } else { // if there is no root span, we assume the first span is the root span rootSpan = spans[0]; } return rootSpan; }; if (!spans.length) { return; } const tree = buildSpanTree(spans); setTraceTree(tree); setMissingRootSpan( !!tree.parentSpanID && !spans.find((span) => tree.parentSpanID === span.spanID), ); setGlobalDuration(gEndTimeNano - gStartTimeNano); setGlobalStartTime(gStartTimeNano); }, [spans]); const verticalResizeStyle = { left: mouseState.moving ? paneWidth + mouseState.delta?.x : mouseState.position.x, }; return ( <div className="space-y-4"> <div className="relative mt-8 flex w-full flex-col gap-y-1 rounded-md border p-3 text-sm md:mt-0"> <span className="absolute right-0 top-0 -mr-1 -mt-1 flex h-4 w-4 hover:cursor-progress"> <TooltipProvider> <Tooltip delayDuration={300}> <TooltipTrigger> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"></span> <span className="relative inline-flex h-4 w-4 rounded-full bg-primary"></span> </TooltipTrigger> <TooltipContent>Checking for new spans</TooltipContent> </Tooltip> </TooltipProvider> </span> <div> <table className="table-auto"> <tbody> <tr> <td className="pr-6">Spans</td> <td> <div className="flex items-center gap-x-3"> <span>:</span> <span>{spans.length}</span> </div> </td> </tr> <tr> <td className="pr-6">Services</td> <td> <div className="flex items-center gap-x-3"> <span>:</span> <div className="flex flex-wrap gap-x-3"> {detectedService.map((service) => { return ( <span key={service.name} className="flex items-center gap-x-2" > <div className={"h-4 w-4 rounded-sm bg-sky-300"} style={{ backgroundColor: service.color }} /> <span>{service.name}</span> </span> ); })} </div> </div> </td> </tr> <tr> <td className="pr-6">Info</td> <td> <div className="flex items-center gap-x-3"> <span>:</span> {missingRootSpan ? ( <div> <ExclamationTriangleIcon className="inline h-4 w-4 shrink-0 text-orange-600 dark:text-orange-400" />{" "} This trace has no root span. This can have several causes.{" "} <a target="_blank" rel="noreferrer" href={ docsBaseURL + "/router/open-telemetry#why-is-my-trace-incomplete" } className="text-primary" > Learn more. </a> </div> ) : ( <span>-</span> )} </div> </td> </tr> </tbody> </table> </div> </div> {traceTree && ( <> <Card className="flex w-full flex-col overflow-hidden"> <div className="scrollbar-custom relative resize-none overflow-x-auto"> <div className="flex items-center px-4 py-4"> <span className="flex-shrink-0 pl-2" style={{ width: `${paneWidth}px`, }} > Request </span> <span>Timing</span> </div> <hr className="w-full border-input" /> <div className="absolute left-0 right-0 top-0 h-full"> <div ref={ref} style={verticalResizeStyle} className={clsx( mouseState.moving ? "bg-primary" : "bg-transparent", "absolute z-50 ml-[-9px] h-full w-[2px] cursor-col-resize border-l-2 border-transparent hover:bg-primary", )} ></div> </div> <div className="pb-4 pr-4"> <Node span={traceTree} level={1} globalDuration={globalDuration} globalStartTime={globalStartTime} isParentDetailsOpen={false} paneWidth={paneWidth} services={detectedService} /> </div> </div> </Card> </> )} </div> ); }; export default Trace;