From c55f6f5ba17e3fb238288d056116c73ad67061d9 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 26 Sep 2026 19:15:24 +0200 Subject: [PATCH 1/2] fix(proxy): stamp snake_case project and request fields on proxy logs Proxy log lines only carried camelCase projectId, requestId and traceId, while the runtime and the API stamp project_id, request_id and trace_id. A single Loki filter could not scope proxy lines to a project, so the project Logs API needed a proxy-specific matcher. Emit the snake_case fields next to the existing camelCase ones, which stay for current dashboards, and move JSON line building into formatProxyJsonLine so it can be tested without process state. --- src/proxy/logger.test.ts | 39 ++++++++++++++++++ src/proxy/logger.ts | 88 ++++++++++++++++++++++++++++------------ 2 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 src/proxy/logger.test.ts diff --git a/src/proxy/logger.test.ts b/src/proxy/logger.test.ts new file mode 100644 index 0000000000..60f613a5e7 --- /dev/null +++ b/src/proxy/logger.test.ts @@ -0,0 +1,39 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert"; +import { describe, it } from "#veryfront/testing/bdd"; +import { formatProxyJsonLine, runWithProxyRequestContext } from "./logger.ts"; + +describe("proxy JSON log line", () => { + it("stamps the resolved project and request with the snake_case fields the runtime uses", () => { + const entry = JSON.parse(runWithProxyRequestContext( + { + requestId: "req-1", + projectSlug: "northwind-inbox", + projectId: "project-1", + releaseId: "release-1", + branchId: "branch-1", + branchName: "main", + domain: "northwind-inbox.production.veryfront.com", + environment: "production", + }, + () => formatProxyJsonLine("info", "200 POST /api/control-plane/runs/run-1/stream"), + )); + + assertEquals(entry.project_id, "project-1"); + assertEquals(entry.project_slug, "northwind-inbox"); + assertEquals(entry.request_id, "req-1"); + assertEquals(entry.release_id, "release-1"); + assertEquals(entry.branch_id, "branch-1"); + assertEquals(entry.branch_name, "main"); + assertEquals(entry.projectId, "project-1"); + assertEquals(entry.requestId, "req-1"); + }); + + it("leaves project fields out of lines logged outside a project request", () => { + const entry = JSON.parse(formatProxyJsonLine("info", "Proxy listening")); + + assertEquals("project_id" in entry, false); + assertEquals("projectId" in entry, false); + assertEquals("request_id" in entry, false); + }); +}); diff --git a/src/proxy/logger.ts b/src/proxy/logger.ts index 4d198929bd..32b5484b3a 100644 --- a/src/proxy/logger.ts +++ b/src/proxy/logger.ts @@ -110,15 +110,33 @@ interface LogEntry { service: string; veryfrontVersion: string; message: string; + /** @deprecated Use `trace_id` instead. Kept for Grafana dashboard transition. */ traceId?: string; + /** @deprecated Use `span_id` instead. Kept for Grafana dashboard transition. */ spanId?: string; // Request context fields (at top level for Grafana filtering) + /** @deprecated Use `request_id` instead. Kept for Grafana dashboard transition. */ requestId?: string; + /** @deprecated Use `project_slug` instead. Kept for Grafana dashboard transition. */ projectSlug?: string; + /** @deprecated Use `project_id` instead. Kept for Grafana dashboard transition. */ projectId?: string; + /** @deprecated Use `release_id` instead. Kept for Grafana dashboard transition. */ releaseId?: string; + /** @deprecated Use `branch_id` instead. Kept for Grafana dashboard transition. */ branchId?: string; + /** @deprecated Use `branch_name` instead. Kept for Grafana dashboard transition. */ branchName?: string; + // Standard snake_case fields shared with the runtime logger and the API, so + // one Loki filter scopes lines from every service to a project. + trace_id?: string; + span_id?: string; + request_id?: string; + project_slug?: string; + project_id?: string; + release_id?: string; + branch_id?: string; + branch_name?: string; domain?: string; environment?: string; context?: Record; @@ -149,6 +167,48 @@ function getLogFormat(): "json" | "text" { return isProduction() ? "json" : "text"; } +/** Serialize one proxy log line in the JSON shape Loki ingests. */ +export function formatProxyJsonLine( + level: LogLevel, + message: string, + context?: Record, + error?: unknown, +): string { + const traceCtx = getTraceContext(); + const reqCtx = getProxyRequestContext(); + + const entry: LogEntry = { + timestamp: new Date().toISOString(), + level, + service: "proxy", + veryfrontVersion: PROXY_RUNTIME_VERSION, + message, + ...(traceCtx.traceId && { + traceId: traceCtx.traceId, + spanId: traceCtx.spanId, + trace_id: traceCtx.traceId, + span_id: traceCtx.spanId, + }), + // Include request context fields at top level (like renderer logs) + ...(reqCtx?.requestId && { requestId: reqCtx.requestId, request_id: reqCtx.requestId }), + ...(reqCtx?.projectSlug && + { projectSlug: reqCtx.projectSlug, project_slug: reqCtx.projectSlug }), + ...(reqCtx?.projectId && { projectId: reqCtx.projectId, project_id: reqCtx.projectId }), + ...(reqCtx?.releaseId && { releaseId: reqCtx.releaseId, release_id: reqCtx.releaseId }), + ...(reqCtx?.branchId && { branchId: reqCtx.branchId, branch_id: reqCtx.branchId }), + ...(reqCtx?.branchName && { branchName: reqCtx.branchName, branch_name: reqCtx.branchName }), + ...(reqCtx?.domain && { domain: reqCtx.domain }), + ...(reqCtx?.environment && { environment: reqCtx.environment }), + }; + + if (context && Object.keys(context).length > 0) entry.context = context; + + const serializedError = serializeError(error); + if (serializedError) entry.error = serializedError; + + return JSON.stringify(entry); +} + class ProxyLogger { private log( level: LogLevel, @@ -165,33 +225,7 @@ class ProxyLogger { return; } - const traceCtx = getTraceContext(); - const reqCtx = getProxyRequestContext(); - - const entry: LogEntry = { - timestamp: new Date().toISOString(), - level, - service: "proxy", - veryfrontVersion: PROXY_RUNTIME_VERSION, - message, - ...(traceCtx.traceId && { traceId: traceCtx.traceId, spanId: traceCtx.spanId }), - // Include request context fields at top level (like renderer logs) - ...(reqCtx?.requestId && { requestId: reqCtx.requestId }), - ...(reqCtx?.projectSlug && { projectSlug: reqCtx.projectSlug }), - ...(reqCtx?.projectId && { projectId: reqCtx.projectId }), - ...(reqCtx?.releaseId && { releaseId: reqCtx.releaseId }), - ...(reqCtx?.branchId && { branchId: reqCtx.branchId }), - ...(reqCtx?.branchName && { branchName: reqCtx.branchName }), - ...(reqCtx?.domain && { domain: reqCtx.domain }), - ...(reqCtx?.environment && { environment: reqCtx.environment }), - }; - - if (context && Object.keys(context).length > 0) entry.context = context; - - const serializedError = serializeError(error); - if (serializedError) entry.error = serializedError; - - console.log(JSON.stringify(entry)); + console.log(formatProxyJsonLine(level, message, context, error)); } debug(message: string, context?: Record): void { From 54b63895e8af7151bc6cfec01796bee57b701294 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sat, 26 Sep 2026 20:16:19 +0200 Subject: [PATCH 2/2] fix(proxy): stamp run_id on lines of signed control-plane run requests The proxy logs every run-stream request, but its lines carried no run identifier, so the Logs API could not correlate them with the runtime and API lines of the same run. The run id is read from the matched run route only when the control-plane signature, which covers the method and path, verified the request. --- src/channels/control-plane-routes.test.ts | 29 +++++++++++++++++++++++ src/channels/control-plane-routes.ts | 14 +++++++++++ src/proxy/handler.test.ts | 2 ++ src/proxy/handler.ts | 7 ++++++ src/proxy/logger.test.ts | 20 ++++++++++++++++ src/proxy/logger.ts | 3 +++ src/proxy/main.ts | 1 + 7 files changed, 76 insertions(+) create mode 100644 src/channels/control-plane-routes.test.ts diff --git a/src/channels/control-plane-routes.test.ts b/src/channels/control-plane-routes.test.ts new file mode 100644 index 0000000000..b666295f3e --- /dev/null +++ b/src/channels/control-plane-routes.test.ts @@ -0,0 +1,29 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert"; +import { describe, it } from "#veryfront/testing/bdd"; +import { controlPlaneRunIdFromPath } from "./control-plane-routes.ts"; + +describe("controlPlaneRunIdFromPath", () => { + it("reads the run id from every signed run operation route", () => { + for (const operation of ["execute", "stream", "resume"]) { + assertEquals( + controlPlaneRunIdFromPath("POST", `/api/control-plane/runs/run_1/${operation}`), + "run_1", + ); + } + assertEquals(controlPlaneRunIdFromPath("delete", "/api/control-plane/runs/run_1"), "run_1"); + }); + + it("returns undefined for a method and path pair no run handler serves", () => { + assertEquals( + controlPlaneRunIdFromPath("GET", "/api/control-plane/runs/run_1/stream"), + undefined, + ); + assertEquals(controlPlaneRunIdFromPath("POST", "/api/control-plane/runs/run_1"), undefined); + assertEquals( + controlPlaneRunIdFromPath("POST", "/api/control-plane/runs/run_1/logs"), + undefined, + ); + assertEquals(controlPlaneRunIdFromPath("POST", "/api/control-plane/agents/list"), undefined); + }); +}); diff --git a/src/channels/control-plane-routes.ts b/src/channels/control-plane-routes.ts index 4a6a229158..bee4b9f2e5 100644 --- a/src/channels/control-plane-routes.ts +++ b/src/channels/control-plane-routes.ts @@ -15,3 +15,17 @@ export const CONTROL_PLANE_RUN_OPERATION_PATH = /** Matches the bare run route, which only DELETE addresses. */ export const CONTROL_PLANE_RUN_PATH = /^\/api\/control-plane\/runs\/[^/]+$/u; + +/** + * The run id a control-plane run route addresses, or `undefined` when the + * method and path pair is not one a run handler serves. + */ +export function controlPlaneRunIdFromPath(method: string, pathname: string): string | undefined { + const normalizedMethod = method.toUpperCase(); + const route = normalizedMethod === "POST" + ? CONTROL_PLANE_RUN_OPERATION_PATH + : normalizedMethod === "DELETE" + ? CONTROL_PLANE_RUN_PATH + : undefined; + return route?.test(pathname) ? pathname.split("/")[4] : undefined; +} diff --git a/src/proxy/handler.test.ts b/src/proxy/handler.test.ts index ff900e4909..a36095622c 100644 --- a/src/proxy/handler.test.ts +++ b/src/proxy/handler.test.ts @@ -2343,6 +2343,7 @@ describe("Proxy Handler", () => { assertEquals(ctx.projectId, "proj-123"); assertEquals(ctx.releaseId, "rel-123"); assertEquals(ctx.defaultBranchName, "trunk"); + assertEquals(ctx.runId, "run_1"); const forwarded = injectContextHeaders(req, ctx); const runtimeHeaders = extractRequestHeaders( forwarded, @@ -2374,6 +2375,7 @@ describe("Proxy Handler", () => { ); assertEquals(rejected.error?.status, 401); assertEquals(rejected.token, undefined); + assertEquals(rejected.runId, undefined); await handler.close(); handler = undefined; diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index a5a74989bf..2c9646c263 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -28,6 +28,7 @@ import { isVerifiedInternalControlPlaneRequest, resolveVerifiedControlPlaneBranchBinding, } from "./control-plane-signature.ts"; +import { controlPlaneRunIdFromPath } from "#veryfront/channels/control-plane-routes.ts"; import { encodeIdentityHeaderValue } from "#veryfront/utils/header-identity.ts"; import { createProjectMetadataClient, @@ -126,6 +127,8 @@ export interface ProxyContext { branchId?: string; branchName?: string; defaultBranchName?: string; + /** Run addressed by a signed control-plane run route; the signature covers the path. */ + runId?: string; environmentId?: string; environmentName?: string; environment: "preview" | "production"; @@ -1305,6 +1308,9 @@ export function createProxyHandler(options: ProxyHandlerOptions) { parsedDomain.branch, releaseId, ); + const runId = signedInternalControlPlaneRequest + ? controlPlaneRunIdFromPath(req.method, url.pathname) + : undefined; return { token, @@ -1314,6 +1320,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) { branchId, branchName, defaultBranchName, + ...(runId && { runId }), environmentId, environmentName, contentSourceId, diff --git a/src/proxy/logger.test.ts b/src/proxy/logger.test.ts index 60f613a5e7..8e4996c4b4 100644 --- a/src/proxy/logger.test.ts +++ b/src/proxy/logger.test.ts @@ -36,4 +36,24 @@ describe("proxy JSON log line", () => { assertEquals("projectId" in entry, false); assertEquals("request_id" in entry, false); }); + + it("stamps the run a signed control-plane request addresses as run_id", () => { + const entry = JSON.parse(runWithProxyRequestContext( + { requestId: "req-1", projectId: "project-1", runId: "run_1" }, + () => formatProxyJsonLine("info", "200 POST /api/control-plane/runs/run_1/stream"), + )); + + assertEquals(entry.run_id, "run_1"); + assertEquals("runId" in entry, false); + }); + + it("keeps the caller context and serialized error on the line", () => { + const entry = JSON.parse( + formatProxyJsonLine("error", "Upstream failed", { ms: 12 }, new Error("connection reset")), + ); + + assertEquals(entry.context, { ms: 12 }); + assertEquals(entry.error.message, "connection reset"); + assertEquals("run_id" in entry, false); + }); }); diff --git a/src/proxy/logger.ts b/src/proxy/logger.ts index 32b5484b3a..1c5feb0ddb 100644 --- a/src/proxy/logger.ts +++ b/src/proxy/logger.ts @@ -27,6 +27,7 @@ interface ProxyRequestContext { releaseId?: string; branchId?: string; branchName?: string; + runId?: string; domain?: string; environment?: string; } @@ -137,6 +138,7 @@ interface LogEntry { release_id?: string; branch_id?: string; branch_name?: string; + run_id?: string; domain?: string; environment?: string; context?: Record; @@ -197,6 +199,7 @@ export function formatProxyJsonLine( ...(reqCtx?.releaseId && { releaseId: reqCtx.releaseId, release_id: reqCtx.releaseId }), ...(reqCtx?.branchId && { branchId: reqCtx.branchId, branch_id: reqCtx.branchId }), ...(reqCtx?.branchName && { branchName: reqCtx.branchName, branch_name: reqCtx.branchName }), + ...(reqCtx?.runId && { run_id: reqCtx.runId }), ...(reqCtx?.domain && { domain: reqCtx.domain }), ...(reqCtx?.environment && { environment: reqCtx.environment }), }; diff --git a/src/proxy/main.ts b/src/proxy/main.ts index 4cac9b1917..2918d70b40 100644 --- a/src/proxy/main.ts +++ b/src/proxy/main.ts @@ -461,6 +461,7 @@ function forwardToServer(req: Request, url: URL): Promise { releaseId: ctx.releaseId, branchId: ctx.branchId, branchName: ctx.branchName, + runId: ctx.runId, domain: ctx.host || host, environment: ctx.environment, },