Spaces:
Running
Running
alessandro trinca tornidor
commited on
Commit
•
c7d60e1
1
Parent(s):
fe47840
feat: change logger library to structlog
Browse files- .gitignore +1 -0
- app.py +80 -14
- helpers/formatters.py +10 -10
- helpers/structlog_setup.py +129 -0
- helpers/uvicorn_disable_logging.json +40 -0
- requirements.txt +3 -0
- routes.py +8 -11
.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
|
|
|
|
|
|
|
6 |
|
7 |
-
import
|
8 |
-
from helpers import formatters
|
|
|
9 |
|
|
|
10 |
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
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 |
-
|
52 |
-
|
|
|
|
|
|
|
|
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
|
2 |
from fastapi import HTTPException
|
3 |
|
4 |
-
|
5 |
|
6 |
|
7 |
def divide(a: int, b: int) -> float:
|
8 |
-
|
9 |
result = a / b
|
10 |
-
|
11 |
return result
|
12 |
|
13 |
|
14 |
-
@session_logger.set_uuid_logging
|
15 |
def request_formatter(text: str) -> dict:
|
16 |
-
|
17 |
try:
|
18 |
-
|
19 |
b = int(text)
|
20 |
transformed_text = f"input after integer cast: {b}."
|
21 |
try:
|
22 |
result_division = divide(100, b)
|
23 |
-
|
24 |
return {"text": transformed_text, "result": result_division}
|
25 |
except ZeroDivisionError as zde:
|
26 |
-
|
27 |
raise HTTPException(status_code=500, detail="Internal server error")
|
28 |
except ValueError as ve:
|
29 |
-
|
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
|
2 |
-
import logging
|
3 |
|
|
|
4 |
from fastapi import APIRouter
|
5 |
|
6 |
-
from helpers import
|
7 |
|
8 |
|
|
|
9 |
router = APIRouter()
|
10 |
|
11 |
|
12 |
@router.get("/health")
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
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}")
|
|
|
|
|
|
|
|