alessandro trinca tornidor commited on
Commit
c7d60e1
1 Parent(s): fe47840

feat: change logger library to structlog

Browse files
.gitignore CHANGED
@@ -4,6 +4,7 @@ __pycache__
4
  *.pyc
5
  flagged/*
6
  .DS_Store
 
7
 
8
  # Created by https://www.toptal.com/developers/gitignore/api/pycharm+all
9
  # Edit at https://www.toptal.com/developers/gitignore?templates=pycharm+all
 
4
  *.pyc
5
  flagged/*
6
  .DS_Store
7
+ .env*
8
 
9
  # Created by https://www.toptal.com/developers/gitignore/api/pycharm+all
10
  # Edit at https://www.toptal.com/developers/gitignore?templates=pycharm+all
app.py CHANGED
@@ -1,34 +1,96 @@
1
  import logging
 
 
2
 
3
  import gradio as gr
 
4
  import uvicorn
5
- from fastapi import FastAPI
 
 
 
6
 
7
- import routes
8
- from helpers import formatters, session_logger
 
9
 
 
10
 
11
- session_logger.change_logging()
12
- app = FastAPI(title="fastapi_with_gradio...", version="1.0")
13
- logging.info("FastAPI app created, including routes...")
14
- app.include_router(routes.router)
15
- logging.info("routes included, creating gradio app")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
 
 
 
18
  CUSTOM_GRADIO_PATH = "/"
 
 
19
  def get_gradio_app():
20
  with gr.Blocks() as gradio_app:
 
21
  gr.Markdown(
22
  """
23
  # Hello World!
24
-
25
  Start typing below to _see_ the *output*.
26
 
27
  Here a [link](https://huggingface.co/spaces/aletrn/gradio_with_fastapi).
28
  """
29
  )
30
  btn = gr.Button(value="Divide et Impera")
31
- text_input = gr.Textbox(lines=1, placeholder="10", label="write an integer to divide 100; 0 will raise ZeroDivisionError")
 
32
  text_output = gr.Textbox(lines=1, placeholder=None, label="Text Output")
33
  gr.Examples(
34
  examples=[19, 1, -7, 0],
@@ -36,7 +98,7 @@ def get_gradio_app():
36
  )
37
  btn.click(
38
  formatters.request_formatter,
39
- inputs=[text_input],
40
  outputs=[text_output]
41
  )
42
  return gradio_app
@@ -44,9 +106,13 @@ def get_gradio_app():
44
 
45
  logging.info("mounting gradio app within FastAPI...")
46
  gradio_app_md = get_gradio_app()
 
47
  app = gr.mount_gradio_app(app, gradio_app_md, path=CUSTOM_GRADIO_PATH)
48
  logging.info("gradio app mounted")
49
 
50
-
51
- if __name__ == '__main__':
52
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
 
 
 
1
  import logging
2
+ import os
3
+ import time
4
 
5
  import gradio as gr
6
+ import structlog
7
  import uvicorn
8
+ from asgi_correlation_id import CorrelationIdMiddleware
9
+ from asgi_correlation_id.context import correlation_id
10
+ from fastapi import FastAPI, Request, Response, APIRouter
11
+ from uvicorn.protocols.utils import get_path_with_query_string
12
 
13
+ from helpers.structlog_setup import setup_logging
14
+ from helpers import formatters
15
+ from dotenv import load_dotenv
16
 
17
+ from routes import router
18
 
19
+ load_dotenv()
20
+
21
+ # LOG_JSON_FORMAT = parse_obj_as(bool, os.getenv("LOG_JSON_FORMAT", False))
22
+ LOG_JSON_FORMAT = bool(os.getenv("LOG_JSON_FORMAT", False))
23
+ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
24
+ setup_logging(json_logs=LOG_JSON_FORMAT, log_level=LOG_LEVEL)
25
+
26
+ access_logger = structlog.stdlib.get_logger(__name__)
27
+
28
+ app = FastAPI(title="Example API", version="1.0.0")
29
+
30
+
31
+ @app.middleware("http")
32
+ async def logging_middleware(request: Request, call_next) -> Response:
33
+ structlog.contextvars.clear_contextvars()
34
+ # These context vars will be added to all log entries emitted during the request
35
+ request_id = correlation_id.get()
36
+ # print(f"request_id:{request_id}.")
37
+ structlog.contextvars.bind_contextvars(request_id=request_id)
38
+
39
+ start_time = time.perf_counter_ns()
40
+ # If the call_next raises an error, we still want to return our own 500 response,
41
+ # so we can add headers to it (process time, request ID...)
42
+ response = Response(status_code=500)
43
+ try:
44
+ response = await call_next(request)
45
+ except Exception:
46
+ # TODO: Validate that we don't swallow exceptions (unit test?)
47
+ structlog.stdlib.get_logger("api.error").exception("Uncaught exception")
48
+ raise
49
+ finally:
50
+ process_time = time.perf_counter_ns() - start_time
51
+ status_code = response.status_code
52
+ url = get_path_with_query_string(request.scope)
53
+ client_host = request.client.host
54
+ client_port = request.client.port
55
+ http_method = request.method
56
+ http_version = request.scope["http_version"]
57
+ # Recreate the Uvicorn access log format, but add all parameters as structured information
58
+ access_logger.info(
59
+ f"""{client_host}:{client_port} - "{http_method} {url} HTTP/{http_version}" {status_code}""",
60
+ http={
61
+ "url": str(request.url),
62
+ "status_code": status_code,
63
+ "method": http_method,
64
+ "request_id": request_id,
65
+ "version": http_version,
66
+ },
67
+ network={"client": {"ip": client_host, "port": client_port}},
68
+ duration=process_time,
69
+ )
70
+ response.headers["X-Process-Time"] = str(process_time / 10 ** 9)
71
+ return response
72
 
73
 
74
+ app.include_router(router)
75
+ logging.info("routes included, creating gradio app")
76
  CUSTOM_GRADIO_PATH = "/"
77
+
78
+
79
  def get_gradio_app():
80
  with gr.Blocks() as gradio_app:
81
+ access_logger.info("start gradio app building...")
82
  gr.Markdown(
83
  """
84
  # Hello World!
85
+
86
  Start typing below to _see_ the *output*.
87
 
88
  Here a [link](https://huggingface.co/spaces/aletrn/gradio_with_fastapi).
89
  """
90
  )
91
  btn = gr.Button(value="Divide et Impera")
92
+ text_input = gr.Textbox(lines=1, placeholder="10",
93
+ label="write an integer to divide 100; 0 will raise ZeroDivisionError")
94
  text_output = gr.Textbox(lines=1, placeholder=None, label="Text Output")
95
  gr.Examples(
96
  examples=[19, 1, -7, 0],
 
98
  )
99
  btn.click(
100
  formatters.request_formatter,
101
+ inputs=[text_input, ],
102
  outputs=[text_output]
103
  )
104
  return gradio_app
 
106
 
107
  logging.info("mounting gradio app within FastAPI...")
108
  gradio_app_md = get_gradio_app()
109
+ app.add_middleware(CorrelationIdMiddleware)
110
  app = gr.mount_gradio_app(app, gradio_app_md, path=CUSTOM_GRADIO_PATH)
111
  logging.info("gradio app mounted")
112
 
113
+ if __name__ == "__main__":
114
+ try:
115
+ uvicorn.run("app:app", host="127.0.0.1", port=7860, log_config=None, reload=True)
116
+ except Exception as ex:
117
+ logging.error(f"ex:{ex}.")
118
+ raise ex
helpers/formatters.py CHANGED
@@ -1,30 +1,30 @@
1
- import logging
2
  from fastapi import HTTPException
3
 
4
- from helpers import session_logger
5
 
6
 
7
  def divide(a: int, b: int) -> float:
8
- logging.info(f"a:{a}, b:{b}.")
9
  result = a / b
10
- logging.info(f"result:{result}.")
11
  return result
12
 
13
 
14
- @session_logger.set_uuid_logging
15
  def request_formatter(text: str) -> dict:
16
- logging.info("start request...")
17
  try:
18
- logging.info(f"input text we need to treat as an integer: {text}.")
19
  b = int(text)
20
  transformed_text = f"input after integer cast: {b}."
21
  try:
22
  result_division = divide(100, b)
23
- logging.info(f"some_function, result_division:{result_division}.")
24
  return {"text": transformed_text, "result": result_division}
25
  except ZeroDivisionError as zde:
26
- logging.info(f"exception:{zde}.")
27
  raise HTTPException(status_code=500, detail="Internal server error")
28
  except ValueError as ve:
29
- logging.info(f"exception:{ve}.")
30
  raise HTTPException(status_code=500, detail="Internal server error")
 
1
+ import structlog
2
  from fastapi import HTTPException
3
 
4
+ app_logger = structlog.stdlib.get_logger(__name__)
5
 
6
 
7
  def divide(a: int, b: int) -> float:
8
+ app_logger.info(f"a:{a}, b:{b}.")
9
  result = a / b
10
+ app_logger.info(f"result:{result}.")
11
  return result
12
 
13
 
14
+ # @session_logger.set_uuid_logging
15
  def request_formatter(text: str) -> dict:
16
+ app_logger.info("start request...")
17
  try:
18
+ app_logger.info(f"input text we need to treat as an integer: {text}.")
19
  b = int(text)
20
  transformed_text = f"input after integer cast: {b}."
21
  try:
22
  result_division = divide(100, b)
23
+ app_logger.info(f"some_function, result_division:{result_division}.")
24
  return {"text": transformed_text, "result": result_division}
25
  except ZeroDivisionError as zde:
26
+ app_logger.info(f"exception:{zde}.")
27
  raise HTTPException(status_code=500, detail="Internal server error")
28
  except ValueError as ve:
29
+ app_logger.info(f"exception:{ve}.")
30
  raise HTTPException(status_code=500, detail="Internal server error")
helpers/structlog_setup.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import sys
3
+
4
+ import structlog
5
+ from structlog.types import EventDict, Processor
6
+
7
+
8
+ # https://github.com/hynek/structlog/issues/35#issuecomment-591321744
9
+ def rename_event_key(_, __, event_dict: EventDict) -> EventDict:
10
+ """
11
+ Log entries keep the text message in the `event` field, but Datadog
12
+ uses the `message` field. This processor moves the value from one field to
13
+ the other.
14
+ See https://github.com/hynek/structlog/issues/35#issuecomment-591321744
15
+ """
16
+ event_dict["message"] = event_dict.pop("event")
17
+ return event_dict
18
+
19
+
20
+ def drop_color_message_key(_, __, event_dict: EventDict) -> EventDict:
21
+ """
22
+ Uvicorn logs the message a second time in the extra `color_message`, but we don't
23
+ need it. This processor drops the key from the event dict if it exists.
24
+ """
25
+ event_dict.pop("color_message", None)
26
+ return event_dict
27
+
28
+
29
+ def setup_logging(json_logs: bool = False, log_level: str = "INFO"):
30
+ timestamper = structlog.processors.TimeStamper(fmt="iso")
31
+
32
+ shared_processors: list[Processor] = [
33
+ structlog.contextvars.merge_contextvars,
34
+ structlog.stdlib.add_logger_name,
35
+ structlog.stdlib.add_log_level,
36
+ structlog.stdlib.PositionalArgumentsFormatter(),
37
+ structlog.stdlib.ExtraAdder(),
38
+ drop_color_message_key,
39
+ timestamper,
40
+ structlog.processors.StackInfoRenderer(),
41
+ # adapted from https://www.structlog.org/en/stable/standard-library.html
42
+ # If the "exc_info" key in the event dict is either true or a
43
+ # sys.exc_info() tuple, remove "exc_info" and render the exception
44
+ # with traceback into the "exception" key.
45
+ structlog.processors.format_exc_info,
46
+ # If some value is in bytes, decode it to a Unicode str.
47
+ structlog.processors.UnicodeDecoder(),
48
+ # Add callsite parameters.
49
+ structlog.processors.CallsiteParameterAdder(
50
+ {
51
+ structlog.processors.CallsiteParameter.FUNC_NAME,
52
+ structlog.processors.CallsiteParameter.LINENO,
53
+ }
54
+ ),
55
+ # Render the final event dict as JSON.
56
+ ]
57
+
58
+ if json_logs:
59
+ # We rename the `event` key to `message` only in JSON logs, as Datadog looks for the
60
+ # `message` key but the pretty ConsoleRenderer looks for `event`
61
+ shared_processors.append(rename_event_key)
62
+ # Format the exception only for JSON logs, as we want to pretty-print them when
63
+ # using the ConsoleRenderer
64
+ shared_processors.append(structlog.processors.format_exc_info)
65
+
66
+ structlog.configure(
67
+ processors=shared_processors
68
+ + [
69
+ # Prepare event dict for `ProcessorFormatter`.
70
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
71
+ ],
72
+ logger_factory=structlog.stdlib.LoggerFactory(),
73
+ cache_logger_on_first_use=True,
74
+ )
75
+
76
+ log_renderer: structlog.types.Processor
77
+ if json_logs:
78
+ log_renderer = structlog.processors.JSONRenderer()
79
+ else:
80
+ log_renderer = structlog.dev.ConsoleRenderer()
81
+
82
+ formatter = structlog.stdlib.ProcessorFormatter(
83
+ # These run ONLY on `logging` entries that do NOT originate within
84
+ # structlog.
85
+ foreign_pre_chain=shared_processors,
86
+ # These run on ALL entries after the pre_chain is done.
87
+ processors=[
88
+ # Remove _record & _from_structlog.
89
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
90
+ log_renderer,
91
+ ],
92
+ )
93
+
94
+ handler = logging.StreamHandler()
95
+ # Use OUR `ProcessorFormatter` to format all `logging` entries.
96
+ handler.setFormatter(formatter)
97
+ root_logger = logging.getLogger()
98
+ root_logger.addHandler(handler)
99
+ root_logger.setLevel(log_level.upper())
100
+
101
+ for _log in ["uvicorn", "uvicorn.error"]:
102
+ # Clear the log handlers for uvicorn loggers, and enable propagation
103
+ # so the messages are caught by our root logger and formatted correctly
104
+ # by structlog
105
+ logging.getLogger(_log).handlers.clear()
106
+ logging.getLogger(_log).propagate = True
107
+
108
+ # Since we re-create the access logs ourselves, to add all information
109
+ # in the structured log (see the `logging_middleware` in main.py), we clear
110
+ # the handlers and prevent the logs to propagate to a logger higher up in the
111
+ # hierarchy (effectively rendering them silent).
112
+ logging.getLogger("uvicorn.access").handlers.clear()
113
+ logging.getLogger("uvicorn.access").propagate = False
114
+
115
+ def handle_exception(exc_type, exc_value, exc_traceback):
116
+ """
117
+ Log any uncaught exception instead of letting it be printed by Python
118
+ (but leave KeyboardInterrupt untouched to allow users to Ctrl+C to stop)
119
+ See https://stackoverflow.com/a/16993115/3641865
120
+ """
121
+ if issubclass(exc_type, KeyboardInterrupt):
122
+ sys.__excepthook__(exc_type, exc_value, exc_traceback)
123
+ return
124
+
125
+ root_logger.error(
126
+ "Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)
127
+ )
128
+
129
+ sys.excepthook = handle_exception
helpers/uvicorn_disable_logging.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": 1,
3
+ "disable_existing_loggers": false,
4
+ "formatters": {
5
+ "default": {
6
+ "()": "uvicorn.logging.DefaultFormatter",
7
+ "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
8
+ },
9
+ "access": {
10
+ "()": "uvicorn.logging.AccessFormatter",
11
+ "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
12
+ }
13
+ },
14
+ "handlers": {
15
+ "default": {
16
+ "formatter": "default",
17
+ "class": "logging.NullHandler"
18
+ },
19
+ "access": {
20
+ "formatter": "access",
21
+ "class": "logging.NullHandler"
22
+ }
23
+ },
24
+ "loggers": {
25
+ "uvicorn.error": {
26
+ "level": "INFO",
27
+ "handlers": [
28
+ "default"
29
+ ],
30
+ "propagate": false
31
+ },
32
+ "uvicorn.access": {
33
+ "level": "INFO",
34
+ "handlers": [
35
+ "access"
36
+ ],
37
+ "propagate": false
38
+ }
39
+ }
40
+ }
requirements.txt CHANGED
@@ -1,2 +1,5 @@
1
  fastapi==0.110.2
2
  gradio==4.32.2
 
 
 
 
1
  fastapi==0.110.2
2
  gradio==4.32.2
3
+ python-dotenv==1.0.1
4
+ structlog==24.4.0
5
+ uvicorn==0.30.1
routes.py CHANGED
@@ -1,20 +1,17 @@
1
- import json
2
- import logging
3
 
 
4
  from fastapi import APIRouter
5
 
6
- from helpers import session_logger
7
 
8
 
 
9
  router = APIRouter()
10
 
11
 
12
  @router.get("/health")
13
- @session_logger.set_uuid_logging
14
- def health() -> str:
15
- try:
16
- logging.info("health check")
17
- return json.dumps({"msg": "still alive..."})
18
- except Exception as e:
19
- logging.error(f"exception:{e}.")
20
- return json.dumps({"msg": "request failed"})
 
1
+ import random
 
2
 
3
+ import structlog
4
  from fastapi import APIRouter
5
 
6
+ from helpers import formatters
7
 
8
 
9
+ custom_structlog_logger = structlog.stdlib.get_logger(__name__)
10
  router = APIRouter()
11
 
12
 
13
  @router.get("/health")
14
+ def health():
15
+ n = random.randint(-1, 1)
16
+ custom_structlog_logger.info(f"random int:{n}")
17
+ return formatters.request_formatter(f"{n}")