跳到正文
DEV Community· JethroRhodes8268·· 3 小時前AI 評分62

Node.js SaaS 請求的 JSON 結構化應用程式日誌與 3 項回滾訊號

Node.js JSON Structured Application Logging for SaaS Requests with 3 Rollback Signals

AI 導讀

Node.js 日誌方案以 JSON 記錄 SaaS 智能體請求的狀態轉換,並按 agent_release 比較版本,協助診斷回滾。事件包含 request_id、W3C trace_id、span_id、假名化 actor_id,以及各步驟的延遲和模型 token 用量;提示詞、玩家文字、電郵、IP 位址及授權資料不應寫入。

正文

TL;DR: Emit one JSON event per meaningful state transition in the game agent loop. Carry request_id for the inbound call, W3C trace_id and span_id for distributed work, and a pseudonymous actor_id for abuse and support queries. Record latency and token usage at each step, but keep prompts, player text, email addresses, IP addresses, and authorization data out. A versioned schema plus a rollback marker makes a bad release diagnosable without turning the log stream into a second player database.

The hard constraint is rollback safety. If a new agent build makes tool selection slower or more expensive, operators need to compare it with the previous build and identify which game sessions were exposed. They do not need raw chat transcripts to do that. More data is often less useful: it raises disclosure risk, inflates ingestion, and makes the stable fields harder to find.

What should a SaaS application log for each structured request?

A useful event should answer four questions: which execution was this, where did time go, how much model usage did the step report, and which deploy produced it? That leads to a small contract rather than a dump of every object in memory. Picture the rollback query before choosing fields: group the three steps by agent_release, compare their latency and reported usage, then inspect errors by trace_id. If that query needs a prompt, email address, player name, or raw tool argument, the schema has crossed a boundary. If it cannot distinguish the canary from the previous release, it has missed the operational purpose. This constraint is sharper than “log useful context,” and it is much easier to test.

Start there.

For a three-step loop such as plan, act, and verify, emit a completion event for each step and one final loop event. Keep request_id opaque and unique enough for application correlation. Preserve the incoming W3C trace context when it is valid; otherwise let the tracing layer create a new trace. The W3C format gives trace-id 16 bytes and parent-id 8 bytes, represented as lowercase hexadecimal, and explicitly forbids all-zero values.

Do not confuse the IDs. request_id joins application events for one API request. trace_id joins spans across process boundaries. actor_id supports a controlled lookup for one player without exposing the account identifier itself. A raw user_id can still be personal data, even when it is an integer.

Here is the schema I would freeze first:

Field Purpose Cardinality policy
schema_version Parser compatibility Tiny, fixed set
event Stable event name Bounded vocabulary
request_id One inbound request Log field only
trace_id, span_id Distributed correlation Log and trace fields, never metric labels
actor_id Pseudonymous account correlation Log field with restricted access
agent_release Deployment comparison and rollback Bounded release set
step, outcome Loop state and result Bounded vocabulary
duration_ms Latency measurement Numeric value
input_tokens, output_tokens Usage measurement Numeric values
cost_microunits Internally computed cost in a named currency Integer value

That last field needs a companion currency and a documented rate-table version if the application computes cost. Do not bake a changing public price into the logger. The billing or model adapter should supply the calculated value, and the log event should preserve the inputs needed to audit it.

The smallest implementation I would ship

This example uses Node.js AsyncLocalStorage to carry request context through asynchronous calls. Node documents it as a stable API for coherent asynchronous state. The logger accepts an allow-listed event shape, so an innocent-looking spread such as { ...request.body } cannot quietly add a prompt or access token.

import { AsyncLocalStorage } from "node:async_hooks";
import { createHmac, randomUUID } from "node:crypto";

type Context = {
  request_id: string;
  trace_id: string;
  actor_id: string;
  agent_release: string;
};

type StepEvent = {
  event: "agent.step.completed";
  step: "plan" | "act" | "verify";
  outcome: "ok" | "error" | "blocked";
  span_id: string;
  duration_ms: number;
  input_tokens: number;
  output_tokens: number;
  cost_microunits: number;
  currency: string;
  error_code?: string;
};

const context = new AsyncLocalStorage<Context>();
const actorKey = Buffer.from(process.env.LOG_ACTOR_HMAC_KEY ?? "", "utf8");

function pseudonymizeActor(accountId: string): string {
  if (actorKey.length < 32) throw new Error("LOG_ACTOR_HMAC_KEY must be at least 32 bytes");
  return createHmac("sha256", actorKey).update(accountId).digest("hex");
}

function writeStep(event: StepEvent): void {
  const base = context.getStore();
  if (!base) throw new Error("missing request log context");

  process.stdout.write(`${JSON.stringify({
    timestamp: new Date().toISOString(),
    severity: event.outcome === "error" ? "error" : "info",
    schema_version: 1,
    ...base,
    ...event,
  })}\n`);
}

export async function withRequestContext<T>(
  accountId: string,
  traceId: string,
  agentRelease: string,
  work: () => Promise<T>,
): Promise<T> {
  return context.run({
    request_id: randomUUID(),
    trace_id: traceId,
    actor_id: pseudonymizeActor(accountId),
    agent_release: agentRelease,
  }, work);
}

This is deliberately boring. Good.

The code does not log the prompt, completion, player display name, email, network address, session cookie, or tool arguments. An HMAC makes the actor value consistent for authorized correlation while preventing a plain dictionary lookup without the secret. It is still linkable data, not anonymous data, so access controls and retention limits still apply. Rotate the HMAC key through an explicit migration plan; an unplanned rotation destroys historical joins.

The adapter that calls the model should time each step with a monotonic clock and pass the provider-reported token counts into writeStep. Wall-clock timestamps establish ordering, while monotonic elapsed time avoids errors when the system clock changes. Never infer token counts from string length in the logging layer.

PII masking is a boundary, not a regex

Regex replacement is a weak last line of defense. It can catch familiar email or card-like patterns, but it cannot know that dragon_queen_91 is a player name or that a tool argument contains a private guild message. The safer design is structural: define allowed keys, reject unknown keys in tests, and sanitize at the point where typed domain data becomes a log event.

OWASP recommends excluding or masking data such as access tokens, authentication passwords, sensitive personal data, and information whose collection is prohibited. It also recommends sanitizing event data to prevent log injection. For JSON output, that means letting the serializer escape strings and keeping untrusted input out of field names. Newlines inside an approved string remain encoded inside one JSON record.

Errors need the same discipline. Log a stable error_code, the failing step, and retryability. A stack trace may be useful in a restricted error stream, but exception messages can contain tool inputs or upstream payload fragments. Treat them as untrusted. Do not attach them to the default production event without a reviewed sanitizer.

Make rollback a data contract

Rollback works only if every relevant event carries agent_release and a low-cardinality outcome. During a canary, compare latency distributions, error ratios, token totals, and cost totals by release and step. Keep request, trace, and actor identifiers out of metric labels: each can create an effectively unbounded series set. Prometheus naming guidance says labels should represent the same logical quantity across their dimensions, and a metric should have a single unit.

The decision rule should be written before deployment. For example: roll back when the canary breaches the team's pre-approved latency or cost budget for a sustained evaluation window, or when verify errors cross its release gate. Those thresholds are system-specific, so inventing universal numbers would be fake precision. The log contract provides evidence; the release policy supplies the limits.

One trap deserves emphasis. Sampling successful step events can distort cost totals. If volume requires sampling, keep aggregate usage counters unsampled and mark sampled logs with the sampling policy. Error events and explicit rollback events usually need separate retention and sampling treatment because they reconstruct the decision path.

What changes at scale

At higher volume, I would separate three concerns. Logs retain discrete state transitions for investigation. Traces retain causal timing across services. Metrics retain cheap aggregates for alerting and release gates. OpenTelemetry's log data model defines trace and span identifiers on log records, which lets these signals correlate without forcing one storage shape to serve every query.

Schema evolution also becomes operational work. Validate fixtures in CI, increment schema_version for breaking changes, and run old and new parsers during the retention overlap. Add a byte budget per event. Reject accidental high-cardinality metric labels in tests. Benchmark serialization and context propagation in the actual service before choosing a logger or transport, because the relevant result depends on payload size, runtime version, concurrency, and destination.

The trade-off is plain: tighter schemas slow down ad hoc field additions, but they make rollback queries predictable and PII review finite. For an agent loop touching player data, that is the right bias. The quickest time-to-first-log is irrelevant if the first incident requires guessing what every field meant.

Further reading

來源:DEV Community · dev.to