|
| 1 | +import contextlib |
| 2 | +import json |
| 3 | +from collections.abc import Generator |
| 4 | +from datetime import datetime, timezone |
| 5 | +from importlib.metadata import version |
| 6 | +from typing import cast |
| 7 | + |
| 8 | +import inflection |
| 9 | +import structlog |
| 10 | +from opentelemetry import baggage, trace |
| 11 | +from opentelemetry import context as otel_context |
| 12 | +from opentelemetry._logs import SeverityNumber |
| 13 | +from opentelemetry.baggage.propagation import W3CBaggagePropagator |
| 14 | +from opentelemetry.exporter.otlp.proto.http._log_exporter import ( |
| 15 | + OTLPLogExporter, |
| 16 | +) |
| 17 | +from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( |
| 18 | + OTLPSpanExporter, |
| 19 | +) |
| 20 | +from opentelemetry.instrumentation.django import DjangoInstrumentor |
| 21 | +from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor |
| 22 | +from opentelemetry.instrumentation.redis import RedisInstrumentor |
| 23 | +from opentelemetry.propagate import set_global_textmap |
| 24 | +from opentelemetry.propagators.composite import CompositePropagator |
| 25 | +from opentelemetry.propagators.textmap import TextMapPropagator |
| 26 | +from opentelemetry.sdk._logs import LoggerProvider |
| 27 | +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor |
| 28 | +from opentelemetry.sdk.resources import Resource |
| 29 | +from opentelemetry.sdk.trace import TracerProvider |
| 30 | +from opentelemetry.sdk.trace.export import BatchSpanProcessor |
| 31 | +from opentelemetry.trace.propagation.tracecontext import ( |
| 32 | + TraceContextTextMapPropagator, |
| 33 | +) |
| 34 | +from opentelemetry.util.types import AnyValue, Attributes |
| 35 | +from structlog.typing import EventDict, Processor |
| 36 | + |
| 37 | +_SEVERITY_MAP: dict[str, SeverityNumber] = { |
| 38 | + "debug": SeverityNumber.DEBUG, |
| 39 | + "info": SeverityNumber.INFO, |
| 40 | + "warning": SeverityNumber.WARN, |
| 41 | + "error": SeverityNumber.ERROR, |
| 42 | + "critical": SeverityNumber.FATAL, |
| 43 | +} |
| 44 | + |
| 45 | +_RESERVED_KEYS = frozenset( |
| 46 | + [ |
| 47 | + "event", |
| 48 | + "level", |
| 49 | + "timestamp", |
| 50 | + "logger", |
| 51 | + "trace_id", |
| 52 | + "span_id", |
| 53 | + ] |
| 54 | +) |
| 55 | + |
| 56 | + |
| 57 | +def add_otel_trace_context( |
| 58 | + logger: structlog.types.WrappedLogger, |
| 59 | + method_name: str, |
| 60 | + event_dict: EventDict, |
| 61 | +) -> EventDict: |
| 62 | + """Add ``trace_id`` and ``span_id`` from the active OTel span to the event dict.""" |
| 63 | + span = trace.get_current_span() |
| 64 | + ctx = span.get_span_context() |
| 65 | + if ctx and ctx.is_valid: |
| 66 | + event_dict["trace_id"] = f"{ctx.trace_id:032x}" |
| 67 | + event_dict["span_id"] = f"{ctx.span_id:016x}" |
| 68 | + return event_dict |
| 69 | + |
| 70 | + |
| 71 | +def make_structlog_otel_processor(logger_provider: LoggerProvider) -> Processor: |
| 72 | + """Create a structlog processor that emits log records to OpenTelemetry. |
| 73 | +
|
| 74 | + Sits in the processor chain *before* the final renderer so that |
| 75 | + only structlog-originated logs reach OTel. Passes the event_dict |
| 76 | + through unchanged so downstream processors (console/JSON renderers) |
| 77 | + still work normally. |
| 78 | +
|
| 79 | + Pass the returned processor to :func:`~common.core.logging.setup_logging` |
| 80 | + via ``otel_processor``. |
| 81 | + """ |
| 82 | + otel_logger = logger_provider.get_logger(__name__, version("flagsmith-common")) |
| 83 | + |
| 84 | + def processor( |
| 85 | + logger: structlog.types.WrappedLogger, |
| 86 | + method_name: str, |
| 87 | + event_dict: EventDict, |
| 88 | + ) -> EventDict: |
| 89 | + attributes = map_event_dict_to_otel_attributes(event_dict) |
| 90 | + |
| 91 | + # Copy W3C baggage entries into log attributes so downstream |
| 92 | + # exporters can access them. |
| 93 | + ctx = otel_context.get_current() |
| 94 | + for key, value in baggage.get_all(ctx).items(): |
| 95 | + attributes[key] = str(value) |
| 96 | + |
| 97 | + body = event_dict.get("event", "") |
| 98 | + logger_name = event_dict.get("logger") |
| 99 | + event_name = inflection.underscore(body) if body else "unknown" |
| 100 | + if logger_name: |
| 101 | + event_name = f"{logger_name}.{event_name}" |
| 102 | + |
| 103 | + # Some observability platforms don't surface OTel's EventName. |
| 104 | + # Keep a custom attribute for better visibility. |
| 105 | + attributes["flagsmith.event"] = event_name |
| 106 | + |
| 107 | + log_level = event_dict.get("level", method_name) |
| 108 | + |
| 109 | + otel_logger.emit( |
| 110 | + timestamp=int(datetime.now(timezone.utc).timestamp() * 1e9), |
| 111 | + context=otel_context.get_current(), |
| 112 | + severity_text=log_level, |
| 113 | + severity_number=_SEVERITY_MAP.get(log_level, SeverityNumber.TRACE), |
| 114 | + body=body, |
| 115 | + event_name=event_name, |
| 116 | + attributes=attributes, |
| 117 | + ) |
| 118 | + |
| 119 | + # Also attach as a span event if there's an active span. |
| 120 | + span = trace.get_current_span() |
| 121 | + if span.is_recording(): |
| 122 | + # AnyValue is a superset of AttributeValue at runtime; |
| 123 | + # the cast keeps mypy happy. |
| 124 | + span.add_event(event_name, attributes=cast(Attributes, attributes)) |
| 125 | + |
| 126 | + return event_dict |
| 127 | + |
| 128 | + return processor |
| 129 | + |
| 130 | + |
| 131 | +def map_event_dict_to_otel_attributes(event_dict: EventDict) -> dict[str, AnyValue]: |
| 132 | + return { |
| 133 | + k.replace("__", "."): map_value_to_otel_value(v) |
| 134 | + for k, v in event_dict.items() |
| 135 | + if k not in _RESERVED_KEYS |
| 136 | + } |
| 137 | + |
| 138 | + |
| 139 | +def map_value_to_otel_value(value: object) -> str | int | float | bool: |
| 140 | + """Coerce a value to an OTel-attribute-compatible type.""" |
| 141 | + if isinstance(value, (bool, str, int, float)): |
| 142 | + return value |
| 143 | + return json.dumps(value, default=str) |
| 144 | + |
| 145 | + |
| 146 | +def build_otel_log_provider(*, endpoint: str, service_name: str) -> LoggerProvider: |
| 147 | + """Create and configure an OTel LoggerProvider with OTLP/HTTP export.""" |
| 148 | + resource = Resource.create({"service.name": service_name}) |
| 149 | + provider = LoggerProvider(resource=resource) |
| 150 | + exporter = OTLPLogExporter(endpoint=endpoint) |
| 151 | + provider.add_log_record_processor(BatchLogRecordProcessor(exporter)) |
| 152 | + return provider |
| 153 | + |
| 154 | + |
| 155 | +def build_tracer_provider(*, endpoint: str, service_name: str) -> TracerProvider: |
| 156 | + """Create a TracerProvider with OTLP/HTTP export.""" |
| 157 | + resource = Resource.create({"service.name": service_name}) |
| 158 | + tracer_provider = TracerProvider(resource=resource) |
| 159 | + span_exporter = OTLPSpanExporter(endpoint=endpoint) |
| 160 | + tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter)) |
| 161 | + return tracer_provider |
| 162 | + |
| 163 | + |
| 164 | +@contextlib.contextmanager |
| 165 | +def setup_tracing( |
| 166 | + tracer_provider: TracerProvider, |
| 167 | + excluded_urls: str | None = None, |
| 168 | +) -> Generator[None, None, None]: |
| 169 | + """Set up and tear down OTel distributed tracing with Django instrumentation. |
| 170 | +
|
| 171 | + Sets the global TracerProvider, configures W3C trace context + |
| 172 | + baggage propagation, and instruments Django so that every request |
| 173 | + creates a span with the incoming trace context. |
| 174 | +
|
| 175 | + On exit, uninstruments Django and shuts down the tracer provider. |
| 176 | +
|
| 177 | + Must be called *before* Django's WSGI app is created. |
| 178 | +
|
| 179 | + Args: |
| 180 | + tracer_provider: The TracerProvider to use. |
| 181 | + excluded_urls: Comma-separated URL paths to exclude from tracing |
| 182 | + (e.g. ``"health/liveness,health/readiness"``). If not provided, |
| 183 | + falls back to the ``OTEL_PYTHON_DJANGO_EXCLUDED_URLS`` env var. |
| 184 | + """ |
| 185 | + trace.set_tracer_provider(tracer_provider) |
| 186 | + |
| 187 | + propagator: TextMapPropagator = CompositePropagator( |
| 188 | + [ |
| 189 | + TraceContextTextMapPropagator(), |
| 190 | + W3CBaggagePropagator(), |
| 191 | + ] |
| 192 | + ) |
| 193 | + set_global_textmap(propagator) |
| 194 | + |
| 195 | + DjangoInstrumentor().instrument(excluded_urls=excluded_urls) |
| 196 | + Psycopg2Instrumentor().instrument(enable_commenter=True, skip_dep_check=True) |
| 197 | + RedisInstrumentor().instrument() |
| 198 | + try: |
| 199 | + yield |
| 200 | + finally: |
| 201 | + RedisInstrumentor().uninstrument() |
| 202 | + Psycopg2Instrumentor().uninstrument() |
| 203 | + DjangoInstrumentor().uninstrument() |
| 204 | + tracer_provider.shutdown() |
0 commit comments