021-python-observability 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.dist-info → 021_python_observability-0.4.0.dist-info}/METADATA +32 -11
- 021_python_observability-0.4.0.dist-info/RECORD +11 -0
- {021_python_observability-0.3.0.dist-info → 021_python_observability-0.4.0.dist-info}/WHEEL +1 -1
- observability_021/asgi.py +18 -2
- observability_021/config.py +15 -0
- observability_021/logging.py +15 -2
- observability_021/runtime.py +31 -3
- 021_python_observability-0.3.0.dist-info/RECORD +0 -11
- {021_python_observability-0.3.0.dist-info → 021_python_observability-0.4.0.dist-info}/licenses/LICENSE +0 -0
{021_python_observability-0.3.0.dist-info → 021_python_observability-0.4.0.dist-info}/METADATA
RENAMED
|
@@ -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
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
observability_021/__init__.py,sha256=0RU4GAtHbN-dyK8tqvEJ9t9e5rxJnRLCUoFN1q7GkuE,283
|
|
2
|
+
observability_021/asgi.py,sha256=M-0s6GjzUvKTSdnAIzQgdZ5l3L531cz2OstTvMfna_U,10800
|
|
3
|
+
observability_021/config.py,sha256=bsZ9noXNtDv52BHlTaOQK14RV0ixh6eGF_xkl009Tq0,5903
|
|
4
|
+
observability_021/dependencies.py,sha256=4PdfFmLHmIBN3IIMHG41d2ify5H7At9wrCkpZvByZRs,6065
|
|
5
|
+
observability_021/logging.py,sha256=Q0lToRDI5lN2cI1lYUwjbtZ7p9aInvzzA7iNtap-dZE,7237
|
|
6
|
+
observability_021/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
observability_021/runtime.py,sha256=9zmrwPvCSjI-OHNXIwn2_rd879WGY078sIpjJoGpN0Y,15873
|
|
8
|
+
021_python_observability-0.4.0.dist-info/METADATA,sha256=8BnQb-KV1Eg2jDvfknWUpefTurD3hjbnUAmINpS4oWc,19266
|
|
9
|
+
021_python_observability-0.4.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
021_python_observability-0.4.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
11
|
+
021_python_observability-0.4.0.dist-info/RECORD,,
|
observability_021/asgi.py
CHANGED
|
@@ -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:
|
observability_021/config.py
CHANGED
|
@@ -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")),
|
observability_021/logging.py
CHANGED
|
@@ -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
|
)
|
observability_021/runtime.py
CHANGED
|
@@ -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,11 +0,0 @@
|
|
|
1
|
-
observability_021/__init__.py,sha256=0RU4GAtHbN-dyK8tqvEJ9t9e5rxJnRLCUoFN1q7GkuE,283
|
|
2
|
-
observability_021/asgi.py,sha256=2cpHSdmrNindHIY_dYdMwhBXGhefJlCL8VfSEkf4Zb4,10212
|
|
3
|
-
observability_021/config.py,sha256=4LR6nNNdD2-2pso-Z4NRbLwsgiXKa8T4D-8X1bbAcNg,5232
|
|
4
|
-
observability_021/dependencies.py,sha256=4PdfFmLHmIBN3IIMHG41d2ify5H7At9wrCkpZvByZRs,6065
|
|
5
|
-
observability_021/logging.py,sha256=OjJIRKmJJ4-RGafyvHaYKUjKInjscOIHhn5Ficx70Oc,6799
|
|
6
|
-
observability_021/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
observability_021/runtime.py,sha256=DfaQPn5ZbAjAaJ-IYu_RCijnxOqBMDPEgssrdvwTTGc,14547
|
|
8
|
-
021_python_observability-0.3.0.dist-info/METADATA,sha256=BzXNnBB6so7eNlsGsH30nW6AiOJRXrYnc-XxTck5xLE,17743
|
|
9
|
-
021_python_observability-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
-
021_python_observability-0.3.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
11
|
-
021_python_observability-0.3.0.dist-info/RECORD,,
|
|
File without changes
|