Free lesson · Forward Deployed GenAI Engineering

Log compliance events as OTEL traces with structured attributes

You build a ComplianceAuditLogger that records deployment operations as OpenTelemetry traces with actor/action/resource attributes and OTLP/JSON exporters.

Course: AI Solution Delivery · Chapter 6 · Deploying in Customer Environments

Free to read — no subscription required.

Introduction

In production, customer-deployed AI systems must satisfy compliance frameworks like SOC 2 and HIPAA — which means every deployment action, secret rotation, and policy change needs a tamper-evident, queryable audit trail. Custom logging approaches scatter this data across unstructured log files that auditors cannot efficiently query or correlate across clusters. By the end of this lesson, you'll be able to instrument deployment operations using OpenTelemetry traces, capturing the actor, action, resource, and outcome of every cluster mutation as structured, exportable compliance records that any OTEL-compatible backend can receive.

Key Terminology

  • Compliance audit trail — a tamper-evident, queryable record of every cluster mutation (deployment creation, secret rotation, policy change) required by frameworks like SOC 2 and HIPAA; this lesson produces it as a stream of OTEL spans rather than unstructured log lines.
  • OpenTelemetry span — the atomic unit of tracing that captures a single compliance event: a name (e.g. audit.secret.rotated), start and end timestamps, structured attributes, and a terminal status code that OTEL backends use for alerting and SLOs.
  • Span attributes — typed key-value pairs attached to a span at creation time; ComplianceAuditLogger.log_event writes audit.actor, audit.action, audit.resource, audit.namespace, and audit.outcome as attributes, making every event queryable across any OTEL-compatible backend without log parsing.
  • AuditEvent — the Pydantic BaseModel that enforces the compliance record schema at the API boundary; because actor, action, resource, namespace, and outcome are required fields, a structurally incomplete record raises a ValidationError before any span is opened.
  • BatchSpanProcessor — the OTEL pipeline component that buffers completed spans in memory and flushes them in batches to the OTLPSpanExporter over gRPC, decoupling span creation from network I/O so deployment operations are not blocked on export latency.
  • StatusCode.ERROR — the OTEL semantic failure signal set via span.set_status(StatusCode.ERROR, ...) when AuditOutcome.FAILURE is recorded; distinct from writing an error string into an attribute, this is what SLO dashboards and alerting rules key on to surface failed compliance events.

Concepts

Loading diagram...

Compliance Records Need Queryable Structure, Not Text

Audit frameworks like SOC 2 and HIPAA ask specific operational questions: who rotated which secret, when, and did it succeed? A custom logger that writes unstructured lines — 2026-05-16 ci-bot rotated inference-api-key OK — forces auditors to parse free text and reconstruct context across log files that may span multiple clusters. The answer is not better text formatting; it is a structured data model with typed dimensions that a backend can index and query directly.

OpenTelemetry's span model provides exactly this. Each span carries named, typed attributes (audit.actor, audit.action, audit.resource, audit.namespace, audit.outcome) that any OTEL-compatible backend can filter, aggregate, and correlate without a custom parser. A query like "all audit.secret.rotated spans where audit.outcome=failure in namespace ml-prod over the last 30 days" is expressible in every major OTEL backend out of the box.

Spans as Atomic Compliance Events

A span represents one discrete unit of work with a name, precise start and end times, a bag of structured attributes, and a terminal status. For compliance logging this maps cleanly — one cluster mutation, one span. The span name encodes the action type using a dotted convention (audit.deployment.created, audit.secret.rotated) so backends can group events by action without parsing attribute values.

Because OTEL traces are hierarchical, a multi-step deployment operation can be modeled as a parent span containing child spans for each sub-operation, all sharing a single trace ID. This links related actions in one view for an auditor — rather than correlating timestamps across separate log entries — while each child span still carries its own actor, resource, and outcome attributes (see Code Walkthrough).

Schema Enforcement Before the Span Opens

A compliance record that is missing an actor or resource cannot be attributed to anyone. Discovering that gap at query time — when an auditor asks "who did this?" — is far worse than catching it at write time. AuditEvent as a Pydantic BaseModel places the schema check at the entry point: actor, action, resource, namespace, and outcome are required fields, and Pydantic raises a ValidationError before log_event ever opens a span. Structurally incomplete records are impossible to persist.

The AuditAction and AuditOutcome enums enforce the value space as well. Rather than accepting arbitrary strings a caller could misspell, the type system constrains the action to a known set (deployment.created, secret.rotated, etc.) and the outcome to exactly success or failure. Both inherit from str, so their values serialize directly into span attributes without conversion — callers get type-checked inputs and the span carries the canonical string.

Failure Signaling vs. Failure Logging

Setting span.set_status(StatusCode.ERROR) is not the same as writing an error message into a log attribute. Span status is a semantic signal that OTEL backends treat structurally: error spans surface in error-trace views, contribute to error-rate metrics, and trigger threshold-based alerts. A span whose status is OK but whose audit.detail attribute contains the word "failed" is invisible to any alerting rule that keys on span status.

log_event calls span.set_status(StatusCode.ERROR, ...) only when AuditOutcome.FAILURE is present, so the failure-signaling path is deterministic and tied to the validated outcome enum rather than to string matching in log messages. SLO dashboards then reflect the true operational failure rate of deployment operations without requiring a custom alert rule for every possible error string (see Code Walkthrough).

Code Walkthrough

Now that you understand why OpenTelemetry's span model is the right foundation for compliance logging — hierarchical traces, multi-backend export, and queryable structured attributes — here is how those principles translate into a ComplianceAuditLogger that enforces the required schema at the API boundary.

The block below establishes the data model and initializes the tracing pipeline:

Code snippetpython
1from opentelemetry import trace 2from opentelemetry.sdk.trace import TracerProvider 3from opentelemetry.sdk.trace.export import BatchSpanProcessor 4from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter 5from opentelemetry.sdk.resources import Resource 6from opentelemetry.trace import StatusCode 7from pydantic import BaseModel 8from typing import Optional 9from datetime import datetime 10from enum import Enum 11 12class AuditAction(str, Enum): 13 DEPLOYMENT_CREATED = "deployment.created" 14 DEPLOYMENT_UPDATED = "deployment.updated" 15 SECRET_ROTATED = "secret.rotated" 16 POLICY_APPLIED = "policy.applied" 17 CONFIG_CHANGED = "config.changed" 18 19class AuditOutcome(str, Enum): 20 SUCCESS = "success" 21 FAILURE = "failure" 22 23class AuditEvent(BaseModel): 24 actor: str 25 action: AuditAction 26 resource: str 27 namespace: str 28 outcome: AuditOutcome 29 detail: Optional[str] = None 30 31class ComplianceAuditLogger: 32 def __init__(self, service_name: str, otlp_endpoint: str = "localhost:4317"): 33 resource = Resource.create({"service.name": service_name}) 34 provider = TracerProvider(resource=resource) 35 exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True) 36 provider.add_span_processor(BatchSpanProcessor(exporter)) 37 trace.set_tracer_provider(provider) 38 self.tracer = trace.get_tracer("compliance-audit") 39 40 def log_event(self, event: AuditEvent) -> None: 41 with self.tracer.start_as_current_span( 42 name=f"audit.{event.action.value}", 43 attributes={ 44 "audit.actor": event.actor, 45 "audit.action": event.action.value, 46 "audit.resource": event.resource, 47 "audit.namespace": event.namespace, 48 "audit.outcome": event.outcome.value, 49 "audit.timestamp": datetime.utcnow().isoformat(), 50 "audit.detail": event.detail or "", 51 }, 52 ) as span: 53 if event.outcome == AuditOutcome.FAILURE: 54 span.set_status(StatusCode.ERROR, event.detail or "operation failed")

AuditAction and AuditOutcome are str/Enum hybrids, so each member serializes to a plain string in span attributes without extra conversion. AuditEvent is a Pydantic BaseModel, which means any caller that omits a required field — actor, resource, or namespace — receives a validation error before a span is ever opened, making structurally incomplete audit records impossible to create.

The __init__ method wires the full OTEL pipeline: a Resource tags every span with the deployer's service name, a BatchSpanProcessor buffers and flushes spans to the OTLPSpanExporter over gRPC, and trace.set_tracer_provider registers this pipeline globally so nested deployment calls inherit the same trace context automatically.

log_event converts a validated AuditEvent into a span whose name follows the audit.<action> convention — making compliance queries such as "show all audit.secret.rotated spans from the last 30 days" executable against any OTEL backend without log parsing. Failure outcomes call span.set_status(StatusCode.ERROR) so alerting rules and SLO dashboards surface them immediately rather than letting them pass silently through the pipeline.

The following snippet shows a typical call site inside a deployment workflow:

Code snippetpython
1logger = ComplianceAuditLogger(service_name="ai-deployer") 2 3logger.log_event(AuditEvent( 4 actor="ci-bot@deploy.internal", 5 action=AuditAction.SECRET_ROTATED, 6 resource="inference-api-key", 7 namespace="ml-prod", 8 outcome=AuditOutcome.SUCCESS, 9 detail="Rotated after 90-day policy window", 10))

Check that after calling log_event, a span named audit.secret.rotated appears in your OTEL backend with audit.actor, audit.namespace, and audit.outcome attributes populated — and that submitting an AuditOutcome.FAILURE event causes that span to carry an ERROR status code.

Do's and Don'ts

Having walked through the material above, the following Do's and Don'ts distill it into practice.

Do's

  1. Do use AuditEvent as the sole entry point for every compliance record — Pydantic's validation rejects any call missing actor, resource, or namespace before a span is opened, making it structurally impossible to write an incomplete audit trail that would fail a SOC 2 or HIPAA review.
  2. Do name spans with the audit.<action> convention derived from AuditAction enum values — this makes compliance queries like "all audit.secret.rotated events in the last 30 days" executable directly against any OTEL backend without log parsing or regex extraction.
  3. Do call span.set_status(StatusCode.ERROR) on AuditOutcome.FAILURE events — failure outcomes that don't set ERROR status pass silently through alerting rules and SLO dashboards, hiding policy violations from the operators who need to act on them.

Don'ts

  1. Don't write free-form log strings instead of structured AuditEvent spans — unstructured log lines cannot be correlated across clusters or queried by an auditor without custom parsing, which defeats the tamper-evident, queryable trail that SOC 2 and HIPAA require.
  2. Don't bypass AuditEvent validation by constructing raw span attributes manually — hand-crafting audit.actor or audit.namespace attributes outside the Pydantic model removes the schema enforcement that prevents structurally incomplete compliance records from reaching the OTEL backend.
  3. Don't hardcode a single log destination instead of using OTLPSpanExporter with BatchSpanProcessor — tying compliance records to one backend makes it impossible to fan out to a second OTEL-compatible receiver (e.g., a SIEM alongside Jaeger) and risks dropping spans under load by flushing synchronously instead of in batches.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the Forward Deployed GenAI Engineering subscription.

From · cancel anytime

More free lessons in AI Solution Delivery

All free lessons in Forward Deployed GenAI Engineering