Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/channels/control-plane-routes.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions src/channels/control-plane-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,17 @@

/** 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;

Check warning on line 29 in src/channels/control-plane-routes.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=veryfront_veryfront-code&issues=AaDe-p2UHGdXYMwayTrx&open=AaDe-p2UHGdXYMwayTrx&pullRequest=4601
return route?.test(pathname) ? pathname.split("/")[4] : undefined;
}
2 changes: 2 additions & 0 deletions src/proxy/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/proxy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -1305,6 +1308,9 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
parsedDomain.branch,
releaseId,
);
const runId = signedInternalControlPlaneRequest
? controlPlaneRunIdFromPath(req.method, url.pathname)
: undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {
token,
Expand All @@ -1314,6 +1320,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
branchId,
branchName,
defaultBranchName,
...(runId && { runId }),
environmentId,
environmentName,
contentSourceId,
Expand Down
59 changes: 59 additions & 0 deletions src/proxy/logger.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
91 changes: 64 additions & 27 deletions src/proxy/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface ProxyRequestContext {
releaseId?: string;
branchId?: string;
branchName?: string;
runId?: string;
domain?: string;
environment?: string;
}
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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<string, unknown>,
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,
Expand All @@ -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<string, unknown>): void {
Expand Down
1 change: 1 addition & 0 deletions src/proxy/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ function forwardToServer(req: Request, url: URL): Promise<Response> {
releaseId: ctx.releaseId,
branchId: ctx.branchId,
branchName: ctx.branchName,
runId: ctx.runId,
domain: ctx.host || host,
environment: ctx.environment,
},
Expand Down
Loading