Skip to content

Commit 7ad0323

Browse files
committed
Address OpenTelemetry review feedback
1 parent b8b4afc commit 7ad0323

9 files changed

Lines changed: 177 additions & 38 deletions

File tree

packages/react-on-rails-pro-node-renderer/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
},
7979
"peerDependencies": {
8080
"@honeybadger-io/js": ">=4.0.0",
81-
"@opentelemetry/api": ">=1.9.0 <1.10.0",
81+
"@opentelemetry/api": ">=1.9.0 <2.0.0",
8282
"@opentelemetry/exporter-trace-otlp-http": ">=0.214.0 <1.0.0",
8383
"@fastify/otel": ">=0.18.0 <1.0.0",
8484
"@opentelemetry/instrumentation": ">=0.214.0 <1.0.0",

packages/react-on-rails-pro-node-renderer/src/integrations/opentelemetry.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ export function init(opts: OpenTelemetryInitOptions = {}): void {
170170
let registeredProvider: NodeTracerProviderType | undefined;
171171
let unregisterFastifyConfig: (() => void) | undefined;
172172
let unregisterWorkerShutdownHook: (() => void) | undefined;
173+
let ownsOpenTelemetryGlobals = false;
173174

174175
try {
175176
/* eslint-disable @typescript-eslint/no-require-imports, global-require --
@@ -219,8 +220,6 @@ export function init(opts: OpenTelemetryInitOptions = {}): void {
219220
spanProcessors: [spanProcessor],
220221
});
221222

222-
configureOpenTelemetryDiagnostics(loadedOtelApi);
223-
224223
if (opts.fastify) {
225224
/* eslint-disable @typescript-eslint/no-require-imports, global-require */
226225
const { registerInstrumentations } =
@@ -285,15 +284,21 @@ export function init(opts: OpenTelemetryInitOptions = {}): void {
285284
}
286285
});
287286
installedAdapters.subSpan = setupSubSpan(subSpanImpl);
287+
} else {
288+
message(
289+
'[OpenTelemetry] tracing integration was not installed because another tracing integration is ' +
290+
'active; skipping OpenTelemetry sub-spans.',
291+
);
288292
}
289293
}
290294

291295
try {
292296
provider.register();
293297
setOpenTelemetryTracerProvider(provider);
294298
registeredProvider = provider;
299+
ownsOpenTelemetryGlobals = true;
300+
configureOpenTelemetryDiagnostics(loadedOtelApi);
295301
} catch (err) {
296-
disableOpenTelemetryGlobals(loadedOtelApi);
297302
installedAdapters = resetInstalledTracingAdapters(installedAdapters);
298303
// When opts.fastify is enabled, OpenTelemetry does not expose a public
299304
// rollback API for module patches applied by registerInstrumentations().
@@ -310,6 +315,7 @@ export function init(opts: OpenTelemetryInitOptions = {}): void {
310315
if (getOpenTelemetryTracerProvider() === provider) {
311316
setOpenTelemetryTracerProvider(null);
312317
disableOpenTelemetryGlobals(loadedOtelApi);
318+
ownsOpenTelemetryGlobals = false;
313319
installedAdapters = resetInstalledTracingAdapters(installedAdapters);
314320
}
315321
} finally {
@@ -333,11 +339,15 @@ export function init(opts: OpenTelemetryInitOptions = {}): void {
333339
} catch (err) {
334340
unregisterFastifyConfig?.();
335341
unregisterWorkerShutdownHook?.();
336-
if (registeredProvider && getOpenTelemetryTracerProvider() === registeredProvider) {
342+
if (
343+
ownsOpenTelemetryGlobals &&
344+
registeredProvider &&
345+
otelApi &&
346+
getOpenTelemetryTracerProvider() === registeredProvider
347+
) {
337348
setOpenTelemetryTracerProvider(null);
338-
}
339-
if (otelApi) {
340349
disableOpenTelemetryGlobals(otelApi);
350+
ownsOpenTelemetryGlobals = false;
341351
}
342352
installedAdapters = resetInstalledTracingAdapters(installedAdapters);
343353
message(`[OpenTelemetry] init failed: ${String(err)}`);

packages/react-on-rails-pro-node-renderer/src/worker.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
Asset,
4141
getAssetPath,
4242
} from './shared/utils.js';
43-
import { startSsrRequestOptions, subSpan, trace } from './shared/tracing.js';
43+
import { startSsrRequestOptions, subSpan, trace, type TracingContext } from './shared/tracing.js';
4444
import { applyFastifyConfigFunctions } from './worker/fastifyConfig.js';
4545

4646
export { configureFastify, type FastifyConfigFunction } from './worker/fastifyConfig.js';
@@ -382,10 +382,12 @@ export default function run(config: Partial<Config>) {
382382
// Track whether we've already started sending a response (streaming or otherwise)
383383
// If true, we can't send an error response on failure - headers are already sent
384384
let responseStarted = false;
385+
let incrementalTracingContext: TracingContext | undefined;
385386

386387
try {
387388
await trace(
388-
async () => {
389+
async (context) => {
390+
incrementalTracingContext = context;
389391
// Handle the incremental render stream
390392
await handleIncrementalRenderStream({
391393
request: req,
@@ -430,6 +432,7 @@ export default function run(config: Partial<Config>) {
430432
err,
431433
'Error while handling incremental render request',
432434
),
435+
context,
433436
);
434437
return {
435438
response: errorResponse,
@@ -502,7 +505,7 @@ export default function run(config: Partial<Config>) {
502505
}
503506
} else {
504507
// Response hasn't started yet, we can send an error response
505-
const errorResponse = errorResponseResult(errorMessage);
508+
const errorResponse = errorResponseResult(errorMessage, incrementalTracingContext);
506509
await setResponse(errorResponse, res);
507510
}
508511
}
@@ -649,6 +652,8 @@ export default function run(config: Partial<Config>) {
649652
});
650653
}
651654

655+
// Integration hooks registered before the worker loads are applied here, immediately after
656+
// listen() is scheduled and before Fastify finishes booting.
652657
applyFastifyConfigFunctions(app);
653658

654659
return app;

packages/react-on-rails-pro-node-renderer/src/worker/fastifyConfig.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export function registerFastifyConfigFunction(configFunction: FastifyConfigFunct
2121
};
2222
}
2323

24+
/**
25+
* Public one-way registration API for custom entrypoints and integrations.
26+
* Internal callers use registerFastifyConfigFunction() when they need the
27+
* unregister callback during failed initialization or shutdown cleanup.
28+
*/
2429
export function configureFastify(configFunction: FastifyConfigFunction): void {
2530
registerFastifyConfigFunction(configFunction);
2631
}

packages/react-on-rails-pro-node-renderer/src/worker/shutdownHooks.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,26 @@ export type WorkerShutdownHook = () => void | Promise<void>;
22

33
const workerShutdownHooks: WorkerShutdownHook[] = [];
44

5+
type AggregateErrorWithErrors = Error & { errors: readonly unknown[] };
6+
type AggregateErrorConstructor = new (
7+
errors: readonly unknown[],
8+
message?: string,
9+
) => AggregateErrorWithErrors;
10+
11+
function createAggregateShutdownHookError(errors: readonly unknown[]): AggregateErrorWithErrors {
12+
const { AggregateError: AggregateErrorCtor } = globalThis as typeof globalThis & {
13+
AggregateError?: AggregateErrorConstructor;
14+
};
15+
const aggregateError = AggregateErrorCtor
16+
? new AggregateErrorCtor(errors, 'Multiple worker shutdown hooks failed')
17+
: Object.assign(new Error('Multiple worker shutdown hooks failed'), {
18+
errors,
19+
name: 'AggregateError',
20+
});
21+
22+
return aggregateError;
23+
}
24+
525
export function registerWorkerShutdownHook(hook: WorkerShutdownHook): () => void {
626
workerShutdownHooks.push(hook);
727
return () => {
@@ -18,12 +38,16 @@ export async function runWorkerShutdownHooks(): Promise<void> {
1838
await hook();
1939
}),
2040
);
21-
const rejectedResult = results.find(
41+
const rejectedResults = results.filter(
2242
(result): result is PromiseRejectedResult => result.status === 'rejected',
2343
);
2444

25-
if (rejectedResult) {
26-
throw rejectedResult.reason;
45+
const firstRejectedResult = rejectedResults[0];
46+
if (rejectedResults.length === 1 && firstRejectedResult) {
47+
throw firstRejectedResult.reason;
48+
}
49+
if (rejectedResults.length > 1) {
50+
throw createAggregateShutdownHookError(rejectedResults.map((result) => result.reason));
2751
}
2852
}
2953

packages/react-on-rails-pro-node-renderer/tests/integrations/opentelemetry-init.test.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { jest } from '@jest/globals';
22
import {
33
context as otelContext,
4+
DiagLogLevel,
45
diag as otelDiag,
56
propagation as otelPropagation,
67
trace as otelTrace,
@@ -394,18 +395,15 @@ describe('opentelemetry integration: init() failure path', () => {
394395
await resetOpenTelemetryForTest();
395396
});
396397

397-
test('init() disables OTel diagnostics when provider.register() fails', async () => {
398-
const log = {
398+
test('init() preserves existing OTel globals when provider.register() fails', async () => {
399+
const existingDiagLogger = {
399400
debug: jest.fn(),
400401
error: jest.fn(),
401402
info: jest.fn(),
403+
verbose: jest.fn(),
402404
warn: jest.fn(),
403405
};
404406

405-
jest.doMock('../../src/integrations/api.js', () => ({
406-
log,
407-
message: jest.fn(),
408-
}));
409407
jest.doMock('@opentelemetry/sdk-trace-node', () => ({
410408
NodeTracerProvider: jest.fn(function NodeTracerProvider() {
411409
return {
@@ -417,19 +415,31 @@ describe('opentelemetry integration: init() failure path', () => {
417415
}),
418416
}));
419417

420-
const { diag } = await import('@opentelemetry/api');
421-
const { InMemorySpanExporter, SimpleSpanProcessor } = await import('@opentelemetry/sdk-trace-base');
418+
const { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } = await import(
419+
'@opentelemetry/sdk-trace-base'
420+
);
421+
const existingExporter = new InMemorySpanExporter();
422+
const existingProvider = new BasicTracerProvider({
423+
spanProcessors: [new SimpleSpanProcessor(existingExporter)],
424+
});
425+
expect(otelTrace.setGlobalTracerProvider(existingProvider)).toBe(true);
426+
otelDiag.setLogger(existingDiagLogger, DiagLogLevel.WARN);
427+
existingDiagLogger.warn.mockClear();
428+
422429
const otel = await import('../../src/integrations/opentelemetry');
423430

424431
otel.init({
425432
spanProcessor: new SimpleSpanProcessor(new InMemorySpanExporter()),
426433
});
427-
diag.warn('should not reach renderer log');
428434

429-
expect(log.warn).not.toHaveBeenCalledWith(
430-
{ otel: true, level: 'warn', args: [] },
431-
'should not reach renderer log',
432-
);
435+
otelTrace.getTracer('existing').startActiveSpan('span.after.failed.init', (span) => {
436+
span.end();
437+
});
438+
otelDiag.warn('diagnostic after failed init');
439+
440+
expect(existingExporter.getFinishedSpans().map((span) => span.name)).toEqual(['span.after.failed.init']);
441+
expect(existingDiagLogger.warn).toHaveBeenCalledWith('diagnostic after failed init');
442+
await existingProvider.shutdown();
433443
});
434444

435445
test('Fastify onClose suppresses late provider.shutdown() rejection after timeout', async () => {

packages/react-on-rails-pro-node-renderer/tests/integrations/opentelemetry.test.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { resetOpenTelemetryForTest } from '../../src/testUtils/opentelemetry';
66
import worker, { disableHttp2 } from '../../src/worker';
77
import { applyFastifyConfigFunctions } from '../../src/worker/fastifyConfig';
88
import packageJson from '../../src/shared/packageJson';
9+
import * as errorReporter from '../../src/shared/errorReporter';
910
import {
1011
trace,
1112
subSpan,
@@ -336,22 +337,29 @@ describe('opentelemetry integration: tracing wiring', () => {
336337
});
337338

338339
test('init({ tracing: true }) does not install subSpan when another tracing integration is active', async () => {
340+
const messageSpy = jest.spyOn(errorReporter, 'message').mockImplementation(jest.fn());
339341
const existingExecutor = jest.fn(async (fn) => fn());
340-
expect(setupTracing({ executor: existingExecutor })).toBe(true);
341342

342-
init({
343-
tracing: true,
344-
spanProcessor: new SimpleSpanProcessor(exporter),
345-
});
343+
try {
344+
expect(setupTracing({ executor: existingExecutor })).toBe(true);
346345

347-
const result = await trace(
348-
() => subSpan({ name: 'ror.vm.execute' }, async () => 'ok'),
349-
startSsrRequestOptions({ renderingRequest: 'irrelevant' }),
350-
);
346+
init({
347+
tracing: true,
348+
spanProcessor: new SimpleSpanProcessor(exporter),
349+
});
351350

352-
expect(result).toBe('ok');
353-
expect(existingExecutor).toHaveBeenCalledTimes(1);
354-
expect(exporter.getFinishedSpans()).toHaveLength(0);
351+
const result = await trace(
352+
() => subSpan({ name: 'ror.vm.execute' }, async () => 'ok'),
353+
startSsrRequestOptions({ renderingRequest: 'irrelevant' }),
354+
);
355+
356+
expect(result).toBe('ok');
357+
expect(existingExecutor).toHaveBeenCalledTimes(1);
358+
expect(exporter.getFinishedSpans()).toHaveLength(0);
359+
expect(messageSpy).toHaveBeenCalledWith(expect.stringContaining('skipping OpenTelemetry sub-spans'));
360+
} finally {
361+
messageSpy.mockRestore();
362+
}
355363
});
356364

357365
test('subSpan does not leak renderingRequest payload into span attributes (sensitive data audit)', async () => {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {
2+
__resetWorkerShutdownHooksForTest,
3+
registerWorkerShutdownHook,
4+
runWorkerShutdownHooks,
5+
} from '../src/worker/shutdownHooks';
6+
7+
describe('worker shutdown hooks', () => {
8+
beforeEach(() => {
9+
__resetWorkerShutdownHooksForTest();
10+
});
11+
12+
afterEach(() => {
13+
__resetWorkerShutdownHooksForTest();
14+
});
15+
16+
test('throws the original error when a single shutdown hook fails', async () => {
17+
const error = new Error('shutdown failed');
18+
registerWorkerShutdownHook(async () => {
19+
throw error;
20+
});
21+
22+
await expect(runWorkerShutdownHooks()).rejects.toBe(error);
23+
});
24+
25+
test('surfaces all errors when multiple shutdown hooks fail', async () => {
26+
const firstError = new Error('first shutdown failed');
27+
const secondError = new Error('second shutdown failed');
28+
registerWorkerShutdownHook(async () => {
29+
throw firstError;
30+
});
31+
registerWorkerShutdownHook(async () => {
32+
throw secondError;
33+
});
34+
35+
await expect(runWorkerShutdownHooks()).rejects.toMatchObject({
36+
errors: [firstError, secondError],
37+
message: 'Multiple worker shutdown hooks failed',
38+
name: 'AggregateError',
39+
});
40+
});
41+
});

packages/react-on-rails-pro-node-renderer/tests/worker.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import packageJson from '../package.json';
88
import worker, { configureFastify, disableHttp2 } from '../src/worker';
99
import * as vm from '../src/worker/vm';
1010
import * as errorReporter from '../src/shared/errorReporter';
11+
import { __resetTracingForTest, setupTracing, type TracingContext } from '../src/shared/tracing';
1112
import {
1213
BUNDLE_TIMESTAMP,
1314
SECONDARY_BUNDLE_TIMESTAMP,
@@ -1100,6 +1101,41 @@ describe('worker', () => {
11001101
expect(res.payload).toContain('Invalid first incremental render request chunk received');
11011102
});
11021103

1104+
test('reports initial render errors with the active tracing context', async () => {
1105+
const app = createWorkerApp();
1106+
const tracingContext = { testContext: true } as TracingContext;
1107+
const reportMessageSpy = jest.spyOn(errorReporter, 'message').mockImplementation(jest.fn());
1108+
1109+
try {
1110+
__resetTracingForTest();
1111+
expect(setupTracing({ executor: async (fn) => fn(tracingContext) })).toBe(true);
1112+
1113+
const incompletePayload = {
1114+
gemVersion,
1115+
protocolVersion,
1116+
password: 'my_password',
1117+
dependencyBundleTimestamps: [String(BUNDLE_TIMESTAMP)],
1118+
};
1119+
1120+
const res = await callIncrementalRender(
1121+
app,
1122+
BUNDLE_TIMESTAMP,
1123+
'd41d8cd98f00b204e9800998ecf8427e',
1124+
incompletePayload,
1125+
400,
1126+
);
1127+
1128+
expect(res.payload).toContain('Invalid first incremental render request chunk received');
1129+
expect(reportMessageSpy).toHaveBeenCalledWith(
1130+
expect.stringContaining('Invalid first incremental render request chunk received'),
1131+
tracingContext,
1132+
);
1133+
} finally {
1134+
__resetTracingForTest();
1135+
reportMessageSpy.mockRestore();
1136+
}
1137+
});
1138+
11031139
test('fails when password is missing', async () => {
11041140
const app = createWorkerApp();
11051141
await uploadBundle(app);

0 commit comments

Comments
 (0)