-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy patherror-reporting.ts
More file actions
426 lines (398 loc) · 15 KB
/
Copy patherror-reporting.ts
File metadata and controls
426 lines (398 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
/**
* Central error reporting for CLI command failures.
*
* Provides two things:
*
* 1. **Silencing rules** — `OutputError`, network failures (offline/DNS/proxy),
* `ContextError` (a required value the user omitted), `ValidationError` (bad
* user input such as an ambiguous project slug), `AuthError` (expected auth
* states the user must act on), 401–499 `ApiError`, and 400 `ApiError`s that
* report an unparseable user search query are not sent to Sentry as issues.
* A `cli.error.silenced` metric preserves volume + user/org context.
*
* 2. **Grouping tags** — enriches every error event with `cli_error.*` tags
* that Sentry's server-side fingerprint rules use for stable grouping.
* The rules live in Settings → Issue Grouping → Fingerprint Rules and
* can be adjusted without a deploy.
*
* Fingerprint normalization is handled server-side, NOT in code. See the
* project's fingerprint rules for the active grouping policy.
*/
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node-core/light";
import {
ApiError,
AuthError,
CliError,
ContextError,
DeviceFlowError,
HostScopeError,
isNetworkError,
isSearchQueryParseError,
OutputError,
ResolutionError,
SeerError,
TimeoutError,
UpgradeError,
ValidationError,
WizardError,
} from "./errors.js";
// ---------------------------------------------------------------------------
// Silencing
// ---------------------------------------------------------------------------
/**
* Reasons an error may be silenced (not sent to Sentry as an issue).
* Exposed as the `reason` attribute on the `cli.error.silenced` metric.
*/
type SilenceReason =
| "output_error"
| "context_missing"
| "auth_expected"
| "api_user_error"
| "api_query_error"
| "network_error"
| "validation_error";
/**
* Classify whether an error should be silenced.
* Returns the reason string when silenced, `null` when it should be captured.
*
* @internal Exported for `telemetry.ts` (session-crash decision) and testing.
*/
export function classifySilenced(error: unknown): SilenceReason | null {
if (error instanceof OutputError) {
return "output_error";
}
// A raw `TypeError: "fetch failed"` (CLI-16W) means the CLI could not reach
// Sentry at all (offline, DNS, connection refused/timeout). There is nothing
// actionable in a "user is offline" report, so drop it — same rationale as
// EPIPE/EBADF OS noise in `beforeSend`. Note: TLS cert errors are wrapped as
// ApiError(status 0), NOT matched here, so they stay captured/actionable.
if (isNetworkError(error)) {
return "network_error";
}
// A ContextError always means the user omitted a required value (no
// org/project could be resolved, a required ID was not provided, etc.). It is
// never a CLI bug — unlike ResolutionError, where a *provided* value that
// can't be matched may signal a product/access issue worth observing. There
// is nothing per-instance to investigate, so silence the whole class; the
// `cli.error.silenced` metric (keyed by `error_class` + `resource`) preserves
// the volume and which value was missing. (CLI-3B: ~2000 users.)
if (error instanceof ContextError) {
return "context_missing";
}
// ValidationError always means the user supplied invalid input (e.g. an
// ambiguous project slug that matches multiple orgs). It is never a CLI
// bug — the error message is already actionable — so silence it here.
if (error instanceof ValidationError) {
return "validation_error";
}
// All AuthError reasons are expected auth states the user must act on, not
// CLI bugs: `not_authenticated` (no token), `expired` (token aged out), and
// `invalid` (a bad/insufficiently-scoped token the user supplied). `invalid`
// is now only thrown for a genuine 401/403 (see auth/login.ts) — transient
// network/server failures no longer masquerade as it — so it is safe to
// silence alongside the others (CLI-19).
if (error instanceof AuthError) {
return "auth_expected";
}
if (error instanceof ApiError && error.status > 400 && error.status < 500) {
return "api_user_error";
}
// A 400 normally signals a malformed request the CLI built (a code defect),
// so it is captured by default. The exception: when the server reports it
// could not parse the user's search query, the `--query` syntax is wrong
// (CLI-FA: ~450 users across issue/explore/trace list). That is a user input
// error, and the API already returns an actionable message, so silence it.
if (error instanceof ApiError && isSearchQueryParseError(error)) {
return "api_query_error";
}
return null;
}
/** Emit a metric for silenced errors so volume remains visible. */
function recordSilencedError(error: unknown, reason: SilenceReason): void {
const attributes: Record<string, string | number> = {
error_class: error instanceof Error ? error.name : typeof error,
reason,
};
if (error instanceof ApiError) {
attributes.api_status = error.status;
}
if (error instanceof AuthError) {
attributes.auth_reason = error.reason;
}
// Preserve which required value was missing so the silenced-volume metric
// keeps sub-grouping context (e.g. "Organization and project" vs "Event ID").
// ContextError resources are a small fixed set, so cardinality stays low.
if (error instanceof ContextError) {
attributes.resource = error.resource;
}
try {
Sentry.metrics.distribution("cli.error.silenced", 1, { attributes });
} catch {
// Metric emission must never block error handling.
}
// Structured log for user API errors — `detail` is often the most actionable
// field and is searchable in Sentry Logs.
if (reason === "api_user_error" && error instanceof ApiError) {
try {
Sentry.logger.info("cli.api_error_silenced", {
status: error.status,
endpoint: error.endpoint,
detail: error.detail,
});
} catch {
// Logger may not be initialized — ignore.
}
}
}
// ---------------------------------------------------------------------------
// Grouping tags
// ---------------------------------------------------------------------------
/** Endpoint normalization patterns — compiled once at module scope. */
const ENDPOINT_PATTERNS: [RegExp, string][] = [
[/\/organizations\/[^/]+/, "/organizations/{org}"],
[/\/projects\/[^/]+\/[^/]+/, "/projects/{org}/{project}"],
[/\/issues\/[^/]+/, "/issues/{id}"],
[/\/events\/[^/]+/, "/events/{id}"],
[/\/groups\/[^/]+/, "/groups/{id}"],
[/\/releases\/[^/]+/, "/releases/{version}"],
[/\/teams\/[^/]+\/[^/]+/, "/teams/{org}/{team}"],
[/\/dashboards\/[^/]+/, "/dashboards/{id}"],
[/\/customers\/[^/]+/, "/customers/{org}"],
];
/**
* Strip remaining bare numeric segments (e.g. /12345/) but preserve
* the API version prefix /0/ which is always the second segment.
*/
const BARE_NUMERIC_SEGMENT_RE = /(?<=\/api\/0\/.*)\/\d+(?=\/|$)/g;
/**
* Normalize an API endpoint path by parameterizing variable segments.
*
* Replaces org slugs, project slugs, issue IDs, event IDs, and other
* entity identifiers with placeholders so that server-side fingerprint
* rules can sub-group `ApiError` by endpoint shape rather than exact path.
*
* `"/api/0/projects/my-org/my-project/events/abc123/"` →
* `"/api/0/projects/{org}/{project}/events/{id}/"`
*/
export function normalizeEndpoint(endpoint: string): string {
let result = endpoint;
for (const [pattern, replacement] of ENDPOINT_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result.replace(BARE_NUMERIC_SEGMENT_RE, "/{id}");
}
/**
* Strip quoted substrings, numeric/hex IDs, and org/project paths from a
* resource string to produce a stable "kind" for grouping.
*
* `"Project 'my-app' not found"` → `"Project not found"`
* `"Issue 7420431306 not found."` → `"Issue not found."`
* `"Issue 19 not found."` → `"Issue not found."`
* `"not found in neurio/installer-app"` → `"not found"`
*/
export function extractResourceKind(resource: string): string {
return (
resource
.replace(/'[^']*'/g, "")
.replace(/"[^"]*"/g, "")
.replace(/\b[0-9a-f]{16,32}\b/gi, "")
.replace(/\bin\s+[\w-]+(?:\/[\w-]+)*/g, "")
// Strip hyphenated slugs after known entity names (e.g., "Organization my-company").
// Requires at least one hyphen to avoid stripping English words ("Project not found").
// Safe for current callers: resource values with slugs use quotes (stripped above),
// and headline values don't start with entity names.
.replace(
/\b(Organization|Dashboard|Dashboards|Project|Team)\s+[\w][\w-]*-[\w-]*/gi,
"$1"
)
.replace(/\b\d+\b/g, "")
.replace(/\s+/g, " ")
.trim()
);
}
/**
* Extract the first N words from the first line of an error message, after
* stripping quoted user data. Used as a grouping-key fallback when an error
* class lacks a structured `field`/`reason` to key on.
*
* `"Invalid trace ID \"abc\". Expected ..."` → `"Invalid trace ID"` (with maxWords=3)
*/
export function extractMessagePrefix(message: string, maxWords = 3): string {
const firstLine = message.split("\n", 1)[0] ?? "";
return firstLine
.replace(/'[^']*'/g, "")
.replace(/"[^"]*"/g, "")
.replace(/\s+/g, " ")
.trim()
.split(" ")
.slice(0, maxWords)
.join(" ");
}
/**
* Derive a stable `cli_error.kind` grouping key from an error instance.
*
* Returns `undefined` when the error is not a recognized CLI error class
* (the caller should still set `cli_error.class` for basic grouping).
*/
function deriveErrorKind(error: Error): string | undefined {
if (error instanceof ContextError) {
return error.resource;
}
if (error instanceof ResolutionError) {
return (
extractResourceKind(error.resource) +
" " +
extractResourceKind(error.headline)
);
}
// Fall back to the first few words of the message when no field is set
// (e.g. validateHexId throws with no `field`, so using field would
// collapse every unfielded ValidationError into one group).
if (error instanceof ValidationError) {
return error.field ?? extractMessagePrefix(error.message);
}
if (error instanceof ApiError) {
return String(error.status);
}
if (error instanceof SeerError) {
return error.reason;
}
if (error instanceof AuthError) {
return error.reason;
}
if (error instanceof UpgradeError) {
return error.reason;
}
if (error instanceof DeviceFlowError) {
return error.code;
}
if (error instanceof TimeoutError) {
return "timeout";
}
if (error instanceof HostScopeError) {
return "host_scope";
}
if (error instanceof WizardError) {
return "wizard";
}
// Catch-all for bare CliError — must be checked AFTER all subclasses
// because instanceof matches the entire prototype chain.
// ConfigError and OutputError intentionally fall through here:
// ConfigError has no structured field beyond message; OutputError is
// silenced by classifySilenced() before reaching deriveErrorKind().
if (error instanceof CliError) {
return extractMessagePrefix(error.message, 4);
}
return;
}
/**
* Set `cli_error.*` tags on a Sentry scope for an error that will be
* captured. These tags are matched by server-side fingerprint rules to
* achieve stable grouping without SDK-side fingerprint logic.
*
* Tags set:
* - `cli_error.class` — error class name (e.g. `"ContextError"`)
* - `cli_error.kind` — stable grouping key derived from structured fields
* - `cli_error.api_status` — HTTP status (ApiError only)
* - `cli_error.api_endpoint` — normalized API path (ApiError only)
*/
function setGroupingTags(scope: Sentry.Scope, error: unknown): void {
if (!(error instanceof Error)) {
return;
}
scope.setTag("cli_error.class", error.name);
const kind = deriveErrorKind(error);
if (kind !== undefined) {
scope.setTag("cli_error.kind", kind);
}
if (error instanceof ApiError) {
scope.setTag("cli_error.api_status", String(error.status));
if (error.endpoint) {
scope.setTag("cli_error.api_endpoint", normalizeEndpoint(error.endpoint));
}
}
}
// ---------------------------------------------------------------------------
// Structured context
// ---------------------------------------------------------------------------
/** Attach a `cli_error` context with full structured details for Discover. */
function setCliErrorContext(scope: Sentry.Scope, error: unknown): void {
if (error instanceof ApiError) {
scope.setContext("api_error", {
status: error.status,
endpoint: error.endpoint,
detail: error.detail,
});
} else if (error instanceof ContextError) {
scope.setContext("cli_error", {
resource: error.resource,
command: error.command,
});
} else if (error instanceof ResolutionError) {
scope.setContext("cli_error", {
resource: error.resource,
headline: error.headline,
hint: error.hint,
});
} else if (error instanceof SeerError) {
scope.setContext("cli_error", {
reason: error.reason,
org_slug: error.orgSlug,
});
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Report a command-level error to Sentry.
*
* - Silenced errors emit a metric and return without calling `captureException`.
* - Captured errors get grouping tags + structured context on a fresh scope.
*/
export function reportCliError(error: unknown): void {
const silenced = classifySilenced(error);
if (silenced) {
recordSilencedError(error, silenced);
return;
}
Sentry.withScope((scope) => {
setGroupingTags(scope, error);
setCliErrorContext(scope, error);
Sentry.captureException(error);
});
}
/**
* Enrich an error event with `cli_error.*` tags for server-side fingerprinting.
*
* Called from `beforeSend` to catch events that bypass {@link reportCliError}
* (uncaught exceptions, unhandled rejections, best-effort background captures).
* Only sets tags when `cli_error.class` isn't already present (events from
* `reportCliError` already have them).
*/
export function enrichEventWithGroupingTags(
event: Sentry.ErrorEvent
): Sentry.ErrorEvent {
// Skip if reportCliError already set the tags via withScope.
if (event.tags?.["cli_error.class"]) {
return event;
}
// Use the last (outermost/thrown) exception in the chain, not the first
// (innermost/root cause). Per the Sentry protocol, values[0] is the root
// cause and values[n-1] is the actually-thrown exception.
const values = event.exception?.values;
const exc = values?.[values.length - 1];
if (!exc?.type) {
return event;
}
event.tags = event.tags ?? {};
event.tags["cli_error.class"] = exc.type;
// Set kind from exception message prefix so server-side rules can group
// non-CliError exceptions (TypeError, Error, WizardCancelledError, etc.)
// that bypass reportCliError (uncaught exceptions, unhandled rejections).
if (exc.value) {
event.tags["cli_error.kind"] = extractMessagePrefix(exc.value, 4);
}
return event;
}