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.

151 linesCodeBlameRaw
1import { Link, useNavigate } from "react-router";
2
3import type { Route } from "./+types/observability-metrics";
4import { FilterBar, ObservabilityHeader, TimeChart, useLoadingView } from "../../components/observability";
5import { EmptyState, TimeAgo } from "../../components/ui";
6import { Card } from "../../components/ui/card";
7import { SelectField } from "../../components/ui/select";
8import { cn } from "../../lib/cn";
9import { page } from "../../lib/meta";
10import { duration, viewHref } from "../../lib/observability";
11import { observed, soft } from "../../lib/observability.server";
12import { Unavailable } from "../../components/boundary";
13import { isFailed, part } from "../../lib/partial";
14import { PRODUCT_STYLE } from "../../lib/usage";
15
16const AGGREGATES = ["avg", "sum", "min", "max", "p50", "p95", "p99"] as const;
17type Aggregate = (typeof AGGREGATES)[number];
18
19export function meta({ params, ...args }: Route.MetaArgs) {
20 return page(args, { title: `Metrics · ${params.owner}/${params.repo} · g1t` });
21}
22
23export async function loader({ params, context, request }: Route.LoaderArgs) {
24 const { projectId, service, view, filter } = await observed(context, params, request);
25 const search = new URL(request.url).searchParams;
26 const [metrics, facets] = await Promise.all([
27 soft(service.metrics({ project_id: projectId }), "metrics"),
28 soft(service.facets({ project_id: projectId }), "facets"),
29 ]);
30 const name = search.get("name") ?? metrics?.[0]?.name ?? null;
31 const info = metrics?.find((m) => m.name === name) ?? null;
32 const aggregate = AGGREGATES.find((a) => a === search.get("agg")) ?? (info?.kind === "sum" ? "sum" : "avg");
33 const groupBy = search.get("by") || null;
34 // The chosen metric's chart says so in its place when it could not be read.
35 const series = name ? await part(service.metric({ project_id: projectId, ...filter, name, aggregate, group_by: groupBy }), `${name}'s points`) : null;
36 return { view, metrics, facets, name, info, aggregate, groupBy, series };
37}
38
39/** A value in its unit: durations read as durations, bytes as bytes. */
40function formatter(unit: string): (n: number) => string {
41 if (unit === "ms") return (n) => duration(n);
42 if (unit === "s") return (n) => duration(n * 1000);
43 if (unit === "us" || unit === "µs") return (n) => duration(n / 1000);
44 if (unit === "By") return (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : n >= 1e3 ? `${(n / 1e3).toFixed(1)} KB` : `${Math.round(n)} B`);
45 return (n) => `${n.toLocaleString("en-US", { maximumFractionDigits: n < 10 ? 2 : 0 })}${unit && unit !== "1" && !unit.startsWith("{") ? ` ${unit}` : ""}`;
46}
47
48export default function Metrics({ loaderData, params }: Route.ComponentProps) {
49 const { view, metrics, facets, name, info, aggregate, groupBy, series } = loaderData;
50 const path = `/${params.owner}/${params.repo}/observability/metrics`;
51 const navigate = useNavigate();
52 const loading = useLoadingView();
53 const extra = { name, agg: aggregate, by: groupBy };
54 const go = (change: Record<string, string | null>) => navigate(viewHref(path, view, { ...extra, ...change }), { preventScrollReset: true });
55 const aggregates: Aggregate[] = info?.kind === "histogram" ? [...AGGREGATES] : AGGREGATES.filter((a) => !a.startsWith("p"));
56
57 return (
58 <div className="mx-auto max-w-6xl">
59 <ObservabilityHeader title="Metrics" about="Every metric the project's software sends with OpenTelemetry: counters, gauges and histograms, over time." />
60 <FilterBar path={path} view={view} environments={facets?.environments ?? []} services={facets?.services ?? []} />
61
62 <div className={cn("mt-4 transition-opacity", loading && "opacity-60")}>
63 {!metrics ? (
64 <Unavailable what="Metrics" />
65 ) : metrics.length === 0 ? (
66 <EmptyState title="No metrics yet">
67 Request counts, errors and latency are on the Overview already. Metrics of your own arrive from an OpenTelemetry SDK's metric exporter, with an
68 ingest token.{" "}
69 <a href="https://docs.g1t.sh/guides/observability/#metrics" className="text-accent hover:underline">
70 How
71 </a>
72 </EmptyState>
73 ) : (
74 <div className="grid gap-4 lg:grid-cols-[16rem_minmax(0,1fr)]">
75 <Card asChild className="min-w-0 self-start overflow-hidden">
76 <nav aria-label="Metrics">
77 <ul className="max-h-128 divide-y divide-line overflow-auto">
78 {metrics.map((m) => (
79 <li key={m.name}>
80 <Link
81 to={viewHref(path, view, { name: m.name })}
82 preventScrollReset
83 aria-current={m.name === name ? "page" : undefined}
84 className={cn("block px-3 py-2 hover:bg-raised/60", m.name === name && "bg-raised")}
85 >
86 <span className="block truncate font-mono text-xs">{m.name}</span>
87 <span className="text-[0.6875rem] text-faint">
88 {m.kind}
89 {m.unit ? ` · ${m.unit}` : ""} · <TimeAgo at={m.last_us / 1000} />
90 </span>
91 </Link>
92 </li>
93 ))}
94 </ul>
95 </nav>
96 </Card>
97 <div className="min-w-0">
98 {info && (
99 <div className="mb-3 flex flex-wrap items-center gap-2">
100 <p className="mr-auto min-w-0 text-sm text-muted">{info.description || <span className="font-mono">{info.name}</span>}</p>
101 <SelectField
102 aria-label="Aggregate"
103 size="sm"
104 className="w-28"
105 value={aggregate}
106 onValueChange={(value) => go({ agg: value })}
107 options={aggregates.map((a) => ({ value: a, label: a }))}
108 />
109 <SelectField
110 aria-label="Group by"
111 size="sm"
112 className="w-44"
113 value={groupBy ?? "*"}
114 onValueChange={(value) => go({ by: value === "*" ? null : value })}
115 options={[
116 { value: "*", label: "No grouping" },
117 { value: "service", label: "By service" },
118 ...(facets?.attribute_keys ?? []).slice(0, 50).map((k) => ({ value: k, label: `By ${k}` })),
119 ]}
120 />
121 </div>
122 )}
123 {isFailed(series) && <Unavailable what={series.what} />}
124 {series && !isFailed(series) && (
125 <TimeChart
126 title={`${series.name} (${aggregate})`}
127 kind="lines"
128 times={series.series[0]?.points.map((p) => p.time_us) ?? []}
129 stepS={series.step_s}
130 format={formatter(series.unit)}
131 series={series.series.slice(0, PRODUCT_STYLE.length).map((s, i) => ({
132 key: s.group ?? "all",
133 label: s.group ?? series.name,
134 color: PRODUCT_STYLE[i]!.color,
135 values: s.points.map((p) => p.value),
136 }))}
137 empty="No points in this period."
138 />
139 )}
140 {series && !isFailed(series) && series.series.length > PRODUCT_STYLE.length && (
141 <p className="mt-2 text-xs text-faint">
142 Showing the first {PRODUCT_STYLE.length} of {series.series.length} groups.
143 </p>
144 )}
145 </div>
146 </div>
147 )}
148 </div>
149 </div>
150 );
151}