File size: 3,911 Bytes
2e818da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
"""Structured log creation and trace/span correlation.

The signal-responsibility table in the plan preamble gives logs one job:
"Explain a discrete decision, failure, or fallback" -- e.g. "collection lookup
failed before compatibility fallback returned []". This module produces those
records through stdlib ``logging`` (no new framework), attaching correlation
fields via the ``extra=`` mechanism so a record can be joined to its trace/span
and carries operation, pipeline version, consumer, status, and error type.

Field names are ``otel_``-prefixed to avoid colliding with stdlib
``LogRecord`` reserved attributes (``message``, ``name``, ``args`` ...).
"""

from __future__ import annotations

import logging
from typing import Any

from opentelemetry import trace

from app.observability.sanitize import sanitize_exception_text

# The dedicated logger for observability-emitted correlation records. Product
# code keeps using its own ``logging.getLogger(__name__)`` loggers; this one is
# only for the boundary's own error/fallback explanations.
LOGGER_NAME = "app.observability"
_logger = logging.getLogger(LOGGER_NAME)

_INVALID_TRACE_ID = 0
_INVALID_SPAN_ID = 0


def trace_context_ids(span: trace.Span | None) -> tuple[str | None, str | None]:
    """Return ``(trace_id_hex, span_id_hex)`` for a span, or ``(None, None)``.

    A non-recording / invalid span (disabled mode) has an all-zero context and
    yields ``None`` rather than a misleading zero string.
    """
    if span is None:
        return None, None
    ctx = span.get_span_context()
    if ctx is None or ctx.trace_id == _INVALID_TRACE_ID:
        return None, None
    span_id = format(ctx.span_id, "016x") if ctx.span_id != _INVALID_SPAN_ID else None
    return format(ctx.trace_id, "032x"), span_id


def build_log_extra(
    *,
    operation: str,
    status: str,
    trace_id: str | None = None,
    span_id: str | None = None,
    pipeline_version: str | None = None,
    consumer: str | None = None,
    subsystem: str | None = None,
    error_type: str | None = None,
    diagnostic_smoke: bool | None = None,
) -> dict[str, Any]:
    """Assemble the correlation ``extra`` dict for a structured log record."""
    extra = {
        "otel_operation": operation,
        "otel_status": status,
        "otel_trace_id": trace_id,
        "otel_span_id": span_id,
        "otel_pipeline_version": pipeline_version,
        "otel_consumer": consumer,
        "otel_subsystem": subsystem,
        "otel_error_type": error_type,
    }
    # Keep normal product records unchanged/absent. Only the explicit bounded
    # smoke marker is allowed through; this is not a general attributes escape
    # hatch for log records.
    if diagnostic_smoke is not None:
        extra["diagnostic.smoke"] = bool(diagnostic_smoke)
    return extra


def emit_correlated_log(
    message: str,
    *,
    operation: str,
    status: str,
    level: int = logging.ERROR,
    trace_id: str | None = None,
    span_id: str | None = None,
    pipeline_version: str | None = None,
    consumer: str | None = None,
    subsystem: str | None = None,
    error_type: str | None = None,
    diagnostic_smoke: bool | None = None,
    logger: logging.Logger | None = None,
) -> None:
    """Emit one structured, trace-correlated log record.

    ``message`` is sanitized (paths stripped, bounded) so an error string that
    embeds a filesystem path never reaches an exported log body.
    """
    log = logger or _logger
    log.log(
        level,
        sanitize_exception_text(str(message)),
        extra=build_log_extra(
            operation=operation,
            status=status,
            trace_id=trace_id,
            span_id=span_id,
            pipeline_version=pipeline_version,
            consumer=consumer,
            subsystem=subsystem,
            error_type=error_type,
            diagnostic_smoke=diagnostic_smoke,
        ),
    )