Skip to content

Commit

Permalink
Merge pull request #12 from feteu/develop
Browse files Browse the repository at this point in the history
v1.0.5
  • Loading branch information
feteu authored Feb 15, 2025
2 parents 60e6f87 + ae3e27c commit 58b5fcb
Show file tree
Hide file tree
Showing 13 changed files with 925 additions and 171 deletions.
97 changes: 53 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[![PyPI - Downloads](https://img.shields.io/pypi/dm/asgi-request-duration.svg)](https://pypi.org/project/asgi-request-duration/)
[![PyPI - License](https://img.shields.io/pypi/l/asgi-request-duration)](https://www.gnu.org/licenses/gpl-3.0)
[![PyPI - Version](https://img.shields.io/pypi/v/asgi-request-duration.svg)](https://pypi.org/project/asgi-request-duration/)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/asgi-request-duration)](https://pypi.org/project/asgi-request-duration/)
Expand All @@ -12,19 +13,22 @@

ASGI Request Duration is a middleware for ASGI applications that measures the duration of HTTP requests and integrates this information into response headers and log records. This middleware is designed to be easy to integrate and configure, providing valuable insights into the performance of your ASGI application.

> **Note:** If you find this project useful, please consider giving it a star ⭐ on GitHub. This helps prioritize its maintenance and development. If you encounter any typos, bugs 🐛, or have new feature requests, feel free to open an issue. I will be happy to address them.
## Table of Contents 📚

- [Features](#features)
- [Installation](#installation)
- [Usage](#usage)
- [Middleware](#middleware)
- [Logging Filter](#logging-filter)
- [Configuration](#configuration)
- [Examples](#examples)
- [Development](#development)
- [License](#license)
- [Contributing](#contributing)
- [Contact](#contact)
1. [Features ✨](#features-✨)
2. [Installation 🛠️](#installation-🛠️)
3. [Usage 🚀](#usage-🚀)
1. [Middleware 🧩](#middleware-🧩)
2. [Logging Filter 📝](#logging-filter-📝)
3. [Configuration ⚙️](#configuration-⚙️)
1. [Middleware Configuration 🔧](#middleware-configuration-🔧)
2. [Logging Filter Configuration 🔍](#logging-filter-configuration-🔍)
4. [Examples 📖](#examples-📖)
1. [Example with Starlette 🌟](#example-with-starlette-🌟)
5. [Contributing 🤝](#contributing-🤝)
6. [License 📜](#license-📜)

## Features ✨

Expand All @@ -44,7 +48,7 @@ pip install asgi-request-duration

## Usage 🚀

### Middleware
### Middleware 🧩

To use the middleware, add it to your ASGI application:

Expand All @@ -56,7 +60,7 @@ app = Starlette()
app.add_middleware(RequestDurationMiddleware)
```

### Logging Filter
### Logging Filter 📝

To use the logging filter, configure your logger to use the `RequestDurationFilter`:

Expand All @@ -71,7 +75,7 @@ logger.addFilter(RequestDurationFilter())

### Configuration ⚙️

#### Middleware Configuration
#### Middleware Configuration 🔧

You can configure the middleware by passing parameters to the `RequestDurationMiddleware`:

Expand All @@ -86,15 +90,15 @@ Example:
```python
app.add_middleware(
RequestDurationMiddleware,
excluded_paths=["/health"],
header_name="X-Request-Duration",
excluded_paths=["^/health/?$"],
header_name="x-request-duration",
precision=3,
skip_validate_header_name=False,
skip_validate_precision=False
skip_validate_precision=False,
)
```

#### Logging Filter Configuration
#### Logging Filter Configuration 🔍

You can configure the logging filter by passing parameters to the `RequestDurationFilter`:

Expand All @@ -109,41 +113,46 @@ logger.addFilter(RequestDurationFilter(context_key="request_duration", default_v

## Examples 📖

Here are complete examples of how to use the middleware with Starlette applications. You can find the full example code in the [examples](examples) folder.

## Development 👩‍💻👨‍💻
Here is a complete example of how to use the middleware with the Starlette framework. For more examples and detailed usage, please refer to the [examples](https://github.com/feteu/asgi-request-duration/tree/main/examples) folder in the repository.

### Requirements
### Example with Starlette 🌟

- Python 3.11+
- Poetry
```python
from asgi_request_duration import RequestDurationMiddleware
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from uvicorn import run

### Setup

Clone the repository and install the dependencies:
async def info_endpoint(request: Request) -> JSONResponse:
return JSONResponse({"message": "info"})

```sh
git clone https://github.com/yourusername/asgi-request-duration.git
cd asgi-request-duration
poetry install
```
async def excluded_endpoint(request: Request) -> JSONResponse:
return JSONResponse({"message": "excluded"})

### Running Tests 🧪
routes = [
Route("/info", info_endpoint, methods=["GET"]),
Route("/excluded", excluded_endpoint, methods=["GET"]),
]

You can run the tests using `pytest`:
app = Starlette(routes=routes)
app.add_middleware(
RequestDurationMiddleware,
excluded_paths=["/excluded"],
header_name="x-request-duration",
precision=4,
skip_validate_header_name=False,
skip_validate_precision=False,
)

```sh
poetry run pytest
if __name__ == "__main__":
run(app, host='127.0.0.1', port=8000)
```

## License 📜

This project is licensed under the GNU GPLv3 License. See the [LICENSE](LICENSE) file for more details.

## Contributing 🤝
Contributions are welcome! Please refer to the [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to contribute to this project.

Contributions are welcome! Please read the [CONTRIBUTING](CONTRIBUTING.md) file for guidelines on how to contribute to this project.

## Contact 📬

For any questions or suggestions, please open an issue on GitHub.
## License 📜
This project is licensed under the GNU GPLv3 License. See the [LICENSE](LICENSE) file for more details.
29 changes: 18 additions & 11 deletions asgi_request_duration/decorators.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .exceptions import InvalidHeaderNameException, PrecisionValueOutOfRangeException
from collections.abc import Callable
from .constants import (
_DEFAULT_HEADER_NAME,
_DEFAULT_PRECISION,
Expand All @@ -8,8 +8,12 @@
_PRECISION_MAX,
_PRECISION_MIN,
)
from .exceptions import (
InvalidHeaderNameException,
PrecisionValueOutOfRangeException,
)

def validate_header_name(skip: bool =_DEFAULT_SKIP_VALIDATE_HEADER_NAME) -> callable:
def validate_header_name(skip: bool =_DEFAULT_SKIP_VALIDATE_HEADER_NAME) -> Callable:
"""
Decorator to validate the header name against a pattern.
Expand All @@ -23,17 +27,17 @@ def validate_header_name(skip: bool =_DEFAULT_SKIP_VALIDATE_HEADER_NAME) -> call
Raises:
InvalidHeaderNameException: If the header name is invalid.
"""
def decorator(func: callable)-> callable:
def wrapper(*args, **kwargs) -> callable:
def decorator(func: Callable)-> Callable:
def wrapper(self, *args, **kwargs) -> Callable:
if not skip:
header_name = kwargs.get('header_name', _DEFAULT_HEADER_NAME)
header_name = getattr(self, 'header_name', _DEFAULT_HEADER_NAME)
if not _HEADER_NAME_PATTERN.match(header_name):
raise InvalidHeaderNameException(header_name)
return func(*args, **kwargs)
return func(self, *args, **kwargs)
return wrapper
return decorator

def validate_precision(skip: bool =_DEFAULT_SKIP_VALIDATE_PRECISION)-> callable:
def validate_precision(skip: bool =_DEFAULT_SKIP_VALIDATE_PRECISION)-> Callable:
"""
Decorator to validate the precision value.
Expand All @@ -45,14 +49,17 @@ def validate_precision(skip: bool =_DEFAULT_SKIP_VALIDATE_PRECISION)-> callable:
function: The wrapped function with precision validation.
Raises:
TypeError: If the precision value is not an integer.
PrecisionValueOutOfRangeException: If the precision value is out of range.
"""
def decorator(func: callable) -> callable:
def wrapper(*args, **kwargs) -> callable:
def decorator(func: Callable) -> Callable:
def wrapper(self, *args, **kwargs) -> Callable:
if not skip:
precision = kwargs.get('precision', _DEFAULT_PRECISION)
precision = getattr(self, 'precision', _DEFAULT_PRECISION)
if not isinstance(precision, int):
raise TypeError(f"Precision value must be an integer, not {type(precision).__name__}")
if not (_PRECISION_MIN <= precision <= _PRECISION_MAX):
raise PrecisionValueOutOfRangeException(precision, _PRECISION_MIN, _PRECISION_MAX)
return func(*args, **kwargs)
return func(self, *args, **kwargs)
return wrapper
return decorator
35 changes: 35 additions & 0 deletions examples/starlette/complex/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import os
import uvicorn
from asgi_request_duration import RequestDurationMiddleware
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route


async def info_endpoint(request: Request) -> JSONResponse:
return JSONResponse({"message": "info"})

async def excluded_endpoint(request: Request) -> JSONResponse:
return JSONResponse({"message": "excluded"})

routes = [
Route("/info", info_endpoint, methods=["GET"]),
Route("/excluded", excluded_endpoint, methods=["GET"]),
]

app = Starlette(routes=routes)
app.add_middleware(
RequestDurationMiddleware,
excluded_paths=["/excluded"],
header_name="x-request-duration",
precision=4,
skip_validate_header_name=False,
skip_validate_precision=False,
)

if __name__ == "__main__":
log_config = f"{os.path.dirname(__file__)}{os.sep}conf{os.sep}logging.yaml"
config = uvicorn.Config("app:app", host="127.0.0.1", port=8000, log_config=log_config)
server = uvicorn.Server(config)
server.run()
35 changes: 35 additions & 0 deletions examples/starlette/complex/conf/logging.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
version: 1
filters:
request_duration:
(): 'asgi_request_duration.RequestDurationFilter'
default_value: '-'
formatters:
default:
(): 'uvicorn.logging.DefaultFormatter'
fmt: '%(levelprefix)s [%(asctime)s] %(message)s'
access:
(): 'uvicorn.logging.AccessFormatter'
fmt: '%(levelprefix)s [%(asctime)s] %(client_addr)s - "%(request_line)s" %(status_code)s {%(request_duration)s}'
handlers:
default:
class: logging.StreamHandler
formatter: default
stream: ext://sys.stderr
access:
class: logging.StreamHandler
filters: [request_duration]
formatter: access
stream: ext://sys.stdout
loggers:
uvicorn:
level: INFO
handlers:
- default
uvicorn.error:
level: INFO
uvicorn.access:
level: INFO
propagate: False
handlers:
- access
31 changes: 31 additions & 0 deletions examples/starlette/simple/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from asgi_request_duration import RequestDurationMiddleware
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from uvicorn import run


async def info_endpoint(request: Request) -> JSONResponse:
return JSONResponse({"message": "info"})

async def excluded_endpoint(request: Request) -> JSONResponse:
return JSONResponse({"message": "excluded"})

routes = [
Route("/info", info_endpoint, methods=["GET"]),
Route("/excluded", excluded_endpoint, methods=["GET"]),
]

app = Starlette(routes=routes)
app.add_middleware(
RequestDurationMiddleware,
excluded_paths=["/excluded"],
header_name="x-request-duration",
precision=4,
skip_validate_header_name=False,
skip_validate_precision=False,
)

if __name__ == "__main__":
run(app, host='127.0.0.1', port=8000)
41 changes: 0 additions & 41 deletions examples/starlette_complex.py

This file was deleted.

15 changes: 0 additions & 15 deletions examples/starlette_simple.py

This file was deleted.

Loading

0 comments on commit 58b5fcb

Please sign in to comment.