diff --git a/dev-packages/e2e-tests/test-applications/nestjs-bullmq/tests/bullmq.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-bullmq/tests/bullmq.test.ts index e49ebd80488c..7fc8d7137712 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-bullmq/tests/bullmq.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-bullmq/tests/bullmq.test.ts @@ -61,10 +61,7 @@ test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', a expect(leakedBreadcrumb).toBeUndefined(); }); -// TODO: @OnWorkerEvent('completed') handlers run outside the isolation scope created by process(). -// They are registered via worker.on() (EventEmitter), so breadcrumbs/tags set there -// leak into the default isolation scope and appear on subsequent HTTP requests. -test('BullMQ @OnWorkerEvent completed lifecycle breadcrumbs currently leak into subsequent HTTP requests', async ({ +test('BullMQ @OnWorkerEvent completed lifecycle breadcrumbs do not leak into subsequent HTTP requests', async ({ baseURL, }) => { const processTransactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => { @@ -87,13 +84,10 @@ test('BullMQ @OnWorkerEvent completed lifecycle breadcrumbs currently leak into const leakedBreadcrumb = (transaction.breadcrumbs || []).find( (b: any) => b.message === 'leaked-breadcrumb-from-lifecycle-event', ); - // This SHOULD be toBeUndefined() once lifecycle event isolation is implemented. - expect(leakedBreadcrumb).toBeDefined(); + expect(leakedBreadcrumb).toBeUndefined(); }); -// TODO: @OnWorkerEvent('active') handlers run outside the isolation scope created by process(). -// Breadcrumbs set there leak into the default isolation scope and appear on subsequent HTTP requests. -test('BullMQ @OnWorkerEvent active lifecycle breadcrumbs currently leak into subsequent HTTP requests', async ({ +test('BullMQ @OnWorkerEvent active lifecycle breadcrumbs do not leak into subsequent HTTP requests', async ({ baseURL, }) => { const processTransactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => { @@ -115,13 +109,10 @@ test('BullMQ @OnWorkerEvent active lifecycle breadcrumbs currently leak into sub const leakedBreadcrumb = (transaction.breadcrumbs || []).find( (b: any) => b.message === 'leaked-breadcrumb-from-active-event', ); - // This SHOULD be toBeUndefined() once lifecycle event isolation is implemented. - expect(leakedBreadcrumb).toBeDefined(); + expect(leakedBreadcrumb).toBeUndefined(); }); -// TODO: @OnWorkerEvent('failed') handlers run outside the isolation scope created by process(). -// Breadcrumbs set there leak into the default isolation scope and appear on subsequent HTTP requests. -test('BullMQ @OnWorkerEvent failed lifecycle breadcrumbs currently leak into subsequent HTTP requests', async ({ +test('BullMQ @OnWorkerEvent failed lifecycle breadcrumbs do not leak into subsequent HTTP requests', async ({ baseURL, }) => { const processTransactionPromise = waitForTransaction('nestjs-bullmq', transactionEvent => { @@ -143,8 +134,7 @@ test('BullMQ @OnWorkerEvent failed lifecycle breadcrumbs currently leak into sub const leakedBreadcrumb = (transaction.breadcrumbs || []).find( (b: any) => b.message === 'leaked-breadcrumb-from-failed-event', ); - // This SHOULD be toBeUndefined() once lifecycle event isolation is implemented. - expect(leakedBreadcrumb).toBeDefined(); + expect(leakedBreadcrumb).toBeUndefined(); }); // The 'progress' event does NOT leak breadcrumbs — unlike 'active', 'completed', and 'failed', diff --git a/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts index b18bab1dc07c..d4d968069204 100644 --- a/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts @@ -5,19 +5,55 @@ import { InstrumentationNodeModuleFile, isWrapped, } from '@opentelemetry/instrumentation'; -import { captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core'; +import type { Scope, Span } from '@sentry/core'; +import { + addNonEnumerableProperty, + captureException, + SDK_VERSION, + startSpanManual, + withIsolationScope, +} from '@sentry/core'; import { getBullMQProcessSpanOptions } from './helpers'; import type { ProcessorDecoratorTarget } from './types'; const supportedVersions = ['>=10.0.0']; const COMPONENT = '@nestjs/bullmq'; +// Metadata key used by @nestjs/bullmq's @OnWorkerEvent decorator (via NestJS SetMetadata) +const ON_WORKER_EVENT_METADATA = 'bullmq:worker_events_metadata'; + +const SENTRY_ISOLATION_SCOPE_KEY = '_sentryIsolationScope'; +const SENTRY_SPAN_KEY = '_sentrySpan'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type JobLike = Record; + +function getScopeFromJob(job: JobLike): Scope | undefined { + return job?.[SENTRY_ISOLATION_SCOPE_KEY] as Scope | undefined; +} + +function setScopeOnJob(job: JobLike, scope: Scope): void { + addNonEnumerableProperty(job, SENTRY_ISOLATION_SCOPE_KEY, scope); +} + +function getSpanFromJob(job: JobLike): Span | undefined { + return job?.[SENTRY_SPAN_KEY] as Span | undefined; +} + +function setSpanOnJob(job: JobLike, span: Span): void { + addNonEnumerableProperty(job, SENTRY_SPAN_KEY, span); +} + /** * Custom instrumentation for nestjs bullmq module. * * This hooks into the `@Processor` class decorator, which is applied on queue processor classes. - * It wraps the `process` method on the decorated class to fork the isolation scope for each job - * invocation, create a span, and capture errors. + * It wraps the `process` method and any `@OnWorkerEvent` lifecycle methods on the decorated class + * to fork the isolation scope for each job invocation, create a span, and capture errors. + * + * All lifecycle events for a single job share the same isolation scope (stored on the Job object). + * The span is created via `startSpanManual` and ended either by a terminal event handler + * (`completed`/`failed`) or by `process()` itself if no appropriate handler is defined. */ export class SentryNestBullMQInstrumentation extends InstrumentationBase { public constructor(config: InstrumentationConfig = {}) { @@ -72,8 +108,39 @@ export class SentryNestBullMQInstrumentation extends InstrumentationBase { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const classDecorator = original(...decoratorArgs); - // Return a new class decorator that wraps the process method + // Return a new class decorator that wraps the process method and lifecycle handlers return function (target: ProcessorDecoratorTarget) { + // Scan prototype for @OnWorkerEvent lifecycle methods + let hasCompletedHandler = false; + let hasFailedHandler = false; + const lifecycleMethods: { key: string; method: Function; eventName: string }[] = []; + + const prototypeKeys = Object.getOwnPropertyNames(target.prototype); + for (const key of prototypeKeys) { + if (key === 'constructor' || key === 'process') continue; + + const method = target.prototype[key]; + if (typeof method !== 'function' || method.__SENTRY_INSTRUMENTED__) continue; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + let eventMetadata: { eventName: string } | undefined; + try { + // NestJS's SetMetadata stores metadata on the method function itself + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + eventMetadata = Reflect.getMetadata(ON_WORKER_EVENT_METADATA, method); + } catch { + continue; + } + if (!eventMetadata?.eventName) continue; + + if (eventMetadata.eventName === 'completed') hasCompletedHandler = true; + if (eventMetadata.eventName === 'failed') hasFailedHandler = true; + lifecycleMethods.push({ key, method, eventName: eventMetadata.eventName }); + } + + // Wrap the process method const originalProcess = target.prototype.process; if ( @@ -84,11 +151,24 @@ export class SentryNestBullMQInstrumentation extends InstrumentationBase { ) { target.prototype.process = new Proxy(originalProcess, { apply: (originalProcessFn, thisArg, args) => { - return withIsolationScope(() => { - return startSpan(getBullMQProcessSpanOptions(queueName), async () => { + const job = args[0] as JobLike; + const existingScope = getScopeFromJob(job); + + const runProcess = (isolationScope: Scope): Promise => { + if (!existingScope) { + setScopeOnJob(job, isolationScope); + } + + return startSpanManual(getBullMQProcessSpanOptions(queueName), async span => { + if (!getSpanFromJob(job)) { + setSpanOnJob(job, span); + } + + let processSucceeded = true; try { return await originalProcessFn.apply(thisArg, args); } catch (error) { + processSucceeded = false; captureException(error, { mechanism: { handled: false, @@ -96,15 +176,90 @@ export class SentryNestBullMQInstrumentation extends InstrumentationBase { }, }); throw error; + } finally { + // End span here only if the appropriate terminal handler doesn't exist + if ((!processSucceeded && !hasFailedHandler) || (processSucceeded && !hasCompletedHandler)) { + span.end(); + } } }); - }); + }; + + if (existingScope) { + return withIsolationScope(existingScope, runProcess); + } + return withIsolationScope(runProcess); }, }); target.prototype.process.__SENTRY_INSTRUMENTED__ = true; } + // Wrap lifecycle methods + for (const { key, method, eventName } of lifecycleMethods) { + const isTerminalEvent = eventName === 'completed' || eventName === 'failed'; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrappedMethod: any = new Proxy(method, { + apply: (originalMethod, thisArg, args) => { + const job = args[0] as JobLike; + const storedScope = getScopeFromJob(job); + + const runHandler = (isolationScope: Scope): unknown => { + if (!storedScope) { + setScopeOnJob(job, isolationScope); + } + try { + return originalMethod.apply(thisArg, args); + } catch (error) { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.queue.nestjs.bullmq', + }, + }); + throw error; + } finally { + if (isTerminalEvent) { + const span = getSpanFromJob(job); + span?.end(); + } + } + }; + + if (storedScope) { + return withIsolationScope(storedScope, runHandler); + } + return withIsolationScope(runHandler); + }, + }); + + // Copy reflect-metadata from original method to wrapped method. + // NestJS uses Reflect.getMetadata() keyed by object identity to discover + // @OnWorkerEvent handlers. Without this, the Proxy (a different object) won't + // be recognized as a lifecycle handler and NestJS won't register it. + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const metadataKeys: string[] = Reflect.getOwnMetadataKeys?.(method) ?? []; + for (const metaKey of metadataKeys) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const metaValue = Reflect.getOwnMetadata(metaKey, method); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect + Reflect.defineMetadata(metaKey, metaValue, wrappedMethod); + } + } catch { + // reflect-metadata not available — skip + } + + target.prototype[key] = wrappedMethod; + wrappedMethod.__SENTRY_INSTRUMENTED__ = true; + } + // Apply the original class decorator // eslint-disable-next-line @typescript-eslint/no-unsafe-return return classDecorator(target); diff --git a/packages/nestjs/src/integrations/types.ts b/packages/nestjs/src/integrations/types.ts index 6dd00caa8cc1..92c519b21afc 100644 --- a/packages/nestjs/src/integrations/types.ts +++ b/packages/nestjs/src/integrations/types.ts @@ -110,6 +110,8 @@ export interface ProcessorDecoratorTarget { __SENTRY_INTERNAL__?: boolean; prototype: { process?: ((...args: any[]) => Promise) & { __SENTRY_INSTRUMENTED__?: boolean }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: (((...args: any[]) => any) & { __SENTRY_INSTRUMENTED__?: boolean }) | undefined; }; } diff --git a/packages/nestjs/test/integrations/bullmq.test.ts b/packages/nestjs/test/integrations/bullmq.test.ts index 349a0c1b8e43..ffc96b301bf2 100644 --- a/packages/nestjs/test/integrations/bullmq.test.ts +++ b/packages/nestjs/test/integrations/bullmq.test.ts @@ -3,22 +3,34 @@ import * as core from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SentryNestBullMQInstrumentation } from '../../src/integrations/sentry-nest-bullmq-instrumentation'; +// Metadata key matching @nestjs/bullmq's @OnWorkerEvent decorator +const ON_WORKER_EVENT_METADATA = 'bullmq:worker_events_metadata'; + describe('BullMQInstrumentation', () => { let instrumentation: SentryNestBullMQInstrumentation; + const mockSpan = { end: vi.fn() }; beforeEach(() => { instrumentation = new SentryNestBullMQInstrumentation(); vi.spyOn(core, 'captureException'); - vi.spyOn(core, 'withIsolationScope').mockImplementation(callback => { - return (callback as () => unknown)(); + vi.spyOn(core, 'withIsolationScope').mockImplementation((...args: unknown[]) => { + // Handle both overloads: (callback) and (scope, callback) + if (args.length === 2) { + return (args[1] as (scope: unknown) => unknown)(args[0]); + } + return (args[0] as (scope: unknown) => unknown)({}); + }); + vi.spyOn(core, 'startSpanManual').mockImplementation((_, callback) => { + return (callback as (span: unknown) => unknown)(mockSpan); }); - vi.spyOn(core, 'startSpan').mockImplementation((_, callback) => { - return (callback as () => unknown)(); + vi.spyOn(core, 'addNonEnumerableProperty').mockImplementation((obj: any, key: string, value: unknown) => { + obj[key] = value; }); }); afterEach(() => { vi.restoreAllMocks(); + mockSpan.end.mockClear(); }); describe('Processor decorator wrapping', () => { @@ -38,7 +50,7 @@ describe('BullMQInstrumentation', () => { wrappedDecorator = moduleExports.Processor; }); - it('should call withIsolationScope and startSpan on process execution', async () => { + it('should call withIsolationScope and startSpanManual on process execution', async () => { const originalProcess = vi.fn().mockResolvedValue('result'); mockProcessor = class TestProcessor { @@ -49,10 +61,10 @@ describe('BullMQInstrumentation', () => { const classDecoratorFn = wrappedDecorator('test-queue'); classDecoratorFn(mockProcessor); - await mockProcessor.prototype.process(); + await mockProcessor.prototype.process({ id: '1' }); expect(core.withIsolationScope).toHaveBeenCalled(); - expect(core.startSpan).toHaveBeenCalledWith( + expect(core.startSpanManual).toHaveBeenCalledWith( expect.objectContaining({ name: 'test-queue process', forceTransaction: true, @@ -78,7 +90,7 @@ describe('BullMQInstrumentation', () => { const classDecoratorFn = wrappedDecorator('test-queue'); classDecoratorFn(mockProcessor); - await expect(mockProcessor.prototype.process()).rejects.toThrow(error); + await expect(mockProcessor.prototype.process({ id: '1' })).rejects.toThrow(error); expect(core.captureException).toHaveBeenCalledWith(error, { mechanism: { handled: false, @@ -130,9 +142,9 @@ describe('BullMQInstrumentation', () => { const classDecoratorFn = wrappedDecorator({ name: 'my-queue' }); classDecoratorFn(mockProcessor); - await mockProcessor.prototype.process(); + await mockProcessor.prototype.process({ id: '1' }); - expect(core.startSpan).toHaveBeenCalledWith( + expect(core.startSpanManual).toHaveBeenCalledWith( expect.objectContaining({ name: 'my-queue process', }), @@ -151,5 +163,264 @@ describe('BullMQInstrumentation', () => { expect(mockClassDecorator).toHaveBeenCalledWith('test-queue'); }); + + it('should end span in process() when no terminal handlers are defined', async () => { + const originalProcess = vi.fn().mockResolvedValue('result'); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + await mockProcessor.prototype.process({ id: '1' }); + + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('should not end span in process() when completed handler is defined and process succeeds', async () => { + const originalProcess = vi.fn().mockResolvedValue('result'); + const onCompleted = vi.fn(); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + mockProcessor.prototype.onCompleted = onCompleted; + Reflect.defineMetadata(ON_WORKER_EVENT_METADATA, { eventName: 'completed' }, onCompleted); + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + await mockProcessor.prototype.process({ id: '1' }); + + // Span should NOT be ended by process() — completed handler will end it + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + + it('should end span in process() when completed handler exists but process throws', async () => { + const error = new Error('Test error'); + const originalProcess = vi.fn().mockRejectedValue(error); + const onCompleted = vi.fn(); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + mockProcessor.prototype.onCompleted = onCompleted; + Reflect.defineMetadata(ON_WORKER_EVENT_METADATA, { eventName: 'completed' }, onCompleted); + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + await expect(mockProcessor.prototype.process({ id: '1' })).rejects.toThrow(error); + + // Span SHOULD be ended by process() — no failed handler defined + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('should not end span in process() when failed handler is defined and process throws', async () => { + const error = new Error('Test error'); + const originalProcess = vi.fn().mockRejectedValue(error); + const onFailed = vi.fn(); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + mockProcessor.prototype.onFailed = onFailed; + Reflect.defineMetadata(ON_WORKER_EVENT_METADATA, { eventName: 'failed' }, onFailed); + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + await expect(mockProcessor.prototype.process({ id: '1' })).rejects.toThrow(error); + + // Span should NOT be ended by process() — failed handler will end it + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + }); + + describe('Lifecycle method wrapping', () => { + let wrappedDecorator: any; + let mockClassDecorator: vi.Mock; + let mockProcessor: any; + + beforeEach(() => { + mockClassDecorator = vi.fn().mockImplementation(() => { + return (target: any) => target; + }); + + const moduleDef = instrumentation.init(); + const file = moduleDef.files[0]; + const moduleExports = { Processor: mockClassDecorator }; + file?.patch(moduleExports); + wrappedDecorator = moduleExports.Processor; + }); + + it('should wrap methods with @OnWorkerEvent metadata', () => { + const originalProcess = vi.fn().mockResolvedValue('result'); + const onCompleted = vi.fn(); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + mockProcessor.prototype.onCompleted = onCompleted; + Reflect.defineMetadata(ON_WORKER_EVENT_METADATA, { eventName: 'completed' }, onCompleted); + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + expect(mockProcessor.prototype.onCompleted).not.toBe(onCompleted); + expect(mockProcessor.prototype.onCompleted.__SENTRY_INSTRUMENTED__).toBe(true); + }); + + it('should not wrap methods without @OnWorkerEvent metadata', () => { + const originalProcess = vi.fn().mockResolvedValue('result'); + const plainMethod = vi.fn(); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + mockProcessor.prototype.plainMethod = plainMethod; + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + expect(mockProcessor.prototype.plainMethod).toBe(plainMethod); + }); + + it('should call withIsolationScope in lifecycle handler', () => { + const originalProcess = vi.fn().mockResolvedValue('result'); + const onCompleted = vi.fn(); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + mockProcessor.prototype.onCompleted = onCompleted; + Reflect.defineMetadata(ON_WORKER_EVENT_METADATA, { eventName: 'completed' }, onCompleted); + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + mockProcessor.prototype.onCompleted({ id: '1' }); + + expect(core.withIsolationScope).toHaveBeenCalled(); + }); + + it('should reuse stored scope from process() in terminal handler', async () => { + const originalProcess = vi.fn().mockResolvedValue('result'); + const onCompleted = vi.fn(); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + mockProcessor.prototype.onCompleted = onCompleted; + Reflect.defineMetadata(ON_WORKER_EVENT_METADATA, { eventName: 'completed' }, onCompleted); + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + const job = { id: '1' }; + + // process() stores the scope on the job + await mockProcessor.prototype.process(job); + + // completed handler should reuse the stored scope + mockProcessor.prototype.onCompleted(job); + + // withIsolationScope should have been called with the stored scope (2-arg overload) + const calls = (core.withIsolationScope as any).mock.calls; + const completedCall = calls[calls.length - 1]; + // 2-arg call means scope was reused + expect(completedCall).toHaveLength(2); + }); + + it('should end span in terminal handler', async () => { + const originalProcess = vi.fn().mockResolvedValue('result'); + const onCompleted = vi.fn(); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + mockProcessor.prototype.onCompleted = onCompleted; + Reflect.defineMetadata(ON_WORKER_EVENT_METADATA, { eventName: 'completed' }, onCompleted); + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + const job = { id: '1' }; + + await mockProcessor.prototype.process(job); + expect(mockSpan.end).not.toHaveBeenCalled(); + + mockProcessor.prototype.onCompleted(job); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('should capture exceptions in lifecycle handlers', () => { + const originalProcess = vi.fn().mockResolvedValue('result'); + const error = new Error('Lifecycle error'); + const onCompleted = vi.fn().mockImplementation(() => { + throw error; + }); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + mockProcessor.prototype.onCompleted = onCompleted; + Reflect.defineMetadata(ON_WORKER_EVENT_METADATA, { eventName: 'completed' }, onCompleted); + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + expect(() => mockProcessor.prototype.onCompleted({ id: '1' })).toThrow(error); + expect(core.captureException).toHaveBeenCalledWith(error, { + mechanism: { + handled: false, + type: 'auto.queue.nestjs.bullmq', + }, + }); + }); + + it('should not double-wrap lifecycle methods', () => { + const originalProcess = vi.fn().mockResolvedValue('result'); + const onCompleted = vi.fn(); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + mockProcessor.prototype.onCompleted = onCompleted; + Reflect.defineMetadata(ON_WORKER_EVENT_METADATA, { eventName: 'completed' }, onCompleted); + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + const wrappedOnCompleted = mockProcessor.prototype.onCompleted; + + // Apply decorator again (simulate double application) + // Need a fresh process to avoid __SENTRY_INSTRUMENTED__ on process too + mockProcessor.prototype.process = vi.fn().mockResolvedValue('result'); + const classDecoratorFn2 = wrappedDecorator('test-queue'); + classDecoratorFn2(mockProcessor); + + expect(mockProcessor.prototype.onCompleted).toBe(wrappedOnCompleted); + }); + + it('should create scope on job from active handler and reuse in process()', async () => { + const originalProcess = vi.fn().mockResolvedValue('result'); + const onActive = vi.fn(); + const onCompleted = vi.fn(); + + mockProcessor = class TestProcessor {}; + mockProcessor.prototype.process = originalProcess; + mockProcessor.prototype.onActive = onActive; + mockProcessor.prototype.onCompleted = onCompleted; + Reflect.defineMetadata(ON_WORKER_EVENT_METADATA, { eventName: 'active' }, onActive); + Reflect.defineMetadata(ON_WORKER_EVENT_METADATA, { eventName: 'completed' }, onCompleted); + + const classDecoratorFn = wrappedDecorator('test-queue'); + classDecoratorFn(mockProcessor); + + const job = { id: '1' }; + + // Active fires first (before process) — creates and stores scope on job + mockProcessor.prototype.onActive(job); + + // Process should reuse the stored scope (2-arg withIsolationScope) + await mockProcessor.prototype.process(job); + + const calls = (core.withIsolationScope as any).mock.calls; + const processCall = calls[calls.length - 1]; + // 2-arg call means process() found and reused the scope from active + expect(processCall).toHaveLength(2); + }); }); });