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; anadj_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.
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:
function decideRefund(order: Order, customerTier: string): RefundDecision {
const decision = computeRefund(order, customerTier);
console.log("Refund decision", { orderId: order.id, decision });
return decision;
}
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:
- 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.
- 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.
- 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 ownagentIdconstructor 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
DecisionLogEntryshape with atoolCalls?: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