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.
- {dataexcept-1.6.0 → dataexcept-1.7.0}/CHANGELOG.md +72 -17
- {dataexcept-1.6.0 → dataexcept-1.7.0}/CITATION.cff +2 -2
- {dataexcept-1.6.0 → dataexcept-1.7.0}/PKG-INFO +10 -3
- {dataexcept-1.6.0 → dataexcept-1.7.0}/README.md +9 -2
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/__init__.py +7 -12
- dataexcept-1.7.0/dataexcept/http_context.py +136 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/logging_helpers.py +42 -10
- dataexcept-1.7.0/dataexcept/observability.py +107 -0
- dataexcept-1.7.0/dataexcept/opentelemetry.py +173 -0
- dataexcept-1.7.0/dataexcept/orchestrator_context.py +110 -0
- dataexcept-1.7.0/dataexcept/sentry.py +111 -0
- dataexcept-1.7.0/dataexcept/trace_context.py +192 -0
- dataexcept-1.7.0/dataexcept/worker_context.py +167 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/pyproject.toml +3 -3
- {dataexcept-1.6.0 → dataexcept-1.7.0}/LICENSE +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/__main__.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/_causes.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/_previews.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/_validation.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/base.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/broker_exceptions.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/database_exceptions.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/dataengineering_exceptions.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/datascience_exceptions/__init__.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/datascience_exceptions/base.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/datascience_exceptions/ingestion.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/datascience_exceptions/operations.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/datascience_exceptions/training.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/__init__.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/authentication.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/base.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/configuration.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/external.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/lifecycle.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/notification.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/parsing.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/scheduling.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/exceptions/validation.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/failure_metadata.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/io_exceptions.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/network_exceptions.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/pandas_exceptions.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/pino.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/pipeline_exceptions.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/py.typed +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/redaction.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/schema.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/schemas/envelope-1.0.0.json +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/schemas/pino-1.0.0.json +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/security_exceptions.py +0 -0
- {dataexcept-1.6.0 → dataexcept-1.7.0}/dataexcept/serialization.py +0 -0
- {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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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`
|
|
58
|
-
`
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
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.
|
|
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-
|
|
14
|
+
date-released: "2026-09-17"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: DataExcept
|
|
3
|
-
Version: 1.
|
|
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
|
-
|
|
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(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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(
|
|
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(
|
|
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
|