-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
test(test-utils): Add Sentry CLI trace helpers as test-utils/cli
#24279
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+135
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { spawnSync } from 'node:child_process'; | ||
|
|
||
| /** | ||
| * Spans only become queryable once they have made it through to EAP, which takes | ||
| * noticeably longer than the error pipeline (~2min vs ~20s when this was measured). | ||
| */ | ||
| export const EVENT_POLLING_OPTIONS = { timeout: 180_000, intervals: [5_000] }; | ||
|
|
||
| /** | ||
| * A node of the span tree returned by `sentry trace view`. Spans, errors and occurrences all | ||
| * share this shape and are discriminated by `event_type`. | ||
| */ | ||
| export interface TraceItem { | ||
| /** On a span this is the span id. */ | ||
| event_id?: string; | ||
| event_type?: 'span' | 'error' | 'occurrence' | 'uptime_check'; | ||
| op?: string | null; | ||
| children?: TraceItem[] | null; | ||
| errors?: TraceItem[] | null; | ||
| occurrences?: TraceItem[] | null; | ||
| } | ||
|
|
||
| /** | ||
| * The `sentry trace view` target of a trace in the E2E test project, so a log line can be pasted | ||
| * into a terminal as-is. | ||
| */ | ||
| export function traceTarget(traceId: string): string { | ||
| return `${process.env['E2E_TEST_SENTRY_ORG_SLUG']}/${process.env['E2E_TEST_SENTRY_PROJECT']}/${traceId}`; | ||
| } | ||
|
|
||
| /** | ||
| * Fetch a trace of the E2E test project through the `sentry` CLI, which the calling test app has to | ||
| * list as a dev dependency. Returns an empty list while the trace has not landed yet. | ||
| */ | ||
| export function fetchTrace(traceId: string): TraceItem[] { | ||
| const target = traceTarget(traceId); | ||
| const result = spawnSync('pnpm', ['exec', 'sentry', 'trace', 'view', target, '--json', '--fresh'], { | ||
| encoding: 'utf8', | ||
| env: { | ||
| ...process.env, | ||
| // The E2E token is the only credential CI has. Locally the CLI would prefer a stored login | ||
| // over an env token, so force the env token for identical behaviour everywhere. | ||
| SENTRY_AUTH_TOKEN: process.env['E2E_TEST_AUTH_TOKEN'], | ||
| SENTRY_FORCE_ENV_TOKEN: '1', | ||
| }, | ||
| }); | ||
|
|
||
| if (result.status === 0) { | ||
| return (JSON.parse(result.stdout) as { spans?: TraceItem[] }).spans ?? []; | ||
| } | ||
|
|
||
| // Exit codes 10-19 are auth errors, and a rejected token also surfaces as an API error (exit 30) | ||
| // with a 401 in the message. Neither resolves by waiting, so fail loudly instead of polling until | ||
| // the timeout and reporting it as a missing event. The trace endpoint is org scoped, so the token | ||
| // needs `org:read` on top of the project scopes. | ||
| const isAuthError = result.status !== null && result.status >= 10 && result.status < 20; | ||
| if (isAuthError || /\b40[13]\b/.test(result.stderr)) { | ||
| throw new Error( | ||
| `sentry trace view ${target} failed with exit code ${result.status}: ${result.stderr}` + | ||
| 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', | ||
| ); | ||
| } | ||
|
|
||
| // Exit code 23 means the trace has not landed yet. Log anything else, since a rejected request | ||
| // and a trace that has not landed are otherwise indistinguishable. | ||
| if (result.status !== 23) { | ||
| // eslint-disable-next-line no-console | ||
| console.log(`sentry trace view ${target} exited with ${result.status}: ${result.stderr}`); | ||
| } | ||
|
|
||
| return []; | ||
| } | ||
|
|
||
| /** | ||
| * Errors attach to whichever span was active when they were captured, and relocate from the | ||
| * top level into that span once it lands, so a given event can surface at any depth. | ||
| */ | ||
| export function flattenTrace(items: TraceItem[]): TraceItem[] { | ||
| return items.flatMap(item => [ | ||
| item, | ||
| ...flattenTrace(item.children ?? []), | ||
| ...flattenTrace(item.errors ?? []), | ||
| ...flattenTrace(item.occurrences ?? []), | ||
| ]); | ||
| } | ||
|
|
||
| /** | ||
| * Without an `eventId` any error in the trace matches. That is what a request the server failed | ||
| * needs, because the client never learns the event id of an unhandled exception. | ||
| */ | ||
| export function findErrorInTrace(traceId: string, eventId?: string): TraceItem | undefined { | ||
| return flattenTrace(fetchTrace(traceId)).find( | ||
| item => item.event_type === 'error' && (eventId === undefined || item.event_id === eventId), | ||
| ); | ||
| } | ||
|
|
||
| let loggedTraceShape = false; | ||
|
|
||
| /** | ||
| * Streamed spans never become transaction events, so the segment is matched by its op rather than by | ||
| * the event id of an enclosing transaction. The trace is already unique to the request under test, | ||
| * so the op identifies the segment within it. | ||
| */ | ||
| export function findSpanInTrace(traceId: string, op: string): TraceItem | undefined { | ||
| const items = flattenTrace(fetchTrace(traceId)); | ||
| const match = items.find(item => item.op === op); | ||
|
|
||
| // The trace endpoint's exact span shape is what this lookup depends on, so report it once when a | ||
| // non-empty trace does not contain the op we are waiting for. | ||
| if (!match && items.length && !loggedTraceShape) { | ||
| loggedTraceShape = true; | ||
| // eslint-disable-next-line no-console | ||
| console.log( | ||
| `Trace ${traceId} has no "${op}" item yet. Items so far:`, | ||
| JSON.stringify( | ||
| items.map(item => ({ event_type: item.event_type, op: item.op, event_id: item.event_id })), | ||
| null, | ||
| 2, | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| return match; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: The function misinterprets a
nullstatus fromspawnSync(when a command isn't found) as a transient condition, causing unnecessary polling instead of failing immediately.Severity: MEDIUM
Suggested Fix
Check the
result.errorproperty of the object returned byspawnSync. Ifresult.erroris truthy, it indicates a spawn failure (like the command not being found). In this case, throw the error or return a specific error state to immediately signal the failure to the caller, instead of falling through to the polling logic.Prompt for AI Agent
Did we get this right? 👍 / 👎 to inform future reviews.