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.

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