Skip to content

Commit 0b6784a

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 0b6784a

10 files changed

Lines changed: 4308 additions & 45 deletions

File tree

AGENTS.md

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

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"@biomejs/biome": "2.3.8",
1111
"@clack/prompts": "^0.11.0",
1212
"@mastra/client-js": "^1.4.0",
13-
"@sentry/api": "^0.21.0",
13+
"@sentry/api": "^0.54.0",
1414
"@sentry/bun": "10.39.0",
1515
"@sentry/esbuild-plugin": "^2.23.0",
1616
"@sentry/node": "10.39.0",
@@ -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+
- `--all - Show all endpoints in a flat list`
750+
- `-q, --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: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Generate API Schema Index from Sentry's OpenAPI Specification
4+
*
5+
* Fetches the dereferenced OpenAPI spec from the sentry-api-schema repository
6+
* and extracts a lightweight JSON index of all API endpoints. This index is
7+
* bundled into the CLI for runtime introspection via `sentry schema`.
8+
*
9+
* Data source: https://github.com/getsentry/sentry-api-schema
10+
* - openapi-derefed.json — full dereferenced OpenAPI 3.0 spec
11+
*
12+
* Also reads SDK function names from the installed @sentry/api package to
13+
* map operationIds to their TypeScript SDK function names.
14+
*
15+
* Usage:
16+
* bun run script/generate-api-schema.ts
17+
*
18+
* Output:
19+
* src/generated/api-schema.json
20+
*/
21+
22+
import { resolve } from "node:path";
23+
24+
const OUTPUT_PATH = "src/generated/api-schema.json";
25+
26+
/**
27+
* Build the OpenAPI spec URL from the installed @sentry/api version.
28+
* The sentry-api-schema repo tags match the @sentry/api npm version.
29+
*/
30+
function getOpenApiUrl(): string {
31+
const pkgPath = require.resolve("@sentry/api/package.json");
32+
const pkg = require(pkgPath) as { version: string };
33+
return `https://raw.githubusercontent.com/getsentry/sentry-api-schema/${pkg.version}/openapi-derefed.json`;
34+
}
35+
36+
/** Regex to extract path parameters from URL templates */
37+
const PATH_PARAM_PATTERN = /\{(\w+)\}/g;
38+
39+
/** Parsed endpoint data — exported to make this file a module for top-level await */
40+
export type ApiEndpoint = {
41+
/** SDK function name (empty string if not found in @sentry/api) */
42+
fn: string;
43+
/** HTTP method */
44+
method: string;
45+
/** URL template with {param} placeholders */
46+
path: string;
47+
/** Human-readable description from OpenAPI spec */
48+
description: string;
49+
/** Path parameter names extracted from URL template */
50+
pathParams: string[];
51+
/** Query parameter names from OpenAPI spec */
52+
queryParams: string[];
53+
/** Whether the endpoint is deprecated */
54+
deprecated: boolean;
55+
/** Resource category derived from URL path */
56+
resource: string;
57+
/** Operation ID from OpenAPI spec (human-readable) */
58+
operationId: string;
59+
};
60+
61+
// ---------------------------------------------------------------------------
62+
// OpenAPI Types (minimal subset we need)
63+
// ---------------------------------------------------------------------------
64+
65+
type OpenApiSpec = {
66+
paths: Record<string, Record<string, OpenApiOperation>>;
67+
};
68+
69+
type OpenApiOperation = {
70+
operationId?: string;
71+
description?: string;
72+
deprecated?: boolean;
73+
parameters?: OpenApiParameter[];
74+
};
75+
76+
type OpenApiParameter = {
77+
in: "path" | "query" | "header" | "cookie";
78+
name: string;
79+
required?: boolean;
80+
description?: string;
81+
schema?: { type?: string };
82+
};
83+
84+
// ---------------------------------------------------------------------------
85+
// SDK Function Name Mapping
86+
// ---------------------------------------------------------------------------
87+
88+
/**
89+
* Build a map from URL+method to SDK function name by parsing
90+
* the @sentry/api index.js bundle.
91+
*/
92+
async function buildSdkFunctionMap(): Promise<Map<string, string>> {
93+
const pkgDir = resolve(
94+
require.resolve("@sentry/api/package.json"),
95+
"..",
96+
"dist"
97+
);
98+
const js = await Bun.file(`${pkgDir}/index.js`).text();
99+
const results = new Map<string, string>();
100+
101+
// Match: var NAME = (options...) => (options...client ?? client).METHOD({
102+
const funcPattern =
103+
/var (\w+) = \(options\S*\) => \(options\S*client \?\? client\)\.(\w+)\(/g;
104+
// Match: url: "..."
105+
const urlPattern = /url: "([^"]+)"/g;
106+
107+
// Extract all function declarations with their positions
108+
const funcs: { name: string; method: string; index: number }[] = [];
109+
let match = funcPattern.exec(js);
110+
while (match !== null) {
111+
funcs.push({
112+
name: match[1],
113+
method: match[2].toUpperCase(),
114+
index: match.index,
115+
});
116+
match = funcPattern.exec(js);
117+
}
118+
119+
// Extract all URLs with their positions
120+
const urls: { url: string; index: number }[] = [];
121+
match = urlPattern.exec(js);
122+
while (match !== null) {
123+
urls.push({ url: match[1], index: match.index });
124+
match = urlPattern.exec(js);
125+
}
126+
127+
// Match each function to its nearest following URL
128+
for (const func of funcs) {
129+
const nextUrl = urls.find((u) => u.index > func.index);
130+
if (nextUrl) {
131+
const key = `${func.method}:${nextUrl.url}`;
132+
results.set(key, func.name);
133+
}
134+
}
135+
136+
return results;
137+
}
138+
139+
// ---------------------------------------------------------------------------
140+
// Resource Derivation
141+
// ---------------------------------------------------------------------------
142+
143+
/**
144+
* Derive the resource name from a URL template.
145+
* Uses the last non-parameter path segment.
146+
*
147+
* @example "/api/0/organizations/{org}/issues/" → "issues"
148+
* @example "/api/0/issues/{issue_id}/" → "issues"
149+
*/
150+
function deriveResource(url: string): string {
151+
const segments = url
152+
.split("/")
153+
.filter((s) => s.length > 0 && !s.startsWith("{"));
154+
const meaningful = segments.filter((s) => s !== "api" && s !== "0");
155+
return meaningful.at(-1) ?? "unknown";
156+
}
157+
158+
/**
159+
* Extract path parameter names from a URL template.
160+
*/
161+
function extractPathParams(url: string): string[] {
162+
const params: string[] = [];
163+
const pattern = new RegExp(
164+
PATH_PARAM_PATTERN.source,
165+
PATH_PARAM_PATTERN.flags
166+
);
167+
let match = pattern.exec(url);
168+
while (match !== null) {
169+
params.push(match[1]);
170+
match = pattern.exec(url);
171+
}
172+
return params;
173+
}
174+
175+
// ---------------------------------------------------------------------------
176+
// Main
177+
// ---------------------------------------------------------------------------
178+
179+
const openApiUrl = getOpenApiUrl();
180+
console.log(`Fetching OpenAPI spec from ${openApiUrl}...`);
181+
const response = await fetch(openApiUrl);
182+
if (!response.ok) {
183+
throw new Error(
184+
`Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`
185+
);
186+
}
187+
const spec = (await response.json()) as OpenApiSpec;
188+
189+
console.log("Building SDK function name map from @sentry/api...");
190+
const sdkMap = await buildSdkFunctionMap();
191+
192+
const endpoints: ApiEndpoint[] = [];
193+
const HTTP_METHODS = ["get", "post", "put", "delete", "patch"];
194+
195+
for (const [urlPath, pathItem] of Object.entries(spec.paths)) {
196+
for (const method of HTTP_METHODS) {
197+
const operation = pathItem[method] as OpenApiOperation | undefined;
198+
if (!operation) {
199+
continue;
200+
}
201+
202+
const methodUpper = method.toUpperCase();
203+
const sdkKey = `${methodUpper}:${urlPath}`;
204+
const fn = sdkMap.get(sdkKey) ?? "";
205+
206+
const queryParams = (operation.parameters ?? [])
207+
.filter((p) => p.in === "query")
208+
.map((p) => p.name);
209+
210+
endpoints.push({
211+
fn,
212+
method: methodUpper,
213+
path: urlPath,
214+
description: (operation.description ?? "").trim(),
215+
pathParams: extractPathParams(urlPath),
216+
queryParams,
217+
deprecated: operation.deprecated ?? false,
218+
resource: deriveResource(urlPath),
219+
operationId: operation.operationId ?? "",
220+
});
221+
}
222+
}
223+
224+
// Sort by resource, then method for stable output
225+
endpoints.sort((a, b) => {
226+
const resourceCmp = a.resource.localeCompare(b.resource);
227+
if (resourceCmp !== 0) {
228+
return resourceCmp;
229+
}
230+
return a.operationId.localeCompare(b.operationId);
231+
});
232+
233+
await Bun.write(OUTPUT_PATH, JSON.stringify(endpoints, null, 2));
234+
235+
console.log(
236+
`Generated ${OUTPUT_PATH} (${endpoints.length} endpoints, ${Math.round(JSON.stringify(endpoints).length / 1024)}KB)`
237+
);

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)