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.

408 linesCodeBlameRaw
1import { ArrowRight, Bug, Globe, Send } from "lucide-react";
2import { Link } from "react-router";
3
4import { TELEMETRY_OTLP_ENDPOINT, type TelemetryOverview } from "@g1t/contracts/telemetry";
5
6import type { Route } from "./+types/observability";
7import { FilterBar, ObservabilityHeader, StatTile, TimeChart, useLoadingView } from "../../components/observability";
8import { ButtonLink, CopyLine, TimeAgo } from "../../components/ui";
9import { Badge } from "../../components/ui/badge";
10import { Card } from "../../components/ui/card";
11import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table";
12import { cn } from "../../lib/cn";
13import { page } from "../../lib/meta";
14import { SERIES, count, duration, percent, shortId, viewHref } from "../../lib/observability";
15import { observed, soft } from "../../lib/observability.server";
16import { Unavailable } from "../../components/boundary";
17import { isFailed, part } from "../../lib/partial";
18
19export function meta({ params, ...args }: Route.MetaArgs) {
20 return page(args, { title: `Observability · ${params.owner}/${params.repo} · g1t` });
21}
22
23export async function loader({ params, context, request }: Route.LoaderArgs) {
24 const { projectId, service, view, filter, access } = await observed(context, params, request);
25 // The overview and its open errors each say so in their place when they
26 // could not be read. The filter's choices quietly go without (soft): a
27 // filter in the address still applies.
28 const [overview, facets, errors] = await Promise.all([
29 part(service.overview({ project_id: projectId, ...filter }), "Observability"),
30 soft(service.facets({ project_id: projectId }), "facets"),
31 part(service.errors({ project_id: projectId, ...filter, state: "open" }), "Open errors"),
32 ]);
33 return { view, overview, facets, errors: isFailed(errors) ? errors : errors.slice(0, 5), canManage: access.can.manage_integrations };
34}
35
36export default function Observability({ loaderData, params }: Route.ComponentProps) {
37 const { view, overview, facets, errors, canManage } = loaderData;
38 const base = `/${params.owner}/${params.repo}`;
39 const path = `${base}/observability`;
40 const loading = useLoadingView();
41 const nothing = !isFailed(overview) && overview.totals.requests === 0 && overview.services.length === 0 && (facets?.services.length ?? 0) === 0;
42
43 return (
44 <div className="mx-auto max-w-6xl">
45 <ObservabilityHeader
46 title="Observability"
47 about="What the project's running software does: its requests, errors and latency, by deployment. Apps on g1t.page report on their own; anything else sends OpenTelemetry."
48 />
49 <FilterBar path={path} view={view} environments={facets?.environments ?? []} services={facets?.services ?? []} />
50
51 <div className={cn("mt-6 transition-opacity", loading && "opacity-60")}>
52 {isFailed(overview) ? (
53 <Unavailable what={overview.what} />
54 ) : nothing ? (
55 <NothingYet base={base} canManage={canManage} />
56 ) : (
57 <Loaded overview={overview} errors={errors} base={base} path={path} view={view} />
58 )}
59 </div>
60 </div>
61 );
62}
63
64function Loaded({
65 overview,
66 errors,
67 base,
68 path,
69 view,
70}: {
71 overview: TelemetryOverview;
72 errors: Route.ComponentProps["loaderData"]["errors"];
73 base: string;
74 path: string;
75 view: Route.ComponentProps["loaderData"]["view"];
76}) {
77 const { totals, points } = overview;
78 // The period before is compared only when it had enough requests to say something.
79 const enough = overview.previous.requests >= Math.max(30, totals.requests * 0.05);
80 const previous = enough
81 ? overview.previous
82 : {
83 requests: null,
84 errors: null,
85 error_rate: null,
86 latency: { p50: null, p75: null, p95: null, p99: null },
87 cpu: { p50: null, p75: null, p95: null, p99: null },
88 };
89 const times = points.map((p) => p.time_us);
90 const anyCpu = totals.cpu.p50 !== null;
91 return (
92 <>
93 <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-5">
94 <StatTile label="Requests" value={count(totals.requests)} now={totals.requests} before={previous.requests} />
95 <StatTile
96 label="Error rate"
97 value={percent(totals.error_rate)}
98 now={totals.error_rate}
99 before={previous.error_rate}
100 lowerIsBetter
101 detail={`${count(totals.errors)} failed`}
102 />
103 <StatTile label="Latency p50" value={duration(totals.latency.p50)} now={totals.latency.p50} before={previous.latency.p50} lowerIsBetter />
104 <StatTile label="Latency p95" value={duration(totals.latency.p95)} now={totals.latency.p95} before={previous.latency.p95} lowerIsBetter />
105 <StatTile
106 label={anyCpu ? "CPU time p50" : "Latency p99"}
107 value={duration(anyCpu ? totals.cpu.p50 : totals.latency.p99)}
108 now={anyCpu ? totals.cpu.p50 : totals.latency.p99}
109 before={anyCpu ? previous.cpu.p50 : previous.latency.p99}
110 lowerIsBetter
111 />
112 </div>
113
114 <div className="mt-3 grid gap-3 lg:grid-cols-2">
115 <TimeChart
116 title="Requests"
117 kind="bars"
118 times={times}
119 stepS={overview.step_s}
120 format={count}
121 series={[
122 // Whatever did not fail as a 4xx or a 5xx (requests with no status, such as a cron, count here too).
123 {
124 key: "ok",
125 label: "2xx and 3xx",
126 color: SERIES.ok,
127 values: points.map((p) => Math.max(0, p.requests - p.status_4xx - Math.max(p.status_5xx, p.errors))),
128 },
129 { key: "4xx", label: "4xx", color: SERIES.client, values: points.map((p) => p.status_4xx) },
130 { key: "5xx", label: "5xx and failures", color: SERIES.server, values: points.map((p) => Math.max(p.status_5xx, p.errors)) },
131 ]}
132 action={
133 <Link to={viewHref(`${path}/requests`, view)} className="text-xs text-muted hover:text-fg">
134 Requests <ArrowRight size={12} className="inline" />
135 </Link>
136 }
137 />
138 <TimeChart
139 title="Latency"
140 kind="lines"
141 times={times}
142 stepS={overview.step_s}
143 format={(n) => duration(n)}
144 series={[
145 { key: "p50", label: "p50", color: SERIES.p50, values: points.map((p) => p.latency.p50) },
146 { key: "p95", label: "p95", color: SERIES.p95, values: points.map((p) => p.latency.p95) },
147 { key: "p99", label: "p99", color: SERIES.p99, values: points.map((p) => p.latency.p99) },
148 ]}
149 />
150 </div>
151
152 <div className="mt-3 grid gap-3 lg:grid-cols-3">
153 <Card asChild className="min-w-0 lg:col-span-2">
154 <section aria-labelledby="deployments-title">
155 <div className="flex items-center justify-between border-b border-line px-4 py-3">
156 <h2 id="deployments-title" className="text-sm font-medium">
157 By deployment
158 </h2>
159 <span className="hidden text-xs text-faint sm:inline">Newest first: a change in the numbers starts with the deployment that brought it.</span>
160 </div>
161 {overview.deployments.length === 0 ? (
162 <p className="px-4 py-6 text-sm text-muted">No deployment on g1t.page served requests in this period.</p>
163 ) : (
164 <Table frameProps={{ className: "rounded-none border-0" }}>
165 <TableHeader>
166 <TableRow>
167 <TableHead>Deployment</TableHead>
168 <TableHead className="hidden sm:table-cell">Environment</TableHead>
169 <TableHead className="text-right">Requests</TableHead>
170 <TableHead className="text-right">Error rate</TableHead>
171 <TableHead className="text-right">p95</TableHead>
172 </TableRow>
173 </TableHeader>
174 <TableBody>
175 {overview.deployments.map((d, i) => {
176 const rate = d.requests ? d.errors / d.requests : 0;
177 const before = overview.deployments[i + 1];
178 const worse = before && before.requests > 20 && d.requests > 20 && rate > (before.errors / before.requests) * 1.5 + 0.005;
179 return (
180 <TableRow key={d.deployment_id}>
181 <TableCell>
182 <Link to={viewHref(path, view, { deployment: d.deployment_id })} className="font-mono text-xs hover:text-accent">
183 {d.commit_sha ? shortId(d.commit_sha, 7) : shortId(d.deployment_id, 12)}
184 </Link>
185 <span className="ml-2 text-xs whitespace-nowrap text-faint">
186 <TimeAgo at={d.first_us / 1000} />
187 </span>
188 </TableCell>
189 <TableCell className="hidden text-muted sm:table-cell">{d.environment ?? "—"}</TableCell>
190 <TableCell className="text-right tabular-nums">{count(d.requests)}</TableCell>
191 <TableCell className={cn("text-right tabular-nums", worse && "text-danger")}>{percent(rate)}</TableCell>
192 <TableCell className="text-right whitespace-nowrap tabular-nums">{duration(d.latency.p95)}</TableCell>
193 </TableRow>
194 );
195 })}
196 </TableBody>
197 </Table>
198 )}
199 </section>
200 </Card>
201
202 <Card asChild className="min-w-0">
203 <section aria-labelledby="errors-title">
204 <div className="flex items-center justify-between border-b border-line px-4 py-3">
205 <h2 id="errors-title" className="flex items-center gap-2 text-sm font-medium">
206 <Bug size={14} className="text-faint" />
207 Open errors
208 </h2>
209 <Link to={viewHref(`${path}/errors`, view)} className="text-xs text-muted hover:text-fg">
210 All <ArrowRight size={12} className="inline" />
211 </Link>
212 </div>
213 {isFailed(errors) ? (
214 <div className="px-4 py-4">
215 <Unavailable what={errors.what} compact />
216 </div>
217 ) : errors.length === 0 ? (
218 <p className="px-4 py-6 text-sm text-muted">No open errors in this period.</p>
219 ) : (
220 <ul className="divide-y divide-line">
221 {errors.map((e) => (
222 <li key={e.fingerprint} className="px-4 py-2.5">
223 <Link to={`${path}/errors#${e.fingerprint}`} className="block min-w-0 hover:text-accent">
224 <p className="truncate font-mono text-xs">{e.name}</p>
225 <p className="truncate text-sm text-muted">{e.message}</p>
226 </Link>
227 <p className="mt-0.5 text-xs text-faint tabular-nums">
228 {count(e.count)} times · last <TimeAgo at={e.last_us / 1000} />
229 </p>
230 </li>
231 ))}
232 </ul>
233 )}
234 </section>
235 </Card>
236 </div>
237
238 <Card asChild className="mt-3 min-w-0">
239 <section aria-labelledby="routes-title">
240 <div className="flex items-center justify-between border-b border-line px-4 py-3">
241 <h2 id="routes-title" className="text-sm font-medium">
242 Routes
243 </h2>
244 <span className="hidden text-xs text-faint sm:inline">Paths with their ids taken out, busiest first</span>
245 </div>
246 {overview.routes.length === 0 ? (
247 <p className="px-4 py-6 text-sm text-muted">No requests in this period.</p>
248 ) : (
249 <Table frameProps={{ className: "rounded-none border-0" }}>
250 <TableHeader>
251 <TableRow>
252 <TableHead>Route</TableHead>
253 <TableHead className="text-right">Requests</TableHead>
254 <TableHead className="text-right">Error rate</TableHead>
255 <TableHead className="hidden text-right sm:table-cell">p50</TableHead>
256 <TableHead className="text-right">p95</TableHead>
257 <TableHead className="hidden text-right sm:table-cell">p99</TableHead>
258 </TableRow>
259 </TableHeader>
260 <TableBody>
261 {overview.routes.slice(0, 15).map((r) => (
262 <TableRow key={`${r.method} ${r.route}`}>
263 <TableCell className="w-1/2 max-w-40 sm:max-w-0">
264 <Link
265 to={viewHref(`${path}/requests`, view, { route: r.route })}
266 className="flex min-w-0 items-center gap-2 font-mono text-xs hover:text-accent"
267 >
268 {r.method && <span className="shrink-0 text-faint">{r.method}</span>}
269 <span className="truncate">{r.route || "/"}</span>
270 </Link>
271 </TableCell>
272 <TableCell className="text-right tabular-nums">{count(r.requests)}</TableCell>
273 <TableCell className={cn("text-right tabular-nums", r.errors > 0 && "text-danger")}>
274 {percent(r.requests ? r.errors / r.requests : 0)}
275 </TableCell>
276 <TableCell className="hidden text-right whitespace-nowrap tabular-nums sm:table-cell">{duration(r.latency.p50)}</TableCell>
277 <TableCell className="text-right whitespace-nowrap tabular-nums">{duration(r.latency.p95)}</TableCell>
278 <TableCell className="hidden text-right whitespace-nowrap tabular-nums sm:table-cell">{duration(r.latency.p99)}</TableCell>
279 </TableRow>
280 ))}
281 </TableBody>
282 </Table>
283 )}
284 </section>
285 </Card>
286
287 {(overview.services.length > 1 || overview.regions.length > 0) && (
288 <div className="mt-3 grid gap-3 md:grid-cols-2">
289 {overview.services.length > 0 && (
290 <Card asChild className="min-w-0">
291 <section aria-labelledby="services-title">
292 <h2 id="services-title" className="border-b border-line px-4 py-3 text-sm font-medium">
293 Services
294 </h2>
295 <ul className="divide-y divide-line">
296 {overview.services.map((s) => (
297 <li key={`${s.service}-${s.source}`} className="flex items-center justify-between gap-3 px-4 py-2 text-sm">
298 <Link to={viewHref(path, view, { service: s.service })} className="flex min-w-0 items-center gap-2 hover:text-accent">
299 <span className="truncate font-mono text-xs">{s.service || "(unnamed)"}</span>
300 <Badge tone="neutral">{s.source === "cloudflare" ? "g1t.page" : "OTLP"}</Badge>
301 </Link>
302 <span className="shrink-0 text-xs text-muted tabular-nums">
303 {count(s.requests)} · {percent(s.requests ? s.errors / s.requests : 0)} failed
304 </span>
305 </li>
306 ))}
307 </ul>
308 </section>
309 </Card>
310 )}
311 {overview.regions.length > 0 && (
312 <Card asChild className="min-w-0">
313 <section aria-labelledby="regions-title">
314 <h2 id="regions-title" className="flex items-center gap-2 border-b border-line px-4 py-3 text-sm font-medium">
315 <Globe size={14} className="text-faint" />
316 Where requests were served
317 </h2>
318 <ul className="space-y-1.5 px-4 py-3">
319 {overview.regions.slice(0, 8).map((r) => {
320 const share =
321 r.requests /
322 Math.max(
323 1,
324 overview.regions.reduce((n, x) => n + x.requests, 0),
325 );
326 return (
327 <li key={r.region} className="grid grid-cols-[3rem_1fr_4rem] items-center gap-3 text-xs">
328 <span className="font-mono">{r.region}</span>
329 <span className="h-1.5 overflow-hidden rounded-full bg-line" aria-hidden="true">
330 <span className="block h-full rounded-full bg-accent" style={{ width: `${Math.max(2, share * 100)}%` }} />
331 </span>
332 <span className="text-right text-muted tabular-nums">{percent(share)}</span>
333 </li>
334 );
335 })}
336 </ul>
337 </section>
338 </Card>
339 )}
340 </div>
341 )}
342 <p className="mt-4 text-xs text-faint">
343 Numbers are counted each minute; the newest minute fills in as its requests finish.{" "}
344 <Link to={`${base}/observability/logs`} className="underline hover:text-fg">
345 Logs
346 </Link>{" "}
347 and{" "}
348 <Link to={`${base}/observability/requests`} className="underline hover:text-fg">
349 requests
350 </Link>{" "}
351 arrive within seconds.
352 </p>
353 </>
354 );
355}
356
357/** Nothing has arrived yet: the two ways it does. */
358function NothingYet({ base, canManage }: { base: string; canManage: boolean }) {
359 return (
360 <Card asChild>
361 <section>
362 <div className="border-b border-line px-6 py-5">
363 <h2 className="font-medium">Nothing has arrived yet</h2>
364 <p className="mt-1 max-w-2xl text-sm text-muted">
365 Requests, logs, traces and metrics show here as the project's software sends them, kept for its retention and counted for its charts.
366 </p>
367 </div>
368 <div className="grid divide-y divide-line md:grid-cols-2 md:divide-x md:divide-y-0">
369 <div className="min-w-0 p-6">
370 <h3 className="flex items-center gap-2 text-sm font-medium">
371 <Globe size={14} className="text-accent" />
372 Deployed on g1t.page
373 </h3>
374 <p className="mt-1.5 text-sm text-muted">
375 Nothing to set up. Every request an app on g1t.page answers arrives here with its logs, exceptions, status and CPU time, and an OpenTelemetry SDK
376 in the app sends its spans here without configuration.
377 </p>
378 <ButtonLink to={`${base}/deployments`} variant="outline" size="sm" className="mt-4">
379 Deployments
380 </ButtonLink>
381 </div>
382 <div className="min-w-0 p-6">
383 <h3 className="flex items-center gap-2 text-sm font-medium">
384 <Send size={14} className="text-accent" />
385 Running anywhere else
386 </h3>
387 <p className="mt-1.5 text-sm text-muted">
388 Send OpenTelemetry (OTLP over HTTP, JSON or protobuf) from any SDK or collector, with one of the project's ingest tokens.
389 </p>
390 <div className="mt-3">
391 <CopyLine text={TELEMETRY_OTLP_ENDPOINT} />
392 </div>
393 <div className="mt-4 flex flex-wrap gap-2">
394 {canManage && (
395 <ButtonLink to={`${base}/observability/settings`} size="sm">
396 Make an ingest token
397 </ButtonLink>
398 )}
399 <ButtonLink to="https://docs.g1t.sh/guides/observability/" variant="outline" size="sm">
400 How to send
401 </ButtonLink>
402 </div>
403 </div>
404 </div>
405 </section>
406 </Card>
407 );
408}