Skip to content

OpenTelemetry Integration

TenantShield observability events are emitted via structlog. The processor chain is fully adopter-controlled, so OpenTelemetry context propagation works without any TenantShield-side coupling.

Pattern

Add a structlog processor that pulls the current OTel span context and injects trace_id / span_id into every emitted event:

import structlog
from opentelemetry import trace
from structlog.contextvars import merge_contextvars
from structlog.processors import JSONRenderer, TimeStamper, add_log_level


def add_otel_context(logger, method_name, event_dict):
    span = trace.get_current_span()
    if span and span.is_recording():
        ctx = span.get_span_context()
        event_dict["trace_id"] = format(ctx.trace_id, "032x")
        event_dict["span_id"] = format(ctx.span_id, "016x")
    return event_dict


structlog.configure(
    processors=[
        merge_contextvars,
        add_otel_context,
        add_log_level,
        TimeStamper(fmt="iso"),
        JSONRenderer(),
    ],
)

Result

Every tenant.scope.*, tenant.write.*, tenant.read.*, and tenant.middleware.* event now carries trace_id and span_id, linking enforcement decisions to the request-level trace.

{
  "event": "tenant.write.blocked",
  "tenant_id": "acme",
  "attempted_tenant_id": "globex",
  "model_class": "Invoice",
  "operation": "before_insert",
  "trace_id": "0af7651916cd43dd8448eb211c80319c",
  "span_id": "b7ad6b7169203331",
  "log_level": "warning",
  "timestamp": "2026-05-18T10:24:33.412Z"
}

Coverage

The same processor chain decorates the audit logger (tenantshield.audit) when adopters route audit events through structlog via StructLogSink. OTel context propagates to both layers without additional configuration.

See also