Skip to content

Commit e170d8b

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 9ed32c7 commit e170d8b

File tree

11 files changed

+930
-49
lines changed

11 files changed

+930
-49
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ jobs:
110110
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
111111
- if: steps.cache.outputs.cache-hit != 'true'
112112
run: bun install --frozen-lockfile
113+
- name: Generate API Schema
114+
run: bun run generate:schema
113115
- name: Check SKILL.md
114116
id: check
115117
run: bun run check:skill
@@ -143,6 +145,7 @@ jobs:
143145
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
144146
- if: steps.cache.outputs.cache-hit != 'true'
145147
run: bun install --frozen-lockfile
148+
- run: bun run generate:schema
146149
- run: bun run lint
147150
- run: bun run typecheck
148151
- run: bun run check:deps
@@ -167,6 +170,8 @@ jobs:
167170
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
168171
- if: steps.cache.outputs.cache-hit != 'true'
169172
run: bun install --frozen-lockfile
173+
- name: Generate API Schema
174+
run: bun run generate:schema
170175
- name: Unit Tests
171176
run: bun run test:unit
172177
- name: Isolated Tests
@@ -412,6 +417,8 @@ jobs:
412417
path: dist-bin
413418
- name: Make binary executable
414419
run: chmod +x dist-bin/sentry-linux-x64
420+
- name: Generate API Schema
421+
run: bun run generate:schema
415422
- name: E2E Tests
416423
env:
417424
SENTRY_CLI_BINARY: ${{ github.workspace }}/dist-bin/sentry-linux-x64

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,8 @@ docs/.astro
5353
# Finder (MacOS) folder config
5454
.DS_Store
5555

56+
# Generated files (rebuilt at build time)
57+
src/generated/
58+
5659
# OpenCode
5760
.opencode/

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: 6 additions & 5 deletions
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",
@@ -59,10 +59,10 @@
5959
"@stricli/core@1.2.5": "patches/@stricli%2Fcore@1.2.5.patch"
6060
},
6161
"scripts": {
62-
"dev": "bun run src/bin.ts",
63-
"build": "bun run script/build.ts --single",
64-
"build:all": "bun run script/build.ts",
65-
"bundle": "bun run script/bundle.ts",
62+
"dev": "bun run generate:schema && bun run src/bin.ts",
63+
"build": "bun run generate:schema && bun run script/build.ts --single",
64+
"build:all": "bun run generate:schema && bun run script/build.ts",
65+
"bundle": "bun run generate:schema && bun run script/bundle.ts",
6666
"typecheck": "tsc --noEmit",
6767
"lint": "bunx ultracite check",
6868
"lint:fix": "bunx ultracite fix",
@@ -72,6 +72,7 @@
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",
7677
"check:deps": "bun run script/check-no-deps.ts"
7778
},

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
## Global Options
741755

742756
All commands support the following global options:

script/generate-api-schema.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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+
// Single source of truth for the ApiEndpoint type lives in src/lib/api-schema.ts.
40+
// Re-export so this file remains a module (required for top-level await).
41+
export type { ApiEndpoint } from "../src/lib/api-schema.js";
42+
43+
import type { ApiEndpoint } from "../src/lib/api-schema.js";
44+
45+
// ---------------------------------------------------------------------------
46+
// OpenAPI Types (minimal subset we need)
47+
// ---------------------------------------------------------------------------
48+
49+
type OpenApiSpec = {
50+
paths: Record<string, Record<string, OpenApiOperation>>;
51+
};
52+
53+
type OpenApiOperation = {
54+
operationId?: string;
55+
description?: string;
56+
deprecated?: boolean;
57+
parameters?: OpenApiParameter[];
58+
};
59+
60+
type OpenApiParameter = {
61+
in: "path" | "query" | "header" | "cookie";
62+
name: string;
63+
required?: boolean;
64+
description?: string;
65+
schema?: { type?: string };
66+
};
67+
68+
// ---------------------------------------------------------------------------
69+
// SDK Function Name Mapping
70+
// ---------------------------------------------------------------------------
71+
72+
/**
73+
* Build a map from URL+method to SDK function name by parsing
74+
* the @sentry/api index.js bundle.
75+
*/
76+
async function buildSdkFunctionMap(): Promise<Map<string, string>> {
77+
const pkgDir = resolve(
78+
require.resolve("@sentry/api/package.json"),
79+
"..",
80+
"dist"
81+
);
82+
const js = await Bun.file(`${pkgDir}/index.js`).text();
83+
const results = new Map<string, string>();
84+
85+
// Match: var NAME = (options...) => (options...client ?? client).METHOD({
86+
const funcPattern =
87+
/var (\w+) = \(options\S*\) => \(options\S*client \?\? client\)\.(\w+)\(/g;
88+
// Match: url: "..."
89+
const urlPattern = /url: "([^"]+)"/g;
90+
91+
// Extract all function declarations with their positions
92+
const funcs: { name: string; method: string; index: number }[] = [];
93+
let match = funcPattern.exec(js);
94+
while (match !== null) {
95+
funcs.push({
96+
name: match[1],
97+
method: match[2].toUpperCase(),
98+
index: match.index,
99+
});
100+
match = funcPattern.exec(js);
101+
}
102+
103+
// Extract all URLs with their positions
104+
const urls: { url: string; index: number }[] = [];
105+
match = urlPattern.exec(js);
106+
while (match !== null) {
107+
urls.push({ url: match[1], index: match.index });
108+
match = urlPattern.exec(js);
109+
}
110+
111+
// Match each function to its nearest following URL
112+
for (const func of funcs) {
113+
const nextUrl = urls.find((u) => u.index > func.index);
114+
if (nextUrl) {
115+
const key = `${func.method}:${nextUrl.url}`;
116+
results.set(key, func.name);
117+
}
118+
}
119+
120+
return results;
121+
}
122+
123+
// ---------------------------------------------------------------------------
124+
// Resource Derivation
125+
// ---------------------------------------------------------------------------
126+
127+
/**
128+
* Derive the resource name from a URL template.
129+
* Uses the last non-parameter path segment.
130+
*
131+
* @example "/api/0/organizations/{org}/issues/" → "issues"
132+
* @example "/api/0/issues/{issue_id}/" → "issues"
133+
*/
134+
function deriveResource(url: string): string {
135+
const segments = url
136+
.split("/")
137+
.filter((s) => s.length > 0 && !s.startsWith("{"));
138+
const meaningful = segments.filter((s) => s !== "api" && s !== "0");
139+
return meaningful.at(-1) ?? "unknown";
140+
}
141+
142+
/**
143+
* Extract path parameter names from a URL template.
144+
*/
145+
function extractPathParams(url: string): string[] {
146+
const params: string[] = [];
147+
const pattern = new RegExp(
148+
PATH_PARAM_PATTERN.source,
149+
PATH_PARAM_PATTERN.flags
150+
);
151+
let match = pattern.exec(url);
152+
while (match !== null) {
153+
params.push(match[1]);
154+
match = pattern.exec(url);
155+
}
156+
return params;
157+
}
158+
159+
// ---------------------------------------------------------------------------
160+
// Main
161+
// ---------------------------------------------------------------------------
162+
163+
const openApiUrl = getOpenApiUrl();
164+
console.log(`Fetching OpenAPI spec from ${openApiUrl}...`);
165+
const response = await fetch(openApiUrl);
166+
if (!response.ok) {
167+
throw new Error(
168+
`Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`
169+
);
170+
}
171+
const spec = (await response.json()) as OpenApiSpec;
172+
173+
console.log("Building SDK function name map from @sentry/api...");
174+
const sdkMap = await buildSdkFunctionMap();
175+
176+
const endpoints: ApiEndpoint[] = [];
177+
const HTTP_METHODS = ["get", "post", "put", "delete", "patch"];
178+
179+
for (const [urlPath, pathItem] of Object.entries(spec.paths)) {
180+
for (const method of HTTP_METHODS) {
181+
const operation = pathItem[method] as OpenApiOperation | undefined;
182+
if (!operation) {
183+
continue;
184+
}
185+
186+
const methodUpper = method.toUpperCase();
187+
const sdkKey = `${methodUpper}:${urlPath}`;
188+
const fn = sdkMap.get(sdkKey) ?? "";
189+
190+
const queryParams = (operation.parameters ?? [])
191+
.filter((p) => p.in === "query")
192+
.map((p) => p.name);
193+
194+
endpoints.push({
195+
fn,
196+
method: methodUpper,
197+
path: urlPath,
198+
description: (operation.description ?? "").trim(),
199+
pathParams: extractPathParams(urlPath),
200+
queryParams,
201+
deprecated:
202+
operation.deprecated === true ||
203+
fn.startsWith("deprecated") ||
204+
(operation.operationId ?? "").toLowerCase().includes("deprecated"),
205+
resource: deriveResource(urlPath),
206+
operationId: operation.operationId ?? "",
207+
});
208+
}
209+
}
210+
211+
// Sort by resource, then method for stable output
212+
endpoints.sort((a, b) => {
213+
const resourceCmp = a.resource.localeCompare(b.resource);
214+
if (resourceCmp !== 0) {
215+
return resourceCmp;
216+
}
217+
return a.operationId.localeCompare(b.operationId);
218+
});
219+
220+
await Bun.write(OUTPUT_PATH, JSON.stringify(endpoints, null, 2));
221+
222+
console.log(
223+
`Generated ${OUTPUT_PATH} (${endpoints.length} endpoints, ${Math.round(JSON.stringify(endpoints).length / 1024)}KB)`
224+
);

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)