diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md index 3ef5f9452..f0ebf6ccb 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -197,7 +197,7 @@ Edit an issue alert rule | `triggers` | object \| null | Conditions on which the alert triggers | | `actionFilters` | array \| null | Filters to run before actions fire, each with the actions to fire | | `environment` | string \| null | Name of the environment the alert is limited to. Null for all environments | -| `config` | unknown | Configuration of the alert, such as frequency in minutes | +| `config` | object | Configuration of the alert, such as frequency in minutes | | `detectorIds` | array \| null | IDs of the monitors connected to the alert | | `enabled` | boolean | Whether the alert is enabled | | `lastTriggered` | string \| null | When the alert last fired (ISO 8601) | @@ -402,7 +402,7 @@ Edit a metric alert rule | `dateUpdated` | string | When the monitor was last updated (ISO 8601) | | `dataSources` | array \| null | Data sources the monitor evaluates, such as a query subscription | | `conditionGroup` | object \| null | Conditions that decide the monitor's status | -| `config` | object \| unknown | | +| `config` | object | | | `enabled` | boolean | Whether the monitor is enabled | | `org` | string | Organization slug | | `status` | string | Rule status (active, disabled) | diff --git a/packages/cli/script/lib/output-schema-fields.ts b/packages/cli/script/lib/output-schema-fields.ts index c132f4761..4c0d920aa 100644 --- a/packages/cli/script/lib/output-schema-fields.ts +++ b/packages/cli/script/lib/output-schema-fields.ts @@ -53,6 +53,7 @@ const VALIBOT_TYPE_MAP: Record = { boolean: "boolean", object: "object", loose_object: "object", + strict_object: "object", array: "array", record: "object", null: "null", diff --git a/packages/cli/src/lib/api/issue-app-links.ts b/packages/cli/src/lib/api/issue-app-links.ts index 6d06bda8f..924cf5173 100644 --- a/packages/cli/src/lib/api/issue-app-links.ts +++ b/packages/cli/src/lib/api/issue-app-links.ts @@ -22,6 +22,7 @@ import { array, boolean, type InferOutput, + looseObject, nullish, number, object, @@ -31,6 +32,7 @@ import { string, tuple, union, + unknown, } from "valibot"; import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; @@ -73,6 +75,21 @@ type Choice = InferOutput; type Field = InferOutput; type LinkForm = InferOutput; +/** + * Sentry App components, with each app's `schema` left unchecked. + * + * `@sentry/api` types `schema` as the UI element union Sentry checks apps + * against when they are saved. One installed app whose stored schema doesn't + * match it would fail the whole page and hide every other app's form, so only + * the selected app's link form is checked, against {@link LinkFormSchema}. + */ +const ComponentsSchema = array( + object({ + ...vListOrganizationSentryAppComponentsResponse.item.entries, + schema: looseObject({ link: optional(unknown()) }), + }), +); + /** Inputs for a read-only preflight of the app's existing-issue link action. */ export type ResolveAppIssueLinkOptions = { /** Organization containing the Sentry issue and app installation. */ @@ -296,7 +313,7 @@ async function getLinkForm( }); return unwrapPaginatedResult(result, "Failed to list app components"); }, - vListOrganizationSentryAppComponentsResponse, + ComponentsSchema, "listing Sentry App components", ); const matches = components.filter( @@ -318,8 +335,8 @@ async function getLinkForm( JSON.stringify(component.error), ); } - // The component schema is typed per component type, but its form fields - // are app-defined, so validate the link form here. + // ComponentsSchema leaves each app's schema unchecked, so validate only + // this app's link form here. const { schema } = component; const form = safeParse( LinkFormSchema, diff --git a/packages/cli/src/types/sentry.ts b/packages/cli/src/types/sentry.ts index 504cf60f5..6c6497a9f 100644 --- a/packages/cli/src/types/sentry.ts +++ b/packages/cli/src/types/sentry.ts @@ -388,8 +388,27 @@ export type ReplayContext = { [key: string]: unknown; }; -/** High-level metadata returned by the organization trace-meta endpoint. */ -export const TraceMetaSchema = vOrganizationTraceMetaResponse; +const sdkTraceMeta = vOrganizationTraceMetaResponse.entries; +const sdkTransactionChildCount = + sdkTraceMeta.transactionChildCountMap.item.entries; + +/** + * High-level metadata returned by the organization trace-meta endpoint. + * + * Built from the `@sentry/api` schema. The spec says `transaction.event_id` is + * a string, but spans that belong to no transaction are counted under null. + */ +export const TraceMetaSchema = object({ + ...sdkTraceMeta, + transactionChildCountMap: array( + object({ + ...sdkTransactionChildCount, + "transaction.event_id": nullable( + sdkTransactionChildCount["transaction.event_id"], + ), + }), + ), +}); export type TraceMeta = InferOutput; diff --git a/packages/cli/test/lib/api/issue-app-links.test.ts b/packages/cli/test/lib/api/issue-app-links.test.ts index cd1d21dbc..7c2186135 100644 --- a/packages/cli/test/lib/api/issue-app-links.test.ts +++ b/packages/cli/test/lib/api/issue-app-links.test.ts @@ -69,6 +69,7 @@ let calls: Request[]; let links: AppIssueLink[]; let choices: [string, string][]; let form: unknown; +let otherComponents: unknown[]; let installation: typeof INSTALLATION; let actionStatus: number; let actionLink: AppIssueLink; @@ -86,6 +87,7 @@ beforeEach(async () => { links = []; choices = [["linear-uuid", "ENG-42: Fix the crash"]]; form = FORM; + otherComponents = []; installation = INSTALLATION; actionStatus = 201; actionLink = LINK; @@ -102,6 +104,7 @@ beforeEach(async () => { if (path.endsWith("/sentry-app-components/")) { return json([ { ...COMPONENT, schema: { ...COMPONENT.schema, link: form } }, + ...otherComponents, ]); } if (path.endsWith("/external-requests/")) { @@ -416,6 +419,25 @@ describe("app issue-link action", () => { expect(writes()).toHaveLength(0); }); + test("ignores another app's component schema that the API spec doesn't describe", async () => { + otherComponents = [ + { + ...COMPONENT, + uuid: "5e0b7d2c-8f4a-4c1e-a3b6-9d2f7e1c0a48", + sentryApp: { + uuid: "6b1f8e3d-2c5a-4d7b-9e0f-3a8c1d6b2e74", + slug: "legacy-tracker", + name: "Legacy Tracker", + avatars: [], + }, + // No `create` form, and a link URI without the leading slash. + schema: { type: "issue-link", link: { uri: "link" } }, + }, + ]; + const prepared = await resolveAppIssueLink(OPTIONS); + expect(prepared.fields).toEqual({ issueId: "linear-uuid" }); + }); + test("does not allow user fields to override the action URI or target", async () => { await expect( resolveAppIssueLink({ ...OPTIONS, fields: { uri: "/create" } }), diff --git a/packages/cli/test/lib/api/traces.test.ts b/packages/cli/test/lib/api/traces.test.ts index 5636735bb..89b0c7463 100644 --- a/packages/cli/test/lib/api/traces.test.ts +++ b/packages/cli/test/lib/api/traces.test.ts @@ -1,5 +1,6 @@ /** - * Tests for the traces API helpers (getDetailedTrace, listTransactions, listSpans). + * Tests for the traces API helpers (getDetailedTrace, getTraceMeta, + * listTransactions, listSpans). * * Verifies URL construction, query parameter encoding, schema validation, * pagination cursor extraction, auto-pagination, and the 14d→90d empty-trace retry. @@ -10,6 +11,7 @@ import { fetchMultiSpanDetails, getDetailedTrace, getSpanDetails, + getTraceMeta, listSpans, listTransactions, } from "../../../src/lib/api/traces.js"; @@ -875,3 +877,50 @@ describe("getDetailedTrace", () => { } }); }); + +// --------------------------------------------------------------------------- +// getTraceMeta +// --------------------------------------------------------------------------- + +describe("getTraceMeta", () => { + useTestConfigDir("traces-meta-test-"); + + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("accepts spans counted under a null transaction.event_id", async () => { + const transactionChildCountMap = [ + { + "transaction.event_id": "0daf40dc453a429c8c57e4c215c4e82c", + "count()": 10, + }, + { "transaction.event_id": null, "count()": 15 }, + ]; + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify({ + errorsCount: 2, + logsCount: 3, + metricsCount: 0, + performanceIssuesCount: 1, + spansCount: 25, + transactionChildCountMap, + spansCountMap: { "http.client": 25 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const meta = await getTraceMeta("my-org", "abc123def456"); + + expect(meta.transactionChildCountMap).toEqual(transactionChildCountMap); + }); +}); diff --git a/packages/cli/test/script/output-schema-fields.test.ts b/packages/cli/test/script/output-schema-fields.test.ts index 87a76b20e..6727ec7b9 100644 --- a/packages/cli/test/script/output-schema-fields.test.ts +++ b/packages/cli/test/script/output-schema-fields.test.ts @@ -5,7 +5,11 @@ import { object, optional, pipe, + record, + strictObject, string, + union, + unknown, } from "valibot"; import { describe, expect, test } from "vitest"; import { @@ -34,6 +38,20 @@ describe("extractSchemaFields", () => { }); }); + test("reports strict objects as objects", () => { + // `@sentry/api` 1.52.0 emits `strictObject` for objects whose spec sets + // `additionalProperties: false`, often as one member of a union. + const [field] = extractSchemaFields( + object({ + config: union([ + record(string(), unknown()), + strictObject({ frequency: optional(string()) }), + ]), + }), + ); + expect(field?.type).toBe("object"); + }); + test("prefers a description the CLI adds over the API's", () => { const [field] = extractSchemaFields( object({ formatted: pipe(apiField, description("CLI description")) }),