Skip to content

Commit 6e1b776

Browse files
authored
chore: improve API logs (#1227)
1 parent f7ccfa3 commit 6e1b776

File tree

19 files changed

+281
-336
lines changed

19 files changed

+281
-336
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ vars-*.env
6161
config/.env.*
6262
venv
6363
cypress.env.json
64+
.deploy-env-dev
6465

6566
__pycache__
6667
/functions-python/*/.env.local

api/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
#
1616

1717
# Builder Stage
18-
FROM --platform=$BUILDPLATFORM python:3.11 AS builder
18+
FROM --platform=$TARGETPLATFORM python:3.11 AS builder
1919

2020
WORKDIR /usr/src/app
2121

api/src/feeds/impl/error_handling.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import logging
2+
13
from fastapi import HTTPException
24

35
from shared.common.error_handling import InternalHTTPException
@@ -21,10 +23,12 @@ def raise_http_error(status_code: int, error: str):
2123
"detail": "Invalid date format for 'field_name'. Expected ISO 8601 format, example: '2021-01-01T00:00:00Z'"
2224
}
2325
"""
24-
raise HTTPException(
26+
exception = HTTPException(
2527
status_code=status_code,
2628
detail=error,
2729
)
30+
logging.error(exception)
31+
raise exception
2832

2933

3034
def raise_http_validation_error(error: str):

api/src/main.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
#
1616
# This files allows to add extra application decorators aside from the generated code.
1717
# The app created here is intended to replace the generated feeds_gen.main:app variable.
18-
import logging
1918
import os
2019

2120
import uvicorn
@@ -31,7 +30,8 @@
3130
from starlette.middleware.cors import CORSMiddleware
3231

3332
from middleware.request_context_middleware import RequestContextMiddleware
34-
from utils.logger import API_ACCESS_LOG, GCPLogHandler
33+
from utils.logger import global_logging_setup
34+
3535

3636
app = FastAPI(
3737
title="Mobility Data Catalog API",
@@ -58,11 +58,7 @@
5858

5959
@app.on_event("startup")
6060
async def startup_event():
61-
# Add the GCP log handler to the logger.
62-
# This is required to log the API access logs to the GCP logging service.
63-
logger = logging.getLogger(API_ACCESS_LOG)
64-
handler = GCPLogHandler()
65-
logger.handlers.append(handler)
61+
global_logging_setup()
6662

6763

6864
if __name__ == "__main__":

api/src/middleware/request_context.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from contextvars import ContextVar
23

34
import requests
@@ -49,7 +50,7 @@ def decode_jwt(self, token: str):
4950
return None
5051
return jwt.decode(token, self.google_public_keys, audience=get_config(PROJECT_ID))
5152
except Exception as e:
52-
print(f"Error decoding JWT: {e}")
53+
logging.warning("Error decoding JWT: %s", e)
5354
return None
5455

5556
def _extract_from_headers(self, headers: dict, scope: Scope) -> None:

api/src/middleware/request_context_middleware.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,21 @@ def create_http_request(
3838
"""
3939
request_method = scope["method"]
4040
request_path = scope["path"]
41+
query_string = scope.get("query_string", b"").decode("utf-8")
42+
full_path = f"{request_path}?{query_string}" if query_string else request_path
43+
4144
protocol = scope["scheme"].upper() + "/" + str(scope["http_version"])
45+
4246
return HttpRequest(
4347
requestMethod=request_method,
44-
requestUrl=f"{request_context.protocol}://{request_context.host}{request_path}",
48+
requestUrl=f"{request_context.protocol}://{request_context.host}{full_path}",
4549
remoteIp=request_context.client_host,
4650
protocol=protocol,
4751
status=status_code,
4852
responseSize=content_length,
4953
userAgent=request_context.client_user_agent,
5054
serverIp=request_context.server_ip,
51-
latency=latency,
55+
latency=f"{latency:.9f}s",
5256
)
5357

5458
def log_api_access(

api/src/utils/logger.py

Lines changed: 111 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
import asyncio
2-
import json
31
import logging
4-
from dataclasses import dataclass
2+
import os
3+
import sys
4+
from dataclasses import dataclass, asdict
55
from typing import Final, Optional
66

77
from middleware.request_context import RequestContext, get_request_context
8+
from shared.common.logging_utils import get_env_logging_level
89
from utils.config import get_config, PROJECT_ID
910
from utils.dict_utils import get_safe_value
1011

12+
import google.cloud.logging
13+
from google.cloud.logging_v2.handlers import CloudLoggingFilter, CloudLoggingHandler
14+
1115
API_ACCESS_LOG: Final[str] = "api-access-log"
1216
CLOUD_RUN_SERVICE_ID: Final[str] = "K_SERVICE"
1317
CLOUD_RUN_REVISION_ID: Final[str] = "K_REVISION"
@@ -27,101 +31,125 @@ class HttpRequest:
2731
userAgent: str
2832
remoteIp: str
2933
serverIp: str
30-
latency: float
34+
latency: str
3135
protocol: str
3236

3337

34-
@dataclass
35-
class LogRecord:
38+
def get_trace(request_context: RequestContext):
3639
"""
37-
Data class for Log Record
40+
Get the trace id from the log record
3841
"""
42+
trace = ""
43+
trace_id = get_safe_value(request_context, "trace_id")
44+
if trace_id:
45+
trace = f"projects/{get_config(PROJECT_ID, '')}/traces/{trace_id}"
46+
return trace
3947

40-
user_id: str
41-
httpRequest: dict
42-
trace: str
43-
spanId: str
44-
traceSampled: bool
45-
textPayload: Optional[str]
46-
jsonPayload: Optional[dict]
48+
49+
def get_http_request(record) -> HttpRequest | None:
50+
"""
51+
Get the http request from the log record
52+
If the http request is not found, return None
53+
"""
54+
context = record.__getattribute__("context") if hasattr(record, "context") else None
55+
return context.get("http_request") if context else {}
4756

4857

49-
class AsyncStreamHandler(logging.StreamHandler):
58+
class GoogleCloudLogFilter(CloudLoggingFilter):
5059
"""
51-
Async Stream Handler
60+
Log filter for Google Cloud Logging.
61+
This filter adds the trace, span and http_request fields to the log record.
5262
"""
5363

54-
def __init__(self, *args, **kwargs):
55-
super().__init__(*args, **kwargs)
56-
self.loop = asyncio.get_event_loop()
64+
def filter(self, record: logging.LogRecord) -> bool:
65+
request_context = get_request_context()
66+
http_request = get_http_request(record)
67+
if http_request:
68+
record.http_request = asdict(http_request)
69+
span_id = request_context.get("span_id")
70+
trace = get_trace(request_context)
71+
record.trace = trace
72+
record.span_id = span_id
73+
74+
record._log_fields = {
75+
"logging.googleapis.com/trace": trace,
76+
"logging.googleapis.com/spanId": span_id,
77+
"logging.googleapis.com/httpRequest": asdict(http_request) if http_request else None,
78+
"logging.googleapis.com/trace_sampled": True,
79+
}
80+
super().filter(record)
81+
82+
return True
83+
84+
85+
class StderrToLog:
86+
"""
87+
Redirect stderr to log
88+
"""
5789

58-
def emit(self, record):
59-
"""
60-
Emit the log record
61-
"""
62-
asyncio.ensure_future(self.async_emit(record))
90+
def __init__(self, logger):
91+
self.logger = logger
6392

64-
async def async_emit(self, record):
65-
"""
66-
Async emit the log record
67-
"""
68-
msg = self.format(record)
69-
stream = self.stream
70-
await self.loop.run_in_executor(None, stream.write, msg)
71-
await self.loop.run_in_executor(None, stream.flush)
93+
def write(self, message):
94+
message = message.strip()
95+
if message:
96+
self.logger.error(message)
97+
98+
def flush(self):
99+
pass
72100

73101

74-
class GCPLogHandler(AsyncStreamHandler):
102+
def get_logger(name: Optional[str]):
75103
"""
76-
GCP Log Handler
104+
Returns a logger with the name making sure the propagate flag is set to True.
77105
"""
106+
logger = logging.getLogger(name)
107+
logger.propagate = True
108+
return logger
78109

79-
def __init__(self):
80-
console_handler = logging.StreamHandler()
81-
self.logger = logging.getLogger()
82-
self.logger.addHandler(console_handler)
83-
self.logger.setLevel(logging.DEBUG)
84-
super().__init__()
85-
86-
@staticmethod
87-
def get_trace(request_context: RequestContext):
88-
"""
89-
Get the trace id from the log record
90-
"""
91-
trace = ""
92-
trace_id = get_safe_value(request_context, "trace_id")
93-
if trace_id:
94-
trace = f"projects/{get_config(PROJECT_ID, '')}/traces/{trace_id}"
95-
return trace
96-
97-
@staticmethod
98-
def get_http_request(record) -> HttpRequest:
99-
context = record.__getattribute__("context") if hasattr(record, "context") else None
100-
return context.get("http_request") if context else {}
101-
102-
async def async_emit(self, record):
103-
"""
104-
Emit the GCP log record
105-
"""
106-
http_request = self.get_http_request(record)
107-
request_context = get_request_context()
108-
text_payload = None
109-
json_payload = None
110-
message = record.msg if hasattr(record, "msg") else None
111-
message = record.getMessage() if message is None and hasattr(record, "getMessage") else message
112-
if message:
113-
if type(message) is dict:
114-
json_payload = message
115-
else:
116-
text_payload = str(message)
117-
118-
log_record: LogRecord = LogRecord(
119-
httpRequest=http_request.__dict__ if not isinstance(http_request, dict) else {},
120-
trace=self.get_trace(request_context),
121-
spanId=request_context.get("span_id"),
122-
traceSampled=request_context.get("trace_sampled"),
123-
user_id=request_context.get("user_id"),
124-
textPayload=text_payload,
125-
jsonPayload=json_payload,
126-
)
127-
self.logger.info(json.dumps(log_record.__dict__))
110+
111+
def is_local_env():
112+
"""
113+
Returns: True if the environment is local, False otherwise
114+
"""
115+
return os.getenv("K_SERVICE") is None
116+
117+
118+
def global_logging_setup():
119+
if is_local_env():
120+
logging.basicConfig(level=get_env_logging_level())
121+
return
122+
123+
# Send warnings through logging
124+
logging.captureWarnings(True)
125+
# Replace sys.stderr
126+
sys.stderr = StderrToLog(logging.getLogger("stderr"))
127+
try:
128+
client = google.cloud.logging.Client()
129+
handler = CloudLoggingHandler(client, structured=True)
130+
handler.setLevel(get_env_logging_level())
131+
handler.addFilter(GoogleCloudLogFilter(project=client.project))
132+
except Exception as e:
133+
logging.error("Error initializing cloud logging: %s", e)
134+
logging.basicConfig(level=get_env_logging_level())
135+
return
136+
137+
# Configure root logger
138+
root_logger = logging.getLogger()
139+
root_logger.setLevel(get_env_logging_level())
140+
root_logger.handlers.clear()
141+
root_logger.addHandler(handler)
142+
143+
# This overrides individual logs essential for debugging purposes.
144+
for name in [
145+
"sqlalchemy",
146+
"uvicorn",
147+
"uvicorn.error",
148+
"uvicorn.access",
149+
"sqlalchemy.exc",
150+
"feed-api",
151+
"sqlalchemy.engine",
152+
]:
153+
get_logger(name)
154+
155+
logging.info("Setting cloud up logging completed")

api/tests/utils/test_async_stream_handler.py

Lines changed: 0 additions & 31 deletions
This file was deleted.

0 commit comments

Comments
 (0)