Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
224 lines
5.8 KiB
TypeScript
224 lines
5.8 KiB
TypeScript
import type { StartedNetwork } from 'testcontainers';
|
|
import { GenericContainer, Wait } from 'testcontainers';
|
|
|
|
import { TEST_CONTAINER_IMAGES } from '../test-containers';
|
|
import type { HelperContext, Service, ServiceResult, StartContext } from './types';
|
|
|
|
const VICTORIA_METRICS_HTTP_PORT = 8428;
|
|
const VICTORIA_METRICS_HOSTNAME = 'victoria-metrics';
|
|
|
|
export interface ScrapeTarget {
|
|
job: string;
|
|
instance: string;
|
|
host: string;
|
|
port: number;
|
|
}
|
|
|
|
export interface VictoriaMetricsConfig {
|
|
scrapeTargets: ScrapeTarget[];
|
|
}
|
|
|
|
export interface VictoriaMetricsMeta {
|
|
queryEndpoint: string;
|
|
internalEndpoint: string;
|
|
}
|
|
|
|
export type VictoriaMetricsResult = ServiceResult<VictoriaMetricsMeta>;
|
|
|
|
function generateScrapeConfig(targets: ScrapeTarget[]): string {
|
|
const jobGroups = new Map<string, ScrapeTarget[]>();
|
|
for (const target of targets) {
|
|
const existing = jobGroups.get(target.job) ?? [];
|
|
existing.push(target);
|
|
jobGroups.set(target.job, existing);
|
|
}
|
|
|
|
const scrapeConfigs: string[] = [];
|
|
for (const [jobName, jobTargets] of jobGroups) {
|
|
const targetConfigs = jobTargets
|
|
.map(
|
|
(t) => ` - targets: ['${t.host}:${t.port}']
|
|
labels:
|
|
instance: '${t.instance}'`,
|
|
)
|
|
.join('\n');
|
|
|
|
scrapeConfigs.push(` - job_name: '${jobName}'
|
|
static_configs:
|
|
${targetConfigs}
|
|
metrics_path: '/metrics'
|
|
scrape_interval: '5s'`);
|
|
}
|
|
|
|
return `
|
|
global:
|
|
scrape_interval: 15s
|
|
|
|
scrape_configs:
|
|
${scrapeConfigs.join('\n')}
|
|
`;
|
|
}
|
|
|
|
export const victoriaMetrics: Service<VictoriaMetricsResult> = {
|
|
description: 'VictoriaMetrics',
|
|
|
|
getOptions(ctx: StartContext): VictoriaMetricsConfig {
|
|
const { mains, workers, projectName } = ctx;
|
|
const scrapeTargets: ScrapeTarget[] = [];
|
|
|
|
for (let i = 1; i <= mains; i++) {
|
|
const hostname = mains > 1 ? `${projectName}-n8n-main-${i}` : `${projectName}-n8n`;
|
|
scrapeTargets.push({
|
|
job: 'n8n-main',
|
|
instance: `n8n-main-${i}`,
|
|
host: hostname,
|
|
port: 5678,
|
|
});
|
|
}
|
|
for (let i = 1; i <= workers; i++) {
|
|
scrapeTargets.push({
|
|
job: 'n8n-worker',
|
|
instance: `n8n-worker-${i}`,
|
|
host: `${projectName}-n8n-worker-${i}`,
|
|
port: 5678,
|
|
});
|
|
}
|
|
|
|
return { scrapeTargets };
|
|
},
|
|
|
|
async start(
|
|
network: StartedNetwork,
|
|
projectName: string,
|
|
config?: unknown,
|
|
): Promise<VictoriaMetricsResult> {
|
|
const { scrapeTargets = [] } = (config as VictoriaMetricsConfig) ?? {};
|
|
const scrapeConfig = generateScrapeConfig(scrapeTargets);
|
|
|
|
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.victoriaMetrics)
|
|
.withName(`${projectName}-victoria-metrics`)
|
|
.withNetwork(network)
|
|
.withNetworkAliases(VICTORIA_METRICS_HOSTNAME)
|
|
.withLabels({
|
|
'com.docker.compose.project': projectName,
|
|
'com.docker.compose.service': 'victoria-metrics',
|
|
})
|
|
.withExposedPorts(VICTORIA_METRICS_HTTP_PORT)
|
|
.withCommand([
|
|
'-storageDataPath=/victoria-metrics-data',
|
|
'-retentionPeriod=1d',
|
|
'-promscrape.config=/etc/prometheus/prometheus.yml',
|
|
])
|
|
.withCopyContentToContainer([
|
|
{
|
|
content: scrapeConfig,
|
|
target: '/etc/prometheus/prometheus.yml',
|
|
},
|
|
])
|
|
.withWaitStrategy(
|
|
Wait.forHttp('/health', VICTORIA_METRICS_HTTP_PORT)
|
|
.forStatusCode(200)
|
|
.withStartupTimeout(60000),
|
|
)
|
|
.withReuse()
|
|
.start();
|
|
|
|
const httpPort = container.getMappedPort(VICTORIA_METRICS_HTTP_PORT);
|
|
|
|
return {
|
|
container,
|
|
meta: {
|
|
queryEndpoint: `http://localhost:${httpPort}`,
|
|
internalEndpoint: `http://${VICTORIA_METRICS_HOSTNAME}:${VICTORIA_METRICS_HTTP_PORT}`,
|
|
},
|
|
};
|
|
},
|
|
|
|
env(): Record<string, string> {
|
|
return {
|
|
N8N_METRICS_ENABLED: 'true',
|
|
};
|
|
},
|
|
};
|
|
|
|
export interface MetricResult {
|
|
labels: Record<string, string>;
|
|
value: number;
|
|
}
|
|
|
|
export interface WaitForMetricOptions {
|
|
timeoutMs?: number;
|
|
intervalMs?: number;
|
|
predicate?: (values: MetricResult[]) => boolean;
|
|
}
|
|
|
|
export class MetricsHelper {
|
|
constructor(private readonly endpoint: string) {}
|
|
|
|
async exportAll(options: { start?: string; end?: string } = {}): Promise<string> {
|
|
const params = new URLSearchParams({
|
|
'match[]': '{__name__=~".+"}',
|
|
});
|
|
if (options.start) params.set('start', options.start);
|
|
if (options.end) params.set('end', options.end);
|
|
|
|
const response = await fetch(`${this.endpoint}/api/v1/export?${params}`);
|
|
if (!response.ok) {
|
|
throw new Error(`VictoriaMetrics export failed: ${response.status}`);
|
|
}
|
|
|
|
return await response.text();
|
|
}
|
|
|
|
async query(query: string): Promise<MetricResult[]> {
|
|
const response = await fetch(`${this.endpoint}/api/v1/query?${new URLSearchParams({ query })}`);
|
|
if (!response.ok) {
|
|
throw new Error(`VictoriaMetrics query failed: ${response.status}`);
|
|
}
|
|
|
|
const data = (await response.json()) as {
|
|
status: string;
|
|
data?: { result: Array<{ metric: Record<string, string>; value: [number, string] }> };
|
|
error?: string;
|
|
};
|
|
|
|
if (data.status !== 'success') {
|
|
throw new Error(`VictoriaMetrics error: ${data.error}`);
|
|
}
|
|
|
|
return (data.data?.result ?? []).map((r) => ({
|
|
labels: r.metric,
|
|
value: parseFloat(r.value[1]),
|
|
}));
|
|
}
|
|
|
|
async waitForMetric(
|
|
query: string,
|
|
options: WaitForMetricOptions = {},
|
|
): Promise<MetricResult | null> {
|
|
const { setTimeout: wait } = await import('node:timers/promises');
|
|
const deadline = Date.now() + (options.timeoutMs ?? 30000);
|
|
const interval = options.intervalMs ?? 1000;
|
|
const predicate = options.predicate ?? ((v) => v.length > 0);
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const values = await this.query(query);
|
|
if (predicate(values)) return values[0] ?? null;
|
|
} catch {
|
|
// Ignore transient errors during polling
|
|
}
|
|
await wait(interval);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function createMetricsHelper(ctx: HelperContext): MetricsHelper {
|
|
const result = ctx.serviceResults.victoriaMetrics as VictoriaMetricsResult | undefined;
|
|
if (!result) {
|
|
throw new Error('VictoriaMetrics service not found in context');
|
|
}
|
|
return new MetricsHelper(result.meta.queryEndpoint);
|
|
}
|