Skip to content

Commit b76d21e

Browse files
nicohrubecclaude
andauthored
feat(server-utils): Add lru-memoizer diagnostics-channel integration (#21786)
Adds an orchestrion-based lru-memoizer integration replacing the equivalent otel integration if enabled. This currently supports `>=2.1.0`, which should be sufficient I think since `<2.1.0` is [barely used anymore](https://www.npmjs.com/package/lru-memoizer?activeTab=versions). Closes #20759 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3c1754b commit b76d21e

7 files changed

Lines changed: 182 additions & 42 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Opting in via `experimentalUseDiagnosticsChannelInjection()` (before `init`)
2+
// is all that's needed.
3+
//
4+
// `Sentry.init()` swaps the OTel `lru-memoizer` instrumentation
5+
// for the diagnostics-channel one and synchronously
6+
// installs the module hooks that inject the channels.
7+
import * as Sentry from '@sentry/node';
8+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
9+
10+
Sentry.experimentalUseDiagnosticsChannelInjection();
11+
12+
Sentry.init({
13+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
14+
release: '1.0',
15+
tracesSampleRate: 1.0,
16+
transport: loggingTransport,
17+
});

dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts

Lines changed: 85 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,51 +7,97 @@ describe('lru-memoizer', () => {
77
cleanupChildProcesses();
88
});
99

10-
createEsmAndCjsTests(
11-
__dirname,
12-
'scenario.mjs',
13-
'instrument.mjs',
14-
(createTestRunner, test) => {
15-
test('keeps outer context inside the memoized inner functions', async () => {
16-
await createTestRunner()
17-
.expect({
10+
// Each case maps to the OpenTelemetry default and the diagnostics-channel opt-in
11+
// variants, mirroring the mysql suite. `flags` are extra Node CLI flags; the
12+
// instrument file is always loaded via `--import` (esm) / `--require` (cjs).
13+
//
14+
// lru-memoizer creates no spans, so there's no `sentry.origin` to
15+
// assert: the opt-in cases prove the channel ran because the opt-in removes the
16+
// OTel integration via `replacedOtelIntegrationNames`.
17+
const CASES = [
18+
// OpenTelemetry default — no opt-in, no injection. (OTel does not support ESM.)
19+
{ label: 'opentelemetry (default)', instrument: 'instrument.mjs', flags: [], failsOnEsm: true },
20+
// Opt-in via init only. `Sentry.init()` injects the channels synchronously.
21+
{
22+
label: 'diagnostics-channel (init opt-in)',
23+
instrument: 'instrument-orchestrion.mjs',
24+
flags: [],
25+
failsOnEsm: false,
26+
},
27+
// Opt-in and rely on `node --import @sentry/node/import`.
28+
{
29+
label: 'diagnostics-channel (--import @sentry/node/import opt-in)',
30+
instrument: 'instrument-orchestrion.mjs',
31+
flags: ['--import', '@sentry/node/import'],
32+
failsOnEsm: false,
33+
},
34+
// Without opt-in: channels are injected unconditionally but not subscribed to,
35+
// so the OTel instrumentation does the work — proves injecting the channels has
36+
// no downside. (OTel does not support ESM.)
37+
{
38+
label: 'opentelemetry (channels injected, no opt-in)',
39+
instrument: 'instrument.mjs',
40+
flags: ['--import', '@sentry/node/import'],
41+
failsOnEsm: true,
42+
},
43+
] as const;
44+
45+
for (const { label, instrument, flags, failsOnEsm } of CASES) {
46+
describe(label, () => {
47+
createEsmAndCjsTests(
48+
__dirname,
49+
'scenario.mjs',
50+
instrument,
51+
(createTestRunner, test) => {
52+
test('keeps outer context inside the memoized inner functions', async () => {
53+
await createTestRunner()
54+
.withFlags(...flags)
55+
.expect({
56+
transaction: {
57+
transaction: '<unknown>',
58+
contexts: {
59+
trace: expect.objectContaining({
60+
op: 'run',
61+
data: expect.objectContaining({
62+
'sentry.op': 'run',
63+
'sentry.origin': 'manual',
64+
'memoized.context_preserved': true,
65+
}),
66+
}),
67+
},
68+
},
69+
})
70+
.start()
71+
.completed();
72+
});
73+
},
74+
{ failsOnEsm },
75+
);
76+
77+
// CJS-only: the parallel scenario is flaky in ESM (see #21729).
78+
createCjsTests(__dirname, 'scenario-parallel.mjs', instrument, (createTestRunner, test) => {
79+
test('keeps each span context across parallel memoized requests', async () => {
80+
// Each parallel request emits a transaction whose callback must have run in its own context.
81+
// Two identical expectations keep this order-independent.
82+
const expectation = {
1883
transaction: {
19-
transaction: '<unknown>',
2084
contexts: {
2185
trace: expect.objectContaining({
22-
op: 'run',
23-
data: expect.objectContaining({
24-
'sentry.op': 'run',
25-
'sentry.origin': 'manual',
26-
'memoized.context_preserved': true,
27-
}),
86+
op: expect.stringMatching(/^(first|second)$/),
87+
data: expect.objectContaining({ 'memoized.context_preserved': true }),
2888
}),
2989
},
3090
},
31-
})
32-
.start()
33-
.completed();
34-
});
35-
},
36-
{ failsOnEsm: true },
37-
);
38-
39-
createCjsTests(__dirname, 'scenario-parallel.mjs', 'instrument.mjs', (createTestRunner, test) => {
40-
test('keeps each span context across parallel memoized requests', async () => {
41-
// Each parallel request emits a transaction whose callback must have run in its own context.
42-
// Two identical expectations keep this order-independent.
43-
const expectation = {
44-
transaction: {
45-
contexts: {
46-
trace: expect.objectContaining({
47-
op: expect.stringMatching(/^(first|second)$/),
48-
data: expect.objectContaining({ 'memoized.context_preserved': true }),
49-
}),
50-
},
51-
},
52-
};
91+
};
5392

54-
await createTestRunner().expect(expectation).expect(expectation).start().completed();
93+
await createTestRunner()
94+
.withFlags(...flags)
95+
.expect(expectation)
96+
.expect(expectation)
97+
.start()
98+
.completed();
99+
});
100+
});
55101
});
56-
});
102+
}
57103
});

packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { mysqlChannelIntegration, detectOrchestrionSetup } from '@sentry/server-utils/orchestrion';
1+
import {
2+
mysqlChannelIntegration,
3+
lruMemoizerChannelIntegration,
4+
detectOrchestrionSetup,
5+
} from '@sentry/server-utils/orchestrion';
26
import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register';
37
import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection';
48
import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInjection';
@@ -38,8 +42,8 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject
3842
export function experimentalUseDiagnosticsChannelInjection(): void {
3943
setDiagnosticsChannelInjectionLoader(
4044
(): DiagnosticsChannelInjection => ({
41-
integrations: [mysqlChannelIntegration()],
42-
replacedOtelIntegrationNames: ['Mysql'],
45+
integrations: [mysqlChannelIntegration(), lruMemoizerChannelIntegration()],
46+
replacedOtelIntegrationNames: ['Mysql', 'LruMemoizer'],
4347
register: registerDiagnosticsChannelInjection,
4448
detect: detectOrchestrionSetup,
4549
}),
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import * as diagnosticsChannel from 'node:diagnostics_channel';
2+
import type { IntegrationFn } from '@sentry/core';
3+
import { debug, defineIntegration, getCurrentScope, withScope } from '@sentry/core';
4+
import { DEBUG_BUILD } from '../../debug-build';
5+
import { CHANNELS } from '../../orchestrion/channels';
6+
7+
// Same name as the OTel integration by design — when enabled, the OTel
8+
// 'LruMemoizer' integration is omitted from the default set.
9+
const INTEGRATION_NAME = 'LruMemoizer';
10+
11+
interface LruMemoizerChannelContext {
12+
arguments: unknown[];
13+
}
14+
15+
const _lruMemoizerChannelIntegration = (() => {
16+
return {
17+
name: INTEGRATION_NAME,
18+
setupOnce() {
19+
// `tracingChannel` is unavailable before Node 18.19 so do nothing in that case.
20+
if (!diagnosticsChannel.tracingChannel) {
21+
return;
22+
}
23+
24+
DEBUG_BUILD && debug.log(`[orchestrion:lru-memoizer] subscribing to channel "${CHANNELS.LRU_MEMOIZER_LOAD}"`);
25+
const lruMemoizerCh = diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD);
26+
27+
lruMemoizerCh.subscribe({
28+
start(rawCtx) {
29+
const ctx = rawCtx as LruMemoizerChannelContext;
30+
if (ctx.arguments.length === 0) {
31+
return;
32+
}
33+
34+
// Capture the scope while we're still synchronously inside the memoized call.
35+
// lru-memoizer queues the callback and fires it later via setImmediate, where the
36+
// active scope no longer reflects the caller's context.
37+
const scope = getCurrentScope();
38+
const cbIdx = ctx.arguments.length - 1;
39+
const orchestrionWrappedCb = ctx.arguments[cbIdx];
40+
41+
if (typeof orchestrionWrappedCb !== 'function') {
42+
return;
43+
}
44+
45+
const wrapped = orchestrionWrappedCb as (...a: unknown[]) => unknown;
46+
ctx.arguments[cbIdx] = function (this: unknown, ...args: unknown[]): unknown {
47+
return withScope(scope, () => wrapped.apply(this, args));
48+
};
49+
},
50+
end() {},
51+
asyncStart() {},
52+
asyncEnd() {},
53+
error() {},
54+
});
55+
},
56+
};
57+
}) satisfies IntegrationFn;
58+
59+
/**
60+
* EXPERIMENTAL — orchestrion-driven lru-memoizer integration. Subscribes to
61+
* `orchestrion:lru-memoizer:load` (injected into `lru-memoizer/lib/async.js`'s
62+
* `memoizedFunction`). Creates no spans; only re-runs the memoized callback with the
63+
* caller's scope. Requires the orchestrion runtime hook or bundler plugin.
64+
*/
65+
export const lruMemoizerChannelIntegration = defineIntegration(_lruMemoizerChannelIntegration);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*/
1414
export const CHANNELS = {
1515
MYSQL_QUERY: 'orchestrion:mysql:query',
16+
LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load',
1617
} as const;
1718

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

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
3232
// attach `'end'`/`'error'` listeners that finish the span.
3333
functionQuery: { expressionName: 'query', kind: 'Auto' },
3434
},
35+
{
36+
channelName: 'load',
37+
// `>=2.1.0` only: the named `function memoizedFunction()` the selector targets exists from 2.1.0
38+
module: { name: 'lru-memoizer', versionRange: '>=2.1.0 <4', filePath: 'lib/async.js' },
39+
functionQuery: { functionName: 'memoizedFunction', kind: 'Callback' },
40+
},
3541
];
3642

3743
/**
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { detectOrchestrionSetup } from './detect';
22
export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql';
3+
export { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer';

0 commit comments

Comments
 (0)