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.
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: 021-python-observability
3
- Version: 0.3.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-python
6
- Project-URL: Changelog, https://github.com/from021-io/observability-python/blob/main/CHANGELOG.md
7
- Project-URL: Contract, https://github.com/from021-io/observability-python/blob/main/CONTRACT.md
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.44
27
- Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.44
28
- Requires-Dist: opentelemetry-sdk<2,>=1.44
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-python
38
+ # python-observability
38
39
 
39
- Repository: `from021-io/observability-python`. Python distribution: `021-python-observability`; import: `observability_021`.
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-python.git", tag = "v0.3.0" } # x-release-please-version
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.3.0" # x-release-please-version
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.4
2
+ Generator: hatchling 1.33.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
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(settings.log_level)
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(settings.log_level)
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:
@@ -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")),
@@ -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(level: str = "INFO") -> None:
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
  )
@@ -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.3.0" # x-release-please-version
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,,