Skip to content

Commit d314ba3

Browse files
committed
feat(nest): final (still dormant) Orchestrion instrumentation
Connect the `@Cron`/`@interval`/`@Timeout` (schedule), `@OnEvent` (event), and `@Processor` (bullmq) instrumentations in the orchestrion implementation. At this point, it's still not wired up by default into the SDK, but all of the functionality is there. Next step is the final wire-up and opt-in to swap out the OTel NestJS for this Orchestrion implementation.
1 parent e75fd9d commit d314ba3

7 files changed

Lines changed: 505 additions & 30 deletions

File tree

packages/server-utils/src/integrations/tracing-channel/nestjs-decorators.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,7 @@ import {
1010
startSpanManual,
1111
withActiveSpan,
1212
} from '@sentry/core';
13-
14-
/**
15-
* A function of unknown signature.
16-
*/
17-
export type AnyFn = (this: unknown, ...args: unknown[]) => unknown;
13+
import type { AnyFn } from './nestjs-shared';
1814

1915
const OP_MIDDLEWARE = 'middleware.nestjs';
2016
const ORIGIN_MIDDLEWARE = 'auto.middleware.nestjs';
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import * as diagnosticsChannel from 'node:diagnostics_channel';
2+
import {
3+
captureException,
4+
isThenable,
5+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
6+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
7+
startSpan,
8+
withIsolationScope,
9+
} from '@sentry/core';
10+
import { CHANNELS } from '../../orchestrion/channels';
11+
import type { AnyFn, ChannelContext } from './nestjs-shared';
12+
import { isWrapped, markWrapped } from './nestjs-shared';
13+
14+
const NOOP = (): void => {};
15+
16+
// Mechanism types for scheduled-handler error capture (no span)
17+
// match vendored `SentryNestScheduleInstrumentation`
18+
const MECHANISM_CRON = 'auto.function.nestjs.cron';
19+
const MECHANISM_INTERVAL = 'auto.function.nestjs.interval';
20+
const MECHANISM_TIMEOUT = 'auto.function.nestjs.timeout';
21+
const MECHANISM_EVENT = 'auto.event.nestjs';
22+
const MECHANISM_BULLMQ = 'auto.queue.nestjs.bullmq';
23+
24+
const EVENT_LISTENER_METADATA = 'EVENT_LISTENER_METADATA';
25+
26+
interface ReflectWithMetadata {
27+
getMetadataKeys?: (target: object) => unknown[];
28+
getMetadata?: (key: unknown, target: object) => unknown;
29+
}
30+
31+
/**
32+
* The class a `@Processor` decorator is applied to (a BullMQ queue processor). */
33+
interface ProcessorTarget {
34+
__SENTRY_INTERNAL__?: boolean;
35+
prototype?: { process?: AnyFn };
36+
}
37+
38+
function captureHandlerError(error: unknown, mechanismType: string): void {
39+
captureException(error, { mechanism: { handled: false, type: mechanismType } });
40+
}
41+
42+
/**
43+
* Wrap a scheduled handler (`@Cron`/`@Interval`/`@Timeout`): fork the
44+
* isolation scope and capture errors. NOT async. Preserve the handler's sync
45+
* return type, so sync and async errors are handled on separate paths
46+
* matches vendored OTel implementation
47+
*/
48+
function wrapScheduleHandler(handler: AnyFn, mechanismType: string): AnyFn {
49+
return function (this: unknown, ...args: unknown[]): unknown {
50+
return withIsolationScope(() => {
51+
let result: unknown;
52+
try {
53+
result = handler.apply(this, args);
54+
} catch (error) {
55+
captureHandlerError(error, mechanismType);
56+
throw error;
57+
}
58+
if (isThenable(result)) {
59+
return result.then(undefined, (error: unknown) => {
60+
captureHandlerError(error, mechanismType);
61+
throw error;
62+
});
63+
}
64+
return result;
65+
});
66+
};
67+
}
68+
69+
function eventNameFromEvent(event: unknown): string {
70+
if (typeof event === 'string') {
71+
return event;
72+
}
73+
if (Array.isArray(event)) {
74+
return event.map(eventNameFromEvent).join(',');
75+
}
76+
return String(event);
77+
}
78+
79+
/**
80+
* Derive the event name(s) for an @OnEvent span. The wrapped handler carries
81+
* `EVENT_LISTENER_METADATA` (set by the original decorator), which lists every
82+
* event when multiple @OnEvent decorators are stacked; fall back to the event
83+
* captured from the decorator factory.
84+
*/
85+
function deriveEventName(handler: AnyFn, fallbackEvent: unknown): string {
86+
const R = Reflect as unknown as ReflectWithMetadata;
87+
if (typeof R.getMetadataKeys === 'function' && typeof R.getMetadata === 'function') {
88+
if (R.getMetadataKeys(handler)?.includes(EVENT_LISTENER_METADATA)) {
89+
const eventData = R.getMetadata(EVENT_LISTENER_METADATA, handler);
90+
if (Array.isArray(eventData)) {
91+
return (eventData as unknown[])
92+
.map(entry => {
93+
const event = entry && typeof entry === 'object' ? (entry as { event?: unknown }).event : undefined;
94+
return event ? eventNameFromEvent(event) : '';
95+
})
96+
.reverse() // decorators evaluate bottom to top
97+
.join('|');
98+
}
99+
}
100+
}
101+
return eventNameFromEvent(fallbackEvent);
102+
}
103+
104+
/**
105+
* Wrap an @OnEvent handler: fork the isolation scope, open an `event.nestjs`
106+
* transaction, and capture errors. (event-handler errors bypass the global
107+
* filter)
108+
*/
109+
function wrapEventHandler(handler: AnyFn, fallbackEvent: unknown): AnyFn {
110+
const wrapped = async function (this: unknown, ...args: unknown[]): Promise<unknown> {
111+
const eventName = deriveEventName(wrapped, fallbackEvent);
112+
return withIsolationScope(() =>
113+
startSpan(
114+
{
115+
name: `event ${eventName}`,
116+
attributes: {
117+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs',
118+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MECHANISM_EVENT,
119+
},
120+
forceTransaction: true,
121+
},
122+
async () => {
123+
try {
124+
return await handler.apply(this, args);
125+
} catch (error) {
126+
captureHandlerError(error, MECHANISM_EVENT);
127+
throw error;
128+
}
129+
},
130+
),
131+
);
132+
};
133+
return wrapped;
134+
}
135+
136+
/**
137+
* Wrap a BullMQ `process` method: fork the isolation scope, open a
138+
* `queue.process` transaction, and capture errors.
139+
*/
140+
function wrapBullMQProcess(process: AnyFn, queueName: string): AnyFn {
141+
return function (this: unknown, ...args: unknown[]): unknown {
142+
return withIsolationScope(() =>
143+
startSpan(
144+
{
145+
name: `${queueName} process`,
146+
attributes: {
147+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process',
148+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MECHANISM_BULLMQ,
149+
'messaging.system': 'bullmq',
150+
'messaging.destination.name': queueName,
151+
},
152+
forceTransaction: true,
153+
},
154+
async () => {
155+
try {
156+
return await process.apply(this, args);
157+
} catch (error) {
158+
captureHandlerError(error, MECHANISM_BULLMQ);
159+
throw error;
160+
}
161+
},
162+
),
163+
);
164+
};
165+
}
166+
167+
/**
168+
* Wrap a method decorator (the function the factory returns for
169+
* `@Cron`/`@Interval`/`@Timeout`/`@OnEvent`) so it replaces
170+
* `descriptor.value` with a wrapped handler before delegating to the
171+
* original decorator (which then attaches its metadata to our wrapper).
172+
*/
173+
function makeMethodDecorator(original: AnyFn, wrapHandler: (handler: AnyFn) => AnyFn): AnyFn {
174+
return function (this: unknown, ...args: unknown[]): unknown {
175+
const target = args[0] as { __SENTRY_INTERNAL__?: boolean } | undefined;
176+
const propertyKey = args[1];
177+
const descriptor = args[2] as PropertyDescriptor | undefined;
178+
const handler = descriptor?.value;
179+
if (handler && typeof handler === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(handler as AnyFn)) {
180+
const wrapped = wrapHandler(handler as AnyFn);
181+
Object.defineProperty(wrapped, 'name', {
182+
value: (handler as AnyFn).name || String(propertyKey),
183+
configurable: true,
184+
});
185+
markWrapped(wrapped);
186+
descriptor.value = wrapped;
187+
}
188+
return original.apply(this, args);
189+
};
190+
}
191+
192+
/**
193+
* Wrap the class decorator @Processor returns so it patches
194+
* `target.prototype.process` before delegating to the original decorator.
195+
*/
196+
function makeProcessorDecorator(original: AnyFn, queueName: string): AnyFn {
197+
return function (this: unknown, ...args: unknown[]): unknown {
198+
const target = args[0] as ProcessorTarget | undefined;
199+
const process = target?.prototype?.process;
200+
if (process && typeof process === 'function' && !target?.__SENTRY_INTERNAL__ && !isWrapped(process)) {
201+
const wrapped = wrapBullMQProcess(process, queueName);
202+
markWrapped(wrapped);
203+
target.prototype!.process = wrapped;
204+
}
205+
return original.apply(this, args);
206+
};
207+
}
208+
209+
function extractQueueName(arg: unknown): string {
210+
if (typeof arg === 'string') {
211+
return arg;
212+
}
213+
if (arg && typeof arg === 'object' && 'name' in arg && typeof (arg as { name?: unknown }).name === 'string') {
214+
return (arg as { name: string }).name;
215+
}
216+
return 'unknown';
217+
}
218+
219+
/**
220+
* Subscribe to a decorator-factory channel. `end` reassigns `data.result` (the
221+
* decorator the factory returns) with a wrapped version -> `traceSync` returns
222+
* whatever `end` leaves there. `wrap` receives the original decorator and the
223+
* channel context (for the factory's args, e.g. the BullMQ queue name).
224+
*/
225+
function subscribeFactoryDecorator(channelName: string, wrap: (decorator: AnyFn, data: ChannelContext) => AnyFn): void {
226+
diagnosticsChannel.tracingChannel<ChannelContext>(channelName).subscribe({
227+
start: NOOP,
228+
end(data) {
229+
const decorator = data.result;
230+
if (typeof decorator === 'function' && !isWrapped(decorator as AnyFn)) {
231+
const wrapped = wrap(decorator as AnyFn, data);
232+
markWrapped(wrapped);
233+
data.result = wrapped;
234+
}
235+
},
236+
asyncStart: NOOP,
237+
asyncEnd: NOOP,
238+
error: NOOP,
239+
});
240+
}
241+
242+
/**
243+
* Subscribe the @Cron/@Interval/@Timeout (schedule), @OnEvent (event-emitter)
244+
* and @Processor (bullmq) decorator channels. Each `end` handler reassigns the
245+
* decorator the factory returns (via `data.result`) with one that wraps the
246+
* user handler (schedule/event) or the `process` method (bullmq).
247+
*/
248+
export function subscribeNestHandlerDecorators(): void {
249+
subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_CRON, decorator =>
250+
makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_CRON)),
251+
);
252+
subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_INTERVAL, decorator =>
253+
makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_INTERVAL)),
254+
);
255+
subscribeFactoryDecorator(CHANNELS.NESTJS_SCHEDULE_TIMEOUT, decorator =>
256+
makeMethodDecorator(decorator, handler => wrapScheduleHandler(handler, MECHANISM_TIMEOUT)),
257+
);
258+
subscribeFactoryDecorator(CHANNELS.NESTJS_ONEVENT, (decorator, data) =>
259+
makeMethodDecorator(decorator, handler => wrapEventHandler(handler, data.arguments?.[0])),
260+
);
261+
subscribeFactoryDecorator(CHANNELS.NESTJS_PROCESSOR, (decorator, data) =>
262+
makeProcessorDecorator(decorator, extractQueueName(data.arguments?.[0])),
263+
);
264+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/** A function of unknown signature, matching the methods/handlers we wrap. */
2+
export type AnyFn = (this: unknown, ...args: unknown[]) => unknown;
3+
4+
/**
5+
* The orchestrion tracing-channel context. `arguments` is the live call args
6+
* array; `result` is the return value, which an `end` handler may reassign to
7+
* substitute it (`traceSync`/`tracePromise` always return `ctx.result`).
8+
*/
9+
export interface ChannelContext {
10+
arguments: unknown[];
11+
moduleVersion?: string;
12+
result?: unknown;
13+
error?: unknown;
14+
}
15+
16+
/**
17+
* Marks a function as already wrapped so repeated subscriptions (eg a second
18+
* `setupOnce`) or multiple decorators on one method don't double-wrap it.
19+
*/
20+
const SENTRY_WRAPPED = Symbol.for('sentry.orchestrion.nestjs.wrapped');
21+
22+
/** Whether `fn` has already been wrapped by this integration. */
23+
export function isWrapped(fn: AnyFn): boolean {
24+
return !!(fn as AnyFn & Record<symbol, unknown>)[SENTRY_WRAPPED];
25+
}
26+
27+
/** Mark `fn` as wrapped (see {@link isWrapped}). */
28+
export function markWrapped(fn: AnyFn): void {
29+
(fn as AnyFn & Record<symbol, unknown>)[SENTRY_WRAPPED] = true;
30+
}

packages/server-utils/src/integrations/tracing-channel/nestjs.ts

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import { debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInacti
44
import { DEBUG_BUILD } from '../../debug-build';
55
import { CHANNELS } from '../../orchestrion/channels';
66
import { bindTracingChannelToSpan } from '../../tracing-channel';
7-
import type { AnyFn, CatchTarget, InjectableTarget } from './nestjs-decorators';
7+
import type { CatchTarget, InjectableTarget } from './nestjs-decorators';
88
import { patchCatchTarget, patchInjectableTarget } from './nestjs-decorators';
9+
import { subscribeNestHandlerDecorators } from './nestjs-handler-wrappers';
10+
import type { AnyFn, ChannelContext } from './nestjs-shared';
11+
import { isWrapped, markWrapped } from './nestjs-shared';
912

1013
// NOTE: this uses the same name as the OTel integration by design.
1114
// When enabled, the OTel 'Nest' integration is omitted from the default set.
@@ -33,30 +36,6 @@ const TYPE_REQUEST_HANDLER = 'handler';
3336

3437
const NOOP = (): void => {};
3538

36-
// Marks a function as already wrapped so repeated subscriptions (e.g. a second
37-
// `setupOnce`) don't double-wrap a callback or returned handler.
38-
const SENTRY_WRAPPED = Symbol.for('sentry.orchestrion.nestjs.wrapped');
39-
40-
function isWrapped(fn: AnyFn): boolean {
41-
return !!(fn as AnyFn & Record<symbol, unknown>)[SENTRY_WRAPPED];
42-
}
43-
44-
function markWrapped(fn: AnyFn): void {
45-
(fn as AnyFn & Record<symbol, unknown>)[SENTRY_WRAPPED] = true;
46-
}
47-
48-
/**
49-
* The orchestrion tracing-channel context. `arguments` is the live call args
50-
* array; `result` is the return value, which an `end` handler may reassign to
51-
* substitute it (`traceSync`/`tracePromise` always return `ctx.result`).
52-
*/
53-
interface ChannelContext {
54-
arguments: unknown[];
55-
moduleVersion?: string;
56-
result?: unknown;
57-
error?: unknown;
58-
}
59-
6039
/** Minimal request shape, across the express/fastify adapters. */
6140
interface NestRequest {
6241
route?: { path?: string };
@@ -263,6 +242,9 @@ const _nestjsChannelIntegration = (() => {
263242
patchInjectableTarget(target, seenInterceptorContexts),
264243
);
265244
subscribeDecoratorChannel<CatchTarget>(CHANNELS.NESTJS_CATCH, patchCatchTarget);
245+
246+
// @Cron/@Interval/@Timeout (schedule), @OnEvent (event), @Processor (bullmq).
247+
subscribeNestHandlerDecorators();
266248
},
267249
};
268250
}) satisfies IntegrationFn;

packages/server-utils/src/orchestrion/channels.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export const CHANNELS = {
2121
NESTJS_ROUTER_CONTEXT: 'orchestrion:@nestjs/core:routerExecutionContextCreate',
2222
NESTJS_INJECTABLE: 'orchestrion:@nestjs/common:injectableDecorator',
2323
NESTJS_CATCH: 'orchestrion:@nestjs/common:catchDecorator',
24+
NESTJS_SCHEDULE_CRON: 'orchestrion:@nestjs/schedule:cronDecorator',
25+
NESTJS_SCHEDULE_INTERVAL: 'orchestrion:@nestjs/schedule:intervalDecorator',
26+
NESTJS_SCHEDULE_TIMEOUT: 'orchestrion:@nestjs/schedule:timeoutDecorator',
27+
NESTJS_ONEVENT: 'orchestrion:@nestjs/event-emitter:onEventDecorator',
28+
NESTJS_PROCESSOR: 'orchestrion:@nestjs/bullmq:processorDecorator',
2429
} as const;
2530

2631
export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS];

0 commit comments

Comments
 (0)