Skip to main content

TypeScript AI Agent Logging and Audit Trail Patterns

Five copy-paste TypeScript patterns for AI agent logging and audit trails: OpenTelemetry spans, PII-safe traces, audit records, token use, human review.

9 min readBy Jesus A McKinneyUpdated July 6, 2026
TypeScript AI Agent Logging and Audit Trail Patterns

TypeScript AI Agent Logging and Audit Trail Patterns 2026, 5 Code Templates

A magnifying glass resting on printed charts and graphs, representing AI agent logging and audit trail patterns for compliance

Every AI agent decision is a liability if you cannot reconstruct what happened and why. These five patterns give you the logging infrastructure to answer "what did the agent do, with what data, and what was the outcome", which is what EU AI Act Article 12, SOC 2, and any serious incident investigation will ask for.

Part of the TypeScript AI Agent Security series. This is the logging and audit trail deep-dive. Start with the complete TypeScript AI agent security playbook, or see the companion guides on authorization patterns and output validation.

Each pattern is standalone, drop in and wire to your existing agent framework.

Pattern What it logs Compliance relevance Storage cost
1. OpenTelemetry span logging (GenAI conventions) Model name, token usage, latency, prompt/completion hashes, tool calls EU AI Act Article 12 (record-keeping); SOC 2 CC6 Low, trace data only; no full prompt storage
2. PII-safe trace storage Hashed/redacted version of user inputs; PII fields replaced before storage GDPR data minimization; CCPA; HIPAA (if PHI in prompts) Low, redacted traces are small
3. Compliance-ready audit record Full decision audit: user ID, session, prompt summary, model response, tool calls, final action taken EU AI Act Article 12; EEOC automated decision audit trail; GDPR Article 22 Medium, full decision record per request
4. Token usage tracker per session Token counts per user per session; budget thresholds; cost attribution Internal cost governance; per-team budget accountability Very low, counter only
5. Human review decision trail Human reviewer ID, review timestamp, decision (approve/reject/modify), rationale EU AI Act human oversight requirement; financial services supervisory procedures Low, decision metadata only

TL;DR: Five TypeScript patterns for AI agent logging and audit trails: (1) Structured OpenTelemetry span logging following GenAI semantic conventions, every LLM call traced with inputs, outputs, model, tokens; (2) PII-safe trace storage, redact before the span closes, not after; (3) Compliance-ready audit record, immutable append-only log with session ID, user ID, decision type, and outcome; (4) Token usage tracker per session, feed into cost circuit breaker and billing; (5) Human review decision trail, structured log of every automated decision with confidence, rationale, and override history. Each pattern is standalone and wires to any agent framework.


Pattern 1: OpenTelemetry Span Logging (GenAI Semantic Conventions)

Instrument every LLM call with standard OTel attributes. Backends (Jaeger, Tempo, Honeycomb, Datadog) all consume this without custom parsers.

import { trace, SpanStatusCode, context } from "@opentelemetry/api";

const tracer = trace.getTracer("ai-agent", "1.0.0");

interface LLMCallParams {
  model: string;
  messages: Array<{ role: string; content: string }>;
  maxTokens?: number;
  sessionId: string;
  userId: string;
}

async function tracedLLMCall(
  params: LLMCallParams,
  llmFn: () => Promise<{ content: string; usage: { prompt_tokens: number; completion_tokens: number } }>
) {
  return tracer.startActiveSpan("gen_ai.chat", async (span) => {
    // GenAI semantic conventions, standard attribute names
    span.setAttributes({
      "gen_ai.system": "openai",
      "gen_ai.operation.name": "chat",
      "gen_ai.request.model": params.model,
      "gen_ai.request.max_tokens": params.maxTokens ?? 4096,
      "session.id": params.sessionId,
      "user.id": params.userId,
    });

    // Prompts as span events (can be filtered at collector, not attributes)
    span.addEvent("gen_ai.content.prompt", {
      "gen_ai.prompt": JSON.stringify(params.messages),
    });

    try {
      const result = await llmFn();

      span.setAttributes({
        "gen_ai.response.model": params.model,
        "gen_ai.usage.input_tokens": result.usage.prompt_tokens,
        "gen_ai.usage.output_tokens": result.usage.completion_tokens,
      });

      span.addEvent("gen_ai.content.completion", {
        "gen_ai.completion": result.content,
      });

      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } catch (err) {
      span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
      span.recordException(err as Error);
      throw err;
    } finally {
      span.end();
    }
  });
}

Span attributes are indexed and retained forever. Storing prompt and completion content as span attributes means it lands in every backend and retention tier you have, use Logs API events instead so you can filter or drop at the OTel Collector without touching application code. Note: OTel GenAI semantic conventions are in Development status (v1.38). The older gen_ai.prompt/gen_ai.completion span attributes are deprecated, newer implementations use the Logs API or gen_ai.input.messages/gen_ai.output.messages attributes. Verify against the current OTel GenAI spec before shipping.


Pattern 2: PII-Safe Trace Storage

Redact before the span closes. Post-processing after export misses the window before the backend receives the data.

type RedactionRule = { pattern: RegExp; label: string };

const PII_RULES: RedactionRule[] = [
  { pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, label: "EMAIL" },
  { pattern: /\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b/g, label: "PHONE" },
  { pattern: /\b\d{3}-\d{2}-\d{4}\b/g, label: "SSN" },
  { pattern: /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, label: "CARD" },
];

function redactForTrace(text: string): { safe: string; redactedTypes: string[] } {
  const redactedTypes: string[] = [];
  let safe = text;
  for (const rule of PII_RULES) {
    let hit = false;
    safe = safe.replace(rule.pattern, () => {
      hit = true;
      return `[${rule.label}]`;
    });
    if (hit) redactedTypes.push(rule.label);
  }
  return { safe, redactedTypes };
}

// Wrap Pattern 1's event emission:
function addSafePromptEvent(span: Span, messages: Array<{ role: string; content: string }>) {
  const redacted = messages.map((m) => {
    const { safe, redactedTypes } = redactForTrace(m.content);
    return { role: m.role, content: safe, _redacted: redactedTypes };
  });

  span.addEvent("gen_ai.content.prompt", {
    "gen_ai.prompt": JSON.stringify(redacted),
  });
}

Regex catches structured PII. Named entities, names, addresses, require a NER model. For GDPR Article 5(1)(f), treat all prompt content as personal data by default and route only to backends with a signed DPA.


Pattern 3: Compliance-Ready Audit Record

Immutable append-only log of every agent decision. Covers the core EU AI Act Article 12 requirements: what happened, when, on what input, with what outcome.

interface AuditRecord {
  id: string;              // UUID, immutable primary key
  timestamp: string;       // ISO 8601 UTC
  sessionId: string;
  userId: string;
  agentId: string;
  operationType: string;   // "inference" | "tool_call" | "decision" | "escalation"
  modelVersion: string;
  inputHash: string;       // SHA-256 of input (not the input itself)
  outputSummary: string;   // Safe summary, not raw output
  confidence?: number;
  humanReviewRequired: boolean;
  humanReviewId?: string;  // FK to review queue if escalated
  policyViolation?: string;
  durationMs: number;
  tokenUsage: { input: number; output: number };
}

class AuditLogger {
  private records: AuditRecord[] = []; // Replace with append-only DB write

  async log(record: Omit<AuditRecord, "id" | "timestamp">): Promise<string> {
    const id = crypto.randomUUID();
    const entry: AuditRecord = {
      ...record,
      id,
      timestamp: new Date().toISOString(),
    };

    // In production: INSERT INTO audit_log, never UPDATE or DELETE
    this.records.push(entry);

    return id;
  }

  // Export for regulatory review, returns immutable snapshot
  async exportRange(from: Date, to: Date): Promise<AuditRecord[]> {
    return this.records.filter((r) => {
      const ts = new Date(r.timestamp);
      return ts >= from && ts <= to;
    });
  }
}

// Usage, log every inference decision
const audit = new AuditLogger();

const startMs = Date.now();
const result = await agent.run(userInput);

await audit.log({
  sessionId,
  userId,
  agentId: "contract-analyzer-v2",
  operationType: "decision",
  modelVersion: "gpt-4o-2024-11-20",
  inputHash: await sha256(userInput),
  outputSummary: result.summary, // Safe field, not raw LLM output
  confidence: result.confidence,
  humanReviewRequired: result.confidence < 0.75,
  humanReviewId: result.reviewId,
  durationMs: Date.now() - startMs,
  tokenUsage: result.usage,
});

inputHash instead of raw input: keeps the audit log lean and avoids storing PII in the audit table. If you need to reconstruct the input for investigation, use the hash to look it up in an encrypted input store.

Hands pointing at printed charts and graphs on a table while colleagues look on, representing AI agent audit logs must be retained for at least 6 months under EU AI Act Article 26(6) for high-risk systems


Pattern 4: Token Usage Tracker Per Session

Feed into cost circuit breaker (Pattern 6 in output validation patterns) and per-user billing.

interface SessionUsage {
  sessionId: string;
  userId: string;
  startedAt: Date;
  calls: Array<{
    ts: Date;
    model: string;
    inputTokens: number;
    outputTokens: number;
    costUsd: number;
  }>;
}

const MODEL_PRICING: Record<string, { input: number; output: number }> = {
  "gpt-4o": { input: 0.0000025, output: 0.00001 },       // per token
  "gpt-4o-mini": { input: 0.00000015, output: 0.0000006 },
  "claude-sonnet-4-6": { input: 0.000003, output: 0.000015 },
};

class SessionTokenTracker {
  private sessions = new Map<string, SessionUsage>();

  record(
    sessionId: string,
    userId: string,
    model: string,
    inputTokens: number,
    outputTokens: number
  ): { sessionTotal: number; sessionCostUsd: number } {
    if (!this.sessions.has(sessionId)) {
      this.sessions.set(sessionId, {
        sessionId, userId, startedAt: new Date(), calls: [],
      });
    }

    const pricing = MODEL_PRICING[model] ?? { input: 0, output: 0 };
    const costUsd =
      inputTokens * pricing.input + outputTokens * pricing.output;

    const session = this.sessions.get(sessionId)!;
    session.calls.push({ ts: new Date(), model, inputTokens, outputTokens, costUsd });

    const sessionTotal = session.calls.reduce(
      (sum, c) => sum + c.inputTokens + c.outputTokens, 0
    );
    const sessionCostUsd = session.calls.reduce((sum, c) => sum + c.costUsd, 0);

    return { sessionTotal, sessionCostUsd };
  }

  getSession(sessionId: string): SessionUsage | undefined {
    return this.sessions.get(sessionId);
  }
}

// Usage, call after every LLM response
const tracker = new SessionTokenTracker();

const { sessionTotal, sessionCostUsd } = tracker.record(
  sessionId, userId, "gpt-4o",
  response.usage.prompt_tokens,
  response.usage.completion_tokens
);

if (sessionCostUsd > 1.0) { // $1 per session limit
  throw new Error(`Session cost limit exceeded: $${sessionCostUsd.toFixed(4)}`);
}

Pattern 5: Human Review Decision Trail

Log every automated decision with confidence and rationale. When a human overrides, record the override. This record satisfies the "human oversight" requirements under EU AI Act high-risk provisions.

type DecisionOutcome = "automated" | "escalated" | "overridden" | "confirmed";

interface DecisionRecord {
  id: string;
  sessionId: string;
  userId: string;
  query: string;
  agentDraft: string;
  agentConfidence: number;
  agentRationale: string;
  outcome: DecisionOutcome;
  humanReviewerId?: string;
  humanDecision?: string;
  overrideReason?: string;
  resolvedAt: string;
}

class DecisionTrail {
  private decisions = new Map<string, DecisionRecord>();

  record(draft: {
    sessionId: string;
    userId: string;
    query: string;
    agentDraft: string;
    agentConfidence: number;
    agentRationale: string;
  }): string {
    const id = crypto.randomUUID();
    this.decisions.set(id, {
      ...draft,
      id,
      outcome: draft.agentConfidence >= 0.8 ? "automated" : "escalated",
      resolvedAt: new Date().toISOString(),
    });
    return id;
  }

  humanOverride(
    decisionId: string,
    reviewerId: string,
    decision: string,
    reason: string
  ): void {
    const record = this.decisions.get(decisionId);
    if (!record) throw new Error(`Decision ${decisionId} not found`);

    // Append-only update, preserve original draft
    this.decisions.set(decisionId, {
      ...record,
      outcome: "overridden",
      humanReviewerId: reviewerId,
      humanDecision: decision,
      overrideReason: reason,
      resolvedAt: new Date().toISOString(),
    });
  }

  humanConfirm(decisionId: string, reviewerId: string): void {
    const record = this.decisions.get(decisionId);
    if (!record) throw new Error(`Decision ${decisionId} not found`);

    this.decisions.set(decisionId, {
      ...record,
      outcome: "confirmed",
      humanReviewerId: reviewerId,
      resolvedAt: new Date().toISOString(),
    });
  }

  // Audit export, all decisions in a time range
  getDecisions(from: Date, to: Date): DecisionRecord[] {
    return Array.from(this.decisions.values()).filter((d) => {
      const ts = new Date(d.resolvedAt);
      return ts >= from && ts <= to;
    });
  }
}

Wiring it all together

const otelTracer = trace.getTracer("ai-agent");
const auditLogger = new AuditLogger();
const tokenTracker = new SessionTokenTracker();
const decisionTrail = new DecisionTrail();

async function instrumentedAgentCall(
  sessionId: string,
  userId: string,
  query: string
): Promise<string> {
  const startMs = Date.now();

  // 1. Traced LLM call (Pattern 1 + 2)
  const result = await tracedLLMCall(
    { model: "gpt-4o", messages: [{ role: "user", content: query }], sessionId, userId },
    () => llmClient.chat({ model: "gpt-4o", messages: [{ role: "user", content: query }] })
  );

  // 2. Token tracking (Pattern 4)
  const { sessionCostUsd } = tokenTracker.record(
    sessionId, userId, "gpt-4o",
    result.usage.prompt_tokens, result.usage.completion_tokens
  );

  // 3. Audit record (Pattern 3)
  await auditLogger.log({
    sessionId, userId,
    agentId: "main-agent",
    operationType: "inference",
    modelVersion: "gpt-4o",
    inputHash: await sha256(query),
    outputSummary: result.content.slice(0, 200),
    humanReviewRequired: false,
    durationMs: Date.now() - startMs,
    tokenUsage: { input: result.usage.prompt_tokens, output: result.usage.completion_tokens },
  });

  // 4. Decision trail (Pattern 5)
  decisionTrail.record({
    sessionId, userId, query,
    agentDraft: result.content,
    agentConfidence: 0.9, // from confidence pattern
    agentRationale: "High-confidence structured output",
  });

  return result.content;
}

References

  1. OpenTelemetry, AI Agent Observability standards and semantic conventions
  2. OpenTelemetry, GenAI semantic conventions
  3. Arize AI, Best AI Observability Tools for Autonomous Agents in 2026
  4. EU AI Act, Article 12: Logging obligations for high-risk AI systems

Legal disclaimer

This article is published for informational and educational purposes only. It does not constitute legal, regulatory, or professional compliance advice and should not be relied upon as such. AI governance requirements vary by jurisdiction, industry, and organizational context. Always consult a qualified legal or compliance professional before implementing policies or making decisions with regulatory implications.

About the author

Jesus A McKinney

Templates & Workflows Desk

Builds the copy-paste side of the site: policy templates, registers, checklists and vendor comparisons. Each template says which requirement every field answers, and is written to be used the same day.

  • Fields mapped to the clause they answer
  • Vendor terms read on the vendor's own pages
  • Dated checks on each post