Skip to content

Commit b5e410f

Browse files
authored
feat: JSON logging (#97)
* feat: JSON logging
1 parent f53bbe9 commit b5e410f

File tree

8 files changed

+143
-11
lines changed

8 files changed

+143
-11
lines changed

config.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,10 @@
1414
"identities": {
1515
"use_cache": false
1616
}
17+
},
18+
"logging": {
19+
"log_level": "INFO",
20+
"log_format": "generic",
21+
"enable_access_log": false
1722
}
18-
}
23+
}

requirements.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ flagsmith-flag-engine
55
python-decouple
66
python-dotenv
77
pydantic
8+
structlog
89
orjson
910
httpx

requirements.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.12
2+
# This file is autogenerated by pip-compile with Python 3.10
33
# by the following command:
44
#
55
# pip-compile requirements.in
@@ -14,6 +14,8 @@ certifi==2023.7.22
1414
# httpx
1515
click==8.1.7
1616
# via uvicorn
17+
exceptiongroup==1.2.0
18+
# via anyio
1719
fastapi==0.109.1
1820
# via -r requirements.in
1921
flagsmith-flag-engine==4.1.0
@@ -57,10 +59,13 @@ sniffio==1.3.0
5759
# httpx
5860
starlette==0.35.0
5961
# via fastapi
62+
structlog==24.1.0
63+
# via -r requirements.in
6064
typing-extensions==4.8.0
6165
# via
6266
# fastapi
6367
# pydantic
6468
# pydantic-collections
69+
# uvicorn
6570
uvicorn==0.23.2
6671
# via -r requirements.in

src/cache.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
import logging
21
import typing
32
from abc import ABC
43

5-
logger = logging.getLogger(__name__)
6-
74

85
class BaseEnvironmentsCache(ABC):
96
def __init__(self, *args, **kwargs):

src/environments.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import logging
21
import typing
32
from datetime import datetime
43
from functools import lru_cache
54

65
import httpx
6+
import structlog
77
from flag_engine.engine import (
88
get_environment_feature_state,
99
get_environment_feature_states,
@@ -24,7 +24,7 @@
2424
from src.models import IdentityWithTraits
2525
from src.settings import Settings
2626

27-
logger = logging.getLogger(__name__)
27+
logger = structlog.get_logger(__name__)
2828

2929

3030
class EnvironmentService:
@@ -64,7 +64,7 @@ async def refresh_environment_caches(self):
6464
await self._clear_endpoint_caches()
6565
except (httpx.HTTPError, orjson.JSONDecodeError):
6666
logger.exception(
67-
f"Failed to fetch document for {key_pair.client_side_key}"
67+
"error_fetching_document", client_side_key=key_pair.client_side_key
6868
)
6969
received_error = True
7070
if not received_error:

src/logging.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import logging
2+
import logging.handlers
3+
4+
import structlog
5+
6+
from .settings import LogFormat, LoggingSettings
7+
8+
9+
def _extract_gunicorn_access_log_event(
10+
record: logging.LogRecord,
11+
name: str,
12+
event_dict: structlog.types.EventDict,
13+
) -> structlog.types.EventDict:
14+
# Extract logger args from Gunicorn access log entry
15+
# and map them to Flagsmith's JSON log format.
16+
# Argument positions taken from
17+
# https://github.com/encode/uvicorn/blob/a2219eb2ed2bbda4143a0fb18c4b0578881b1ae8/uvicorn/logging.py#L99-L105
18+
if event_dict.get("logger") == "uvicorn.access":
19+
remote_ip, method, path, _, status = event_dict["positional_args"]
20+
event_dict["remote_ip"] = remote_ip
21+
event_dict["method"] = method
22+
event_dict["path"] = path
23+
event_dict["status"] = status
24+
return event_dict
25+
26+
27+
def setup_logging(settings: LoggingSettings) -> None:
28+
"""
29+
Configure stdlib logger to use structlog processors and formatters so that
30+
uvicorn and application logs are consistent.
31+
"""
32+
is_generic_format = settings.log_format is LogFormat.GENERIC
33+
34+
processors: list[structlog.types.Processor] = [
35+
structlog.contextvars.merge_contextvars,
36+
structlog.stdlib.add_logger_name,
37+
structlog.stdlib.add_log_level,
38+
_extract_gunicorn_access_log_event,
39+
structlog.stdlib.PositionalArgumentsFormatter(),
40+
structlog.stdlib.ExtraAdder(),
41+
structlog.processors.StackInfoRenderer(),
42+
structlog.processors.TimeStamper(fmt="iso"),
43+
]
44+
45+
if is_generic_format:
46+
# For `generic` format, set `exc_info` on the log event if the log method is
47+
# `exception` and `exc_info` is not already set.
48+
#
49+
# Rendering of `exc_info` is handled by ConsoleRenderer.
50+
processors.append(structlog.dev.set_exc_info)
51+
else:
52+
# For `json` format `exc_info` must be set explicitly when
53+
# needed, and is translated into a formatted `exception` field.
54+
processors.append(structlog.processors.format_exc_info)
55+
56+
processors.append(structlog.processors.EventRenamer(settings.log_event_field_name))
57+
58+
structlog.configure(
59+
processors=processors
60+
+ [structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
61+
logger_factory=structlog.stdlib.LoggerFactory(),
62+
cache_logger_on_first_use=True,
63+
)
64+
65+
if is_generic_format:
66+
log_renderer = structlog.dev.ConsoleRenderer(
67+
event_key=settings.log_event_field_name
68+
)
69+
else:
70+
log_renderer = structlog.processors.JSONRenderer()
71+
72+
formatter = structlog.stdlib.ProcessorFormatter(
73+
use_get_message=False,
74+
pass_foreign_args=True,
75+
foreign_pre_chain=processors,
76+
processors=[
77+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
78+
log_renderer,
79+
],
80+
)
81+
82+
handler = logging.StreamHandler()
83+
handler.setFormatter(formatter)
84+
85+
root = logging.getLogger()
86+
root.addHandler(handler)
87+
root.setLevel(settings.log_level.to_logging_log_level())
88+
89+
# Propagate uvicorn logs instead of letting uvicorn configure the format
90+
for name in ["uvicorn", "uvicorn.error"]:
91+
logging.getLogger(name).handlers.clear()
92+
logging.getLogger(name).propagate = True
93+
94+
logging.getLogger("uvicorn.access").handlers.clear()
95+
logging.getLogger("uvicorn.access").propagate = settings.enable_access_log

src/main.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import logging
21
from contextlib import suppress
32
from datetime import datetime
43

54
import httpx
5+
import structlog
66
from fastapi import FastAPI, Header
77
from fastapi.middleware.cors import CORSMiddleware
88
from fastapi.middleware.gzip import GZipMiddleware
@@ -13,16 +13,18 @@
1313
from .cache import LocalMemEnvironmentsCache
1414
from .environments import EnvironmentService
1515
from .exceptions import FeatureNotFoundError, FlagsmithUnknownKeyError
16+
from .logging import setup_logging
1617
from .models import IdentityWithTraits
1718
from .settings import Settings
1819

19-
app = FastAPI()
2020
settings = Settings()
21+
setup_logging(settings.logging)
2122
environment_service = EnvironmentService(
2223
LocalMemEnvironmentsCache(),
2324
httpx.AsyncClient(timeout=settings.api_poll_timeout),
2425
settings,
2526
)
27+
app = FastAPI()
2628

2729

2830
@app.exception_handler(FlagsmithUnknownKeyError)
@@ -77,7 +79,7 @@ async def identity(
7779
@repeat_every(
7880
seconds=settings.api_poll_frequency,
7981
raise_exceptions=True,
80-
logger=logging.getLogger(__name__),
82+
logger=structlog.get_logger(__name__),
8183
)
8284
async def refresh_cache():
8385
await environment_service.refresh_environment_caches()

src/settings.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
11
import json
2+
import logging
3+
from enum import Enum
24
from pathlib import Path
35
from typing import Any, Dict, List, Tuple
46

57
from pydantic import BaseModel, BaseSettings, HttpUrl
68
from pydantic.env_settings import SettingsSourceCallable
79

810

11+
class LogFormat(Enum):
12+
GENERIC = "generic"
13+
JSON = "json"
14+
15+
16+
class LogLevel(Enum):
17+
CRITICAL = "CRITICAL"
18+
ERROR = "ERROR"
19+
WARNING = "WARNING"
20+
INFO = "INFO"
21+
DEBUG = "DEBUG"
22+
NOTSET = "NOTSET"
23+
24+
def to_logging_log_level(self) -> int:
25+
return getattr(logging, self.value)
26+
27+
928
def json_config_settings_source(settings: BaseSettings) -> Dict[str, Any]:
1029
"""
1130
A simple settings source that loads variables from a JSON file
@@ -31,13 +50,21 @@ class EndpointCachesSettings(BaseModel):
3150
identities: EndpointCacheSettings = EndpointCacheSettings(use_cache=False)
3251

3352

53+
class LoggingSettings(BaseModel):
54+
enable_access_log: bool = False
55+
log_format: LogFormat = LogFormat.GENERIC
56+
log_level: LogLevel = LogLevel.INFO
57+
log_event_field_name: str = "message"
58+
59+
3460
class Settings(BaseSettings):
3561
environment_key_pairs: List[EnvironmentKeyPair]
3662
api_url: HttpUrl = "https://edge.api.flagsmith.com/api/v1"
3763
api_poll_frequency: int = 10 # seconds
3864
api_poll_timeout: int = 5 # seconds
3965
endpoint_caches: EndpointCachesSettings | None = None
4066
allow_origins: List[str] = ["*"]
67+
logging: LoggingSettings = LoggingSettings()
4168

4269
class Config:
4370
@classmethod

0 commit comments

Comments
 (0)