Skip to content

Commit ecc5e73

Browse files
committed
updates
1 parent e23ae35 commit ecc5e73

13 files changed

Lines changed: 396 additions & 4 deletions

File tree

.github/workflows/docs.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Docs
2+
3+
on:
4+
push:
5+
paths:
6+
- "docs/**"
7+
- "README.md"
8+
- ".github/workflows/docs.yml"
9+
pull_request:
10+
paths:
11+
- "docs/**"
12+
- "README.md"
13+
- ".github/workflows/docs.yml"
14+
15+
jobs:
16+
docs-check:
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v4
21+
22+
- name: Verify docs layout
23+
run: |
24+
test -f README.md
25+
test -d docs

.github/workflows/publish.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Publish
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
build:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
19+
- name: Set up Python
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: "3.12"
23+
24+
- name: Build package
25+
run: |
26+
python -m pip install --upgrade pip build twine
27+
python -m build
28+
python -m twine check dist/*
29+
30+
- name: Upload dist artifacts
31+
uses: actions/upload-artifact@v4
32+
with:
33+
name: dist
34+
path: dist/*
35+
36+
publish:
37+
needs: build
38+
if: startsWith(github.ref, 'refs/tags/v')
39+
runs-on: ubuntu-latest
40+
permissions:
41+
id-token: write
42+
contents: read
43+
environment:
44+
name: pypi
45+
steps:
46+
- name: Download dist artifacts
47+
uses: actions/download-artifact@v4
48+
with:
49+
name: dist
50+
path: dist
51+
52+
- name: Publish to PyPI
53+
uses: pypa/gh-action-pypi-publish@release/v1

.github/workflows/tests.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
tox:
9+
name: Python ${{ matrix.python-version }} / ${{ matrix.toxenv }}
10+
runs-on: ubuntu-latest
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
include:
15+
- python-version: "3.10"
16+
toxenv: py310
17+
- python-version: "3.11"
18+
toxenv: py311
19+
- python-version: "3.12"
20+
toxenv: py312
21+
- python-version: "3.13"
22+
toxenv: py313
23+
- python-version: "3.14"
24+
toxenv: py314
25+
26+
steps:
27+
- name: Checkout
28+
uses: actions/checkout@v4
29+
30+
- name: Set up Python
31+
uses: actions/setup-python@v5
32+
with:
33+
python-version: ${{ matrix.python-version }}
34+
cache: "pip"
35+
36+
- name: Install tox
37+
run: python -m pip install --upgrade pip tox
38+
39+
- name: Run tests via tox
40+
run: tox -e ${{ matrix.toxenv }}

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,9 @@ cython_debug/
182182
.abstra/
183183

184184
# Visual Studio Code
185-
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
185+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186186
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187-
# and can be added to the global gitignore or merged into this file. However, if you prefer,
187+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
188188
# you could uncomment the following to ignore the entire vscode folder
189189
# .vscode/
190190

@@ -205,3 +205,4 @@ cython_debug/
205205
marimo/_static/
206206
marimo/_lsp/
207207
__marimo__/
208+
.pre-commit-config.yaml

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
# fastapi-observer
2-
FastApi Logs visualization
2+
FastApi Logs visualization

fastapi_observer/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
from .config import ObserverConfig
2+
from .logger import build_logger, log_event
3+
from .models import LogEvent
24

3-
__all__ = ["ObserverConfig"]
5+
__all__ = ["ObserverConfig", "LogEvent", "build_logger", "log_event"]

fastapi_observer/logger.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import logging
5+
from logging.handlers import RotatingFileHandler
6+
from typing import Any
7+
8+
from .config import ObserverConfig
9+
from .models import LogEvent
10+
11+
_BASE_RECORD_ATTRS = set(logging.makeLogRecord({}).__dict__.keys())
12+
_LEVEL_TO_INT: dict[str, int] = {
13+
"DEBUG": logging.DEBUG,
14+
"INFO": logging.INFO,
15+
"WARNING": logging.WARNING,
16+
"ERROR": logging.ERROR,
17+
"CRITICAL": logging.CRITICAL,
18+
}
19+
20+
21+
class JsonFormatter(logging.Formatter):
22+
def format(self, record: logging.LogRecord) -> str:
23+
payload: dict[str, Any] = {
24+
"timestamp": self.formatTime(record, self.datefmt),
25+
"level": record.levelname,
26+
"logger": record.name,
27+
"message": record.getMessage(),
28+
}
29+
for key, value in record.__dict__.items():
30+
if key not in _BASE_RECORD_ATTRS and key != "message":
31+
payload[key] = value
32+
return json.dumps(payload, default=str)
33+
34+
35+
def build_logger(
36+
config: ObserverConfig, *, logger_name: str = "fastapi_observer"
37+
) -> logging.Logger:
38+
logger = logging.getLogger(logger_name)
39+
logger.setLevel(config.log_level)
40+
logger.propagate = False
41+
42+
logger.handlers.clear()
43+
formatter = _build_formatter(config)
44+
45+
if "console" in config.handlers:
46+
console_handler = logging.StreamHandler()
47+
console_handler.setFormatter(formatter)
48+
logger.addHandler(console_handler)
49+
50+
if "file" in config.handlers:
51+
file_handler = RotatingFileHandler(
52+
filename=config.file_path,
53+
maxBytes=config.file_max_bytes,
54+
backupCount=config.file_backup_count,
55+
encoding="utf-8",
56+
)
57+
file_handler.setFormatter(formatter)
58+
logger.addHandler(file_handler)
59+
60+
return logger
61+
62+
63+
def log_event(logger: logging.Logger, config: ObserverConfig, event: LogEvent) -> None:
64+
level = _LEVEL_TO_INT.get(event.level, logging.INFO)
65+
payload = event.to_payload(
66+
service_name=config.service_name,
67+
environment=config.environment,
68+
)
69+
logger.log(level, event.message, extra={"event": payload})
70+
71+
72+
def _build_formatter(config: ObserverConfig) -> logging.Formatter:
73+
if config.log_format == "json":
74+
return JsonFormatter()
75+
return logging.Formatter(
76+
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
77+
datefmt="%Y-%m-%d %H:%M:%S",
78+
)
79+
80+
81+
__all__ = ["build_logger", "log_event", "JsonFormatter"]

fastapi_observer/models.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
from typing import Any
5+
6+
from pydantic import BaseModel, Field, field_validator
7+
8+
from .config import LogLevel
9+
10+
11+
class LogEvent(BaseModel):
12+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
13+
level: LogLevel = "INFO"
14+
message: str
15+
method: str
16+
path: str
17+
status_code: int | None = None
18+
duration_ms: float | None = None
19+
correlation_id: str | None = None
20+
error: str | None = None
21+
metadata: dict[str, Any] = Field(default_factory=dict)
22+
23+
model_config = {"str_strip_whitespace": True}
24+
25+
@field_validator("method")
26+
@classmethod
27+
def _normalize_method(cls, value: str) -> str:
28+
method = value.upper()
29+
if not method:
30+
raise ValueError("method must be non-empty")
31+
return method
32+
33+
@field_validator("path")
34+
@classmethod
35+
def _normalize_path(cls, value: str) -> str:
36+
if not value:
37+
raise ValueError("path must be non-empty")
38+
path = value if value.startswith("/") else f"/{value}"
39+
if len(path) > 1 and path.endswith("/"):
40+
path = path.rstrip("/")
41+
return path
42+
43+
@field_validator("duration_ms")
44+
@classmethod
45+
def _validate_duration(cls, value: float | None) -> float | None:
46+
if value is not None and value < 0:
47+
raise ValueError("duration_ms must be non-negative")
48+
return value
49+
50+
def to_payload(self, *, service_name: str, environment: str) -> dict[str, Any]:
51+
return {
52+
"timestamp": self.timestamp.isoformat(),
53+
"service_name": service_name,
54+
"environment": environment,
55+
"level": self.level,
56+
"message": self.message,
57+
"method": self.method,
58+
"path": self.path,
59+
"status_code": self.status_code,
60+
"duration_ms": self.duration_ms,
61+
"correlation_id": self.correlation_id,
62+
"error": self.error,
63+
"metadata": self.metadata,
64+
}
65+
66+
67+
__all__ = ["LogEvent"]

pyproject.toml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[build-system]
2+
requires = ["setuptools>=69", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "fastapi-observer"
7+
version = "0.1.0"
8+
description = "Logging and observability helpers for FastAPI applications."
9+
readme = "README.md"
10+
requires-python = ">=3.10,<3.15"
11+
license = { file = "LICENSE" }
12+
authors = [{ name = "fastapi-observer maintainers" }]
13+
dependencies = [
14+
"pydantic>=2.0,<3.0",
15+
]
16+
17+
[project.optional-dependencies]
18+
test = [
19+
"pytest>=8.0",
20+
]
21+
dev = [
22+
"pre-commit>=3.7",
23+
"black>=24.10",
24+
]
25+
26+
[tool.setuptools.packages.find]
27+
include = ["fastapi_observer*"]
28+
29+
[tool.pytest.ini_options]
30+
addopts = "-ra"
31+
testpaths = ["tests"]
32+
33+
[tool.black]
34+
line-length = 88
35+
target-version = ["py310"]

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from setuptools import setup
2+
3+
setup()

0 commit comments

Comments
 (0)