fix(core): Re-register expression metrics after Prometheus registry reset (#31484)

This commit is contained in:
Iván Ovejero 2026-06-01 14:46:01 +02:00 committed by GitHub
parent 2431a43ac1
commit ccf401c720
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 93 additions and 51 deletions

View File

@ -110,6 +110,48 @@ describe('ExpressionObservabilityProvider', () => {
});
});
describe('survives a global registry clear', () => {
it('re-registers expression metrics on emission without warning after a clear', async () => {
const provider = new ExpressionObservabilityProvider(
buildConfig(),
buildLogger(),
buildGlobalConfig(),
);
promClient.register.clear();
provider.metrics.counter(EXPRESSION_METRICS.poolAcquired.name, 3);
provider.metrics.gauge(EXPRESSION_METRICS.codeCacheSize.name, 7);
provider.metrics.histogram(EXPRESSION_METRICS.evaluationDuration.name, 0.02, {
status: 'success',
type: 'none',
});
expect(scopedLogger.warn).not.toHaveBeenCalled();
const output = await promClient.register.metrics();
expect(output).toContain('n8n_expression_pool_acquired_total 3');
expect(output).toContain('n8n_expression_code_cache_size 7');
expect(output).toContain('n8n_expression_evaluation_duration_seconds_count');
});
it('still warns for genuinely unknown metric names after a clear', () => {
const provider = new ExpressionObservabilityProvider(
buildConfig(),
buildLogger(),
buildGlobalConfig(),
);
promClient.register.clear();
provider.metrics.counter('test.unknown', 1);
expect(scopedLogger.warn).toHaveBeenCalledWith('Emitted unknown expression metric', {
name: 'test.unknown',
});
});
});
describe('tail sampling', () => {
const startSpanMock = jest.fn().mockReturnValue({
setStatus: jest.fn(),

View File

@ -42,6 +42,10 @@ export class ExpressionObservabilityProvider implements ObservabilityProvider {
private tracer?: Tracer;
private readonly metricDefs = new Map<string, MetricDef>(
(Object.values(EXPRESSION_METRICS) as MetricDef[]).map((def) => [def.name, def]),
);
constructor(
private readonly config: ExpressionEngineConfig,
private readonly logger: Logger,
@ -58,7 +62,9 @@ export class ExpressionObservabilityProvider implements ObservabilityProvider {
this.prefix = globalConfig.endpoints.metrics.prefix;
this.registerMetrics();
for (const metric of this.metricDefs.values()) {
this.getOrRegisterMetric(metric);
}
this.metrics = {
counter: (name, value, tags) => this.counter(name, value, tags),
@ -78,71 +84,65 @@ export class ExpressionObservabilityProvider implements ObservabilityProvider {
};
}
private registerMetrics(): void {
for (const def of Object.values(EXPRESSION_METRICS) as MetricDef[]) {
const promName = toPromName(def.name, def.kind, this.prefix);
switch (def.kind) {
case 'counter':
new promClient.Counter({
name: promName,
help: def.help,
labelNames: def.labels,
});
break;
case 'gauge':
new promClient.Gauge({
name: promName,
help: def.help,
labelNames: def.labels,
});
break;
case 'histogram':
new promClient.Histogram({
name: promName,
help: def.help,
labelNames: def.labels,
buckets: DURATION_BUCKETS_SECONDS,
});
break;
default: {
const _exhaustive: never = def.kind;
throw new UnexpectedError(`Unknown metric kind: ${String(_exhaustive)}`);
}
private getOrRegisterMetric(def: MetricDef) {
const promName = toPromName(def.name, def.kind, this.prefix);
const existing = promClient.register.getSingleMetric(promName);
if (existing) return existing;
switch (def.kind) {
case 'counter':
return new promClient.Counter({
name: promName,
help: def.help,
labelNames: def.labels,
});
case 'gauge':
return new promClient.Gauge({
name: promName,
help: def.help,
labelNames: def.labels,
});
case 'histogram':
return new promClient.Histogram({
name: promName,
help: def.help,
labelNames: def.labels,
buckets: DURATION_BUCKETS_SECONDS,
});
default: {
const _exhaustive: never = def.kind;
throw new UnexpectedError(`Unknown metric kind: ${String(_exhaustive)}`);
}
}
}
private getMetricDef(name: string, kind: MetricDef['kind']) {
const def = this.metricDefs.get(name);
if (def?.kind === kind) return def;
this.scopedLogger.warn('Emitted unknown expression metric', { name });
return undefined;
}
private counter(name: string, value: number, tags?: Record<string, string>): void {
const promName = toPromName(name, 'counter', this.prefix);
const counter = promClient.register.getSingleMetric(promName) as Counter<string> | undefined;
if (!counter) {
this.scopedLogger.warn('Emitted unknown expression metric', { name });
return;
}
const def = this.getMetricDef(name, 'counter');
if (!def) return;
const counter = this.getOrRegisterMetric(def) as Counter<string>;
if (tags) counter.inc(tags, value);
else counter.inc(value);
}
private gauge(name: string, value: number, tags?: Record<string, string>): void {
const promName = toPromName(name, 'gauge', this.prefix);
const gauge = promClient.register.getSingleMetric(promName) as Gauge<string> | undefined;
if (!gauge) {
this.scopedLogger.warn('Emitted unknown expression metric', { name });
return;
}
const def = this.getMetricDef(name, 'gauge');
if (!def) return;
const gauge = this.getOrRegisterMetric(def) as Gauge<string>;
if (tags) gauge.set(tags, value);
else gauge.set(value);
}
private histogram(name: string, value: number, tags?: Record<string, string>): void {
const promName = toPromName(name, 'histogram', this.prefix);
const histogram = promClient.register.getSingleMetric(promName) as
| Histogram<string>
| undefined;
if (!histogram) {
this.scopedLogger.warn('Emitted unknown expression metric', { name });
return;
}
const def = this.getMetricDef(name, 'histogram');
if (!def) return;
const histogram = this.getOrRegisterMetric(def) as Histogram<string>;
if (tags) histogram.observe(tags, value);
else histogram.observe(value);