diff --git a/pyproject.toml b/pyproject.toml index 55f933f..520799c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,10 +30,12 @@ dependencies = [ "jinja2>=3.1.4", "markdown>=3.7", "pillow>=10.4.0", + "pydantic-settings>=2.5.2", "pydyf==0.8.0", "python-levenshtein>=0.25.1", "rich>=13.9.2", "selenium>=4.25.0", + "structlog>=24.4.0", "uvicorn>=0.31.1", "weasyprint>=61.2", ] diff --git a/src/fastapi_dynamic_response/logging_config.py b/src/fastapi_dynamic_response/logging_config.py new file mode 100644 index 0000000..cc46f54 --- /dev/null +++ b/src/fastapi_dynamic_response/logging_config.py @@ -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 diff --git a/src/fastapi_dynamic_response/main.py b/src/fastapi_dynamic_response/main.py index b14b1b6..bbe1c2f 100644 --- a/src/fastapi_dynamic_response/main.py +++ b/src/fastapi_dynamic_response/main.py @@ -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.middleware import ( Sitemap, + add_process_time_header, catch_exceptions_middleware, - log_request_state, + log_requests, respond_based_on_content_type, set_prefers, set_span_id, ) from fastapi_dynamic_response.zpages.router import router as zpages_router +from fastapi_dynamic_response.logging_config import configure_logging + + +configure_logging() app = FastAPI( title="FastAPI Dynamic Response", version=__version__, @@ -25,16 +30,23 @@ app = FastAPI( # exception_handlers=exception_handlers, debug=True, dependencies=[ - Depends(set_prefers), - Depends(set_span_id), - Depends(log_request_state), + # Depends(set_prefers), + # Depends(set_span_id), + # Depends(log_request_state), ], ) + +# configure_tracing(app) + app.include_router(zpages_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")(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") diff --git a/src/fastapi_dynamic_response/middleware.py b/src/fastapi_dynamic_response/middleware.py index 6b73daf..ac69cc9 100644 --- a/src/fastapi_dynamic_response/middleware.py +++ b/src/fastapi_dynamic_response/middleware.py @@ -1,6 +1,7 @@ from difflib import get_close_matches from io import BytesIO import json +import time import traceback from typing import Any, Dict from uuid import uuid4 @@ -23,6 +24,10 @@ import base64 from fastapi_dynamic_response.constant import ACCEPT_TYPES from fastapi_dynamic_response.globals import templates +import structlog + +logger = structlog.get_logger() + console = Console() @@ -36,6 +41,13 @@ class Prefers(BaseModel): png: 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 def textlike(self) -> bool: return self.rtf or self.text or self.markdown @@ -63,13 +75,29 @@ def log_request_state(request: Request): 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() 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( request: Request, + call_next, ): content_type = ( request.query_params.get( @@ -93,24 +121,31 @@ def set_prefers( user_agent = request.headers.get("user-agent", "").lower() if hx_request_header == "true": - request.state.prefers = Prefers(html=True, partial=True) - return + content_type = "text/html-partial" + # 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" elif is_rtf_request(user_agent) and content_type is None: content_type = "text/rtf" - elif content_type is None: + else: content_type = "application/json" + partial = "partial" in content_type # if content_type in ACCEPT_TYPES: for accept_type, accept_value in ACCEPT_TYPES.items(): if accept_type in content_type: - request.state.prefers = Prefers(**{accept_value: True}) - return - request.state.prefers = Prefers(JSON=True, partial=False) + request.state.prefers = Prefers(**{accept_value: True}, partial=partial) + + 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: @@ -318,7 +353,7 @@ async def respond_based_on_content_type(request: Request, call_next): if str(response.status_code)[0] not in "123": return response - return await handle_response(request, data) + return await handle_response(request, response, data) # except TemplateNotFound: # return HTMLResponse(content="Template Not Found ", status_code=404) 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) -async def handle_response(request: Request, data: str): +async def handle_response(request: Request, response: Response, data: str): json_data = json.loads(data) template_name = getattr(request.state, "template_name", "default_template.html") if request.state.prefers.partial: template_name = "partial_" + template_name - content_type = request.state.prefers if request.state.prefers.JSON: - return JSONResponse(content=json_data) - - elif request.state.prefers.html: - return templates.TemplateResponse( - template_name, {"request": request, "data": json_data} + return JSONResponse( + content=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 template = templates.get_template(template_name) html_content = template.render(data=json_data) 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) - 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) - 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) html_content = template.render(data=json_data) 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) html_content = template.render(data=json_data) scale = float( @@ -380,6 +425,42 @@ async def handle_response(request: Request, data: str): console.log(f"Scale: {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 diff --git a/src/fastapi_dynamic_response/settings.py b/src/fastapi_dynamic_response/settings.py new file mode 100644 index 0000000..81fd81a --- /dev/null +++ b/src/fastapi_dynamic_response/settings.py @@ -0,0 +1,8 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + ENV: str = "local" + + class Config: + env_file = "config.env" diff --git a/src/fastapi_dynamic_response/tracing.py b/src/fastapi_dynamic_response/tracing.py new file mode 100644 index 0000000..d73fa73 --- /dev/null +++ b/src/fastapi_dynamic_response/tracing.py @@ -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) diff --git a/uv.lock b/uv.lock index e9df9c5..85686bb 100644 --- a/uv.lock +++ b/uv.lock @@ -294,10 +294,12 @@ dependencies = [ { name = "jinja2" }, { name = "markdown" }, { name = "pillow" }, + { name = "pydantic-settings" }, { name = "pydyf" }, { name = "python-levenshtein" }, { name = "rich" }, { name = "selenium" }, + { name = "structlog" }, { name = "uvicorn" }, { name = "weasyprint" }, ] @@ -309,10 +311,12 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1.4" }, { name = "markdown", specifier = ">=3.7" }, { name = "pillow", specifier = ">=10.4.0" }, + { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "pydyf", specifier = "==0.8.0" }, { name = "python-levenshtein", specifier = ">=0.25.1" }, { name = "rich", specifier = ">=13.9.2" }, { name = "selenium", specifier = ">=4.25.0" }, + { name = "structlog", specifier = ">=24.4.0" }, { name = "uvicorn", specifier = ">=0.31.1" }, { name = "weasyprint", specifier = ">=61.2" }, ] @@ -418,14 +422,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.5.0" +version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { 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 = [ - { 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]] @@ -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 }, ] +[[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]] name = "pydyf" 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 }, ] +[[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]] name = "python-levenshtein" 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 }, ] +[[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]] name = "tinycss2" version = "1.3.0"