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.

377 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 : { requests: null, errors: null, error_rate: null, latency: { p50: null, p75: null, p95: null, p99: null }, cpu: { p50: null, p75: null, p95: null, p99: null } };
83 const times = points.map((p) => p.time_us);
84 const anyCpu = totals.cpu.p50 !== null;
85 return (
86 <>
87 <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-5">
88 <StatTile label="Requests" value={count(totals.requests)} now={totals.requests} before={previous.requests} />
89 <StatTile label="Error rate" value={percent(totals.error_rate)} now={totals.error_rate} before={previous.error_rate} lowerIsBetter detail={`${count(totals.errors)} failed`} />
90 <StatTile label="Latency p50" value={duration(totals.latency.p50)} now={totals.latency.p50} before={previous.latency.p50} lowerIsBetter />
91 <StatTile label="Latency p95" value={duration(totals.latency.p95)} now={totals.latency.p95} before={previous.latency.p95} lowerIsBetter />
92 <StatTile
93 label={anyCpu ? "CPU time p50" : "Latency p99"}
94 value={duration(anyCpu ? totals.cpu.p50 : totals.latency.p99)}
95 now={anyCpu ? totals.cpu.p50 : totals.latency.p99}
96 before={anyCpu ? previous.cpu.p50 : previous.latency.p99}
97 lowerIsBetter
98 />
99 </div>
100
101 <div className="mt-3 grid gap-3 lg:grid-cols-2">
102 <TimeChart
103 title="Requests"
104 kind="bars"
105 times={times}
106 stepS={overview.step_s}
107 format={count}
108 series={[
109 // Whatever did not fail as a 4xx or a 5xx (requests with no status, such as a cron, count here too).
110 { key: "ok", label: "2xx and 3xx", color: SERIES.ok, values: points.map((p) => Math.max(0, p.requests - p.status_4xx - Math.max(p.status_5xx, p.errors))) },
111 { key: "4xx", label: "4xx", color: SERIES.client, values: points.map((p) => p.status_4xx) },
112 { key: "5xx", label: "5xx and failures", color: SERIES.server, values: points.map((p) => Math.max(p.status_5xx, p.errors)) },
113 ]}
114 action={
115 <Link to={viewHref(`${path}/requests`, view)} className="text-xs text-muted hover:text-fg">
116 Requests <ArrowRight size={12} className="inline" />
117 </Link>
118 }
119 />
120 <TimeChart
121 title="Latency"
122 kind="lines"
123 times={times}
124 stepS={overview.step_s}
125 format={(n) => duration(n)}
126 series={[
127 { key: "p50", label: "p50", color: SERIES.p50, values: points.map((p) => p.latency.p50) },
128 { key: "p95", label: "p95", color: SERIES.p95, values: points.map((p) => p.latency.p95) },
129 { key: "p99", label: "p99", color: SERIES.p99, values: points.map((p) => p.latency.p99) },
130 ]}
131 />
132 </div>
133
134 <div className="mt-3 grid gap-3 lg:grid-cols-3">
135 <Card asChild className="min-w-0 lg:col-span-2">
136 <section aria-labelledby="deployments-title">
137 <div className="flex items-center justify-between border-b border-line px-4 py-3">
138 <h2 id="deployments-title" className="text-sm font-medium">
139 By deployment
140 </h2>
141 <span className="hidden text-xs text-faint sm:inline">Newest first: a change in the numbers starts with the deployment that brought it.</span>
142 </div>
143 {overview.deployments.length === 0 ? (
144 <p className="px-4 py-6 text-sm text-muted">No deployment on g1t.page served requests in this period.</p>
145 ) : (
146 <Table frameProps={{ className: "rounded-none border-0" }}>
147 <TableHeader>
148 <TableRow>
149 <TableHead>Deployment</TableHead>
150 <TableHead className="hidden sm:table-cell">Environment</TableHead>
151 <TableHead className="text-right">Requests</TableHead>
152 <TableHead className="text-right">Error rate</TableHead>
153 <TableHead className="text-right">p95</TableHead>
154 </TableRow>
155 </TableHeader>
156 <TableBody>
157 {overview.deployments.map((d, i) => {
158 const rate = d.requests ? d.errors / d.requests : 0;
159 const before = overview.deployments[i + 1];
160 const worse = before && before.requests > 20 && d.requests > 20 && rate > (before.errors / before.requests) * 1.5 + 0.005;
161 return (
162 <TableRow key={d.deployment_id}>
163 <TableCell>
164 <Link to={viewHref(path, view, { deployment: d.deployment_id })} className="font-mono text-xs hover:text-accent">
165 {d.commit_sha ? shortId(d.commit_sha, 7) : shortId(d.deployment_id, 12)}
166 </Link>
167 <span className="ml-2 text-xs whitespace-nowrap text-faint">
168 <TimeAgo at={d.first_us / 1000} />
169 </span>
170 </TableCell>
171 <TableCell className="hidden text-muted sm:table-cell">{d.environment ?? "—"}</TableCell>
172 <TableCell className="text-right tabular-nums">{count(d.requests)}</TableCell>
173 <TableCell className={cn("text-right tabular-nums", worse && "text-danger")}>{percent(rate)}</TableCell>
174 <TableCell className="text-right whitespace-nowrap tabular-nums">{duration(d.latency.p95)}</TableCell>
175 </TableRow>
176 );
177 })}
178 </TableBody>
179 </Table>
180 )}
181 </section>
182 </Card>
183
184 <Card asChild className="min-w-0">
185 <section aria-labelledby="errors-title">
186 <div className="flex items-center justify-between border-b border-line px-4 py-3">
187 <h2 id="errors-title" className="flex items-center gap-2 text-sm font-medium">
188 <Bug size={14} className="text-faint" />
189 Open errors
190 </h2>
191 <Link to={viewHref(`${path}/errors`, view)} className="text-xs text-muted hover:text-fg">
192 All <ArrowRight size={12} className="inline" />
193 </Link>
194 </div>
195 {isFailed(errors) ? (
196 <div className="px-4 py-4">
197 <Unavailable what={errors.what} compact />
198 </div>
199 ) : errors.length === 0 ? (
200 <p className="px-4 py-6 text-sm text-muted">No open errors in this period.</p>
201 ) : (
202 <ul className="divide-y divide-line">
203 {errors.map((e) => (
204 <li key={e.fingerprint} className="px-4 py-2.5">
205 <Link to={`${path}/errors#${e.fingerprint}`} className="block min-w-0 hover:text-accent">
206 <p className="truncate font-mono text-xs">{e.name}</p>
207 <p className="truncate text-sm text-muted">{e.message}</p>
208 </Link>
209 <p className="mt-0.5 text-xs text-faint tabular-nums">
210 {count(e.count)} times · last <TimeAgo at={e.last_us / 1000} />
211 </p>
212 </li>
213 ))}
214 </ul>
215 )}
216 </section>
217 </Card>
218 </div>
219
220 <Card asChild className="mt-3 min-w-0">
221 <section aria-labelledby="routes-title">
222 <div className="flex items-center justify-between border-b border-line px-4 py-3">
223 <h2 id="routes-title" className="text-sm font-medium">
224 Routes
225 </h2>
226 <span className="hidden text-xs text-faint sm:inline">Paths with their ids taken out, busiest first</span>
227 </div>
228 {overview.routes.length === 0 ? (
229 <p className="px-4 py-6 text-sm text-muted">No requests in this period.</p>
230 ) : (
231 <Table frameProps={{ className: "rounded-none border-0" }}>
232 <TableHeader>
233 <TableRow>
234 <TableHead>Route</TableHead>
235 <TableHead className="text-right">Requests</TableHead>
236 <TableHead className="text-right">Error rate</TableHead>
237 <TableHead className="hidden text-right sm:table-cell">p50</TableHead>
238 <TableHead className="text-right">p95</TableHead>
239 <TableHead className="hidden text-right sm:table-cell">p99</TableHead>
240 </TableRow>
241 </TableHeader>
242 <TableBody>
243 {overview.routes.slice(0, 15).map((r) => (
244 <TableRow key={`${r.method} ${r.route}`}>
245 <TableCell className="w-1/2 max-w-40 sm:max-w-0">
246 <Link
247 to={viewHref(`${path}/requests`, view, { route: r.route })}
248 className="flex min-w-0 items-center gap-2 font-mono text-xs hover:text-accent"
249 >
250 {r.method && <span className="shrink-0 text-faint">{r.method}</span>}
251 <span className="truncate">{r.route || "/"}</span>
252 </Link>
253 </TableCell>
254 <TableCell className="text-right tabular-nums">{count(r.requests)}</TableCell>
255 <TableCell className={cn("text-right tabular-nums", r.errors > 0 && "text-danger")}>{percent(r.requests ? r.errors / r.requests : 0)}</TableCell>
256 <TableCell className="hidden text-right whitespace-nowrap tabular-nums sm:table-cell">{duration(r.latency.p50)}</TableCell>
257 <TableCell className="text-right whitespace-nowrap tabular-nums">{duration(r.latency.p95)}</TableCell>
258 <TableCell className="hidden text-right whitespace-nowrap tabular-nums sm:table-cell">{duration(r.latency.p99)}</TableCell>
259 </TableRow>
260 ))}
261 </TableBody>
262 </Table>
263 )}
264 </section>
265 </Card>
266
267 {(overview.services.length > 1 || overview.regions.length > 0) && (
268 <div className="mt-3 grid gap-3 md:grid-cols-2">
269 {overview.services.length > 0 && (
270 <Card asChild className="min-w-0">
271 <section aria-labelledby="services-title">
272 <h2 id="services-title" className="border-b border-line px-4 py-3 text-sm font-medium">
273 Services
274 </h2>
275 <ul className="divide-y divide-line">
276 {overview.services.map((s) => (
277 <li key={`${s.service}-${s.source}`} className="flex items-center justify-between gap-3 px-4 py-2 text-sm">
278 <Link to={viewHref(path, view, { service: s.service })} className="flex min-w-0 items-center gap-2 hover:text-accent">
279 <span className="truncate font-mono text-xs">{s.service || "(unnamed)"}</span>
280 <Badge tone="neutral">{s.source === "cloudflare" ? "g1t.page" : "OTLP"}</Badge>
281 </Link>
282 <span className="shrink-0 text-xs text-muted tabular-nums">
283 {count(s.requests)} · {percent(s.requests ? s.errors / s.requests : 0)} failed
284 </span>
285 </li>
286 ))}
287 </ul>
288 </section>
289 </Card>
290 )}
291 {overview.regions.length > 0 && (
292 <Card asChild className="min-w-0">
293 <section aria-labelledby="regions-title">
294 <h2 id="regions-title" className="flex items-center gap-2 border-b border-line px-4 py-3 text-sm font-medium">
295 <Globe size={14} className="text-faint" />
296 Where requests were served
297 </h2>
298 <ul className="space-y-1.5 px-4 py-3">
299 {overview.regions.slice(0, 8).map((r) => {
300 const share = r.requests / Math.max(1, overview.regions.reduce((n, x) => n + x.requests, 0));
301 return (
302 <li key={r.region} className="grid grid-cols-[3rem_1fr_4rem] items-center gap-3 text-xs">
303 <span className="font-mono">{r.region}</span>
304 <span className="h-1.5 overflow-hidden rounded-full bg-line" aria-hidden="true">
305 <span className="block h-full rounded-full bg-accent" style={{ width: `${Math.max(2, share * 100)}%` }} />
306 </span>
307 <span className="text-right text-muted tabular-nums">{percent(share)}</span>
308 </li>
309 );
310 })}
311 </ul>
312 </section>
313 </Card>
314 )}
315 </div>
316 )}
317 <p className="mt-4 text-xs text-faint">
318 Numbers are counted each minute; the newest minute fills in as its requests finish. <Link to={`${base}/observability/logs`} className="underline hover:text-fg">Logs</Link> and{" "}
319 <Link to={`${base}/observability/requests`} className="underline hover:text-fg">requests</Link> arrive within seconds.
320 </p>
321 </>
322 );
323}
324
325/** Nothing has arrived yet: the two ways it does. */
326function NothingYet({ base, canManage }: { base: string; canManage: boolean }) {
327 return (
328 <Card asChild>
329 <section>
330 <div className="border-b border-line px-6 py-5">
331 <h2 className="font-medium">Nothing has arrived yet</h2>
332 <p className="mt-1 max-w-2xl text-sm text-muted">
333 Requests, logs, traces and metrics show here as the project's software sends them, kept for its retention and counted for
334 its charts.
335 </p>
336 </div>
337 <div className="grid divide-y divide-line md:grid-cols-2 md:divide-x md:divide-y-0">
338 <div className="min-w-0 p-6">
339 <h3 className="flex items-center gap-2 text-sm font-medium">
340 <Globe size={14} className="text-accent" />
341 Deployed on g1t.page
342 </h3>
343 <p className="mt-1.5 text-sm text-muted">
344 Nothing to set up. Every request an app on g1t.page answers arrives here with its logs, exceptions, status and CPU time, and
345 an OpenTelemetry SDK in the app sends its spans here without configuration.
346 </p>
347 <ButtonLink to={`${base}/deployments`} variant="outline" size="sm" className="mt-4">
348 Deployments
349 </ButtonLink>
350 </div>
351 <div className="min-w-0 p-6">
352 <h3 className="flex items-center gap-2 text-sm font-medium">
353 <Send size={14} className="text-accent" />
354 Running anywhere else
355 </h3>
356 <p className="mt-1.5 text-sm text-muted">
357 Send OpenTelemetry (OTLP over HTTP, JSON or protobuf) from any SDK or collector, with one of the project's ingest tokens.
358 </p>
359 <div className="mt-3">
360 <CopyLine text={TELEMETRY_OTLP_ENDPOINT} />
361 </div>
362 <div className="mt-4 flex flex-wrap gap-2">
363 {canManage && (
364 <ButtonLink to={`${base}/observability/settings`} size="sm">
365 Make an ingest token
366 </ButtonLink>
367 )}
368 <ButtonLink to="https://docs.g1t.sh/guides/observability/" variant="outline" size="sm">
369 How to send
370 </ButtonLink>
371 </div>
372 </div>
373 </div>
374 </section>
375 </Card>
376 );
377}