Files

105 lines
5.1 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:3c9603902fe01cc9593bf69315c4734fdbba824688512bed37af01059c22d688
import * as db from '../db.js';
import { recordApiCall } from './api-metrics.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
const LOG = CONFIG.INTEGRATION_LOG;
const MIN_SECRET_LENGTH = 4;
const AUTH_VALUE = /\b(Bearer|Basic)\s+\S+/gi;
const SECRET_PARAM = /\b((?:api[_-]?key|apikey|access[_-]?token|token|key|secret|signature|password)=)[^&\s"']+/gi;
const URL_TEXT = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi;
function cleanDetail(text, secrets) {
if (text == null) return null;
let s = String(text);
for (const v of secrets) {
if (typeof v !== 'string' || v.length < MIN_SECRET_LENGTH) continue;
for (const form of new Set([v, encodeURIComponent(v)])) s = s.split(form).join('***');
}
s = s.replace(URL_TEXT, '<url>').replace(AUTH_VALUE, '$1 ***').replace(SECRET_PARAM, '$1***');
s = s.replace(/\s+/g, ' ').trim();
if (!s) return null;
return s.length > LOG.DETAIL_MAX_CHARS ? `${s.slice(0, LOG.DETAIL_MAX_CHARS - 1)}…` : s;
}
const KEY_PART = /^[A-Za-z][\w.-]{0,79}$/;
const cleanKey = (key) => {
const parts = String(key ?? '').split(/\s+/).filter(p => KEY_PART.test(p));
return parts.length ? parts.join(' ') : null;
};
const REQUEST_ID = /^[A-Za-z0-9-]{8,64}$/;
function timeOf(at) {
if (at == null) return new Date().toISOString();
const d = new Date(at);
if (Number.isNaN(d.getTime())) throw new Error(`Invalid occurredAt: '${at}'.`);
return d.toISOString();
}
function prune() {
const grens = new Date(Date.now() - LOG.RETENTION_DAYS * 86_400_000).toISOString();
db.run(`DELETE FROM IntegrationCall WHERE occurredAt < ?`, [grens]);
db.run(`DELETE FROM IntegrationCall
WHERE id <= (SELECT id FROM IntegrationCall ORDER BY id DESC LIMIT 1 OFFSET ?)`, [LOG.MAX_ROWS]);
}
export async function recordIntegrationCall(entry, { secrets = [] } = {}) {
const e = entry || {};
if (!ENUMS.INTEGRATION_TARGET.includes(e.target)) throw new Error(`Unknown integration target: '${e.target}'.`);
if (!ENUMS.INTEGRATION_OUTCOME.includes(e.outcome)) throw new Error(`Unknown integration outcome: '${e.outcome}'.`);
const providerKey = e.target === 'provider' ? String(e.providerKey || '') : null;
if (e.target === 'provider' && !providerKey) throw new Error('A provider row needs a providerKey.');
const operation = String(e.operation || '');
if (!operation || /[?#]|:\/\//.test(operation)) throw new Error(`An operation is a path or a capability: '${operation}'.`);
const subject = e.subject == null ? null : String(e.subject).slice(0, 100);
const status = Number.isInteger(e.httpStatus) && e.httpStatus >= 100 && e.httpStatus <= 599 ? e.httpStatus : null;
const latency = Number.isFinite(e.latencyMs) && e.latencyMs >= 0 ? Math.round(e.latencyMs) : null;
const requestId = e.target === 'service' && REQUEST_ID.test(String(e.requestId ?? '')) ? e.requestId : null;
const clean = cleanDetail(e.detail, Array.isArray(secrets) ? secrets : []);
db.run(`INSERT INTO IntegrationCall
(target, providerKey, operation, subject, outcome, httpStatus, errorKey, detail, latencyMs, requestId, occurredAt)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
[e.target, providerKey, operation, subject, e.outcome, status, cleanKey(e.errorKey), clean, latency, requestId,
timeOf(e.occurredAt)]);
prune();
await db.persist();
}
export async function countServiceCall({ capability, ok, latencyMs }) {
const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [CONFIG.MARKET_SERVICE.PROVIDER_KEY]);
if (!p) return;
await recordApiCall({ providerId: p.id, capability, ok: !!ok, latency_ms: latencyMs });
}
export function listIntegrationCalls({ days = 7, target = null, providerKey = null, limit = 200 } = {}) {
const sinds = new Date(Date.now() - Math.max(0, Number(days) || 0) * 86_400_000).toISOString();
const n = Math.min(Math.max(1, Math.trunc(Number(limit)) || 200), LOG.MAX_ROWS);
const t = target || null;
const p = providerKey || null;
return db.all(`SELECT * FROM IntegrationCall
WHERE occurredAt >= ? AND (? IS NULL OR target = ?) AND (? IS NULL OR providerKey = ?)
ORDER BY id DESC LIMIT ?`, [sinds, t, t, p, p, n]);
}
export function serviceState() {
const lastFail = db.one(`SELECT * FROM IntegrationCall WHERE target = 'service' AND outcome = 'fail'
ORDER BY id DESC LIMIT 1`) || null;
const row = db.one(`SELECT MAX(bucketMinute) AS at FROM ApiCallMetric
JOIN Provider ON Provider.id = ApiCallMetric.providerId
WHERE Provider.key = ? AND okCount > 0`, [CONFIG.MARKET_SERVICE.PROVIDER_KEY]);
const lastOkAt = row?.at ?? null;
if (lastFail && (!lastOkAt || lastFail.occurredAt.slice(0, 16) >= lastOkAt)) {
return { ok: false, at: lastFail.occurredAt, lastFail, lastOkAt };
}
return { ok: lastOkAt ? true : null, at: lastOkAt ? `${lastOkAt}:00.000Z` : null, lastFail, lastOkAt };
}
export function lastIntegrationFailures() {
return db.all(`SELECT * FROM IntegrationCall
WHERE id IN (SELECT MAX(id) FROM IntegrationCall GROUP BY target, providerKey)
ORDER BY id DESC`);
}