authweave-otel 7.1.2__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.
- authweave_otel-7.1.2/.gitignore +36 -0
- authweave_otel-7.1.2/LICENSE +21 -0
- authweave_otel-7.1.2/PKG-INFO +83 -0
- authweave_otel-7.1.2/README.md +56 -0
- authweave_otel-7.1.2/authweave_otel/__init__.py +66 -0
- authweave_otel-7.1.2/authweave_otel/catalog.py +180 -0
- authweave_otel-7.1.2/authweave_otel/privacy.py +115 -0
- authweave_otel-7.1.2/authweave_otel/py.typed +0 -0
- authweave_otel-7.1.2/authweave_otel/telemetry.py +448 -0
- authweave_otel-7.1.2/pyproject.toml +65 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# macOS
|
|
2
|
+
.DS_Store
|
|
3
|
+
|
|
4
|
+
# Python bytecode and native build products
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Local environments and secrets
|
|
10
|
+
/.venv/
|
|
11
|
+
/.uv-cache/
|
|
12
|
+
.env
|
|
13
|
+
.env.*
|
|
14
|
+
!.env.example
|
|
15
|
+
|
|
16
|
+
# Packaging output
|
|
17
|
+
/build/
|
|
18
|
+
/dist/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
|
|
21
|
+
# Test, coverage, and tool caches
|
|
22
|
+
.coverage
|
|
23
|
+
.coverage.*
|
|
24
|
+
/coverage.xml
|
|
25
|
+
/htmlcov/
|
|
26
|
+
/.pytest_cache/
|
|
27
|
+
/.ruff_cache/
|
|
28
|
+
/.cache/
|
|
29
|
+
|
|
30
|
+
# Generated documentation and code intelligence
|
|
31
|
+
/site/
|
|
32
|
+
/docs/_include/
|
|
33
|
+
/.codegraph/
|
|
34
|
+
|
|
35
|
+
# Docker reference ephemeral key material
|
|
36
|
+
/docker/reference/http-signatures/_runtime/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vladislav Shepilov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: authweave-otel
|
|
3
|
+
Version: 7.1.2
|
|
4
|
+
Summary: OpenTelemetry API-only security observability facade for the AuthWeave stack
|
|
5
|
+
Project-URL: homepage, https://github.com/ZYLVEXT/litestar-auth
|
|
6
|
+
Project-URL: documentation, https://zylvext.github.io/litestar-auth/
|
|
7
|
+
Project-URL: source, https://github.com/ZYLVEXT/litestar-auth
|
|
8
|
+
Project-URL: tracker, https://github.com/ZYLVEXT/litestar-auth/issues
|
|
9
|
+
Author-email: Vladislav Shepilov <shepilov.v@protonmail.com>
|
|
10
|
+
Maintainer-email: Vladislav Shepilov <shepilov.v@protonmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: authentication,metrics,observability,opentelemetry,security,tracing
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Topic :: System :: Monitoring
|
|
23
|
+
Requires-Python: <3.15.0,>=3.12.0
|
|
24
|
+
Requires-Dist: authweave-core==7.1.2
|
|
25
|
+
Requires-Dist: opentelemetry-api<2.0,>=1.44.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# authweave-otel
|
|
29
|
+
|
|
30
|
+
OpenTelemetry API-only security observability facade for the AuthWeave
|
|
31
|
+
authentication stack.
|
|
32
|
+
|
|
33
|
+
`authweave-otel` depends only on `opentelemetry-api` and `authweave-core`. It
|
|
34
|
+
never installs an SDK, exporter, sampler, propagator, or `service.name`. Without
|
|
35
|
+
a configured SDK every span and metric is a no-op, so instrumentation is safe and
|
|
36
|
+
free by default.
|
|
37
|
+
|
|
38
|
+
The application owns the `TracerProvider`/`MeterProvider`, sampling, export, and
|
|
39
|
+
retention. Construct `AuthWeaveTelemetry` with global or explicit providers and
|
|
40
|
+
pass it into your AuthWeave adapter configuration. `LitestarAuth` creates only
|
|
41
|
+
`INTERNAL` child security spans inside the server span produced by normal ASGI
|
|
42
|
+
instrumentation; it never creates a second HTTP span:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from authweave_otel import AuthWeaveTelemetry
|
|
46
|
+
from litestar_auth import LitestarAuthConfig
|
|
47
|
+
|
|
48
|
+
telemetry = AuthWeaveTelemetry()
|
|
49
|
+
config = LitestarAuthConfig(
|
|
50
|
+
# normal required configuration omitted
|
|
51
|
+
observer=telemetry,
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The same observer can be passed to the HTTP-signature/webhook verifier and
|
|
56
|
+
webhook sender constructors. `verify(..., links=(TraceCorrelation(...),))` and
|
|
57
|
+
`send(..., links=...)` attach retry/async causal links without accepting trace
|
|
58
|
+
context as identity. Direct `operation_span()` and metric methods remain
|
|
59
|
+
available for application-owned operations.
|
|
60
|
+
|
|
61
|
+
## Privacy and abuse resistance
|
|
62
|
+
|
|
63
|
+
- Attribute keys are allowlisted (`authweave.*` catalog only).
|
|
64
|
+
- Values are truncated to 64 characters and redacted when they match secret
|
|
65
|
+
canaries (tokens, cookies, PEM headers, payment fragments).
|
|
66
|
+
- Unknown reason codes collapse to `_OTHER` so hostile input cannot grow label
|
|
67
|
+
cardinality.
|
|
68
|
+
- Baggage extraction/forwarding defaults to off
|
|
69
|
+
(`DEFAULT_TRACE_CONTEXT_POLICY`); remote parents are never trusted identity.
|
|
70
|
+
- Telemetry never participates in authentication, authorization, replay, or
|
|
71
|
+
idempotency decisions. Collector/exporter outage must not change auth results.
|
|
72
|
+
- Workload `SecurityEvent` values receive the active `trace_id`/`span_id` for
|
|
73
|
+
lookup only. The mandatory event callback remains the durable audit channel.
|
|
74
|
+
|
|
75
|
+
## Catalog and reference stack
|
|
76
|
+
|
|
77
|
+
Span names, metric names, units, and attribute keys are a versioned catalog. See
|
|
78
|
+
`authweave_otel.catalog`; renames and cardinality growth are breaking changes.
|
|
79
|
+
|
|
80
|
+
A non-production Collector/Prometheus/Tempo/Grafana stack lives at
|
|
81
|
+
`docker/reference/observability/`. Run `sh docker/reference/observability/verify.sh`
|
|
82
|
+
for the synthetic smoke test and golden Prometheus name checks after the pinned
|
|
83
|
+
OTel→Prometheus translation.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# authweave-otel
|
|
2
|
+
|
|
3
|
+
OpenTelemetry API-only security observability facade for the AuthWeave
|
|
4
|
+
authentication stack.
|
|
5
|
+
|
|
6
|
+
`authweave-otel` depends only on `opentelemetry-api` and `authweave-core`. It
|
|
7
|
+
never installs an SDK, exporter, sampler, propagator, or `service.name`. Without
|
|
8
|
+
a configured SDK every span and metric is a no-op, so instrumentation is safe and
|
|
9
|
+
free by default.
|
|
10
|
+
|
|
11
|
+
The application owns the `TracerProvider`/`MeterProvider`, sampling, export, and
|
|
12
|
+
retention. Construct `AuthWeaveTelemetry` with global or explicit providers and
|
|
13
|
+
pass it into your AuthWeave adapter configuration. `LitestarAuth` creates only
|
|
14
|
+
`INTERNAL` child security spans inside the server span produced by normal ASGI
|
|
15
|
+
instrumentation; it never creates a second HTTP span:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from authweave_otel import AuthWeaveTelemetry
|
|
19
|
+
from litestar_auth import LitestarAuthConfig
|
|
20
|
+
|
|
21
|
+
telemetry = AuthWeaveTelemetry()
|
|
22
|
+
config = LitestarAuthConfig(
|
|
23
|
+
# normal required configuration omitted
|
|
24
|
+
observer=telemetry,
|
|
25
|
+
)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The same observer can be passed to the HTTP-signature/webhook verifier and
|
|
29
|
+
webhook sender constructors. `verify(..., links=(TraceCorrelation(...),))` and
|
|
30
|
+
`send(..., links=...)` attach retry/async causal links without accepting trace
|
|
31
|
+
context as identity. Direct `operation_span()` and metric methods remain
|
|
32
|
+
available for application-owned operations.
|
|
33
|
+
|
|
34
|
+
## Privacy and abuse resistance
|
|
35
|
+
|
|
36
|
+
- Attribute keys are allowlisted (`authweave.*` catalog only).
|
|
37
|
+
- Values are truncated to 64 characters and redacted when they match secret
|
|
38
|
+
canaries (tokens, cookies, PEM headers, payment fragments).
|
|
39
|
+
- Unknown reason codes collapse to `_OTHER` so hostile input cannot grow label
|
|
40
|
+
cardinality.
|
|
41
|
+
- Baggage extraction/forwarding defaults to off
|
|
42
|
+
(`DEFAULT_TRACE_CONTEXT_POLICY`); remote parents are never trusted identity.
|
|
43
|
+
- Telemetry never participates in authentication, authorization, replay, or
|
|
44
|
+
idempotency decisions. Collector/exporter outage must not change auth results.
|
|
45
|
+
- Workload `SecurityEvent` values receive the active `trace_id`/`span_id` for
|
|
46
|
+
lookup only. The mandatory event callback remains the durable audit channel.
|
|
47
|
+
|
|
48
|
+
## Catalog and reference stack
|
|
49
|
+
|
|
50
|
+
Span names, metric names, units, and attribute keys are a versioned catalog. See
|
|
51
|
+
`authweave_otel.catalog`; renames and cardinality growth are breaking changes.
|
|
52
|
+
|
|
53
|
+
A non-production Collector/Prometheus/Tempo/Grafana stack lives at
|
|
54
|
+
`docker/reference/observability/`. Run `sh docker/reference/observability/verify.sh`
|
|
55
|
+
for the synthetic smoke test and golden Prometheus name checks after the pinned
|
|
56
|
+
OTel→Prometheus translation.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""OpenTelemetry API-only security observability for the AuthWeave stack."""
|
|
2
|
+
|
|
3
|
+
from authweave_otel.catalog import (
|
|
4
|
+
ERROR_OUTCOMES,
|
|
5
|
+
EXPECTED_OUTCOMES,
|
|
6
|
+
INSTRUMENTATION_SCOPE,
|
|
7
|
+
METRIC_CATALOG,
|
|
8
|
+
OTHER_REASON_CODE,
|
|
9
|
+
REASON_CODES,
|
|
10
|
+
SEMANTIC_CONVENTIONS_SCHEMA_URL,
|
|
11
|
+
AttributeKey,
|
|
12
|
+
Instrument,
|
|
13
|
+
MetricSpec,
|
|
14
|
+
Operation,
|
|
15
|
+
Outcome,
|
|
16
|
+
normalize_reason_code,
|
|
17
|
+
span_name,
|
|
18
|
+
)
|
|
19
|
+
from authweave_otel.privacy import (
|
|
20
|
+
ALLOWED_ATTRIBUTE_KEYS,
|
|
21
|
+
DEFAULT_BAGGAGE_KEYS_ALLOWLIST,
|
|
22
|
+
DEFAULT_TRACE_CONTEXT_POLICY,
|
|
23
|
+
MAX_ATTRIBUTE_COUNT,
|
|
24
|
+
MAX_ATTRIBUTE_VALUE_LENGTH,
|
|
25
|
+
REDACTED_ATTRIBUTE_VALUE,
|
|
26
|
+
SECRET_CANARY_FRAGMENTS,
|
|
27
|
+
TraceContextPolicy,
|
|
28
|
+
contains_secret_canary,
|
|
29
|
+
sanitize_attributes,
|
|
30
|
+
truncate_attribute_value,
|
|
31
|
+
)
|
|
32
|
+
from authweave_otel.telemetry import AuthWeaveTelemetry, KeyAgeCallback, OperationScope
|
|
33
|
+
|
|
34
|
+
__version__ = "7.1.2"
|
|
35
|
+
|
|
36
|
+
__all__ = (
|
|
37
|
+
"ALLOWED_ATTRIBUTE_KEYS",
|
|
38
|
+
"DEFAULT_BAGGAGE_KEYS_ALLOWLIST",
|
|
39
|
+
"DEFAULT_TRACE_CONTEXT_POLICY",
|
|
40
|
+
"ERROR_OUTCOMES",
|
|
41
|
+
"EXPECTED_OUTCOMES",
|
|
42
|
+
"INSTRUMENTATION_SCOPE",
|
|
43
|
+
"MAX_ATTRIBUTE_COUNT",
|
|
44
|
+
"MAX_ATTRIBUTE_VALUE_LENGTH",
|
|
45
|
+
"METRIC_CATALOG",
|
|
46
|
+
"OTHER_REASON_CODE",
|
|
47
|
+
"REASON_CODES",
|
|
48
|
+
"REDACTED_ATTRIBUTE_VALUE",
|
|
49
|
+
"SECRET_CANARY_FRAGMENTS",
|
|
50
|
+
"SEMANTIC_CONVENTIONS_SCHEMA_URL",
|
|
51
|
+
"AttributeKey",
|
|
52
|
+
"AuthWeaveTelemetry",
|
|
53
|
+
"Instrument",
|
|
54
|
+
"KeyAgeCallback",
|
|
55
|
+
"MetricSpec",
|
|
56
|
+
"Operation",
|
|
57
|
+
"OperationScope",
|
|
58
|
+
"Outcome",
|
|
59
|
+
"TraceContextPolicy",
|
|
60
|
+
"__version__",
|
|
61
|
+
"contains_secret_canary",
|
|
62
|
+
"normalize_reason_code",
|
|
63
|
+
"sanitize_attributes",
|
|
64
|
+
"span_name",
|
|
65
|
+
"truncate_attribute_value",
|
|
66
|
+
)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Versioned, immutable telemetry catalog for AuthWeave security instrumentation.
|
|
2
|
+
|
|
3
|
+
This module is pure data and stdlib only: it never imports ``opentelemetry`` so
|
|
4
|
+
that the span/metric contract can be asserted by golden tests without an SDK.
|
|
5
|
+
Renames, unit changes, or cardinality growth are breaking changes and must be
|
|
6
|
+
caught by :mod:`tests.test_catalog`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
from authweave_core import FailureCode, SecurityOperation, SecurityOutcome
|
|
15
|
+
|
|
16
|
+
#: Instrumentation scope reported for every AuthWeave span and metric.
|
|
17
|
+
INSTRUMENTATION_SCOPE = "authweave"
|
|
18
|
+
|
|
19
|
+
#: Pinned stable OpenTelemetry Semantic Conventions schema URL. Upgrading this is
|
|
20
|
+
#: a deliberate, reviewed change gated by the golden catalog test.
|
|
21
|
+
SEMANTIC_CONVENTIONS_SCHEMA_URL = "https://opentelemetry.io/schemas/1.30.0"
|
|
22
|
+
|
|
23
|
+
#: Reason code substituted for any value outside the bounded vocabulary so that a
|
|
24
|
+
#: hostile or unknown reason can never grow metric/label cardinality.
|
|
25
|
+
OTHER_REASON_CODE = "_OTHER"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
Operation = SecurityOperation
|
|
29
|
+
Outcome = SecurityOutcome
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
#: Outcomes that represent an expected negative decision, never a span ``ERROR``.
|
|
33
|
+
EXPECTED_OUTCOMES = frozenset(
|
|
34
|
+
{Outcome.NOT_APPLICABLE, Outcome.INVALID, Outcome.REPLAY, Outcome.MISS, Outcome.STALE},
|
|
35
|
+
)
|
|
36
|
+
#: Outcomes that mark a span status ``ERROR`` because verification could not
|
|
37
|
+
#: complete safely or an invariant was violated.
|
|
38
|
+
ERROR_OUTCOMES = frozenset({Outcome.UNAVAILABLE, Outcome.INVARIANT_FAILURE, Outcome.ERROR})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AttributeKey(StrEnum):
|
|
42
|
+
"""Namespaced ``authweave.*`` attribute keys shared by spans and metrics."""
|
|
43
|
+
|
|
44
|
+
PROFILE = "authweave.profile"
|
|
45
|
+
OPERATION = "authweave.operation"
|
|
46
|
+
OUTCOME = "authweave.outcome"
|
|
47
|
+
REASON_CODE = "authweave.reason_code"
|
|
48
|
+
PRINCIPAL_KIND = "authweave.principal_kind"
|
|
49
|
+
CREDENTIAL_KIND = "authweave.credential_kind"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Instrument(StrEnum):
|
|
53
|
+
"""Supported synchronous instrument kinds in the catalog."""
|
|
54
|
+
|
|
55
|
+
COUNTER = "counter"
|
|
56
|
+
HISTOGRAM = "histogram"
|
|
57
|
+
OBSERVABLE_GAUGE = "observable_gauge"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class MetricSpec:
|
|
62
|
+
"""One versioned metric definition."""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
instrument: Instrument
|
|
66
|
+
unit: str
|
|
67
|
+
description: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
#: The frozen metric catalog. Prometheus/OpenMetrics suffixes (``_total`` and
|
|
71
|
+
#: histogram suffixes) are added by the exporter, never encoded here.
|
|
72
|
+
METRIC_CATALOG: tuple[MetricSpec, ...] = (
|
|
73
|
+
MetricSpec(
|
|
74
|
+
"authweave.authentication.attempts",
|
|
75
|
+
Instrument.COUNTER,
|
|
76
|
+
"{attempt}",
|
|
77
|
+
"Authentication attempts by profile and outcome.",
|
|
78
|
+
),
|
|
79
|
+
MetricSpec(
|
|
80
|
+
"authweave.authentication.duration",
|
|
81
|
+
Instrument.HISTOGRAM,
|
|
82
|
+
"s",
|
|
83
|
+
"Coordinator and provider authentication latency.",
|
|
84
|
+
),
|
|
85
|
+
MetricSpec(
|
|
86
|
+
"authweave.integrity.verifications",
|
|
87
|
+
Instrument.COUNTER,
|
|
88
|
+
"{verification}",
|
|
89
|
+
"DPoP, HTTP signature, and webhook verification outcomes.",
|
|
90
|
+
),
|
|
91
|
+
MetricSpec(
|
|
92
|
+
"authweave.integrity.duration",
|
|
93
|
+
Instrument.HISTOGRAM,
|
|
94
|
+
"s",
|
|
95
|
+
"Message integrity verification latency.",
|
|
96
|
+
),
|
|
97
|
+
MetricSpec(
|
|
98
|
+
"authweave.replay.decisions",
|
|
99
|
+
Instrument.COUNTER,
|
|
100
|
+
"{decision}",
|
|
101
|
+
"Replay/nonce store decisions.",
|
|
102
|
+
),
|
|
103
|
+
MetricSpec(
|
|
104
|
+
"authweave.remote.operation.duration",
|
|
105
|
+
Instrument.HISTOGRAM,
|
|
106
|
+
"s",
|
|
107
|
+
"JWKS, PAR, introspection, and token-exchange latency.",
|
|
108
|
+
),
|
|
109
|
+
MetricSpec(
|
|
110
|
+
"authweave.remote.operation.attempts",
|
|
111
|
+
Instrument.COUNTER,
|
|
112
|
+
"{attempt}",
|
|
113
|
+
"Remote dependency outcomes without any URL label.",
|
|
114
|
+
),
|
|
115
|
+
MetricSpec(
|
|
116
|
+
"authweave.cache.requests",
|
|
117
|
+
Instrument.COUNTER,
|
|
118
|
+
"{request}",
|
|
119
|
+
"Cache hit, miss, stale, and error outcomes.",
|
|
120
|
+
),
|
|
121
|
+
MetricSpec(
|
|
122
|
+
"authweave.key.age",
|
|
123
|
+
Instrument.OBSERVABLE_GAUGE,
|
|
124
|
+
"s",
|
|
125
|
+
"Age of the current trusted key/bundle snapshot.",
|
|
126
|
+
),
|
|
127
|
+
MetricSpec(
|
|
128
|
+
"authweave.webhook.delivery.attempts",
|
|
129
|
+
Instrument.COUNTER,
|
|
130
|
+
"{attempt}",
|
|
131
|
+
"Webhook delivery outcome and retry class.",
|
|
132
|
+
),
|
|
133
|
+
MetricSpec(
|
|
134
|
+
"authweave.webhook.delivery.duration",
|
|
135
|
+
Instrument.HISTOGRAM,
|
|
136
|
+
"s",
|
|
137
|
+
"End-to-end webhook sender attempt latency.",
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
#: Protocol-specific reason codes layered on top of the neutral core failure
|
|
142
|
+
#: codes for integrity, nonce, and key-distribution decisions.
|
|
143
|
+
_PROTOCOL_REASON_CODES = frozenset(
|
|
144
|
+
{
|
|
145
|
+
"nonce_required",
|
|
146
|
+
"nonce_stale",
|
|
147
|
+
"digest_mismatch",
|
|
148
|
+
"signature_invalid",
|
|
149
|
+
"key_unknown",
|
|
150
|
+
"trust_domain_rejected",
|
|
151
|
+
"store_unavailable",
|
|
152
|
+
"capacity_exceeded",
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
#: Bounded reason-code vocabulary sourced from the neutral core failure codes plus
|
|
157
|
+
#: the protocol-specific codes. Any other value collapses to
|
|
158
|
+
#: :data:`OTHER_REASON_CODE`.
|
|
159
|
+
REASON_CODES: frozenset[str] = frozenset(code.value for code in FailureCode) | _PROTOCOL_REASON_CODES
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def span_name(operation: Operation) -> str:
|
|
163
|
+
"""Return the fully qualified span name for a logical operation.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The ``authweave.<operation>`` span name.
|
|
167
|
+
"""
|
|
168
|
+
return f"{INSTRUMENTATION_SCOPE}.{operation.value}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def normalize_reason_code(reason_code: str | None) -> str | None:
|
|
172
|
+
"""Collapse any out-of-vocabulary reason code to a safe bounded value.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
``None`` when no reason code is supplied, the reason code when it is in
|
|
176
|
+
the bounded vocabulary, otherwise :data:`OTHER_REASON_CODE`.
|
|
177
|
+
"""
|
|
178
|
+
if reason_code is None:
|
|
179
|
+
return None
|
|
180
|
+
return reason_code if reason_code in REASON_CODES else OTHER_REASON_CODE
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Privacy, cardinality, and abuse-resistance policy for AuthWeave telemetry.
|
|
2
|
+
|
|
3
|
+
These limits apply before attributes leave the process. They do not replace
|
|
4
|
+
application-owned Trace Context boundary policy, SDK Views, or Collector
|
|
5
|
+
attribute filtering — they are the library's last line of defense against
|
|
6
|
+
cardinality growth and secret leakage through labels.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from authweave_otel.catalog import AttributeKey
|
|
14
|
+
|
|
15
|
+
#: Maximum number of AuthWeave attributes attached to one span or metric point.
|
|
16
|
+
MAX_ATTRIBUTE_COUNT = 8
|
|
17
|
+
|
|
18
|
+
#: Maximum UTF-8 character length of any AuthWeave attribute value.
|
|
19
|
+
MAX_ATTRIBUTE_VALUE_LENGTH = 64
|
|
20
|
+
|
|
21
|
+
#: Attribute keys the facade is allowed to emit. Anything else is dropped.
|
|
22
|
+
ALLOWED_ATTRIBUTE_KEYS: frozenset[str] = frozenset(key.value for key in AttributeKey)
|
|
23
|
+
|
|
24
|
+
#: Baggage key allowlist. Empty by default: baggage is never extracted or
|
|
25
|
+
#: forwarded by AuthWeave instrumentation.
|
|
26
|
+
DEFAULT_BAGGAGE_KEYS_ALLOWLIST: frozenset[str] = frozenset()
|
|
27
|
+
|
|
28
|
+
#: Substrings that must never appear in emitted attribute values. Used by
|
|
29
|
+
#: secret-canary tests and as documentation of the denial vocabulary.
|
|
30
|
+
SECRET_CANARY_FRAGMENTS: frozenset[str] = frozenset({
|
|
31
|
+
"Bearer ",
|
|
32
|
+
"eyJ", # JWT header prefix
|
|
33
|
+
"cookie=",
|
|
34
|
+
"webhook-signature",
|
|
35
|
+
"dpop ",
|
|
36
|
+
"-----BEGIN",
|
|
37
|
+
"password=",
|
|
38
|
+
"client_secret",
|
|
39
|
+
"authorization_code",
|
|
40
|
+
"account_number",
|
|
41
|
+
"pan=",
|
|
42
|
+
"cvv=",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#: Value substituted when a free-form attribute matches a secret canary.
|
|
47
|
+
REDACTED_ATTRIBUTE_VALUE = "_REDACTED"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class TraceContextPolicy:
|
|
52
|
+
"""Deployer-facing Trace Context and baggage policy defaults.
|
|
53
|
+
|
|
54
|
+
AuthWeave never treats a remote parent as trusted identity and never uses
|
|
55
|
+
baggage for authentication, authorization, replay, or idempotency decisions.
|
|
56
|
+
Applications enable W3C Trace Context extraction only on explicitly
|
|
57
|
+
configured boundaries.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
accept_remote_parent: bool = False
|
|
61
|
+
extract_baggage: bool = False
|
|
62
|
+
forward_baggage: bool = False
|
|
63
|
+
baggage_keys_allowlist: frozenset[str] = DEFAULT_BAGGAGE_KEYS_ALLOWLIST
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
#: Documented library default. Applications may construct a stricter policy;
|
|
67
|
+
#: loosening requires an explicit deployer decision outside this package.
|
|
68
|
+
DEFAULT_TRACE_CONTEXT_POLICY = TraceContextPolicy()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def truncate_attribute_value(value: str) -> str:
|
|
72
|
+
"""Bound one attribute value to :data:`MAX_ATTRIBUTE_VALUE_LENGTH`.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The original value when it fits, otherwise a truncated prefix.
|
|
76
|
+
"""
|
|
77
|
+
if len(value) <= MAX_ATTRIBUTE_VALUE_LENGTH:
|
|
78
|
+
return value
|
|
79
|
+
return value[:MAX_ATTRIBUTE_VALUE_LENGTH]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sanitize_attributes(attributes: dict[str, str]) -> dict[str, str]:
|
|
83
|
+
"""Drop unknown keys, redact canaries, truncate values, and enforce the budget.
|
|
84
|
+
|
|
85
|
+
Unknown keys are discarded. Values matching
|
|
86
|
+
:data:`SECRET_CANARY_FRAGMENTS` are replaced with
|
|
87
|
+
:data:`REDACTED_ATTRIBUTE_VALUE`. Remaining values longer than
|
|
88
|
+
:data:`MAX_ATTRIBUTE_VALUE_LENGTH` are truncated. When more than
|
|
89
|
+
:data:`MAX_ATTRIBUTE_COUNT` attributes remain after filtering, excess entries
|
|
90
|
+
are dropped in insertion order so cardinality stays bounded.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
A new dict safe to attach to a span or metric data point.
|
|
94
|
+
"""
|
|
95
|
+
sanitized: dict[str, str] = {}
|
|
96
|
+
for key, value in attributes.items():
|
|
97
|
+
if key not in ALLOWED_ATTRIBUTE_KEYS:
|
|
98
|
+
continue
|
|
99
|
+
if contains_secret_canary(value):
|
|
100
|
+
sanitized[key] = REDACTED_ATTRIBUTE_VALUE
|
|
101
|
+
else:
|
|
102
|
+
sanitized[key] = truncate_attribute_value(value)
|
|
103
|
+
if len(sanitized) >= MAX_ATTRIBUTE_COUNT:
|
|
104
|
+
break
|
|
105
|
+
return sanitized
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def contains_secret_canary(value: str) -> bool:
|
|
109
|
+
"""Return whether ``value`` matches a documented secret-canary fragment.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
``True`` when any fragment in :data:`SECRET_CANARY_FRAGMENTS` appears.
|
|
113
|
+
"""
|
|
114
|
+
lowered = value.lower()
|
|
115
|
+
return any(fragment.lower() in lowered for fragment in SECRET_CANARY_FRAGMENTS)
|
|
File without changes
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""OpenTelemetry API-only security observability facade.
|
|
2
|
+
|
|
3
|
+
The facade depends only on ``opentelemetry-api``. Without a configured SDK the
|
|
4
|
+
API returns no-op tracers, meters, and instruments, so instrumentation is safe
|
|
5
|
+
and free. The application owns the ``TracerProvider``/``MeterProvider``, SDK,
|
|
6
|
+
Collector, sampling, export, and retention; this library never installs a global
|
|
7
|
+
SDK, exporter, sampler, or ``service.name``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from time import perf_counter
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from authweave_core import TraceCorrelation
|
|
17
|
+
from opentelemetry import metrics, trace
|
|
18
|
+
from opentelemetry.trace import Link, SpanContext, SpanKind, TraceFlags
|
|
19
|
+
from opentelemetry.trace.status import Status, StatusCode
|
|
20
|
+
|
|
21
|
+
from authweave_otel.catalog import (
|
|
22
|
+
ERROR_OUTCOMES,
|
|
23
|
+
INSTRUMENTATION_SCOPE,
|
|
24
|
+
METRIC_CATALOG,
|
|
25
|
+
SEMANTIC_CONVENTIONS_SCHEMA_URL,
|
|
26
|
+
AttributeKey,
|
|
27
|
+
Instrument,
|
|
28
|
+
Operation,
|
|
29
|
+
Outcome,
|
|
30
|
+
normalize_reason_code,
|
|
31
|
+
span_name,
|
|
32
|
+
)
|
|
33
|
+
from authweave_otel.privacy import sanitize_attributes
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
37
|
+
|
|
38
|
+
from opentelemetry.metrics import CallbackOptions, Counter, Histogram, Meter, MeterProvider, Observation
|
|
39
|
+
from opentelemetry.trace import Span, TracerProvider
|
|
40
|
+
|
|
41
|
+
__version__ = "7.1.2"
|
|
42
|
+
|
|
43
|
+
type AttributeValue = str
|
|
44
|
+
type Attributes = dict[str, AttributeValue]
|
|
45
|
+
type KeyAgeCallback = Callable[[CallbackOptions], Sequence[Observation]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OperationScope:
|
|
49
|
+
"""Handle for enriching one in-flight AuthWeave security span."""
|
|
50
|
+
|
|
51
|
+
__slots__ = (
|
|
52
|
+
"_credential_kind",
|
|
53
|
+
"_operation",
|
|
54
|
+
"_outcome",
|
|
55
|
+
"_principal_kind",
|
|
56
|
+
"_profile",
|
|
57
|
+
"_reason_code",
|
|
58
|
+
"_span",
|
|
59
|
+
"_unexpected",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
span: Span,
|
|
65
|
+
operation: Operation,
|
|
66
|
+
*,
|
|
67
|
+
profile: str | None,
|
|
68
|
+
principal_kind: str | None,
|
|
69
|
+
credential_kind: str | None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Bind the scope to its span and low-cardinality context."""
|
|
72
|
+
self._span = span
|
|
73
|
+
self._operation = operation
|
|
74
|
+
self._profile = profile
|
|
75
|
+
self._principal_kind = principal_kind
|
|
76
|
+
self._credential_kind = credential_kind
|
|
77
|
+
self._outcome: Outcome | None = None
|
|
78
|
+
self._reason_code: str | None = None
|
|
79
|
+
self._unexpected = False
|
|
80
|
+
|
|
81
|
+
def set_outcome(self, outcome: Outcome, *, reason_code: str | None = None) -> None:
|
|
82
|
+
"""Record the terminal outcome and an optional bounded reason code."""
|
|
83
|
+
self._outcome = outcome
|
|
84
|
+
self._reason_code = reason_code
|
|
85
|
+
|
|
86
|
+
def record_unexpected_exception(self, exc: BaseException) -> None:
|
|
87
|
+
"""Record only the sanitized exception type, never its message or stack."""
|
|
88
|
+
self._unexpected = True
|
|
89
|
+
self._span.add_event("exception", {"exception.type": type(exc).__name__})
|
|
90
|
+
|
|
91
|
+
def _finalize(self) -> None:
|
|
92
|
+
attributes: Attributes = {AttributeKey.OPERATION.value: self._operation.value}
|
|
93
|
+
for key, value in (
|
|
94
|
+
(AttributeKey.PROFILE, self._profile),
|
|
95
|
+
(AttributeKey.PRINCIPAL_KIND, self._principal_kind),
|
|
96
|
+
(AttributeKey.CREDENTIAL_KIND, self._credential_kind),
|
|
97
|
+
):
|
|
98
|
+
if value is not None:
|
|
99
|
+
attributes[key.value] = value
|
|
100
|
+
if self._outcome is not None:
|
|
101
|
+
attributes[AttributeKey.OUTCOME.value] = self._outcome.value
|
|
102
|
+
normalized = normalize_reason_code(self._reason_code)
|
|
103
|
+
if normalized is not None:
|
|
104
|
+
attributes[AttributeKey.REASON_CODE.value] = normalized
|
|
105
|
+
for key, value in sanitize_attributes(attributes).items():
|
|
106
|
+
self._span.set_attribute(key, value)
|
|
107
|
+
if self._unexpected or (self._outcome is not None and self._outcome in ERROR_OUTCOMES):
|
|
108
|
+
self._span.set_status(Status(StatusCode.ERROR))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AuthWeaveTelemetry:
|
|
112
|
+
"""Typed telemetry facade over the pinned AuthWeave span and metric catalog."""
|
|
113
|
+
|
|
114
|
+
__slots__ = ("_counters", "_histograms", "_meter", "_metrics_enabled", "_spans_enabled", "_tracer")
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
tracer_provider: TracerProvider | None = None,
|
|
120
|
+
meter_provider: MeterProvider | None = None,
|
|
121
|
+
spans_enabled: bool = True,
|
|
122
|
+
metrics_enabled: bool = True,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Acquire scoped tracer/meter and eagerly build the catalog instruments."""
|
|
125
|
+
self._spans_enabled = spans_enabled
|
|
126
|
+
self._metrics_enabled = metrics_enabled
|
|
127
|
+
self._tracer = trace.get_tracer(
|
|
128
|
+
INSTRUMENTATION_SCOPE,
|
|
129
|
+
__version__,
|
|
130
|
+
tracer_provider,
|
|
131
|
+
SEMANTIC_CONVENTIONS_SCHEMA_URL,
|
|
132
|
+
)
|
|
133
|
+
self._counters: dict[str, Counter] = {}
|
|
134
|
+
self._histograms: dict[str, Histogram] = {}
|
|
135
|
+
self._meter: Meter | None = None
|
|
136
|
+
if metrics_enabled:
|
|
137
|
+
self._build_instruments(meter_provider)
|
|
138
|
+
|
|
139
|
+
def _build_instruments(self, meter_provider: MeterProvider | None) -> None:
|
|
140
|
+
meter = metrics.get_meter(
|
|
141
|
+
INSTRUMENTATION_SCOPE,
|
|
142
|
+
__version__,
|
|
143
|
+
meter_provider,
|
|
144
|
+
SEMANTIC_CONVENTIONS_SCHEMA_URL,
|
|
145
|
+
)
|
|
146
|
+
self._meter = meter
|
|
147
|
+
for spec in METRIC_CATALOG:
|
|
148
|
+
if spec.instrument is Instrument.COUNTER:
|
|
149
|
+
self._counters[spec.name] = meter.create_counter(spec.name, spec.unit, spec.description)
|
|
150
|
+
elif spec.instrument is Instrument.HISTOGRAM:
|
|
151
|
+
self._histograms[spec.name] = meter.create_histogram(spec.name, spec.unit, spec.description)
|
|
152
|
+
# Observable instruments (key.age) are registered with an application
|
|
153
|
+
# callback in a dedicated API and are not built eagerly here.
|
|
154
|
+
|
|
155
|
+
@contextmanager
|
|
156
|
+
def operation_span(
|
|
157
|
+
self,
|
|
158
|
+
operation: Operation,
|
|
159
|
+
*,
|
|
160
|
+
profile: str | None = None,
|
|
161
|
+
principal_kind: str | None = None,
|
|
162
|
+
credential_kind: str | None = None,
|
|
163
|
+
links: Sequence[TraceCorrelation] = (),
|
|
164
|
+
) -> Iterator[OperationScope]:
|
|
165
|
+
"""Run one INTERNAL security span, recording only sanitized exceptions.
|
|
166
|
+
|
|
167
|
+
Cancellation propagates untouched; only ``Exception`` subclasses are
|
|
168
|
+
recorded (by sanitized type) before being re-raised.
|
|
169
|
+
|
|
170
|
+
Yields:
|
|
171
|
+
The scope used to set the terminal outcome and reason code.
|
|
172
|
+
"""
|
|
173
|
+
if not self._spans_enabled:
|
|
174
|
+
yield OperationScope(
|
|
175
|
+
trace.INVALID_SPAN,
|
|
176
|
+
operation,
|
|
177
|
+
profile=profile,
|
|
178
|
+
principal_kind=principal_kind,
|
|
179
|
+
credential_kind=credential_kind,
|
|
180
|
+
)
|
|
181
|
+
return
|
|
182
|
+
with self._tracer.start_as_current_span(
|
|
183
|
+
span_name(operation),
|
|
184
|
+
kind=SpanKind.INTERNAL,
|
|
185
|
+
links=[_otel_link(link) for link in links],
|
|
186
|
+
record_exception=False,
|
|
187
|
+
set_status_on_exception=False,
|
|
188
|
+
) as span:
|
|
189
|
+
scope = OperationScope(
|
|
190
|
+
span,
|
|
191
|
+
operation,
|
|
192
|
+
profile=profile,
|
|
193
|
+
principal_kind=principal_kind,
|
|
194
|
+
credential_kind=credential_kind,
|
|
195
|
+
)
|
|
196
|
+
try:
|
|
197
|
+
yield scope
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
scope.record_unexpected_exception(exc)
|
|
200
|
+
raise
|
|
201
|
+
finally:
|
|
202
|
+
scope._finalize() # ruff: ignore[private-member-access] - scope is owned by this facade
|
|
203
|
+
|
|
204
|
+
@contextmanager
|
|
205
|
+
def observe(
|
|
206
|
+
self,
|
|
207
|
+
operation: Operation,
|
|
208
|
+
*,
|
|
209
|
+
profile: str | None = None,
|
|
210
|
+
principal_kind: str | None = None,
|
|
211
|
+
credential_kind: str | None = None,
|
|
212
|
+
links: Sequence[TraceCorrelation] = (),
|
|
213
|
+
) -> Iterator[OperationScope]:
|
|
214
|
+
"""Implement the core observer contract and emit matching metrics.
|
|
215
|
+
|
|
216
|
+
Yields:
|
|
217
|
+
The operation scope used by core/provider flow instrumentation.
|
|
218
|
+
"""
|
|
219
|
+
started = perf_counter()
|
|
220
|
+
with self.operation_span(
|
|
221
|
+
operation,
|
|
222
|
+
profile=profile,
|
|
223
|
+
principal_kind=principal_kind,
|
|
224
|
+
credential_kind=credential_kind,
|
|
225
|
+
links=links,
|
|
226
|
+
) as scope:
|
|
227
|
+
try:
|
|
228
|
+
yield scope
|
|
229
|
+
except Exception:
|
|
230
|
+
scope.set_outcome(Outcome.ERROR)
|
|
231
|
+
raise
|
|
232
|
+
finally:
|
|
233
|
+
if scope._outcome is not None: # ruff: ignore[private-member-access] - owned observer scope
|
|
234
|
+
self._record_observation(scope, perf_counter() - started)
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def current_correlation() -> TraceCorrelation | None:
|
|
238
|
+
"""Return the current valid OpenTelemetry span identifiers."""
|
|
239
|
+
context = trace.get_current_span().get_span_context()
|
|
240
|
+
if not context.is_valid:
|
|
241
|
+
return None
|
|
242
|
+
return TraceCorrelation(trace_id=f"{context.trace_id:032x}", span_id=f"{context.span_id:016x}")
|
|
243
|
+
|
|
244
|
+
def _record_observation(self, scope: OperationScope, duration_seconds: float) -> None:
|
|
245
|
+
operation = scope._operation # ruff: ignore[private-member-access] - owned observer scope
|
|
246
|
+
outcome = scope._outcome # ruff: ignore[private-member-access] - owned observer scope
|
|
247
|
+
if outcome is None:
|
|
248
|
+
return
|
|
249
|
+
if operation is Operation.AUTHENTICATE:
|
|
250
|
+
self.record_authentication(
|
|
251
|
+
profile=scope._profile, # ruff: ignore[private-member-access] - owned observer scope
|
|
252
|
+
outcome=outcome,
|
|
253
|
+
reason_code=scope._reason_code, # ruff: ignore[private-member-access] - owned observer scope
|
|
254
|
+
principal_kind=scope._principal_kind, # ruff: ignore[private-member-access] - owned observer scope
|
|
255
|
+
credential_kind=scope._credential_kind, # ruff: ignore[private-member-access] - owned observer scope
|
|
256
|
+
duration_seconds=duration_seconds,
|
|
257
|
+
)
|
|
258
|
+
elif operation in {
|
|
259
|
+
Operation.VERIFY_DPOP,
|
|
260
|
+
Operation.VERIFY_HTTP_SIGNATURE,
|
|
261
|
+
Operation.VERIFY_WEBHOOK,
|
|
262
|
+
Operation.VALIDATE_SPIFFE_SVID,
|
|
263
|
+
}:
|
|
264
|
+
self.record_integrity(
|
|
265
|
+
operation=operation,
|
|
266
|
+
profile=scope._profile, # ruff: ignore[private-member-access] - owned observer scope
|
|
267
|
+
outcome=outcome,
|
|
268
|
+
reason_code=scope._reason_code, # ruff: ignore[private-member-access] - owned observer scope
|
|
269
|
+
credential_kind=scope._credential_kind, # ruff: ignore[private-member-access] - owned observer scope
|
|
270
|
+
duration_seconds=duration_seconds,
|
|
271
|
+
)
|
|
272
|
+
elif operation is Operation.REPLAY_CHECK:
|
|
273
|
+
self.record_replay(profile=scope._profile, outcome=outcome) # ruff: ignore[private-member-access]
|
|
274
|
+
elif operation is Operation.WEBHOOK_DELIVER:
|
|
275
|
+
self.record_webhook_delivery(
|
|
276
|
+
profile=scope._profile, # ruff: ignore[private-member-access] - owned observer scope
|
|
277
|
+
outcome=outcome,
|
|
278
|
+
duration_seconds=duration_seconds,
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
self.record_remote_operation(
|
|
282
|
+
operation=operation,
|
|
283
|
+
outcome=outcome,
|
|
284
|
+
profile=scope._profile, # ruff: ignore[private-member-access] - owned observer scope
|
|
285
|
+
duration_seconds=duration_seconds,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def record_authentication( # ruff: ignore[too-many-arguments] - each parameter is an explicit bounded label
|
|
289
|
+
self,
|
|
290
|
+
*,
|
|
291
|
+
profile: str | None,
|
|
292
|
+
outcome: Outcome,
|
|
293
|
+
reason_code: str | None = None,
|
|
294
|
+
principal_kind: str | None = None,
|
|
295
|
+
credential_kind: str | None = None,
|
|
296
|
+
duration_seconds: float | None = None,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Record one authentication attempt and optional latency."""
|
|
299
|
+
attributes = _build_attributes(
|
|
300
|
+
profile=profile,
|
|
301
|
+
operation=Operation.AUTHENTICATE,
|
|
302
|
+
outcome=outcome,
|
|
303
|
+
reason_code=reason_code,
|
|
304
|
+
principal_kind=principal_kind,
|
|
305
|
+
credential_kind=credential_kind,
|
|
306
|
+
)
|
|
307
|
+
self._add("authweave.authentication.attempts", attributes)
|
|
308
|
+
self._record("authweave.authentication.duration", duration_seconds, attributes)
|
|
309
|
+
|
|
310
|
+
def record_integrity( # ruff: ignore[too-many-arguments] - each parameter is an explicit bounded label
|
|
311
|
+
self,
|
|
312
|
+
*,
|
|
313
|
+
operation: Operation,
|
|
314
|
+
profile: str | None,
|
|
315
|
+
outcome: Outcome,
|
|
316
|
+
reason_code: str | None = None,
|
|
317
|
+
credential_kind: str | None = None,
|
|
318
|
+
duration_seconds: float | None = None,
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Record one message-integrity verification and optional latency."""
|
|
321
|
+
attributes = _build_attributes(
|
|
322
|
+
profile=profile,
|
|
323
|
+
operation=operation,
|
|
324
|
+
outcome=outcome,
|
|
325
|
+
reason_code=reason_code,
|
|
326
|
+
principal_kind=None,
|
|
327
|
+
credential_kind=credential_kind,
|
|
328
|
+
)
|
|
329
|
+
self._add("authweave.integrity.verifications", attributes)
|
|
330
|
+
self._record("authweave.integrity.duration", duration_seconds, attributes)
|
|
331
|
+
|
|
332
|
+
def record_replay(self, *, profile: str | None, outcome: Outcome) -> None:
|
|
333
|
+
"""Record one replay/nonce store decision."""
|
|
334
|
+
attributes = _build_attributes(
|
|
335
|
+
profile=profile,
|
|
336
|
+
operation=Operation.REPLAY_CHECK,
|
|
337
|
+
outcome=outcome,
|
|
338
|
+
reason_code=None,
|
|
339
|
+
principal_kind=None,
|
|
340
|
+
credential_kind=None,
|
|
341
|
+
)
|
|
342
|
+
self._add("authweave.replay.decisions", attributes)
|
|
343
|
+
|
|
344
|
+
def record_remote_operation(
|
|
345
|
+
self,
|
|
346
|
+
*,
|
|
347
|
+
operation: Operation,
|
|
348
|
+
outcome: Outcome,
|
|
349
|
+
profile: str | None = None,
|
|
350
|
+
duration_seconds: float | None = None,
|
|
351
|
+
) -> None:
|
|
352
|
+
"""Record one remote dependency attempt and optional latency, never a URL."""
|
|
353
|
+
attributes = _build_attributes(
|
|
354
|
+
profile=profile,
|
|
355
|
+
operation=operation,
|
|
356
|
+
outcome=outcome,
|
|
357
|
+
reason_code=None,
|
|
358
|
+
principal_kind=None,
|
|
359
|
+
credential_kind=None,
|
|
360
|
+
)
|
|
361
|
+
self._add("authweave.remote.operation.attempts", attributes)
|
|
362
|
+
self._record("authweave.remote.operation.duration", duration_seconds, attributes)
|
|
363
|
+
|
|
364
|
+
def record_cache(self, *, operation: Operation, outcome: Outcome) -> None:
|
|
365
|
+
"""Record one cache hit, miss, stale, or error decision."""
|
|
366
|
+
attributes = _build_attributes(
|
|
367
|
+
profile=None,
|
|
368
|
+
operation=operation,
|
|
369
|
+
outcome=outcome,
|
|
370
|
+
reason_code=None,
|
|
371
|
+
principal_kind=None,
|
|
372
|
+
credential_kind=None,
|
|
373
|
+
)
|
|
374
|
+
self._add("authweave.cache.requests", attributes)
|
|
375
|
+
|
|
376
|
+
def record_webhook_delivery(
|
|
377
|
+
self,
|
|
378
|
+
*,
|
|
379
|
+
profile: str | None,
|
|
380
|
+
outcome: Outcome,
|
|
381
|
+
duration_seconds: float | None = None,
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Record one webhook delivery attempt and optional end-to-end latency."""
|
|
384
|
+
attributes = _build_attributes(
|
|
385
|
+
profile=profile,
|
|
386
|
+
operation=Operation.WEBHOOK_DELIVER,
|
|
387
|
+
outcome=outcome,
|
|
388
|
+
reason_code=None,
|
|
389
|
+
principal_kind=None,
|
|
390
|
+
credential_kind=None,
|
|
391
|
+
)
|
|
392
|
+
self._add("authweave.webhook.delivery.attempts", attributes)
|
|
393
|
+
self._record("authweave.webhook.delivery.duration", duration_seconds, attributes)
|
|
394
|
+
|
|
395
|
+
def register_key_age(self, callback: KeyAgeCallback) -> None:
|
|
396
|
+
"""Register the application callback backing the ``authweave.key.age`` gauge.
|
|
397
|
+
|
|
398
|
+
The callback must return bounded, low-cardinality observations and never a
|
|
399
|
+
key identifier or URL. It is a no-op when metrics are disabled.
|
|
400
|
+
"""
|
|
401
|
+
if not self._metrics_enabled or self._meter is None:
|
|
402
|
+
return
|
|
403
|
+
spec = next(spec for spec in METRIC_CATALOG if spec.name == "authweave.key.age")
|
|
404
|
+
self._meter.create_observable_gauge(spec.name, [callback], spec.unit, spec.description)
|
|
405
|
+
|
|
406
|
+
def _add(self, name: str, attributes: Attributes) -> None:
|
|
407
|
+
if self._metrics_enabled:
|
|
408
|
+
self._counters[name].add(1, attributes)
|
|
409
|
+
|
|
410
|
+
def _record(self, name: str, value: float | None, attributes: Attributes) -> None:
|
|
411
|
+
if self._metrics_enabled and value is not None:
|
|
412
|
+
self._histograms[name].record(value, attributes)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _build_attributes( # ruff: ignore[too-many-arguments] - each label is an explicit bounded dimension
|
|
416
|
+
*,
|
|
417
|
+
profile: str | None,
|
|
418
|
+
operation: Operation,
|
|
419
|
+
outcome: Outcome,
|
|
420
|
+
reason_code: str | None,
|
|
421
|
+
principal_kind: str | None,
|
|
422
|
+
credential_kind: str | None,
|
|
423
|
+
) -> Attributes:
|
|
424
|
+
attributes: Attributes = {
|
|
425
|
+
AttributeKey.OPERATION.value: operation.value,
|
|
426
|
+
AttributeKey.OUTCOME.value: outcome.value,
|
|
427
|
+
}
|
|
428
|
+
normalized = normalize_reason_code(reason_code)
|
|
429
|
+
for key, value in (
|
|
430
|
+
(AttributeKey.PROFILE, profile),
|
|
431
|
+
(AttributeKey.REASON_CODE, normalized),
|
|
432
|
+
(AttributeKey.PRINCIPAL_KIND, principal_kind),
|
|
433
|
+
(AttributeKey.CREDENTIAL_KIND, credential_kind),
|
|
434
|
+
):
|
|
435
|
+
if value is not None:
|
|
436
|
+
attributes[key.value] = value
|
|
437
|
+
return sanitize_attributes(attributes)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _otel_link(correlation: TraceCorrelation) -> Link:
|
|
441
|
+
return Link(
|
|
442
|
+
SpanContext(
|
|
443
|
+
trace_id=int(correlation.trace_id, 16),
|
|
444
|
+
span_id=int(correlation.span_id, 16),
|
|
445
|
+
is_remote=True,
|
|
446
|
+
trace_flags=TraceFlags(TraceFlags.DEFAULT),
|
|
447
|
+
),
|
|
448
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "authweave-otel"
|
|
3
|
+
version = "7.1.2"
|
|
4
|
+
description = "OpenTelemetry API-only security observability facade for the AuthWeave stack"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Vladislav Shepilov", email = "shepilov.v@protonmail.com"},
|
|
10
|
+
]
|
|
11
|
+
maintainers = [
|
|
12
|
+
{name = "Vladislav Shepilov", email = "shepilov.v@protonmail.com"},
|
|
13
|
+
]
|
|
14
|
+
keywords = ["opentelemetry", "observability", "authentication", "security", "metrics", "tracing"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Topic :: Security",
|
|
19
|
+
"Topic :: System :: Monitoring",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
]
|
|
26
|
+
requires-python = "<3.15.0,>=3.12.0"
|
|
27
|
+
dependencies = [
|
|
28
|
+
"authweave-core==7.1.2",
|
|
29
|
+
"opentelemetry-api>=1.44.0,<2.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
homepage = "https://github.com/ZYLVEXT/litestar-auth"
|
|
34
|
+
documentation = "https://zylvext.github.io/litestar-auth/"
|
|
35
|
+
source = "https://github.com/ZYLVEXT/litestar-auth"
|
|
36
|
+
tracker = "https://github.com/ZYLVEXT/litestar-auth/issues"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling==1.31.0"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["authweave_otel"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
only-include = ["authweave_otel"]
|
|
47
|
+
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = [
|
|
50
|
+
"opentelemetry-sdk>=1.44.0,<2.0",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.coverage.run]
|
|
54
|
+
source = ["authweave_otel"]
|
|
55
|
+
branch = true
|
|
56
|
+
|
|
57
|
+
[tool.coverage.report]
|
|
58
|
+
fail_under = 100
|
|
59
|
+
show_missing = true
|
|
60
|
+
|
|
61
|
+
[tool.ty.environment]
|
|
62
|
+
python-version = "3.12"
|
|
63
|
+
|
|
64
|
+
[tool.ty.src]
|
|
65
|
+
include = ["authweave_otel", "tests"]
|