Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

216 linesCodeBlameRaw
1import { ChevronLeft } from "lucide-react";
2import { useMemo, useState } from "react";
3import { Link, data } from "react-router";
4
5import type { TelemetrySpan } from "@g1t/contracts";
6
7import type { Route } from "./+types/observability-trace";
8import { LogLines } from "../../components/observability-logs";
9import { SourceBadge } from "../../components/observability";
10import { Badge } from "../../components/ui/badge";
11import { Card } from "../../components/ui/card";
12import { cn } from "../../lib/cn";
13import { page } from "../../lib/meta";
14import { clock, duration } from "../../lib/observability";
15import { observed } from "../../lib/observability.server";
16import { spanTree } from "../../lib/observability-trace";
17
18export function meta({ params, ...args }: Route.MetaArgs) {
19 return page(args, { title: `Trace ${params.trace.slice(0, 8)} · ${params.owner}/${params.repo} · g1t` });
20}
21
22export 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). */
31const SERVICE_COLORS = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"];
32
33export 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
81 key={f}
82 className={cn("absolute", f === 1 ? "right-0" : f === 0 ? "left-0" : "-translate-x-1/2")}
83 style={f > 0 && f < 1 ? { left: `${f * 100}%` } : undefined}
84 >
85 {duration((total * f) / 1000)}
86 </span>
87 ))}
88 </div>
89 </div>
90 <ol role="tree" aria-label="Spans">
91 {rows.map(({ span, depth }) => {
92 const left = ((span.start_us - start) / total) * 100;
93 const width = Math.max(0.4, (span.duration_us / total) * 100);
94 const on = span.span_id === selected;
95 return (
96 <li key={span.span_id} role="treeitem" aria-selected={on} aria-level={depth + 1}>
97 <button
98 type="button"
99 onClick={() => setSelected(span.span_id)}
100 className={cn(
101 "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",
102 on && "bg-raised",
103 )}
104 >
105 <span className="flex min-w-0 items-center gap-1.5" style={{ paddingLeft: depth * 14 }}>
106 <span className="size-2 shrink-0 rounded-sm" style={{ background: color(span.service) }} />
107 <span className={cn("truncate font-mono", span.status === "error" && "text-danger")}>{span.name}</span>
108 </span>
109 <span className="relative block h-4">
110 <span
111 className={cn(
112 "absolute top-1/2 h-2.5 -translate-y-1/2 rounded-[3px]",
113 span.status === "error" && "ring-2 ring-danger ring-offset-1 ring-offset-bg",
114 )}
115 style={{ left: `${left}%`, width: `${width}%`, background: color(span.service) }}
116 />
117 <span
118 className="absolute top-1/2 -translate-y-1/2 pl-1 font-mono text-[0.6875rem] whitespace-nowrap text-muted tabular-nums"
119 style={left + width > 80 ? { right: `${100 - left}%`, paddingRight: 4 } : { left: `${left + width}%` }}
120 >
121 {duration(span.duration_us / 1000)}
122 </span>
123 </span>
124 </button>
125 </li>
126 );
127 })}
128 </ol>
129 </Card>
130
131 <Card className="min-w-0 self-start p-4 lg:sticky lg:top-4">
132 {chosen ? <SpanDetail span={chosen} /> : <p className="text-sm text-muted">Choose a span.</p>}
133 </Card>
134 </div>
135
136 <section className="mt-6" aria-labelledby="logs-title">
137 <h2 id="logs-title" className="text-sm font-semibold">
138 Logs <span className="font-normal text-faint">{logs.length}</span>
139 </h2>
140 {logs.length === 0 ? (
141 <p className="mt-2 text-sm text-muted">Nothing was written under this trace.</p>
142 ) : (
143 <Card className="mt-2 overflow-hidden">
144 <LogLines logs={logs} base={base} />
145 </Card>
146 )}
147 </section>
148 </div>
149 );
150}
151
152function SpanDetail({ span }: { span: TelemetrySpan }) {
153 const sections: [string, Record<string, unknown>][] = [
154 ["Attributes", span.attributes],
155 ["Resource", span.resource],
156 ];
157 return (
158 <div className="min-w-0 text-xs">
159 <p className="font-mono text-sm break-all">{span.name}</p>
160 <div className="mt-2 flex flex-wrap gap-1.5">
161 <Badge tone="neutral">{span.kind}</Badge>
162 <Badge tone={span.status === "error" ? "danger" : span.status === "ok" ? "success" : "neutral"}>{span.status}</Badge>
163 <SourceBadge source={span.source} />
164 </div>
165 {span.status_message && <p className="mt-2 text-danger">{span.status_message}</p>}
166 <dl className="mt-3 grid grid-cols-[6rem_1fr] gap-x-3 gap-y-1">
167 <dt className="text-faint">Service</dt>
168 <dd className="font-mono break-all">{span.service}</dd>
169 <dt className="text-faint">Duration</dt>
170 <dd className="tabular-nums">{duration(span.duration_us / 1000)}</dd>
171 <dt className="text-faint">Span</dt>
172 <dd className="font-mono">{span.span_id}</dd>
173 {span.parent_span_id && (
174 <>
175 <dt className="text-faint">Parent</dt>
176 <dd className="font-mono">{span.parent_span_id}</dd>
177 </>
178 )}
179 </dl>
180 {sections.map(([title, values]) =>
181 Object.keys(values).length ? (
182 <div key={title} className="mt-4">
183 <p className="font-medium">{title}</p>
184 <dl className="mt-1 space-y-1">
185 {Object.entries(values).map(([k, v]) => (
186 <div key={k} className="min-w-0">
187 <dt className="truncate font-mono text-faint">{k}</dt>
188 <dd className="font-mono break-all">{typeof v === "string" ? v : JSON.stringify(v)}</dd>
189 </div>
190 ))}
191 </dl>
192 </div>
193 ) : null,
194 )}
195 {span.events.length > 0 && (
196 <div className="mt-4">
197 <p className="font-medium">Events</p>
198 <ul className="mt-1 space-y-2">
199 {span.events.map((e, i) => (
200 <li key={i}>
201 <p className={cn("font-mono", e.name === "exception" && "text-danger")}>
202 {e.name} <span className="text-faint">+{duration((e.time_us - span.start_us) / 1000)}</span>
203 </p>
204 {Object.entries(e.attributes).map(([k, v]) => (
205 <p key={k} className="font-mono break-all whitespace-pre-wrap text-muted">
206 <span className="text-faint">{k}</span> {typeof v === "string" ? v : JSON.stringify(v)}
207 </p>
208 ))}
209 </li>
210 ))}
211 </ul>
212 </div>
213 )}
214 </div>
215 );
216}