diff --git a/src/utils/__tests__/analytics.test.ts b/src/utils/__tests__/analytics.test.ts index 347424ec1..c3d8a307f 100644 --- a/src/utils/__tests__/analytics.test.ts +++ b/src/utils/__tests__/analytics.test.ts @@ -1,5 +1,7 @@ import { Analytics, groupsFromUser, sessionProperties } from '@utils/analytics'; import { PostHog } from 'posthog-node'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; import { v4 as uuidv4 } from 'uuid'; import { ANALYTICS_TEAM_TAG, WIZARD_FLAG_KEYS } from '@lib/constants'; import { VERSION } from '@lib/version'; @@ -503,6 +505,7 @@ describe('Analytics', () => { properties?: Record; }; type BeforeSendFn = (event: TestEvent | null) => TestEvent | null; + type ExceptionList = [{ stacktrace: { frames: unknown[] } }]; const getBeforeSend = (): BeforeSendFn => (MockedPostHog.mock.calls[0][1] as { before_send: BeforeSendFn }) @@ -546,6 +549,29 @@ describe('Analytics', () => { expect(result?.distinctId).toBe('user-123'); }); + it('stabilizes the frames of the wizard’s own code', () => { + const beforeSend = getBeforeSend(); + const own = resolve( + fileURLToPath(import.meta.url), + '..', + '..', + '..', + 'dist/agent-runner-CksP2v2P.js', + ); + + const result = beforeSend({ + event: '$exception', + properties: { + $exception_list: [{ stacktrace: { frames: [{ source: own }] } }], + }, + }); + + expect( + (result?.properties?.$exception_list as ExceptionList)[0].stacktrace + .frames, + ).toEqual([{ source: 'dist/agent-runner.js', in_app: true }]); + }); + it('leaves non-exception events untouched', () => { const beforeSend = getBeforeSend(); const event = { event: 'x', distinctId: 'd', properties: { a: 1 } }; diff --git a/src/utils/__tests__/exception-frames.test.ts b/src/utils/__tests__/exception-frames.test.ts new file mode 100644 index 000000000..64e1ce3af --- /dev/null +++ b/src/utils/__tests__/exception-frames.test.ts @@ -0,0 +1,56 @@ +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { stabilizeOwnFrames } from '@utils/exception-frames'; + +// The module computes its package root as its own directory's parent, so under +// vitest (unbundled) that is `src/`, two levels up from this test file. +const PACKAGE_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..'); + +const listWith = (...sources: string[]) => [ + { stacktrace: { frames: sources.map((source) => ({ source })) } }, +]; + +describe('stabilizeOwnFrames', () => { + it('rewrites the wizard’s own frames to a package-relative path without the build hash', () => { + const list = listWith(`${PACKAGE_ROOT}/dist/agent-runner-CksP2v2P.js`); + + stabilizeOwnFrames(list); + + expect(list[0].stacktrace.frames[0]).toEqual({ + source: 'dist/agent-runner.js', + in_app: true, + }); + }); + + it('gives one frame the same source whatever directory the wizard runs from', () => { + const absolute = `${PACKAGE_ROOT}/dist/bin.js`; + const results = ['/home/someone/a-project', '/tmp'].map((cwd) => { + const spy = vi.spyOn(process, 'cwd').mockReturnValue(cwd); + const list = listWith(absolute); + stabilizeOwnFrames(list); + spy.mockRestore(); + return list[0].stacktrace.frames[0].source; + }); + + expect(results).toEqual(['dist/bin.js', 'dist/bin.js']); + }); + + it('leaves frames outside the package alone', () => { + const sources = [ + '../../.npm/_npx/e5076a3e/node_modules/ink/build/components/App.js', + 'node:internal/modules/esm/loader', + ]; + const list = listWith(...sources); + + stabilizeOwnFrames(list); + + expect(list[0].stacktrace.frames).toEqual( + sources.map((s) => ({ source: s })), + ); + }); + + it('ignores exceptions that carry no frames', () => { + expect(() => stabilizeOwnFrames([{ type: 'Error' }, null])).not.toThrow(); + expect(() => stabilizeOwnFrames(undefined)).not.toThrow(); + }); +}); diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index f494fc439..fefbcd17f 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -16,6 +16,7 @@ import { IS_PRODUCTION_BUILD, RUN_SURFACE, TASK_ID, TASK_RUN_ID } from '@env'; import { VERSION } from '@lib/version'; import { debug, logToFile } from './debug'; import { applyCiFlagOverrides } from './ci-flag-overrides'; +import { stabilizeOwnFrames } from './exception-frames'; /** * The invocation, reduced to flag-safe strings: the command word (first @@ -132,6 +133,7 @@ export class Analytics { event.properties ?? {}; void $process_person_profile; event.properties = { ...this.tags, ...properties }; + stabilizeOwnFrames(event.properties.$exception_list); } // The SDK captures this one itself, bypassing capture(), so tags merge here. if (event.event === '$feature_flag_called') { diff --git a/src/utils/exception-frames.ts b/src/utils/exception-frames.ts new file mode 100644 index 000000000..8bcb2e66e --- /dev/null +++ b/src/utils/exception-frames.ts @@ -0,0 +1,46 @@ +/** + * Give the wizard's own stack frames an identity that does not change per user. + * + * posthog-node makes every frame path relative to `process.cwd()`. The wizard + * runs from the user's project while its bundle sits in an npx / dlx / bunx + * cache, so one frame arrives under a different path for nearly every run — + * different `../` depth, cache hash, home directory, drive letter — and error + * tracking files the same failure as a new issue per path. Frames inside a + * package cache are never marked `in_app` either, though this bundle is the + * application. Both are fixed by re-anchoring our frames to the package root. + */ + +import { isAbsolute, relative, resolve, sep } from 'path'; +import { fileURLToPath } from 'url'; + +/** Root of the installed package — this module is bundled into `/dist`. */ +const PACKAGE_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); + +/** Content hash rolldown appends to a split chunk, e.g. `-CksP2v2P`. */ +const BUILD_HASH = /-[A-Za-z0-9_-]{8}(\.[cm]?js)$/; + +type Frame = { source?: string; in_app?: boolean }; + +/** `/dist/agent-runner-CksP2v2P.js` → `dist/agent-runner.js`, else null. */ +function packageRelativeSource(source: string): string | null { + const path = relative(PACKAGE_ROOT, resolve(process.cwd(), source)); + if (!path || path.startsWith('..') || isAbsolute(path)) return null; + return path.split(sep).join('/').replace(BUILD_HASH, '$1'); +} + +/** Rewrite the wizard's own frames in place; every other frame is left alone. */ +export function stabilizeOwnFrames(exceptionList: unknown): void { + if (!Array.isArray(exceptionList)) return; + for (const exception of exceptionList) { + const frames = (exception as { stacktrace?: { frames?: Frame[] } }) + ?.stacktrace?.frames; + if (!Array.isArray(frames)) continue; + for (const frame of frames) { + if (!frame?.source) continue; + const source = packageRelativeSource(frame.source); + if (source === null) continue; + frame.source = source; + frame.in_app = true; + } + } +}