Skip to content

Commit 263382d

Browse files
fix(cli): Record why Sentry refused a request on 401/403 errors (#2163)
Split out of #2160. 401/403s from the CI commands (`release *`, `sourcemap upload`) are silenced as user errors, and the detail on their spans is mostly scrubbed to `[Filtered]`, so we can't tell them apart. Over 14 days that was about 217k failures from about 1.5k orgs. This PR records a fixed reason category for each one. The CLI's own output doesn't change. ### Before ``` # cli.command span api_error.status: 401 api_error.message: Failed to create release: 401 Unauthorized api_error.detail: [Filtered] # cli.error.silenced metric error_class: ApiError reason: api_user_error api_status: 401 # cli.api_error_silenced log status: 401 endpoint: … detail: [Filtered] ``` ### After ``` # cli.command span api_error.status: 401 api_error.message: Failed to create release: 401 Unauthorized api_error.denial_reason: unrecognized # cli.error.silenced metric error_class: ApiError reason: api_user_error api_status: 401 api_denial_reason: unrecognized # cli.api_error_silenced log status: 401 endpoint: … denial_reason: unrecognized ``` The values are `insufficient_scope`, `access_denied`, `expired`, `unrecognized`, `member_policy` and `seat_limit`; #2168 adds `org_mismatch`. On 401/403s, `denial_reason` replaces `detail`. The values avoid words such as "auth" and "token", because scrubbing turns values containing them into `[Filtered]`. <!-- junior-request-attribution:start --> via @betegon. <!-- junior-request-attribution:end --> <!-- junior-session-footer:start --> <!-- junior-conversation-id:agent%3A28e783cd48c13b214d69caf6d8977a0c --> -- [View Session in Sentry](https://sentry.sentry.io/explore/conversations/agent%3A28e783cd48c13b214d69caf6d8977a0c/?project=4510944073809921) <!-- junior-session-footer:end --> --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: bete <28983162+betegon@users.noreply.github.com>
1 parent 07e83a7 commit 263382d

4 files changed

Lines changed: 102 additions & 31 deletions

File tree

‎packages/cli/src/lib/api/infrastructure.ts‎

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import { getConfiguredSentryUrl } from "../constants.js";
3838
import { getEnv } from "../env.js";
3939
import {
40+
type ApiDenialReason,
4041
ApiError,
4142
CliError,
4243
type FunctionArgument,
@@ -53,6 +54,12 @@ import {
5354
import { getSdkClientConfig } from "../sentry-sdk-client.js";
5455
import { isSentrySaasUrl } from "../sentry-urls.js";
5556

57+
/** User-facing detail for a 401/403, and the reason to record for it. */
58+
type EnrichedDetail = {
59+
detail: string;
60+
denialReason: ApiDenialReason;
61+
};
62+
5663
/**
5764
* Enrich a 403 Forbidden error detail with actionable guidance.
5865
*
@@ -66,15 +73,18 @@ import { isSentrySaasUrl } from "../sentry-urls.js";
6673
* - env-var tokens → suggest checking token scopes
6774
* - OAuth tokens → suggest re-authentication
6875
*/
69-
function enrich403Detail(rawDetail: string | undefined): string {
76+
function enrich403Detail(rawDetail: string | undefined): EnrichedDetail {
7077
// Org-level policy — re-auth and token scope advice do not apply here.
7178
if (rawDetail?.includes("disabled this feature")) {
72-
return [
73-
rawDetail,
74-
"",
75-
"This is an org-level policy setting, not an auth issue.",
76-
"You need org:admin/manager/owner role, or team:admin role on the team.",
77-
].join("\n ");
79+
return {
80+
detail: [
81+
rawDetail,
82+
"",
83+
"This is an org-level policy setting, not an auth issue.",
84+
"You need org:admin/manager/owner role, or team:admin role on the team.",
85+
].join("\n "),
86+
denialReason: "member_policy",
87+
};
7888
}
7989

8090
const lines: string[] = [];
@@ -109,7 +119,10 @@ function enrich403Detail(rawDetail: string | undefined): string {
109119
`Re-authenticate with: ${sentry`auth login`}`,
110120
);
111121
}
112-
return lines.join("\n ");
122+
return {
123+
detail: lines.join("\n "),
124+
denialReason: scopes.length > 0 ? "insufficient_scope" : "access_denied",
125+
};
113126
}
114127

115128
/**
@@ -131,25 +144,28 @@ function enrich403Detail(rawDetail: string | undefined): string {
131144
*
132145
* @see https://github.com/getsentry/sentry/blob/934f1473f198a62f9268d7140b80cd9ca1e59bb9/src/sentry/api/authentication.py#L536-L539
133146
*/
134-
function enrich401Detail(rawDetail: string | undefined): string {
147+
function enrich401Detail(rawDetail: string | undefined): EnrichedDetail {
135148
// Seat-limit lockout, not an auth failure. Sentry returns 401 with
136149
// `code: member-disabled-over-limit` when the org is over its member limit
137150
// and the caller's seat is disabled — re-authenticating cannot fix this.
138151
if (rawDetail?.includes("member-disabled-over-limit")) {
139-
return [
140-
"Your account is disabled in this organization because it is over its member limit.",
141-
"This is a billing/seat-limit issue, not an auth problem — re-authenticating won't help.",
142-
"Ask an org owner to upgrade the plan or free up a seat, then retry.",
143-
`Or target a different org, e.g.: ${sentry`issue list my-other-org/`}`,
144-
].join("\n ");
152+
return {
153+
detail: [
154+
"Your account is disabled in this organization because it is over its member limit.",
155+
"This is a billing/seat-limit issue, not an auth problem — re-authenticating won't help.",
156+
"Ask an org owner to upgrade the plan or free up a seat, then retry.",
157+
`Or target a different org, e.g.: ${sentry`issue list my-other-org/`}`,
158+
].join("\n "),
159+
denialReason: "seat_limit",
160+
};
145161
}
146162

147163
const lines: string[] = [];
148164
if (rawDetail) {
149165
lines.push(rawDetail, "");
150166
}
167+
const expired = rawDetail?.toLowerCase().includes("expired") ?? false;
151168
if (isEnvTokenActive()) {
152-
const expired = rawDetail?.toLowerCase().includes("expired");
153169
lines.push(
154170
`Your ${getActiveEnvVarName()} token ${expired ? "has expired" : "is not recognized or has been revoked"}.`,
155171
"Create a new token at: https://sentry.io/settings/account/api/auth-tokens/",
@@ -160,15 +176,19 @@ function enrich401Detail(rawDetail: string | undefined): string {
160176
`Re-authenticate with: ${sentry`auth login`}`,
161177
);
162178
}
163-
return lines.join("\n ");
179+
return {
180+
detail: lines.join("\n "),
181+
denialReason: expired ? "expired" : "unrecognized",
182+
};
164183
}
165184

166185
/**
167186
* Select and apply status-specific detail enrichment.
168187
*
169188
* Extracted from {@link throwApiError} and {@link throwRawApiError} to keep
170189
* their cognitive complexity within the linter limit. 403 and 401 get
171-
* actionable guidance; all other statuses pass the raw detail through.
190+
* actionable guidance and a {@link ApiDenialReason}; all other statuses pass
191+
* the raw detail through.
172192
*
173193
* `hasUsableDetail` controls whether the raw detail string is forwarded to
174194
* the enrichment functions — passing `undefined` when false lets them render
@@ -178,14 +198,14 @@ function enrichDetail(
178198
status: number,
179199
detail: string | undefined,
180200
hasUsableDetail: boolean,
181-
): string | undefined {
201+
): { detail: string | undefined; denialReason?: ApiDenialReason } {
182202
if (status === 403) {
183203
return enrich403Detail(hasUsableDetail ? detail : undefined);
184204
}
185205
if (status === 401) {
186206
return enrich401Detail(hasUsableDetail ? detail : undefined);
187207
}
188-
return detail;
208+
return { detail };
189209
}
190210

191211
/**
@@ -377,13 +397,17 @@ export function throwApiError(
377397
? stringifyUnknown(rawDetail)
378398
: stringifyUnknown(error);
379399

380-
const is403 = status === 403;
400+
const enriched = enrichDetail(status, detail, hasUsableDetail);
381401
throw new ApiError(
382402
`${context}: ${status} ${response.statusText ?? "Unknown"}`,
383403
status,
384-
enrichDetail(status, detail, hasUsableDetail),
404+
enriched.detail,
385405
undefined,
386-
{ enriched403: is403, ...readErrorFields(error) },
406+
{
407+
enriched403: status === 403,
408+
denialReason: enriched.denialReason,
409+
...readErrorFields(error),
410+
},
387411
);
388412
}
389413

@@ -918,13 +942,17 @@ export async function throwRawApiError(
918942
"x-sentry-error": response.headers.get("x-sentry-error"),
919943
"www-authenticate": response.headers.get("www-authenticate"),
920944
});
921-
const is403 = response.status === 403;
945+
const enriched = enrichDetail(response.status, detail, detail !== undefined);
922946
throw new ApiError(
923947
`${context}: ${response.status} ${response.statusText}`,
924948
response.status,
925-
enrichDetail(response.status, detail, detail !== undefined),
949+
enriched.detail,
926950
endpoint,
927-
{ enriched403: is403, ...readErrorFields(body.json) },
951+
{
952+
enriched403: response.status === 403,
953+
denialReason: enriched.denialReason,
954+
...readErrorFields(body.json),
955+
},
928956
);
929957
}
930958

‎packages/cli/src/lib/error-reporting.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@ function recordSilencedError(error: unknown, reason: SilenceReason): void {
215215
};
216216
if (error instanceof ApiError) {
217217
attributes.api_status = error.status;
218+
if (error.denialReason) {
219+
attributes.api_denial_reason = error.denialReason;
220+
}
218221
}
219222
if (error instanceof AuthError) {
220223
attributes.auth_reason = error.reason;
@@ -234,14 +237,17 @@ function recordSilencedError(error: unknown, reason: SilenceReason): void {
234237
}
235238

236239
// Structured log for user API errors — `detail` is often the most actionable
237-
// field and is searchable in Sentry Logs.
240+
// field and is searchable in Sentry Logs. A 401/403 logs its denial reason
241+
// instead: its detail is the CLI's own guidance, not the server's.
238242
if (reason === "api_user_error" && error instanceof ApiError) {
239243
// oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
240244
try {
241245
Sentry.logger.info("cli.api_error_silenced", {
242246
status: error.status,
243247
endpoint: error.endpoint,
244-
detail: error.detail,
248+
...(error.denialReason
249+
? { denial_reason: error.denialReason }
250+
: { detail: error.detail }),
245251
});
246252
} catch {
247253
// Logger may not be initialized — ignore.

‎packages/cli/src/lib/errors.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,30 @@ export type FunctionArgument = {
170170
hint?: string;
171171
};
172172

173+
/**
174+
* Why Sentry refused a request with a 401 or 403, as far as the CLI can tell.
175+
*
176+
* Recorded on spans and the `cli.error.silenced` metric, so each value is a
177+
* fixed category: never a slug, token, URL, or text from the server. Avoid
178+
* words such as "auth" and "token" in new values; Sentry's data scrubbing
179+
* replaces values that contain them with `[Filtered]`.
180+
*
181+
* - `insufficient_scope`: the token lacks every scope the endpoint accepts
182+
* - `member_policy`: an org setting blocks members from the action
183+
* - `seat_limit`: the member is disabled because the org is over its limit
184+
* - `access_denied`: a 403 that names no missing scope
185+
* - `expired`: Sentry says the credential expired
186+
* - `unrecognized`: Sentry does not know the credential (revoked, mistyped,
187+
* or created on another server)
188+
*/
189+
export type ApiDenialReason =
190+
| "insufficient_scope"
191+
| "member_policy"
192+
| "seat_limit"
193+
| "access_denied"
194+
| "expired"
195+
| "unrecognized";
196+
173197
/**
174198
* API request errors from Sentry.
175199
*
@@ -204,6 +228,9 @@ export class ApiError extends CliError {
204228
*/
205229
readonly enriched403: boolean;
206230

231+
/** Set with the 401/403 enrichment in `infrastructure.ts`. */
232+
readonly denialReason?: ApiDenialReason;
233+
207234
constructor(
208235
message: string,
209236
status: number,
@@ -213,10 +240,12 @@ export class ApiError extends CliError {
213240
enriched403 = false,
214241
code,
215242
functionArgument,
243+
denialReason,
216244
}: {
217245
enriched403?: boolean;
218246
code?: string;
219247
functionArgument?: FunctionArgument;
248+
denialReason?: ApiDenialReason;
220249
} = {},
221250
) {
222251
super(message, EXIT.API);
@@ -227,6 +256,7 @@ export class ApiError extends CliError {
227256
this.enriched403 = enriched403;
228257
this.code = code;
229258
this.functionArgument = functionArgument;
259+
this.denialReason = denialReason;
230260
}
231261

232262
override format(): string {

‎packages/cli/src/lib/telemetry.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -436,15 +436,22 @@ export function isUserApiError(error: unknown): boolean {
436436
/**
437437
* Record a client API error as span attributes for Discover queryability.
438438
*
439-
* Sets `api_error.status`, `api_error.message`, and optionally `api_error.detail`
440-
* on the span. Must be called before `span.end()`.
439+
* Sets `api_error.status`, `api_error.message`, and either
440+
* `api_error.denial_reason` (401/403) or `api_error.detail` on the span. Must
441+
* be called before `span.end()`.
442+
*
443+
* A 401/403 detail is the CLI's own guidance rather than the server's, and
444+
* Sentry's data scrubbing replaces most of them with `[Filtered]`. The denial
445+
* reason is a fixed category that tells those failures apart instead.
441446
*
442447
* @internal Exported for testing
443448
*/
444449
export function recordApiErrorOnSpan(span: Span, error: ApiError): void {
445450
span.setAttribute("api_error.status", error.status);
446451
span.setAttribute("api_error.message", error.message);
447-
if (error.detail) {
452+
if (error.denialReason) {
453+
span.setAttribute("api_error.denial_reason", error.denialReason);
454+
} else if (error.detail) {
448455
span.setAttribute("api_error.detail", error.detail);
449456
}
450457
}

0 commit comments

Comments
 (0)