021-python-observability 0.3.0__tar.gz → 0.4.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.
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/CHANGELOG.md +21 -0
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/CONTRACT.md +9 -0
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/PKG-INFO +32 -11
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/README.md +24 -4
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/pyproject.toml +8 -7
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/asgi.py +18 -2
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/config.py +15 -0
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/logging.py +15 -2
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/runtime.py +31 -3
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/test_contract.py +28 -0
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/test_extra_contract.py +31 -7
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/test_logging_contract.py +41 -2
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/wheel_smoke.py +2 -0
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/.gitignore +0 -0
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/LICENSE +0 -0
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/__init__.py +0 -0
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/dependencies.py +0 -0
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/py.typed +0 -0
- {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/test_consumer_contract.py +0 -0
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0](https://github.com/from021-io/python-observability/compare/v0.3.2...v0.4.0) (2026-10-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **logging:** log service and instrumentation versions ([#18](https://github.com/from021-io/python-observability/issues/18)) ([0ec6e90](https://github.com/from021-io/python-observability/commit/0ec6e90fa0806d92b4f19d0c699cdb7faca36b4e))
|
|
9
|
+
|
|
10
|
+
## [0.3.2](https://github.com/from021-io/python-observability/compare/v0.3.1...v0.3.2) (2026-10-25)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug fixes
|
|
14
|
+
|
|
15
|
+
* **telemetry:** actualiza dependencias y aísla credenciales OTLP ([#16](https://github.com/from021-io/python-observability/issues/16)) ([c383af9](https://github.com/from021-io/python-observability/commit/c383af9e5e50c9eec9fb4093d168b0f05dd73df2))
|
|
16
|
+
|
|
17
|
+
## [0.3.1](https://github.com/from021-io/python-observability/compare/v0.3.0...v0.3.1) (2026-10-21)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Build
|
|
21
|
+
|
|
22
|
+
* point the project URLs at from021-io/python-observability ([#14](https://github.com/from021-io/python-observability/issues/14)) ([851054e](https://github.com/from021-io/python-observability/commit/851054e5845b29e3a0de23b0eab775e7e1920c0e))
|
|
23
|
+
|
|
3
24
|
## [0.3.0](https://github.com/from021-io/observability-python/compare/v0.2.0...v0.3.0) (2026-10-21)
|
|
4
25
|
|
|
5
26
|
|
|
@@ -72,6 +72,15 @@ Logs stay in Vercel; internal agent traces and LLM token/cost instrumentation re
|
|
|
72
72
|
|
|
73
73
|
## Safe log diagnostics
|
|
74
74
|
|
|
75
|
+
At application lifespan startup, `observability.started` logs `service_name`, `environment`,
|
|
76
|
+
`service_version` and `observability_version` at INFO. `service_version` reflects the
|
|
77
|
+
consumer's configured version (or `unknown`), while `observability_version` is the installed
|
|
78
|
+
instrumentation version. This event is filtered when the configured log level exceeds INFO.
|
|
79
|
+
Every log through the configured JSON formatter includes both version fields. HTTP request
|
|
80
|
+
context selects that app's service version; without request context the process-wide formatter
|
|
81
|
+
uses the most recently configured app. Independent handlers and logs before setup are outside
|
|
82
|
+
this contract.
|
|
83
|
+
|
|
75
84
|
Domain correlation in logs accepts only UUID objects/strings for `brain_id`, `job_id`,
|
|
76
85
|
`file_id`, `file_version_id`, `entity_id`, `event_id` and `correlation_id`. Values are
|
|
77
86
|
normalized into JSON and `message`; invalid values are dropped. The metric attribute
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: 021-python-observability
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: OpenTelemetry metrics, traces and safe JSON logs for FastAPI services
|
|
5
|
-
Project-URL: Repository, https://github.com/from021-io/observability
|
|
6
|
-
Project-URL: Changelog, https://github.com/from021-io/observability
|
|
7
|
-
Project-URL: Contract, https://github.com/from021-io/observability
|
|
5
|
+
Project-URL: Repository, https://github.com/from021-io/python-observability
|
|
6
|
+
Project-URL: Changelog, https://github.com/from021-io/python-observability/blob/main/CHANGELOG.md
|
|
7
|
+
Project-URL: Contract, https://github.com/from021-io/python-observability/blob/main/CONTRACT.md
|
|
8
8
|
License-Expression: Apache-2.0
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Keywords: asgi,fastapi,observability,opentelemetry,otlp,structlog
|
|
@@ -23,9 +23,10 @@ Classifier: Typing :: Typed
|
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
24
|
Requires-Dist: fastapi<1,>=0.116
|
|
25
25
|
Requires-Dist: httpx<1,>=0.28
|
|
26
|
-
Requires-Dist: opentelemetry-api<2,>=1.
|
|
27
|
-
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.
|
|
28
|
-
Requires-Dist: opentelemetry-sdk<2,>=1.
|
|
26
|
+
Requires-Dist: opentelemetry-api<2,>=1.45.1
|
|
27
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.45.1
|
|
28
|
+
Requires-Dist: opentelemetry-sdk<2,>=1.45.1
|
|
29
|
+
Requires-Dist: requests<3,>=2.34
|
|
29
30
|
Requires-Dist: sqlalchemy[asyncio]<3,>=2.0
|
|
30
31
|
Requires-Dist: structlog<27,>=25.1
|
|
31
32
|
Provides-Extra: prometheus
|
|
@@ -34,9 +35,9 @@ Provides-Extra: vercel
|
|
|
34
35
|
Requires-Dist: vercel<1,>=0.5.8; extra == 'vercel'
|
|
35
36
|
Description-Content-Type: text/markdown
|
|
36
37
|
|
|
37
|
-
# observability
|
|
38
|
+
# python-observability
|
|
38
39
|
|
|
39
|
-
Repository: `from021-io/observability-python
|
|
40
|
+
Repository: `from021-io/python-observability` (named `observability-python` until 2026-10-21; the old URL redirects). Python distribution: `021-python-observability`; import: `observability_021`.
|
|
40
41
|
Tags up to `v0.2.0` use the previous distribution name, `021-observability`.
|
|
41
42
|
|
|
42
43
|
Common API observability for Python 3.12–3.14: FastAPI request metrics and traces,
|
|
@@ -54,14 +55,14 @@ writes [CHANGELOG.md](CHANGELOG.md) and tags `vX.Y.Z`. Consumers depend on a tag
|
|
|
54
55
|
|
|
55
56
|
```toml
|
|
56
57
|
[tool.uv.sources]
|
|
57
|
-
021-python-observability = { git = "https://github.com/from021-io/observability
|
|
58
|
+
021-python-observability = { git = "https://github.com/from021-io/python-observability.git", tag = "v0.4.0" } # x-release-please-version
|
|
58
59
|
```
|
|
59
60
|
|
|
60
61
|
Once releases are published to PyPI, pin the exact version instead; no Git credential is
|
|
61
62
|
needed to install it:
|
|
62
63
|
|
|
63
64
|
```sh
|
|
64
|
-
uv add "021-python-observability==0.
|
|
65
|
+
uv add "021-python-observability==0.4.0" # x-release-please-version
|
|
65
66
|
```
|
|
66
67
|
|
|
67
68
|
## Publishing to PyPI
|
|
@@ -134,6 +135,14 @@ keep its default value. A different Content-Type does not change the encoding.
|
|
|
134
135
|
Use custom headers for gateway authentication and verify receipt of both signals
|
|
135
136
|
against the configured gateway during rollout.
|
|
136
137
|
|
|
138
|
+
`Settings` defaults to no compression. Set `traces_compression` and
|
|
139
|
+
`metrics_compression` to `none`, `gzip` or `deflate` explicitly.
|
|
140
|
+
`Settings.from_env()` reads `OTEL_EXPORTER_OTLP_COMPRESSION` and the signal-specific
|
|
141
|
+
`OTEL_EXPORTER_OTLP_TRACES_COMPRESSION` and `OTEL_EXPORTER_OTLP_METRICS_COMPRESSION`
|
|
142
|
+
overrides. Direct `Settings(...)` calls no longer inherit ambient compression.
|
|
143
|
+
The exporter owns `Content-Encoding` so that the header matches the body. Custom
|
|
144
|
+
and ambient headers cannot override it.
|
|
145
|
+
|
|
137
146
|
Separate destinations are supported and take precedence over the shared base:
|
|
138
147
|
|
|
139
148
|
```dotenv
|
|
@@ -225,6 +234,18 @@ Operation/result remain separate dimensions. `operation()` reports `ok`, `error`
|
|
|
225
234
|
|
|
226
235
|
## Logging
|
|
227
236
|
|
|
237
|
+
Each application lifespan emits an `observability.started` JSON log at INFO with
|
|
238
|
+
`service_name`, `environment`, `service_version` and `observability_version`. The service
|
|
239
|
+
version comes from `OTEL_SERVICE_VERSION`, falling back to `VERCEL_GIT_COMMIT_SHA` and
|
|
240
|
+
then `unknown`; the deployment must set the release version to identify the running
|
|
241
|
+
artifact. The observability version identifies this installed package, not the service.
|
|
242
|
+
The event is suppressed if `LOG_LEVEL` is higher than INFO. Every log handled by this
|
|
243
|
+
package's JSON formatter also includes both version fields, including stdlib and Uvicorn
|
|
244
|
+
logs outside requests. Logging is configured process-wide: for multiple apps in one process,
|
|
245
|
+
request logs use the active app version; logs without request context use the most recently
|
|
246
|
+
configured app version. Logs emitted before setup or through independent handlers are outside
|
|
247
|
+
this formatter's scope.
|
|
248
|
+
|
|
228
249
|
`get_logger(__name__)` provides structlog logging. Use static event names and approved
|
|
229
250
|
structured fields (`operation`, `op`, `action`, `dependency`, `status`, `reason`, `error_type`). Do not
|
|
230
251
|
interpolate messages or pass business payloads into approved fields. Unknown extra fields
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# observability
|
|
1
|
+
# python-observability
|
|
2
2
|
|
|
3
|
-
Repository: `from021-io/observability-python
|
|
3
|
+
Repository: `from021-io/python-observability` (named `observability-python` until 2026-10-21; the old URL redirects). Python distribution: `021-python-observability`; import: `observability_021`.
|
|
4
4
|
Tags up to `v0.2.0` use the previous distribution name, `021-observability`.
|
|
5
5
|
|
|
6
6
|
Common API observability for Python 3.12–3.14: FastAPI request metrics and traces,
|
|
@@ -18,14 +18,14 @@ writes [CHANGELOG.md](CHANGELOG.md) and tags `vX.Y.Z`. Consumers depend on a tag
|
|
|
18
18
|
|
|
19
19
|
```toml
|
|
20
20
|
[tool.uv.sources]
|
|
21
|
-
021-python-observability = { git = "https://github.com/from021-io/observability
|
|
21
|
+
021-python-observability = { git = "https://github.com/from021-io/python-observability.git", tag = "v0.4.0" } # x-release-please-version
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Once releases are published to PyPI, pin the exact version instead; no Git credential is
|
|
25
25
|
needed to install it:
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
|
-
uv add "021-python-observability==0.
|
|
28
|
+
uv add "021-python-observability==0.4.0" # x-release-please-version
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
## Publishing to PyPI
|
|
@@ -98,6 +98,14 @@ keep its default value. A different Content-Type does not change the encoding.
|
|
|
98
98
|
Use custom headers for gateway authentication and verify receipt of both signals
|
|
99
99
|
against the configured gateway during rollout.
|
|
100
100
|
|
|
101
|
+
`Settings` defaults to no compression. Set `traces_compression` and
|
|
102
|
+
`metrics_compression` to `none`, `gzip` or `deflate` explicitly.
|
|
103
|
+
`Settings.from_env()` reads `OTEL_EXPORTER_OTLP_COMPRESSION` and the signal-specific
|
|
104
|
+
`OTEL_EXPORTER_OTLP_TRACES_COMPRESSION` and `OTEL_EXPORTER_OTLP_METRICS_COMPRESSION`
|
|
105
|
+
overrides. Direct `Settings(...)` calls no longer inherit ambient compression.
|
|
106
|
+
The exporter owns `Content-Encoding` so that the header matches the body. Custom
|
|
107
|
+
and ambient headers cannot override it.
|
|
108
|
+
|
|
101
109
|
Separate destinations are supported and take precedence over the shared base:
|
|
102
110
|
|
|
103
111
|
```dotenv
|
|
@@ -189,6 +197,18 @@ Operation/result remain separate dimensions. `operation()` reports `ok`, `error`
|
|
|
189
197
|
|
|
190
198
|
## Logging
|
|
191
199
|
|
|
200
|
+
Each application lifespan emits an `observability.started` JSON log at INFO with
|
|
201
|
+
`service_name`, `environment`, `service_version` and `observability_version`. The service
|
|
202
|
+
version comes from `OTEL_SERVICE_VERSION`, falling back to `VERCEL_GIT_COMMIT_SHA` and
|
|
203
|
+
then `unknown`; the deployment must set the release version to identify the running
|
|
204
|
+
artifact. The observability version identifies this installed package, not the service.
|
|
205
|
+
The event is suppressed if `LOG_LEVEL` is higher than INFO. Every log handled by this
|
|
206
|
+
package's JSON formatter also includes both version fields, including stdlib and Uvicorn
|
|
207
|
+
logs outside requests. Logging is configured process-wide: for multiple apps in one process,
|
|
208
|
+
request logs use the active app version; logs without request context use the most recently
|
|
209
|
+
configured app version. Logs emitted before setup or through independent handlers are outside
|
|
210
|
+
this formatter's scope.
|
|
211
|
+
|
|
192
212
|
`get_logger(__name__)` provides structlog logging. Use static event names and approved
|
|
193
213
|
structured fields (`operation`, `op`, `action`, `dependency`, `status`, `reason`, `error_type`). Do not
|
|
194
214
|
interpolate messages or pass business payloads into approved fields. Unknown extra fields
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "021-python-observability"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "OpenTelemetry metrics, traces and safe JSON logs for FastAPI services"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -28,17 +28,18 @@ classifiers = [
|
|
|
28
28
|
dependencies = [
|
|
29
29
|
"fastapi>=0.116,<1",
|
|
30
30
|
"httpx>=0.28,<1",
|
|
31
|
+
"requests>=2.34,<3",
|
|
31
32
|
"sqlalchemy[asyncio]>=2.0,<3",
|
|
32
33
|
"structlog>=25.1,<27",
|
|
33
|
-
"opentelemetry-api>=1.
|
|
34
|
-
"opentelemetry-sdk>=1.
|
|
35
|
-
"opentelemetry-exporter-otlp-proto-http>=1.
|
|
34
|
+
"opentelemetry-api>=1.45.1,<2",
|
|
35
|
+
"opentelemetry-sdk>=1.45.1,<2",
|
|
36
|
+
"opentelemetry-exporter-otlp-proto-http>=1.45.1,<2",
|
|
36
37
|
]
|
|
37
38
|
|
|
38
39
|
[project.urls]
|
|
39
|
-
Repository = "https://github.com/from021-io/observability
|
|
40
|
-
Changelog = "https://github.com/from021-io/observability
|
|
41
|
-
Contract = "https://github.com/from021-io/observability
|
|
40
|
+
Repository = "https://github.com/from021-io/python-observability"
|
|
41
|
+
Changelog = "https://github.com/from021-io/python-observability/blob/main/CHANGELOG.md"
|
|
42
|
+
Contract = "https://github.com/from021-io/python-observability/blob/main/CONTRACT.md"
|
|
42
43
|
|
|
43
44
|
[project.optional-dependencies]
|
|
44
45
|
vercel = ["vercel>=0.5.8,<1"]
|
{021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/asgi.py
RENAMED
|
@@ -20,6 +20,7 @@ from starlette.responses import JSONResponse
|
|
|
20
20
|
from .config import Settings, identifier
|
|
21
21
|
from .logging import configure_logging, get_logger
|
|
22
22
|
from .runtime import (
|
|
23
|
+
INSTRUMENTATION_VERSION,
|
|
23
24
|
BackgroundTask,
|
|
24
25
|
Observability,
|
|
25
26
|
current_observability,
|
|
@@ -175,7 +176,11 @@ def setup_observability(
|
|
|
175
176
|
if app.middleware_stack is not None:
|
|
176
177
|
raise RuntimeError("Configure observability before the application starts")
|
|
177
178
|
settings = settings or Settings.from_env(service_name=service_name)
|
|
178
|
-
configure_logging(
|
|
179
|
+
configure_logging(
|
|
180
|
+
settings.log_level,
|
|
181
|
+
service_version=settings.service_version,
|
|
182
|
+
observability_version=INSTRUMENTATION_VERSION,
|
|
183
|
+
)
|
|
179
184
|
obs = Observability(
|
|
180
185
|
settings,
|
|
181
186
|
prometheus=prometheus,
|
|
@@ -192,9 +197,20 @@ def setup_observability(
|
|
|
192
197
|
@asynccontextmanager
|
|
193
198
|
async def lifespan(application):
|
|
194
199
|
# Servers accepting an already-built app may configure logging after setup.
|
|
195
|
-
configure_logging(
|
|
200
|
+
configure_logging(
|
|
201
|
+
settings.log_level,
|
|
202
|
+
service_version=settings.service_version,
|
|
203
|
+
observability_version=INSTRUMENTATION_VERSION,
|
|
204
|
+
)
|
|
196
205
|
token = current_observability.set(obs)
|
|
197
206
|
try:
|
|
207
|
+
logger.info(
|
|
208
|
+
"observability.started",
|
|
209
|
+
service_name=settings.service_name,
|
|
210
|
+
environment=settings.environment,
|
|
211
|
+
service_version=settings.service_version,
|
|
212
|
+
observability_version=INSTRUMENTATION_VERSION,
|
|
213
|
+
)
|
|
198
214
|
async with original_lifespan(application) as state:
|
|
199
215
|
yield state
|
|
200
216
|
finally:
|
{021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/config.py
RENAMED
|
@@ -64,6 +64,8 @@ class Settings:
|
|
|
64
64
|
log_level: str = "INFO"
|
|
65
65
|
serverless: bool = False
|
|
66
66
|
readiness_timeout_seconds: float = 2.0
|
|
67
|
+
traces_compression: str = "none"
|
|
68
|
+
metrics_compression: str = "none"
|
|
67
69
|
|
|
68
70
|
def __post_init__(self) -> None:
|
|
69
71
|
identifier(self.service_name)
|
|
@@ -72,6 +74,11 @@ class Settings:
|
|
|
72
74
|
raise ValueError("service_version must contain 1–128 characters")
|
|
73
75
|
_endpoint(self.traces_endpoint)
|
|
74
76
|
_endpoint(self.metrics_endpoint)
|
|
77
|
+
if any(
|
|
78
|
+
value not in {"none", "gzip", "deflate"}
|
|
79
|
+
for value in (self.traces_compression, self.metrics_compression)
|
|
80
|
+
):
|
|
81
|
+
raise ValueError("OTLP compression must be none, gzip or deflate")
|
|
75
82
|
for value in (
|
|
76
83
|
self.export_timeout_seconds,
|
|
77
84
|
self.metric_interval_ms,
|
|
@@ -124,6 +131,14 @@ class Settings:
|
|
|
124
131
|
"OTEL_EXPORTER_OTLP_METRICS_HEADERS", env.get("OTEL_EXPORTER_OTLP_HEADERS", "")
|
|
125
132
|
)
|
|
126
133
|
),
|
|
134
|
+
traces_compression=env.get(
|
|
135
|
+
"OTEL_EXPORTER_OTLP_TRACES_COMPRESSION",
|
|
136
|
+
env.get("OTEL_EXPORTER_OTLP_COMPRESSION", "none"),
|
|
137
|
+
),
|
|
138
|
+
metrics_compression=env.get(
|
|
139
|
+
"OTEL_EXPORTER_OTLP_METRICS_COMPRESSION",
|
|
140
|
+
env.get("OTEL_EXPORTER_OTLP_COMPRESSION", "none"),
|
|
141
|
+
),
|
|
127
142
|
export_timeout_seconds=float(env.get("OTEL_EXPORTER_OTLP_TIMEOUT", "2")),
|
|
128
143
|
metric_interval_ms=float(env.get("OTEL_METRIC_EXPORT_INTERVAL", "60000")),
|
|
129
144
|
trace_sample_ratio=float(env.get("OBS_TRACE_SAMPLE_RATIO", "1")),
|
{021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/logging.py
RENAMED
|
@@ -31,6 +31,7 @@ _FIELDS = frozenset(
|
|
|
31
31
|
"service_name",
|
|
32
32
|
"environment",
|
|
33
33
|
"service_version",
|
|
34
|
+
"observability_version",
|
|
34
35
|
"request_id",
|
|
35
36
|
"trace_id",
|
|
36
37
|
"span_id",
|
|
@@ -154,9 +155,21 @@ def _sanitize(_logger, _method, event):
|
|
|
154
155
|
return result
|
|
155
156
|
|
|
156
157
|
|
|
157
|
-
def configure_logging(
|
|
158
|
+
def configure_logging(
|
|
159
|
+
level: str = "INFO",
|
|
160
|
+
*,
|
|
161
|
+
service_version: str = "unknown",
|
|
162
|
+
observability_version: str = "unknown",
|
|
163
|
+
) -> None:
|
|
164
|
+
def add_versions(_logger, _method, event):
|
|
165
|
+
context = structlog.contextvars.get_contextvars()
|
|
166
|
+
event["service_version"] = context.get("service_version", service_version)
|
|
167
|
+
event["observability_version"] = observability_version
|
|
168
|
+
return event
|
|
169
|
+
|
|
158
170
|
shared = [
|
|
159
171
|
structlog.contextvars.merge_contextvars,
|
|
172
|
+
add_versions,
|
|
160
173
|
structlog.stdlib.add_log_level,
|
|
161
174
|
structlog.stdlib.add_logger_name,
|
|
162
175
|
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
|
@@ -174,7 +187,7 @@ def configure_logging(level: str = "INFO") -> None:
|
|
|
174
187
|
handler = logging.StreamHandler(sys.stdout)
|
|
175
188
|
handler.setFormatter(
|
|
176
189
|
structlog.stdlib.ProcessorFormatter(
|
|
177
|
-
processors=[_sanitize, structlog.processors.JSONRenderer(sort_keys=True)],
|
|
190
|
+
processors=[add_versions, _sanitize, structlog.processors.JSONRenderer(sort_keys=True)],
|
|
178
191
|
foreign_pre_chain=[*shared, structlog.stdlib.ExtraAdder(allow=_FIELDS)],
|
|
179
192
|
)
|
|
180
193
|
)
|
{021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/runtime.py
RENAMED
|
@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
17
17
|
if TYPE_CHECKING:
|
|
18
18
|
import httpx
|
|
19
19
|
|
|
20
|
+
from opentelemetry.exporter.otlp.proto.http import Compression
|
|
20
21
|
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
|
|
21
22
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
22
23
|
from opentelemetry.metrics import Observation
|
|
@@ -27,12 +28,14 @@ from opentelemetry.sdk.resources import Resource
|
|
|
27
28
|
from opentelemetry.sdk.trace import TracerProvider
|
|
28
29
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
29
30
|
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
|
|
31
|
+
from requests import PreparedRequest, Request, Session
|
|
32
|
+
from requests.structures import CaseInsensitiveDict
|
|
30
33
|
|
|
31
34
|
from .config import Settings, identifier
|
|
32
35
|
from .logging import get_logger
|
|
33
36
|
|
|
34
37
|
# The package version, kept in step by release-please.
|
|
35
|
-
INSTRUMENTATION_VERSION = "0.
|
|
38
|
+
INSTRUMENTATION_VERSION = "0.4.0" # x-release-please-version
|
|
36
39
|
|
|
37
40
|
# Seconds, with finite boundaries for both ordinary API traffic and long ingestion.
|
|
38
41
|
DURATION_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300)
|
|
@@ -53,6 +56,25 @@ def error_kind(error: BaseException) -> str:
|
|
|
53
56
|
return "internal_error"
|
|
54
57
|
|
|
55
58
|
|
|
59
|
+
class _ExportSession(Session):
|
|
60
|
+
def __init__(self, headers: Mapping[str, str], compression: Compression):
|
|
61
|
+
super().__init__()
|
|
62
|
+
self._export_headers = CaseInsensitiveDict(
|
|
63
|
+
{"Content-Type": "application/x-protobuf", **headers}
|
|
64
|
+
)
|
|
65
|
+
self._export_headers.pop("Content-Encoding", None)
|
|
66
|
+
if compression is not Compression.NoCompression:
|
|
67
|
+
self._export_headers["Content-Encoding"] = compression.value
|
|
68
|
+
|
|
69
|
+
def prepare_request(self, request: Request) -> PreparedRequest:
|
|
70
|
+
request.headers = CaseInsensitiveDict(self._export_headers)
|
|
71
|
+
prepared = super().prepare_request(request)
|
|
72
|
+
# Requests can add netrc credentials during preparation.
|
|
73
|
+
prepared.headers.pop("Authorization", None)
|
|
74
|
+
prepared.headers.update(self._export_headers)
|
|
75
|
+
return prepared
|
|
76
|
+
|
|
77
|
+
|
|
56
78
|
class Observability:
|
|
57
79
|
def __init__(
|
|
58
80
|
self,
|
|
@@ -82,13 +104,15 @@ class Observability:
|
|
|
82
104
|
sampler=ParentBased(TraceIdRatioBased(settings.trace_sample_ratio)),
|
|
83
105
|
shutdown_on_exit=False,
|
|
84
106
|
)
|
|
85
|
-
# A nonempty explicit mapping prevents SDK fallback to ambient OTLP credentials.
|
|
86
|
-
# Content-Type is already required by OTLP/HTTP, so no extra header is introduced.
|
|
87
107
|
if settings.traces_endpoint:
|
|
88
108
|
self.tracer_provider.add_span_processor(
|
|
89
109
|
BatchSpanProcessor(
|
|
90
110
|
OTLPSpanExporter(
|
|
91
111
|
endpoint=settings.traces_endpoint,
|
|
112
|
+
session=_ExportSession(
|
|
113
|
+
settings.traces_headers, Compression(settings.traces_compression)
|
|
114
|
+
),
|
|
115
|
+
compression=Compression(settings.traces_compression),
|
|
92
116
|
headers={
|
|
93
117
|
"Content-Type": "application/x-protobuf",
|
|
94
118
|
**settings.traces_headers,
|
|
@@ -110,6 +134,10 @@ class Observability:
|
|
|
110
134
|
PeriodicExportingMetricReader(
|
|
111
135
|
OTLPMetricExporter(
|
|
112
136
|
endpoint=settings.metrics_endpoint,
|
|
137
|
+
session=_ExportSession(
|
|
138
|
+
settings.metrics_headers, Compression(settings.metrics_compression)
|
|
139
|
+
),
|
|
140
|
+
compression=Compression(settings.metrics_compression),
|
|
113
141
|
headers={
|
|
114
142
|
"Content-Type": "application/x-protobuf",
|
|
115
143
|
**settings.metrics_headers,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import gzip
|
|
4
5
|
import json
|
|
5
6
|
import logging
|
|
6
7
|
import threading
|
|
7
8
|
import time
|
|
9
|
+
import zlib
|
|
8
10
|
from contextlib import contextmanager
|
|
9
11
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
10
12
|
from unittest.mock import patch
|
|
@@ -27,6 +29,7 @@ from sqlalchemy.pool import NullPool, QueuePool
|
|
|
27
29
|
|
|
28
30
|
from observability_021 import Settings, get_logger, setup_observability
|
|
29
31
|
from observability_021.asgi import ObservabilityMiddleware
|
|
32
|
+
from observability_021.runtime import INSTRUMENTATION_VERSION
|
|
30
33
|
|
|
31
34
|
|
|
32
35
|
@contextmanager
|
|
@@ -105,16 +108,33 @@ def test_configuration_signal_overrides_and_no_implicit_export():
|
|
|
105
108
|
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": "https://metrics.test/custom",
|
|
106
109
|
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Basic%20SECRET",
|
|
107
110
|
"OTEL_EXPORTER_OTLP_METRICS_HEADERS": "x-token=OTHER",
|
|
111
|
+
"OTEL_EXPORTER_OTLP_COMPRESSION": "gzip",
|
|
112
|
+
"OTEL_EXPORTER_OTLP_METRICS_COMPRESSION": "deflate",
|
|
108
113
|
}
|
|
109
114
|
)
|
|
110
115
|
assert settings.traces_endpoint == "https://otel.test/otlp/v1/traces"
|
|
111
116
|
assert settings.metrics_endpoint == "https://metrics.test/custom"
|
|
112
117
|
assert settings.traces_headers == {"Authorization": "Basic SECRET"}
|
|
113
118
|
assert settings.metrics_headers == {"x-token": "OTHER"}
|
|
119
|
+
assert settings.traces_compression == "gzip"
|
|
120
|
+
assert settings.metrics_compression == "deflate"
|
|
114
121
|
assert "SECRET" not in repr(settings) and "OTHER" not in repr(settings)
|
|
115
122
|
assert settings.environment == "staging" and settings.service_version == "0.1.0"
|
|
116
123
|
|
|
117
124
|
|
|
125
|
+
@pytest.mark.parametrize("service_version", ["1.2.3", "unknown"])
|
|
126
|
+
def test_lifespan_logs_service_and_observability_versions(capsys, service_version):
|
|
127
|
+
settings = Settings("test-api", environment="test", service_version=service_version)
|
|
128
|
+
with observed(settings=settings) as (app, _, _, _), TestClient(app):
|
|
129
|
+
lines = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
|
|
130
|
+
events = [line for line in lines if line.get("event") == "observability.started"]
|
|
131
|
+
assert len(events) == 1
|
|
132
|
+
assert events[0]["service_name"] == "test-api"
|
|
133
|
+
assert events[0]["environment"] == "test"
|
|
134
|
+
assert events[0]["service_version"] == service_version
|
|
135
|
+
assert events[0]["observability_version"] == INSTRUMENTATION_VERSION
|
|
136
|
+
|
|
137
|
+
|
|
118
138
|
@pytest.mark.parametrize(
|
|
119
139
|
"env",
|
|
120
140
|
[
|
|
@@ -123,6 +143,9 @@ def test_configuration_signal_overrides_and_no_implicit_export():
|
|
|
123
143
|
{"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=hello%0aInjected"},
|
|
124
144
|
{"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc"},
|
|
125
145
|
{"OTEL_EXPORTER_OTLP_TIMEOUT": "nan"},
|
|
146
|
+
{"OTEL_EXPORTER_OTLP_COMPRESSION": "SECRET"},
|
|
147
|
+
{"OTEL_EXPORTER_OTLP_TRACES_COMPRESSION": "SECRET"},
|
|
148
|
+
{"OTEL_EXPORTER_OTLP_METRICS_COMPRESSION": "SECRET"},
|
|
126
149
|
{"OBS_TRACE_SAMPLE_RATIO": "1.1"},
|
|
127
150
|
],
|
|
128
151
|
)
|
|
@@ -442,6 +465,11 @@ def receiver(*, status=200, delay=0):
|
|
|
442
465
|
class Handler(BaseHTTPRequestHandler):
|
|
443
466
|
def do_POST(self):
|
|
444
467
|
raw = self.rfile.read(int(self.headers.get("content-length", "0")))
|
|
468
|
+
assert self.headers.get("content-encoding") in {None, "gzip", "deflate"}
|
|
469
|
+
if self.headers.get("content-encoding") == "gzip":
|
|
470
|
+
raw = gzip.decompress(raw)
|
|
471
|
+
elif self.headers.get("content-encoding") == "deflate":
|
|
472
|
+
raw = zlib.decompress(raw)
|
|
445
473
|
message = (
|
|
446
474
|
ExportTraceServiceRequest()
|
|
447
475
|
if self.path.endswith("traces")
|
{021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/test_extra_contract.py
RENAMED
|
@@ -152,13 +152,32 @@ def test_readiness_all_healthy_then_unavailable():
|
|
|
152
152
|
assert client.get("/health").status_code == 200
|
|
153
153
|
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
155
|
+
@pytest.mark.parametrize("compression", ["none", "gzip", "deflate"])
|
|
156
|
+
@pytest.mark.parametrize("explicit_auth", [None, "Bearer EXPLICIT"])
|
|
157
|
+
@pytest.mark.parametrize("ambient_encoding", ["gzip", "deflate"])
|
|
158
|
+
def test_explicit_configuration_does_not_inherit_ambient_credentials(
|
|
159
|
+
compression, explicit_auth, ambient_encoding, tmp_path
|
|
160
|
+
):
|
|
161
|
+
netrc = tmp_path / "netrc"
|
|
162
|
+
netrc.write_text("machine 127.0.0.1 login unrelated password SECRET\n")
|
|
163
|
+
ambient = {
|
|
164
|
+
"NETRC": str(netrc),
|
|
165
|
+
"OTEL_EXPORTER_OTLP_HEADERS": (
|
|
166
|
+
f"Authorization=Bearer%20COMMON-SECRET,X-Secret=COMMON,Content-Encoding={ambient_encoding}"
|
|
160
167
|
),
|
|
161
|
-
|
|
168
|
+
"OTEL_EXPORTER_OTLP_TRACES_HEADERS": (
|
|
169
|
+
f"Authorization=Bearer%20TRACE-SECRET,Content-Encoding={ambient_encoding}"
|
|
170
|
+
),
|
|
171
|
+
"OTEL_EXPORTER_OTLP_METRICS_HEADERS": (
|
|
172
|
+
f"Authorization=Bearer%20METRIC-SECRET,Content-Encoding={ambient_encoding}"
|
|
173
|
+
),
|
|
174
|
+
"OTEL_EXPORTER_OTLP_COMPRESSION": "gzip",
|
|
175
|
+
"OTEL_PYTHON_EXPORTER_OTLP_HTTP_CREDENTIAL_PROVIDER": "must-not-load",
|
|
176
|
+
}
|
|
177
|
+
headers = {"Content-Encoding": "br"}
|
|
178
|
+
if explicit_auth:
|
|
179
|
+
headers["authorization"] = explicit_auth
|
|
180
|
+
with receiver() as (endpoint, received), patch.dict("os.environ", ambient):
|
|
162
181
|
app = FastAPI()
|
|
163
182
|
obs = setup_observability(
|
|
164
183
|
app,
|
|
@@ -166,9 +185,14 @@ def test_explicit_configuration_does_not_inherit_ambient_credentials():
|
|
|
166
185
|
"isolated",
|
|
167
186
|
traces_endpoint=endpoint + "/v1/traces",
|
|
168
187
|
metrics_endpoint=endpoint + "/v1/metrics",
|
|
188
|
+
traces_headers=headers,
|
|
189
|
+
metrics_headers=headers,
|
|
190
|
+
traces_compression=compression,
|
|
191
|
+
metrics_compression=compression,
|
|
169
192
|
),
|
|
170
193
|
)
|
|
171
194
|
with TestClient(app) as client:
|
|
172
195
|
assert client.get("/missing").status_code == 404
|
|
173
196
|
asyncio.run(obs.flush_async())
|
|
174
|
-
assert
|
|
197
|
+
assert {path for path, _, _ in received} == {"/v1/traces", "/v1/metrics"}
|
|
198
|
+
assert all(auth == explicit_auth for _, auth, _ in received)
|
{021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/test_logging_contract.py
RENAMED
|
@@ -2,15 +2,17 @@ import asyncio
|
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
import sys
|
|
5
|
+
import threading
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from uuid import UUID, uuid4
|
|
7
8
|
|
|
8
9
|
import pytest
|
|
9
10
|
import structlog
|
|
10
11
|
from fastapi.testclient import TestClient
|
|
11
|
-
from test_contract import observed, points
|
|
12
|
+
from test_contract import invoke, observed, points
|
|
12
13
|
|
|
13
|
-
from observability_021 import get_logger
|
|
14
|
+
from observability_021 import Settings, get_logger
|
|
15
|
+
from observability_021.runtime import INSTRUMENTATION_VERSION
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
def fail():
|
|
@@ -18,6 +20,43 @@ def fail():
|
|
|
18
20
|
raise ValueError(f"SECRET-ERROR {private_local}")
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
def test_versions_on_structlog_stdlib_thread_and_requests(capsys):
|
|
24
|
+
with observed(settings=Settings("api", service_version="1.2.3")) as (app, _, _, _):
|
|
25
|
+
|
|
26
|
+
@app.get("/versions")
|
|
27
|
+
async def versions():
|
|
28
|
+
get_logger("request").info("request.log", service_version="untrusted")
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
get_logger("worker").info("worker.log", observability_version="untrusted")
|
|
32
|
+
thread = threading.Thread(target=logging.getLogger("thread").warning, args=("message",))
|
|
33
|
+
thread.start()
|
|
34
|
+
thread.join()
|
|
35
|
+
with TestClient(app) as client:
|
|
36
|
+
assert client.get("/versions").status_code == 200
|
|
37
|
+
|
|
38
|
+
rows = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
|
|
39
|
+
assert {"worker.log", "library.log", "request.log", "request.completed"} <= {
|
|
40
|
+
row["event"] for row in rows
|
|
41
|
+
}
|
|
42
|
+
assert all(row["service_version"] == "1.2.3" for row in rows)
|
|
43
|
+
assert all(row["observability_version"] == INSTRUMENTATION_VERSION for row in rows)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_request_version_uses_active_app_not_last_configured(capsys):
|
|
47
|
+
with observed(settings=Settings("first", service_version="1.0")) as (first, _, _, _):
|
|
48
|
+
with observed(settings=Settings("second", service_version="2.0")):
|
|
49
|
+
|
|
50
|
+
@first.get("/version")
|
|
51
|
+
async def version():
|
|
52
|
+
get_logger("request").info("request.log")
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
asyncio.run(invoke(first, path="/version"))
|
|
56
|
+
rows = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
|
|
57
|
+
assert [row["service_version"] for row in rows if row["event"] == "request.log"] == ["1.0"]
|
|
58
|
+
|
|
59
|
+
|
|
21
60
|
@pytest.mark.parametrize("source", ["structlog", "stdlib", "uvicorn.error"])
|
|
22
61
|
@pytest.mark.parametrize("exc_form", ["current", "instance", "tuple"])
|
|
23
62
|
def test_exception_logs_keep_safe_diagnostics_and_correlation(capsys, source, exc_form):
|
|
@@ -10,6 +10,7 @@ from fastapi import FastAPI
|
|
|
10
10
|
|
|
11
11
|
import observability_021
|
|
12
12
|
from observability_021 import setup_observability
|
|
13
|
+
from observability_021.runtime import INSTRUMENTATION_VERSION
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
async def main():
|
|
@@ -25,6 +26,7 @@ async def main():
|
|
|
25
26
|
assert response.headers["x-request-id"]
|
|
26
27
|
origin = Path(observability_021.__file__)
|
|
27
28
|
assert "site-packages" in origin.parts, origin
|
|
29
|
+
assert INSTRUMENTATION_VERSION == importlib.metadata.version("021-python-observability")
|
|
28
30
|
print(
|
|
29
31
|
f"wheel={importlib.metadata.version('021-python-observability')} "
|
|
30
32
|
f"python={sys.version.split()[0]} health=200 isolated_install=passed"
|
|
File without changes
|
|
File without changes
|
{021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/py.typed
RENAMED
|
File without changes
|
{021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/test_consumer_contract.py
RENAMED
|
File without changes
|