Skip to content

Commit b25f672

Browse files
committed
handle JSON log formatting in handler
1 parent 934596e commit b25f672

File tree

2 files changed

+126
-11
lines changed

2 files changed

+126
-11
lines changed

infrastructure/aws/cdk/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def __init__(
141141
"OTEL_PYTHON_DISABLED_INSTRUMENTATIONS": "aws-lambda,requests,urllib3,aiohttp-client", # Disable aws-lambda auto-instrumentation (handled by otel_wrapper.py)
142142
"OTEL_PROPAGATORS": "tracecontext,baggage,xray",
143143
"OPENTELEMETRY_COLLECTOR_CONFIG_URI": "/opt/collector-config/config.yaml",
144-
"AWS_LAMBDA_LOG_FORMAT": "JSON",
144+
# AWS_LAMBDA_LOG_FORMAT not set - using custom JSON formatter in handler.py
145145
"AWS_LAMBDA_EXEC_WRAPPER": "/opt/otel-instrument", # Enable OTEL wrapper to avoid circular import
146146
},
147147
log_retention=logs.RetentionDays.ONE_WEEK,

infrastructure/aws/lambda/handler.py

Lines changed: 125 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,136 @@
11
"""AWS Lambda handler optimized for container runtime with OTEL instrumentation."""
22

3+
import json
34
import logging
5+
import os
46
import warnings
7+
from datetime import datetime, timezone
58
from typing import Any, Dict
69

710
from mangum import Mangum
8-
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
9-
from opentelemetry.instrumentation.logging import LoggingInstrumentor
1011

1112
from titiler.multidim.main import app
1213

13-
# Configure root logger to WARN level by default
14-
# Use simple format - AWS Lambda will handle JSON formatting when AWS_LAMBDA_LOG_FORMAT=JSON
15-
logging.basicConfig(
16-
level=logging.WARN,
17-
format="[%(levelname)s] %(name)s: %(message)s",
18-
)
14+
15+
def otel_trace_id_to_xray_format(otel_trace_id: str) -> str:
16+
"""
17+
Convert OpenTelemetry trace ID to X-Ray format.
18+
19+
OTEL format: 32 hex chars (e.g., "68eeb2ec45b07caf760899f308d34ab6")
20+
X-Ray format: "1-{first 8 chars}-{remaining 24 chars}" (e.g., "1-68eeb2ec-45b07caf760899f308d34ab6")
21+
22+
The first 8 hex chars represent the Unix timestamp, which is how X-Ray generates compatible IDs.
23+
"""
24+
if len(otel_trace_id) == 32:
25+
return f"1-{otel_trace_id[:8]}-{otel_trace_id[8:]}"
26+
return otel_trace_id
27+
28+
29+
class XRayJsonFormatter(logging.Formatter):
30+
"""
31+
Custom JSON formatter that includes X-Ray trace ID for log correlation.
32+
33+
This formatter outputs logs as JSON and includes:
34+
- Standard log fields (timestamp, level, message, logger)
35+
- X-Ray trace ID (converted from OTEL format)
36+
- OTEL trace context fields (if present)
37+
- Any extra fields passed via logger.info("msg", extra={...})
38+
"""
39+
40+
# Standard fields that shouldn't be duplicated in the output
41+
RESERVED_ATTRS = {
42+
"name",
43+
"msg",
44+
"args",
45+
"created",
46+
"filename",
47+
"funcName",
48+
"levelname",
49+
"levelno",
50+
"lineno",
51+
"module",
52+
"msecs",
53+
"message",
54+
"pathname",
55+
"process",
56+
"processName",
57+
"relativeCreated",
58+
"thread",
59+
"threadName",
60+
"exc_info",
61+
"exc_text",
62+
"stack_info",
63+
"taskName",
64+
}
65+
66+
def format(self, record: logging.LogRecord) -> str: # noqa: C901
67+
"""Format log record as JSON with X-Ray trace ID."""
68+
# Build base log object with standard fields
69+
log_object = {
70+
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc)
71+
.isoformat()
72+
.replace("+00:00", "Z"),
73+
"level": record.levelname,
74+
"message": record.getMessage(),
75+
"logger": record.name,
76+
}
77+
78+
# Add X-Ray trace ID
79+
xray_trace_id = None
80+
81+
# Method 1: Extract from Lambda's X-Ray environment variable (preferred)
82+
trace_header = os.environ.get("_X_AMZN_TRACE_ID", "")
83+
if trace_header:
84+
for part in trace_header.split(";"):
85+
if part.startswith("Root="):
86+
xray_trace_id = part.split("=", 1)[1]
87+
break
88+
89+
# Method 2: Convert OTEL trace ID if available (fallback)
90+
if not xray_trace_id and hasattr(record, "otelTraceID"):
91+
xray_trace_id = otel_trace_id_to_xray_format(record.otelTraceID)
92+
93+
if xray_trace_id:
94+
log_object["xray_trace_id"] = xray_trace_id
95+
96+
# Add exception info if present
97+
if record.exc_info:
98+
log_object["exception"] = self.formatException(record.exc_info)
99+
100+
# Add OTEL fields if present
101+
for attr in [
102+
"otelSpanID",
103+
"otelTraceID",
104+
"otelTraceSampled",
105+
"otelServiceName",
106+
]:
107+
if hasattr(record, attr):
108+
log_object[attr] = getattr(record, attr)
109+
110+
# Add AWS request ID if available
111+
if hasattr(record, "aws_request_id"):
112+
log_object["requestId"] = record.aws_request_id
113+
114+
# Add any extra fields from record.__dict__ that aren't standard
115+
for key, value in record.__dict__.items():
116+
if key not in self.RESERVED_ATTRS and key not in log_object:
117+
log_object[key] = value
118+
119+
return json.dumps(log_object)
120+
121+
122+
# Configure root logger with custom JSON formatter that includes X-Ray trace ID
123+
root_logger = logging.getLogger()
124+
root_logger.setLevel(logging.WARN)
125+
126+
# Remove any existing handlers
127+
for log_handler in root_logger.handlers[:]:
128+
root_logger.removeHandler(log_handler)
129+
130+
# Add StreamHandler with our custom JSON formatter
131+
json_handler = logging.StreamHandler()
132+
json_handler.setFormatter(XRayJsonFormatter())
133+
root_logger.addHandler(json_handler)
19134

20135
# Set titiler loggers to INFO level
21136
logging.getLogger("titiler").setLevel(logging.INFO)
@@ -29,8 +144,8 @@
29144
warnings.filterwarnings("ignore", category=UserWarning)
30145
warnings.filterwarnings("ignore", category=FutureWarning)
31146

32-
LoggingInstrumentor().instrument(set_logging_format=False)
33-
FastAPIInstrumentor.instrument_app(app)
147+
# LoggingInstrumentor().instrument(set_logging_format=False)
148+
# FastAPIInstrumentor.instrument_app(app)
34149

35150
handler = Mangum(
36151
app,

0 commit comments

Comments
 (0)