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 new file mode 100644 index 0000000000..8e4996c4b4 --- /dev/null +++ b/src/proxy/logger.test.ts @@ -0,0 +1,59 @@ +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); + }); + + 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 4d198929bd..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; } @@ -110,15 +111,34 @@ 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; + run_id?: string; domain?: string; environment?: string; context?: Record; @@ -149,6 +169,49 @@ 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?.runId && { run_id: reqCtx.runId }), + ...(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 +228,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 { 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, },