Files
zitadel/apps/docs/components/ErrorReference/index.tsx
T
e8d95f306b docs: add searchable API error reference (#12502)
## Summary
- Add a searchable catalog of every error ID ZITADEL's gRPC/Connect APIs
can return (`content/apis/errors.mdx`), wired into the sidebar next to
"gRPC Status Codes" (with a cross-link added there too). There was
previously no such reference — error IDs are assigned ad hoc at ~3,800
call sites across the backend.
- `scripts/generate-error-reference.ts` scans `internal/**/*.go` and
`backend/**/*.go` for `zerrors.Throw*()` call sites, resolves i18n
messages, and clusters ~3,200 distinct IDs into ~1,160 distinct causes
by (subsystem, gRPC code, message) — so identical causes are merged into
one entry instead of repeated ~3,200 times.
- Detects real ID collisions (same ID thrown for genuinely different
reasons at different call sites) and surfaces them as warnings, rather
than silently merging them.
- Generates a worked example API response for every cause.
- 116 of the highest-impact causes (covering the majority of call sites)
carry an explanation written against the actual source rather than the
raw message text; the rest carry a mechanically derived explanation —
both are visibly labeled by source so the depth of research is
transparent.
- New `ErrorReference` component renders the catalog as a searchable,
collapsible subsystem → gRPC code → cause tree.

The generator is a standalone script (`pnpm generate:error-reference` in
`apps/docs`), not wired into the docs build's `generate` chain, since
scanning the whole Go backend on every docs build would be wasteful. The
generated `data.json` and the curated overrides are committed, the same
way `content/apis/benchmarks/**/output.json` is already committed
despite being externally generated.

## Test plan
- [x] `pnpm nx run @zitadel/docs:check-types` passes
- [x] `pnpm nx run @zitadel/docs:lint` passes
- [x] `pnpm nx run @zitadel/docs:check-links` passes
- [x] Verified locally in `pnpm nx run @zitadel/docs:dev`: page renders,
tree expands/collapses, search filters by ID/message, collision warnings
render correctly (e.g. `AUTHZ-Kl3p0`)

---------

Co-authored-by: Rajat Singh <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Marco A. <[email protected]>
2026-09-08 09:47:58 +00:00

397 lines
14 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, TriangleAlert } from 'lucide-react';
import { cn } from '@/utils/cn';
interface ErrorLocation {
file: string;
line: number;
}
interface ErrorIdEntry {
id: string;
locations: ErrorLocation[];
}
interface ExampleResponse {
httpStatus: number;
grpcCode: number;
body: { code: number; message: string; details: [{ '@type': string; id: string; message: string }] };
}
type WhySource = 'curated' | 'i18n-decomposition' | 'defensive-guard-template' | 'generic-fallback';
interface ErrorCluster {
key: string;
message: string;
messageType: 'i18n' | 'literal';
ids: ErrorIdEntry[];
why: string;
whySource: WhySource;
example: ExampleResponse;
collidesWith: { key: string; message: string; subsystem: string }[];
}
interface KindBucket {
kind: string;
clusters: ErrorCluster[];
}
interface Subsystem {
id: string;
name: string;
summary: string;
kinds: KindBucket[];
}
interface ErrorReferenceData {
meta: {
generatedAt: string;
sourceRef: string;
totalCallSites: number;
totalIds: number;
totalClusters: number;
totalCollisions: number;
};
subsystems: Subsystem[];
}
const WHY_SOURCE_LABEL: Record<WhySource, string> = {
curated: 'Researched from source',
'i18n-decomposition': 'Derived from message key',
'defensive-guard-template': 'Internal guard',
'generic-fallback': 'Auto-generated',
};
const WHY_SOURCE_STYLE: Record<WhySource, string> = {
curated: 'bg-fd-accent text-fd-accent-foreground',
'i18n-decomposition': 'border border-fd-border text-fd-muted-foreground',
'defensive-guard-template': 'border border-fd-border text-fd-muted-foreground',
'generic-fallback': 'border border-dashed border-fd-border text-fd-muted-foreground',
};
const MAX_LOCATIONS_SHOWN = 5;
const MAX_SEARCH_RESULTS = 150;
function clusterIdCount(cluster: ErrorCluster) {
return cluster.ids.length;
}
function kindIdCount(kind: KindBucket) {
return kind.clusters.reduce((n, c) => n + clusterIdCount(c), 0);
}
function subsystemIdCount(s: Subsystem) {
return s.kinds.reduce((n, k) => n + kindIdCount(k), 0);
}
function matchesQuery(cluster: ErrorCluster, q: string) {
if (cluster.message.toLowerCase().includes(q)) return true;
if (cluster.why.toLowerCase().includes(q)) return true;
return cluster.ids.some((e) => e.id.toLowerCase().includes(q));
}
export default function ErrorReference() {
const [data, setData] = useState<ErrorReferenceData | null>(null);
const [loadError, setLoadError] = useState(false);
const [query, setQuery] = useState('');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
fetch('/api/error-reference')
.then((res) => {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
})
.then((json) => {
if (!cancelled) setData(json as ErrorReferenceData);
})
.catch(() => {
if (!cancelled) setLoadError(true);
});
return () => {
cancelled = true;
};
}, []);
const trimmedQuery = query.trim().toLowerCase();
const searchResults = useMemo(() => {
if (!data || !trimmedQuery) return null;
const results: { subsystem: Subsystem; kind: KindBucket; cluster: ErrorCluster }[] = [];
for (const subsystem of data.subsystems) {
for (const kind of subsystem.kinds) {
for (const cluster of kind.clusters) {
if (matchesQuery(cluster, trimmedQuery)) {
results.push({ subsystem, kind, cluster });
if (results.length >= MAX_SEARCH_RESULTS) return results;
}
}
}
}
return results;
}, [data, trimmedQuery]);
function toggle(key: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
if (loadError) {
return <p className="text-sm text-fd-muted-foreground">Couldn&apos;t load the error reference. Try reloading the page.</p>;
}
if (!data) {
return <p className="text-sm text-fd-muted-foreground">Loading error reference…</p>;
}
return (
<div className="not-prose flex flex-col gap-4">
<div className="flex flex-col gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search ${data.meta.totalIds.toLocaleString()} error IDs or messages, e.g. "COMMAND-2M0fs" or "not found"…`}
aria-label="Search error IDs or messages"
className="w-full px-3 py-2 text-sm bg-transparent border rounded-md border-fd-border text-fd-foreground focus:outline-none focus:ring-2 focus:ring-fd-accent"
/>
<p className="text-xs text-fd-muted-foreground">
{data.meta.totalIds.toLocaleString()} error IDs, grouped into {data.meta.totalClusters.toLocaleString()}{' '}
distinct causes across {data.subsystems.length} subsystems. {data.meta.totalCollisions} of those causes
share an ID string with at least one other cause — see the warning on those entries.
</p>
</div>
{searchResults ? (
<SearchResults results={searchResults} truncated={searchResults.length >= MAX_SEARCH_RESULTS} />
) : (
<div className="flex flex-col gap-2">
{data.subsystems.map((subsystem) => (
<SubsystemSection
key={subsystem.id}
subsystem={subsystem}
expanded={expanded}
toggle={toggle}
/>
))}
</div>
)}
</div>
);
}
function SearchResults({
results,
truncated,
}: {
results: { subsystem: Subsystem; kind: KindBucket; cluster: ErrorCluster }[];
truncated: boolean;
}) {
if (results.length === 0) {
return <p className="text-sm text-fd-muted-foreground">No matches.</p>;
}
return (
<div className="flex flex-col gap-2">
{truncated && (
<p className="text-xs text-fd-muted-foreground">
Showing the first {MAX_SEARCH_RESULTS} matches — refine your search for more specific results.
</p>
)}
{results.map(({ subsystem, kind, cluster }) => (
<div key={cluster.key} className="border rounded-md border-fd-border">
<div className="px-3 py-2 text-xs text-fd-muted-foreground border-b border-fd-border">
{subsystem.name} <ChevronRight className="inline size-3" /> {kind.kind}
</div>
<ClusterDetail cluster={cluster} />
</div>
))}
</div>
);
}
function SubsystemSection({
subsystem,
expanded,
toggle,
}: {
subsystem: Subsystem;
expanded: Set<string>;
toggle: (key: string) => void;
}) {
const open = expanded.has(subsystem.id);
return (
<div className="border rounded-md border-fd-border">
<button
type="button"
onClick={() => toggle(subsystem.id)}
aria-expanded={open}
className="flex items-start w-full gap-2 px-3 py-2 text-left"
>
<ChevronRight className={cn('size-4 shrink-0 mt-0.5 transition-transform text-fd-muted-foreground', open && 'rotate-90')} />
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-fd-foreground">
{subsystem.name} <span className="font-normal text-fd-muted-foreground">({subsystemIdCount(subsystem)} IDs)</span>
</span>
{open && <span className="text-xs text-fd-muted-foreground">{subsystem.summary}</span>}
</div>
</button>
{open && (
<div className="flex flex-col gap-2 px-3 pb-3 pl-9">
{subsystem.kinds.map((kind) => (
<KindSection
key={kind.kind}
parentKey={subsystem.id}
kind={kind}
expanded={expanded}
toggle={toggle}
/>
))}
</div>
)}
</div>
);
}
function KindSection({
parentKey,
kind,
expanded,
toggle,
}: {
parentKey: string;
kind: KindBucket;
expanded: Set<string>;
toggle: (key: string) => void;
}) {
const key = `${parentKey}::${kind.kind}`;
const open = expanded.has(key);
return (
<div className="border rounded-md border-fd-border/60">
<button type="button" onClick={() => toggle(key)} aria-expanded={open} className="flex items-center w-full gap-2 px-3 py-1.5 text-left">
<ChevronRight className={cn('size-3.5 shrink-0 transition-transform text-fd-muted-foreground', open && 'rotate-90')} />
<span className="font-mono text-xs text-fd-foreground">{kind.kind}</span>
<span className="text-xs text-fd-muted-foreground">
{kind.clusters.length} cause{kind.clusters.length === 1 ? '' : 's'}, {kindIdCount(kind)} IDs
</span>
</button>
{open && (
<div className="flex flex-col gap-1.5 px-3 pb-2 ml-5">
{kind.clusters.map((cluster) => (
<ClusterRow key={cluster.key} cluster={cluster} expanded={expanded} toggle={toggle} />
))}
</div>
)}
</div>
);
}
function ClusterRow({
cluster,
expanded,
toggle,
}: {
cluster: ErrorCluster;
expanded: Set<string>;
toggle: (key: string) => void;
}) {
const open = expanded.has(cluster.key);
return (
<div className="border rounded-md border-fd-border/60">
<button type="button" onClick={() => toggle(cluster.key)} aria-expanded={open} className="flex items-start w-full gap-2 px-2 py-1.5 text-left">
<ChevronRight className={cn('size-3.5 shrink-0 mt-0.5 transition-transform text-fd-muted-foreground', open && 'rotate-90')} />
<span className="flex-1 font-mono text-xs break-all text-fd-foreground">{cluster.message || '(empty message)'}</span>
{cluster.collidesWith.length > 0 && <TriangleAlert className="size-3.5 shrink-0 text-amber-500" />}
<span className="text-xs shrink-0 text-fd-muted-foreground">{cluster.ids.length}</span>
</button>
{open && <ClusterDetail cluster={cluster} />}
</div>
);
}
function ClusterDetail({ cluster }: { cluster: ErrorCluster }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
function toggleLocations(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div className="flex flex-col gap-3 px-3 pb-3 text-xs">
<div className="flex items-start justify-between gap-2">
<p className="text-fd-foreground">{cluster.why}</p>
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium whitespace-nowrap', WHY_SOURCE_STYLE[cluster.whySource])}>
{WHY_SOURCE_LABEL[cluster.whySource]}
</span>
</div>
{cluster.collidesWith.length > 0 && (
<div className="flex flex-col gap-1 p-2 border rounded-md border-amber-500/40 bg-amber-500/10">
<p className="flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400">
<TriangleAlert className="size-3.5" /> Same ID string used elsewhere with a different meaning
</p>
<p className="text-fd-muted-foreground">
Don&apos;t disambiguate by ID alone — a client also needs the message. Also thrown for:
</p>
<ul className="list-disc list-inside text-fd-muted-foreground">
{cluster.collidesWith.map((c) => (
<li key={c.key}>
<span className="font-mono">&ldquo;{c.message}&rdquo;</span> ({c.subsystem})
</li>
))}
</ul>
</div>
)}
<div className="flex flex-col gap-1.5">
<p className="font-medium text-fd-muted-foreground">{cluster.ids.length === 1 ? 'ID' : `IDs (${cluster.ids.length})`}</p>
<div className="flex flex-col gap-2">
{cluster.ids.map((idEntry) => {
const showAll = expandedIds.has(idEntry.id);
const shown = showAll ? idEntry.locations : idEntry.locations.slice(0, MAX_LOCATIONS_SHOWN);
const hiddenCount = idEntry.locations.length - shown.length;
return (
<div key={idEntry.id} className="flex flex-col gap-0.5">
<code className="font-mono text-fd-foreground">{idEntry.id}</code>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 pl-3 text-fd-muted-foreground">
{shown.map((loc, i) => (
<span key={i} className="font-mono">
{loc.file}:{loc.line}
</span>
))}
{hiddenCount > 0 && (
<button
type="button"
onClick={() => toggleLocations(idEntry.id)}
className="underline underline-offset-2 hover:text-fd-foreground"
>
+{hiddenCount} more
</button>
)}
{showAll && idEntry.locations.length > MAX_LOCATIONS_SHOWN && (
<button
type="button"
onClick={() => toggleLocations(idEntry.id)}
className="underline underline-offset-2 hover:text-fd-foreground"
>
show fewer
</button>
)}
</div>
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-1">
<p className="font-medium text-fd-muted-foreground">
Example response &mdash; HTTP {cluster.example.httpStatus}, gRPC {cluster.example.grpcCode}
</p>
<pre className="p-2 overflow-x-auto rounded-md bg-fd-secondary text-fd-secondary-foreground">
<code>{JSON.stringify(cluster.example.body, null, 2)}</code>
</pre>
</div>
</div>
);
}