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.

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