From 95696cd13fd16f89b31d2228ec1843ee639ccefd Mon Sep 17 00:00:00 2001 From: DeFiVC Date: Wed, 21 Oct 2026 04:06:17 +0100 Subject: [PATCH 1/4] feat(security): add TLS certificate expiry monitoring check in CI Alert maintainers when a domain endpoint's TLS certificate expires within 30 days, so automated renewals can be verified before encrypted transport breaks. Includes a scheduled GitHub Actions workflow that publishes a report to the job summary and opens/updates an issue on failure. --- .github/workflows/tls-cert-check.yml | 56 +++++++++++ scripts/check-ssl-expiry.mjs | 142 +++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 .github/workflows/tls-cert-check.yml create mode 100644 scripts/check-ssl-expiry.mjs diff --git a/.github/workflows/tls-cert-check.yml b/.github/workflows/tls-cert-check.yml new file mode 100644 index 00000000..8a16bd3e --- /dev/null +++ b/.github/workflows/tls-cert-check.yml @@ -0,0 +1,56 @@ +name: TLS Certificate Expiry Check + +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + pull_request: + branches: [main] + paths: + - 'scripts/check-ssl-expiry.mjs' + - '.github/workflows/tls-cert-check.yml' + +permissions: + contents: read + issues: write + +jobs: + tls-expiry: + name: Check TLS certificate expiry + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Check certificate expiry (30-day alert window) + env: + SSL_CHECK_DOMAINS: ${{ vars.SSL_CHECK_DOMAINS }} + SSL_EXPIRY_WARN_DAYS: '30' + SSL_CHECK_SUMMARY: tls-cert-report.md + run: node scripts/check-ssl-expiry.mjs + + - name: Publish report to job summary + if: always() + run: | + if [ -f tls-cert-report.md ]; then + cat tls-cert-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Alert maintainers on expiring certificates + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: | + TITLE="TLS certificates expiring within 30 days" + EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "TLS certificates expiring" --json number --jq '.[0].number') + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file tls-cert-report.md + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label security --body-file tls-cert-report.md + fi diff --git a/scripts/check-ssl-expiry.mjs b/scripts/check-ssl-expiry.mjs new file mode 100644 index 00000000..c55835f5 --- /dev/null +++ b/scripts/check-ssl-expiry.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/** + * TLS certificate expiry monitor (issue #1362). + * + * Connects to each configured domain endpoint and fails when the peer + * certificate expires within the alert window (default 30 days), so + * maintainers are warned before encrypted transport breaks. + * + * Environment variables: + * SSL_CHECK_DOMAINS Comma-separated host[:port] list (default: localhost:443 disabled) + * SSL_EXPIRY_WARN_DAYS Days before expiry to alert (default: 30) + * SSL_CHECK_SUMMARY If set, write a markdown report to this file + * + * Usage: + * node scripts/check-ssl-expiry.mjs + * SSL_CHECK_DOMAINS=example.com,api.example.com:443 node scripts/check-ssl-expiry.mjs + */ + +import tls from 'node:tls'; +import { writeFileSync } from 'node:fs'; + +const DOMAINS = (process.env.SSL_CHECK_DOMAINS ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + +const WARN_DAYS = Number(process.env.SSL_EXPIRY_WARN_DAYS ?? 30); +const SUMMARY_FILE = process.env.SSL_CHECK_SUMMARY ?? ''; +const WARN_MS = WARN_DAYS * 24 * 60 * 60 * 1000; + +/** + * @param {string} host + * @param {number} port + * @returns {Promise<{ subject: string, issuer: string, validTo: Date, daysRemaining: number }>} + */ +function inspectCertificate(host, port = 443) { + return new Promise((resolve, reject) => { + const socket = tls.connect( + { + host, + port, + servername: host, + rejectUnauthorized: false, + timeout: 15_000, + }, + () => { + const cert = socket.getPeerCertificate(); + socket.end(); + if (!cert || !cert.valid_to) { + reject(new Error(`No peer certificate presented by ${host}:${port}`)); + return; + } + const validTo = new Date(cert.valid_to); + resolve({ + subject: cert.subject?.CN ?? host, + issuer: cert.issuer?.O ?? cert.issuer?.CN ?? 'unknown', + validTo, + daysRemaining: Math.floor((validTo.getTime() - Date.now()) / (24 * 60 * 60 * 1000)), + }); + }, + ); + + socket.on('error', (err) => reject(err)); + socket.on('timeout', () => { + socket.destroy(); + reject(new Error(`Connection to ${host}:${port} timed out`)); + }); + }); +} + +async function main() { + if (DOMAINS.length === 0) { + process.stderr.write( + '[ssl-check] SSL_CHECK_DOMAINS is empty — nothing to check. Set it to a comma-separated host[:port] list.\n', + ); + process.exit(0); + } + + const rows = []; + let expiring = 0; + + for (const entry of DOMAINS) { + const [host, portRaw] = entry.split(':'); + const port = Number(portRaw ?? 443); + try { + const info = await inspectCertificate(host, port); + const ok = info.daysRemaining > WARN_DAYS; + if (!ok) expiring += 1; + rows.push({ endpoint: `${host}:${port}`, status: ok ? 'ok' : 'expiring', ...info }); + } catch (err) { + expiring += 1; + rows.push({ + endpoint: `${host}:${port}`, + status: 'error', + subject: '-', + issuer: '-', + validTo: new Date(0), + daysRemaining: -1, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + const lines = [ + '# TLS Certificate Expiry Report', + '', + `Alert threshold: **${WARN_DAYS} days**`, + '', + '| Endpoint | Status | Subject | Issuer | Expires | Days left |', + '| --- | --- | --- | --- | --- | --- |', + ]; + + for (const row of rows) { + const expires = row.status === 'error' ? '-' : row.validTo.toISOString().slice(0, 10); + const days = row.status === 'error' ? '-' : String(row.daysRemaining); + const detail = row.error ? ` — ${row.error}` : ''; + lines.push( + `| ${row.endpoint} | ${row.status}${detail} | ${row.subject} | ${row.issuer} | ${expires} | ${days} |`, + ); + } + + lines.push(''); + if (expiring > 0) { + lines.push(`**${expiring} endpoint(s) need attention** (expiring within ${WARN_DAYS} days or unreachable).`); + } else { + lines.push(`All ${rows.length} endpoint(s) are valid for more than ${WARN_DAYS} days.`); + } + + const report = lines.join('\n'); + if (SUMMARY_FILE) { + writeFileSync(SUMMARY_FILE, report + '\n', 'utf8'); + } + process.stdout.write(report + '\n'); + + if (expiring > 0) { + process.stderr.write(`[ssl-check] FAIL — ${expiring} endpoint(s) expiring within ${WARN_DAYS} days\n`); + process.exit(1); + } + process.stderr.write(`[ssl-check] OK — all ${rows.length} endpoint(s) healthy\n`); +} + +main(); From c3a71f0b20e82fa8d227edf457a161889298bc50 Mon Sep 17 00:00:00 2001 From: DeFiVC Date: Wed, 21 Oct 2026 04:31:28 +0100 Subject: [PATCH 2/4] feat(testing): publish visual diff artifacts to GitHub Actions run summaries Add a visual-diff-summary script that inventories screenshot diff/actual/ expected artifacts and Playwright results, and wire it into the visual-regression workflow so diffs appear in the run summary alongside the uploaded artifacts. --- .github/workflows/visual-regression.yml | 14 +++ scripts/visual-diff-summary.mjs | 113 ++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 scripts/visual-diff-summary.mjs diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index f529aa3e..4ca29f09 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -65,3 +65,17 @@ jobs: frontend/tests/visual/__snapshots__/**/*-actual.png retention-days: 30 if-no-files-found: ignore + + - name: Publish visual diff report to job summary + if: always() + env: + VISUAL_DIFF_ROOT: frontend/tests/visual + VISUAL_DIFF_SUMMARY: visual-diff-summary.md + run: node scripts/visual-diff-summary.mjs + + - name: Append report to run summary + if: always() + run: | + if [ -f visual-diff-summary.md ]; then + cat visual-diff-summary.md >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/scripts/visual-diff-summary.mjs b/scripts/visual-diff-summary.mjs new file mode 100644 index 00000000..2d698e26 --- /dev/null +++ b/scripts/visual-diff-summary.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * Visual diff summary builder (issue #1363). + * + * Collects Playwright/Percy-style screenshot diff artifacts (actual, expected, + * diff images) and renders a markdown report suitable for the GitHub Actions + * run summary, so visual regressions are visible without downloading artifacts. + * + * Environment variables: + * VISUAL_DIFF_ROOT Directory to scan (default: frontend/tests/visual) + * VISUAL_DIFF_REPORT Path to Playwright JSON results (optional) + * VISUAL_DIFF_SUMMARY Output markdown path (default: stdout) + * + * Usage: + * node scripts/visual-diff-summary.mjs + */ + +import { readdirSync, statSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const ROOT = process.env.VISUAL_DIFF_ROOT ?? 'frontend/tests/visual'; +const REPORT = process.env.VISUAL_DIFF_REPORT ?? ''; +const SUMMARY_FILE = process.env.VISUAL_DIFF_SUMMARY ?? ''; + +/** + * @param {string} dir + * @param {(name: string) => boolean} predicate + * @returns {string[]} + */ +function findFiles(dir, predicate) { + /** @type {string[]} */ + const found = []; + if (!existsSync(dir)) return found; + + /** @param {string} current */ + const walk = (current) => { + for (const entry of readdirSync(current)) { + const full = join(current, entry); + const stats = statSync(full); + if (stats.isDirectory()) { + walk(full); + } else if (predicate(entry)) { + found.push(full); + } + } + }; + + walk(dir); + return found.sort(); +} + +function buildReport() { + const diffs = findFiles(ROOT, (name) => name.endsWith('-diff.png')); + const actuals = findFiles(ROOT, (name) => name.endsWith('-actual.png')); + const expecteds = findFiles(ROOT, (name) => name.endsWith('-expected.png')); + + let testSummary = ''; + if (REPORT && existsSync(REPORT)) { + try { + const results = JSON.parse(readFileSync(REPORT, 'utf8')); + const stats = results.stats ?? {}; + testSummary = [ + '', + '## Test Results', + '', + '| Metric | Count |', + '| --- | --- |', + `| Expected | ${stats.expected ?? 0} |`, + `| Unexpected (failures) | ${stats.unexpected ?? 0} |`, + `| Flaky | ${stats.flaky ?? 0} |`, + `| Skipped | ${stats.skipped ?? 0} |`, + ].join('\n'); + } catch { + testSummary = ''; + } + } + + const lines = [ + '# Visual Regression Report', + '', + `Scanned \`${ROOT}\` for screenshot diff artifacts.`, + '', + '| Artifact type | Files found |', + '| --- | --- |', + `| Diff images (\`-diff.png\`) | ${diffs.length} |`, + `| Actual images (\`-actual.png\`) | ${actuals.length} |`, + `| Expected baselines (\`-expected.png\`) | ${expecteds.length} |`, + ]; + + if (diffs.length > 0) { + lines.push('', '## Visual Diffs', ''); + for (const file of diffs) { + lines.push(`- \`${relative(process.cwd(), file)}\``); + } + lines.push( + '', + '> Diff, actual, and expected images are uploaded as workflow artifacts.', + '> Download the `visual-regression-diffs` artifact to inspect pixel-level changes.', + ); + } else { + lines.push('', 'No visual differences detected.'); + } + + if (testSummary) lines.push(testSummary); + + return lines.join('\n'); +} + +const report = buildReport(); +if (SUMMARY_FILE) { + writeFileSync(SUMMARY_FILE, report + '\n', 'utf8'); +} +process.stdout.write(report + '\n'); From 18955d456845823f3218112cf1df235a6782c0b6 Mon Sep 17 00:00:00 2001 From: DeFiVC Date: Wed, 21 Oct 2026 04:34:51 +0100 Subject: [PATCH 3/4] feat(testing): add synthetic transaction bot for Testnet flow integrity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuous background bot that simulates the register → credit → verify → cleanup user journey against Testnet so campaign flow integrity is exercised 24/7. Emits Prometheus counters/gauges per iteration and supports --once / --iterations for CI and scheduled runs. --- scripts/synthetic-transaction-bot.mjs | 187 ++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 scripts/synthetic-transaction-bot.mjs diff --git a/scripts/synthetic-transaction-bot.mjs b/scripts/synthetic-transaction-bot.mjs new file mode 100644 index 00000000..8d881a51 --- /dev/null +++ b/scripts/synthetic-transaction-bot.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * Synthetic user transaction bot (issue #1364). + * + * Continuously simulates end-to-end campaign activity on Testnet — create, + * credit, verify, clean up — so flow integrity is exercised 24/7 without + * waiting for real user traffic. Emits Prometheus metrics per iteration so + * regressions surface through the existing alerting stack. + * + * Metrics emitted (plain Prometheus text): + * trivela_synthbot_iterations_total{job="trivela-synthbot"} + * trivela_synthbot_failures_total{job="trivela-synthbot"} + * trivela_synthbot_last_duration_seconds{job="trivela-synthbot"} + * trivela_synthbot_last_run_timestamp{job="trivela-synthbot"} + * + * Usage: + * node scripts/synthetic-transaction-bot.mjs # run forever + * node scripts/synthetic-transaction-bot.mjs --once # single iteration + * node scripts/synthetic-transaction-bot.mjs --iterations 5 + * + * Environment variables: + * SYNTHBOT_API_URL Base URL of the Trivela backend (default: http://localhost:3001) + * SYNTHBOT_API_KEY API key with campaign write access + * SYNTHBOT_WALLET Stellar G-address used as the synthetic claimant + * SYNTHBOT_CONTRACT_ID Testnet campaign contract ID (optional) + * SYNTHBOT_INTERVAL_MS Pause between iterations (default: 60000) + * SYNTHBOT_TIMEOUT_MS Per-request timeout (default: 15000) + * SYNTHBOT_METRICS_FILE If set, overwrite this file with metrics each iteration + * STELLAR_NETWORK testnet | mainnet (default: testnet) + */ + +import { writeFileSync } from 'node:fs'; + +const API_URL = (process.env.SYNTHBOT_API_URL ?? 'http://localhost:3001').replace(/\/$/, ''); +const API_KEY = process.env.SYNTHBOT_API_KEY ?? ''; +const WALLET = process.env.SYNTHBOT_WALLET ?? 'GDUMMY_SYNTHBOT_WALLET_ADDRESS_NOT_SET'; +const CONTRACT_ID = process.env.SYNTHBOT_CONTRACT_ID ?? ''; +const INTERVAL_MS = Number(process.env.SYNTHBOT_INTERVAL_MS ?? 60_000); +const TIMEOUT_MS = Number(process.env.SYNTHBOT_TIMEOUT_MS ?? 15_000); +const METRICS_FILE = process.env.SYNTHBOT_METRICS_FILE ?? ''; +const JOB_LABEL = 'trivela-synthbot'; +const NETWORK = process.env.STELLAR_NETWORK ?? 'testnet'; + +const args = process.argv.slice(2); +const ONCE = args.includes('--once'); +const iterationsArg = args.indexOf('--iterations'); +const MAX_ITERATIONS = + ONCE ? 1 : iterationsArg >= 0 ? Number(args[iterationsArg + 1] ?? 1) : Infinity; + +const stats = { iterations: 0, failures: 0, lastDuration: 0, lastRun: 0 }; + +/** @param {string} url @param {RequestInit} opts */ +async function apiFetch(url, opts = {}) { + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), TIMEOUT_MS); + try { + const res = await fetch(url, { + ...opts, + signal: ac.signal, + headers: { + 'Content-Type': 'application/json', + ...(API_KEY ? { 'x-api-key': API_KEY } : {}), + ...opts.headers, + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`HTTP ${res.status} ${res.statusText} — ${body.slice(0, 200)}`); + } + return res.json(); + } finally { + clearTimeout(t); + } +} + +function emitMetrics() { + const lines = [ + `# HELP trivela_synthbot_iterations_total Completed synthetic transaction iterations.`, + `# TYPE trivela_synthbot_iterations_total counter`, + `trivela_synthbot_iterations_total{job="${JOB_LABEL}"} ${stats.iterations}`, + `# HELP trivela_synthbot_failures_total Failed synthetic transaction iterations.`, + `# TYPE trivela_synthbot_failures_total counter`, + `trivela_synthbot_failures_total{job="${JOB_LABEL}"} ${stats.failures}`, + `# HELP trivela_synthbot_last_duration_seconds Wall time of the most recent iteration.`, + `# TYPE trivela_synthbot_last_duration_seconds gauge`, + `trivela_synthbot_last_duration_seconds{job="${JOB_LABEL}"} ${stats.lastDuration.toFixed(3)}`, + `# HELP trivela_synthbot_last_run_timestamp Unix epoch of the most recent iteration.`, + `# TYPE trivela_synthbot_last_run_timestamp gauge`, + `trivela_synthbot_last_run_timestamp{job="${JOB_LABEL}"} ${stats.lastRun}`, + '', + ].join('\n'); + + if (METRICS_FILE) { + writeFileSync(METRICS_FILE, lines, 'utf8'); + } else { + process.stdout.write(lines); + } +} + +/** + * One synthetic user journey: browse → create → credit → verify → clean up. + * Mirrors the canary flow but is safe to repeat indefinitely on Testnet. + */ +async function runIteration() { + const start = Date.now(); + let campaignId = null; + + try { + const health = await apiFetch(`${API_URL}/health`); + if (health.status !== 'ok' && health.status !== 'degraded') { + throw new Error(`Health check returned unexpected status: ${health.status}`); + } + + const stamp = Date.now(); + const campaign = await apiFetch(`${API_URL}/api/v1/campaigns`, { + method: 'POST', + body: JSON.stringify({ + name: `__synthbot_${stamp}`, + description: 'Synthetic transaction bot campaign — safe to delete', + rewardPerAction: 1, + active: true, + ...(CONTRACT_ID ? { contractId: CONTRACT_ID } : {}), + tags: ['synthbot', NETWORK], + }), + }); + campaignId = campaign.id ?? campaign.data?.id; + if (!campaignId) throw new Error('Campaign creation did not return an id'); + + await apiFetch(`${API_URL}/api/v1/campaigns/${campaignId}/credits`, { + method: 'POST', + body: JSON.stringify({ walletAddress: WALLET, amount: 1 }), + }).catch((err) => { + if (err.message.startsWith('HTTP 404') || err.message.startsWith('HTTP 405')) return null; + throw err; + }); + + const listing = await apiFetch(`${API_URL}/api/v1/campaigns`); + if (!listing || (Array.isArray(listing) && listing.length === 0 && listing.data === undefined)) { + if (!listing) throw new Error('Campaign listing returned empty response'); + } + + const statsView = await apiFetch(`${API_URL}/api/v1/campaigns/${campaignId}`); + if (!statsView || (!statsView.id && !statsView.data?.id)) { + throw new Error('Campaign fetch returned empty response'); + } + + await apiFetch(`${API_URL}/api/v1/campaigns/${campaignId}`, { method: 'DELETE' }).catch(() => {}); + + stats.iterations += 1; + stats.lastDuration = (Date.now() - start) / 1000; + stats.lastRun = Math.floor(Date.now() / 1000); + process.stderr.write( + `[synthbot] iteration ${stats.iterations} OK — ${stats.lastDuration.toFixed(2)}s\n`, + ); + } catch (err) { + if (campaignId) { + apiFetch(`${API_URL}/api/v1/campaigns/${campaignId}`, { method: 'DELETE' }).catch(() => {}); + } + stats.iterations += 1; + stats.failures += 1; + stats.lastDuration = (Date.now() - start) / 1000; + stats.lastRun = Math.floor(Date.now() / 1000); + process.stderr.write(`[synthbot] iteration ${stats.iterations} FAIL — ${err.message}\n`); + } finally { + emitMetrics(); + } +} + +async function main() { + process.stderr.write( + `[synthbot] starting against ${API_URL} (network=${NETWORK}, interval=${INTERVAL_MS}ms)\n`, + ); + + for (let i = 0; i < MAX_ITERATIONS; i += 1) { + await runIteration(); + if (i < MAX_ITERATIONS - 1) { + await new Promise((resolve) => setTimeout(resolve, INTERVAL_MS)); + } + } + + process.stderr.write( + `[synthbot] stopped after ${stats.iterations} iteration(s), ${stats.failures} failure(s)\n`, + ); + process.exit(stats.failures > 0 ? 1 : 0); +} + +main(); From 118f95aaff1c40778e7b851ac96ebd24a0cb16aa Mon Sep 17 00:00:00 2001 From: DeFiVC Date: Wed, 21 Oct 2026 04:58:13 +0100 Subject: [PATCH 4/4] feat(infra): log slow database queries for execution time detection Instrument better-sqlite3 prepared statements so queries at or above a 200ms threshold emit structured warn logs (shipped to CloudWatch via the backend's stdout) and optional Sentry events, without altering query results or letting telemetry failures break execution. --- backend/src/dal/index.js | 4 + backend/src/db/slowQueryLog.js | 134 ++++++++++++++++++++++++++++ backend/src/db/slowQueryLog.test.js | 123 +++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 backend/src/db/slowQueryLog.js create mode 100644 backend/src/db/slowQueryLog.test.js diff --git a/backend/src/dal/index.js b/backend/src/dal/index.js index cc72c9d1..b99de655 100644 --- a/backend/src/dal/index.js +++ b/backend/src/dal/index.js @@ -30,6 +30,7 @@ import { createSqliteCampaignRewardNotificationRepository } from './sqliteCampai import { runPgMigrations } from './pg/migrate.js'; import { createPgCampaignRepository } from './pg/pgCampaignRepository.js'; import { createPgAuditLogRepository } from './pg/pgAuditLogRepository.js'; +import { instrumentDatabase } from '../db/slowQueryLog.js'; /** * Build the DAL. @@ -57,6 +58,9 @@ export async function createDal({ } = {}) { const db = new Database(dbPath); await runMigrations(db); + instrumentDatabase(db, { + thresholdMs: Number(process.env.DB_SLOW_QUERY_MS ?? 200), + }); const categories = allowedCategories ?? parseCategoriesConfig(process.env.TRIVELA_CATEGORIES); diff --git a/backend/src/db/slowQueryLog.js b/backend/src/db/slowQueryLog.js new file mode 100644 index 00000000..d5d1151d --- /dev/null +++ b/backend/src/db/slowQueryLog.js @@ -0,0 +1,134 @@ +// @ts-check +import { log } from '../middleware/logger.js'; + +/** Queries at or above this duration (ms) are reported. */ +export const DEFAULT_SLOW_QUERY_MS = 200; + +/** + * Parses a Sentry DSN into the pieces needed for the store endpoint. + * + * @param {string} dsn + */ +function parseSentryDsn(dsn) { + try { + const url = new URL(dsn); + const publicKey = decodeURIComponent(url.username); + const projectId = url.pathname.replace(/^\//, ''); + if (!publicKey || !projectId) return null; + return { + storeUrl: `${url.origin}/api/${projectId}/store/`, + publicKey, + }; + } catch { + return null; + } +} + +/** + * @typedef {{ sql: string, durationMs: number, thresholdMs: number, detail?: string }} SlowQueryEvent + */ + +/** + * Creates a slow-query reporter that logs queries at or above the threshold + * and fans the event out to configured sinks (Sentry, CloudWatch, …). + * + * The default log sink writes a structured JSON line, which is what the + * CloudWatch agent ships from the backend's stdout. + * + * @param {{ thresholdMs?: number, sinks?: Array<(event: SlowQueryEvent) => void>, sentryDsn?: string }} [options] + */ +export function createSlowQueryReporter(options = {}) { + const thresholdMs = options.thresholdMs ?? DEFAULT_SLOW_QUERY_MS; + const sinks = [...(options.sinks ?? [])]; + + const sentryDsn = options.sentryDsn ?? process.env.SENTRY_DSN ?? ''; + const sentry = sentryDsn ? parseSentryDsn(sentryDsn) : null; + + return { + thresholdMs, + /** @param {SlowQueryEvent} event */ + report(event) { + if (event.durationMs < thresholdMs) return; + + log.warn({ + event: 'db.slow_query', + sql: event.sql, + duration_ms: event.durationMs, + threshold_ms: thresholdMs, + ...(event.detail ? { detail: event.detail } : {}), + }); + + if (sentry) { + fetch(sentry.storeUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Sentry-Auth': `Sentry sentry_version=7, sentry_key=${sentry.publicKey}`, + }, + body: JSON.stringify({ + level: 'warning', + logger: 'db.slow_query', + message: `Slow query (${event.durationMs}ms ≥ ${thresholdMs}ms): ${event.sql.slice(0, 200)}`, + tags: { duration_ms: String(event.durationMs), threshold_ms: String(thresholdMs) }, + }), + }).catch(() => { + // Telemetry must never break query execution. + }); + } + + for (const sink of sinks) { + try { + sink(event); + } catch { + // Same guarantee as above. + } + } + }, + }; +} + +/** + * Times `fn` and reports when it runs slowly. + * + * @template T + * @param {{ sql: string, detail?: string }} meta + * @param {() => T} fn + * @param {{ report: (event: SlowQueryEvent) => void }} reporter + * @returns {T} + */ +export function timeSyncQuery(meta, fn, reporter) { + const start = process.hrtime.bigint(); + try { + return fn(); + } finally { + reporter.report({ + sql: meta.sql, + durationMs: Number(process.hrtime.bigint() - start) / 1e6, + ...(meta.detail ? { detail: meta.detail } : {}), + }); + } +} + +/** + * Instruments a better-sqlite3 database so every prepared statement + * execution is timed and slow ones are reported. + * + * @param {import('better-sqlite3').Database} db + * @param {{ thresholdMs?: number, sinks?: Array<(event: SlowQueryEvent) => void>, sentryDsn?: string }} [options] + */ +export function instrumentDatabase(db, options = {}) { + const reporter = createSlowQueryReporter(options); + const originalPrepare = db.prepare.bind(db); + + db.prepare = (sql) => { + const stmt = originalPrepare(sql); + for (const method of /** @type {const} */ (['get', 'all', 'run'])) { + const original = stmt[method].bind(stmt); + stmt[method] = (...args) => + timeSyncQuery({ sql, detail: method }, () => original(...args), reporter); + } + return stmt; + }; + + return reporter; +} diff --git a/backend/src/db/slowQueryLog.test.js b/backend/src/db/slowQueryLog.test.js new file mode 100644 index 00000000..d71a69b9 --- /dev/null +++ b/backend/src/db/slowQueryLog.test.js @@ -0,0 +1,123 @@ +// @ts-check +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Database from 'better-sqlite3'; +import { + DEFAULT_SLOW_QUERY_MS, + createSlowQueryReporter, + instrumentDatabase, + timeSyncQuery, +} from './slowQueryLog.js'; + +test('reporter ignores queries below the threshold', () => { + /** @type {any[]} */ + const events = []; + const reporter = createSlowQueryReporter({ thresholdMs: 200, sinks: [events.push.bind(events)] }); + + reporter.report({ sql: 'SELECT 1', durationMs: 50 }); + + assert.equal(events.length, 0, 'sub-threshold queries must not be reported'); +}); + +test('reporter emits slow queries to every sink', () => { + /** @type {any[]} */ + const a = []; + /** @type {any[]} */ + const b = []; + const reporter = createSlowQueryReporter({ thresholdMs: 200, sinks: [a.push.bind(a), b.push.bind(b)] }); + + reporter.report({ sql: 'SELECT * FROM campaigns', durationMs: 250, detail: 'get' }); + + assert.equal(a.length, 1); + assert.equal(b.length, 1); + assert.equal(a[0].sql, 'SELECT * FROM campaigns'); + assert.equal(a[0].durationMs, 250); + assert.equal(a[0].detail, 'get'); +}); + +test('reporter threshold is configurable and defaults to 200ms', () => { + assert.equal(DEFAULT_SLOW_QUERY_MS, 200); + assert.equal(createSlowQueryReporter().thresholdMs, 200); + assert.equal(createSlowQueryReporter({ thresholdMs: 50 }).thresholdMs, 50); +}); + +test('a throwing sink does not break reporting', () => { + /** @type {any[]} */ + const events = []; + const reporter = createSlowQueryReporter({ + thresholdMs: 1, + sinks: [ + () => { + throw new Error('sink exploded'); + }, + events.push.bind(events), + ], + }); + + reporter.report({ sql: 'SELECT 1', durationMs: 10 }); + + assert.equal(events.length, 1, 'later sinks must still receive the event'); +}); + +test('timeSyncQuery reports only slow executions', () => { + /** @type {any[]} */ + const events = []; + const reporter = createSlowQueryReporter({ thresholdMs: 0.0001, sinks: [events.push.bind(events)] }); + + const result = timeSyncQuery({ sql: 'SELECT 42', detail: 'get' }, () => 42, reporter); + + assert.equal(result, 42); + assert.equal(events.length, 1); + assert.equal(events[0].sql, 'SELECT 42'); + assert.ok(events[0].durationMs >= 0, 'durationMs must be non-negative'); +}); + +test('timeSyncQuery reports even when the query throws', () => { + /** @type {any[]} */ + const events = []; + const reporter = createSlowQueryReporter({ thresholdMs: 0, sinks: [events.push.bind(events)] }); + + assert.throws(() => + timeSyncQuery({ sql: 'SELECT bad' }, () => { + throw new Error('boom'); + }, reporter), + ); + assert.equal(events.length, 1, 'failed slow queries must still be reported'); +}); + +test('instrumentDatabase times prepared statement methods', () => { + /** @type {any[]} */ + const events = []; + const db = new Database(':memory:'); + db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)'); + + instrumentDatabase(db, { thresholdMs: 0, sinks: [events.push.bind(events)] }); + + db.prepare('INSERT INTO t (name) VALUES (?)').run('alpha'); + db.prepare('SELECT * FROM t').all(); + db.prepare('SELECT * FROM t WHERE id = ?').get(1); + + const sqls = events.map((e) => e.sql); + assert.ok(sqls.includes('INSERT INTO t (name) VALUES (?)')); + assert.ok(sqls.includes('SELECT * FROM t')); + assert.ok(sqls.includes('SELECT * FROM t WHERE id = ?')); + assert.equal(events.length, 3, 'each statement execution must be timed'); + + db.close(); +}); + +test('instrumentDatabase still returns correct query results', () => { + const db = new Database(':memory:'); + db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)'); + + instrumentDatabase(db, { thresholdMs: 10_000 }); + + db.prepare('INSERT INTO t (name) VALUES (?)').run('alpha'); + const rows = db.prepare('SELECT name FROM t').all(); + const row = db.prepare('SELECT name FROM t WHERE id = ?').get(1); + + assert.deepEqual(rows, [{ name: 'alpha' }]); + assert.deepEqual(row, { name: 'alpha' }); + + db.close(); +});