Skip to main content

Migrating from console.log to Adjudon traces

Goal

You have an existing AI-decision service. The codebase has console.log (or Python's logger.info) calls scattered through it that record what the agent decided, why, and what inputs it considered. Those logs end up in CloudWatch / Datadog / a flat file somewhere; they are not audit-grade, not tamper-evident, not regulator-readable. This recipe shows the incremental migration from those logs to Adjudon traces — without touching every call site on the first deploy and without losing the existing log destination during the transition.

You'll need

  • An Adjudon Sandbox plan (or above)
  • An adj_test_* agent API key for development; an adj_live_* key for production
  • An existing service with at least one decision-logging call site
  • 5 minutes for the first call site; then iterate

The shim pattern

Direct find-and-replace of every console.log is the brittle path: a thousand call sites, none of them tested, deployed in one PR. The shim pattern is the inverse: write one logDecision(...) function that both writes to your existing logger and sends an Adjudon trace, then call that function from each migrated site as you touch it. The existing logs keep flowing; the trace stream grows site by site.

src/lib/decision-log.ts (Node.js)
import { Adjudon } from "@adjudon/node";
import { safeLog } from "./safe-log";

const adjudon = new Adjudon({
apiKey: process.env.ADJUDON_API_KEY!,
agentId: "refund-classifier",
});

export interface DecisionLogEntry {
prompt: string;
action: string;
rationale?: string;
confidence: number;
metadata?: Record<string, unknown>;
}

/**
* Drop-in replacement for ad-hoc decision logging.
* - Writes to the existing logger (preserve backwards compat)
* - Emits an Adjudon trace with audit-grade fields
* - Returns the Adjudon decision so the caller can act on `block`/`flagged`
*/
export async function logDecision(entry: DecisionLogEntry) {
// Step 1 — keep the existing logger working
console.log(`[decision] ${entry.action} (conf=${entry.confidence})`, {
rationale: entry.rationale,
...entry.metadata,
});

// Step 2 — emit the Adjudon trace, with a payload-free fail-open path
try {
return await adjudon.trace({
inputContext: { prompt: entry.prompt },
outputDecision: {
action: entry.action,
confidence: entry.confidence,
rationale: entry.rationale,
},
metadata: entry.metadata,
});
} catch (err) {
// Cardinal Rule #4 — never log payload in error handlers.
// Use a payload-stripping logger; surface error code only.
safeLog.error("audit-emit-failed", { code: (err as any)?.code });
return { status: "passthrough" as const, id: null };
}
}

The safeLog helper above is a single-line wrapper around your existing logger that strips request bodies, prompts, and output text before emission — the same posture Adjudon's own backend takes (see Audit & Security on Cardinal Rule #4).

An audit-grade error path that re-emits the failed payload to the existing logger would defeat the migration's purpose — the same payload that was supposed to flow only to Adjudon would suddenly flow to CloudWatch as well. The safeLog indirection is non-optional once the migration starts touching real production traffic.

Migrating one call site

Find a high-leverage call site — the one the Compliance Officer is most likely to ask about — and swap one console.log for one logDecision:

Before
function decideRefund(order: Order, customerTier: string): RefundDecision {
const decision = computeRefund(order, customerTier);
console.log("Refund decision", { orderId: order.id, decision });
return decision;
}
After
async function decideRefund(order: Order, customerTier: string): Promise<RefundDecision> {
const decision = computeRefund(order, customerTier);

const trace = await logDecision({
prompt: `Refund request for order ${order.id} (tier=${customerTier})`,
action: decision.action,
rationale: decision.rationale,
confidence: decision.confidence,
metadata: { orderId: order.id, customerTier },
});

if (trace.status === "blocked") {
throw new BlockedByPolicyError(`Refund blocked: ${trace.id}`);
}

return decision;
}

The function signature changed from sync to async — the trace emission needs an await. Most call sites that already do downstream API work are async already; pure-sync paths get the most friction here.

Roll-out order

A migration that touches a handful of call sites a week is easier to defend than a Big-Bang PR. The recommended order:

  1. The compliance-critical paths first. Any decision the operator's compliance team has already raised as a concern. These are the call sites where the audit value pays off the migration cost immediately.
  2. The high-volume paths second. The trace data shape stabilises faster when the dashboards are populated; an accidentally-bad metadata field surfaces in days, not months.
  3. The low-stakes paths last. Internal admin tools, batch-processing jobs, debug-only paths. Migrating them is cheaper because the team has muscle memory for the shim pattern by then.

Throughout, the existing console.log lines stay live: the old log pipeline keeps flowing while the new trace pipeline grows. Once the trace coverage matches the log coverage, the team can decide whether to drop the console.log lines or keep them as dual-write redundancy.

What you've gained, in one diff

- console.log("Refund decision", { orderId, decision });
+ const trace = await logDecision({ prompt, action, rationale, confidence, metadata });
+ if (trace.status === "blocked") throw new BlockedByPolicyError(...);

That single swap moves you from "we wrote a log line" to EU AI Act Article 13 transparency, Article 14 human oversight via the Review Queue, GDPR Article 22(3) human-in-the-loop signal on flagged decisions, and a SHA-256 Hash Chain anchor on every entry. The trace is the regulator-readable artefact; console.log was always just text on disk.

Going further

  • Scope by agentId. Each shim instance carries its own agentId constructor argument. A monolith with three AI-decision surfaces (refunds, fraud, content moderation) becomes three shim instances, each tagged distinctly — the dashboard's per-agent breakdowns become meaningful from day one.
  • Capture downstream tool calls. Once the basic shim is in place, extend the DecisionLogEntry shape with a toolCalls?: field and follow the multi-step-agents recipe.
  • Drop the old logs intentionally. When the audit posture stabilises and the team trusts the trace stream, the legacy log line can be removed. Track this as an explicit deprecation milestone in the team's roadmap, not a silent cleanup.
  • Migration metrics. Count the call sites; track the fraction migrated weekly; surface the number in the team's internal dashboard. The migration is observable progress, not a vague "we're working on it."

See also

  • Quickstart — the first-trace recipe this migration ends with
  • Multi-step agents — the next-step recipe once the shim pattern is in place
  • Audit & Security — what an audit-grade trace actually buys, and the Cardinal Rule #4 framing for the error-path safe-logger
  • Traces API — the underlying surface this recipe targets