Skip to content

Commit 2c58186

Browse files
committed
feat: add schema command for Sentry API introspection
Phase 2 of #346: Add `sentry schema` command for runtime API introspection by AI agents. New features: - `sentry schema` — list all API resources with endpoint counts - `sentry schema <resource>` — list endpoints for a resource - `sentry schema <resource> <operation>` — show endpoint details - `sentry schema --list` — flat list of all 214 API endpoints - `sentry schema --search <query>` — search across endpoints - All modes support `--json` for machine-readable output Architecture: - `script/generate-api-schema.ts` — build-time parser that extracts endpoint metadata from `@sentry/api` (function names, HTTP methods, URL templates, JSDoc descriptions, deprecation status) - `src/generated/api-schema.json` — lightweight index (102KB, 214 endpoints) bundled into the CLI - `src/lib/api-schema.ts` — runtime query functions (by resource, operation, or full-text search) - `src/commands/schema.ts` — command with human + JSON output modes Tests: 19 unit tests for schema query functions
1 parent 0299d3d commit 2c58186

9 files changed

Lines changed: 3663 additions & 42 deletions

File tree

AGENTS.md

Lines changed: 38 additions & 42 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@
7272
"test:e2e": "bun test --timeout 15000 test/e2e",
7373
"test:init-eval": "bun test test/init-eval --timeout 600000 --concurrency 6",
7474
"generate:skill": "bun run script/generate-skill.ts",
75+
"generate:schema": "bun run script/generate-api-schema.ts",
7576
"check:skill": "bun run script/check-skill.ts",
77+
"check:schema": "bun run script/generate-api-schema.ts && git diff --exit-code src/generated/api-schema.json",
7678
"check:deps": "bun run script/check-no-deps.ts"
7779
},
7880
"type": "module"

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,20 @@ Initialize Sentry in your project
737737
- `--features <value>... - Features to enable: errors,tracing,logs,replay,metrics`
738738
- `-t, --team <value> - Team slug to create the project under`
739739

740+
### Schema
741+
742+
Browse the Sentry API schema
743+
744+
#### `sentry schema <resource...>`
745+
746+
Browse the Sentry API schema
747+
748+
**Flags:**
749+
- `--list - List all endpoints in a flat table`
750+
- `--search <value> - Search endpoints by keyword`
751+
- `--json - Output as JSON`
752+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
753+
740754
### Issues
741755

742756
List issues in a project

script/generate-api-schema.ts

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Generate API Schema Index from @sentry/api
4+
*
5+
* Parses the @sentry/api package at build time to generate a lightweight
6+
* JSON index of all API endpoints. This index is bundled into the CLI
7+
* for runtime introspection via `sentry schema`.
8+
*
9+
* Data sources:
10+
* 1. index.js — function name, HTTP method, URL template
11+
* 2. sdk.gen.d.ts — JSDoc descriptions, deprecation status
12+
*
13+
* Usage:
14+
* bun run script/generate-api-schema.ts
15+
*
16+
* Output:
17+
* src/generated/api-schema.json
18+
*/
19+
20+
const API_PKG = "node_modules/@sentry/api/dist";
21+
const OUTPUT_PATH = "src/generated/api-schema.json";
22+
23+
/** Regex to strip JSDoc line prefix (" * ") */
24+
const JSDOC_LINE_PREFIX = /^\s*\*\s?/;
25+
/** Regex to collapse multiple spaces */
26+
const MULTI_SPACE = /\s+/g;
27+
/** Regex to extract path parameters from URL templates */
28+
const PATH_PARAM_PATTERN = /\{(\w+)\}/g;
29+
/** Regex to extract leading lowercase verb from function name */
30+
const LEADING_VERB_PATTERN = /^([a-z]+)/;
31+
32+
/** Parsed endpoint data — exported to make this file a module for top-level await */
33+
export type ApiEndpoint = {
34+
/** SDK function name */
35+
fn: string;
36+
/** HTTP method */
37+
method: string;
38+
/** URL template with {param} placeholders */
39+
path: string;
40+
/** Human-readable description from JSDoc */
41+
description: string;
42+
/** Path parameter names extracted from URL template */
43+
pathParams: string[];
44+
/** Whether the endpoint is deprecated */
45+
deprecated: boolean;
46+
/** Resource category derived from URL path */
47+
resource: string;
48+
/** Operation verb derived from function name */
49+
operation: string;
50+
};
51+
52+
// ---------------------------------------------------------------------------
53+
// Step 1: Parse index.js for function → {method, url}
54+
// ---------------------------------------------------------------------------
55+
56+
/**
57+
* Extract function name, HTTP method, and URL from each SDK function in the
58+
* bundled JS. Each function follows the pattern:
59+
* var NAME = (options...) => (options...client ?? client).METHOD({...url: "URL"...})
60+
*/
61+
async function parseJsBundle(): Promise<
62+
Map<string, { method: string; url: string }>
63+
> {
64+
const js = await Bun.file(`${API_PKG}/index.js`).text();
65+
const results = new Map<string, { method: string; url: string }>();
66+
67+
// Match function declarations with method calls
68+
const funcPattern =
69+
/var (\w+) = \(options\S*\) => \(options\S*client \?\? client\)\.(\w+)\(/g;
70+
// Match URL assignments
71+
const urlPattern = /url: "([^"]+)"/g;
72+
73+
// Extract all function declarations
74+
const funcs: { name: string; method: string; index: number }[] = [];
75+
let match: RegExpExecArray | null;
76+
match = funcPattern.exec(js);
77+
while (match !== null) {
78+
funcs.push({
79+
name: match[1],
80+
method: match[2].toUpperCase(),
81+
index: match.index,
82+
});
83+
match = funcPattern.exec(js);
84+
}
85+
86+
// Extract all URLs
87+
const urls: { url: string; index: number }[] = [];
88+
match = urlPattern.exec(js);
89+
while (match !== null) {
90+
urls.push({ url: match[1], index: match.index });
91+
match = urlPattern.exec(js);
92+
}
93+
94+
// Match each function to its URL (the URL that follows it in the source)
95+
for (const func of funcs) {
96+
const nextUrl = urls.find((u) => u.index > func.index);
97+
if (nextUrl) {
98+
results.set(func.name, { method: func.method, url: nextUrl.url });
99+
}
100+
}
101+
102+
return results;
103+
}
104+
105+
// ---------------------------------------------------------------------------
106+
// Step 2: Parse sdk.gen.d.ts for JSDoc descriptions
107+
// ---------------------------------------------------------------------------
108+
109+
/**
110+
* Extract JSDoc descriptions and deprecation status for each exported function.
111+
* Pattern: JSDoc block immediately before `export declare const NAME`
112+
*/
113+
async function parseDeclarations(): Promise<
114+
Map<string, { description: string; deprecated: boolean }>
115+
> {
116+
const dts = await Bun.file(`${API_PKG}/sdk.gen.d.ts`).text();
117+
const results = new Map<
118+
string,
119+
{ description: string; deprecated: boolean }
120+
>();
121+
122+
// Match JSDoc + export declare const patterns
123+
const pattern = /\/\*\*\n([\s\S]*?)\*\/\nexport declare const (\w+):/g;
124+
125+
let match = pattern.exec(dts);
126+
while (match !== null) {
127+
const jsdocBody = match[1];
128+
const name = match[2];
129+
130+
// Clean up JSDoc: remove leading " * " from each line, trim
131+
const description = jsdocBody
132+
.split("\n")
133+
.map((line) => line.replace(JSDOC_LINE_PREFIX, "").trim())
134+
.filter((line) => line.length > 0)
135+
.join(" ")
136+
.replace(MULTI_SPACE, " ")
137+
.trim();
138+
139+
const deprecated =
140+
description.includes("## Deprecated") ||
141+
description.includes("🚧") ||
142+
name.startsWith("deprecated");
143+
144+
results.set(name, { description, deprecated });
145+
146+
match = pattern.exec(dts);
147+
}
148+
149+
return results;
150+
}
151+
152+
// ---------------------------------------------------------------------------
153+
// Step 3: Derive resource and operation from URL and function name
154+
// ---------------------------------------------------------------------------
155+
156+
/** Known operation verbs that appear as function name prefixes */
157+
const OPERATION_VERBS = [
158+
"list",
159+
"retrieve",
160+
"create",
161+
"update",
162+
"delete",
163+
"fetch",
164+
"query",
165+
"add",
166+
"remove",
167+
"bulk",
168+
"start",
169+
"enable",
170+
"disable",
171+
"edit",
172+
"register",
173+
"resolve",
174+
"submit",
175+
"upload",
176+
"mutate",
177+
"provision",
178+
"get",
179+
"debug",
180+
"regenerate",
181+
"syncs",
182+
"deprecated",
183+
] as const;
184+
185+
/**
186+
* Derive the resource name from a URL template.
187+
* Uses the last non-parameter path segment.
188+
*
189+
* Examples:
190+
* /api/0/organizations/{org}/issues/ → "issues"
191+
* /api/0/issues/{issue_id}/ → "issues"
192+
* /api/0/teams/{org}/{team}/projects/ → "projects"
193+
* /api/0/organizations/ → "organizations"
194+
*/
195+
function deriveResource(url: string): string {
196+
const segments = url
197+
.split("/")
198+
.filter((s) => s.length > 0 && !s.startsWith("{"));
199+
// Skip "api" and "0" prefix segments
200+
const meaningful = segments.filter((s) => s !== "api" && s !== "0");
201+
// Return the last meaningful segment
202+
return meaningful.at(-1) ?? "unknown";
203+
}
204+
205+
/**
206+
* Derive the operation verb from a function name.
207+
* Extracts the leading verb token.
208+
*
209+
* Examples:
210+
* listAnOrganization_sIssues → "list"
211+
* retrieveAnIssue → "retrieve"
212+
* createANewProject → "create"
213+
* deprecatedListAnOrganization_sMetricAlertRules → "list" (strips deprecated prefix)
214+
*/
215+
function deriveOperation(fnName: string): string {
216+
let name = fnName;
217+
218+
// Strip "deprecated" prefix to get the actual verb
219+
if (name.startsWith("deprecated")) {
220+
name = name.charAt(10).toLowerCase() + name.slice(11);
221+
}
222+
223+
// Match against known verbs
224+
for (const verb of OPERATION_VERBS) {
225+
if (verb === "deprecated") {
226+
continue;
227+
}
228+
if (name.startsWith(verb)) {
229+
return verb;
230+
}
231+
}
232+
233+
// Fallback: first lowercase sequence before an uppercase letter
234+
const match = name.match(LEADING_VERB_PATTERN);
235+
return match ? match[1] : "unknown";
236+
}
237+
238+
/**
239+
* Extract path parameter names from a URL template.
240+
* /api/0/organizations/{organization_id_or_slug}/issues/ → ["organization_id_or_slug"]
241+
*/
242+
function extractPathParams(url: string): string[] {
243+
const params: string[] = [];
244+
const pattern = new RegExp(
245+
PATH_PARAM_PATTERN.source,
246+
PATH_PARAM_PATTERN.flags
247+
);
248+
let match = pattern.exec(url);
249+
while (match !== null) {
250+
params.push(match[1]);
251+
match = pattern.exec(url);
252+
}
253+
return params;
254+
}
255+
256+
// ---------------------------------------------------------------------------
257+
// Main
258+
// ---------------------------------------------------------------------------
259+
260+
const jsData = await parseJsBundle();
261+
const dtsData = await parseDeclarations();
262+
263+
const endpoints: ApiEndpoint[] = [];
264+
265+
for (const [name, { method, url }] of jsData) {
266+
const decl = dtsData.get(name);
267+
268+
endpoints.push({
269+
fn: name,
270+
method,
271+
path: url,
272+
description: decl?.description ?? "",
273+
pathParams: extractPathParams(url),
274+
deprecated: decl?.deprecated ?? false,
275+
resource: deriveResource(url),
276+
operation: deriveOperation(name),
277+
});
278+
}
279+
280+
// Sort by resource, then operation for stable output
281+
endpoints.sort((a, b) => {
282+
const resourceCmp = a.resource.localeCompare(b.resource);
283+
if (resourceCmp !== 0) {
284+
return resourceCmp;
285+
}
286+
return a.operation.localeCompare(b.operation);
287+
});
288+
289+
await Bun.write(OUTPUT_PATH, JSON.stringify(endpoints, null, 2));
290+
291+
console.log(
292+
`Generated ${OUTPUT_PATH} (${endpoints.length} endpoints, ${Math.round(JSON.stringify(endpoints).length / 1024)}KB)`
293+
);

src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { projectRoute } from "./commands/project/index.js";
2424
import { listCommand as projectListCommand } from "./commands/project/list.js";
2525
import { repoRoute } from "./commands/repo/index.js";
2626
import { listCommand as repoListCommand } from "./commands/repo/list.js";
27+
import { schemaCommand } from "./commands/schema.js";
2728
import { spanRoute } from "./commands/span/index.js";
2829
import { listCommand as spanListCommand } from "./commands/span/list.js";
2930
import { teamRoute } from "./commands/team/index.js";
@@ -75,6 +76,7 @@ export const routes = buildRouteMap({
7576
trial: trialRoute,
7677
init: initCommand,
7778
api: apiCommand,
79+
schema: schemaCommand,
7880
issues: issueListCommand,
7981
orgs: orgListCommand,
8082
projects: projectListCommand,

0 commit comments

Comments
 (0)