| 1 | import { ChevronLeft } from "lucide-react"; |
| 2 | import { useMemo, useState } from "react"; |
| 3 | import { Link, data } from "react-router"; |
| 4 | |
| 5 | import type { TelemetrySpan } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/observability-trace"; |
| 8 | import { LogLines } from "../../components/observability-logs"; |
| 9 | import { SourceBadge } from "../../components/observability"; |
| 10 | import { Badge } from "../../components/ui/badge"; |
| 11 | import { Card } from "../../components/ui/card"; |
| 12 | import { cn } from "../../lib/cn"; |
| 13 | import { page } from "../../lib/meta"; |
| 14 | import { clock, duration } from "../../lib/observability"; |
| 15 | import { observed } from "../../lib/observability.server"; |
| 16 | import { spanTree } from "../../lib/observability-trace"; |
| 17 | |
| 18 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 19 | return page(args, { title: `Trace ${params.trace.slice(0, 8)} · ${params.owner}/${params.repo} · g1t` }); |
| 20 | } |
| 21 | |
| 22 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 23 | const { projectId, service } = await observed(context, params, request); |
| 24 | if (!/^[0-9a-f]{32}$/.test(params.trace)) throw data(null, { status: 404 }); |
| 25 | const trace = await service.trace({ project_id: projectId, trace_id: params.trace }); |
| 26 | if (!trace) throw data(null, { status: 404 }); |
| 27 | return trace; |
| 28 | } |
| 29 | |
| 30 | /** Each service keeps one of the site's categorical colors through the trace (lib/usage.ts). */ |
| 31 | const SERVICE_COLORS = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"]; |
| 32 | |
| 33 | export default function Trace({ loaderData, params }: Route.ComponentProps) { |
| 34 | const { spans, logs, trace_id } = loaderData; |
| 35 | const base = `/${params.owner}/${params.repo}`; |
| 36 | const rows = useMemo(() => spanTree(spans), [spans]); |
| 37 | const start = Math.min(...spans.map((s) => s.start_us)); |
| 38 | const end = Math.max(...spans.map((s) => s.start_us + s.duration_us)); |
| 39 | const total = Math.max(1, end - start); |
| 40 | const services = [...new Set(spans.map((s) => s.service))]; |
| 41 | const color = (service: string) => SERVICE_COLORS[services.indexOf(service) % SERVICE_COLORS.length]!; |
| 42 | const [selected, setSelected] = useState<string | null>(rows[0]?.span.span_id ?? null); |
| 43 | const chosen = spans.find((s) => s.span_id === selected) ?? null; |
| 44 | const errors = spans.filter((s) => s.status === "error").length; |
| 45 | |
| 46 | return ( |
| 47 | <div className="mx-auto max-w-6xl"> |
| 48 | <Link to={`${base}/observability/traces`} className="inline-flex items-center gap-1 text-sm text-muted hover:text-fg"> |
| 49 | <ChevronLeft size={14} /> |
| 50 | Traces |
| 51 | </Link> |
| 52 | <header className="mt-3"> |
| 53 | <h1 className="truncate font-mono text-lg">{rows[0]?.span.name ?? trace_id}</h1> |
| 54 | <p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"> |
| 55 | <span className="font-mono">{trace_id}</span> |
| 56 | <span className="tabular-nums">{clock(start, true)} UTC</span> |
| 57 | <span className="tabular-nums">{duration(total / 1000)}</span> |
| 58 | <span> |
| 59 | {spans.length} span{spans.length === 1 ? "" : "s"} |
| 60 | </span> |
| 61 | {errors > 0 && <span className="text-danger">{errors} failed</span>} |
| 62 | </p> |
| 63 | <ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted" aria-label="Services"> |
| 64 | {services.map((s) => ( |
| 65 | <li key={s} className="flex items-center gap-1.5"> |
| 66 | <span className="size-2.5 rounded-sm" style={{ background: color(s) }} /> |
| 67 | <span className="font-mono">{s || "(unnamed)"}</span> |
| 68 | </li> |
| 69 | ))} |
| 70 | </ul> |
| 71 | </header> |
| 72 | |
| 73 | <div className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]"> |
| 74 | <Card className="min-w-0 overflow-hidden"> |
| 75 | {/* The time ruler. */} |
| 76 | <div className="grid grid-cols-[minmax(10rem,40%)_1fr] border-b border-line px-3 py-1.5 text-[0.6875rem] text-faint tabular-nums"> |
| 77 | <span>Span</span> |
| 78 | <div className="relative h-4"> |
| 79 | {[0, 0.25, 0.5, 0.75, 1].map((f) => ( |
| 80 | <span key={f} className={cn("absolute", f === 1 ? "right-0" : f === 0 ? "left-0" : "-translate-x-1/2")} style={f > 0 && f < 1 ? { left: `${f * 100}%` } : undefined}> |
| 81 | {duration((total * f) / 1000)} |
| 82 | </span> |
| 83 | ))} |
| 84 | </div> |
| 85 | </div> |
| 86 | <ol role="tree" aria-label="Spans"> |
| 87 | {rows.map(({ span, depth }) => { |
| 88 | const left = ((span.start_us - start) / total) * 100; |
| 89 | const width = Math.max(0.4, (span.duration_us / total) * 100); |
| 90 | const on = span.span_id === selected; |
| 91 | return ( |
| 92 | <li key={span.span_id} role="treeitem" aria-selected={on} aria-level={depth + 1}> |
| 93 | <button |
| 94 | type="button" |
| 95 | onClick={() => setSelected(span.span_id)} |
| 96 | className={cn( |
| 97 | "grid w-full grid-cols-[minmax(10rem,40%)_1fr] items-center gap-2 px-3 py-1 text-left text-xs hover:bg-raised/60", |
| 98 | on && "bg-raised", |
| 99 | )} |
| 100 | > |
| 101 | <span className="flex min-w-0 items-center gap-1.5" style={{ paddingLeft: depth * 14 }}> |
| 102 | <span className="size-2 shrink-0 rounded-sm" style={{ background: color(span.service) }} /> |
| 103 | <span className={cn("truncate font-mono", span.status === "error" && "text-danger")}>{span.name}</span> |
| 104 | </span> |
| 105 | <span className="relative block h-4"> |
| 106 | <span |
| 107 | className={cn("absolute top-1/2 h-2.5 -translate-y-1/2 rounded-[3px]", span.status === "error" && "ring-2 ring-danger ring-offset-1 ring-offset-bg")} |
| 108 | style={{ left: `${left}%`, width: `${width}%`, background: color(span.service) }} |
| 109 | /> |
| 110 | <span |
| 111 | className="absolute top-1/2 -translate-y-1/2 pl-1 font-mono text-[0.6875rem] whitespace-nowrap text-muted tabular-nums" |
| 112 | style={left + width > 80 ? { right: `${100 - left}%`, paddingRight: 4 } : { left: `${left + width}%` }} |
| 113 | > |
| 114 | {duration(span.duration_us / 1000)} |
| 115 | </span> |
| 116 | </span> |
| 117 | </button> |
| 118 | </li> |
| 119 | ); |
| 120 | })} |
| 121 | </ol> |
| 122 | </Card> |
| 123 | |
| 124 | <Card className="min-w-0 self-start p-4 lg:sticky lg:top-4">{chosen ? <SpanDetail span={chosen} /> : <p className="text-sm text-muted">Choose a span.</p>}</Card> |
| 125 | </div> |
| 126 | |
| 127 | <section className="mt-6" aria-labelledby="logs-title"> |
| 128 | <h2 id="logs-title" className="text-sm font-semibold"> |
| 129 | Logs <span className="font-normal text-faint">{logs.length}</span> |
| 130 | </h2> |
| 131 | {logs.length === 0 ? ( |
| 132 | <p className="mt-2 text-sm text-muted">Nothing was written under this trace.</p> |
| 133 | ) : ( |
| 134 | <Card className="mt-2 overflow-hidden"> |
| 135 | <LogLines logs={logs} base={base} /> |
| 136 | </Card> |
| 137 | )} |
| 138 | </section> |
| 139 | </div> |
| 140 | ); |
| 141 | } |
| 142 | |
| 143 | function SpanDetail({ span }: { span: TelemetrySpan }) { |
| 144 | const sections: [string, Record<string, unknown>][] = [ |
| 145 | ["Attributes", span.attributes], |
| 146 | ["Resource", span.resource], |
| 147 | ]; |
| 148 | return ( |
| 149 | <div className="min-w-0 text-xs"> |
| 150 | <p className="font-mono text-sm break-all">{span.name}</p> |
| 151 | <div className="mt-2 flex flex-wrap gap-1.5"> |
| 152 | <Badge tone="neutral">{span.kind}</Badge> |
| 153 | <Badge tone={span.status === "error" ? "danger" : span.status === "ok" ? "success" : "neutral"}>{span.status}</Badge> |
| 154 | <SourceBadge source={span.source} /> |
| 155 | </div> |
| 156 | {span.status_message && <p className="mt-2 text-danger">{span.status_message}</p>} |
| 157 | <dl className="mt-3 grid grid-cols-[6rem_1fr] gap-x-3 gap-y-1"> |
| 158 | <dt className="text-faint">Service</dt> |
| 159 | <dd className="font-mono break-all">{span.service}</dd> |
| 160 | <dt className="text-faint">Duration</dt> |
| 161 | <dd className="tabular-nums">{duration(span.duration_us / 1000)}</dd> |
| 162 | <dt className="text-faint">Span</dt> |
| 163 | <dd className="font-mono">{span.span_id}</dd> |
| 164 | {span.parent_span_id && ( |
| 165 | <> |
| 166 | <dt className="text-faint">Parent</dt> |
| 167 | <dd className="font-mono">{span.parent_span_id}</dd> |
| 168 | </> |
| 169 | )} |
| 170 | </dl> |
| 171 | {sections.map(([title, values]) => |
| 172 | Object.keys(values).length ? ( |
| 173 | <div key={title} className="mt-4"> |
| 174 | <p className="font-medium">{title}</p> |
| 175 | <dl className="mt-1 space-y-1"> |
| 176 | {Object.entries(values).map(([k, v]) => ( |
| 177 | <div key={k} className="min-w-0"> |
| 178 | <dt className="truncate font-mono text-faint">{k}</dt> |
| 179 | <dd className="font-mono break-all">{typeof v === "string" ? v : JSON.stringify(v)}</dd> |
| 180 | </div> |
| 181 | ))} |
| 182 | </dl> |
| 183 | </div> |
| 184 | ) : null, |
| 185 | )} |
| 186 | {span.events.length > 0 && ( |
| 187 | <div className="mt-4"> |
| 188 | <p className="font-medium">Events</p> |
| 189 | <ul className="mt-1 space-y-2"> |
| 190 | {span.events.map((e, i) => ( |
| 191 | <li key={i}> |
| 192 | <p className={cn("font-mono", e.name === "exception" && "text-danger")}> |
| 193 | {e.name} <span className="text-faint">+{duration((e.time_us - span.start_us) / 1000)}</span> |
| 194 | </p> |
| 195 | {Object.entries(e.attributes).map(([k, v]) => ( |
| 196 | <p key={k} className="font-mono break-all whitespace-pre-wrap text-muted"> |
| 197 | <span className="text-faint">{k}</span> {typeof v === "string" ? v : JSON.stringify(v)} |
| 198 | </p> |
| 199 | ))} |
| 200 | </li> |
| 201 | ))} |
| 202 | </ul> |
| 203 | </div> |
| 204 | )} |
| 205 | </div> |
| 206 | ); |
| 207 | } |