#!/usr/bin/env node /** * Shared metrics sender for CI scripts. * See .github/CI-TELEMETRY.md for payload shape and BigQuery schema. * * Usage (fire-and-forget — best-effort, never blocks CI): * import { sendMetrics, metric } from './send-metrics.mjs'; * sendMetrics([metric('build-duration', 45.2, 's', { package: '@n8n/cli' })]) * .catch((err) => console.warn(`[metrics] send failed: ${err.message}`)); * * Env: QA_METRICS_WEBHOOK_URL, QA_METRICS_WEBHOOK_USER, QA_METRICS_WEBHOOK_PASSWORD */ import * as os from 'node:os'; /** Build a single metric object. */ export function metric(name, value, unit, dimensions = {}) { return { metric_name: name, value, unit, dimensions }; } /** Build git/ci/runner context from environment variables. */ export function buildContext(benchmarkName = null) { // Use `||` (not `??`) when falling back across GitHub env vars: on `push` and // `merge_group` events GITHUB_HEAD_REF is set to '' (empty string, not undefined), // so `??` would never fall through to GITHUB_REF_NAME and every non-PR row would // land in BigQuery with git_branch = ''. The same applies to any other GITHUB_* // var whose intended fallback is null — preserve null, not ''. const ref = process.env.GITHUB_REF || ''; const prMatch = ref.match(/refs\/pull\/(\d+)/); const runId = process.env.GITHUB_RUN_ID || null; return { timestamp: new Date().toISOString(), benchmark_name: benchmarkName, git: { sha: process.env.GITHUB_SHA?.slice(0, 8) || null, branch: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || null, pr: prMatch ? parseInt(prMatch[1], 10) : null, }, ci: { runId, runUrl: runId && process.env.GITHUB_REPOSITORY ? `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${runId}` : null, job: process.env.GITHUB_JOB || null, workflow: process.env.GITHUB_WORKFLOW || null, attempt: process.env.GITHUB_RUN_ATTEMPT ? parseInt(process.env.GITHUB_RUN_ATTEMPT, 10) : null, }, runner: { provider: !process.env.CI ? 'local' : process.env.RUNNER_ENVIRONMENT === 'github-hosted' ? 'github' : 'blacksmith', cpuCores: os.cpus().length, memoryGb: Math.round((os.totalmem() / (1024 * 1024 * 1024)) * 10) / 10, }, }; } export async function sendMetrics(metrics, benchmarkName = null) { const webhookUrl = process.env.QA_METRICS_WEBHOOK_URL; const webhookUser = process.env.QA_METRICS_WEBHOOK_USER; const webhookPassword = process.env.QA_METRICS_WEBHOOK_PASSWORD; if (!webhookUrl) { console.log('QA_METRICS_WEBHOOK_URL not set, skipping.'); return; } if (!webhookUser || !webhookPassword) { console.log('QA_METRICS_WEBHOOK_USER/PASSWORD not set, skipping.'); return; } const payload = { ...buildContext(benchmarkName), metrics }; const basicAuth = Buffer.from(`${webhookUser}:${webhookPassword}`).toString('base64'); // Best-effort telemetry: never throw into the caller. Slow/down webhook // becomes a one-line warning instead of an uncaught rejection. 5s caps // wall-time in callers that do await this; fire-and-forget callers exit // before then anyway. try { const response = await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Basic ${basicAuth}`, }, body: JSON.stringify(payload), signal: AbortSignal.timeout(5_000), }); if (!response.ok) { const body = await response.text().catch(() => ''); console.warn( `[metrics] webhook ${response.status} ${response.statusText}${body ? `\n${body}` : ''}`, ); return; } console.log(`Sent ${metrics.length} metric(s): ${response.status}`); } catch (err) { console.warn(`[metrics] send failed (non-fatal): ${err.message}`); } }