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.
Files changed (19) hide show
  1. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/CHANGELOG.md +21 -0
  2. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/CONTRACT.md +9 -0
  3. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/PKG-INFO +32 -11
  4. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/README.md +24 -4
  5. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/pyproject.toml +8 -7
  6. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/asgi.py +18 -2
  7. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/config.py +15 -0
  8. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/logging.py +15 -2
  9. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/runtime.py +31 -3
  10. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/test_contract.py +28 -0
  11. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/test_extra_contract.py +31 -7
  12. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/test_logging_contract.py +41 -2
  13. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/tests/wheel_smoke.py +2 -0
  14. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/.gitignore +0 -0
  15. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/LICENSE +0 -0
  16. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/__init__.py +0 -0
  17. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/dependencies.py +0 -0
  18. {021_python_observability-0.3.0 → 021_python_observability-0.4.0}/src/observability_021/py.typed +0 -0
  19. {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.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
@@ -1,6 +1,6 @@
1
- # observability-python
1
+ # python-observability
2
2
 
3
- Repository: `from021-io/observability-python`. Python distribution: `021-python-observability`; import: `observability_021`.
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-python.git", tag = "v0.3.0" } # x-release-please-version
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.3.0" # x-release-please-version
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.3.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.44,<2",
34
- "opentelemetry-sdk>=1.44,<2",
35
- "opentelemetry-exporter-otlp-proto-http>=1.44,<2",
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-python"
40
- Changelog = "https://github.com/from021-io/observability-python/blob/main/CHANGELOG.md"
41
- Contract = "https://github.com/from021-io/observability-python/blob/main/CONTRACT.md"
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"]
@@ -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,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")
@@ -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
- def test_explicit_configuration_does_not_inherit_ambient_credentials():
156
- with (
157
- receiver() as (endpoint, received),
158
- patch.dict(
159
- "os.environ", {"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer%20UNRELATED-SECRET"}
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 received and all(auth is None for _, auth, _ in received)
197
+ assert {path for path, _, _ in received} == {"/v1/traces", "/v1/metrics"}
198
+ assert all(auth == explicit_auth for _, auth, _ in received)
@@ -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"