fix: Replace deprecated APIs in services and tooling (#40199)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-10-05 04:50:04 +00:00 committed by GitHub
parent 388b4036d4
commit be2ee6f356
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 118 additions and 204 deletions

View File

@ -1,5 +1,5 @@
import { context, trace } from '@opentelemetry/api';
import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import {
BasicTracerProvider,
InMemorySpanExporter,
@ -24,8 +24,7 @@ export class OtelTestProvider {
private constructor(
private readonly provider: BasicTracerProvider,
private readonly exporter: InMemorySpanExporter,
// oxlint-disable-next-line typescript/no-deprecated
private readonly contextManager: AsyncHooksContextManager,
private readonly contextManager: AsyncLocalStorageContextManager,
) {}
static create(): OtelTestProvider {
@ -34,8 +33,7 @@ export class OtelTestProvider {
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);
// oxlint-disable-next-line typescript/no-deprecated
const contextManager = new AsyncHooksContextManager().enable();
const contextManager = new AsyncLocalStorageContextManager().enable();
context.setGlobalContextManager(contextManager);
return new OtelTestProvider(provider, exporter, contextManager);
}

View File

@ -1,5 +1,5 @@
// oxlint-disable-next-line typescript/no-deprecated
export { ApplicationError } from './application.error';
// Keep this export for community nodes that still import ApplicationError.
export * from './application.error';
export { BaseError, type BaseErrorOptions } from './base.error';
export { IsolateError } from './isolate.error';
export { OperationalError, type OperationalErrorOptions } from './operational.error';

View File

@ -16,10 +16,7 @@ function runProbeWithFrame(html: string, frameHtml: string) {
url: 'http://test.local/page',
});
const frame = dom.window.document.querySelector('iframe');
frame?.contentDocument?.open();
// oxlint-disable-next-line typescript/no-deprecated
frame?.contentDocument?.write(frameHtml);
frame?.contentDocument?.close();
if (frame?.contentDocument?.body) frame.contentDocument.body.innerHTML = frameHtml;
return dom.window.eval(HTML_PROBE_SCRIPT);
}

View File

@ -17,10 +17,7 @@ async function lintFile(filePath: string, eslintConfig: typeof config) {
const eslint = new ESLint({
cwd: path.dirname(filePath),
overrideConfigFile: true,
// `tseslint.config()` returns the typescript-eslint `ConfigArray`, which
// is structurally a `Linter.Config[]` but nominally a different type due
// to duplicated `Parser` definitions across packages.
overrideConfig: eslintConfig as unknown as ESLint.Options['overrideConfig'],
overrideConfig: eslintConfig,
});
const [result] = await eslint.lintFiles([filePath]);
return result;

View File

@ -1,18 +1,20 @@
import eslint from '@eslint/js';
import { n8nCommunityNodesPlugin } from '@n8n/eslint-plugin-community-nodes';
import { globalIgnores } from 'eslint/config';
import type { Linter } from 'eslint';
import { defineConfig, globalIgnores } from 'eslint/config';
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
import importPlugin from 'eslint-plugin-import-x';
import n8nNodesPlugin from 'eslint-plugin-n8n-nodes-base';
import tseslint, { type ConfigArray } from 'typescript-eslint';
import tseslint from 'typescript-eslint';
type ConfigArray = ReturnType<typeof defineConfig>;
function createConfig(supportCloud = true): ConfigArray {
const communityNodesRecommended = supportCloud
? n8nCommunityNodesPlugin.configs.recommended
: n8nCommunityNodesPlugin.configs.recommendedWithoutN8nCloudSupport;
// oxlint-disable-next-line typescript/no-deprecated - We're moving away from eslint
return tseslint.config(
return defineConfig(
globalIgnores(['dist']),
{
files: ['**/*.ts'],
@ -20,7 +22,8 @@ function createConfig(supportCloud = true): ConfigArray {
eslint.configs.recommended,
tseslint.configs.recommended,
communityNodesRecommended,
importPlugin.configs['flat/recommended'],
// import-x uses typescript-eslint's ESLint types for its preset.
importPlugin.configs['flat/recommended'] as Linter.Config,
],
rules: {
'prefer-spread': 'off',
@ -44,7 +47,8 @@ function createConfig(supportCloud = true): ConfigArray {
...n8nNodesPlugin.configs.community.rules,
},
languageOptions: {
parser: tseslint.parser,
// typescript-eslint bundles a parser type that differs from ESLint's parser type.
parser: tseslint.parser as Linter.Parser,
parserOptions: {
extraFileExtensions: ['.json'],
},

View File

@ -188,6 +188,7 @@
"@n8n_io/ai-assistant-sdk": "catalog:",
"@n8n_io/license-sdk": "3.1.0",
"@opentelemetry/api": "catalog:",
"@opentelemetry/context-async-hooks": "^2.7.1",
"@opentelemetry/core": "2.10.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.221.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.221.0",

View File

@ -1,133 +1,21 @@
import type { RedisOptions } from 'ioredis';
import type { IProcessedDataConfig } from 'n8n-workflow';
import type { schema } from './schema';
// -----------------------------------
// transformers
// -----------------------------------
/**
* Transform an object (convict schema) into a union of string arrays (path segments),
* one for every valid path in the schema object, filtered by type.
*
* ```ts
* ["port", "default"] | ["queue", "bull", "redis", "port", "default"] | ["queue", "bull", "redis", "db", "default"] | ["queue", "bull", "redis", "timeoutThreshold", "default"] | etc
* ```
*/
type GetPathSegments<Traversable, Filter> = Traversable extends Filter
? []
: {
[K in ValidKeys<Traversable>]: [K, ...GetPathSegments<Traversable[K], Filter>];
}[ValidKeys<Traversable>];
/**
* Transform a union of string arrays (path segments) into a union of strings (dotted paths).
*
* ```ts
* "port" | "queue.bull.redis.port" | "queue.bull.redis.db" | "queue.bull.redis.timeoutThreshold" | etc
* ```
*/
type JoinByDotting<T extends string[]> = T extends [infer F]
? F
: T extends [infer F, ...infer R]
? F extends string
? R extends string[]
? `${F}.${JoinByDotting<R>}`
: never
: never
: string;
type ToDottedPath<T> = JoinByDotting<RemoveExcess<T>>;
// oxlint-disable-next-line typescript/no-deprecated
type CollectPathsByType<T> = ToDottedPath<GetPathSegments<typeof schema, T>>;
// -----------------------------------
// path-to-return-type mapper
// -----------------------------------
type NumericPath = CollectPathsByType<number>;
type BooleanPath = CollectPathsByType<boolean>;
type StringLiteralArrayPath = CollectPathsByType<readonly string[]>;
type StringPath = CollectPathsByType<string>;
type ConfigOptionPath =
| NumericPath
| BooleanPath
| StringPath
| StringLiteralArrayPath
| keyof ExceptionPaths;
type ToReturnType<T extends ConfigOptionPath> = T extends NumericPath
? number
: T extends BooleanPath
? boolean
: T extends StringLiteralArrayPath
? StringLiteralMap[T]
: T extends keyof ExceptionPaths
? ExceptionPaths[T]
: T extends StringPath
? string
: unknown;
type ExceptionPaths = {
// Keep the typed paths that the legacy convict config and its external consumers use.
type LegacyConfigPaths = {
'userManagement.isInstanceOwnerSetUp': boolean;
'userManagement.authenticationMethod': 'email' | 'ldap' | 'saml';
'endpoints.rest': string;
'ai.enabled': boolean;
'ai.allowSendingParameterValues': boolean;
'queue.bull.redis': RedisOptions;
processedDataManager: IProcessedDataConfig;
'ui.banners.dismissed': string[] | undefined;
easyAIWorkflowOnboarded: boolean | undefined;
};
// -----------------------------------
// string literals map
// -----------------------------------
type GetPathSegmentsWithUnions<T> = T extends ReadonlyArray<infer C>
? [C]
: {
[K in ValidKeys<T>]: [K, ...GetPathSegmentsWithUnions<T[K]>];
}[ValidKeys<T>];
type ToPathUnionPair<T extends string[]> = T extends [...infer Path, infer Union]
? Path extends string[]
? { path: ToDottedPath<Path>; union: Union }
: never
: never;
type ToStringLiteralMap<T extends { path: string; union: string }> = {
[Path in T['path']]: Extract<T, { path: Path }>['union'];
};
type StringLiteralMap = ToStringLiteralMap<
// oxlint-disable-next-line typescript/no-deprecated
ToPathUnionPair<GetPathSegmentsWithUnions<typeof schema>>
>;
// -----------------------------------
// utils
// -----------------------------------
type ValidKeys<T> = keyof T extends string
? keyof T extends keyof NumberConstructor
? never
: keyof T
: never;
type RemoveExcess<T> = T extends [...infer Path, 'format' | 'default']
? Path extends string[]
? Path
: never
: never;
// -----------------------------------
// module augmentation
// -----------------------------------
declare module 'convict' {
interface Config<T> {
getEnv<Path extends ConfigOptionPath>(path: Path): ToReturnType<Path>;
getEnv<Path extends keyof LegacyConfigPaths>(path: Path): LegacyConfigPaths[Path];
}
}

View File

@ -1,15 +1,8 @@
import type { Logger } from '@n8n/backend-common';
import type { OutboundHttp } from '@n8n/backend-network';
import type { Context } from '@opentelemetry/api';
import {
context,
createContextKey,
propagation,
ProxyTracerProvider,
ROOT_CONTEXT,
trace,
} from '@opentelemetry/api';
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
import { context, createContextKey, propagation, ROOT_CONTEXT, trace } from '@opentelemetry/api';
import { AlwaysOffSampler, type ReadableSpan } from '@opentelemetry/sdk-trace-base';
import {
InMemorySpanExporter,
NodeTracerProvider,
@ -73,13 +66,6 @@ function activeContextInside(ctx: Context): Context {
return context.with(ctx, () => context.active());
}
function globalTracerProviderDelegate() {
const globalProvider = trace.getTracerProvider();
return globalProvider instanceof ProxyTracerProvider
? globalProvider.getDelegate()
: globalProvider;
}
function registerForeignProvider() {
const exporter = new InMemorySpanExporter();
const provider = new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] });
@ -155,8 +141,29 @@ describe('OtelService tracer provider', () => {
expect(foreign?.exporter.getFinishedSpans().map((span) => span.name)).toEqual([
'GET /webhook',
]);
expect(globalTracerProviderDelegate()).toBe(foreign?.provider);
expect(activeContextInside(markedContext)).toBe(markedContext);
await service.shutdown();
expect(service.getTracer('n8n-workflow').startSpan('workflow.execute').isRecording()).toBe(
false,
);
expect(trace.getTracer('foreign').startSpan('another request').isRecording()).toBe(true);
});
it('does not replace a foreign provider that samples no spans', async () => {
await foreign?.provider.shutdown();
trace.disable();
const provider = new NodeTracerProvider({ sampler: new AlwaysOffSampler() });
provider.register();
foreign = { provider, exporter: new InMemorySpanExporter() };
await service.init();
await service.restart();
expect(trace.getTracer('foreign').startSpan('test').isRecording()).toBe(false);
service.getTracer('n8n-workflow').startSpan('workflow.execute').end();
expect(exportedSpanNames()).toEqual(['workflow.execute']);
expect(logger.info).toHaveBeenCalledTimes(1);
});
it('logs the foreign owner once for the lifetime of the service', async () => {

View File

@ -1,6 +1,6 @@
import type { Logger } from '@n8n/backend-common';
import type { OutboundHttp } from '@n8n/backend-network';
import { diag } from '@opentelemetry/api';
import { diag, trace } from '@opentelemetry/api';
import { OTLPTraceExporter as OTLPGrpcTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
@ -13,7 +13,6 @@ import type { OtelConfig } from '../otel.config';
import { ATTR, OTEL_TEST_SPAN_NAME } from '../otel.constants';
import { OtelService } from '../otel.service';
const register = vi.fn();
const shutdown = vi.fn();
const providerGetTracer = vi.fn();
@ -115,6 +114,7 @@ vi.mock('@grpc/grpc-js', () => ({
}));
vi.mock('@opentelemetry/sdk-trace-base', () => ({
AlwaysOffSampler: vi.fn(),
BatchSpanProcessor: vi.fn(),
BasicTracerProvider: vi.fn().mockImplementation(function (config: {
spanProcessors?: unknown[];
@ -123,7 +123,7 @@ vi.mock('@opentelemetry/sdk-trace-base', () => ({
mockSpanEnd.mockImplementation(() => {
for (const processor of processors) processor.onEnd({ name: 'n8n.test_trace' });
});
mockStartSpan.mockReturnValue({ end: mockSpanEnd });
mockStartSpan.mockReturnValue({ end: mockSpanEnd, isRecording: () => false });
mockGetTracer.mockReturnValue({ startSpan: mockStartSpan });
return { getTracer: mockGetTracer, shutdown: mockProviderShutdown };
}),
@ -142,7 +142,7 @@ vi.mock('@opentelemetry/sdk-trace-node', () => ({
return {};
}),
NodeTracerProvider: vi.fn().mockImplementation(function () {
return { register, shutdown, getTracer: providerGetTracer };
return { shutdown, getTracer: providerGetTracer };
}),
}));
@ -196,6 +196,7 @@ describe('OtelService', () => {
);
service = new OtelService(otelSettingsService, instanceSettings, logger, outboundHttp);
});
afterEach(() => vi.restoreAllMocks());
describe('init', () => {
it('does not start a tracer provider when enabled is false', async () => {
@ -484,7 +485,7 @@ describe('OtelService', () => {
it('shuts the provider down when registering it with the global API throws', async () => {
otelSettingsService.loadSettings.mockResolvedValue(enabledSettings);
register.mockImplementationOnce(() => {
vi.spyOn(trace, 'setGlobalTracerProvider').mockImplementationOnce(() => {
throw new Error('provider registration failed');
});

View File

@ -2,8 +2,14 @@ import type { Metadata } from '@grpc/grpc-js';
import { Logger } from '@n8n/backend-common';
import { OutboundHttp } from '@n8n/backend-network';
import { Service } from '@n8n/di';
import type { DiagLogger, Tracer, TracerProvider } from '@opentelemetry/api';
import { DiagLogLevel, ProxyTracerProvider, diag, trace } from '@opentelemetry/api';
import type { DiagLogger, Tracer } from '@opentelemetry/api';
import { DiagLogLevel, context, diag, propagation, trace } from '@opentelemetry/api';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import {
CompositePropagator,
W3CBaggagePropagator,
W3CTraceContextPropagator,
} from '@opentelemetry/core';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import {
detectResources,
@ -13,6 +19,7 @@ import {
resourceFromAttributes,
} from '@opentelemetry/resources';
import {
AlwaysOffSampler,
BasicTracerProvider,
BatchSpanProcessor,
type ReadableSpan,
@ -35,17 +42,7 @@ export type OtelTestTraceResult = { success: true } | { success: false; error: s
const stripEmptyResolutionNote = (message: string) =>
message.replace(/\s*Resolution note:\s*$/, '');
// Deprecated in `@opentelemetry/api` 1.x, but the only public source of a no-op tracer provider.
// oxlint-disable-next-line typescript/no-deprecated
const noopTracerProvider = new ProxyTracerProvider();
function registeredGlobalTracerProvider(): TracerProvider | undefined {
const globalProvider = trace.getTracerProvider();
const delegate =
// oxlint-disable-next-line typescript/no-deprecated
globalProvider instanceof ProxyTracerProvider ? globalProvider.getDelegate() : globalProvider;
return delegate === noopTracerProvider.getDelegate() ? undefined : delegate;
}
let noopTracerProvider: BasicTracerProvider | undefined;
@Service()
export class OtelService {
@ -73,7 +70,9 @@ export class OtelService {
}
getTracer(name: string): Tracer {
return (this.provider ?? noopTracerProvider).getTracer(name);
if (this.provider) return this.provider.getTracer(name);
noopTracerProvider ??= new BasicTracerProvider({ sampler: new AlwaysOffSampler() });
return noopTracerProvider.getTracer(name);
}
/**
@ -191,13 +190,20 @@ export class OtelService {
return;
}
if (registeredGlobalTracerProvider()) {
// Claim the global API before installing context and propagation defaults.
// A failed claim leaves another library's global registrations in place.
this.ownsGlobalApi = trace.setGlobalTracerProvider(provider);
if (!this.ownsGlobalApi) {
this.logForeignGlobalApiOwner();
return;
}
provider.register();
this.ownsGlobalApi = registeredGlobalTracerProvider() === provider;
context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable());
propagation.setGlobalPropagator(
new CompositePropagator({
propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()],
}),
);
}
private logForeignGlobalApiOwner(): void {

View File

@ -11,6 +11,7 @@ import { mock, type MockProxy } from 'vitest-mock-extended';
import type { Cipher, InstanceSettings } from 'n8n-core';
import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow';
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
import type { BindingContext } from 'samlify/types/src/entity';
import { BadRequestError, ForbiddenError } from '@n8n/errors';
import type { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
@ -167,6 +168,7 @@ describe('SamlService', () => {
let cipher: Cipher;
let cacheService: Mocked<CacheService>;
let outboundHttp: Mocked<OutboundHttp>;
let urlService: MockProxy<UrlService>;
let httpRequest: Mock;
const validator = new SamlValidator(mock());
const logger = mockLogger();
@ -226,6 +228,7 @@ describe('SamlService', () => {
httpRequest = vi.fn();
outboundHttp = mock<OutboundHttp>();
outboundHttp.requests.mockReturnValue(mock<HttpRequestClient>({ request: httpRequest }));
urlService = mock<UrlService>({ getInstanceBaseUrl: () => 'http://localhost:5678' });
vi.spyOn(ssoHelpers, 'reloadAuthenticationMethod').mockImplementation(
async () => await Promise.resolve(),
@ -234,7 +237,7 @@ describe('SamlService', () => {
samlService = new SamlService(
logger,
mock<UrlService>(),
urlService,
validator,
userRepository,
settingsRepository,
@ -282,6 +285,26 @@ describe('SamlService', () => {
});
});
describe('getLoginRequestUrl', () => {
it('passes each relay state with its request and uses the instance URL by default', async () => {
const idp = mock<IdentityProviderInstance>();
const sp = mock<ServiceProviderInstance>();
sp.createLoginRequest.mockReturnValue(mock<BindingContext>());
vi.spyOn(samlService, 'getIdentityProviderInstance').mockReturnValue(idp);
vi.spyOn(samlService, 'getServiceProviderInstance').mockReturnValue(sp);
await samlService.getLoginRequestUrl('http://localhost:5678/first', 'redirect');
await samlService.getLoginRequestUrl(undefined, 'post');
expect(sp.createLoginRequest).toHaveBeenNthCalledWith(1, idp, 'redirect', {
relayState: 'http://localhost:5678/first',
});
expect(sp.createLoginRequest).toHaveBeenNthCalledWith(2, idp, 'post', {
relayState: 'http://localhost:5678',
});
});
});
describe('getAttributesFromLoginResponse', () => {
test('throws when any attribute is missing', async () => {
// ARRANGE

View File

@ -308,9 +308,9 @@ export class SamlService {
binding ??= this._samlPreferences.loginBinding ?? 'redirect';
const sp = this.getServiceProviderInstance();
// oxlint-disable-next-line typescript/no-deprecated
sp.entitySetting.relayState = relayState ?? this.urlService.getInstanceBaseUrl();
const loginRequest = sp.createLoginRequest(idp, binding);
const loginRequest = sp.createLoginRequest(idp, binding, {
relayState: relayState ?? this.urlService.getInstanceBaseUrl(),
});
return {
binding,
context: binding === 'post' ? loginRequest : loginRequest,

View File

@ -1,5 +1,5 @@
import { Container } from '@n8n/di';
import type { SchedulingFunctions, Workflow, CronContext, Cron } from 'n8n-workflow';
import type { SchedulingFunctions, Workflow, Cron } from 'n8n-workflow';
import { ScheduledTaskManager } from '../../scheduled-task-manager';
@ -13,22 +13,13 @@ export const getSchedulingFunctions = (
const scheduledTaskManager = Container.get(ScheduledTaskManager);
return {
registerCron: ({ expression, recurrence }: Cron, onTick: (scheduledT: Date) => void) => {
// oxlint-disable-next-line typescript/no-deprecated
const ctx: CronContext = {
expression,
recurrence,
nodeId,
workflowId,
timezone,
};
return scheduledTaskManager.register(
{
group: { type: WORKFLOW_SCHEDULE_GROUP_TYPE, id: ctx.workflowId },
targetId: ctx.nodeId,
timezone: ctx.timezone,
expression: ctx.expression,
recurrence: ctx.recurrence,
group: { type: WORKFLOW_SCHEDULE_GROUP_TYPE, id: workflowId },
targetId: nodeId,
timezone,
expression,
recurrence,
},
onTick,
);

View File

@ -3,7 +3,7 @@ import { CronLoggingConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { CronJob, CronTime } from 'cron';
import type { CronContext } from 'n8n-workflow';
import type { Cron } from 'n8n-workflow';
import { InstanceSettings } from '@/instance-settings';
@ -21,8 +21,7 @@ export type ScheduledTaskContext = {
targetId: string;
timezone: string;
expression: string;
// oxlint-disable-next-line typescript/no-deprecated
recurrence?: CronContext['recurrence'];
recurrence?: Cron['recurrence'];
};
@Service()

View File

@ -1129,8 +1129,7 @@ type CronRecurrenceRule =
/**
* @deprecated Remnant of the legacy in-memory scheduling path. `registerCron`
* takes {@link Cron}, not this type; the durable scheduler path never uses it.
* Only `ScheduledTaskManager` and its helper still reference it (and only for
* `CronContext['recurrence']`). Slated to go away with `ScheduledTaskManager`.
* Legacy test helpers still use it. Use {@link Cron} for new scheduling code.
*/
export type CronContext = {
nodeId: string;

View File

@ -4758,6 +4758,9 @@ importers:
'@opentelemetry/api':
specifier: 1.9.1
version: 1.9.1
'@opentelemetry/context-async-hooks':
specifier: ^2.7.1
version: 2.10.0(@opentelemetry/api@1.9.1)
'@opentelemetry/core':
specifier: 2.10.0
version: 2.10.0(@opentelemetry/api@1.9.1)