xtrace 0.0.15

An AI observability service for collecting, storing, and querying traces, spans, and metrics across LLM and agent workflows.
Documentation
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import {
  Copy,
  MessageSquare,
  ChevronDown,
  ChevronRight,
  Sparkles,
  ArrowRightLeft,
  Database,
  Code,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Observation, TraceDetail } from "@/lib/xtrace";

interface SpanNode {
  id: string;
  name: string;
  type: "generation" | "retrieval" | "span" | "embedding" | "observation";
  latencyText: string;
  costText?: string;
  tokensText?: string;
  children?: SpanNode[];
  level?: string | null;
}

interface TraceDetailProps {
  trace: TraceDetail;
}

const formatSeconds = (value: number | null) => {
  if (value === null || Number.isNaN(value)) return "-";
  return `${value.toFixed(2)}s`;
};

const formatCost = (value: number | null) => {
  if (value === null || Number.isNaN(value)) return "-";
  return `$${value.toFixed(6)}`;
};

const formatTokens = (obs: Observation) => {
  if (!obs.usage) return "-";
  return `${obs.usage.input} → ${obs.usage.output}`;
};

const observationToNode = (observation: Observation): SpanNode => {
  const type = observation.type.toLowerCase();
  const mappedType = ((): SpanNode["type"] => {
    if (type.includes("generation")) return "generation";
    if (type.includes("embedding")) return "embedding";
    if (type.includes("retrieval")) return "retrieval";
    return "observation";
  })();

  return {
    id: observation.id,
    name: observation.name || observation.type,
    type: mappedType,
    latencyText: formatSeconds(observation.latency),
    costText: formatCost(observation.calculatedTotalCost ?? observation.totalPrice),
    tokensText: observation.usage ? formatTokens(observation) : undefined,
    level: observation.level,
  };
};

const buildSpanTree = (observations: Observation[]): SpanNode[] => {
  const nodes = new Map<string, SpanNode>();
  const childrenMap = new Map<string, string[]>();
  const roots: SpanNode[] = [];

  observations.forEach((obs) => {
    nodes.set(obs.id, observationToNode(obs));
    if (obs.parentObservationId) {
      const list = childrenMap.get(obs.parentObservationId) || [];
      list.push(obs.id);
      childrenMap.set(obs.parentObservationId, list);
    }
  });

  observations.forEach((obs) => {
    const node = nodes.get(obs.id);
    if (!node) return;
    const childIds = childrenMap.get(obs.id) || [];
    if (childIds.length) {
      node.children = childIds
        .map((childId) => nodes.get(childId))
        .filter((child): child is SpanNode => Boolean(child));
    }
    if (!obs.parentObservationId) {
      roots.push(node);
    }
  });

  return roots.length ? roots : observations.map(observationToNode);
};

function SpanTreeNode({ node, depth = 0 }: { node: SpanNode; depth?: number }) {
  const [expanded, setExpanded] = useState(true);
  const hasChildren = node.children && node.children.length > 0;

  const getTypeIcon = (type: SpanNode["type"]) => {
    switch (type) {
      case "generation":
        return <Sparkles className="h-3.5 w-3.5 text-xtrace-pink" />;
      case "retrieval":
        return <ArrowRightLeft className="h-3.5 w-3.5 text-xtrace-info" />;
      case "embedding":
        return <Database className="h-3.5 w-3.5 text-xtrace-orange" />;
      default:
        return <Code className="h-3.5 w-3.5 text-muted-foreground" />;
    }
  };

  return (
    <div className="select-none">
      <div
        className={cn(
          "flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-accent/50 cursor-pointer group",
          depth > 0 && "ml-5"
        )}
        onClick={() => hasChildren && setExpanded(!expanded)}
      >
        <div className="w-4 h-4 flex items-center justify-center">
          {hasChildren ? (
            expanded ? (
              <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
            ) : (
              <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
            )
          ) : (
            <div className="w-1 h-1 rounded-full bg-muted-foreground/30" />
          )}
        </div>

        {getTypeIcon(node.type)}

        <span className="font-medium text-sm text-foreground">{node.name}</span>

        {node.level && (
          <Badge variant="outline" className="text-[10px] h-4 px-1">
            {node.level}
          </Badge>
        )}

        <div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground">
          <span>{node.latencyText}</span>
          {node.tokensText && <span>{node.tokensText}</span>}
          {node.costText && <span>{node.costText}</span>}
        </div>
      </div>

      {hasChildren && expanded && (
        <div className="border-l border-border ml-4">
          {node.children!.map((child) => (
            <SpanTreeNode key={child.id} node={child} depth={depth + 1} />
          ))}
        </div>
      )}
    </div>
  );
}

export function TraceDetail({ trace }: TraceDetailProps) {
  const spanRoots = useMemo(() => buildSpanTree(trace.observations), [trace.observations]);

  return (
    <div className="flex flex-col h-full bg-card rounded-lg border border-border overflow-hidden">
      <div className="p-4 border-b border-border">
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-3">
            <Code className="h-5 w-5 text-muted-foreground" />
            <span className="font-semibold text-foreground">{trace.name || "(unnamed)"}</span>
            <button className="text-muted-foreground hover:text-foreground">
              <Copy className="h-3.5 w-3.5" />
            </button>
            <span className="text-xs text-muted-foreground">ID</span>
          </div>
          <div className="flex items-center gap-2">
            <Button variant="ghost" size="icon" className="h-8 w-8">
              <MessageSquare className="h-4 w-4" />
            </Button>
          </div>
        </div>

        <div className="mt-3 text-sm text-muted-foreground">{trace.timestamp || "-"}</div>

        <div className="mt-3 flex flex-wrap gap-2">
          {trace.sessionId && (
            <Badge className="bg-xtrace-info/10 text-xtrace-info border-xtrace-info/20 hover:bg-xtrace-info/20">
              Session: {trace.sessionId}
            </Badge>
          )}
          {trace.userId && (
            <Badge className="bg-xtrace-purple/10 text-xtrace-purple border-xtrace-purple/20 hover:bg-xtrace-purple/20">
              User ID: {trace.userId}
            </Badge>
          )}
          <Badge variant="outline">Env: {trace.projectId || "-"}</Badge>
          <Badge variant="outline">Latency: {formatSeconds(trace.latency)}</Badge>
          <Badge variant="outline">Total Cost: {formatCost(trace.totalCost)}</Badge>
        </div>

        <div className="mt-3 flex items-center gap-2 text-xs">
          <span className="text-muted-foreground">
            Observations: {trace.observations.length}
          </span>
          {trace.release && (
            <>
              <span className="text-muted-foreground">|</span>
              <span className="text-muted-foreground font-mono text-[10px]">
                Release: {trace.release}
              </span>
            </>
          )}
        </div>
      </div>

      <div className="flex flex-1 overflow-hidden">
        <div className="w-[380px] border-r border-border overflow-y-auto scrollbar-thin p-3">
          {spanRoots.length ? (
            spanRoots.map((node) => <SpanTreeNode key={node.id} node={node} />)
          ) : (
            <div className="text-sm text-muted-foreground">No Observations</div>
          )}
        </div>

        <div className="flex-1 overflow-y-auto scrollbar-thin">
          <Tabs defaultValue="preview" className="h-full flex flex-col">
            <div className="border-b border-border px-4">
              <TabsList className="h-10 bg-transparent p-0 gap-4">
                <TabsTrigger
                  value="preview"
                  className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none px-0 pb-2"
                >
                  Preview
                </TabsTrigger>
                <TabsTrigger
                  value="scores"
                  className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none px-0 pb-2"
                >
                  Scores
                </TabsTrigger>
              </TabsList>
              <div className="absolute right-4 top-2 flex items-center gap-2">
                <Button variant="ghost" size="sm" className="h-7 text-xs" disabled>
                  Formatted
                </Button>
                <Button variant="ghost" size="sm" className="h-7 text-xs" disabled>
                  JSON
                </Button>
              </div>
            </div>

            <TabsContent value="preview" className="flex-1 p-4 m-0">
              <div className="space-y-4">
                <div>
                  <div className="flex items-center justify-between mb-2">
                    <h3 className="font-medium text-foreground">Input</h3>
                  </div>
                  <div className="bg-muted rounded-lg p-3 font-mono text-sm">
                    <pre className="text-xs whitespace-pre-wrap">
                      {JSON.stringify(trace.input, null, 2) || "-"}
                    </pre>
                  </div>
                </div>

                <div>
                  <div className="flex items-center justify-between mb-2">
                    <h3 className="font-medium text-foreground">Output</h3>
                  </div>
                  <div className="bg-accent/50 border-l-2 border-primary rounded-lg p-3 text-sm leading-relaxed">
                    <pre className="text-xs whitespace-pre-wrap">
                      {JSON.stringify(trace.output, null, 2) || "-"}
                    </pre>
                  </div>
                </div>

                <div>
                  <h3 className="font-medium text-foreground mb-2">Metadata</h3>
                  <div className="bg-muted rounded-lg p-3 font-mono text-sm">
                    <pre className="text-xs whitespace-pre-wrap">
                      {JSON.stringify(trace.metadata, null, 2) || "-"}
                    </pre>
                  </div>
                </div>
              </div>
            </TabsContent>

            <TabsContent value="scores" className="flex-1 p-4 m-0">
              <div className="flex items-center justify-center h-full text-muted-foreground">
                No score data
              </div>
            </TabsContent>
          </Tabs>
        </div>
      </div>
    </div>
  );
}