Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/cli-docs/src/fragments/commands/schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Search the bundled API schema using product concepts or CLI command paths.
Discovery includes summaries, paths, tags, and query parameter values: `logs`
can find Explore's `dataset=logs` as well as feature-flag audit logs. Results
explain their matches and show related CLI commands when a relationship is
declared. A related API does not necessarily reproduce the command's filters,
pagination, or transformations.

Use an exact operation ID, SDK function name, or HTTP method and path for a
precise lookup. Concept searches return all matching candidates; deprecated
operations are marked and sorted after active ones. No endpoint is chosen just
because it appears first in the catalog.

With `--json`, discovery returns an array of endpoints with additive `match`
metadata. Exact single-endpoint lookups remain endpoint objects, `--search`
always returns an array, and `--all` returns the flat catalog without match
metadata. The schema is bundled from the CLI's SDK version and works offline.
20 changes: 20 additions & 0 deletions packages/cli/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,26 @@ pnpm run build

## Architecture

### API discovery references

`sentry schema` reads the index generated by `pnpm run generate:schema` from
the installed `@sentry/api` version. New operations and query enum values become
searchable when this index is regenerated; do not add a separate vocabulary map.

To associate a CLI command with its API, add optional `docs.relatedApi` metadata
to its command builder. Reuse a descriptor exported by the API helper, such as
`LOGS_API` in `src/lib/api/logs.ts`. Each reference contains the exact generated
SDK function name (`fn`) and an optional `note` describing the dataset or filter
used. Share selector constants with the request where practical. A reference
documents related functionality, not an equivalent request.

Introspection derives command paths, registered aliases, and group membership
from the route tree. Groups collect their public commands' references, so adding
a command requires no separate group mapping. Documentation generation checks
that every reference matches exactly one operation in the generated catalog.

### Authentication

The CLI uses the OAuth 2.0 Device Authorization Grant ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) for authentication. This flow is designed for CLI tools and other devices that can't easily handle browser redirects:

1. CLI requests a device code from Sentry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Browse the Sentry API schema

| Argument | Description |
|----------|-------------|
| `<resource...>` | Resource, exact operation ID, or METHOD /path |
| `<resource...>` | API concept, CLI command path, exact operation ID, or METHOD /path |

**Flags:**
- `--all - Show all endpoints in a flat list`
Expand All @@ -34,9 +34,21 @@ sentry schema
# Browse issue endpoints
sentry schema issues

# View details for a specific operation
# Find list operations (there may be multiple candidates)
sentry schema issues list

# Find APIs used by log listing
sentry schema log list

# Find APIs used by trace listing, including command aliases
sentry schema trace ls

# Find APIs used by a command group
sentry schema alert metrics

# Find APIs used by a nested command
sentry schema alert metrics view

# Look up an endpoint by its exact operation ID
sentry schema listOrganizationEvents

Expand Down
113 changes: 2 additions & 111 deletions packages/cli/script/generate-api-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { extractApiEndpoints, type OpenApiSpec } from "./lib/api-schema.js";

/** Resolve the root directory of @sentry/api by finding its main entry point. */
function sentryApiDir(): string {
Expand All @@ -45,38 +46,6 @@ async function getOpenApiUrl(): Promise<string> {
return `https://raw.githubusercontent.com/getsentry/sentry-api-schema/${pkg.version}/openapi-derefed.json`;
}

/** Regex to extract path parameters from URL templates */
const PATH_PARAM_PATTERN = /\{(\w+)\}/g;

// Single source of truth for the ApiEndpoint type lives in src/lib/api-schema.ts.
// Re-export so this file remains a module (required for top-level await).
export type { ApiEndpoint } from "../src/lib/api-schema.js";

import type { ApiEndpoint } from "../src/lib/api-schema.js";

// ---------------------------------------------------------------------------
// OpenAPI Types (minimal subset we need)
// ---------------------------------------------------------------------------

type OpenApiSpec = {
paths: Record<string, Record<string, OpenApiOperation>>;
};

type OpenApiOperation = {
operationId?: string;
description?: string;
deprecated?: boolean;
parameters?: OpenApiParameter[];
};

type OpenApiParameter = {
in: "path" | "query" | "header" | "cookie";
name: string;
required?: boolean;
description?: string;
schema?: { type?: string };
};

// ---------------------------------------------------------------------------
// SDK Function Name Mapping
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -128,42 +97,6 @@ async function buildSdkFunctionMap(): Promise<Map<string, string>> {
return results;
}

// ---------------------------------------------------------------------------
// Resource Derivation
// ---------------------------------------------------------------------------

/**
* Derive the resource name from a URL template.
* Uses the last non-parameter path segment.
*
* @example "/api/0/organizations/{org}/issues/" → "issues"
* @example "/api/0/issues/{issue_id}/" → "issues"
*/
function deriveResource(url: string): string {
const segments = url
.split("/")
.filter((s) => s.length > 0 && !s.startsWith("{"));
const meaningful = segments.filter((s) => s !== "api" && s !== "0");
return meaningful.at(-1) ?? "unknown";
}

/**
* Extract path parameter names from a URL template.
*/
function extractPathParams(url: string): string[] {
const params: string[] = [];
const pattern = new RegExp(
PATH_PARAM_PATTERN.source,
PATH_PARAM_PATTERN.flags,
);
let match = pattern.exec(url);
while (match !== null) {
params.push(match[1]);
match = pattern.exec(url);
}
return params;
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
Expand All @@ -181,49 +114,7 @@ const spec = (await response.json()) as OpenApiSpec;
console.log("Building SDK function name map from @sentry/api...");
const sdkMap = await buildSdkFunctionMap();

const endpoints: ApiEndpoint[] = [];
const HTTP_METHODS = ["get", "post", "put", "delete", "patch"];

for (const [urlPath, pathItem] of Object.entries(spec.paths)) {
for (const method of HTTP_METHODS) {
const operation = pathItem[method] as OpenApiOperation | undefined;
if (!operation) {
continue;
}

const methodUpper = method.toUpperCase();
const sdkKey = `${methodUpper}:${urlPath}`;
const fn = sdkMap.get(sdkKey) ?? "";

const queryParams = (operation.parameters ?? [])
.filter((p) => p.in === "query")
.map((p) => p.name);

endpoints.push({
fn,
method: methodUpper,
path: urlPath,
description: (operation.description ?? "").trim(),
pathParams: extractPathParams(urlPath),
queryParams,
deprecated:
operation.deprecated === true ||
fn.startsWith("deprecated") ||
(operation.operationId ?? "").toLowerCase().includes("deprecated"),
resource: deriveResource(urlPath),
operationId: operation.operationId ?? "",
});
}
}

// Sort by resource, then method for stable output
endpoints.sort((a, b) => {
const resourceCmp = a.resource.localeCompare(b.resource);
if (resourceCmp !== 0) {
return resourceCmp;
}
return a.operationId.localeCompare(b.operationId);
});
const endpoints = extractApiEndpoints(spec, sdkMap);

await mkdir(dirname(OUTPUT_PATH), { recursive: true });
await writeFile(OUTPUT_PATH, JSON.stringify(endpoints, null, 2));
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/script/generate-command-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ import type {
} from "../src/lib/introspect.js";

const { routes } = await import("../src/app.js");
const { extractAllRoutes } = await import("../src/lib/introspect.js");
const { extractAllRoutes, validateCommandApiReferences } =
await import("../src/lib/introspect.js");
const { getAllEndpoints } = await import("../src/lib/api-schema.js");
const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js");

const DOCS_DIR = `${DOCS_CONTENT}/commands`;
Expand Down Expand Up @@ -386,6 +388,7 @@ async function readTopLevelFragment(fragmentName: string): Promise<string> {
// ---------------------------------------------------------------------------

const routeMap = routes as unknown as RouteMap;
validateCommandApiReferences(routeMap, getAllEndpoints());
const routeInfos = extractAllRoutes(routeMap).filter(
(r) => !SKIP_ROUTES.has(r.name),
);
Expand Down
139 changes: 139 additions & 0 deletions packages/cli/script/lib/api-schema.ts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think all this should be part of the @sentry/api module.

Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/** Extract the bundled discovery index from a dereferenced OpenAPI document. */

import type {
ApiEndpoint,
ApiQueryParameter,
} from "../../src/lib/api-schema.js";

const HTTP_METHODS = ["get", "post", "put", "delete", "patch"] as const;
const PATH_PARAM_PATTERN = /\{(\w+)\}/g;

/** Only the OpenAPI fields used by the discovery index. */
export type OpenApiSpec = {
paths: Record<string, OpenApiPathItem>;
};

type OpenApiPathItem = Partial<
Record<(typeof HTTP_METHODS)[number], OpenApiOperation>
> & {
parameters?: OpenApiParameter[];
};

type OpenApiOperation = {
operationId?: string;
summary?: string;
description?: string;
tags?: string[];
deprecated?: boolean;
parameters?: OpenApiParameter[];
};

type OpenApiParameter = {
in: "path" | "query" | "header" | "cookie";
name: string;
required?: boolean;
description?: string;
schema?: {
type?: string;
enum?: unknown[];
items?: { enum?: unknown[] };
};
};

/** Keep the existing resource grouping: the last literal URL segment. */
function deriveResource(url: string): string {
const segments = url
.split("/")
.filter((s) => s.length > 0 && !s.startsWith("{"));
const meaningful = segments.filter((s) => s !== "api" && s !== "0");
return meaningful.at(-1) ?? "unknown";
}

function isEnumValue(
value: unknown,
): value is string | number | boolean | null {
return (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
);
}

/** Operation parameters override inherited parameters with the same name/in. */
function extractQueryParameters(
pathItem: OpenApiPathItem,
operation: OpenApiOperation,
): ApiQueryParameter[] {
const parameters = new Map<string, OpenApiParameter>();
for (const parameter of [
...(pathItem.parameters ?? []),
...(operation.parameters ?? []),
]) {
if (parameter.in === "query") {
parameters.set(parameter.name, parameter);
}
}

return [...parameters.values()].map((parameter) => {
const schema = parameter.schema;
const values = schema?.type === "array" ? schema.items?.enum : schema?.enum;
return {
name: parameter.name,
description: parameter.description?.trim(),
type: schema?.type,
required: parameter.required === true,
enum: values?.filter(isEnumValue),
};
});
}

/**
* Generate entries directly from API metadata so new operations and enum values
* become discoverable on the next schema generation, without a vocabulary map.
*/
export function extractApiEndpoints(
spec: OpenApiSpec,
sdkMap: ReadonlyMap<string, string>,
): ApiEndpoint[] {
const endpoints: ApiEndpoint[] = [];

for (const [urlPath, pathItem] of Object.entries(spec.paths)) {
for (const method of HTTP_METHODS) {
const operation = pathItem[method];
if (!operation) {
continue;
}

const methodUpper = method.toUpperCase();
const fn = sdkMap.get(`${methodUpper}:${urlPath}`) ?? "";
const queryParameters = extractQueryParameters(pathItem, operation);

endpoints.push({
fn,
method: methodUpper,
path: urlPath,
summary: operation.summary?.trim(),
description: (operation.description ?? "").trim(),
tags: operation.tags,
pathParams: [...urlPath.matchAll(PATH_PARAM_PATTERN)].map((match) =>
match[0].slice(1, -1),
),
queryParams: queryParameters.map((parameter) => parameter.name),
queryParameters,
deprecated:
operation.deprecated === true ||
fn.startsWith("deprecated") ||
(operation.operationId ?? "").toLowerCase().includes("deprecated"),
resource: deriveResource(urlPath),
operationId: operation.operationId ?? "",
});
}
}

// Keep stable ordering and the existing resource/operation ID contract.
return endpoints.sort((a, b) => {
const resourceCmp = a.resource.localeCompare(b.resource);
return resourceCmp || a.operationId.localeCompare(b.operationId);
});
}
2 changes: 2 additions & 0 deletions packages/cli/src/commands/alert/metrics/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import type { SentryContext } from "../../../context.js";
import { METRIC_ALERT_API } from "../../../lib/api/alerts.js";
import { createMetricAlertRule } from "../../../lib/api-client.js";
import { buildCommand, numberParser } from "../../../lib/command.js";
import { stringifyUnknown, ValidationError } from "../../../lib/errors.js";
Expand Down Expand Up @@ -72,6 +73,7 @@ function resolveMetricCreateOrg(

export const createCommand = buildCommand({
docs: {
relatedApi: [METRIC_ALERT_API.create],
brief: "Create a metric alert rule",
fullDescription:
"Create a metric alert rule (a metric monitor in the detectors API).\n\n" +
Expand Down
Loading
Loading