DataExcept 1.6.0__tar.gz → 1.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {dataexcept-1.6.0 → dataexcept-1.7.0}/CHANGELOG.md +72 -17
  2. {dataexcept-1.6.0 → dataexcept-1.7.0}/CITATION.cff +2 -2
  3. {dataexcept-1.6.0 → dataexcept-1.7.0}/PKG-INFO +10 -3
  4. {dataexcept-1.6.0 → dataexcept-1.7.0}/README.md +9 -2
  5. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/__init__.py +7 -12
  6. dataexcept-1.7.0/dataexcept/http_context.py +136 -0
  7. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/logging_helpers.py +42 -10
  8. dataexcept-1.7.0/dataexcept/observability.py +107 -0
  9. dataexcept-1.7.0/dataexcept/opentelemetry.py +173 -0
  10. dataexcept-1.7.0/dataexcept/orchestrator_context.py +110 -0
  11. dataexcept-1.7.0/dataexcept/sentry.py +111 -0
  12. dataexcept-1.7.0/dataexcept/trace_context.py +192 -0
  13. dataexcept-1.7.0/dataexcept/worker_context.py +167 -0
  14. {dataexcept-1.6.0 → dataexcept-1.7.0}/pyproject.toml +3 -3
  15. {dataexcept-1.6.0 → dataexcept-1.7.0}/LICENSE +0 -0
  16. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/__main__.py +0 -0
  17. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/_causes.py +0 -0
  18. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/_previews.py +0 -0
  19. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/_validation.py +0 -0
  20. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/base.py +0 -0
  21. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/broker_exceptions.py +0 -0
  22. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/database_exceptions.py +0 -0
  23. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/dataengineering_exceptions.py +0 -0
  24. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/datascience_exceptions/__init__.py +0 -0
  25. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/datascience_exceptions/base.py +0 -0
  26. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/datascience_exceptions/ingestion.py +0 -0
  27. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/datascience_exceptions/operations.py +0 -0
  28. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/datascience_exceptions/training.py +0 -0
  29. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/__init__.py +0 -0
  30. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/authentication.py +0 -0
  31. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/base.py +0 -0
  32. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/configuration.py +0 -0
  33. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/external.py +0 -0
  34. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/lifecycle.py +0 -0
  35. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/notification.py +0 -0
  36. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/parsing.py +0 -0
  37. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/scheduling.py +0 -0
  38. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/validation.py +0 -0
  39. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/failure_metadata.py +0 -0
  40. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/io_exceptions.py +0 -0
  41. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/network_exceptions.py +0 -0
  42. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/pandas_exceptions.py +0 -0
  43. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/pino.py +0 -0
  44. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/pipeline_exceptions.py +0 -0
  45. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/py.typed +0 -0
  46. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/redaction.py +0 -0
  47. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/schema.py +0 -0
  48. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/schemas/envelope-1.0.0.json +0 -0
  49. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/schemas/pino-1.0.0.json +0 -0
  50. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/security_exceptions.py +0 -0
  51. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/serialization.py +0 -0
  52. {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/wrapping.py +0 -0
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.7.0] - 2026-09-17
11
+
12
+ ### Added
13
+
14
+ - **A product-neutral observability context.** `OperationContext` carries
15
+ stable system, component and operation labels separately from request, job,
16
+ correlation, trace and span identifiers. `exception_to_observability_event()`
17
+ combines that context with the existing redacted exception envelope without
18
+ coupling DataExcept to a framework or telemetry SDK.
19
+ - **Dependency-free OpenTelemetry support.** Exception conversion exposes the
20
+ standard `exception.*` attributes plus `dataexcept.failure.*` recovery
21
+ metadata and can record an exception through the minimal span interface
22
+ without importing OpenTelemetry.
23
+ - **Dependency-free Sentry enrichment.** Sentry events can carry the redacted
24
+ DataExcept envelope and operation context while only low-cardinality fields
25
+ become tags.
26
+ - **W3C Trace Context continuity.** `traceparent` can be parsed without a
27
+ tracing runtime, with `tracestate` and `baggage` preserved for forwarding.
28
+ Invalid or all-zero identifiers are rejected rather than replaced with
29
+ invented provenance, and an incoming parent span ID is never mislabelled as
30
+ a local span ID.
31
+ - **Framework-neutral execution-boundary adapters** for HTTP requests,
32
+ background workers and workflow/orchestrator steps. They map stable operation
33
+ names and correlation identifiers onto the common context while keeping raw
34
+ request paths, payloads and task arguments out of indexed telemetry. The
35
+ adapters depend only on plain mappings and therefore fit ASGI/WSGI services,
36
+ serverless gateways, Celery/RQ/Arq/Dramatiq-style workers, Airflow/Dagster/
37
+ Prefect/Argo-style orchestrators and custom runtimes without importing them.
38
+
39
+ ### Changed
40
+
41
+ - Logging helpers can attach `OperationContext` separately from free-form
42
+ application context, keeping filterable operation identity distinct from
43
+ arbitrary payload-shaped values.
44
+ - OpenTelemetry and Sentry now project the same operation context so logs,
45
+ traces and error trackers can correlate one failure consistently.
46
+ - The documentation home page and the README now link every guide the site
47
+ publishes. Each release had added its guide to the navigation and stopped
48
+ there, leaving cause-aware exceptions, failure metadata, parsing context,
49
+ message brokers and the Pino profile reachable only from the sidebar. A test
50
+ now fails when a page in the navigation is linked from neither landing page,
51
+ so the two cannot drift apart again.
52
+
10
53
  ## [1.6.0] - 2026-09-06
11
54
 
12
55
  ### Added
@@ -21,13 +64,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
64
  - Structured broker context — `broker`, `topic`, `partition`, `offset`,
22
65
  `consumer_group`, `operation` and `timeout_seconds` — with `broker` redacted
23
66
  when it is a URL, since a bootstrap address routinely carries credentials.
24
- All six take `cause=` on the canonical contract and set `__cause__`.
25
-
26
- The hierarchy is about the operation rather than the product, so it fits Kafka,
27
- RabbitMQ, Pulsar and NATS alike, and the package depends on no broker client.
28
- Retryability is left unclassified: a rejected publish may be a leader election
29
- or a missing topic, and the exception cannot tell which.
30
-
67
+ The five concrete classes take `cause=` on the canonical contract and set
68
+ `__cause__`; `MessageBrokerError` itself is a plain base, for catching.
31
69
  - **A published Pino profile for the exception envelope.** `exception_to_pino()`
32
70
  and `envelope_to_pino()` project an envelope onto the value Pino logs under
33
71
  its error key, and `pino_profile_schema()`, `PINO_PROFILE_VERSION` and
@@ -46,21 +84,37 @@ or a missing topic, and the exception cannot tell which.
46
84
  - Documented JavaScript and TypeScript usage, including the projection restated
47
85
  in JavaScript and a contract test over the fixture pairs.
48
86
 
49
- No Node.js or Pino dependency is added, in either direction: the Python package
50
- gains a JSON document and a function that renames one field.
87
+ Neither addition brings a dependency. The broker hierarchy describes the
88
+ operation rather than the product, so it fits Kafka, RabbitMQ, Pulsar and NATS
89
+ without any broker client; retryability is left unclassified, because a
90
+ rejected publish may be a leader election or a missing topic and the exception
91
+ cannot tell which. The Pino profile adds a JSON document and a function that
92
+ renames one field — no Node.js or Pino, in either direction.
93
+
94
+ ### Changed
95
+
96
+ - Pull requests now run the release gate's wheel verification: the built wheel
97
+ is installed, the source tree is deleted, and the suite runs against the
98
+ installed distribution. A test that quietly depends on the source tree used
99
+ to surface only at the last job before publication, which is where it
100
+ surfaced for 1.5.0.
101
+ - The pre-commit hooks are pinned to the versions `poetry.lock` pins, and a
102
+ test derives one from the other. They had drifted to isort 8 and ruff 0.16.4
103
+ against a lock on isort 9 and ruff 0.16.5, which is enough to format a commit
104
+ differently from what CI then checks.
51
105
 
52
106
  ## [1.5.0] - 2026-09-06
53
107
 
54
108
  ### Added
55
109
 
56
110
  - **Redaction-safe source context for parsing failures.** `ParsingError` and
57
- `DeserializationError` accept keyword-only `source`, `format`, `preview` and
58
- `cause`, so a failure on untrusted content can be described without keeping
59
- the content. `source` is redacted when it is a URL and left alone when it is
60
- a file path; `preview` is bounded to 200 characters including its truncation
61
- marker, decodes bytes as UTF-8
62
- with any undecodable byte shown as `\xNN`, and is redacted like any other
63
- public attribute.
111
+ `DeserializationError` take keyword-only `source`, `preview` and `cause`, and
112
+ `ParsingError` gains a keyword-only `format` that `DeserializationError`
113
+ already took positionally — so a failure on untrusted content can be
114
+ described without keeping the content. `source` is redacted when it is a URL
115
+ and left alone when it is a file path; `preview` is bounded to 200 characters
116
+ including its truncation marker, decodes bytes as UTF-8 with any undecodable
117
+ byte shown as `\xNN`, and is redacted like any other public attribute.
64
118
  - `cause=` on both classes, following the canonical 1.4.0 contract: recorded as
65
119
  `.cause` and set as `__cause__`.
66
120
  - **A published JSON Schema for the structured envelope.** The payload
@@ -708,7 +762,8 @@ First public release.
708
762
  - Published to PyPI via OIDC trusted publishing; no long-lived API token is
709
763
  involved in a release.
710
764
 
711
- [Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.6.0...HEAD
765
+ [Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.7.0...HEAD
766
+ [1.7.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.6.0...v1.7.0
712
767
  [1.6.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.5.0...v1.6.0
713
768
  [1.5.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.4.0...v1.5.0
714
769
  [1.4.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.3.0...v1.4.0
@@ -1,7 +1,7 @@
1
1
  cff-version: 1.2.0
2
2
  message: "If you use this software, please cite it using the following metadata."
3
3
  title: "DataExcept"
4
- version: "1.6.0"
4
+ version: "1.7.0"
5
5
  authors:
6
6
  - family-names: "Ribeiro"
7
7
  given-names: "Diogo"
@@ -11,4 +11,4 @@ authors:
11
11
  type: software
12
12
  url: "https://github.com/DiogoRibeiro7/DataExcept"
13
13
  repository-code: "https://github.com/DiogoRibeiro7/DataExcept"
14
- date-released: "2026-09-06"
14
+ date-released: "2026-09-17"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DataExcept
3
- Version: 1.6.0
3
+ Version: 1.7.0
4
4
  Summary: A Python package providing structured, easily-extendable custom exception types.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -45,7 +45,7 @@ Description-Content-Type: text/markdown
45
45
  - **🏗️ Hierarchical Structure**: Catch one specific error, a whole domain, or every operational error via `DataExceptError`
46
46
  - **📦 One Import**: Every exception is available from `dataexcept` directly, or from its domain module — same objects either way
47
47
  - **📊 Data Science Focused**: 106 exception classes covering ML pipelines, feature engineering, model training
48
- - **🧭 Language-neutral envelopes**: exceptions export to strict JSON against a published, versioned schema — so a non-Python consumer can validate what it receives
48
+ - **🧭 Language-neutral envelopes**: exceptions export to strict JSON against a published, versioned schema — so a non-Python consumer can validate what it receives, or read it through the [Pino projection](https://diogoribeiro7.github.io/DataExcept/pino/)
49
49
  - **🔧 Production Ready**: Logging helpers, error context, and exceptions that pickle — so they cross a process boundary with their message, attributes and cause intact
50
50
  - **📚 Academic Quality**: Proper documentation, type hints, and citation support
51
51
  - **🐍 Python 3.10 – 3.14**: Every supported version tested in CI, with full type safety
@@ -394,14 +394,21 @@ through [SECURITY.md](SECURITY.md), not the public issue tracker.
394
394
  - **API Stability**: [What is public and what may change](https://diogoribeiro7.github.io/DataExcept/stability/)
395
395
  - **Upgrading from 0.x**: [Migration guide](https://diogoribeiro7.github.io/DataExcept/migration/)
396
396
  - **Advanced Usage**: [Advanced Guide](https://diogoribeiro7.github.io/DataExcept/advanced_usage/)
397
+ - **Cause-aware Exceptions**: [Wrapping a third-party failure](https://diogoribeiro7.github.io/DataExcept/causes/)
398
+ - **Failure Metadata**: [Transient, permanent, or unclassified](https://diogoribeiro7.github.io/DataExcept/failure_metadata/)
399
+ - **Parsing Context**: [Reporting a failure without keeping the payload](https://diogoribeiro7.github.io/DataExcept/parsing_context/)
400
+ - **Message Brokers**: [Publish, consume and acknowledgement failures](https://diogoribeiro7.github.io/DataExcept/brokers/)
397
401
  - **Envelope Schema**: [The published envelope contract](https://diogoribeiro7.github.io/DataExcept/envelope_schema/)
402
+ - **Pino Interoperability**: [Consuming envelopes from Node.js](https://diogoribeiro7.github.io/DataExcept/pino/)
398
403
  - **CLI Reference**: [CLI Guide](https://diogoribeiro7.github.io/DataExcept/cli/)
399
404
  - **Changelog**: [CHANGELOG.md](CHANGELOG.md)
400
405
 
401
406
  ## 🎓 Citation
402
407
 
403
408
  If you use DataExcept in your research, please cite the exact release you used.
404
- The canonical release metadata is maintained in [CITATION.cff](CITATION.cff).
409
+ The canonical release metadata is maintained in [CITATION.cff](CITATION.cff), and
410
+ the [citation guide](https://diogoribeiro7.github.io/DataExcept/citation/)
411
+ explains how to get the APA and BibTeX forms from it.
405
412
 
406
413
  ## 📄 License
407
414
 
@@ -18,7 +18,7 @@
18
18
  - **🏗️ Hierarchical Structure**: Catch one specific error, a whole domain, or every operational error via `DataExceptError`
19
19
  - **📦 One Import**: Every exception is available from `dataexcept` directly, or from its domain module — same objects either way
20
20
  - **📊 Data Science Focused**: 106 exception classes covering ML pipelines, feature engineering, model training
21
- - **🧭 Language-neutral envelopes**: exceptions export to strict JSON against a published, versioned schema — so a non-Python consumer can validate what it receives
21
+ - **🧭 Language-neutral envelopes**: exceptions export to strict JSON against a published, versioned schema — so a non-Python consumer can validate what it receives, or read it through the [Pino projection](https://diogoribeiro7.github.io/DataExcept/pino/)
22
22
  - **🔧 Production Ready**: Logging helpers, error context, and exceptions that pickle — so they cross a process boundary with their message, attributes and cause intact
23
23
  - **📚 Academic Quality**: Proper documentation, type hints, and citation support
24
24
  - **🐍 Python 3.10 – 3.14**: Every supported version tested in CI, with full type safety
@@ -367,14 +367,21 @@ through [SECURITY.md](SECURITY.md), not the public issue tracker.
367
367
  - **API Stability**: [What is public and what may change](https://diogoribeiro7.github.io/DataExcept/stability/)
368
368
  - **Upgrading from 0.x**: [Migration guide](https://diogoribeiro7.github.io/DataExcept/migration/)
369
369
  - **Advanced Usage**: [Advanced Guide](https://diogoribeiro7.github.io/DataExcept/advanced_usage/)
370
+ - **Cause-aware Exceptions**: [Wrapping a third-party failure](https://diogoribeiro7.github.io/DataExcept/causes/)
371
+ - **Failure Metadata**: [Transient, permanent, or unclassified](https://diogoribeiro7.github.io/DataExcept/failure_metadata/)
372
+ - **Parsing Context**: [Reporting a failure without keeping the payload](https://diogoribeiro7.github.io/DataExcept/parsing_context/)
373
+ - **Message Brokers**: [Publish, consume and acknowledgement failures](https://diogoribeiro7.github.io/DataExcept/brokers/)
370
374
  - **Envelope Schema**: [The published envelope contract](https://diogoribeiro7.github.io/DataExcept/envelope_schema/)
375
+ - **Pino Interoperability**: [Consuming envelopes from Node.js](https://diogoribeiro7.github.io/DataExcept/pino/)
371
376
  - **CLI Reference**: [CLI Guide](https://diogoribeiro7.github.io/DataExcept/cli/)
372
377
  - **Changelog**: [CHANGELOG.md](CHANGELOG.md)
373
378
 
374
379
  ## 🎓 Citation
375
380
 
376
381
  If you use DataExcept in your research, please cite the exact release you used.
377
- The canonical release metadata is maintained in [CITATION.cff](CITATION.cff).
382
+ The canonical release metadata is maintained in [CITATION.cff](CITATION.cff), and
383
+ the [citation guide](https://diogoribeiro7.github.io/DataExcept/citation/)
384
+ explains how to get the APA and BibTeX forms from it.
378
385
 
379
386
  ## 📄 License
380
387
 
@@ -121,24 +121,15 @@ from .exceptions import (
121
121
  WebhookError,
122
122
  )
123
123
  from .failure_metadata import FailureKind, FailureMetadata
124
- from .io_exceptions import (
125
- CustomIOError,
126
- FileLockError,
127
- FileReadError,
128
- FileWriteError,
129
- )
130
- from .logging_helpers import (
131
- Context,
132
- log_and_raise,
133
- log_exception,
134
- log_then_raise,
135
- )
124
+ from .io_exceptions import CustomIOError, FileLockError, FileReadError, FileWriteError
125
+ from .logging_helpers import Context, log_and_raise, log_exception, log_then_raise
136
126
  from .network_exceptions import (
137
127
  ConnectionTimeoutError,
138
128
  HostUnreachableError,
139
129
  NetworkError,
140
130
  ProtocolError,
141
131
  )
132
+ from .observability import OperationContext, exception_to_observability_event
142
133
  from .pandas_exceptions import (
143
134
  DtypeMismatchError,
144
135
  IndexAlignmentError,
@@ -178,6 +169,7 @@ from .security_exceptions import (
178
169
  InvalidTokenError,
179
170
  SecurityError,
180
171
  )
172
+ from .sentry import enrich_sentry_event
181
173
  from .serialization import exception_to_dict, exception_to_json
182
174
  from .wrapping import wrap, wrapping
183
175
 
@@ -295,6 +287,8 @@ __all__ = [
295
287
  "wrapping",
296
288
  "exception_to_dict",
297
289
  "exception_to_json",
290
+ "exception_to_observability_event",
291
+ "OperationContext",
298
292
  "ENVELOPE_SCHEMA_ID",
299
293
  "ENVELOPE_SCHEMA_VERSION",
300
294
  "envelope_schema",
@@ -303,6 +297,7 @@ __all__ = [
303
297
  "pino_profile_schema",
304
298
  "envelope_to_pino",
305
299
  "exception_to_pino",
300
+ "enrich_sentry_event",
306
301
  "Context",
307
302
  "log_and_raise",
308
303
  "log_exception",
@@ -0,0 +1,136 @@
1
+ """Framework-neutral HTTP request context for DataExcept observability.
2
+
3
+ The adapter works with plain header mappings and route templates, so ASGI,
4
+ WSGI, serverless and RPC gateways can use the same logic without becoming
5
+ runtime dependencies of DataExcept.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Mapping, Sequence
12
+
13
+ from .observability import OperationContext
14
+ from .trace_context import W3CTraceContext, trace_context_from_mapping
15
+
16
+ __all__ = ["HttpContext", "http_context_from_request"]
17
+
18
+ _METHOD = re.compile(r"^[A-Z][A-Z0-9!#$%&'*+.^_`|~-]*$")
19
+ _DEFAULT_REQUEST_ID_HEADERS = ("x-request-id", "request-id")
20
+ _DEFAULT_CORRELATION_ID_HEADERS = ("x-correlation-id", "correlation-id")
21
+
22
+
23
+ def _safe_header_text(value: object) -> str | None:
24
+ if not isinstance(value, str):
25
+ return None
26
+ if not value or "\r" in value or "\n" in value:
27
+ return None
28
+ return value
29
+
30
+
31
+ def _normalized_headers(headers: Mapping[str, object]) -> dict[str, object]:
32
+ normalized: dict[str, object] = {}
33
+ for key, value in headers.items():
34
+ if isinstance(key, str):
35
+ normalized[key.lower()] = value
36
+ return normalized
37
+
38
+
39
+ def _first_header(
40
+ headers: Mapping[str, object],
41
+ names: Sequence[str],
42
+ ) -> str | None:
43
+ for name in names:
44
+ value = _safe_header_text(headers.get(name.lower()))
45
+ if value is not None:
46
+ return value
47
+ return None
48
+
49
+
50
+ def _validate_header_names(names: Sequence[str], field: str) -> tuple[str, ...]:
51
+ if isinstance(names, (str, bytes)):
52
+ raise TypeError(f"{field} must be a sequence of header names")
53
+ result: list[str] = []
54
+ for name in names:
55
+ if not isinstance(name, str):
56
+ raise TypeError(f"{field} entries must be strings")
57
+ stripped = name.strip().lower()
58
+ if not stripped:
59
+ raise ValueError(f"{field} entries must not be empty")
60
+ result.append(stripped)
61
+ return tuple(result)
62
+
63
+
64
+ def _operation_name(method: str, route: str | None) -> str:
65
+ if route is None:
66
+ return method
67
+ if not isinstance(route, str):
68
+ raise TypeError("route must be a string or None")
69
+ if not route.strip():
70
+ raise ValueError("route must not be empty")
71
+ if "?" in route or "#" in route:
72
+ raise ValueError("route must be a route template without query or fragment")
73
+ return f"{method} {route}"
74
+
75
+
76
+ class HttpContext:
77
+ """Request operation context plus optional W3C propagation context."""
78
+
79
+ __slots__ = ("operation_context", "trace_context")
80
+
81
+ def __init__(
82
+ self,
83
+ operation_context: OperationContext,
84
+ trace_context: W3CTraceContext | None,
85
+ ) -> None:
86
+ self.operation_context = operation_context
87
+ self.trace_context = trace_context
88
+
89
+
90
+ def http_context_from_request(
91
+ method: str,
92
+ *,
93
+ route: str | None = None,
94
+ headers: Mapping[str, object] | None = None,
95
+ system: str | None = "http",
96
+ component: str | None = None,
97
+ request_id_headers: Sequence[str] = _DEFAULT_REQUEST_ID_HEADERS,
98
+ correlation_id_headers: Sequence[str] = _DEFAULT_CORRELATION_ID_HEADERS,
99
+ ) -> HttpContext:
100
+ """Build request observability context from plain HTTP-style inputs.
101
+
102
+ ``route`` should be a low-cardinality route template such as
103
+ ``/users/{id}``, never a raw request path. Request and correlation IDs are
104
+ read from configurable headers. W3C trace context is propagated when
105
+ present, but no trace or span identifiers are generated when it is absent.
106
+ """
107
+ if not isinstance(method, str):
108
+ raise TypeError("method must be a string")
109
+ normalized_method = method.strip().upper()
110
+ if not _METHOD.fullmatch(normalized_method):
111
+ raise ValueError("method must be a valid HTTP method token")
112
+ if headers is None:
113
+ headers = {}
114
+ if not isinstance(headers, Mapping):
115
+ raise TypeError("headers must be a mapping or None")
116
+
117
+ normalized = _normalized_headers(headers)
118
+ request_names = _validate_header_names(request_id_headers, "request_id_headers")
119
+ correlation_names = _validate_header_names(
120
+ correlation_id_headers,
121
+ "correlation_id_headers",
122
+ )
123
+
124
+ operation_context = OperationContext(
125
+ system=system,
126
+ component=component,
127
+ operation=_operation_name(normalized_method, route),
128
+ request_id=_first_header(normalized, request_names),
129
+ correlation_id=_first_header(normalized, correlation_names),
130
+ )
131
+
132
+ trace_context = trace_context_from_mapping(normalized)
133
+ if trace_context is not None:
134
+ operation_context = trace_context.to_operation_context(operation_context)
135
+
136
+ return HttpContext(operation_context, trace_context)
@@ -8,6 +8,7 @@ import logging
8
8
  import traceback
9
9
  from typing import Any, Iterator, Mapping, Optional
10
10
 
11
+ from .observability import OperationContext
11
12
  from .redaction import redact_urls_in_text
12
13
 
13
14
  Context = Mapping[str, Any]
@@ -80,13 +81,25 @@ def _normalize_context_value(value: Any) -> Any:
80
81
  return _described(value)
81
82
 
82
83
 
83
- def _build_extra(context: Context | None) -> dict[str, Any] | None:
84
- if not context:
85
- return None
86
- serialized = {
87
- key: _normalize_context_value(value) for key, value in context.items()
88
- }
89
- return {"dataexcept_context": serialized}
84
+ def _build_extra(
85
+ context: Context | None,
86
+ operation_context: OperationContext | None = None,
87
+ ) -> dict[str, Any] | None:
88
+ extra: dict[str, Any] = {}
89
+
90
+ if context:
91
+ extra["dataexcept_context"] = {
92
+ key: _normalize_context_value(value) for key, value in context.items()
93
+ }
94
+
95
+ if operation_context is not None:
96
+ if not isinstance(operation_context, OperationContext):
97
+ raise TypeError("operation_context must be an OperationContext or None")
98
+ serialized = operation_context.to_dict()
99
+ if serialized:
100
+ extra["dataexcept_operation"] = serialized
101
+
102
+ return extra or None
90
103
 
91
104
 
92
105
  def _chain_mentions_a_url(exc: BaseException) -> bool:
@@ -113,11 +126,16 @@ def log_exception(
113
126
  logger: Optional[logging.Logger] = None,
114
127
  level: int = logging.ERROR,
115
128
  context: Context | None = None,
129
+ operation_context: OperationContext | None = None,
116
130
  ) -> None:
117
131
  """Log *exc* at the given log *level* using *logger*.
118
132
 
119
133
  If *logger* is ``None`` a module level logger is used.
120
134
 
135
+ ``context`` remains the free-form application context. ``operation_context``
136
+ carries the stable request/job/tool-call identifiers shared with tracing and
137
+ error-tracker integrations.
138
+
121
139
  DataExcept redacts what it renders, but a wrapped third-party exception
122
140
  renders itself: an HTTP client's error may quote the credential-bearing URL
123
141
  it was called with, and ``exc_info`` makes logging print that whole chain.
@@ -127,7 +145,7 @@ def log_exception(
127
145
  """
128
146
  if logger is None:
129
147
  logger = logging.getLogger(__name__)
130
- extra = _build_extra(context)
148
+ extra = _build_extra(context, operation_context)
131
149
 
132
150
  if _chain_mentions_a_url(exc):
133
151
  formatted = "".join(
@@ -147,12 +165,19 @@ def log_and_raise(
147
165
  logger: Optional[logging.Logger] = None,
148
166
  level: int = logging.ERROR,
149
167
  context: Context | None = None,
168
+ operation_context: OperationContext | None = None,
150
169
  ) -> Iterator[None]:
151
170
  """Context manager that logs and re-raises exceptions preserving traceback."""
152
171
  try:
153
172
  yield
154
173
  except Exception as exc:
155
- log_exception(exc, logger=logger, level=level, context=context)
174
+ log_exception(
175
+ exc,
176
+ logger=logger,
177
+ level=level,
178
+ context=context,
179
+ operation_context=operation_context,
180
+ )
156
181
  raise
157
182
 
158
183
 
@@ -161,6 +186,7 @@ def log_then_raise(
161
186
  logger: Optional[logging.Logger] = None,
162
187
  level: int = logging.ERROR,
163
188
  context: Context | None = None,
189
+ operation_context: OperationContext | None = None,
164
190
  ) -> None:
165
191
  """Log *exc* and immediately raise it.
166
192
 
@@ -168,5 +194,11 @@ def log_then_raise(
168
194
  ``with`` block would be too intrusive. Prefer :func:`log_and_raise` whenever
169
195
  possible so tracebacks remain untouched.
170
196
  """
171
- log_exception(exc, logger=logger, level=level, context=context)
197
+ log_exception(
198
+ exc,
199
+ logger=logger,
200
+ level=level,
201
+ context=context,
202
+ operation_context=operation_context,
203
+ )
172
204
  raise exc
@@ -0,0 +1,107 @@
1
+ """Product-neutral observability context for failures crossing boundaries.
2
+
3
+ Frameworks call the same concepts by different names: request, task, job,
4
+ workflow step, tool call, invocation. DataExcept keeps the shared part small
5
+ and explicit so adapters can project it into logs, traces, error trackers or
6
+ protocol-specific metadata without making those frameworks runtime
7
+ requirements.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, fields
13
+ from typing import Any
14
+
15
+ from .redaction import redact_urls_in_text
16
+ from .serialization import exception_to_dict
17
+
18
+ __all__ = ["OperationContext", "exception_to_observability_event"]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class OperationContext:
23
+ """Identifiers that describe where an operation is executing.
24
+
25
+ ``system``, ``component`` and ``operation`` are intended to stay
26
+ low-cardinality and are suitable for filtering. Request, job, correlation,
27
+ trace and span identifiers are correlation data and should not normally be
28
+ turned into metric dimensions or indexed tags.
29
+
30
+ Every value is optional because inventing provenance is worse than omitting
31
+ it. URL-shaped values are redacted before they can leave this object.
32
+ """
33
+
34
+ system: str | None = None
35
+ component: str | None = None
36
+ operation: str | None = None
37
+ request_id: str | None = None
38
+ job_id: str | None = None
39
+ correlation_id: str | None = None
40
+ trace_id: str | None = None
41
+ span_id: str | None = None
42
+
43
+ def __post_init__(self) -> None:
44
+ for field in fields(self):
45
+ value = getattr(self, field.name)
46
+ if value is None:
47
+ continue
48
+ if not isinstance(value, str):
49
+ raise TypeError(f"{field.name} must be a string or None")
50
+ if not value.strip():
51
+ raise ValueError(f"{field.name} must not be empty")
52
+ object.__setattr__(
53
+ self,
54
+ field.name,
55
+ redact_urls_in_text(value, keep_path=False),
56
+ )
57
+
58
+ def to_dict(self) -> dict[str, str]:
59
+ """Return only the identifiers that are actually present."""
60
+ return {
61
+ field.name: value
62
+ for field in fields(self)
63
+ if (value := getattr(self, field.name)) is not None
64
+ }
65
+
66
+ def index_fields(self) -> dict[str, str]:
67
+ """Return only low-cardinality fields suitable for filtering."""
68
+ return {
69
+ key: value
70
+ for key in ("system", "component", "operation")
71
+ if (value := getattr(self, key)) is not None
72
+ }
73
+
74
+
75
+ def exception_to_observability_event(
76
+ exc: BaseException,
77
+ *,
78
+ operation_context: OperationContext | None = None,
79
+ include_attributes: bool = True,
80
+ max_depth: int = 8,
81
+ ) -> dict[str, Any]:
82
+ """Return a strict-JSON-safe failure event shared by observability adapters.
83
+
84
+ The exception payload is the canonical DataExcept envelope. Operation
85
+ metadata is separate so adapters can index stable fields while retaining
86
+ high-cardinality correlation identifiers without flattening them into tags.
87
+ """
88
+ if operation_context is not None and not isinstance(
89
+ operation_context, OperationContext
90
+ ):
91
+ raise TypeError("operation_context must be an OperationContext or None")
92
+
93
+ event: dict[str, Any] = {
94
+ "event": "exception",
95
+ "exception": exception_to_dict(
96
+ exc,
97
+ include_attributes=include_attributes,
98
+ max_depth=max_depth,
99
+ ),
100
+ }
101
+
102
+ if operation_context is not None:
103
+ operation = operation_context.to_dict()
104
+ if operation:
105
+ event["operation"] = operation
106
+
107
+ return event