Skip to content

Commit

Permalink
Remove json_logging.
Browse files Browse the repository at this point in the history
This dependency is no longer maintained and does not work with Connexion >= 3.x
  • Loading branch information
aholmes committed Feb 14, 2025
1 parent 44bee43 commit 6b25e9c
Show file tree
Hide file tree
Showing 22 changed files with 271 additions and 1,131 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,10 @@ def __init__(
"timestamp": "asctime",
})

handler = logging.StreamHandler()
handler.formatter = formatter
json_handler = logging.StreamHandler()
json_handler.formatter = formatter
if logging.lastResort is None:
logging.lastResort = handler
logging.lastResort = json_handler
else:
logging.lastResort.setFormatter(formatter)

Expand All @@ -149,11 +149,13 @@ def __init__(
def force_json_format(*args: Any, **kwargs: Any):
logger = original_get_logger(*args, **kwargs)

if handler in logger.handlers:
if json_handler in logger.handlers:
return logger

logger.handlers.clear()
logger.addHandler(handler)
for handler in logger.handlers:
handler.setFormatter(formatter)

logger.addHandler(json_handler)
return logger

logging.getLogger = force_json_format
Expand Down
13 changes: 0 additions & 13 deletions src/web/Ligare/web/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
overload,
)

import json_logging.util
from connexion import FlaskApp
from connexion.options import SwaggerUIOptions
from flask import Blueprint, Flask
Expand Down Expand Up @@ -454,8 +453,6 @@ def configure_openapi(config: Config, name: Optional[str] = None):
validate_responses=config.flask.openapi.validate_responses,
)

# enable_json_logging = config.logging.format == "JSON"
# if enable_json_logging:
# FIXME what's the new way to get this URL?
# if config.flask.openapi.use_swagger:
# # App context needed for url_for.
Expand Down Expand Up @@ -483,16 +480,6 @@ def configure_blueprint_routes(
app = Flask(config.flask.app_name)
config.update_flask_config(app.config)

enable_json_logging = config.logging.format == "JSON"
if enable_json_logging:
json_logging.init_flask( # pyright: ignore[reportUnknownMemberType]
enable_json=enable_json_logging
)
json_logging.init_request_instrument( # pyright: ignore[reportUnknownMemberType]
app
)
json_logging.config_root_logger() # pyright: ignore[reportUnknownMemberType]

blueprint_modules = _import_blueprint_modules(app, blueprint_import_subdir)
_register_blueprint_modules(app, blueprint_modules)
return app
Expand Down
211 changes: 211 additions & 0 deletions src/web/Ligare/web/middleware/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import uuid
from collections.abc import Collection
from contextvars import ContextVar
from logging import Logger
from typing import Any, NamedTuple, NewType, cast
from uuid import uuid4

from injector import inject
from Ligare.web.middleware.consts import REQUEST_ID_HEADER
from Ligare.web.middleware.openapi import MiddlewareRequestDict, MiddlewareResponseDict
from starlette.types import ASGIApp, Receive, Scope, Send
from typing_extensions import final


# copied from connexion.utils
def extract_content_type(
headers: list[tuple[bytes, bytes]] | dict[str, str],
) -> str | None:
"""Extract the mime type and encoding from the content type headers.

:param headers: Headers from ASGI scope

:return: The content type if available in headers, otherwise None
"""
content_type: str | None = None

header_pairs: Collection[tuple[str | bytes, str | bytes]] = (
headers.items() if isinstance(headers, dict) else headers
)
for key, value in header_pairs:
# Headers can always be decoded using latin-1:
# https://stackoverflow.com/a/27357138/4098821
if isinstance(key, bytes):
decoded_key: str = key.decode("latin-1")
else:
decoded_key = key

if decoded_key.lower() == "content-type":
if isinstance(value, bytes):
content_type = value.decode("latin-1")
else:
content_type = value
break

return content_type


# copied from connexion.utils
def split_content_type(content_type: str | None) -> tuple[str | None, str | None]:
"""Split the content type in mime_type and encoding. Other parameters are ignored."""
mime_type, encoding = None, None

if content_type is None:
return mime_type, encoding

# Check for parameters
if ";" in content_type:
mime_type, parameters = content_type.split(";", maxsplit=1)

# Find parameter describing the charset
prefix = "charset="
for parameter in parameters.split(";"):
if parameter.startswith(prefix):
encoding = parameter[len(prefix) :]
else:
mime_type = content_type
return mime_type, encoding


CorrelationId = NewType("CorrelationId", str)
RequestId = NewType("RequestId", str)

CORRELATION_ID_CTX_KEY = "correlationId"
REQUEST_ID_CTX_KEY = "requestId"

_correlation_id_ctx_var: ContextVar[CorrelationId | None] = ContextVar(
CORRELATION_ID_CTX_KEY, default=None
)
_request_id_ctx_var: ContextVar[RequestId | None] = ContextVar(
REQUEST_ID_CTX_KEY, default=None
)


class TraceId(NamedTuple):
CorrelationId: CorrelationId | None
RequestId: RequestId | None


def get_trace_id() -> TraceId:
return TraceId(_correlation_id_ctx_var.get(), _request_id_ctx_var.get())


@final
class CorrelationIdMiddleware:
"""
Generate a Correlation ID for each request.

https://github.com/encode/starlette/issues/420
"""

def __init__(
self,
app: ASGIApp,
) -> None:
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] not in ["http", "websocket"]:
await self.app(scope, receive, send)
return

correlation_id = _correlation_id_ctx_var.set(CorrelationId(str(uuid4())))

await self.app(scope, receive, send)

_correlation_id_ctx_var.reset(correlation_id)


@final
class RequestIdMiddleware:
"""
Generate a Trace ID for each request.
If X-Correlation-Id is set in the request headers, that ID is used instead.
"""

_app: ASGIApp

def __init__(self, app: ASGIApp):
super().__init__()
self._app = app

@inject
async def __call__(
self, scope: Scope, receive: Receive, send: Send, log: Logger
) -> None:
if scope["type"] not in ["http", "websocket"]:
return await self._app(scope, receive, send)

# extract the request ID from the request headers if it is set

request = cast(MiddlewareRequestDict, scope)
request_headers = request.get("headers")

content_type = extract_content_type(request_headers)
_, encoding = split_content_type(content_type)
if encoding is None:
encoding = "utf-8"

try:
request_id_header_encoded = REQUEST_ID_HEADER.lower().encode(encoding)

request_id: bytes | None = next(
(
request_id
for (header, request_id) in request_headers
if header == request_id_header_encoded
),
None,
)

if request_id:
# validate format
request_id_decoded = request_id.decode(encoding)
_ = uuid.UUID(request_id_decoded)
request_id_token = _request_id_ctx_var.set(
RequestId(request_id_decoded)
)
else:
request_id_decoded = str(uuid4())
request_id = request_id_decoded.encode(encoding)
request_headers.append((
request_id_header_encoded,
request_id,
))
request_id_token = _request_id_ctx_var.set(
RequestId(request_id_decoded)
)
log.info(
f'Generated new UUID "{request_id}" for {REQUEST_ID_HEADER} request header.'
)
except ValueError as e:
log.warning(f"Badly formatted {REQUEST_ID_HEADER} received in request.")
raise e

async def wrapped_send(message: Any) -> None:
nonlocal scope
nonlocal send

if message["type"] != "http.response.start":
return await send(message)

# include the request ID in response headers

response = cast(MiddlewareResponseDict, message)
response_headers = response["headers"]

content_type = extract_content_type(response_headers)
_, encoding = split_content_type(content_type)
if encoding is None:
encoding = "utf-8"

response_headers.append((
request_id_header_encoded,
request_id,
))

return await send(message)

await self._app(scope, receive, wrapped_send)

_request_id_ctx_var.reset(request_id_token)
2 changes: 1 addition & 1 deletion src/web/Ligare/web/middleware/dependency_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
LoggerModule,
)
from Ligare.web.application import Config as AppConfig
from Ligare.web.middleware.openapi import (
from Ligare.web.middleware.context import (
CorrelationIdMiddleware,
RequestIdMiddleware,
get_trace_id,
Expand Down
21 changes: 2 additions & 19 deletions src/web/Ligare/web/middleware/flask/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
from typing import Awaitable, Callable, Dict, TypeAlias, TypeVar
from uuid import uuid4

import json_logging
from connexion import FlaskApp
from flask import Flask, Request, Response, request
from flask.typing import ResponseReturnValue
from injector import inject
from Ligare.web.middleware.context import get_trace_id

from ...config import Config
from ..consts import (
Expand Down Expand Up @@ -71,7 +71,7 @@ def wrapper(request_callable: Callable[..., Response | None]) -> T_request_calla


def _get_correlation_id(log: Logger) -> str:
correlation_id = _get_correlation_id_from_json_logging(log)
correlation_id = get_trace_id().CorrelationId

if not correlation_id:
correlation_id = _get_correlation_id_from_headers(log)
Expand Down Expand Up @@ -99,23 +99,6 @@ def _get_correlation_id_from_headers(log: Logger) -> str:
raise e


def _get_correlation_id_from_json_logging(log: Logger) -> str | None:
correlation_id: None | str
try:
correlation_id = json_logging.get_correlation_id(request)
# validate format
_ = uuid.UUID(correlation_id)
return correlation_id
except ValueError as e:
log.warning(f"Badly formatted {REQUEST_ID_HEADER} received in request.")
raise e
except Exception as e:
log.debug(
f"Error received when getting {REQUEST_ID_HEADER} header from `json_logging`. Possibly `json_logging` is not configured, and this is not an error.",
exc_info=e,
)


@inject
def _log_all_api_requests(
request: Request,
Expand Down
Loading

0 comments on commit 6b25e9c

Please sign in to comment.