Version 0.4.0
All checks were successful
Run linters on applied template / Python 3.13 lint and build (push) Successful in 1m40s

Changes:
- put ObservabilityMiddleware before ExceptionHandlerMiddleware to avoid repetative code
- add application startup and last metrics update metrics along with CPU usage metric and threads count
- move host and port to new uvicorn section at config along with new reload and forwarded_allow_ips
- add request_id and remove trace_id/span_id generation if tracing is disabled
- move logging logic from utils to observability
- pass trace_id/span_id in HEX form
This commit is contained in:
2026-01-03 11:01:43 +03:00
parent b8acb017fd
commit 53f14a8624
26 changed files with 901 additions and 730 deletions

View File

@@ -2,7 +2,7 @@
This is a repository which contains a template for a backend python microservice running a FastAPI framework This is a repository which contains a template for a backend python microservice running a FastAPI framework
Version 0.3.0 Version 0.4.0
## Usage ## Usage

View File

@@ -1,6 +1,10 @@
app: app:
uvicorn:
host: 0.0.0.0 host: 0.0.0.0
port: 8080 port: 8080
reload: true
forwarded_allow_ips:
- 127.0.0.1
debug: true debug: true
cors: cors:
allow_origins: ["*"] allow_origins: ["*"]
@@ -25,7 +29,5 @@ observability:
prometheus: prometheus:
host: 0.0.0.0 host: 0.0.0.0
port: 9090 port: 9090
urls_mapping:
/api/debug/.*: /api/debug/*
jaeger: jaeger:
endpoint: http://otel:4318/v1/traces endpoint: http://otel:4318/v1/traces

View File

@@ -143,6 +143,7 @@ services:
opensearch: opensearch:
image: opensearchproject/opensearch:3.3.0@sha256:d96afaf6cbd2a6a3695aeb2f1d48c9a16ad5c8918eb849e5cbf43475f0f8e146 image: opensearchproject/opensearch:3.3.0@sha256:d96afaf6cbd2a6a3695aeb2f1d48c9a16ad5c8918eb849e5cbf43475f0f8e146
container_name: opensearch container_name: opensearch
restart: unless-stopped
environment: environment:
- discovery.type=single-node - discovery.type=single-node
- plugins.security.disabled=true - plugins.security.disabled=true

325
poetry.lock generated
View File

@@ -311,38 +311,39 @@ files = [
[[package]] [[package]]
name = "black" name = "black"
version = "25.11.0" version = "25.12.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.10"
groups = ["dev"] groups = ["dev"]
files = [ files = [
{file = "black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e"}, {file = "black-25.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f85ba1ad15d446756b4ab5f3044731bf68b777f8f9ac9cdabd2425b97cd9c4e8"},
{file = "black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0"}, {file = "black-25.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546eecfe9a3a6b46f9d69d8a642585a6eaf348bcbbc4d87a19635570e02d9f4a"},
{file = "black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37"}, {file = "black-25.12.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17dcc893da8d73d8f74a596f64b7c98ef5239c2cd2b053c0f25912c4494bf9ea"},
{file = "black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03"}, {file = "black-25.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:09524b0e6af8ba7a3ffabdfc7a9922fb9adef60fed008c7cd2fc01f3048e6e6f"},
{file = "black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a"}, {file = "black-25.12.0-cp310-cp310-win_arm64.whl", hash = "sha256:b162653ed89eb942758efeb29d5e333ca5bb90e5130216f8369857db5955a7da"},
{file = "black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170"}, {file = "black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a"},
{file = "black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc"}, {file = "black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be"},
{file = "black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e"}, {file = "black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b"},
{file = "black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac"}, {file = "black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5"},
{file = "black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96"}, {file = "black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655"},
{file = "black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd"}, {file = "black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a"},
{file = "black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409"}, {file = "black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783"},
{file = "black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b"}, {file = "black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59"},
{file = "black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd"}, {file = "black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892"},
{file = "black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993"}, {file = "black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43"},
{file = "black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c"}, {file = "black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5"},
{file = "black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170"}, {file = "black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f"},
{file = "black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545"}, {file = "black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf"},
{file = "black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda"}, {file = "black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d"},
{file = "black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664"}, {file = "black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce"},
{file = "black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06"}, {file = "black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5"},
{file = "black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2"}, {file = "black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f"},
{file = "black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc"}, {file = "black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f"},
{file = "black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc"}, {file = "black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83"},
{file = "black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b"}, {file = "black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b"},
{file = "black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08"}, {file = "black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828"},
{file = "black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7"},
] ]
[package.dependencies] [package.dependencies]
@@ -938,14 +939,14 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2
[[package]] [[package]]
name = "importlib-metadata" name = "importlib-metadata"
version = "8.7.0" version = "8.7.1"
description = "Read metadata from Python packages" description = "Read metadata from Python packages"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"},
{file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"},
] ]
[package.dependencies] [package.dependencies]
@@ -955,10 +956,10 @@ zipp = ">=3.20"
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
cover = ["pytest-cov"] cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
enabler = ["pytest-enabler (>=2.2)"] enabler = ["pytest-enabler (>=3.4)"]
perf = ["ipython"] perf = ["ipython"]
test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
type = ["pytest-mypy"] type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"]
[[package]] [[package]]
name = "isort" name = "isort"
@@ -1277,14 +1278,14 @@ files = [
[[package]] [[package]]
name = "opentelemetry-api" name = "opentelemetry-api"
version = "1.38.0" version = "1.39.1"
description = "OpenTelemetry Python API" description = "OpenTelemetry Python API"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582"}, {file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"},
{file = "opentelemetry_api-1.38.0.tar.gz", hash = "sha256:f4c193b5e8acb0912b06ac5b16321908dd0843d75049c091487322284a3eea12"}, {file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"},
] ]
[package.dependencies] [package.dependencies]
@@ -1293,45 +1294,45 @@ typing-extensions = ">=4.5.0"
[[package]] [[package]]
name = "opentelemetry-exporter-otlp" name = "opentelemetry-exporter-otlp"
version = "1.38.0" version = "1.39.1"
description = "OpenTelemetry Collector Exporters" description = "OpenTelemetry Collector Exporters"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "opentelemetry_exporter_otlp-1.38.0-py3-none-any.whl", hash = "sha256:bc6562cef229fac8887ed7109fc5abc52315f39d9c03fd487bb8b4ef8fbbc231"}, {file = "opentelemetry_exporter_otlp-1.39.1-py3-none-any.whl", hash = "sha256:68ae69775291f04f000eb4b698ff16ff685fdebe5cb52871bc4e87938a7b00fe"},
{file = "opentelemetry_exporter_otlp-1.38.0.tar.gz", hash = "sha256:2f55acdd475e4136117eff20fbf1b9488b1b0b665ab64407516e1ac06f9c3f9d"}, {file = "opentelemetry_exporter_otlp-1.39.1.tar.gz", hash = "sha256:7cf7470e9fd0060c8a38a23e4f695ac686c06a48ad97f8d4867bc9b420180b9c"},
] ]
[package.dependencies] [package.dependencies]
opentelemetry-exporter-otlp-proto-grpc = "1.38.0" opentelemetry-exporter-otlp-proto-grpc = "1.39.1"
opentelemetry-exporter-otlp-proto-http = "1.38.0" opentelemetry-exporter-otlp-proto-http = "1.39.1"
[[package]] [[package]]
name = "opentelemetry-exporter-otlp-proto-common" name = "opentelemetry-exporter-otlp-proto-common"
version = "1.38.0" version = "1.39.1"
description = "OpenTelemetry Protobuf encoding" description = "OpenTelemetry Protobuf encoding"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "opentelemetry_exporter_otlp_proto_common-1.38.0-py3-none-any.whl", hash = "sha256:03cb76ab213300fe4f4c62b7d8f17d97fcfd21b89f0b5ce38ea156327ddda74a"}, {file = "opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde"},
{file = "opentelemetry_exporter_otlp_proto_common-1.38.0.tar.gz", hash = "sha256:e333278afab4695aa8114eeb7bf4e44e65c6607d54968271a249c180b2cb605c"}, {file = "opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464"},
] ]
[package.dependencies] [package.dependencies]
opentelemetry-proto = "1.38.0" opentelemetry-proto = "1.39.1"
[[package]] [[package]]
name = "opentelemetry-exporter-otlp-proto-grpc" name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.38.0" version = "1.39.1"
description = "OpenTelemetry Collector Protobuf over gRPC Exporter" description = "OpenTelemetry Collector Protobuf over gRPC Exporter"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "opentelemetry_exporter_otlp_proto_grpc-1.38.0-py3-none-any.whl", hash = "sha256:7c49fd9b4bd0dbe9ba13d91f764c2d20b0025649a6e4ac35792fb8d84d764bc7"}, {file = "opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.38.0.tar.gz", hash = "sha256:2473935e9eac71f401de6101d37d6f3f0f1831db92b953c7dcc912536158ebd6"}, {file = "opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad"},
] ]
[package.dependencies] [package.dependencies]
@@ -1341,93 +1342,99 @@ grpcio = [
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""}, {version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
] ]
opentelemetry-api = ">=1.15,<2.0" opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.38.0" opentelemetry-exporter-otlp-proto-common = "1.39.1"
opentelemetry-proto = "1.38.0" opentelemetry-proto = "1.39.1"
opentelemetry-sdk = ">=1.38.0,<1.39.0" opentelemetry-sdk = ">=1.39.1,<1.40.0"
typing-extensions = ">=4.6.0" typing-extensions = ">=4.6.0"
[package.extras]
gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"]
[[package]] [[package]]
name = "opentelemetry-exporter-otlp-proto-http" name = "opentelemetry-exporter-otlp-proto-http"
version = "1.38.0" version = "1.39.1"
description = "OpenTelemetry Collector Protobuf over HTTP Exporter" description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "opentelemetry_exporter_otlp_proto_http-1.38.0-py3-none-any.whl", hash = "sha256:84b937305edfc563f08ec69b9cb2298be8188371217e867c1854d77198d0825b"}, {file = "opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985"},
{file = "opentelemetry_exporter_otlp_proto_http-1.38.0.tar.gz", hash = "sha256:f16bd44baf15cbe07633c5112ffc68229d0edbeac7b37610be0b2def4e21e90b"}, {file = "opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb"},
] ]
[package.dependencies] [package.dependencies]
googleapis-common-protos = ">=1.52,<2.0" googleapis-common-protos = ">=1.52,<2.0"
opentelemetry-api = ">=1.15,<2.0" opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.38.0" opentelemetry-exporter-otlp-proto-common = "1.39.1"
opentelemetry-proto = "1.38.0" opentelemetry-proto = "1.39.1"
opentelemetry-sdk = ">=1.38.0,<1.39.0" opentelemetry-sdk = ">=1.39.1,<1.40.0"
requests = ">=2.7,<3.0" requests = ">=2.7,<3.0"
typing-extensions = ">=4.5.0" typing-extensions = ">=4.5.0"
[package.extras]
gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"]
[[package]] [[package]]
name = "opentelemetry-exporter-prometheus" name = "opentelemetry-exporter-prometheus"
version = "0.59b0" version = "0.60b1"
description = "Prometheus Metric Exporter for OpenTelemetry" description = "Prometheus Metric Exporter for OpenTelemetry"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "opentelemetry_exporter_prometheus-0.59b0-py3-none-any.whl", hash = "sha256:71ced23207abd15b30d1fe4e7e910dcaa7c2ff1f24a6ffccbd4fdded676f541b"}, {file = "opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd"},
{file = "opentelemetry_exporter_prometheus-0.59b0.tar.gz", hash = "sha256:d64f23c49abb5a54e271c2fbc8feacea0c394a30ec29876ab5ef7379f08cf3d7"}, {file = "opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b"},
] ]
[package.dependencies] [package.dependencies]
opentelemetry-api = ">=1.12,<2.0" opentelemetry-api = ">=1.12,<2.0"
opentelemetry-sdk = ">=1.38.0,<1.39.0" opentelemetry-sdk = ">=1.39.1,<1.40.0"
prometheus-client = ">=0.5.0,<1.0.0" prometheus-client = ">=0.5.0,<1.0.0"
[[package]] [[package]]
name = "opentelemetry-instrumentation" name = "opentelemetry-instrumentation"
version = "0.59b0" version = "0.60b1"
description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "opentelemetry_instrumentation-0.59b0-py3-none-any.whl", hash = "sha256:44082cc8fe56b0186e87ee8f7c17c327c4c2ce93bdbe86496e600985d74368ee"}, {file = "opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d"},
{file = "opentelemetry_instrumentation-0.59b0.tar.gz", hash = "sha256:6010f0faaacdaf7c4dff8aac84e226d23437b331dcda7e70367f6d73a7db1adc"}, {file = "opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a"},
] ]
[package.dependencies] [package.dependencies]
opentelemetry-api = ">=1.4,<2.0" opentelemetry-api = ">=1.4,<2.0"
opentelemetry-semantic-conventions = "0.59b0" opentelemetry-semantic-conventions = "0.60b1"
packaging = ">=18.0" packaging = ">=18.0"
wrapt = ">=1.0.0,<2.0.0" wrapt = ">=1.0.0,<2.0.0"
[[package]] [[package]]
name = "opentelemetry-instrumentation-logging" name = "opentelemetry-instrumentation-logging"
version = "0.59b0" version = "0.60b1"
description = "OpenTelemetry Logging instrumentation" description = "OpenTelemetry Logging instrumentation"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "opentelemetry_instrumentation_logging-0.59b0-py3-none-any.whl", hash = "sha256:fdd4eddbd093fc421df8f7d356ecb15b320a1f3396b56bce5543048a5c457eea"}, {file = "opentelemetry_instrumentation_logging-0.60b1-py3-none-any.whl", hash = "sha256:f2e18cbc7e1dd3628c80e30d243897fdc93c5b7e0c8ae60abd2b9b6a99f82343"},
{file = "opentelemetry_instrumentation_logging-0.59b0.tar.gz", hash = "sha256:1b51116444edc74f699daf9002ded61529397100c9bc903c8b9aaa75a5218c76"}, {file = "opentelemetry_instrumentation_logging-0.60b1.tar.gz", hash = "sha256:98f4b9c7aeb9314a30feee7c002c7ea9abea07c90df5f97fb058b850bc45b89a"},
] ]
[package.dependencies] [package.dependencies]
opentelemetry-api = ">=1.12,<2.0" opentelemetry-api = ">=1.12,<2.0"
opentelemetry-instrumentation = "0.59b0" opentelemetry-instrumentation = "0.60b1"
[[package]] [[package]]
name = "opentelemetry-proto" name = "opentelemetry-proto"
version = "1.38.0" version = "1.39.1"
description = "OpenTelemetry Python Proto" description = "OpenTelemetry Python Proto"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "opentelemetry_proto-1.38.0-py3-none-any.whl", hash = "sha256:b6ebe54d3217c42e45462e2a1ae28c3e2bf2ec5a5645236a490f55f45f1a0a18"}, {file = "opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007"},
{file = "opentelemetry_proto-1.38.0.tar.gz", hash = "sha256:88b161e89d9d372ce723da289b7da74c3a8354a8e5359992be813942969ed468"}, {file = "opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8"},
] ]
[package.dependencies] [package.dependencies]
@@ -1435,35 +1442,35 @@ protobuf = ">=5.0,<7.0"
[[package]] [[package]]
name = "opentelemetry-sdk" name = "opentelemetry-sdk"
version = "1.38.0" version = "1.39.1"
description = "OpenTelemetry Python SDK" description = "OpenTelemetry Python SDK"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "opentelemetry_sdk-1.38.0-py3-none-any.whl", hash = "sha256:1c66af6564ecc1553d72d811a01df063ff097cdc82ce188da9951f93b8d10f6b"}, {file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"},
{file = "opentelemetry_sdk-1.38.0.tar.gz", hash = "sha256:93df5d4d871ed09cb4272305be4d996236eedb232253e3ab864c8620f051cebe"}, {file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"},
] ]
[package.dependencies] [package.dependencies]
opentelemetry-api = "1.38.0" opentelemetry-api = "1.39.1"
opentelemetry-semantic-conventions = "0.59b0" opentelemetry-semantic-conventions = "0.60b1"
typing-extensions = ">=4.5.0" typing-extensions = ">=4.5.0"
[[package]] [[package]]
name = "opentelemetry-semantic-conventions" name = "opentelemetry-semantic-conventions"
version = "0.59b0" version = "0.60b1"
description = "OpenTelemetry Semantic Conventions" description = "OpenTelemetry Semantic Conventions"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "opentelemetry_semantic_conventions-0.59b0-py3-none-any.whl", hash = "sha256:35d3b8833ef97d614136e253c1da9342b4c3c083bbaf29ce31d572a1c3825eed"}, {file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"},
{file = "opentelemetry_semantic_conventions-0.59b0.tar.gz", hash = "sha256:7a6db3f30d70202d5bf9fa4b69bc866ca6a30437287de6c510fb594878aed6b0"}, {file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"},
] ]
[package.dependencies] [package.dependencies]
opentelemetry-api = "1.38.0" opentelemetry-api = "1.39.1"
typing-extensions = ">=4.5.0" typing-extensions = ">=4.5.0"
[[package]] [[package]]
@@ -1674,6 +1681,41 @@ files = [
{file = "protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4"}, {file = "protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4"},
] ]
[[package]]
name = "psutil"
version = "7.2.1"
description = "Cross-platform lib for process and system monitoring."
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "psutil-7.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d"},
{file = "psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49"},
{file = "psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc"},
{file = "psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf"},
{file = "psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f"},
{file = "psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672"},
{file = "psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679"},
{file = "psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f"},
{file = "psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129"},
{file = "psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a"},
{file = "psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79"},
{file = "psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266"},
{file = "psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42"},
{file = "psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1"},
{file = "psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8"},
{file = "psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6"},
{file = "psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8"},
{file = "psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67"},
{file = "psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17"},
{file = "psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442"},
{file = "psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3"},
]
[package.extras]
dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel"]
test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "setuptools"]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.12.5"
@@ -2006,69 +2048,64 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.44" version = "2.0.45"
description = "Database Abstraction Library" description = "Database Abstraction Library"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "SQLAlchemy-2.0.44-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:471733aabb2e4848d609141a9e9d56a427c0a038f4abf65dd19d7a21fd563632"}, {file = "sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c64772786d9eee72d4d3784c28f0a636af5b0a29f3fe26ff11f55efe90c0bd85"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48bf7d383a35e668b984c805470518b635d48b95a3c57cb03f37eaa3551b5f9f"}, {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae64ebf7657395824a19bca98ab10eb9a3ecb026bf09524014f1bb81cb598d4"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf4bb6b3d6228fcf3a71b50231199fb94d2dd2611b66d33be0578ea3e6c2726"}, {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f02325709d1b1a1489f23a39b318e175a171497374149eae74d612634b234c0"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:e998cf7c29473bd077704cea3577d23123094311f59bdc4af551923b168332b1"}, {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2c3684fca8a05f0ac1d9a21c1f4a266983a7ea9180efb80ffeb03861ecd01a0"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ebac3f0b5732014a126b43c2b7567f2f0e0afea7d9119a3378bde46d3dcad88e"}, {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040f6f0545b3b7da6b9317fc3e922c9a98fc7243b2a1b39f78390fc0942f7826"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-win32.whl", hash = "sha256:3255d821ee91bdf824795e936642bbf43a4c7cedf5d1aed8d24524e66843aa74"}, {file = "sqlalchemy-2.0.45-cp310-cp310-win32.whl", hash = "sha256:830d434d609fe7bfa47c425c445a8b37929f140a7a44cdaf77f6d34df3a7296a"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-win_amd64.whl", hash = "sha256:78e6c137ba35476adb5432103ae1534f2f5295605201d946a4198a0dea4b38e7"}, {file = "sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl", hash = "sha256:0209d9753671b0da74da2cfbb9ecf9c02f72a759e4b018b3ab35f244c91842c7"},
{file = "sqlalchemy-2.0.44-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c77f3080674fc529b1bd99489378c7f63fcb4ba7f8322b79732e0258f0ea3ce"}, {file = "sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56"},
{file = "sqlalchemy-2.0.44-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26ef74ba842d61635b0152763d057c8d48215d5be9bb8b7604116a059e9985"}, {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b"},
{file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4a172b31785e2f00780eccab00bc240ccdbfdb8345f1e6063175b3ff12ad1b0"}, {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac"},
{file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9480c0740aabd8cb29c329b422fb65358049840b34aba0adf63162371d2a96e"}, {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606"},
{file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17835885016b9e4d0135720160db3095dc78c583e7b902b6be799fb21035e749"}, {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c"},
{file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cbe4f85f50c656d753890f39468fcd8190c5f08282caf19219f684225bfd5fd2"}, {file = "sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177"},
{file = "sqlalchemy-2.0.44-cp310-cp310-win32.whl", hash = "sha256:2fcc4901a86ed81dc76703f3b93ff881e08761c63263c46991081fd7f034b165"}, {file = "sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2"},
{file = "sqlalchemy-2.0.44-cp310-cp310-win_amd64.whl", hash = "sha256:9919e77403a483ab81e3423151e8ffc9dd992c20d2603bf17e4a8161111e55f5"}, {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f"},
{file = "sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd"}, {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d"},
{file = "sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa"}, {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4"},
{file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e"}, {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6"},
{file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e"}, {file = "sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953"},
{file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399"}, {file = "sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1"},
{file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b"}, {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf"},
{file = "sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3"}, {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e"},
{file = "sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5"}, {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b"},
{file = "sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250"}, {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8"},
{file = "sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29"}, {file = "sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a"},
{file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44"}, {file = "sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee"},
{file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1"}, {file = "sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6"},
{file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7"}, {file = "sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a"},
{file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d"}, {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774"},
{file = "sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4"}, {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce"},
{file = "sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e"}, {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33"},
{file = "sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1"}, {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74"},
{file = "sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45"}, {file = "sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f"},
{file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976"}, {file = "sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177"},
{file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c"}, {file = "sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b"},
{file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d"}, {file = "sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b"},
{file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40"}, {file = "sqlalchemy-2.0.45-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5964f832431b7cdfaaa22a660b4c7eb1dfcd6ed41375f67fd3e3440fd95cb3cc"},
{file = "sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73"}, {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee580ab50e748208754ae8980cec79ec205983d8cf8b3f7c39067f3d9f2c8e22"},
{file = "sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e"}, {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13e27397a7810163440c6bfed6b3fe46f1bfb2486eb540315a819abd2c004128"},
{file = "sqlalchemy-2.0.44-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2fc44e5965ea46909a416fff0af48a219faefd5773ab79e5f8a5fcd5d62b2667"}, {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ed3635353e55d28e7f4a95c8eda98a5cdc0a0b40b528433fbd41a9ae88f55b3d"},
{file = "sqlalchemy-2.0.44-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dc8b3850d2a601ca2320d081874033684e246d28e1c5e89db0864077cfc8f5a9"}, {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:db6834900338fb13a9123307f0c2cbb1f890a8656fcd5e5448ae3ad5bbe8d312"},
{file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d733dec0614bb8f4bcb7c8af88172b974f685a31dc3a65cca0527e3120de5606"}, {file = "sqlalchemy-2.0.45-cp38-cp38-win32.whl", hash = "sha256:1d8b4a7a8c9b537509d56d5cd10ecdcfbb95912d72480c8861524efecc6a3fff"},
{file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22be14009339b8bc16d6b9dc8780bacaba3402aa7581658e246114abbd2236e3"}, {file = "sqlalchemy-2.0.45-cp38-cp38-win_amd64.whl", hash = "sha256:ebd300afd2b62679203435f596b2601adafe546cb7282d5a0cd3ed99e423720f"},
{file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:357bade0e46064f88f2c3a99808233e67b0051cdddf82992379559322dfeb183"}, {file = "sqlalchemy-2.0.45-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d29b2b99d527dbc66dd87c3c3248a5dd789d974a507f4653c969999fc7c1191b"},
{file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4848395d932e93c1595e59a8672aa7400e8922c39bb9b0668ed99ac6fa867822"}, {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59a8b8bd9c6bedf81ad07c8bd5543eedca55fe9b8780b2b628d495ba55f8db1e"},
{file = "sqlalchemy-2.0.44-cp38-cp38-win32.whl", hash = "sha256:2f19644f27c76f07e10603580a47278abb2a70311136a7f8fd27dc2e096b9013"}, {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd93c6f5d65f254ceabe97548c709e073d6da9883343adaa51bf1a913ce93f8e"},
{file = "sqlalchemy-2.0.44-cp38-cp38-win_amd64.whl", hash = "sha256:1df4763760d1de0dfc8192cc96d8aa293eb1a44f8f7a5fbe74caf1b551905c5e"}, {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d0beadc2535157070c9c17ecf25ecec31e13c229a8f69196d7590bde8082bf1"},
{file = "sqlalchemy-2.0.44-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7027414f2b88992877573ab780c19ecb54d3a536bef3397933573d6b5068be4"}, {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e057f928ffe9c9b246a55b469c133b98a426297e1772ad24ce9f0c47d123bd5b"},
{file = "sqlalchemy-2.0.44-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fe166c7d00912e8c10d3a9a0ce105569a31a3d0db1a6e82c4e0f4bf16d5eca9"}, {file = "sqlalchemy-2.0.45-cp39-cp39-win32.whl", hash = "sha256:c1c2091b1489435ff85728fafeb990f073e64f6f5e81d5cd53059773e8521eb6"},
{file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3caef1ff89b1caefc28f0368b3bde21a7e3e630c2eddac16abd9e47bd27cc36a"}, {file = "sqlalchemy-2.0.45-cp39-cp39-win_amd64.whl", hash = "sha256:56ead1f8dfb91a54a28cd1d072c74b3d635bcffbd25e50786533b822d4f2cde2"},
{file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc2856d24afa44295735e72f3c75d6ee7fdd4336d8d3a8f3d44de7aa6b766df2"}, {file = "sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0"},
{file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:11bac86b0deada30b6b5f93382712ff0e911fe8d31cb9bf46e6b149ae175eff0"}, {file = "sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88"},
{file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d18cd0e9a0f37c9f4088e50e3839fcb69a380a0ec957408e0b57cff08ee0a26"},
{file = "sqlalchemy-2.0.44-cp39-cp39-win32.whl", hash = "sha256:9e9018544ab07614d591a26c1bd4293ddf40752cc435caf69196740516af7100"},
{file = "sqlalchemy-2.0.44-cp39-cp39-win_amd64.whl", hash = "sha256:8e0e4e66fd80f277a8c3de016a81a554e76ccf6b8d881ee0b53200305a8433f6"},
{file = "sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05"},
{file = "sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22"},
] ]
[package.dependencies] [package.dependencies]
@@ -2172,14 +2209,14 @@ typing-extensions = ">=4.12.0"
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.0" version = "2.6.2"
description = "HTTP library with thread-safe connection pooling, file post, and more." description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f"}, {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
{file = "urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1"}, {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
] ]
[package.extras] [package.extras]
@@ -2466,4 +2503,4 @@ type = ["pytest-mypy"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">= 3.11" python-versions = ">= 3.11"
content-hash = "5367de21ce1036474a8cfc9c03a43e667b13ad68ffb33cb8e9da6df41f28fe4a" content-hash = "cded2fe30ff0b83023e9d29fa1ac9c27cd070eba0f70553ce93b0efd996c01a4"

View File

@@ -15,13 +15,14 @@ dependencies = [
"pyyaml (>=6.0.3,<7.0.0)", "pyyaml (>=6.0.3,<7.0.0)",
"uvicorn (>=0.38.0,<0.39.0)", "uvicorn (>=0.38.0,<0.39.0)",
"asyncpg (>=0.30.0,<0.31.0)", "asyncpg (>=0.30.0,<0.31.0)",
"opentelemetry-exporter-otlp (>=1.38,<2.0)", "opentelemetry-exporter-otlp (>=1.39.1,<2.0)",
"opentelemetry-exporter-prometheus (>=0.59b0,<0.60)", "opentelemetry-exporter-prometheus (>=0.60b1,<0.61)",
"opentelemetry-semantic-conventions (>=0.59b0,<0.60)", "opentelemetry-semantic-conventions (>=0.60b1,<0.61)",
"opentelemetry-instrumentation-logging (>=0.59b0,<0.60)", "opentelemetry-instrumentation-logging (>=0.60b1,<0.61)",
"aiohttp (>=3.13.2,<4.0.0)", "aiohttp (>=3.13.2,<4.0.0)",
"email-validator (>=2.3.0,<3.0.0)", "email-validator (>=2.3.0,<3.0.0)",
"pyjwt (>=2.10.1,<3.0.0)", "pyjwt (>=2.10.1,<3.0.0)",
"psutil (>=7.2.1,<8.0.0)",
] ]
[build-system] [build-system]

View File

@@ -9,16 +9,7 @@ import click
import uvicorn import uvicorn
from dotenv import load_dotenv from dotenv import load_dotenv
from .config import LoggingConfig, {{ProjectName}}Config from .config import {{ProjectName}}Config
LogLevel = tp.Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]
def _run_uvicorn(configuration: dict[str, tp.Any]) -> tp.NoReturn:
uvicorn.run(
"{{project_slug}}.fastapi_init:app",
**configuration,
)
@click.group() @click.group()
@@ -42,34 +33,6 @@ def get_config_example(config_path: Path):
@cli.command("launch") @cli.command("launch")
@click.option(
"--port",
"-p",
envvar="PORT",
type=int,
show_envvar=True,
help="Service port number",
)
@click.option(
"--host",
envvar="HOST",
show_envvar=True,
help="Service HOST address",
)
@click.option(
"--logger_verbosity",
"-v",
type=click.Choice(("TRACE", "DEBUG", "INFO", "WARNING", "ERROR")),
envvar="LOGGER_VERBOSITY",
show_envvar=True,
help="Logger verbosity",
)
@click.option(
"--debug",
envvar="DEBUG",
is_flag=True,
help="Enable debug mode (auto-reload on change, traceback returned to user, etc.)",
)
@click.option( @click.option(
"--config_path", "--config_path",
envvar="CONFIG_PATH", envvar="CONFIG_PATH",
@@ -80,10 +43,6 @@ def get_config_example(config_path: Path):
help="Path to YAML configuration file", help="Path to YAML configuration file",
) )
def launch( def launch(
port: int,
host: str,
logger_verbosity: LogLevel,
debug: bool,
config_path: Path, config_path: Path,
): ):
""" """
@@ -97,10 +56,6 @@ def launch(
) )
config = {{ProjectName}}Config.load(config_path) config = {{ProjectName}}Config.load(config_path)
config.app.host = host or config.app.host
config.app.port = port or config.app.port
config.app.debug = debug or config.app.debug
config.observability.logging = config.observability.logging if logger_verbosity is None else LoggingConfig(level=logger_verbosity)
with tempfile.NamedTemporaryFile(delete=False) as temp_file: with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_yaml_config_path = temp_file.name temp_yaml_config_path = temp_file.name
@@ -111,16 +66,18 @@ def launch(
env_file.write(f"CONFIG_PATH={temp_yaml_config_path}\n") env_file.write(f"CONFIG_PATH={temp_yaml_config_path}\n")
try: try:
uvicorn_config = { uvicorn_config = {
"host": config.app.host, "host": config.app.uvicorn.host,
"port": config.app.port, "port": config.app.uvicorn.port,
"log_level": config.observability.logging.level.lower(), "forwarded_allow_ips": config.app.uvicorn.forwarded_allow_ips,
"log_level": config.observability.logging.root_logger_level.lower(),
"env_file": temp_envfile_path, "env_file": temp_envfile_path,
"access_log": False,
} }
if config.app.debug: if config.app.debug:
try: try:
_run_uvicorn(uvicorn_config | {"reload": True}) _run_uvicorn(uvicorn_config | {"reload": True})
except: # pylint: disable=bare-except except: # pylint: disable=bare-except
print("Debug reload is not supported and will be disabled") print("Retrying with Uvicorn reload disabled")
_run_uvicorn(uvicorn_config) _run_uvicorn(uvicorn_config)
else: else:
_run_uvicorn(uvicorn_config) _run_uvicorn(uvicorn_config)
@@ -131,6 +88,13 @@ def launch(
os.remove(temp_yaml_config_path) os.remove(temp_yaml_config_path)
def _run_uvicorn(configuration: dict[str, tp.Any]) -> tp.NoReturn:
uvicorn.run(
"{{project_slug}}.fastapi_init:app",
**configuration,
)
if __name__ in ("__main__", "{{project_slug}}.__main__"): if __name__ in ("__main__", "{{project_slug}}.__main__"):
load_dotenv(os.environ.get("ENVFILE", ".env")) load_dotenv(os.environ.get("ENVFILE", ".env"))
cli() # pylint: disable=no-value-for-parameter cli() # pylint: disable=no-value-for-parameter

View File

@@ -4,12 +4,19 @@ from collections import OrderedDict
from dataclasses import asdict, dataclass, field, fields from dataclasses import asdict, dataclass, field, fields
from pathlib import Path from pathlib import Path
from types import NoneType, UnionType from types import NoneType, UnionType
from typing import Any, Literal, TextIO, Type from typing import Any, Literal, TextIO, Type, Union, get_origin
import yaml import yaml
from {{project_slug}}.db.config import DBConfig, MultipleDBsConfig from {{project_slug}}.db.config import DBConfig, MultipleDBsConfig
from {{project_slug}}.utils.observability import LoggingConfig, FileLogger, ExporterConfig from {{project_slug}}.observability.config import (
ExporterConfig,
FileLogger,
JaegerConfig,
LoggingConfig,
ObservabilityConfig,
PrometheusConfig,
)
from {{project_slug}}.utils.secrets import SecretStr, representSecretStrYAML from {{project_slug}}.utils.secrets import SecretStr, representSecretStrYAML
@@ -22,32 +29,20 @@ class CORSConfig:
@dataclass @dataclass
class AppConfig: class UvicornConfig:
host: str host: str
port: int port: int
reload: bool = False
forwarded_allow_ips: list[str] = field(default_factory=list)
@dataclass
class AppConfig:
uvicorn: UvicornConfig
debug: bool debug: bool
cors: CORSConfig cors: CORSConfig
@dataclass
class PrometheusConfig:
host: str
port: int
urls_mapping: dict[str, str] = field(default_factory=dict)
@dataclass
class JaegerConfig:
endpoint: str
@dataclass
class ObservabilityConfig:
logging: LoggingConfig
prometheus: PrometheusConfig | None = None
jaeger: JaegerConfig | None = None
@dataclass @dataclass
class {{ProjectName}}Config: class {{ProjectName}}Config:
app: AppConfig app: AppConfig
@@ -96,7 +91,11 @@ class {{ProjectName}}Config:
"""Generate an example of configuration.""" """Generate an example of configuration."""
res = cls( res = cls(
app=AppConfig(host="0.0.0.0", port=8080, debug=False, cors=CORSConfig(["*"], ["*"], ["*"], True)), app=AppConfig(
uvicorn=UvicornConfig(host="0.0.0.0", port=8080, reload=True, forwarded_allow_ips=["127.0.0.1"]),
debug=True,
cors=CORSConfig(["*"], ["*"], ["*"], True),
),
db=MultipleDBsConfig( db=MultipleDBsConfig(
master=DBConfig( master=DBConfig(
host="localhost", host="localhost",
@@ -119,12 +118,12 @@ class {{ProjectName}}Config:
), ),
observability=ObservabilityConfig( observability=ObservabilityConfig(
logging=LoggingConfig( logging=LoggingConfig(
level="INFO", stderr_level="INFO",
root_logger_level="INFO", root_logger_level="INFO",
exporter=ExporterConfig(endpoint="http://127.0.0.1:4317", level="INFO", tls_insecure=True), exporter=ExporterConfig(endpoint="http://127.0.0.1:4317", level="INFO", tls_insecure=True),
files=[FileLogger(filename="logs/info.log", level="INFO")], files=[FileLogger(filename="logs/info.log", level="INFO")],
), ),
prometheus=PrometheusConfig(host="0.0.0.0", port=9090, urls_mapping={"/api/debug/.*": "/api/debug/*"}), prometheus=PrometheusConfig(host="0.0.0.0", port=9090),
jaeger=JaegerConfig(endpoint="http://127.0.0.1:4318/v1/traces"), jaeger=JaegerConfig(endpoint="http://127.0.0.1:4318/v1/traces"),
), ),
) )
@@ -152,7 +151,7 @@ class {{ProjectName}}Config:
"""Try to initialize given type field-by-field recursively with data from dictionary substituting {} and None """Try to initialize given type field-by-field recursively with data from dictionary substituting {} and None
if no value provided. if no value provided.
""" """
if isinstance(t, UnionType): if get_origin(t) is Union or get_origin(t) is UnionType: # both actually required
for inner_type in t.__args__: for inner_type in t.__args__:
if inner_type is NoneType and data is None: if inner_type is NoneType and data is None:
return None return None

View File

@@ -1,34 +0,0 @@
"""Authentication dependency function is defined here."""
from dataclasses import dataclass
import jwt
from fastapi import Request
from . import logger_dep
@dataclass
class AuthenticationData:
api_key: str | None
jwt_payload: dict | None
jwt_original: str | None
def obtain(request: Request) -> AuthenticationData:
if hasattr(request.state, "auth_dep"):
return request.state.auth_dep
auth = AuthenticationData(None, None, None)
if (value := request.headers.get("X-API-Key")) is not None:
auth.api_key = value
if (value := request.headers.get("Authorization")) is not None and value.startswith("Bearer "):
value = value[7:]
auth.jwt_original = value
try:
auth.jwt_payload = jwt.decode(value, algorithms=["HS256"], options={"verify_signature": False})
except Exception: # pylint: disable=broad-except
logger = logger_dep.obtain(request)
logger.warning("failed to parse Authorization header as jwt", value=value)
logger.debug("failed to parse Authorization header as jwt", exc_info=True)
request.state.auth_dep = auth
return auth

View File

@@ -0,0 +1,46 @@
"""Authentication dependency function is defined here."""
from dataclasses import dataclass
import jwt
from fastapi import Request
from {{project_slug}}.exceptions.auth import NotAuthorizedError
from . import logger_dep
@dataclass
class AuthenticationData:
api_key: str | None
jwt_payload: dict | None
def _from_request(request: Request, required: bool = True) -> AuthenticationData | None:
if not hasattr(request.state, "auth_dep"):
auth = AuthenticationData(None, None)
if (value := request.headers.get("X-API-Key")) is not None:
auth.api_key = value
if (value := request.headers.get("Authorization")) is not None and value.startswith("Bearer "):
value = value[7:]
try:
auth.jwt_payload = jwt.decode(value, algorithms=["HS256"], options={"verify_signature": False})
except Exception: # pylint: disable=broad-except
logger = logger_dep.from_request(request)
logger.warning("failed to parse Authorization header as jwt", value=value)
logger.debug("failed to parse Authorization header as jwt", exc_info=True)
if auth.api_key is not None or auth.jwt_payload is not None:
request.state.auth_dep = auth
else:
request.state.auth_dep = None
if required and request.state.auth_dep is None:
raise NotAuthorizedError()
return request.state.auth_dep
def from_request_optional(request: Request) -> AuthenticationData | None:
return _from_request(request, required=False)
def from_request(request: Request) -> AuthenticationData:
return _from_request(request, required=True)

View File

@@ -18,10 +18,17 @@ def init_dispencer(app: FastAPI, connection_manager: PostgresConnectionManager)
app.state.postgres_connection_manager_dep = connection_manager app.state.postgres_connection_manager_dep = connection_manager
def obtain(app_or_request: FastAPI | Request) -> PostgresConnectionManager: def from_app(app: FastAPI) -> PostgresConnectionManager:
"""Get a PostgresConnectionManager from request's app state.""" """Get a connection_manager from app state."""
if isinstance(app_or_request, Request): if not hasattr(app.state, "postgres_connection_manager_dep"):
app_or_request = app_or_request.app
if not hasattr(app_or_request.state, "postgres_connection_manager_dep"):
raise ValueError("PostgresConnectionManager dispencer was not initialized at app preparation") raise ValueError("PostgresConnectionManager dispencer was not initialized at app preparation")
return app_or_request.state.postgres_connection_manager_dep return app.state.postgres_connection_manager_dep
async def from_request(request: Request) -> PostgresConnectionManager:
"""Get a PostgresConnectionManager from request or app state."""
if hasattr(request.state, "postgres_connection_manager_dep"):
connection_manager = request.state.postgres_connection_manager_dep
if isinstance(connection_manager, PostgresConnectionManager):
return connection_manager
return from_app(request.app)

View File

@@ -9,7 +9,7 @@ def init_dispencer(app: FastAPI, logger: BoundLogger) -> None:
if hasattr(app.state, "logger"): if hasattr(app.state, "logger"):
if not isinstance(app.state.logger_dep, BoundLogger): if not isinstance(app.state.logger_dep, BoundLogger):
raise ValueError( raise ValueError(
"logger attribute of app's state is already set" f"with other value ({app.state.logger_dep})" f"logger attribute of app's state is already set with other value ({app.state.logger_dep})"
) )
return return
@@ -24,14 +24,17 @@ def attach_to_request(request: Request, logger: BoundLogger) -> None:
request.state.logger_dep = logger request.state.logger_dep = logger
def obtain(app_or_request: FastAPI | Request) -> BoundLogger: def from_app(app: FastAPI) -> BoundLogger:
"""Get a logger from app state."""
if not hasattr(app.state, "logger_dep"):
raise ValueError("BoundLogger dispencer was not initialized at app preparation")
return app.state.logger_dep
def from_request(request: Request) -> BoundLogger:
"""Get a logger from request or app state.""" """Get a logger from request or app state."""
if isinstance(app_or_request, Request): if hasattr(request.state, "logger_dep"):
if hasattr(app_or_request.state, "logger_dep"): logger = request.state.logger_dep
logger = app_or_request.state.logger_dep
if isinstance(logger, BoundLogger): if isinstance(logger, BoundLogger):
return logger return logger
app_or_request = app_or_request.app return from_app(request.app)
if not hasattr(app_or_request.state, "logger_dep"):
raise ValueError("BoundLogger dispencer was not initialized at app preparation")
return app_or_request.state.logger_dep

View File

@@ -5,22 +5,25 @@ from fastapi import FastAPI, Request
from {{project_slug}}.observability.metrics import Metrics from {{project_slug}}.observability.metrics import Metrics
def init_dispencer(app: FastAPI, connection_manager: Metrics) -> None: def init_dispencer(app: FastAPI, metrics: Metrics) -> None:
"""Initialize Metrics dispencer at app's state.""" """Initialize Metrics dispencer at app's state."""
if hasattr(app.state, "metrics_dep"): if hasattr(app.state, "metrics_dep"):
if not isinstance(app.state.metrics_dep, Metrics): if not isinstance(app.state.metrics_dep, Metrics):
raise ValueError( raise ValueError(
"metrics_dep attribute of app's state is already set" f"with other value ({app.state.metrics_dep})" f"metrics_dep attribute of app's state is already set with other value ({app.state.metrics_dep})"
) )
return return
app.state.metrics_dep = connection_manager app.state.metrics_dep = metrics
def obtain(app_or_request: FastAPI | Request) -> Metrics: def from_app(app: FastAPI) -> Metrics:
"""Get a Metrics from request's app state.""" """Get a Metrics from app state."""
if isinstance(app_or_request, Request): if not hasattr(app.state, "metrics_dep"):
app_or_request = app_or_request.app
if not hasattr(app_or_request.state, "metrics_dep"):
raise ValueError("Metrics dispencer was not initialized at app preparation") raise ValueError("Metrics dispencer was not initialized at app preparation")
return app_or_request.state.metrics_dep return app.state.metrics_dep
async def from_request(request: Request) -> Metrics:
"""Get a Metrics from request's app state."""
return from_app(request.app)

View File

@@ -0,0 +1,7 @@
"""Authentication exceptions are located here."""
from {{project_slug}}.exceptions.base import {{ProjectName}}Error
class NotAuthorizedError({{ProjectName}}Error):
"""Exception to raise when user token is not set, but is required."""

View File

@@ -2,23 +2,37 @@
from typing import Callable, Type from typing import Callable, Type
from fastapi import HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException
class ExceptionMapper: class ExceptionMapper:
"""Maps exceptions to `JSONResponse` for FastAPI error handling."""
def __init__(self): def __init__(self):
self._known_exceptions: dict[Type, Callable[[Exception], JSONResponse]] = {} self._known_exceptions: dict[Type, Callable[[Exception], JSONResponse]] = {}
def register_simple(self, exception_type: Type, status_code: int, detail: str) -> None: def register_simple(self, exception_type: Type, status_code: int, details: str) -> None:
"""Register simple response handler with setting status_code and details."""
self._known_exceptions[exception_type] = lambda _: JSONResponse( self._known_exceptions[exception_type] = lambda _: JSONResponse(
{"code": status_code, "detail": detail}, status_code=status_code {"error": f"{exception_type.__module__}.{exception_type.__qualname__}", "details": details},
status_code=status_code,
) )
def register_func(self, exception_type: Type, func: Callable[[Exception], JSONResponse]) -> None: def register_func(self, exception_type: Type, func: Callable[[Exception], JSONResponse]) -> None:
"""Register complex response handler by passing function."""
self._known_exceptions[exception_type] = func self._known_exceptions[exception_type] = func
def get_status_code(self, exc: Exception) -> int:
"""Get status code of preparing response."""
if isinstance(exc, HTTPException):
return exc.status_code
if type(exc) in self._known_exceptions:
return self._known_exceptions[type(exc)](exc).status_code
return 500
def is_known(self, exc: Exception) -> bool: def is_known(self, exc: Exception) -> bool:
return type(exc) in self._known_exceptions or isinstance(exc, HTTPException) return type(exc) in self._known_exceptions or isinstance(exc, HTTPException)
def apply(self, exc: Exception) -> JSONResponse: def apply(self, exc: Exception) -> JSONResponse:

View File

@@ -2,8 +2,9 @@
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import NoReturn
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.openapi.docs import get_swagger_ui_html from fastapi.openapi.docs import get_swagger_ui_html
@@ -14,30 +15,17 @@ from {{project_slug}}.db.connection.manager import PostgresConnectionManager
from {{project_slug}}.dependencies import connection_manager_dep, logger_dep, metrics_dep from {{project_slug}}.dependencies import connection_manager_dep, logger_dep, metrics_dep
from {{project_slug}}.exceptions.mapper import ExceptionMapper from {{project_slug}}.exceptions.mapper import ExceptionMapper
from {{project_slug}}.handlers.debug import DebugException, DebugExceptionWithParams from {{project_slug}}.handlers.debug import DebugException, DebugExceptionWithParams
from {{project_slug}}.middlewares.exception_handler import ExceptionHandlerMiddleware from {{project_slug}}.middlewares.exception_handler import ExceptionHandlerMiddleware, HandlerNotFoundError
from {{project_slug}}.middlewares.observability import ObservabilityMiddleware from {{project_slug}}.middlewares.observability import ObservabilityMiddleware
from {{project_slug}}.observability.metrics import init_metrics from {{project_slug}}.observability.logging import configure_logging
from {{project_slug}}.observability.metrics import setup_metrics
from {{project_slug}}.observability.otel_agent import OpenTelemetryAgent from {{project_slug}}.observability.otel_agent import OpenTelemetryAgent
from {{project_slug}}.utils.observability import URLsMapper, configure_logging from {{project_slug}}.observability.utils import URLsMapper
from .handlers import list_of_routers from .handlers import list_of_routers
from .version import LAST_UPDATE, VERSION from .version import LAST_UPDATE, VERSION
def _get_exception_mapper(debug: bool) -> ExceptionMapper:
mapper = ExceptionMapper()
if debug:
mapper.register_simple(DebugException, 506, "That's how a debug exception look like")
mapper.register_func(
DebugExceptionWithParams,
lambda exc: JSONResponse(
{"error": "That's how a debug exception with params look like", "message": exc.message},
status_code=exc.status_code,
),
)
return mapper
def bind_routes(application: FastAPI, prefix: str, debug: bool) -> None: def bind_routes(application: FastAPI, prefix: str, debug: bool) -> None:
"""Bind all routes to application.""" """Bind all routes to application."""
for router in list_of_routers: for router in list_of_routers:
@@ -84,6 +72,10 @@ def get_app(prefix: str = "/api") -> FastAPI:
swagger_css_url="https://unpkg.com/swagger-ui-dist@5.11.7/swagger-ui.css", swagger_css_url="https://unpkg.com/swagger-ui-dist@5.11.7/swagger-ui.css",
) )
@application.exception_handler(404)
async def handle_404(request: Request, exc: Exception) -> NoReturn:
raise HandlerNotFoundError() from exc
application.add_middleware( application.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=app_config.app.cors.allow_origins, allow_origins=app_config.app.cors.allow_origins,
@@ -99,30 +91,34 @@ def get_app(prefix: str = "/api") -> FastAPI:
app_config.observability.logging, app_config.observability.logging,
tracing_enabled=app_config.observability.jaeger is not None, tracing_enabled=app_config.observability.jaeger is not None,
) )
metrics = init_metrics() metrics = setup_metrics()
exception_mapper = _get_exception_mapper(app_config.app.debug)
exception_mapper = ExceptionMapper()
_register_exceptions(exception_mapper, debug=app_config.app.debug)
connection_manager = PostgresConnectionManager( connection_manager = PostgresConnectionManager(
master=app_config.db.master, master=app_config.db.master,
replicas=app_config.db.replicas, replicas=app_config.db.replicas,
logger=logger, logger=logger,
application_name=f"{{project_slug}}_{VERSION}", application_name=f"{{project_slug}}_{VERSION}",
) )
urls_mapper = URLsMapper(app_config.observability.prometheus.urls_mapping) urls_mapper = URLsMapper()
urls_mapper.add_routes(application.routes)
connection_manager_dep.init_dispencer(application, connection_manager) connection_manager_dep.init_dispencer(application, connection_manager)
metrics_dep.init_dispencer(application, metrics) metrics_dep.init_dispencer(application, metrics)
logger_dep.init_dispencer(application, logger) logger_dep.init_dispencer(application, logger)
application.add_middleware( application.add_middleware(
ObservabilityMiddleware, ExceptionHandlerMiddleware,
debug=app_config.app.debug,
exception_mapper=exception_mapper, exception_mapper=exception_mapper,
metrics=metrics,
urls_mapper=urls_mapper, urls_mapper=urls_mapper,
errors_metric=metrics.http.errors,
) )
application.add_middleware( application.add_middleware(
ExceptionHandlerMiddleware, ObservabilityMiddleware,
debug=[app_config.app.debug], metrics=metrics,
exception_mapper=exception_mapper, urls_mapper=urls_mapper,
) )
return application return application
@@ -135,13 +131,9 @@ async def lifespan(application: FastAPI):
Initializes database connection in pass_services_dependencies middleware. Initializes database connection in pass_services_dependencies middleware.
""" """
app_config: {{ProjectName}}Config = application.state.config app_config: {{ProjectName}}Config = application.state.config
logger = logger_dep.obtain(application) logger = logger_dep.from_app(application)
await logger.ainfo("application is being configured", config=app_config.to_order_dict()) await logger.ainfo("application is starting", config=app_config.to_order_dict())
for middleware in application.user_middleware:
if middleware.cls == ExceptionHandlerMiddleware:
middleware.kwargs["debug"][0] = app_config.app.debug
otel_agent = OpenTelemetryAgent( otel_agent = OpenTelemetryAgent(
app_config.observability.prometheus, app_config.observability.prometheus,
@@ -153,4 +145,17 @@ async def lifespan(application: FastAPI):
otel_agent.shutdown() otel_agent.shutdown()
def _register_exceptions(mapper: ExceptionMapper, debug: bool) -> None:
if debug:
mapper.register_simple(DebugException, 506, "That's how a debug exception look like")
mapper.register_func(
DebugExceptionWithParams,
lambda exc: JSONResponse(
{"error": "That's how a debug exception with params look like", "message": exc.message},
status_code=exc.status_code,
),
)
return mapper
app = get_app() app = get_app()

View File

@@ -4,14 +4,14 @@ import asyncio
from typing import Literal from typing import Literal
import aiohttp import aiohttp
from fastapi import HTTPException, Request from fastapi import Depends, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from opentelemetry import trace from opentelemetry import trace
from starlette import status from starlette import status
from {{project_slug}}.dependencies import auth_dep from {{project_slug}}.dependencies import auth_dep
from {{project_slug}}.observability.utils import get_tracing_headers
from {{project_slug}}.schemas import PingResponse from {{project_slug}}.schemas import PingResponse
from {{project_slug}}.utils.observability import get_span_headers
from .routers import debug_errors_router from .routers import debug_errors_router
@@ -34,9 +34,7 @@ class DebugExceptionWithParams(Exception):
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
) )
async def get_status_code(status_code: int, as_exception: bool = False): async def get_status_code(status_code: int, as_exception: bool = False):
""" """Return given status code. If `as_exception` is set to True, return as HTTPException."""
Return given status code. If `as_exception` is set to True, return as HTTPException.
"""
if as_exception: if as_exception:
raise HTTPException( raise HTTPException(
status_code=status_code, detail=f"debugging with status code {status_code} as http_exception" status_code=status_code, detail=f"debugging with status code {status_code} as http_exception"
@@ -50,9 +48,7 @@ async def get_status_code(status_code: int, as_exception: bool = False):
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
) )
async def get_exception(error_type: Literal["RuntimeError", "DebugException", "DebugExceptionWithParams"]): async def get_exception(error_type: Literal["RuntimeError", "DebugException", "DebugExceptionWithParams"]):
""" """Raise exception: `RuntimeError`, `DebugException` or `DebugExceptionWithParams` depending on given parameter."""
Raise exception: `RuntimeError`, `DebugException` or `DebugExceptionWithParams` depending on given parameter.
"""
if error_type == "DebugException": if error_type == "DebugException":
raise DebugException() raise DebugException()
if error_type == "DebugExceptionWithParams": if error_type == "DebugExceptionWithParams":
@@ -88,7 +84,7 @@ async def inner_get(host: str = "http://127.0.0.1:8080"):
return asyncio.create_task( return asyncio.create_task(
session.get( session.get(
"/api/debug/tracing_check", "/api/debug/tracing_check",
headers=get_span_headers(), headers=get_tracing_headers(),
) )
) )
@@ -115,9 +111,7 @@ async def inner_get(host: str = "http://127.0.0.1:8080"):
"/authentication_info", "/authentication_info",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
) )
async def authentication_info(request: Request): async def authentication_info(auth: auth_dep.AuthenticationData = Depends(auth_dep.from_request)):
"""Check authentication data from `Authorization` and `X-API-Key` headers.""" """Check authentication data from `Authorization` and `X-API-Key` headers."""
auth = auth_dep.obtain(request)
return JSONResponse({"auth_data": {"x-api-key": auth.api_key, "jwt_payload": auth.jwt_payload}}) return JSONResponse({"auth_data": {"x-api-key": auth.api_key, "jwt_payload": auth.jwt_payload}})

View File

@@ -3,6 +3,7 @@
import fastapi import fastapi
from starlette import status from starlette import status
from {{project_slug}}.db.connection.manager import PostgresConnectionManager
from {{project_slug}}.dependencies import connection_manager_dep from {{project_slug}}.dependencies import connection_manager_dep
from {{project_slug}}.logic import system as system_logic from {{project_slug}}.logic import system as system_logic
from {{project_slug}}.schemas import PingResponse from {{project_slug}}.schemas import PingResponse
@@ -11,9 +12,9 @@ from .routers import system_router
@system_router.get("/", status_code=status.HTTP_307_TEMPORARY_REDIRECT, include_in_schema=False) @system_router.get("/", status_code=status.HTTP_307_TEMPORARY_REDIRECT, include_in_schema=False)
@system_router.get("/api/", status_code=status.HTTP_307_TEMPORARY_REDIRECT, include_in_schema=False) @system_router.get("/api", status_code=status.HTTP_307_TEMPORARY_REDIRECT, include_in_schema=False)
async def redirect_to_swagger_docs(): async def redirect_to_swagger_docs():
"""Redirects to **/api/docs** from **/**""" """Redirects to **/api/docs** from **/** and **/api**."""
return fastapi.responses.RedirectResponse("/api/docs", status_code=status.HTTP_307_TEMPORARY_REDIRECT) return fastapi.responses.RedirectResponse("/api/docs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@@ -34,10 +35,10 @@ async def ping():
response_model=PingResponse, response_model=PingResponse,
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
) )
async def ping_db(request: fastapi.Request, readonly: bool = False): async def ping_db(
""" readonly: bool = False,
Check that database connection is valid. connection_manager: PostgresConnectionManager = fastapi.Depends(connection_manager_dep.from_request),
""" ):
connection_manager = connection_manager_dep.obtain(request) """Check that database connection is valid."""
return await system_logic.ping_db(connection_manager, readonly) return await system_logic.ping_db(connection_manager, readonly)

View File

@@ -3,13 +3,17 @@
import itertools import itertools
import traceback import traceback
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from opentelemetry import trace
from opentelemetry.sdk.metrics import Counter
from opentelemetry.semconv.attributes import exception_attributes, http_attributes, url_attributes
from starlette.exceptions import HTTPException
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from {{project_slug}}.dependencies import logger_dep
from {{project_slug}}.exceptions.mapper import ExceptionMapper from {{project_slug}}.exceptions.mapper import ExceptionMapper
from {{project_slug}}.observability.utils import URLsMapper
from .observability import ObservableException
class ExceptionHandlerMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-public-methods class ExceptionHandlerMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-public-methods
@@ -22,31 +26,67 @@ class ExceptionHandlerMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few
def __init__( def __init__(
self, self,
app: FastAPI, app: FastAPI,
debug: list[bool], *,
debug: bool,
exception_mapper: ExceptionMapper, exception_mapper: ExceptionMapper,
): urls_mapper: URLsMapper,
errors_metric: Counter,
): # pylint: disable=too-many-arguments
"""Passing debug as a list with single element is a hack to be able to change the value on the fly.""" """Passing debug as a list with single element is a hack to be able to change the value on the fly."""
super().__init__(app) super().__init__(app)
self._debug = debug self._debug = debug
self._mapper = exception_mapper self._exception_mapper = exception_mapper
self._urls_mapper = urls_mapper
self._metric = errors_metric
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
try: try:
return await call_next(request) return await call_next(request)
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
additional_headers: dict[str, str] | None = None
if isinstance(exc, ObservableException):
additional_headers = {"X-Trace-Id": exc.trace_id, "X-Span-Id": str(exc.span_id)}
exc = exc.__cause__
status_code = 500 status_code = 500
detail = "exception occured" detail = "exception occured"
if isinstance(exc, HandlerNotFoundError):
exc = exc.__cause__
cause = exc
if isinstance(exc, HTTPException): if isinstance(exc, HTTPException):
status_code = exc.status_code # pylint: disable=no-member status_code = exc.status_code # pylint: disable=no-member
detail = exc.detail # pylint: disable=no-member detail = exc.detail # pylint: disable=no-member
if exc.__cause__ is not None:
cause = exc.__cause__
if self._debug[0]: self._metric.add(
if (res := self._mapper.apply_if_known(exc)) is not None: 1,
{
http_attributes.HTTP_REQUEST_METHOD: request.method,
url_attributes.URL_PATH: self._urls_mapper.map(request.method, request.url.path),
"error_type": type(cause).__qualname__,
http_attributes.HTTP_RESPONSE_STATUS_CODE: status_code,
},
)
span = trace.get_current_span()
logger = logger_dep.from_request(request)
is_known = self._exception_mapper.is_known(cause)
span.record_exception(cause, {"is_known": is_known})
if is_known:
log_func = logger.aerror
else:
log_func = logger.aexception
await log_func("failed to handle request", error_type=type(cause).__name__)
span.set_status(trace.StatusCode.ERROR)
span.set_attributes(
{
exception_attributes.EXCEPTION_TYPE: type(cause).__name__,
exception_attributes.EXCEPTION_MESSAGE: repr(cause),
http_attributes.HTTP_RESPONSE_STATUS_CODE: status_code,
}
)
if self._debug:
if (res := self._exception_mapper.apply_if_known(exc)) is not None:
response = res response = res
else: else:
response = JSONResponse( response = JSONResponse(
@@ -56,18 +96,23 @@ class ExceptionHandlerMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few
"error": str(exc), "error": str(exc),
"error_type": type(exc).__name__, "error_type": type(exc).__name__,
"path": request.url.path, "path": request.url.path,
"params": request.url.query, "query_params": request.url.query,
"tracebacks": _get_tracebacks(exc), "tracebacks": _get_tracebacks(exc),
}, },
status_code=status_code, status_code=status_code,
) )
else: else:
response = self._mapper.apply(exc) response = self._exception_mapper.apply(exc)
if additional_headers is not None:
response.headers.update(additional_headers)
return response return response
class HandlerNotFoundError(Exception):
"""Exception to raise on FastAPI 404 handler (only for situation when no handler was found for request).
Guranteed to have `.__cause__` as its parent exception.
"""
def _get_tracebacks(exc: Exception) -> list[list[str]]: def _get_tracebacks(exc: Exception) -> list[list[str]]:
tracebacks: list[list[str]] = [] tracebacks: list[list[str]] = []
while exc is not None: while exc is not None:

View File

@@ -1,59 +1,43 @@
"""Observability middleware is defined here.""" """Observability middleware is defined here."""
import time import time
from random import randint import uuid
import structlog from fastapi import FastAPI, Request
from fastapi import FastAPI, HTTPException, Request, Response
from opentelemetry import context as tracing_context from opentelemetry import context as tracing_context
from opentelemetry import trace from opentelemetry import trace
from opentelemetry.semconv.attributes import exception_attributes, http_attributes, url_attributes from opentelemetry.semconv.attributes import http_attributes, url_attributes
from opentelemetry.trace import NonRecordingSpan, Span, SpanContext, TraceFlags from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from {{project_slug}}.dependencies import logger_dep from {{project_slug}}.dependencies import logger_dep
from {{project_slug}}.exceptions.mapper import ExceptionMapper
from {{project_slug}}.observability.metrics import Metrics from {{project_slug}}.observability.metrics import Metrics
from {{project_slug}}.utils.observability import URLsMapper, get_handler_from_path from {{project_slug}}.observability.utils import URLsMapper, get_tracing_headers
_tracer = trace.get_tracer_provider().get_tracer(__name__) _tracer = trace.get_tracer_provider().get_tracer(__name__)
class ObservableException(RuntimeError):
"""Runtime Error with `trace_id` and `span_id` set. Guranteed to have `.__cause__` as its parent exception."""
def __init__(self, trace_id: str, span_id: int):
super().__init__()
self.trace_id = trace_id
self.span_id = span_id
class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-public-methods class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-public-methods
"""Middleware for global observability requests. """Middleware for global observability requests.
- Generate tracing span and adds response header 'X-Trace-Id' and X-Span-Id' - Generate tracing span and adds response headers
'X-Trace-Id', 'X-Span-Id' (if tracing is configured) and 'X-Request-Id'
- Binds trace_id it to logger passing it in request state (`request.state.logger`) - Binds trace_id it to logger passing it in request state (`request.state.logger`)
- Collects metrics for Prometheus - Collects metrics for Prometheus
In case when jaeger is not enabled, trace_id and span_id are generated randomly.
""" """
def __init__(self, app: FastAPI, exception_mapper: ExceptionMapper, metrics: Metrics, urls_mapper: URLsMapper): def __init__(self, app: FastAPI, metrics: Metrics, urls_mapper: URLsMapper):
super().__init__(app) super().__init__(app)
self._exception_mapper = exception_mapper
self._http_metrics = metrics.http self._http_metrics = metrics.http
self._urls_mapper = urls_mapper self._urls_mapper = urls_mapper
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
logger = logger_dep.obtain(request) logger = logger_dep.from_request(request)
_try_get_parent_span_id(request) _try_get_parent_span_id(request)
with _tracer.start_as_current_span("http-request", record_exception=False) as span: with _tracer.start_as_current_span("http request") as span:
trace_id = hex(span.get_span_context().trace_id or randint(1, 1 << 63))[2:] request_id = str(uuid.uuid4())
span_id = span.get_span_context().span_id or randint(1, 1 << 31) logger = logger.bind(request_id=request_id)
if trace_id == 0:
trace_id = format(randint(1, 1 << 63), "016x")
span_id = format(randint(1, 1 << 31), "032x")
logger = logger.bind(trace_id=trace_id, span_id=span_id)
logger_dep.attach_to_request(request, logger) logger_dep.attach_to_request(request, logger)
span.set_attributes( span.set_attributes(
@@ -62,106 +46,46 @@ class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-pu
url_attributes.URL_PATH: request.url.path, url_attributes.URL_PATH: request.url.path,
url_attributes.URL_QUERY: str(request.query_params), url_attributes.URL_QUERY: str(request.query_params),
"request_client": request.client.host, "request_client": request.client.host,
"request_id": request_id,
} }
) )
await logger.ainfo( await logger.ainfo(
"handling request", "http begin",
client=request.client.host, client=request.client.host,
path_params=request.path_params, path_params=request.path_params,
method=request.method, method=request.method,
url=str(request.url), url=str(request.url),
) )
path_for_metric = self._urls_mapper.map(request.url.path) path_for_metric = self._urls_mapper.map(request.method, request.url.path)
self._http_metrics.requests_started.add(1, {"method": request.method, "path": path_for_metric}) self._http_metrics.requests_started.add(1, {"method": request.method, "path": path_for_metric})
self._http_metrics.inflight_requests.add(1)
time_begin = time.monotonic() time_begin = time.monotonic()
try:
result = await call_next(request) result = await call_next(request)
duration_seconds = time.monotonic() - time_begin duration_seconds = time.monotonic() - time_begin
result.headers.update({"X-Trace-Id": trace_id, "X-Span-Id": str(span_id)}) result.headers.update({"X-Request-Id": request_id} | get_tracing_headers())
await self._handle_success(
request=request, await logger.ainfo("http end", time_consumed=round(duration_seconds, 3), status_code=result.status_code)
result=result, self._http_metrics.requests_finished.add(
logger=logger, 1,
span=span, {
path_for_metric=path_for_metric, http_attributes.HTTP_REQUEST_METHOD: request.method,
duration_seconds=duration_seconds, url_attributes.URL_PATH: path_for_metric,
http_attributes.HTTP_RESPONSE_STATUS_CODE: result.status_code,
},
) )
return result self._http_metrics.inflight_requests.add(-1)
except Exception as exc:
duration_seconds = time.monotonic() - time_begin if result.status_code // 100 == 2:
await self._handle_exception( span.set_status(trace.StatusCode.OK)
request=request, exc=exc, logger=logger, span=span, duration_seconds=duration_seconds span.set_attribute(http_attributes.HTTP_RESPONSE_STATUS_CODE, result.status_code)
)
raise ObservableException(trace_id=trace_id, span_id=span_id) from exc
finally:
self._http_metrics.request_processing_duration.record( self._http_metrics.request_processing_duration.record(
duration_seconds, {"method": request.method, "path": path_for_metric} duration_seconds, {"method": request.method, "path": path_for_metric}
) )
return result
async def _handle_success( # pylint: disable=too-many-arguments
self,
*,
request: Request,
result: Response,
logger: structlog.stdlib.BoundLogger,
span: Span,
path_for_metric: str,
duration_seconds: float,
) -> None:
await logger.ainfo("request handled successfully", time_consumed=round(duration_seconds, 3))
self._http_metrics.requests_finished.add(
1, {"method": request.method, "path": path_for_metric, "status_code": result.status_code}
)
span.set_attribute(http_attributes.HTTP_RESPONSE_STATUS_CODE, result.status_code)
async def _handle_exception( # pylint: disable=too-many-arguments
self,
*,
request: Request,
exc: Exception,
logger: structlog.stdlib.BoundLogger,
span: Span,
duration_seconds: float,
) -> None:
cause = exc
status_code = 500
if isinstance(exc, HTTPException):
status_code = getattr(exc, "status_code")
if exc.__cause__ is not None:
cause = exc.__cause__
self._http_metrics.errors.add(
1,
{
"method": request.method,
"path": get_handler_from_path(request.url.path),
"error_type": type(cause).__name__,
"status_code": status_code,
},
)
span.record_exception(exc)
if self._exception_mapper.is_known(exc):
log_func = logger.aerror
else:
log_func = logger.aexception
await log_func(
"failed to handle request", time_consumed=round(duration_seconds, 3), error_type=type(exc).__name__
)
span.set_attributes(
{
exception_attributes.EXCEPTION_TYPE: type(exc).__name__,
exception_attributes.EXCEPTION_MESSAGE: repr(exc),
http_attributes.HTTP_RESPONSE_STATUS_CODE: status_code,
}
)
def _try_get_parent_span_id(request: Request) -> None: def _try_get_parent_span_id(request: Request) -> None:
@@ -171,11 +95,14 @@ def _try_get_parent_span_id(request: Request) -> None:
if trace_id_str is None or span_id_str is None: if trace_id_str is None or span_id_str is None:
return return
if not trace_id_str.isnumeric() or not span_id_str.isnumeric(): if not trace_id_str.isalnum() or not span_id_str.isalnum():
return return
try:
span_context = SpanContext( span_context = SpanContext(
trace_id=int(trace_id_str), span_id=int(span_id_str), is_remote=True, trace_flags=TraceFlags(0x01) trace_id=int(trace_id_str, 16), span_id=int(span_id_str, 16), is_remote=True, trace_flags=TraceFlags(0x01)
) )
except Exception: # pylint: disable=broad-except
return
ctx = trace.set_span_in_context(NonRecordingSpan(span_context)) ctx = trace.set_span_in_context(NonRecordingSpan(span_context))
tracing_context.attach(ctx) tracing_context.attach(ctx)

View File

@@ -0,0 +1,49 @@
"""Observability config is defined here."""
from dataclasses import dataclass, field
from typing import Literal
LoggingLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
@dataclass
class ExporterConfig:
endpoint: str
level: LoggingLevel = "INFO"
tls_insecure: bool = False
@dataclass
class FileLogger:
filename: str
level: LoggingLevel
@dataclass
class LoggingConfig:
root_logger_level: LoggingLevel = "INFO"
stderr_level: LoggingLevel | None = None
exporter: ExporterConfig | None = None
files: list[FileLogger] = field(default_factory=list)
def __post_init__(self):
if len(self.files) > 0 and isinstance(self.files[0], dict):
self.files = [FileLogger(**f) for f in self.files]
@dataclass
class PrometheusConfig:
host: str
port: int
@dataclass
class JaegerConfig:
endpoint: str
@dataclass
class ObservabilityConfig:
logging: LoggingConfig
prometheus: PrometheusConfig | None = None
jaeger: JaegerConfig | None = None

View File

@@ -0,0 +1,165 @@
"""Observability helper functions are defined here."""
import json
import logging
import sys
from pathlib import Path
from typing import Any
import structlog
from opentelemetry import trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import (
LoggerProvider,
LoggingHandler,
LogRecordProcessor,
ReadWriteLogRecord,
)
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.util.types import Attributes
from {{project_slug}}.observability.otel_agent import get_resource
from .config import ExporterConfig, FileLogger, LoggingConfig, LoggingLevel
def configure_logging(
config: LoggingConfig,
tracing_enabled: bool,
) -> structlog.stdlib.BoundLogger:
processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
]
if tracing_enabled:
processors.insert(len(processors) - 1, _add_open_telemetry_spans)
structlog.configure(
processors=processors,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
root_logger = logging.getLogger()
root_logger.setLevel(config.root_logger_level)
if config.stderr_level is not None:
_configure_stderr_logger(root_logger, config.stderr_level)
if len(config.files) > 0:
_configure_file_loggers(root_logger, config.files)
if config.exporter is not None:
_configure_otel_exporter(root_logger, config.exporter)
logger: structlog.stdlib.BoundLogger = structlog.get_logger("{{project_name}}")
logger.setLevel(_level_name_mapping[config.root_logger_level])
return logger
_level_name_mapping: dict[LoggingLevel, int] = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}
def _configure_stderr_logger(root_logger: logging.Logger, level: LoggingLevel) -> None:
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setFormatter(
structlog.stdlib.ProcessorFormatter(processor=structlog.dev.ConsoleRenderer(colors=True))
)
stderr_handler.setLevel(_level_name_mapping[level])
root_logger.addHandler(stderr_handler)
def _configure_file_loggers(root_logger: logging.Logger, config_files: list[FileLogger]) -> None:
files = {logger_config.filename: logger_config.level for logger_config in config_files}
for filename, level in files.items():
try:
Path(filename).parent.mkdir(parents=True, exist_ok=True)
except Exception as exc: # pylint: disable=broad-except
print(f"Cannot create directory for log file {filename}, application will crash most likely. {exc!r}")
file_handler = logging.FileHandler(filename=filename, encoding="utf-8")
file_handler.setFormatter(structlog.stdlib.ProcessorFormatter(processor=structlog.processors.JSONRenderer()))
file_handler.setLevel(_level_name_mapping[level])
root_logger.addHandler(file_handler)
def _configure_otel_exporter(root_logger: logging.Logger, config: ExporterConfig) -> None:
logger_provider = LoggerProvider(resource=get_resource())
set_logger_provider(logger_provider)
otlp_exporter = OTLPLogExporter(endpoint=config.endpoint, insecure=config.tls_insecure)
logger_provider.add_log_record_processor(OtelLogPreparationProcessor())
logger_provider.add_log_record_processor(BatchLogRecordProcessor(otlp_exporter))
exporter_handler = AttrFilteredLoggingHandler(
level=config.level,
logger_provider=logger_provider,
)
exporter_handler.setLevel(_level_name_mapping[config.level])
root_logger.addHandler(exporter_handler)
def _add_open_telemetry_spans(_, __, event_dict: dict):
span = trace.get_current_span()
if not span or not span.is_recording():
return event_dict
ctx = span.get_span_context()
event_dict["span_id"] = format(ctx.span_id, "016x")
event_dict["trace_id"] = format(ctx.trace_id, "032x")
return event_dict
class AttrFilteredLoggingHandler(LoggingHandler):
DROP_ATTRIBUTES = ["_logger"]
@staticmethod
def _get_attributes(record: logging.LogRecord) -> Attributes:
attributes = LoggingHandler._get_attributes(record)
for attr in AttrFilteredLoggingHandler.DROP_ATTRIBUTES:
if attr in attributes:
del attributes[attr]
return attributes
class OtelLogPreparationProcessor(LogRecordProcessor):
"""Processor which moves everything except message from log record body to attributes."""
def on_emit(self, log_record: ReadWriteLogRecord) -> None:
if not isinstance(log_record.log_record.body, dict):
return
for key in log_record.log_record.body:
if key == "event":
continue
save_key = key
if key in log_record.log_record.attributes:
save_key = f"{key}__body"
log_record.log_record.attributes[save_key] = self._format_value(log_record.log_record.body[key])
log_record.log_record.body = log_record.log_record.body["event"]
def _format_value(self, value: Any) -> str:
if isinstance(value, (dict, list)):
return json.dumps(value)
return str(value)
def force_flush(self, timeout_millis=30000):
pass
def shutdown(self):
pass

View File

@@ -1,54 +0,0 @@
"""Application metrics are defined here."""
from dataclasses import dataclass
from opentelemetry import metrics
from opentelemetry.sdk.metrics import Counter, Histogram
@dataclass
class HTTPMetrics:
request_processing_duration: Histogram
"""Processing time histogram in seconds by `["method", "path"]`."""
requests_started: Counter
"""Total started requests counter by `["method", "path"]`."""
requests_finished: Counter
"""Total finished requests counter by `["method", "path", "status_code"]`."""
errors: Counter
"""Total errors (exceptions) counter by `["method", "path", "error_type", "status_code"]`."""
@dataclass
class Metrics:
http: HTTPMetrics
def init_metrics() -> Metrics:
meter = metrics.get_meter("{{project_name}}")
return Metrics(
http=HTTPMetrics(
request_processing_duration=meter.create_histogram(
"request_processing_duration",
"sec",
"Request processing duration time in seconds",
explicit_bucket_boundaries_advisory=[
0.05,
0.2,
0.3,
0.7,
1.0,
1.5,
2.5,
5.0,
10.0,
20.0,
40.0,
60.0,
120.0,
],
),
requests_started=meter.create_counter("requests_started_total", "1", "Total number of started requests"),
requests_finished=meter.create_counter("request_finished_total", "1", "Total number of finished requests"),
errors=meter.create_counter("request_errors_total", "1", "Total number of errors (exceptions) in requests"),
)
)

View File

@@ -0,0 +1,113 @@
"""Application metrics are defined here."""
import threading
import time
from dataclasses import dataclass
from typing import Callable
import psutil
from opentelemetry import metrics
from opentelemetry.metrics import CallbackOptions, Observation
from opentelemetry.sdk.metrics import Counter, Histogram, UpDownCounter
from {{project_slug}}.version import VERSION
@dataclass
class HTTPMetrics:
request_processing_duration: Histogram
"""Processing time histogram in seconds by `["method", "path"]`."""
requests_started: Counter
"""Total started requests counter by `["method", "path"]`."""
requests_finished: Counter
"""Total finished requests counter by `["method", "path", "status_code"]`."""
errors: Counter
"""Total errors (exceptions) counter by `["method", "path", "error_type", "status_code"]`."""
inflight_requests: UpDownCounter
"""Current number of requests handled simultaniously."""
@dataclass
class Metrics:
http: HTTPMetrics
def setup_metrics() -> Metrics:
meter = metrics.get_meter("{{project_name}}")
_setup_callback_metrics(meter)
return Metrics(
http=HTTPMetrics(
request_processing_duration=meter.create_histogram(
"request_processing_duration",
"sec",
"Request processing duration time in seconds",
explicit_bucket_boundaries_advisory=[
0.05,
0.2,
0.3,
0.7,
1.0,
1.5,
2.5,
5.0,
10.0,
20.0,
40.0,
60.0,
120.0,
],
),
requests_started=meter.create_counter("requests_started_total", "1", "Total number of started requests"),
requests_finished=meter.create_counter("request_finished_total", "1", "Total number of finished requests"),
errors=meter.create_counter("request_errors_total", "1", "Total number of errors (exceptions) in requests"),
inflight_requests=meter.create_up_down_counter(
"inflight_requests", "1", "Current number of requests handled simultaniously"
),
)
)
def _setup_callback_metrics(meter: metrics.Meter) -> None:
# Create observable gauge
meter.create_observable_gauge(
name="system_resource_usage",
description="System resource utilization",
unit="1",
callbacks=[_get_system_metrics_callback()],
)
meter.create_observable_gauge(
name="application_metrics",
description="Application-specific metrics",
unit="1",
callbacks=[_get_application_metrics_callback()],
)
def _get_system_metrics_callback() -> Callable[[CallbackOptions], None]:
def system_metrics_callback(options: CallbackOptions): # pylint: disable=unused-argument
"""Callback function to collect system metrics"""
# Process CPU time, a bit more information than `process_cpu_seconds_total`
cpu_times = psutil.Process().cpu_times()
yield Observation(cpu_times.user, {"resource": "cpu", "mode": "user"})
yield Observation(cpu_times.system, {"resource": "cpu", "mode": "system"})
return system_metrics_callback
def _get_application_metrics_callback() -> Callable[[CallbackOptions], None]:
startup_time = time.time()
def application_metrics_callback(options: CallbackOptions): # pylint: disable=unused-argument
"""Callback function to collect application-specific metrics"""
# Current timestamp
yield Observation(startup_time, {"metric": "startup_time", "version": VERSION})
yield Observation(time.time(), {"metric": "last_update_time", "version": VERSION})
# Active threads
active_threads = threading.active_count()
yield Observation(active_threads, {"metric": "active_threads"})
return application_metrics_callback

View File

@@ -1,10 +1,13 @@
"""Open Telemetry agent initialization is defined here""" """Open Telemetry agent initialization is defined here"""
import platform
from functools import cache
from opentelemetry import metrics, trace from opentelemetry import metrics, trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.prometheus import PrometheusMetricReader from opentelemetry.exporter.prometheus import PrometheusMetricReader
from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_VERSION, Resource from opentelemetry.sdk.resources import SERVICE_INSTANCE_ID, SERVICE_NAME, SERVICE_VERSION, Resource
from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.trace.export import BatchSpanProcessor
@@ -14,14 +17,16 @@ from {{project_slug}}.version import VERSION as APP_VERSION
from .metrics_server import PrometheusServer from .metrics_server import PrometheusServer
@cache
def get_resource() -> Resource:
return Resource.create(
attributes={SERVICE_NAME: "{{project_slug}}", SERVICE_VERSION: APP_VERSION, SERVICE_INSTANCE_ID: platform.node()}
)
class OpenTelemetryAgent: # pylint: disable=too-few-public-methods class OpenTelemetryAgent: # pylint: disable=too-few-public-methods
def __init__(self, prometheus_config: PrometheusConfig | None, jaeger_config: JaegerConfig | None): def __init__(self, prometheus_config: PrometheusConfig | None, jaeger_config: JaegerConfig | None):
self._resource = Resource.create( self._resource = get_resource()
attributes={
SERVICE_NAME: "{{project_name}}",
SERVICE_VERSION: APP_VERSION,
}
)
self._prometheus: PrometheusServer | None = None self._prometheus: PrometheusServer | None = None
self._span_exporter: OTLPSpanExporter | None = None self._span_exporter: OTLPSpanExporter | None = None

View File

@@ -0,0 +1,64 @@
"""Observability-related utility functions and classes are located here."""
import re
from collections import defaultdict
import fastapi
import structlog
from opentelemetry import trace
class URLsMapper:
"""Helper to change URL from given regex pattern to the given static value.
For example, with map {"GET": {"/api/debug/.*": "/api/debug/*"}} all GET-requests with URL
starting with "/api/debug/" will be placed in path "/api/debug/*" in metrics.
"""
def __init__(self, urls_map: dict[str, dict[str, str]] | None = None):
self._map: dict[str, dict[re.Pattern, str]] = defaultdict(dict)
"""[method -> [pattern -> mapped_to]]"""
if urls_map is not None:
for method, patterns in urls_map.items():
for pattern, value in patterns.items():
self.add(method, pattern, value)
def add(self, method: str, pattern: str, mapped_to: str) -> None:
"""Add entry to the map. If pattern compilation is failed, ValueError is raised."""
regexp = re.compile(pattern)
self._map[method.upper()][regexp] = mapped_to
def add_routes(self, routes: list[fastapi.routing.APIRoute]) -> None:
"""Add full route regexes to the map."""
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
for route in routes:
if not hasattr(route, "path_regex") or not hasattr(route, "path"):
logger.warning("route has no 'path_regex' or 'path' attribute", route=route)
continue
if "{" not in route.path: # ignore simple routes
continue
route_path = route.path
while "{" in route_path:
lbrace = route_path.index("{")
rbrace = route_path.index("}", lbrace + 1)
route_path = route_path[:lbrace] + "*" + route_path[rbrace + 1 :]
for method in route.methods:
self._map[method.upper()][route.path_regex] = route_path
def map(self, method: str, url: str) -> str:
"""Check every map entry with `re.match` and return matched value. If not found, return original string."""
for regexp, mapped_to in self._map[method.upper()].items():
if regexp.match(url) is not None:
return mapped_to
return url
def get_tracing_headers() -> dict[str, str]:
ctx = trace.get_current_span().get_span_context()
if ctx.trace_id == 0:
return {}
return {
"X-Span-Id": format(ctx.span_id, "016x"),
"X-Trace-Id": format(ctx.trace_id, "032x"),
}

View File

@@ -1,193 +0,0 @@
"""Observability helper functions are defined here."""
import logging
import platform
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
import structlog
from opentelemetry import trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.util.types import Attributes
LoggingLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
@dataclass
class ExporterConfig:
endpoint: str
level: LoggingLevel = "INFO"
tls_insecure: bool = False
@dataclass
class FileLogger:
filename: str
level: LoggingLevel
@dataclass
class LoggingConfig:
level: LoggingLevel
exporter: ExporterConfig | None
root_logger_level: LoggingLevel = "INFO"
files: list[FileLogger] = field(default_factory=list)
def __post_init__(self):
if len(self.files) > 0 and isinstance(self.files[0], dict):
self.files = [FileLogger(**f) for f in self.files]
def configure_logging(
config: LoggingConfig,
tracing_enabled: bool,
) -> structlog.stdlib.BoundLogger:
files = {logger_config.filename: logger_config.level for logger_config in config.files}
level_name_mapping = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}
log_level = level_name_mapping[config.level]
processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
]
if tracing_enabled:
def add_open_telemetry_spans(_, __, event_dict: dict):
span = trace.get_current_span()
if not span or not span.is_recording():
return event_dict
ctx = span.get_span_context()
event_dict["span_id"] = format(ctx.span_id, "016x")
event_dict["trace_id"] = format(ctx.trace_id, "032x")
return event_dict
processors.insert(len(processors) - 1, add_open_telemetry_spans)
structlog.configure(
processors=processors,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
logger: structlog.stdlib.BoundLogger = structlog.get_logger("main")
logger.setLevel(log_level)
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setFormatter(
structlog.stdlib.ProcessorFormatter(processor=structlog.dev.ConsoleRenderer(colors=True))
)
root_logger = logging.getLogger()
root_logger.addHandler(console_handler)
for filename, level in files.items():
try:
Path(filename).parent.mkdir(parents=True, exist_ok=True)
except Exception as exc:
print(f"Cannot create directory for log file {filename}, application will crash most likely. {exc!r}")
file_handler = logging.FileHandler(filename=filename, encoding="utf-8")
file_handler.setFormatter(structlog.stdlib.ProcessorFormatter(processor=structlog.processors.JSONRenderer()))
file_handler.setLevel(level_name_mapping[level])
root_logger.addHandler(file_handler)
root_logger.setLevel(config.root_logger_level)
if config.exporter is not None:
logger_provider = LoggerProvider(
resource=Resource.create(
{
"service.name": "{{project_name}}",
"service.instance.id": platform.node(),
}
),
)
set_logger_provider(logger_provider)
otlp_exporter = OTLPLogExporter(endpoint=config.exporter.endpoint, insecure=config.exporter.tls_insecure)
logger_provider.add_log_record_processor(BatchLogRecordProcessor(otlp_exporter))
exporter_handler = AttrFilteredLoggingHandler(
level=config.exporter.level,
logger_provider=logger_provider,
)
# exporter_handler.setFormatter(structlog.stdlib.ProcessorFormatter(processor=structlog.processors.JSONRenderer()))
exporter_handler.setLevel(level_name_mapping[config.exporter.level])
logger.addHandler(exporter_handler)
return logger
def get_handler_from_path(path: str) -> str:
parts = path.split("/")
return "/".join(part if not part.rstrip(".0").isdigit() else "*" for part in parts)
class URLsMapper:
"""Helper to change URL from given regex pattern to the given static value.
For example, with map {"/api/debug/.*": "/api/debug/*"} all requests with URL starting with "/api/debug/"
will be placed in path "/api/debug/*" in metrics.
"""
def __init__(self, urls_map: dict[str, str]):
self._map: dict[re.Pattern, str] = {}
for pattern, value in urls_map.items():
self.add(pattern, value)
def add(self, pattern: str, mapped_to: str) -> None:
"""Add entry to the map. If pattern compilation is failed, ValueError is raised."""
regexp = re.compile(pattern)
self._map[regexp] = mapped_to
def map(self, url: str) -> str:
"""Check every map entry with `re.match` and return matched value. If not found, return original string."""
for regexp, mapped_to in self._map.items():
if regexp.match(url) is not None:
return mapped_to
return url
def get_span_headers() -> dict[str, str]:
ctx = trace.get_current_span().get_span_context()
return {
"X-Span-Id": str(ctx.span_id),
"X-Trace-Id": str(ctx.trace_id),
}
class AttrFilteredLoggingHandler(LoggingHandler):
DROP_ATTRIBUTES = ["_logger"]
@staticmethod
def _get_attributes(record: logging.LogRecord) -> Attributes:
attributes = LoggingHandler._get_attributes(record)
for attr in AttrFilteredLoggingHandler.DROP_ATTRIBUTES:
if attr in attributes:
del attributes[attr]
return attributes