The hard trade-off is cardinality versus reconstruction: log too little and a slow property-management agent becomes an anecdote; log every prompt and response and the bill, privacy exposure, and search noise grow together. The practical answer is to correlate a small browser event record with authoritative server records, using separate IDs for the user action, HTTP request, and agent run. Record elapsed time and usage at each agent step, but keep prompt bodies, tenant details, and model responses out of routine logs. That gives an operator enough evidence to rebuild one maintenance-request incident without turning the logging system into a second application database.
TL;DR: the browser creates an event_id for one user intent and sends it in a request header. The Node.js backend validates that value, creates its own request_id and agent_run_id, then returns the request ID in a response header. Every structured server event carries the relevant IDs. During a full-stack JavaScript incident, start with the frontend event, follow the server ID, and order agent steps by monotonic duration plus sequence number. Do not use a client-provided ID as proof of identity, uniqueness, or authorization.
How should browser fetch keep frontend and backend logging correlated?
Consider a property manager asking an agent to triage a leaking-sink report. One browser action may produce an HTTP retry, while one accepted request may trigger several model calls and tool calls. A single ID cannot describe all three lifetimes cleanly.
Retries happen.
Use an event_id for the user's intent, a request_id for each server acceptance, and an agent_run_id for the loop created by that accepted request. Add step_id and step_seq inside the loop. If the browser retries, preserve the event ID but let the server issue a fresh request ID. This makes the retry visible instead of collapsing two network attempts into one apparently slow request.
The data flow is short. The frontend logs ui.agent_submit, sends the event ID with browser fetch, and later logs either ui.agent_result or ui.agent_error with the backend response ID when available. The Node.js server emits a request-start event, step events around each model or tool operation, and one request-end event. A correlated log query can then walk from user intent to transport attempt to agent work without joining on an address, email, prompt, or other personal data. This division also answers a common full-stack debugging question: browser logs establish what the user attempted and observed, while server logs establish what the application accepted and executed; neither side alone can prove the whole timeline, so the request ID returned by the server is the narrow bridge between them.
That is enough.
A small end-to-end TypeScript implementation
The browser wrapper uses crypto.randomUUID() and performance.now(). It reads the authoritative request ID from the response. For a cross-origin API, the server must expose that response header through CORS or browser code cannot read it.
const API_ORIGIN = "https://api.example.invalid";
type AgentReply = { runId: string; summary: string };
type BrowserEvent = {
name: "ui.agent_submit" | "ui.agent_result" | "ui.agent_error";
event_id: string;
request_id?: string;
elapsed_ms?: number;
status?: number;
};
function recordBrowserEvent(event: BrowserEvent): void {
// Use the application's telemetry path; exclude report text.
console.info(JSON.stringify(event));
}
export async function triageMaintenanceReport(report: string): Promise<AgentReply> {
const eventId = crypto.randomUUID();
const started = performance.now();
recordBrowserEvent({ name: "ui.agent_submit", event_id: eventId });
try {
const response = await fetch(`${API_ORIGIN}/agent-runs`, {
method: "POST",
headers: { "content-type": "application/json", "x-event-id": eventId },
body: JSON.stringify({ report })
});
const requestId = response.headers.get("x-request-id") ?? undefined;
recordBrowserEvent({
name: response.ok ? "ui.agent_result" : "ui.agent_error",
event_id: eventId,
request_id: requestId,
elapsed_ms: Math.round(performance.now() - started),
status: response.status
});
if (!response.ok) throw new Error(`Agent request failed with ${response.status}`);
return (await response.json()) as AgentReply;
} catch (error) {
recordBrowserEvent({
name: "ui.agent_error",
event_id: eventId,
elapsed_ms: Math.round(performance.now() - started)
});
throw error;
}
}
The server-side boundary should be independent of the model provider or agent framework. The agent operation is injected here. performance.now() measures durations; wall-clock timestamps still place records on a shared timeline.
import { randomUUID } from "node:crypto";
import { performance } from "node:perf_hooks";
type Usage = { input_units: number; output_units: number };
type Step = {
name: "classify" | "lookup_property" | "draft_action";
elapsed_ms: number;
usage?: Usage;
outcome: "ok" | "error";
};
type AgentResult = { summary: string };
type RunAgent = (report: string, onStep: (step: Step) => void) => Promise<AgentResult>;
type LogRecord = Record<string, string | number | undefined>;
const writeLog = (record: LogRecord): void => console.info(JSON.stringify(record));
const validEventId = (value: string | undefined): string | undefined =>
value && /^[0-9a-f-]{36}$/i.test(value) ? value : undefined;
export async function handleAgentRun(
report: string,
incomingEventId: string | undefined,
runAgent: RunAgent
): Promise<{ headers: Record<string, string>; body: AgentResult & { runId: string } }> {
const eventId = validEventId(incomingEventId);
const requestId = randomUUID();
const runId = randomUUID();
const started = performance.now();
let stepSeq = 0;
let inputUnits = 0;
let outputUnits = 0;
writeLog({ name: "server.request_start", event_id: eventId,
request_id: requestId, agent_run_id: runId });
try {
const result = await runAgent(report, (step) => {
stepSeq += 1;
inputUnits += step.usage?.input_units ?? 0;
outputUnits += step.usage?.output_units ?? 0;
writeLog({ name: "agent.step_end", event_id: eventId,
request_id: requestId, agent_run_id: runId, step_id: randomUUID(),
step_seq: stepSeq, operation: step.name, outcome: step.outcome,
elapsed_ms: Math.round(step.elapsed_ms),
input_units: step.usage?.input_units, output_units: step.usage?.output_units });
});
writeLog({ name: "server.request_end", event_id: eventId,
request_id: requestId, agent_run_id: runId, outcome: "ok",
elapsed_ms: Math.round(performance.now() - started),
input_units: inputUnits, output_units: outputUnits });
return {
headers: { "x-request-id": requestId,
"access-control-expose-headers": "x-request-id" },
body: { runId, summary: result.summary }
};
} catch (error) {
writeLog({ name: "server.request_end", event_id: eventId,
request_id: requestId, agent_run_id: runId, outcome: "error",
error_type: error instanceof Error ? error.name : "UnknownError",
elapsed_ms: Math.round(performance.now() - started),
input_units: inputUnits, output_units: outputUnits });
throw error;
}
}
The application adapter sets the returned headers before serializing the body. Authentication and authorization remain outside correlation. The incoming event ID is context supplied by an untrusted client; even a syntactically valid UUID can be duplicated or chosen deliberately.
There is one deliberate omission: prompt and response content. Operation names, outcomes, durations, usage counters, and stable internal resource references answer the first incident questions. Content capture needs a separate policy, access boundary, retention period, and redaction design.
Reconstruct the incident before tuning dashboards
Start with a concrete report: βthe maintenance triage spinner ran twice and the result arrived late.β Find the browser's event_id, then enumerate every associated request_id. Two server request starts explain a retry. One request with several slow step records points into the loop. A fast server record paired with a much longer browser duration shifts attention toward connection setup, queuing outside the measured handler, payload transfer, or browser-side work.
These numbers are illustrative, not benchmark results. They show the evidence an operator should retrieve.
| operation | elapsed_ms | input_units | output_units | outcome |
|---|---|---|---|---|
| classify | 420 | 310 | 28 | ok |
| lookup_property | 95 | 0 | 0 | ok |
| draft_action | 1,180 | 740 | 112 | ok |
Do the arithmetic from recorded usage, not character counts. Keep the configured rate and its effective date in a small billing configuration table, outside each high-volume event. Compute estimated run cost as input units multiplied by the configured input rate plus output units multiplied by the configured output rate. Label it as an estimate unless it has been reconciled with billing data.
This distinction matters. A property lookup can add latency without model usage, while a model call can add usage even if the browser disconnects before receiving the answer. Request latency, agent-step latency, and billable usage are related signals, not interchangeable ones. The Google SRE model treats latency, traffic, errors, and saturation as core monitoring signals; per-run records provide detail after those aggregate signals raise an alarm.
Where correlation designs fail
The first trap is trusting x-event-id as a security claim. It is only a join key. Generate request and run IDs on the server, attach tenant and actor identifiers from authenticated server context, and never accept those identity fields from the browser.
The second is logging one total duration. That number confirms pain but cannot locate it. Put timers around model calls and tools, record an outcome on exceptions, and emit the terminal request record from both paths. Keep a sequence number because timestamps from distributed components can tie or arrive out of order.
Cardinality is quieter. Unique IDs belong in investigation logs and traces, but they are poor metric labels because every value creates another series in typical dimensional metric systems. Build aggregate latency and error charts from bounded dimensions such as operation and outcome. Pivot to logs or traces after an aggregate signal identifies a time window.
Then comes ingestion volume. Managed log systems commonly charge for ingestion and related storage or analysis, so repeated payloads have an operating cost. Emit one structured record per meaningful boundary, avoid duplicate full objects, and sample routine successful detail only after verifying that error paths and high-latency runs remain reconstructable. Keep terminal summaries for every run if those summaries drive cost accounting.
Privacy sets the final boundary. Maintenance reports can contain names, unit numbers, access instructions, and descriptions of residents. OWASP's logging guidance recommends excluding or masking data such as access tokens, passwords, and sensitive personal data. Treat prompt capture as sensitive storage, not an innocent debug field.
Operate the scheme as a contract
Before deployment, test the normal call, a browser retry, an agent-step exception, malformed JSON, a missing event header, and a cross-origin response. Assert that each accepted request gets a unique server ID; every step joins to exactly one run; terminal records appear for success and error; and the browser can read x-request-id. Also assert the negative case: report text and credentials must not appear in captured logs.
Version the event schema. A small schema_version field lets consumers migrate intentionally when a field changes meaning. Preserve names across services, define units in names such as elapsed_ms, and validate records in continuous integration. If distributed tracing already exists, carry W3C Trace Context alongside these domain IDs: trace context connects technical spans, while event and run IDs express the application lifetimes an incident responder cares about.
Set retention from the investigation window and privacy policy, then verify it in storage. Review ingestion volume after releases that add agent steps. Reconcile estimated usage with billing data on a schedule, because a clean correlation chain can still be financially wrong if metering semantics change or a code path fails to report usage.
The acceptance test is incident reconstruction, not the presence of a request-ID header. Given one user report and a time window, an operator should identify retries, order the server and agent work, isolate the slow boundary, total recorded usage, and see where evidence is absent. If that walkthrough needs a dashboard redesign during the incident, the logging contract is unfinished.
Sources
References used for the standards, browser behavior, monitoring model, privacy guidance, and ingestion-cost boundary:
- https://sre.google/sre-book/monitoring-distributed-systems/
- https://www.w3.org/TR/trace-context/
- https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers
- https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
- https://aws.amazon.com/cloudwatch/pricing/
Top comments (1)
A nice practical take on observability. I especially liked the distinction between event_id, request_id, and agent_run_id instead of forcing a single identifier to represent multiple lifecycles. That makes retries and async agent work much easier to reason about during incident analysis. The emphasis on correlating events without turning logs into a database full of prompts and user data is also a great point. π