fastapi-ext-pkg 0.4.4__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. fastapi_ext_pkg-0.7.0/PKG-INFO +74 -0
  2. fastapi_ext_pkg-0.7.0/README.md +49 -0
  3. fastapi_ext_pkg-0.7.0/fastapi_ext_pkg.egg-info/PKG-INFO +74 -0
  4. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/fastapi_ext_pkg.egg-info/SOURCES.txt +11 -1
  5. fastapi_ext_pkg-0.7.0/fastapi_ext_pkg.egg-info/requires.txt +5 -0
  6. fastapi_ext_pkg-0.7.0/fastapi_extras/apm/__init__.py +19 -0
  7. fastapi_ext_pkg-0.7.0/fastapi_extras/apm/config.py +66 -0
  8. fastapi_ext_pkg-0.7.0/fastapi_extras/apm/constants.py +46 -0
  9. fastapi_ext_pkg-0.7.0/fastapi_extras/apm/decorators.py +127 -0
  10. fastapi_ext_pkg-0.7.0/fastapi_extras/apm/labels.py +20 -0
  11. fastapi_ext_pkg-0.7.0/fastapi_extras/apm/masking.py +87 -0
  12. fastapi_ext_pkg-0.7.0/fastapi_extras/apm/middleware.py +105 -0
  13. fastapi_ext_pkg-0.7.0/fastapi_extras/apm/processors.py +80 -0
  14. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/fastapi_extras/databases/redis.py +13 -2
  15. fastapi_ext_pkg-0.7.0/fastapi_extras/middlewares/__init__.py +3 -0
  16. fastapi_ext_pkg-0.7.0/fastapi_extras/middlewares/configure.py +98 -0
  17. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/fastapi_extras/security/auth.py +38 -16
  18. fastapi_ext_pkg-0.7.0/fastapi_extras/security/auth_async.py +180 -0
  19. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/pyproject.toml +4 -1
  20. fastapi_ext_pkg-0.4.4/PKG-INFO +0 -42
  21. fastapi_ext_pkg-0.4.4/README.md +0 -19
  22. fastapi_ext_pkg-0.4.4/fastapi_ext_pkg.egg-info/PKG-INFO +0 -42
  23. fastapi_ext_pkg-0.4.4/fastapi_ext_pkg.egg-info/requires.txt +0 -2
  24. fastapi_ext_pkg-0.4.4/fastapi_extras/security/__init__.py +0 -0
  25. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/fastapi_ext_pkg.egg-info/dependency_links.txt +0 -0
  26. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/fastapi_ext_pkg.egg-info/top_level.txt +0 -0
  27. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/fastapi_extras/__init__.py +0 -0
  28. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/fastapi_extras/databases/__init__.py +0 -0
  29. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/fastapi_extras/middlewares/remove_cloud_trace_headers.py +0 -0
  30. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/fastapi_extras/middlewares/remove_path.py +0 -0
  31. {fastapi_ext_pkg-0.4.4/fastapi_extras/middlewares → fastapi_ext_pkg-0.7.0/fastapi_extras/orm}/__init__.py +0 -0
  32. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/fastapi_extras/orm/sqlalchemy.py +0 -0
  33. {fastapi_ext_pkg-0.4.4/fastapi_extras/orm → fastapi_ext_pkg-0.7.0/fastapi_extras/security}/__init__.py +0 -0
  34. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.7.0}/setup.cfg +0 -0
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-ext-pkg
3
+ Version: 0.7.0
4
+ Summary: A well-tailored utility set for FastAPI.
5
+ Author-email: Dotz Developers <devs-dotz@dotz.com>
6
+ Project-URL: Repository, https://github.com/DotzInc/fastapi-ext-pkg
7
+ Keywords: fastapi,utilities
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.8
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Internet
16
+ Classifier: Topic :: Software Development
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: fastapi
22
+ Requires-Dist: httpx
23
+ Provides-Extra: apm
24
+ Requires-Dist: elastic-apm<7,>=6.20; extra == "apm"
25
+
26
+ # FastAPI Extension Package
27
+
28
+ ![Tests](https://github.com/DotzInc/fastapi-ext-pkg/actions/workflows/tests.yml/badge.svg?event=push)
29
+ ![PyPI - Version](https://img.shields.io/pypi/v/fastapi-ext-pkg)
30
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-ext-pkg)
31
+
32
+ A well-tailored utility set for FastAPI.
33
+
34
+ ## Requirements
35
+
36
+ * Python 3.8+
37
+
38
+ ## Installation
39
+
40
+ To install FastAPI Extension Package, use pip:
41
+
42
+ ```sh
43
+ pip install fastapi-ext-pkg
44
+ ```
45
+
46
+ ## Shared middleware configuration
47
+
48
+ `configure_middlewares` registers middleware on an existing FastAPI application
49
+ before startup. Configure service values centrally while preserving routers and
50
+ lifespan:
51
+
52
+ ```python
53
+ from fastapi_extras.middlewares import configure_middlewares
54
+
55
+ configure_middlewares(
56
+ app,
57
+ root_path=settings.ROOT_PATH,
58
+ apm_config=settings.ELASTIC_APM,
59
+ cors={"allow_origins": settings.ALLOW_ORIGINS},
60
+ )
61
+ ```
62
+
63
+ The default registration order is path removal, trace-header removal, APM,
64
+ `extra_middlewares`, then CORS. Incoming requests traverse it in reverse. Use
65
+ `order` to preserve a service-specific order; include every configured name
66
+ exactly once. `MiddlewareSpec` defines custom middleware and named `overrides`
67
+ for replacement or disabling (`enabled=False`).
68
+
69
+ Use `root_path=None`, `remove_cloud_trace_headers=False`, `apm_enabled=False`,
70
+ or `cors=None` to omit middleware. Pass either `apm_config` or `apm_client`.
71
+ APM uses the upstream `elasticapm.contrib.starlette.ElasticAPM` by default and
72
+ requires the optional `[apm]` dependency. This branch contains no custom APM
73
+ implementation. To register another implementation supplied by the caller, pass
74
+ `apm_middleware_class`; `apm_options` supplies its constructor options.
@@ -0,0 +1,49 @@
1
+ # FastAPI Extension Package
2
+
3
+ ![Tests](https://github.com/DotzInc/fastapi-ext-pkg/actions/workflows/tests.yml/badge.svg?event=push)
4
+ ![PyPI - Version](https://img.shields.io/pypi/v/fastapi-ext-pkg)
5
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-ext-pkg)
6
+
7
+ A well-tailored utility set for FastAPI.
8
+
9
+ ## Requirements
10
+
11
+ * Python 3.8+
12
+
13
+ ## Installation
14
+
15
+ To install FastAPI Extension Package, use pip:
16
+
17
+ ```sh
18
+ pip install fastapi-ext-pkg
19
+ ```
20
+
21
+ ## Shared middleware configuration
22
+
23
+ `configure_middlewares` registers middleware on an existing FastAPI application
24
+ before startup. Configure service values centrally while preserving routers and
25
+ lifespan:
26
+
27
+ ```python
28
+ from fastapi_extras.middlewares import configure_middlewares
29
+
30
+ configure_middlewares(
31
+ app,
32
+ root_path=settings.ROOT_PATH,
33
+ apm_config=settings.ELASTIC_APM,
34
+ cors={"allow_origins": settings.ALLOW_ORIGINS},
35
+ )
36
+ ```
37
+
38
+ The default registration order is path removal, trace-header removal, APM,
39
+ `extra_middlewares`, then CORS. Incoming requests traverse it in reverse. Use
40
+ `order` to preserve a service-specific order; include every configured name
41
+ exactly once. `MiddlewareSpec` defines custom middleware and named `overrides`
42
+ for replacement or disabling (`enabled=False`).
43
+
44
+ Use `root_path=None`, `remove_cloud_trace_headers=False`, `apm_enabled=False`,
45
+ or `cors=None` to omit middleware. Pass either `apm_config` or `apm_client`.
46
+ APM uses the upstream `elasticapm.contrib.starlette.ElasticAPM` by default and
47
+ requires the optional `[apm]` dependency. This branch contains no custom APM
48
+ implementation. To register another implementation supplied by the caller, pass
49
+ `apm_middleware_class`; `apm_options` supplies its constructor options.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-ext-pkg
3
+ Version: 0.7.0
4
+ Summary: A well-tailored utility set for FastAPI.
5
+ Author-email: Dotz Developers <devs-dotz@dotz.com>
6
+ Project-URL: Repository, https://github.com/DotzInc/fastapi-ext-pkg
7
+ Keywords: fastapi,utilities
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.8
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Internet
16
+ Classifier: Topic :: Software Development
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: fastapi
22
+ Requires-Dist: httpx
23
+ Provides-Extra: apm
24
+ Requires-Dist: elastic-apm<7,>=6.20; extra == "apm"
25
+
26
+ # FastAPI Extension Package
27
+
28
+ ![Tests](https://github.com/DotzInc/fastapi-ext-pkg/actions/workflows/tests.yml/badge.svg?event=push)
29
+ ![PyPI - Version](https://img.shields.io/pypi/v/fastapi-ext-pkg)
30
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-ext-pkg)
31
+
32
+ A well-tailored utility set for FastAPI.
33
+
34
+ ## Requirements
35
+
36
+ * Python 3.8+
37
+
38
+ ## Installation
39
+
40
+ To install FastAPI Extension Package, use pip:
41
+
42
+ ```sh
43
+ pip install fastapi-ext-pkg
44
+ ```
45
+
46
+ ## Shared middleware configuration
47
+
48
+ `configure_middlewares` registers middleware on an existing FastAPI application
49
+ before startup. Configure service values centrally while preserving routers and
50
+ lifespan:
51
+
52
+ ```python
53
+ from fastapi_extras.middlewares import configure_middlewares
54
+
55
+ configure_middlewares(
56
+ app,
57
+ root_path=settings.ROOT_PATH,
58
+ apm_config=settings.ELASTIC_APM,
59
+ cors={"allow_origins": settings.ALLOW_ORIGINS},
60
+ )
61
+ ```
62
+
63
+ The default registration order is path removal, trace-header removal, APM,
64
+ `extra_middlewares`, then CORS. Incoming requests traverse it in reverse. Use
65
+ `order` to preserve a service-specific order; include every configured name
66
+ exactly once. `MiddlewareSpec` defines custom middleware and named `overrides`
67
+ for replacement or disabling (`enabled=False`).
68
+
69
+ Use `root_path=None`, `remove_cloud_trace_headers=False`, `apm_enabled=False`,
70
+ or `cors=None` to omit middleware. Pass either `apm_config` or `apm_client`.
71
+ APM uses the upstream `elasticapm.contrib.starlette.ElasticAPM` by default and
72
+ requires the optional `[apm]` dependency. This branch contains no custom APM
73
+ implementation. To register another implementation supplied by the caller, pass
74
+ `apm_middleware_class`; `apm_options` supplies its constructor options.
@@ -6,12 +6,22 @@ fastapi_ext_pkg.egg-info/dependency_links.txt
6
6
  fastapi_ext_pkg.egg-info/requires.txt
7
7
  fastapi_ext_pkg.egg-info/top_level.txt
8
8
  fastapi_extras/__init__.py
9
+ fastapi_extras/apm/__init__.py
10
+ fastapi_extras/apm/config.py
11
+ fastapi_extras/apm/constants.py
12
+ fastapi_extras/apm/decorators.py
13
+ fastapi_extras/apm/labels.py
14
+ fastapi_extras/apm/masking.py
15
+ fastapi_extras/apm/middleware.py
16
+ fastapi_extras/apm/processors.py
9
17
  fastapi_extras/databases/__init__.py
10
18
  fastapi_extras/databases/redis.py
11
19
  fastapi_extras/middlewares/__init__.py
20
+ fastapi_extras/middlewares/configure.py
12
21
  fastapi_extras/middlewares/remove_cloud_trace_headers.py
13
22
  fastapi_extras/middlewares/remove_path.py
14
23
  fastapi_extras/orm/__init__.py
15
24
  fastapi_extras/orm/sqlalchemy.py
16
25
  fastapi_extras/security/__init__.py
17
- fastapi_extras/security/auth.py
26
+ fastapi_extras/security/auth.py
27
+ fastapi_extras/security/auth_async.py
@@ -0,0 +1,5 @@
1
+ fastapi
2
+ httpx
3
+
4
+ [apm]
5
+ elastic-apm<7,>=6.20
@@ -0,0 +1,19 @@
1
+ from fastapi_extras.apm.config import build_apm_config
2
+ from fastapi_extras.apm.constants import DEFAULT_SANITIZE_FIELD_NAMES
3
+ from fastapi_extras.apm.decorators import capture_http_request
4
+ from fastapi_extras.apm.labels import label_transaction
5
+ from fastapi_extras.apm.masking import mask_sensitive_data, normalize_span_value
6
+ from fastapi_extras.apm.middleware import ElasticAPMMiddleware
7
+ from fastapi_extras.apm.processors import ALL_PROCESSORS, PROCESSORS
8
+
9
+ __all__ = [
10
+ "ALL_PROCESSORS",
11
+ "DEFAULT_SANITIZE_FIELD_NAMES",
12
+ "PROCESSORS",
13
+ "ElasticAPMMiddleware",
14
+ "build_apm_config",
15
+ "capture_http_request",
16
+ "label_transaction",
17
+ "mask_sensitive_data",
18
+ "normalize_span_value",
19
+ ]
@@ -0,0 +1,66 @@
1
+ from typing import Any, Dict, Iterable, List, Optional, Union
2
+
3
+ import fastapi
4
+
5
+ from fastapi_extras.apm.constants import (
6
+ DEFAULT_CAPTURE_BODY,
7
+ DEFAULT_CLOUD_PROVIDER,
8
+ DEFAULT_LOG_LEVEL,
9
+ DEFAULT_SANITIZE_FIELD_NAMES,
10
+ )
11
+ from fastapi_extras.apm.processors import ALL_PROCESSORS
12
+
13
+ FieldNamesInput = Union[str, Iterable[str]]
14
+
15
+
16
+ def parse_field_names(value: FieldNamesInput) -> List[str]:
17
+ items = value.split(",") if isinstance(value, str) else value
18
+ return [item.strip() for item in items if item.strip()]
19
+
20
+
21
+ def unique(items: Iterable[str]) -> List[str]:
22
+ return list(dict.fromkeys(items))
23
+
24
+
25
+ def has_credentials(server_url: Optional[str], secret_token: Optional[str]) -> bool:
26
+ return bool((server_url or "").strip() and (secret_token or "").strip())
27
+
28
+
29
+ def build_apm_config(
30
+ service_name: str,
31
+ *,
32
+ server_url: Optional[str] = "",
33
+ secret_token: Optional[str] = "",
34
+ environment: str = "",
35
+ enabled: Optional[bool] = None,
36
+ log_level: str = DEFAULT_LOG_LEVEL,
37
+ capture_body: str = DEFAULT_CAPTURE_BODY,
38
+ sanitize_field_names: Optional[FieldNamesInput] = None,
39
+ extra_sanitize_field_names: FieldNamesInput = (),
40
+ extra_processors: Iterable[str] = (),
41
+ **overrides: Any,
42
+ ) -> Dict[str, Any]:
43
+ base_field_names = (
44
+ DEFAULT_SANITIZE_FIELD_NAMES
45
+ if sanitize_field_names is None
46
+ else parse_field_names(sanitize_field_names)
47
+ )
48
+ config: Dict[str, Any] = {
49
+ "ENABLED": has_credentials(server_url, secret_token) if enabled is None else enabled,
50
+ "SERVICE_NAME": service_name,
51
+ "SECRET_TOKEN": secret_token or "",
52
+ "SERVER_URL": server_url or "",
53
+ "ENVIRONMENT": environment,
54
+ "LOG_LEVEL": log_level,
55
+ "CLOUD_PROVIDER": DEFAULT_CLOUD_PROVIDER,
56
+ "FRAMEWORK_NAME": "fastapi",
57
+ "FRAMEWORK_VERSION": fastapi.__version__,
58
+ "CAPTURE_HEADERS": True,
59
+ "CAPTURE_BODY": capture_body,
60
+ "SANITIZE_FIELD_NAMES": unique(
61
+ [*base_field_names, *parse_field_names(extra_sanitize_field_names)]
62
+ ),
63
+ "PROCESSORS": (*ALL_PROCESSORS, *extra_processors),
64
+ }
65
+ config.update({key.upper(): value for key, value in overrides.items()})
66
+ return config
@@ -0,0 +1,46 @@
1
+ EXTERNAL = "external"
2
+ UNKNOWN = "UNKNOWN"
3
+ UNKNOWN_STATUS_CODE = 999
4
+ MAX_FIELD_LENGTH = 500
5
+ MAX_RESPONSE_BODY_BYTES = 64 * 1024
6
+ TRUNCATED_SUFFIX = "… [TRUNCATED]"
7
+ UNREADABLE_RESPONSE = "<could not decode response>"
8
+ RESPONSE_BODY_TOO_LARGE = "<response body too large>"
9
+ RESPONSE_BODY_CONTEXT_KEY = "response_body"
10
+ JSON_MEDIA_TYPE = "application/json"
11
+ JSON_MEDIA_TYPE_SUFFIX = "+json"
12
+ IDENTITY_ENCODING = "identity"
13
+ DEFAULT_SANITIZE_FIELD_NAMES = (
14
+ "password",
15
+ "passwd",
16
+ "pwd",
17
+ "secret",
18
+ "*key",
19
+ "*token*",
20
+ "*session*",
21
+ "*credit*",
22
+ "*card*",
23
+ "*auth*",
24
+ "*principal*",
25
+ "set-cookie",
26
+ "pin",
27
+ "otp",
28
+ "*document*",
29
+ "*cpf*",
30
+ "*cnpj*",
31
+ "*email*",
32
+ "*phone*",
33
+ "*mobile*",
34
+ "*birth*",
35
+ "mother_name",
36
+ "rg",
37
+ "*account_number*",
38
+ "agency",
39
+ "pix_key",
40
+ "*cvv*",
41
+ "*salary*",
42
+ "*income*",
43
+ )
44
+ DEFAULT_LOG_LEVEL = "INFO"
45
+ DEFAULT_CAPTURE_BODY = "off"
46
+ DEFAULT_CLOUD_PROVIDER = "gcp"
@@ -0,0 +1,127 @@
1
+ import functools
2
+ import inspect
3
+ from typing import Any, Awaitable, Callable, Dict, Mapping, Optional, Tuple, TypeVar
4
+ from urllib.parse import urlparse
5
+
6
+ import elasticapm
7
+ from elasticapm.conf.constants import MASK
8
+
9
+ from fastapi_extras.apm.constants import EXTERNAL, UNKNOWN, UNKNOWN_STATUS_CODE, UNREADABLE_RESPONSE
10
+ from fastapi_extras.apm.labels import to_label_value
11
+ from fastapi_extras.apm.masking import (
12
+ FieldNames,
13
+ get_sanitize_field_names,
14
+ is_sensitive_key,
15
+ mask_sensitive_data,
16
+ normalize_span_value,
17
+ )
18
+
19
+ ReturnType = TypeVar("ReturnType")
20
+
21
+ ERROR_BODY_ATTRIBUTES = ("errors", "content", "detail")
22
+
23
+
24
+ def get_span_name(method: str, url: str) -> str:
25
+ parsed = urlparse(url)
26
+ path = parsed.path
27
+ if parsed.query:
28
+ path = f"{path}?{parsed.query}"
29
+ return f"{method.upper()} {path}"
30
+
31
+
32
+ def is_outgoing_request(value: Any) -> bool:
33
+ return isinstance(getattr(value, "method", None), str) and isinstance(
34
+ getattr(value, "url", None), str
35
+ )
36
+
37
+
38
+ def find_outgoing_request(args: Tuple[Any, ...], kwargs: Mapping[str, Any]) -> Any:
39
+ candidate = kwargs.get("request")
40
+ if is_outgoing_request(candidate):
41
+ return candidate
42
+ return next((arg for arg in args if is_outgoing_request(arg)), None)
43
+
44
+
45
+ async def read_response(response: Any) -> Tuple[Optional[int], Any]:
46
+ status_code = getattr(response, "status", None)
47
+ if not isinstance(status_code, int):
48
+ status_code = getattr(response, "status_code", None)
49
+ if not isinstance(status_code, int):
50
+ return None, None
51
+ try:
52
+ text = response.text
53
+ body = text() if callable(text) else text
54
+ if inspect.isawaitable(body):
55
+ body = await body
56
+ except Exception:
57
+ body = UNREADABLE_RESPONSE
58
+ return status_code, body
59
+
60
+
61
+ def read_error(error: Exception) -> Tuple[int, Any]:
62
+ status_code = getattr(error, "status_code", None)
63
+ if not isinstance(status_code, int):
64
+ return UNKNOWN_STATUS_CODE, repr(error)
65
+ body = {}
66
+ for attribute in ERROR_BODY_ATTRIBUTES:
67
+ value = getattr(error, attribute, None)
68
+ if value:
69
+ body[attribute] = value
70
+ return status_code, body
71
+
72
+
73
+ def build_span_labels(
74
+ status_code: Optional[int],
75
+ response_body: Any,
76
+ payload: Any,
77
+ extra_labels: Optional[Mapping[str, Any]] = None,
78
+ field_names: Optional[FieldNames] = None,
79
+ ) -> Dict[str, Any]:
80
+ names = get_sanitize_field_names() if field_names is None else field_names
81
+ labels: Dict[str, Any] = {
82
+ "status_code": status_code,
83
+ "response_body": normalize_span_value(response_body),
84
+ }
85
+ if payload:
86
+ labels["payload"] = normalize_span_value(payload)
87
+ if extra_labels:
88
+ labels.update(extra_labels)
89
+ return {
90
+ key: MASK
91
+ if is_sensitive_key(key, names)
92
+ else to_label_value(mask_sensitive_data(value, names))
93
+ for key, value in labels.items()
94
+ }
95
+
96
+
97
+ def capture_http_request(
98
+ func: Callable[..., Awaitable[ReturnType]],
99
+ ) -> Callable[..., Awaitable[ReturnType]]:
100
+ @functools.wraps(func)
101
+ async def wrapper(*args: Any, **kwargs: Any) -> ReturnType:
102
+ request = find_outgoing_request(args, kwargs)
103
+ method = getattr(request, "method", UNKNOWN)
104
+ url = getattr(request, "url", UNKNOWN)
105
+ payload = getattr(request, "payload", None)
106
+ status_code: Optional[int] = None
107
+ response_body: Any = None
108
+
109
+ async with elasticapm.async_capture_span(
110
+ get_span_name(method, url), span_type=EXTERNAL
111
+ ) as span:
112
+ try:
113
+ response = await func(*args, **kwargs)
114
+ status_code, response_body = await read_response(response)
115
+ return response
116
+ except Exception as error:
117
+ status_code, response_body = read_error(error)
118
+ raise
119
+ finally:
120
+ if span:
121
+ span.label(
122
+ **build_span_labels(
123
+ status_code, response_body, payload, kwargs.get("labels")
124
+ )
125
+ )
126
+
127
+ return wrapper
@@ -0,0 +1,20 @@
1
+ import json
2
+ from typing import Any, Iterable, Mapping
3
+
4
+ import elasticapm
5
+
6
+ from fastapi_extras.apm.masking import get_sanitize_field_names, sanitize, truncate
7
+
8
+
9
+ def to_label_value(value: Any) -> Any:
10
+ if isinstance(value, (dict, list)):
11
+ return json.dumps(value, ensure_ascii=False, default=str)
12
+ return value
13
+
14
+
15
+ def label_transaction(data: Mapping[str, Any], keys: Iterable[str]) -> None:
16
+ labels = {key: data[key] for key in keys if data.get(key) is not None}
17
+ if not labels:
18
+ return
19
+ safe_labels = truncate(sanitize(labels, get_sanitize_field_names()))
20
+ elasticapm.label(**{key: to_label_value(value) for key, value in safe_labels.items()})
@@ -0,0 +1,87 @@
1
+ import base64
2
+ import json
3
+ import logging
4
+ from typing import Any, Optional, Pattern, Sequence
5
+
6
+ import elasticapm
7
+ from elasticapm.conf.constants import BASE_SANITIZE_FIELD_NAMES, MASK
8
+ from pydantic import BaseModel
9
+
10
+ from fastapi_extras.apm.constants import MAX_FIELD_LENGTH, TRUNCATED_SUFFIX
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ FieldNames = Sequence[Pattern]
15
+
16
+
17
+ def get_sanitize_field_names() -> FieldNames:
18
+ client = elasticapm.get_client()
19
+ if client is None:
20
+ return BASE_SANITIZE_FIELD_NAMES
21
+ return client.config.sanitize_field_names
22
+
23
+
24
+ def is_sensitive_key(key: Any, field_names: FieldNames) -> bool:
25
+ if not isinstance(key, str):
26
+ return False
27
+ normalized = key.strip().lower()
28
+ return any(pattern.match(normalized) for pattern in field_names)
29
+
30
+
31
+ def sanitize(data: Any, field_names: FieldNames) -> Any:
32
+ if isinstance(data, dict):
33
+ return {
34
+ key: MASK if is_sensitive_key(key, field_names) else sanitize(value, field_names)
35
+ for key, value in data.items()
36
+ }
37
+ if isinstance(data, (list, tuple)):
38
+ return [sanitize(item, field_names) for item in data]
39
+ return data
40
+
41
+
42
+ def truncate(data: Any, max_length: int = MAX_FIELD_LENGTH) -> Any:
43
+ if isinstance(data, dict):
44
+ return {key: truncate(value, max_length) for key, value in data.items()}
45
+ if isinstance(data, (list, tuple)):
46
+ return [truncate(item, max_length) for item in data]
47
+ if data is None or isinstance(data, (bool, int, float)):
48
+ return data
49
+ text = data if isinstance(data, str) else str(data)
50
+ if len(text) > max_length:
51
+ return text[:max_length] + TRUNCATED_SUFFIX
52
+ return text
53
+
54
+
55
+ def normalize_span_value(value: Any) -> Any:
56
+ if isinstance(value, BaseModel):
57
+ dump = getattr(value, "model_dump", None) or value.dict
58
+ return dump()
59
+ if isinstance(value, (bytes, bytearray)):
60
+ try:
61
+ return bytes(value).decode("utf-8")
62
+ except UnicodeDecodeError:
63
+ return base64.b64encode(bytes(value)).decode("ascii")
64
+ return value
65
+
66
+
67
+ def parse_json(value: str) -> Any:
68
+ try:
69
+ return json.loads(value)
70
+ except ValueError:
71
+ return value
72
+
73
+
74
+ def mask_sensitive_data(
75
+ data: Any,
76
+ field_names: Optional[FieldNames] = None,
77
+ max_length: int = MAX_FIELD_LENGTH,
78
+ ) -> Any:
79
+ names = get_sanitize_field_names() if field_names is None else field_names
80
+ try:
81
+ value = normalize_span_value(data)
82
+ if isinstance(value, str):
83
+ value = parse_json(value)
84
+ return truncate(sanitize(value, names), max_length)
85
+ except Exception:
86
+ logger.exception("Could not mask APM data")
87
+ return MASK