Files
openwork/packaging/docker/otel-hono-live-validate.mjs

779 lines
28 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
function argValue(name, fallback) {
const index = process.argv.indexOf(`--${name}`);
if (index === -1) return fallback;
const value = process.argv[index + 1];
return value === undefined || value.startsWith("--") ? fallback : value;
}
function requiredArg(name) {
const value = argValue(name, "");
if (!value) throw new Error(`Missing --${name}`);
return value;
}
function trimTrailingSlash(value) {
return value.replace(/\/+$/, "");
}
function assertHex(value, size, label) {
if (!new RegExp(`^[0-9a-f]{${size}}$`).test(value)) {
throw new Error(`${label} must be ${size} lowercase hex characters`);
}
}
function nowIso() {
return new Date().toISOString();
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function safeJsonParse(text) {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function isObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeValue(value) {
if (!isObject(value)) return value;
if ("stringValue" in value) return value.stringValue;
if ("intValue" in value) return Number(value.intValue);
if ("doubleValue" in value) return Number(value.doubleValue);
if ("boolValue" in value) return Boolean(value.boolValue);
if ("bytesValue" in value) return value.bytesValue;
if (isObject(value.arrayValue)) {
const values = Array.isArray(value.arrayValue.values) ? value.arrayValue.values : [];
return values.map(normalizeValue);
}
if (isObject(value.kvlistValue)) {
return attributesToMap(value.kvlistValue.values);
}
if ("value" in value && Object.keys(value).length <= 3) return normalizeValue(value.value);
return value;
}
function attributesToMap(attributes) {
const out = {};
if (Array.isArray(attributes)) {
for (const item of attributes) {
if (!isObject(item) || typeof item.key !== "string") continue;
out[item.key] = normalizeValue("value" in item ? item.value : item);
}
return out;
}
if (isObject(attributes)) {
for (const [key, value] of Object.entries(attributes)) out[key] = normalizeValue(value);
}
return out;
}
function serviceName(attrs) {
const value = attrs["service.name"] ?? attrs.service_name ?? attrs.serviceName;
return typeof value === "string" ? value : undefined;
}
function spanId(span) {
const value = span.spanId ?? span.spanID ?? span.span_id;
if (typeof value !== "string") return undefined;
if (/^[0-9a-f]{16}$/iu.test(value)) return value.toLowerCase();
return Buffer.from(value, "base64").toString("hex");
}
function collectTraceSpans(payload) {
const spans = [];
const seen = new Set();
function pushSpan(span, resource) {
if (!isObject(span) || typeof span.name !== "string") return;
const id = spanId(span) ?? `${span.name}:${spans.length}`;
const dedupeKey = `${id}:${span.name}`;
if (seen.has(dedupeKey)) return;
seen.add(dedupeKey);
spans.push({
attributes: attributesToMap(span.attributes ?? span.tags),
name: span.name,
resource,
spanId: id,
});
}
function walk(node, inheritedResource = {}) {
if (Array.isArray(node)) {
for (const item of node) walk(item, inheritedResource);
return;
}
if (!isObject(node)) return;
if (isObject(node.processes) && Array.isArray(node.spans)) {
for (const span of node.spans) {
const process = isObject(span) && typeof span.processID === "string" ? node.processes[span.processID] : undefined;
const processResource = isObject(process)
? { ...attributesToMap(process.tags), "service.name": process.serviceName }
: inheritedResource;
pushSpan(span, processResource);
}
return;
}
let resource = inheritedResource;
if (isObject(node.resource)) {
resource = { ...resource, ...attributesToMap(node.resource.attributes) };
}
for (const key of ["resourceSpans", "batches", "data", "trace"]) {
if (Array.isArray(node[key]) || isObject(node[key])) walk(node[key], resource);
}
for (const key of ["scopeSpans", "instrumentationLibrarySpans"]) {
if (Array.isArray(node[key])) walk(node[key], resource);
}
if (Array.isArray(node.spans)) {
for (const span of node.spans) pushSpan(span, resource);
}
}
walk(payload);
return spans;
}
function analyzeTrace(payload, traceId) {
const spans = collectTraceSpans(payload);
const services = [...new Set(spans.map((span) => serviceName(span.resource)).filter(Boolean))].sort();
const webSpans = spans.filter((span) => serviceName(span.resource) === "den-web");
const apiSpans = spans.filter((span) => serviceName(span.resource) === "den-api");
const honoSpan = apiSpans.find((span) => {
const route = span.attributes["http.route"];
return route === "/openapi.json" && typeof span.attributes["request.id"] === "string";
});
return {
apiSpanIds: apiSpans.map((span) => span.spanId).filter(Boolean),
hasApi: services.includes("den-api"),
hasWeb: services.includes("den-web"),
honoSpan: honoSpan
? {
name: honoSpan.name,
requestId: honoSpan.attributes["request.id"],
route: honoSpan.attributes["http.route"],
spanId: honoSpan.spanId,
}
: undefined,
services,
spanCount: spans.length,
traceId,
webSpanIds: webSpans.map((span) => span.spanId).filter(Boolean),
};
}
function collectStringField(value, names) {
if (Array.isArray(value)) {
for (const item of value) {
const found = collectStringField(item, names);
if (found) return found;
}
return undefined;
}
if (!isObject(value)) return undefined;
for (const name of names) {
const item = value[name];
if (typeof item === "string") return item;
}
for (const item of Object.values(value)) {
const found = collectStringField(item, names);
if (found) return found;
}
return undefined;
}
function collectLokiEntries(payload) {
const results = Array.isArray(payload?.data?.result) ? payload.data.result : [];
const entries = [];
for (const stream of results) {
const labels = isObject(stream.stream) ? stream.stream : {};
const values = Array.isArray(stream.values) ? stream.values : [];
for (const value of values) {
if (!Array.isArray(value)) continue;
const line = typeof value[1] === "string" ? value[1] : "";
const parsedLine = safeJsonParse(line);
const metadata = isObject(value[2]) ? value[2] : {};
const fields = {
...labels,
...metadata,
...(isObject(parsedLine) ? parsedLine : {}),
};
const text = `${line}\n${JSON.stringify(metadata)}`;
entries.push({
body: typeof fields.body === "string" ? fields.body : typeof fields.message === "string" ? fields.message : line,
fields,
line,
serviceName: fields.service_name ?? fields["service.name"] ?? fields.service,
spanId: (collectStringField(fields, ["span_id", "spanid", "spanId", "spanID"]) ?? text.match(/[0-9a-f]{16}/)?.[0])?.toLowerCase(),
text,
traceId: (collectStringField(fields, ["trace_id", "traceid", "traceId", "traceID"]) ?? text.match(/[0-9a-f]{32}/)?.[0])?.toLowerCase(),
});
}
}
return entries;
}
function summarizeLogEntry(entry) {
return {
body: entry.body,
serviceName: entry.serviceName,
spanId: entry.spanId,
traceId: entry.traceId,
};
}
function analyzeServiceLog(payload, traceId, spanIds, serviceName, bodyNeedle) {
const spanIdSet = new Set(spanIds);
const entries = collectLokiEntries(payload);
const matching = entries.find((entry) => {
const bodyMatches = bodyNeedle === undefined || entry.body.includes(bodyNeedle) || entry.line.includes(bodyNeedle);
return entry.serviceName === serviceName
&& entry.traceId === traceId
&& typeof entry.spanId === "string"
&& spanIdSet.has(entry.spanId)
&& bodyMatches;
});
return {
entries: entries.length,
match: matching ? summarizeLogEntry(matching) : undefined,
serviceName,
};
}
function analyzeMetricSeries(payload, options) {
const results = Array.isArray(payload?.data?.result) ? payload.data.result : [];
for (const item of results) {
const metric = isObject(item.metric) ? item.metric : {};
const rawValue = Array.isArray(item.value) ? item.value[1] : undefined;
const value = Number(rawValue);
if (metric.service_name !== options.serviceName) continue;
if (options.route !== undefined && metric.http_route !== options.route) continue;
if (!Number.isFinite(value)) continue;
if (options.requirePositive && value <= 0) continue;
return {
labels: metric,
name: metric.__name__,
value,
};
}
return undefined;
}
function runtimeErrorLines(logs) {
const patterns = [
/\bunhandled(?:rejection| exception)?\b/i,
/\buncaught(?:exception)?\b/i,
/UnhandledPromiseRejection/i,
/\b(?:TypeError|ReferenceError|SyntaxError|RangeError):/,
/\bERR_[A-Z0-9_]+:/,
/"level"\s*:\s*"error"/i,
/\blevel=error\b/i,
/\brequest failed\b/i,
];
return logs
.split("\n")
.filter((line) => patterns.some((pattern) => pattern.test(line)))
.slice(0, 20);
}
function stripComposePrefix(line) {
const marker = " | ";
const index = line.indexOf(marker);
return (index === -1 ? line : line.slice(index + marker.length)).trim();
}
function parseDockerJsonEntries(logs) {
const entries = [];
for (const rawLine of logs.split("\n")) {
const line = stripComposePrefix(rawLine);
const payload = safeJsonParse(line);
if (!isObject(payload)) continue;
entries.push({ line, payload, rawLine });
}
return entries;
}
function summarizeStdoutEntry(entry) {
if (entry === undefined) return undefined;
return {
durationMs: entry.payload.duration_ms,
level: entry.payload.level,
message: entry.payload.message,
route: entry.payload.http_route ?? entry.payload.upstream_path,
service: entry.payload.service,
status: entry.payload.http_status_code ?? entry.payload.status,
};
}
function exporterAttemptLines(logs) {
const patterns = [
/ECONNREFUSED.*(?:4318|otel|otlp|collector|lgtm)/i,
/(?:OTLP|otel|collector|exporter).*(?:ECONNREFUSED|failed|failure|error|connect)/i,
/(?:failed|failure|error|connect).*(?:OTLP|otel|collector|exporter|4318|lgtm)/i,
];
return logs
.split("\n")
.filter((line) => patterns.some((pattern) => pattern.test(line)))
.slice(0, 20);
}
function analyzeNoneStdout(logs, secret) {
const entries = parseDockerJsonEntries(logs);
const web = entries.find((entry) => {
return entry.payload.service === "den-web"
&& entry.payload.message === "den-web upstream proxy completed"
&& entry.payload.upstream_path === "/openapi.json"
&& Number(entry.payload.status) === 200;
});
const api = entries.find((entry) => {
return entry.payload.service === "den-api"
&& entry.payload.message === "request completed"
&& entry.payload.http_route === "/openapi.json"
&& Number(entry.payload.http_status_code) === 200;
});
return {
api: summarizeStdoutEntry(api),
dockerLogSecretAbsent: !logs.includes(secret),
exporterAttemptLines: exporterAttemptLines(logs),
jsonEntryCount: entries.length,
web: summarizeStdoutEntry(web),
};
}
async function fetchText(url, options = {}) {
const response = await fetch(url, options);
const text = await response.text();
return { response, text };
}
async function fetchJson(url, options = {}) {
const { response, text } = await fetchText(url, options);
const json = safeJsonParse(text);
if (!response.ok) {
const message = text.slice(0, 400) || response.statusText;
throw new Error(`${response.status} ${response.statusText}: ${message}`);
}
if (json === undefined) throw new Error(`Expected JSON from ${url}`);
return json;
}
function basicAuthHeader(user, password) {
return `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`;
}
function proxyUrl(grafanaUrl, uid, path) {
return `${grafanaUrl}/api/datasources/proxy/uid/${encodeURIComponent(uid)}${path}`;
}
function encodeQuery(params) {
return new URLSearchParams(params).toString();
}
function nsFromMs(ms) {
return `${BigInt(ms) * 1000000n}`;
}
function selectedDatasources(datasources) {
const byType = (type) => datasources.find((datasource) => datasource.type === type || datasource.typeName === type);
const tempo = byType("tempo");
const loki = byType("loki");
const prometheus = byType("prometheus");
if (!tempo || !loki || !prometheus) {
throw new Error(`Grafana datasources missing tempo/loki/prometheus: ${JSON.stringify(datasources)}`);
}
return { tempo, loki, prometheus };
}
async function collectSignals({ auth, datasources, endMs, grafanaUrl, secret, startMs, traceId }) {
const endNs = nsFromMs(endMs);
const startNs = nsFromMs(startMs);
const signal = {
errors: {},
metrics: {},
secretLeakEntries: undefined,
trace: undefined,
tracePayload: undefined,
logs: {},
logPayloads: {},
};
try {
const tracePayload = await fetchJson(proxyUrl(grafanaUrl, datasources.tempo.uid, `/api/traces/${traceId}`), { headers: auth });
signal.tracePayload = tracePayload;
signal.trace = analyzeTrace(tracePayload, traceId);
} catch (error) {
signal.errors.trace = error instanceof Error ? error.message : String(error);
}
try {
const query = `{service_name="den-web"} |= "den-web upstream proxy completed"`;
const params = encodeQuery({ direction: "backward", end: endNs, limit: "100", query, start: startNs });
const logPayload = await fetchJson(proxyUrl(grafanaUrl, datasources.loki.uid, `/loki/api/v1/query_range?${params}`), { headers: auth });
signal.logPayloads.web = logPayload;
signal.logs.web = signal.trace
? analyzeServiceLog(logPayload, traceId, signal.trace.webSpanIds, "den-web", "den-web upstream proxy completed")
: { entries: collectLokiEntries(logPayload).length, serviceName: "den-web" };
} catch (error) {
signal.errors.webLog = error instanceof Error ? error.message : String(error);
}
try {
const query = `{service_name="den-api"} |= "request completed"`;
const params = encodeQuery({ direction: "backward", end: endNs, limit: "100", query, start: startNs });
const logPayload = await fetchJson(proxyUrl(grafanaUrl, datasources.loki.uid, `/loki/api/v1/query_range?${params}`), { headers: auth });
signal.logPayloads.api = logPayload;
signal.logs.api = signal.trace
? analyzeServiceLog(logPayload, traceId, signal.trace.apiSpanIds, "den-api", "request completed")
: { entries: collectLokiEntries(logPayload).length, serviceName: "den-api" };
} catch (error) {
signal.errors.apiLog = error instanceof Error ? error.message : String(error);
}
try {
const query = `{service_name=~"den-web|den-api"} |= ${JSON.stringify(secret)}`;
const params = encodeQuery({ direction: "backward", end: endNs, limit: "20", query, start: startNs });
const secretPayload = await fetchJson(proxyUrl(grafanaUrl, datasources.loki.uid, `/loki/api/v1/query_range?${params}`), { headers: auth });
signal.secretLeakEntries = collectLokiEntries(secretPayload).length;
} catch (error) {
signal.errors.secretLogQuery = error instanceof Error ? error.message : String(error);
}
try {
const query = `{__name__=~"http_server_request_duration.*",service_name="den-api",http_route="/openapi.json"}`;
const params = encodeQuery({ query, time: `${Math.floor(Date.now() / 1000)}` });
const metricPayload = await fetchJson(proxyUrl(grafanaUrl, datasources.prometheus.uid, `/api/v1/query?${params}`), { headers: auth });
signal.metrics.duration = analyzeMetricSeries(metricPayload, {
requirePositive: true,
route: "/openapi.json",
serviceName: "den-api",
});
} catch (error) {
signal.errors.durationMetric = error instanceof Error ? error.message : String(error);
}
try {
const query = `{__name__=~"http_server_active_requests.*",service_name="den-api"}`;
const params = encodeQuery({ query, time: `${Math.floor(Date.now() / 1000)}` });
const metricPayload = await fetchJson(proxyUrl(grafanaUrl, datasources.prometheus.uid, `/api/v1/query?${params}`), { headers: auth });
signal.metrics.active = analyzeMetricSeries(metricPayload, {
requirePositive: false,
serviceName: "den-api",
});
} catch (error) {
signal.errors.activeMetric = error instanceof Error ? error.message : String(error);
}
signal.traceSecretAbsent = signal.tracePayload === undefined ? false : !JSON.stringify(signal.tracePayload).includes(secret);
signal.logSecretAbsent = Object.keys(signal.logPayloads).length > 0 && !JSON.stringify(signal.logPayloads).includes(secret);
signal.secretLogQueryClean = signal.secretLeakEntries === 0;
signal.ready = Boolean(
signal.trace?.hasApi
&& signal.trace?.hasWeb
&& signal.trace?.honoSpan
&& signal.logs.web?.match
&& signal.logs.api?.match
&& signal.metrics.duration
&& signal.metrics.active
&& signal.traceSecretAbsent
&& signal.logSecretAbsent
&& signal.secretLogQueryClean,
);
return signal;
}
function composeBaseArgs({ denComposeFile, otelComposeFile, project }) {
return [
"compose",
"-p",
project,
"-f",
denComposeFile,
"-f",
otelComposeFile,
];
}
function dockerCompose({ args, denComposeFile, env = {}, otelComposeFile, project }) {
return execFileSync("docker", [
...composeBaseArgs({ denComposeFile, otelComposeFile, project }),
...args,
], {
encoding: "utf8",
env: { ...process.env, ...env },
maxBuffer: 16 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
}
function dockerLogs({ denComposeFile, otelComposeFile, project, since }) {
const args = [
...composeBaseArgs({ denComposeFile, otelComposeFile, project }),
"logs",
"--no-color",
];
if (since) args.push("--since", since);
args.push("den", "web");
return execFileSync("docker", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
}
async function requestOpenApi({ requestPath, secret, traceId = randomBytes(16).toString("hex"), webUrl }) {
const parentSpanId = randomBytes(8).toString("hex");
const traceparent = `00-${traceId}-${parentSpanId}-01`;
const requestResponse = await fetch(`${webUrl}${requestPath}`, {
headers: {
traceparent,
"x-openwork-otel-hono-e2e": traceId,
},
});
const requestBody = await requestResponse.text();
const openApi = safeJsonParse(requestBody);
return {
body: requestBody,
report: {
contentType: requestResponse.headers.get("content-type"),
path: requestPath.replaceAll(secret, "[redacted]"),
status: requestResponse.status,
title: openApi?.info?.title,
traceparent,
},
response: requestResponse,
traceId,
};
}
async function runNonePhase({ denComposeFile, otelComposeFile, project, requestPath, secret, waitSeconds, webUrl }) {
const since = new Date(Date.now() - 1000).toISOString();
const recreateOutput = dockerCompose({
args: ["up", "-d", "--wait", "--wait-timeout", String(waitSeconds), "--no-build", "den", "web"],
denComposeFile,
env: {
DEN_OBSERVABILITY_BACKEND: "none",
NEXT_PUBLIC_DEN_OBSERVABILITY_BACKEND: "none",
},
otelComposeFile,
project,
});
const request = await requestOpenApi({ requestPath, secret, webUrl });
if (request.response.status !== 200) {
throw new Error(`Expected HTTP 200 from none backend ${request.report.path}; got ${request.response.status}: ${request.body.slice(0, 400)}`);
}
let logs = "";
let analysis = analyzeNoneStdout(logs, secret);
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
logs = dockerLogs({ denComposeFile, otelComposeFile, project, since });
analysis = analyzeNoneStdout(logs, secret);
if (analysis.api && analysis.web) break;
await sleep(2_000);
}
return {
logs: analysis,
recreateOutput: recreateOutput.trim(),
request: request.report,
since,
};
}
function addAssertion(report, name, ok, evidence) {
report.assertions.push({ evidence, name, ok });
}
function printSummary(report) {
const status = report.ok ? "PASS" : "FAIL";
console.log(`${status} otel-hono-live`);
console.log(`report: ${report.reportJson}`);
console.log(`request: ${report.request?.status ?? "n/a"} ${report.request?.path ?? ""}`.trim());
if (report.evidence?.trace) {
const trace = report.evidence.trace;
console.log(`trace: ${trace.traceId} services=${trace.services.join(",")} hono=${trace.honoSpan?.name ?? "missing"} request.id=${trace.honoSpan?.requestId ?? "missing"}`);
}
if (report.evidence?.logs?.web?.match) {
const log = report.evidence.logs.web.match;
console.log(`log: den-web trace_id=${log.traceId} span_id=${log.spanId}`);
}
if (report.evidence?.logs?.api?.match) {
const log = report.evidence.logs.api.match;
console.log(`log: den-api trace_id=${log.traceId} span_id=${log.spanId}`);
}
if (report.evidence?.metrics?.duration) {
console.log(`metric: ${report.evidence.metrics.duration.name} route=/openapi.json value=${report.evidence.metrics.duration.value}`);
}
if (report.evidence?.metrics?.active) {
console.log(`metric: ${report.evidence.metrics.active.name} active_value=${report.evidence.metrics.active.value}`);
}
if (report.evidence?.none) {
const none = report.evidence.none;
console.log(`none: ${none.request.status} stdout_web=${Boolean(none.logs.web)} stdout_api=${Boolean(none.logs.api)} exporter_attempts=${none.logs.exporterAttemptLines.length}`);
}
if (!report.ok && report.error) console.error(report.error);
}
async function main() {
const reportJson = requiredArg("report-json");
const grafanaUrl = trimTrailingSlash(requiredArg("grafana-url"));
const webUrl = trimTrailingSlash(requiredArg("web-url"));
const project = requiredArg("project");
const denComposeFile = requiredArg("den-compose-file");
const otelComposeFile = requiredArg("otel-compose-file");
const secret = argValue("secret", process.env.OTEL_HONO_SECRET ?? "super-secret");
const requestPath = argValue("request-path", `/api/den/openapi.json?token=${encodeURIComponent(secret)}`);
const timeoutMs = Number(process.env.OTEL_HONO_POLL_SECONDS ?? "240") * 1000;
const intervalMs = Number(process.env.OTEL_HONO_POLL_INTERVAL_SECONDS ?? "5") * 1000;
const grafanaUser = process.env.OTEL_HONO_GRAFANA_USER ?? "admin";
const grafanaPassword = process.env.OTEL_HONO_GRAFANA_PASSWORD ?? "admin";
const traceId = process.env.OTEL_HONO_TRACE_ID ?? randomBytes(16).toString("hex");
const waitSeconds = process.env.OTEL_HONO_COMPOSE_WAIT_SECONDS ?? "420";
assertHex(traceId, 32, "OTEL_HONO_TRACE_ID");
const report = {
assertions: [],
datasources: undefined,
endedAt: undefined,
error: undefined,
evidence: {},
ok: false,
ports: {
denApi: process.env.DEN_API_PORT,
denWeb: process.env.DEN_WEB_PORT,
grafana: process.env.OTEL_LGTM_GRAFANA_PORT,
otlpHttp: process.env.OTEL_LGTM_OTLP_HTTP_PORT,
},
project,
reportJson,
request: undefined,
startedAt: nowIso(),
};
try {
const auth = { Authorization: basicAuthHeader(grafanaUser, grafanaPassword) };
await fetchJson(`${grafanaUrl}/api/health`, { headers: auth });
const datasources = selectedDatasources(await fetchJson(`${grafanaUrl}/api/datasources`, { headers: auth }));
report.datasources = {
loki: { name: datasources.loki.name, type: datasources.loki.type, uid: datasources.loki.uid },
prometheus: { name: datasources.prometheus.name, type: datasources.prometheus.type, uid: datasources.prometheus.uid },
tempo: { name: datasources.tempo.name, type: datasources.tempo.type, uid: datasources.tempo.uid },
};
const startMs = Date.now() - 30_000;
const otelRequest = await requestOpenApi({ requestPath, secret, traceId, webUrl });
report.request = otelRequest.report;
addAssertion(report, "http_200", otelRequest.response.status === 200, report.request);
if (otelRequest.response.status !== 200) {
throw new Error(`Expected HTTP 200 from ${otelRequest.report.path}; got ${otelRequest.response.status}: ${otelRequest.body.slice(0, 400)}`);
}
let lastSignal;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
lastSignal = await collectSignals({
auth,
datasources,
endMs: Date.now() + 5_000,
grafanaUrl,
secret,
startMs,
traceId,
});
if (lastSignal.ready) break;
await sleep(intervalMs);
}
report.evidence.poll = {
attemptsEndedAt: nowIso(),
lastErrors: lastSignal?.errors,
timeoutSeconds: timeoutMs / 1000,
};
if (!lastSignal?.ready) {
report.evidence.lastSignal = {
logs: lastSignal?.logs,
metrics: lastSignal?.metrics,
secretLeakEntries: lastSignal?.secretLeakEntries,
trace: lastSignal?.trace,
};
throw new Error("Timed out polling Grafana datasource proxy APIs for trace, log, and metric evidence");
}
report.evidence.trace = lastSignal.trace;
report.evidence.logs = lastSignal.logs;
report.evidence.log = lastSignal.logs.web;
report.evidence.metrics = lastSignal.metrics;
report.evidence.metric = lastSignal.metrics.duration;
report.evidence.secret = {
lokiSecretQueryEntries: lastSignal.secretLeakEntries,
logPayloadAbsent: lastSignal.logSecretAbsent,
tracePayloadAbsent: lastSignal.traceSecretAbsent,
};
addAssertion(report, "trace_contains_den_web_and_den_api", lastSignal.trace.hasApi && lastSignal.trace.hasWeb, lastSignal.trace.services);
addAssertion(report, "hono_openapi_span_has_request_id", Boolean(lastSignal.trace.honoSpan), lastSignal.trace.honoSpan);
addAssertion(report, "secret_absent_from_trace_and_loki_log_output", lastSignal.traceSecretAbsent && lastSignal.logSecretAbsent && lastSignal.secretLeakEntries === 0, report.evidence.secret);
addAssertion(report, "den_web_log_trace_span_matches_trace", Boolean(lastSignal.logs.web.match), lastSignal.logs.web.match);
addAssertion(report, "den_api_log_trace_span_matches_trace", Boolean(lastSignal.logs.api.match), lastSignal.logs.api.match);
addAssertion(report, "den_api_hono_duration_metric_exists", Boolean(lastSignal.metrics.duration), lastSignal.metrics.duration);
addAssertion(report, "den_api_hono_active_requests_metric_exists", Boolean(lastSignal.metrics.active), lastSignal.metrics.active);
const none = await runNonePhase({
denComposeFile,
otelComposeFile,
project,
requestPath,
secret,
waitSeconds,
webUrl,
});
report.evidence.none = none;
report.none = none;
addAssertion(report, "none_backend_http_200", none.request.status === 200, none.request);
addAssertion(report, "none_backend_json_stdout_request_logs", Boolean(none.logs.web && none.logs.api), none.logs);
addAssertion(report, "none_backend_no_exporter_connection_or_error_attempt", none.logs.exporterAttemptLines.length === 0, { exporterAttemptLines: none.logs.exporterAttemptLines });
addAssertion(report, "none_backend_secret_absent_from_docker_logs", none.logs.dockerLogSecretAbsent, { dockerLogSecretAbsent: none.logs.dockerLogSecretAbsent });
const logs = dockerLogs({ denComposeFile, otelComposeFile, project });
const errors = runtimeErrorLines(logs);
const dockerLogSecretAbsent = !logs.includes(secret);
report.evidence.runtime = {
dockerLogSecretAbsent,
errorLines: errors,
inspectedServices: ["den", "web"],
};
addAssertion(report, "secret_absent_from_docker_runtime_logs", dockerLogSecretAbsent, { dockerLogSecretAbsent });
addAssertion(report, "no_unhandled_runtime_errors", errors.length === 0, { errorLines: errors });
report.ok = report.assertions.every((assertion) => assertion.ok);
if (!report.ok) throw new Error("One or more live validator assertions failed");
} catch (error) {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
report.error = message.replaceAll(secret, "[redacted]");
} finally {
report.endedAt = nowIso();
await mkdir(dirname(reportJson), { recursive: true });
await writeFile(reportJson, `${JSON.stringify(report, null, 2)}\n`);
printSummary(report);
}
if (!report.ok) process.exit(1);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
process.exit(1);
});