Skip to content

Commit 0997aa2

Browse files
committed
ref(server-utils, nestjs): move NestJS orchestrion configs out of server-utils
Introduce a dependency-injection approach to setting orchestrion configuration from a standalone SDK that is not a part of the "normal" tracing integrations reasonable to put in server-utils. This also allows for better DRY sharing of reusable components between the OTel and Orchestrion implementations of the NestJS integration. The major change is that certain items like the list of Orchestrion instrumentations, need to be fetched on demand with a method, rather than being a static `as const` array. Dependency cycle is still avoided, but all NestJS code is now in packages/nest where it belongs.
1 parent 2c11400 commit 0997aa2

33 files changed

Lines changed: 996 additions & 1346 deletions

packages/bun/src/plugin.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ type UnknownPlugin = any;
3838
// module system.
3939
import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/bun';
4040
import {
41-
INSTRUMENTED_MODULE_NAMES,
41+
instrumentedModuleNames,
4242
SENTRY_INSTRUMENTATIONS,
4343
withoutInstrumentedExternals,
4444
} from '@sentry/server-utils/orchestrion/config';
45+
import type { OrchestrionInstrumentation } from '@sentry/server-utils/orchestrion';
4546

4647
const BUNDLER_MARKER_BANNER =
4748
';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=true;';
@@ -61,18 +62,25 @@ interface BunPluginBuilder {
6162
*
6263
* Pass the result to `Bun.build({ plugins: [...] })`.
6364
*
65+
* A framework SDK that owns its own instrumentation can inject its
66+
* `OrchestrionInstrumentation` descriptor via the `instrumentations` option, merged with the
67+
* built-in configs.
68+
*
6469
* @example
6570
* ```ts
6671
* import { sentryBunPlugin } from '@sentry/bun/plugin';
6772
* await Bun.build({ entrypoints: ['./app.ts'], plugins: [sentryBunPlugin()] });
6873
* ```
6974
*/
70-
export function sentryBunPlugin(): UnknownPlugin {
75+
export function sentryBunPlugin(options?: { instrumentations?: OrchestrionInstrumentation[] }): UnknownPlugin {
76+
const instrumentations = [...SENTRY_INSTRUMENTATIONS, ...(options?.instrumentations ?? []).flatMap(i => i.configs)];
77+
const moduleNames = instrumentedModuleNames(instrumentations);
78+
7179
// Typed upstream as an esbuild `Plugin`, but Bun passes its own
7280
// `PluginBuilder` (which has the `onLoad` the transform uses) to `setup`.
7381
// Cast to the Bun-compatible shape so we can forward Bun's builder to its
7482
// `setup`.
75-
const transformer = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }) as unknown as {
83+
const transformer = codeTransformer({ instrumentations }) as unknown as {
7684
setup: (build: BunPluginBuilder) => void;
7785
};
7886

@@ -91,7 +99,7 @@ export function sentryBunPlugin(): UnknownPlugin {
9199
// the transform's `onLoad`, so its diagnostics_channel calls would
92100
// be silently never injected. Bun has no runtime fallback here, so
93101
// bundling is the only injection path.
94-
build.config.external = withoutInstrumentedExternals(build.config.external);
102+
build.config.external = withoutInstrumentedExternals(build.config.external, moduleNames);
95103

96104
// A blanket externalization strategy like `packages: 'external'` or
97105
// `'*'` in `external` externalizes instrumented packages too, and
@@ -113,7 +121,7 @@ export function sentryBunPlugin(): UnknownPlugin {
113121
console.warn(
114122
`[Sentry] This Bun build externalizes all dependencies (${blanketExternal}), so Sentry ` +
115123
'cannot instrument bundled libraries. Instrumentation will be missing for any of ' +
116-
`these packages your app uses: ${INSTRUMENTED_MODULE_NAMES.join(', ')}. To instrument them, ` +
124+
`these packages your app uses: ${moduleNames.join(', ')}. To instrument them, ` +
117125
'externalize only the specific packages you need external instead of all of them.',
118126
);
119127
}

packages/bun/src/sdk.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
onUnhandledRejectionIntegration,
2323
processSessionIntegration,
2424
} from '@sentry/node';
25-
import { channelIntegrations, isOrchestrionInjected } from '@sentry/server-utils/orchestrion';
25+
import { getChannelIntegrations, isOrchestrionInjected } from '@sentry/server-utils/orchestrion';
2626
import { bunServerIntegration } from './integrations/bunserver';
2727
import { makeFetchTransport } from './transports';
2828
import type { BunOptions } from './types';
@@ -31,8 +31,8 @@ import type { BunOptions } from './types';
3131
* The orchestrion channel-subscriber integrations, listening on the diagnostics
3232
* channels that `@sentry/bun/plugin` injects at build time.
3333
*/
34-
function getChannelIntegrations(): Integration[] {
35-
return Object.values(channelIntegrations).map(integrationFactory => integrationFactory());
34+
function getChannelIntegrationInstances(): Integration[] {
35+
return getChannelIntegrations().map(integrationFactory => integrationFactory());
3636
}
3737

3838
/**
@@ -53,7 +53,7 @@ function getPerformanceIntegrations(options: Options): Integration[] {
5353
return autoPerformanceIntegrations;
5454
}
5555

56-
const channelIntegrationInstances = getChannelIntegrations();
56+
const channelIntegrationInstances = getChannelIntegrationInstances();
5757
// The OTel integrations these channel subscribers replace, keyed by the name they share with them.
5858
const replacedOtelIntegrationNames = new Set(channelIntegrationInstances.map(integration => integration.name));
5959

packages/nestjs/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@
3838
"types": "./setup.d.ts",
3939
"default": "./build/cjs/setup.js"
4040
}
41+
},
42+
"./import": {
43+
"import": {
44+
"default": "./build/import.mjs"
45+
}
4146
}
4247
},
4348
"publishConfig": {
@@ -48,7 +53,8 @@
4853
"@opentelemetry/instrumentation": "^0.220.0",
4954
"@sentry/conventions": "^0.15.1",
5055
"@sentry/core": "10.64.0",
51-
"@sentry/node": "10.64.0"
56+
"@sentry/node": "10.64.0",
57+
"@sentry/server-utils": "10.64.0"
5258
},
5359
"devDependencies": {
5460
"@nestjs/common": "^10.0.0",
Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,29 @@
1+
import { defineConfig } from 'rollup';
12
import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils';
23

3-
export default makeNPMConfigVariants(
4-
makeBaseNPMConfig({
5-
entrypoints: ['src/index.ts', 'src/setup.ts'],
4+
// EXPERIMENTAL: NestJS orchestrion `--import` runtime hook. A hand-written
5+
// `.mjs` shim referenced via `node --import @sentry/nestjs/import`. We pass it
6+
// through rollup only to copy it into `build/import.mjs` at the path the
7+
// package.json `exports` map expects; `external: /.*/` keeps every import (e.g.
8+
// `@sentry/nestjs/orchestrion`) as a runtime resolution against the installed
9+
// package.
10+
const orchestrionRuntimeHooks = [
11+
defineConfig({
12+
input: 'src/import.mjs',
13+
external: /.*/,
14+
output: { format: 'esm', file: 'build/import.mjs' },
615
}),
7-
);
16+
];
17+
18+
export default [
19+
...orchestrionRuntimeHooks,
20+
...makeNPMConfigVariants(
21+
makeBaseNPMConfig({
22+
// `src/orchestrion/index.ts` is internal (NOT a public export). It's listed
23+
// as an entrypoint only to guarantee a stable `build/esm/orchestrion/index.js`
24+
// output path, which the `build/import.mjs` `--import` hook references via a
25+
// relative import to register the `nestjsOrchestrion` descriptor.
26+
entrypoints: ['src/index.ts', 'src/setup.ts', 'src/orchestrion/index.ts'],
27+
}),
28+
),
29+
];

packages/nestjs/src/debug-build.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
declare const __DEBUG_BUILD__: boolean;
2+
3+
/**
4+
* This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code.
5+
*
6+
* ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking.
7+
*/
8+
export const DEBUG_BUILD = __DEBUG_BUILD__;

packages/nestjs/src/import.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// EXPERIMENTAL: NestJS diagnostics-channel injection runtime hook. The
2+
// side-effecting `--import` entry (e.g. `node --import @sentry/nestjs/import app.js`)
3+
// that injects the @nestjs channels unconditionally before the app loads.
4+
//
5+
// Order matters and static ESM imports are hoisted: we import the register
6+
// FUNCTION (not a side-effecting base hook) and call it AFTER registering the
7+
// NestJS instrumentation, so the transform config includes the @nestjs configs.
8+
// The `nestjsOrchestrion` descriptor is an internal detail (not a public export),
9+
// so we reach it via a relative path into this package's own build output;
10+
// importing it has no side effects. It only defines the descriptor.
11+
//
12+
// This file ships verbatim to `build/import.mjs`; the relative import below
13+
// resolves to `build/esm/orchestrion/index.js` at runtime.
14+
15+
import { registerOrchestrionInstrumentation } from '@sentry/server-utils/orchestrion';
16+
import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register';
17+
import { nestjsOrchestrion } from './esm/orchestrion/index.js';
18+
19+
registerOrchestrionInstrumentation(nestjsOrchestrion);
20+
registerDiagnosticsChannelInjection();

packages/nestjs/src/integrations/helpers.ts

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,42 +5,87 @@ import {
55
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
66
withActiveSpan,
77
} from '@sentry/core';
8+
import { isOrchestrionInjected } from '@sentry/server-utils/orchestrion';
89
import type { CatchTarget, InjectableTarget, NextFunction, Observable, Subscription } from './types';
910

10-
const sentryPatched = 'sentryPatched';
11+
/** A function of unknown signature, matching the methods/handlers we wrap. */
12+
export type AnyFn = (this: unknown, ...args: unknown[]) => unknown;
1113

1214
/**
13-
* Helper checking if a concrete target class is already patched.
15+
* Marks a function as already wrapped so repeated subscriptions/decoration
16+
* don't double-wrap it. Shared by the OTel and orchestrion paths.
17+
*/
18+
const SENTRY_WRAPPED = Symbol.for('sentry.nestjs.wrapped');
19+
20+
/** Whether `fn` has already been wrapped by this integration. */
21+
export function isWrapped(fn: AnyFn): boolean {
22+
return !!(fn as AnyFn & Record<symbol, unknown>)[SENTRY_WRAPPED];
23+
}
24+
25+
/** Mark `fn` as wrapped (see {@link isWrapped}). */
26+
export function markWrapped(fn: AnyFn): void {
27+
(fn as AnyFn & Record<symbol, unknown>)[SENTRY_WRAPPED] = true;
28+
}
29+
30+
/**
31+
* Mark a target class as patched (for the given pass) so it's instrumented only
32+
* once, and to stay idempotent across repeated subscriptions/decoration.
1433
*
15-
* We already guard duplicate patching with isWrapped. However, isWrapped checks whether a file has been patched, whereas we use this check for concrete target classes.
16-
* This check might not be necessary, but better to play it safe.
34+
* The `@Injectable` and `@Catch` passes use *separate* flags on purpose: they
35+
* wrap disjoint method sets (use/canActivate/transform/intercept vs catch), and
36+
* a class can be decorated with both (an exception filter that also uses DI).
37+
* A single shared flag would let whichever pass fired first latch it and block
38+
* the other, dropping that pass's spans regardless of ordering.
1739
*/
18-
export function isPatched(target: InjectableTarget | CatchTarget): boolean {
19-
if (target.sentryPatched) {
40+
export function isTargetPatched(target: object, flag: 'sentryPatchedInjectable' | 'sentryPatchedCatch'): boolean {
41+
if ((target as Record<string, unknown>)[flag]) {
2042
return true;
2143
}
22-
23-
addNonEnumerableProperty(target, sentryPatched, true);
44+
addNonEnumerableProperty(target, flag, true);
2445
return false;
2546
}
2647

48+
// The instrumentation path is reflected in the span origin: orchestrion-created
49+
// spans carry an `orchestrion` segment so they're distinguishable from OTel.
50+
// Only one path is ever live in a process (the OTel `Nest` integration is
51+
// swapped out whenever orchestrion is injected), so the global injection flag
52+
// reliably selects the right origin. Everything else about the span is identical.
53+
54+
/** Origin for middleware/guard/pipe/interceptor/exception_filter spans. */
55+
function middlewareOrigin(componentType?: string): string {
56+
const base = isOrchestrionInjected() ? 'auto.middleware.orchestrion.nestjs' : 'auto.middleware.nestjs';
57+
return componentType ? `${base}.${componentType}` : base;
58+
}
59+
60+
/** Origin for the app-creation / request-context / request-handler HTTP spans. */
61+
export function httpOrigin(): string {
62+
return isOrchestrionInjected() ? 'auto.http.orchestrion.nestjs' : 'auto.http.otel.nestjs';
63+
}
64+
65+
/** Origin for `@OnEvent` spans. */
66+
function eventOrigin(): string {
67+
return isOrchestrionInjected() ? 'auto.event.orchestrion.nestjs' : 'auto.event.nestjs';
68+
}
69+
70+
/** Origin for BullMQ `@Processor` `process` spans. */
71+
function bullmqOrigin(): string {
72+
return isOrchestrionInjected() ? 'auto.queue.orchestrion.nestjs.bullmq' : 'auto.queue.nestjs.bullmq';
73+
}
74+
2775
/**
2876
* Returns span options for nest middleware spans.
77+
* name = provided name or class name.
2978
*/
30-
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
3179
export function getMiddlewareSpanOptions(
32-
target: InjectableTarget | CatchTarget,
80+
target: InjectableTarget | CatchTarget | { name?: string },
3381
name: string | undefined = undefined,
3482
componentType: string | undefined = undefined,
35-
) {
36-
const span_name = name ?? target.name; // fallback to class name if no name is provided
37-
const origin = componentType ? `auto.middleware.nestjs.${componentType}` : 'auto.middleware.nestjs';
38-
83+
): { name: string; attributes: Record<string, string> } {
3984
return {
40-
name: span_name,
85+
name: name ?? target.name ?? 'unknown',
4186
attributes: {
4287
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs',
43-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin,
88+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: middlewareOrigin(componentType),
4489
},
4590
};
4691
}
@@ -57,7 +102,7 @@ export function getEventSpanOptions(event: string): {
57102
name: `event ${event}`,
58103
attributes: {
59104
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs',
60-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.event.nestjs',
105+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: eventOrigin(),
61106
},
62107
forceTransaction: true,
63108
};
@@ -75,7 +120,7 @@ export function getBullMQProcessSpanOptions(queueName: string): {
75120
name: `${queueName} process`,
76121
attributes: {
77122
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process',
78-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.nestjs.bullmq',
123+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: bullmqOrigin(),
79124
'messaging.system': 'bullmq',
80125
'messaging.destination.name': queueName,
81126
},

packages/nestjs/src/integrations/sentry-nest-bullmq-instrumentation.ts

Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import {
55
InstrumentationNodeModuleFile,
66
isWrapped,
77
} from '@opentelemetry/instrumentation';
8-
import { captureException, SDK_VERSION, startSpan, withIsolationScope } from '@sentry/core';
9-
import { getBullMQProcessSpanOptions } from './helpers';
8+
import { SDK_VERSION } from '@sentry/core';
109
import type { ProcessorDecoratorTarget } from './types';
10+
import { extractQueueName, patchProcessorTarget } from './wrap-handlers';
1111

1212
const supportedVersions = ['>=10.0.0'];
1313
const COMPONENT = '@nestjs/bullmq';
@@ -18,6 +18,9 @@ const COMPONENT = '@nestjs/bullmq';
1818
* This hooks into the `@Processor` class decorator, which is applied on queue processor classes.
1919
* It wraps the `process` method on the decorated class to fork the isolation scope for each job
2020
* invocation, create a span, and capture errors.
21+
*
22+
* The `process`-wrapping logic lives in `./wrappers` and is shared with the orchestrion
23+
* (diagnostics-channel) path.
2124
*/
2225
export class SentryNestBullMQInstrumentation extends InstrumentationBase {
2326
public constructor(config: InstrumentationConfig = {}) {
@@ -62,50 +65,13 @@ export class SentryNestBullMQInstrumentation extends InstrumentationBase {
6265
return function wrapProcessor(original: any) {
6366
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6467
return function wrappedProcessor(...decoratorArgs: any[]) {
65-
// Extract queue name from decorator args
66-
// @Processor('queueName') or @Processor({ name: 'queueName' })
67-
const queueName =
68-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
69-
typeof decoratorArgs[0] === 'string' ? decoratorArgs[0] : decoratorArgs[0]?.name || 'unknown';
68+
const queueName = extractQueueName(decoratorArgs[0]);
7069

71-
// Get the original class decorator
7270
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
7371
const classDecorator = original(...decoratorArgs);
7472

75-
// Return a new class decorator that wraps the process method
7673
return function (target: ProcessorDecoratorTarget) {
77-
const originalProcess = target.prototype.process;
78-
79-
if (
80-
originalProcess &&
81-
typeof originalProcess === 'function' &&
82-
!target.__SENTRY_INTERNAL__ &&
83-
!originalProcess.__SENTRY_INSTRUMENTED__
84-
) {
85-
target.prototype.process = new Proxy(originalProcess, {
86-
apply: (originalProcessFn, thisArg, args) => {
87-
return withIsolationScope(() => {
88-
return startSpan(getBullMQProcessSpanOptions(queueName), async () => {
89-
try {
90-
return await originalProcessFn.apply(thisArg, args);
91-
} catch (error) {
92-
captureException(error, {
93-
mechanism: {
94-
handled: false,
95-
type: 'auto.queue.nestjs.bullmq',
96-
},
97-
});
98-
throw error;
99-
}
100-
});
101-
});
102-
},
103-
});
104-
105-
target.prototype.process.__SENTRY_INSTRUMENTED__ = true;
106-
}
107-
108-
// Apply the original class decorator
74+
patchProcessorTarget(target, queueName);
10975
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
11076
return classDecorator(target);
11177
};

0 commit comments

Comments
 (0)