chore(core): Stop reporting errors to Sentry for older releases (no-changelog) (#13323)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2025-02-20 12:38:54 +01:00 committed by GitHub
parent aae55fe7ac
commit ac1f651905
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 74 additions and 7 deletions

View file

@ -103,6 +103,7 @@ jobs:
context: ./docker/images/n8n context: ./docker/images/n8n
build-args: | build-args: |
N8N_VERSION=${{ needs.publish-to-npm.outputs.release }} N8N_VERSION=${{ needs.publish-to-npm.outputs.release }}
N8N_RELEASE_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
provenance: false provenance: false
push: true push: true

View file

@ -4,15 +4,18 @@ FROM n8nio/base:${NODE_VERSION}
ARG N8N_VERSION ARG N8N_VERSION
RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi
ARG N8N_RELEASE_DATE
LABEL org.opencontainers.image.title="n8n" LABEL org.opencontainers.image.title="n8n"
LABEL org.opencontainers.image.description="Workflow Automation Tool" LABEL org.opencontainers.image.description="Workflow Automation Tool"
LABEL org.opencontainers.image.source="https://github.com/n8n-io/n8n" LABEL org.opencontainers.image.source="https://github.com/n8n-io/n8n"
LABEL org.opencontainers.image.url="https://n8n.io" LABEL org.opencontainers.image.url="https://n8n.io"
LABEL org.opencontainers.image.version=${N8N_VERSION} LABEL org.opencontainers.image.version=${N8N_VERSION}
LABEL org.opencontainers.image.created=${N8N_RELEASE_DATE}
ENV N8N_VERSION=${N8N_VERSION} ENV N8N_VERSION=${N8N_VERSION}
ENV NODE_ENV=production ENV NODE_ENV=production
ENV N8N_RELEASE_TYPE=stable ENV N8N_RELEASE_TYPE=stable
ENV N8N_RELEASE_DATE=${N8N_RELEASE_DATE}
RUN set -eux; \ RUN set -eux; \
npm install -g --omit=dev n8n@${N8N_VERSION} --ignore-scripts && \ npm install -g --omit=dev n8n@${N8N_VERSION} --ignore-scripts && \
npm rebuild --prefix=/usr/local/lib/node_modules/n8n sqlite3 && \ npm rebuild --prefix=/usr/local/lib/node_modules/n8n sqlite3 && \

View file

@ -9,6 +9,9 @@ export class GenericConfig {
@Env('N8N_RELEASE_TYPE') @Env('N8N_RELEASE_TYPE')
releaseChannel: 'stable' | 'beta' | 'nightly' | 'dev' = 'dev'; releaseChannel: 'stable' | 'beta' | 'nightly' | 'dev' = 'dev';
@Env('N8N_RELEASE_DATE')
releaseDate?: Date;
/** Grace period (in seconds) to wait for components to shut down before process exit. */ /** Grace period (in seconds) to wait for components to shut down before process exit. */
@Env('N8N_GRACEFUL_SHUTDOWN_TIMEOUT') @Env('N8N_GRACEFUL_SHUTDOWN_TIMEOUT')
gracefulShutdownTimeout: number = 30; gracefulShutdownTimeout: number = 30;

View file

@ -55,6 +55,13 @@ export const Config: ClassDecorator = (ConfigClass: Class) => {
} else { } else {
console.warn(`Invalid boolean value for ${envName}: ${value}`); console.warn(`Invalid boolean value for ${envName}: ${value}`);
} }
} else if (type === Date) {
const timestamp = Date.parse(value);
if (isNaN(timestamp)) {
console.warn(`Invalid timestamp value for ${envName}: ${value}`);
} else {
config[key] = new Date(timestamp);
}
} else if (type === String) { } else if (type === String) {
config[key] = value; config[key] = value;
} else { } else {

View file

@ -8,9 +8,12 @@ jest.mock('fs');
const mockFs = mock<typeof fs>(); const mockFs = mock<typeof fs>();
fs.readFileSync = mockFs.readFileSync; fs.readFileSync = mockFs.readFileSync;
const consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation(() => {});
describe('GlobalConfig', () => { describe('GlobalConfig', () => {
beforeEach(() => { beforeEach(() => {
Container.reset(); Container.reset();
jest.clearAllMocks();
}); });
const originalEnv = process.env; const originalEnv = process.env;
@ -18,10 +21,6 @@ describe('GlobalConfig', () => {
process.env = originalEnv; process.env = originalEnv;
}); });
// deepCopy for diff to show plain objects
// eslint-disable-next-line n8n-local-rules/no-json-parse-json-stringify
const deepCopy = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
const defaultConfig: GlobalConfig = { const defaultConfig: GlobalConfig = {
path: '/', path: '/',
host: 'localhost', host: 'localhost',
@ -314,7 +313,7 @@ describe('GlobalConfig', () => {
it('should use all default values when no env variables are defined', () => { it('should use all default values when no env variables are defined', () => {
process.env = {}; process.env = {};
const config = Container.get(GlobalConfig); const config = Container.get(GlobalConfig);
expect(deepCopy(config)).toEqual(defaultConfig); expect(structuredClone(config)).toEqual(defaultConfig);
expect(mockFs.readFileSync).not.toHaveBeenCalled(); expect(mockFs.readFileSync).not.toHaveBeenCalled();
}); });
@ -327,9 +326,10 @@ describe('GlobalConfig', () => {
DB_LOGGING_MAX_EXECUTION_TIME: '0', DB_LOGGING_MAX_EXECUTION_TIME: '0',
N8N_METRICS: 'TRUE', N8N_METRICS: 'TRUE',
N8N_TEMPLATES_ENABLED: '0', N8N_TEMPLATES_ENABLED: '0',
N8N_RELEASE_DATE: '2025-02-17T13:54:15Z',
}; };
const config = Container.get(GlobalConfig); const config = Container.get(GlobalConfig);
expect(deepCopy(config)).toEqual({ expect(structuredClone(config)).toEqual({
...defaultConfig, ...defaultConfig,
database: { database: {
logging: defaultConfig.database.logging, logging: defaultConfig.database.logging,
@ -358,6 +358,10 @@ describe('GlobalConfig', () => {
...defaultConfig.templates, ...defaultConfig.templates,
enabled: false, enabled: false,
}, },
generic: {
...defaultConfig.generic,
releaseDate: new Date('2025-02-17T13:54:15.000Z'),
},
}); });
expect(mockFs.readFileSync).not.toHaveBeenCalled(); expect(mockFs.readFileSync).not.toHaveBeenCalled();
}); });
@ -370,7 +374,7 @@ describe('GlobalConfig', () => {
mockFs.readFileSync.calledWith(passwordFile, 'utf8').mockReturnValueOnce('password-from-file'); mockFs.readFileSync.calledWith(passwordFile, 'utf8').mockReturnValueOnce('password-from-file');
const config = Container.get(GlobalConfig); const config = Container.get(GlobalConfig);
expect(deepCopy(config)).toEqual({ expect(structuredClone(config)).toEqual({
...defaultConfig, ...defaultConfig,
database: { database: {
...defaultConfig.database, ...defaultConfig.database,
@ -382,4 +386,26 @@ describe('GlobalConfig', () => {
}); });
expect(mockFs.readFileSync).toHaveBeenCalled(); expect(mockFs.readFileSync).toHaveBeenCalled();
}); });
it('should handle invalid numbers', () => {
process.env = {
DB_LOGGING_MAX_EXECUTION_TIME: 'abcd',
};
const config = Container.get(GlobalConfig);
expect(config.database.logging.maxQueryExecutionTime).toEqual(0);
expect(consoleWarnMock).toHaveBeenCalledWith(
'Invalid number value for DB_LOGGING_MAX_EXECUTION_TIME: abcd',
);
});
it('should handle invalid timestamps', () => {
process.env = {
N8N_RELEASE_DATE: 'abcd',
};
const config = Container.get(GlobalConfig);
expect(config.generic.releaseDate).toBeUndefined();
expect(consoleWarnMock).toHaveBeenCalledWith(
'Invalid timestamp value for N8N_RELEASE_DATE: abcd',
);
});
}); });

View file

@ -63,6 +63,7 @@ export abstract class BaseCommand extends Command {
async init(): Promise<void> { async init(): Promise<void> {
this.errorReporter = Container.get(ErrorReporter); this.errorReporter = Container.get(ErrorReporter);
const { releaseDate } = this.globalConfig.generic;
const { backendDsn, n8nVersion, environment, deploymentName } = this.globalConfig.sentry; const { backendDsn, n8nVersion, environment, deploymentName } = this.globalConfig.sentry;
await this.errorReporter.init({ await this.errorReporter.init({
serverType: this.instanceSettings.instanceType, serverType: this.instanceSettings.instanceType,
@ -70,6 +71,7 @@ export abstract class BaseCommand extends Command {
environment, environment,
release: n8nVersion, release: n8nVersion,
serverName: deploymentName, serverName: deploymentName,
releaseDate,
}); });
initExpressionEvaluator(); initExpressionEvaluator();
@ -294,6 +296,8 @@ export abstract class BaseCommand extends Command {
await this.shutdownService.waitForShutdown(); await this.shutdownService.waitForShutdown();
await this.errorReporter.shutdown();
await this.stopProcess(); await this.stopProcess();
clearTimeout(forceShutdownTimer); clearTimeout(forceShutdownTimer);

View file

@ -16,6 +16,7 @@ type ErrorReporterInitOptions = {
release: string; release: string;
environment: string; environment: string;
serverName: string; serverName: string;
releaseDate?: Date;
/** /**
* Function to allow filtering out errors before they are sent to Sentry. * Function to allow filtering out errors before they are sent to Sentry.
* Return true if the error should be filtered out. * Return true if the error should be filtered out.
@ -23,8 +24,14 @@ type ErrorReporterInitOptions = {
beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean; beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean;
}; };
const SIX_WEEKS_IN_MS = 6 * 7 * 24 * 60 * 60 * 1000;
const RELEASE_EXPIRATION_WARNING =
'Error tracking disabled because this release is older than 6 weeks.';
@Service() @Service()
export class ErrorReporter { export class ErrorReporter {
private expirationTimer?: NodeJS.Timeout;
/** Hashes of error stack traces, to deduplicate error reports. */ /** Hashes of error stack traces, to deduplicate error reports. */
private seenErrors = new Set<string>(); private seenErrors = new Set<string>();
@ -61,6 +68,7 @@ export class ErrorReporter {
} }
async shutdown(timeoutInMs = 1000) { async shutdown(timeoutInMs = 1000) {
clearTimeout(this.expirationTimer);
await close(timeoutInMs); await close(timeoutInMs);
} }
@ -71,11 +79,26 @@ export class ErrorReporter {
release, release,
environment, environment,
serverName, serverName,
releaseDate,
}: ErrorReporterInitOptions) { }: ErrorReporterInitOptions) {
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
this.error(error); this.error(error);
}); });
if (releaseDate) {
const releaseExpiresInMs = releaseDate.getTime() + SIX_WEEKS_IN_MS - Date.now();
if (releaseExpiresInMs <= 0) {
this.logger.warn(RELEASE_EXPIRATION_WARNING);
return;
}
// Once this release expires, reject all events
this.expirationTimer = setTimeout(() => {
this.logger.warn(RELEASE_EXPIRATION_WARNING);
// eslint-disable-next-line @typescript-eslint/unbound-method
this.report = this.defaultReport;
}, releaseExpiresInMs);
}
if (!dsn) return; if (!dsn) return;
// Collect longer stacktraces // Collect longer stacktraces