add structlog

This commit is contained in:
Waylon S. Walker 2024-10-16 20:46:11 -05:00
parent 7f0934ac14
commit f64e488ab1
7 changed files with 323 additions and 36 deletions

View file

@ -30,10 +30,12 @@ dependencies = [
"jinja2>=3.1.4", "jinja2>=3.1.4",
"markdown>=3.7", "markdown>=3.7",
"pillow>=10.4.0", "pillow>=10.4.0",
"pydantic-settings>=2.5.2",
"pydyf==0.8.0", "pydyf==0.8.0",
"python-levenshtein>=0.25.1", "python-levenshtein>=0.25.1",
"rich>=13.9.2", "rich>=13.9.2",
"selenium>=4.25.0", "selenium>=4.25.0",
"structlog>=24.4.0",
"uvicorn>=0.31.1", "uvicorn>=0.31.1",
"weasyprint>=61.2", "weasyprint>=61.2",
] ]

View file

@ -0,0 +1,114 @@
# logging_config.py
import logging
import structlog
import sys
from structlog.dev import ConsoleRenderer
from structlog.processors import JSONRenderer
from rich.traceback import install
logger = structlog.get_logger()
install(show_locals=True)
def configure_logging_one(dev_mode: bool = True):
"""Configure structlog based on the mode (dev or prod)."""
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=logging.DEBUG if dev_mode else logging.INFO,
)
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"), # Add timestamps
structlog.stdlib.add_log_level, # Add log levels
structlog.processors.StackInfoRenderer(), # Render stack info
structlog.processors.format_exc_info, # Format exceptions
# structlog.dev.RichTracebackFormatter(),
ConsoleRenderer(
# exception_formatter=structlog.dev.rich_traceback
)
if dev_mode
else JSONRenderer(), # Render logs nicely for dev
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
import logging
from fastapi_dynamic_response.settings import Settings
import structlog
def configure_logging_two():
settings = Settings()
# Clear existing loggers
logging.config.dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
}
)
if settings.ENV == "local":
# Local development logging configuration
processors = [
# structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer(colors=False),
]
logging_level = logging.DEBUG
# Enable rich tracebacks
from rich.traceback import install
install(show_locals=True)
# Use RichHandler for pretty console logs
from rich.logging import RichHandler
logging.basicConfig(
level=logging_level,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler()],
)
else:
# Production logging configuration
processors = [
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
]
logging_level = logging.INFO
# Standard logging configuration
logging.basicConfig(
format="%(message)s",
level=logging_level,
handlers=[logging.StreamHandler()],
)
structlog.configure(
processors=processors,
wrapper_class=structlog.make_filtering_bound_logger(logging_level),
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
# Redirect uvicorn loggers to structlog
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
logger = logging.getLogger(logger_name)
logger.handlers = []
logger.propagate = True
configure_logging = configure_logging_two

View file

@ -7,14 +7,19 @@ from fastapi_dynamic_response.base.router import router as base_router
from fastapi_dynamic_response.dependencies import get_content_type from fastapi_dynamic_response.dependencies import get_content_type
from fastapi_dynamic_response.middleware import ( from fastapi_dynamic_response.middleware import (
Sitemap, Sitemap,
add_process_time_header,
catch_exceptions_middleware, catch_exceptions_middleware,
log_request_state, log_requests,
respond_based_on_content_type, respond_based_on_content_type,
set_prefers, set_prefers,
set_span_id, set_span_id,
) )
from fastapi_dynamic_response.zpages.router import router as zpages_router from fastapi_dynamic_response.zpages.router import router as zpages_router
from fastapi_dynamic_response.logging_config import configure_logging
configure_logging()
app = FastAPI( app = FastAPI(
title="FastAPI Dynamic Response", title="FastAPI Dynamic Response",
version=__version__, version=__version__,
@ -25,16 +30,23 @@ app = FastAPI(
# exception_handlers=exception_handlers, # exception_handlers=exception_handlers,
debug=True, debug=True,
dependencies=[ dependencies=[
Depends(set_prefers), # Depends(set_prefers),
Depends(set_span_id), # Depends(set_span_id),
Depends(log_request_state), # Depends(log_request_state),
], ],
) )
# configure_tracing(app)
app.include_router(zpages_router) app.include_router(zpages_router)
app.include_router(base_router) app.include_router(base_router)
app.middleware("http")(Sitemap(app))
app.middleware("http")(catch_exceptions_middleware)
app.middleware("http")(respond_based_on_content_type) app.middleware("http")(respond_based_on_content_type)
app.middleware("http")(add_process_time_header)
app.middleware("http")(log_requests)
app.middleware("http")(Sitemap(app))
app.middleware("http")(set_prefers)
app.middleware("http")(set_span_id)
app.middleware("http")(catch_exceptions_middleware)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")

View file

@ -1,6 +1,7 @@
from difflib import get_close_matches from difflib import get_close_matches
from io import BytesIO from io import BytesIO
import json import json
import time
import traceback import traceback
from typing import Any, Dict from typing import Any, Dict
from uuid import uuid4 from uuid import uuid4
@ -23,6 +24,10 @@ import base64
from fastapi_dynamic_response.constant import ACCEPT_TYPES from fastapi_dynamic_response.constant import ACCEPT_TYPES
from fastapi_dynamic_response.globals import templates from fastapi_dynamic_response.globals import templates
import structlog
logger = structlog.get_logger()
console = Console() console = Console()
@ -36,6 +41,13 @@ class Prefers(BaseModel):
png: bool = False png: bool = False
pdf: bool = False pdf: bool = False
def __repr__(self):
_repr = []
for key, value in self.dict().items():
if value:
_repr.append(key + "=True")
return f'Prefers({", ".join(_repr)})'
@property @property
def textlike(self) -> bool: def textlike(self) -> bool:
return self.rtf or self.text or self.markdown return self.rtf or self.text or self.markdown
@ -63,13 +75,29 @@ def log_request_state(request: Request):
console.log(request.state.prefers) console.log(request.state.prefers)
def set_span_id(request: Request): async def add_process_time_header(request: Request, call_next):
start_time = time.perf_counter()
response = await call_next(request)
process_time = time.perf_counter() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
async def set_span_id(request: Request, call_next):
span_id = uuid4() span_id = uuid4()
request.state.span_id = span_id request.state.span_id = span_id
request.state.bound_logger = logger.bind(span_id=request.state.span_id)
response = await call_next(request)
response.headers["x-request-id"] = str(span_id)
response.headers["x-span-id"] = str(span_id)
return response
def set_prefers( def set_prefers(
request: Request, request: Request,
call_next,
): ):
content_type = ( content_type = (
request.query_params.get( request.query_params.get(
@ -93,24 +121,31 @@ def set_prefers(
user_agent = request.headers.get("user-agent", "").lower() user_agent = request.headers.get("user-agent", "").lower()
if hx_request_header == "true": if hx_request_header == "true":
request.state.prefers = Prefers(html=True, partial=True) content_type = "text/html-partial"
return # request.state.prefers = Prefers(html=True, partial=True)
# content_type = "text/html"
if is_browser_request(user_agent) and content_type is None: elif is_browser_request(user_agent) and content_type is None:
content_type = "text/html" content_type = "text/html"
elif is_rtf_request(user_agent) and content_type is None: elif is_rtf_request(user_agent) and content_type is None:
content_type = "text/rtf" content_type = "text/rtf"
elif content_type is None: else:
content_type = "application/json" content_type = "application/json"
partial = "partial" in content_type
# if content_type in ACCEPT_TYPES: # if content_type in ACCEPT_TYPES:
for accept_type, accept_value in ACCEPT_TYPES.items(): for accept_type, accept_value in ACCEPT_TYPES.items():
if accept_type in content_type: if accept_type in content_type:
request.state.prefers = Prefers(**{accept_value: True}) request.state.prefers = Prefers(**{accept_value: True}, partial=partial)
return
request.state.prefers = Prefers(JSON=True, partial=False) request.state.content_type = content_type
request.state.bound_logger = request.state.bound_logger.bind(
# content_type=request.state.content_type,
prefers=request.state.prefers,
)
return call_next(request)
class Sitemap: class Sitemap:
@ -318,7 +353,7 @@ async def respond_based_on_content_type(request: Request, call_next):
if str(response.status_code)[0] not in "123": if str(response.status_code)[0] not in "123":
return response return response
return await handle_response(request, data) return await handle_response(request, response, data)
# except TemplateNotFound: # except TemplateNotFound:
# return HTMLResponse(content="Template Not Found ", status_code=404) # return HTMLResponse(content="Template Not Found ", status_code=404)
except StarletteHTTPException as exc: except StarletteHTTPException as exc:
@ -333,45 +368,55 @@ async def respond_based_on_content_type(request: Request, call_next):
return HTMLResponse(content=f"Internal Server Error: {e!s}", status_code=500) return HTMLResponse(content=f"Internal Server Error: {e!s}", status_code=500)
async def handle_response(request: Request, data: str): async def handle_response(request: Request, response: Response, data: str):
json_data = json.loads(data) json_data = json.loads(data)
template_name = getattr(request.state, "template_name", "default_template.html") template_name = getattr(request.state, "template_name", "default_template.html")
if request.state.prefers.partial: if request.state.prefers.partial:
template_name = "partial_" + template_name template_name = "partial_" + template_name
content_type = request.state.prefers
if request.state.prefers.JSON: if request.state.prefers.JSON:
return JSONResponse(content=json_data) return JSONResponse(
content=json_data,
elif request.state.prefers.html:
return templates.TemplateResponse(
template_name, {"request": request, "data": json_data}
) )
elif request.state.prefers.markdown: if request.state.prefers.html:
return templates.TemplateResponse(
template_name,
{"request": request, "data": json_data},
headers=response.headers,
)
if request.state.prefers.markdown:
import html2text import html2text
template = templates.get_template(template_name) template = templates.get_template(template_name)
html_content = template.render(data=json_data) html_content = template.render(data=json_data)
markdown_content = html2text.html2text(html_content) markdown_content = html2text.html2text(html_content)
return PlainTextResponse(content=markdown_content) return PlainTextResponse(content=markdown_content, headers=response.headers)
elif request.state.prefers.text: if request.state.prefers.text:
plain_text_content = format_json_as_plain_text(json_data) plain_text_content = format_json_as_plain_text(json_data)
return PlainTextResponse(content=plain_text_content) return PlainTextResponse(
content=plain_text_content,
)
elif request.state.prefers.rtf: if request.state.prefers.rtf:
rich_text_content = format_json_as_rich_text(json_data, template_name) rich_text_content = format_json_as_rich_text(json_data, template_name)
return PlainTextResponse(content=rich_text_content) return PlainTextResponse(
content=rich_text_content,
)
elif request.state.prefers.png: if request.state.prefers.png:
template = templates.get_template(template_name) template = templates.get_template(template_name)
html_content = template.render(data=json_data) html_content = template.render(data=json_data)
screenshot = get_screenshot(html_content) screenshot = get_screenshot(html_content)
return Response(content=screenshot.getvalue(), media_type="image/png") return Response(
content=screenshot.getvalue(),
media_type="image/png",
)
elif request.state.prefers.pdf: if request.state.prefers.pdf:
template = templates.get_template(template_name) template = templates.get_template(template_name)
html_content = template.render(data=json_data) html_content = template.render(data=json_data)
scale = float( scale = float(
@ -380,6 +425,42 @@ async def handle_response(request: Request, data: str):
console.log(f"Scale: {scale}") console.log(f"Scale: {scale}")
pdf = get_pdf(html_content, scale) pdf = get_pdf(html_content, scale)
return Response(content=pdf, media_type="application/pdf") return Response(
content=pdf,
media_type="application/pdf",
)
return JSONResponse(content=json_data) return JSONResponse(
content=json_data,
)
# Initialize the logger
async def log_requests(request: Request, call_next):
# Log request details
request.state.bound_logger.info(
"Request received",
# span_id=request.state.span_id,
method=request.method,
path=request.url.path,
# headers=dict(request.headers),
# prefers=request.state.prefers,
)
# logger.info(
# headers=dict(request.headers),
# prefers=request.state.prefers,
# )
# Process the request
response = await call_next(request)
# Log response details
# logger.info(
# "Response sent",
# span_id=request.state.span_id,
# method=request.method,
# status_code=response.status_code,
# headers=dict(response.headers),
# )
return response

View file

@ -0,0 +1,8 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
ENV: str = "local"
class Config:
env_file = "config.env"

View file

@ -0,0 +1,35 @@
# tracing.py
from fastapi_dynamic_response.settings import Settings
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# from opentelemetry.exporter.richconsole import RichConsoleSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
def configure_tracing(app):
settings = Settings()
trace.set_tracer_provider(TracerProvider())
tracer_provider = trace.get_tracer_provider()
if settings.ENV == "local":
# Use console exporter for local development
# span_exporter = RichConsoleSpanExporter()
# span_processor = SimpleSpanProcessor(span_exporter)
# span_exporter = OTLPSpanExporter()
span_exporter = OTLPSpanExporter(
endpoint="http://localhost:4317", insecure=True
)
span_processor = BatchSpanProcessor(span_exporter)
else:
# Use OTLP exporter for production
span_exporter = OTLPSpanExporter()
span_processor = BatchSpanProcessor(span_exporter)
tracer_provider.add_span_processor(span_processor)
# Instrument FastAPI
FastAPIInstrumentor.instrument_app(app)

41
uv.lock generated
View file

@ -294,10 +294,12 @@ dependencies = [
{ name = "jinja2" }, { name = "jinja2" },
{ name = "markdown" }, { name = "markdown" },
{ name = "pillow" }, { name = "pillow" },
{ name = "pydantic-settings" },
{ name = "pydyf" }, { name = "pydyf" },
{ name = "python-levenshtein" }, { name = "python-levenshtein" },
{ name = "rich" }, { name = "rich" },
{ name = "selenium" }, { name = "selenium" },
{ name = "structlog" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "weasyprint" }, { name = "weasyprint" },
] ]
@ -309,10 +311,12 @@ requires-dist = [
{ name = "jinja2", specifier = ">=3.1.4" }, { name = "jinja2", specifier = ">=3.1.4" },
{ name = "markdown", specifier = ">=3.7" }, { name = "markdown", specifier = ">=3.7" },
{ name = "pillow", specifier = ">=10.4.0" }, { name = "pillow", specifier = ">=10.4.0" },
{ name = "pydantic-settings", specifier = ">=2.5.2" },
{ name = "pydyf", specifier = "==0.8.0" }, { name = "pydyf", specifier = "==0.8.0" },
{ name = "python-levenshtein", specifier = ">=0.25.1" }, { name = "python-levenshtein", specifier = ">=0.25.1" },
{ name = "rich", specifier = ">=13.9.2" }, { name = "rich", specifier = ">=13.9.2" },
{ name = "selenium", specifier = ">=4.25.0" }, { name = "selenium", specifier = ">=4.25.0" },
{ name = "structlog", specifier = ">=24.4.0" },
{ name = "uvicorn", specifier = ">=0.31.1" }, { name = "uvicorn", specifier = ">=0.31.1" },
{ name = "weasyprint", specifier = ">=61.2" }, { name = "weasyprint", specifier = ">=61.2" },
] ]
@ -418,14 +422,14 @@ wheels = [
[[package]] [[package]]
name = "importlib-metadata" name = "importlib-metadata"
version = "8.5.0" version = "8.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "zipp", marker = "python_full_version < '3.13'" }, { name = "zipp", marker = "python_full_version < '3.13'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } sdist = { url = "https://files.pythonhosted.org/packages/c0/bd/fa8ce65b0a7d4b6d143ec23b0f5fd3f7ab80121078c465bc02baeaab22dc/importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5", size = 54320 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 }, { url = "https://files.pythonhosted.org/packages/c0/14/362d31bf1076b21e1bcdcb0dc61944822ff263937b804a79231df2774d28/importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1", size = 26269 },
] ]
[[package]] [[package]]
@ -852,6 +856,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/d9/1d7ecb98318da4cb96986daaf0e20d66f1651d0aeb9e2d4435b916ce031d/pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", size = 1920855 }, { url = "https://files.pythonhosted.org/packages/1d/d9/1d7ecb98318da4cb96986daaf0e20d66f1651d0aeb9e2d4435b916ce031d/pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", size = 1920855 },
] ]
[[package]]
name = "pydantic-settings"
version = "2.5.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/27/0bed9dd26b93328b60a1402febc780e7be72b42847fa8b5c94b7d0aeb6d1/pydantic_settings-2.5.2.tar.gz", hash = "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0", size = 70938 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/8d/29e82e333f32d9e2051c10764b906c2a6cd140992910b5f49762790911ba/pydantic_settings-2.5.2-py3-none-any.whl", hash = "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907", size = 26864 },
]
[[package]] [[package]]
name = "pydyf" name = "pydyf"
version = "0.8.0" version = "0.8.0"
@ -888,6 +905,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 }, { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 },
] ]
[[package]]
name = "python-dotenv"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
]
[[package]] [[package]]
name = "python-levenshtein" name = "python-levenshtein"
version = "0.25.1" version = "0.25.1"
@ -1086,6 +1112,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/9c/93f7bc03ff03199074e81974cc148908ead60dcf189f68ba1761a0ee35cf/starlette-0.38.6-py3-none-any.whl", hash = "sha256:4517a1409e2e73ee4951214ba012052b9e16f60e90d73cfb06192c19203bbb05", size = 71451 }, { url = "https://files.pythonhosted.org/packages/b7/9c/93f7bc03ff03199074e81974cc148908ead60dcf189f68ba1761a0ee35cf/starlette-0.38.6-py3-none-any.whl", hash = "sha256:4517a1409e2e73ee4951214ba012052b9e16f60e90d73cfb06192c19203bbb05", size = 71451 },
] ]
[[package]]
name = "structlog"
version = "24.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/a3/e811a94ac3853826805253c906faa99219b79951c7d58605e89c79e65768/structlog-24.4.0.tar.gz", hash = "sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4", size = 1348634 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/65/813fc133609ebcb1299be6a42e5aea99d6344afb35ccb43f67e7daaa3b92/structlog-24.4.0-py3-none-any.whl", hash = "sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610", size = 67180 },
]
[[package]] [[package]]
name = "tinycss2" name = "tinycss2"
version = "1.3.0" version = "1.3.0"