tigrcorn-observability 0.3.16.dev12__tar.gz → 0.3.17.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/PKG-INFO +3 -3
  2. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/pyproject.toml +3 -3
  3. tigrcorn_observability-0.3.17.dev1/src/tigrcorn_observability/__init__.py +35 -0
  4. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/src/tigrcorn_observability/logging.py +113 -3
  5. tigrcorn_observability-0.3.17.dev1/src/tigrcorn_observability/protocol.py +225 -0
  6. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/src/tigrcorn_observability.egg-info/PKG-INFO +3 -3
  7. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/src/tigrcorn_observability.egg-info/SOURCES.txt +1 -0
  8. tigrcorn_observability-0.3.17.dev1/src/tigrcorn_observability.egg-info/requires.txt +2 -0
  9. tigrcorn_observability-0.3.16.dev12/src/tigrcorn_observability/__init__.py +0 -1
  10. tigrcorn_observability-0.3.16.dev12/src/tigrcorn_observability.egg-info/requires.txt +0 -2
  11. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/LICENSE +0 -0
  12. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/README.md +0 -0
  13. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/setup.cfg +0 -0
  14. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/src/tigrcorn_observability/events.py +0 -0
  15. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/src/tigrcorn_observability/metrics.py +0 -0
  16. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/src/tigrcorn_observability/py.typed +0 -0
  17. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/src/tigrcorn_observability/tracing.py +0 -0
  18. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/src/tigrcorn_observability.egg-info/dependency_links.txt +0 -0
  19. {tigrcorn_observability-0.3.16.dev12 → tigrcorn_observability-0.3.17.dev1}/src/tigrcorn_observability.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tigrcorn-observability
3
- Version: 0.3.16.dev12
3
+ Version: 0.3.17.dev1
4
4
  Summary: Logging, metrics, tracing, DoS warning events, and release-evidence metadata for the Tigrcorn Python web server.
5
5
  Author-email: Jacob Stewart <jacob@swarmauri.com>
6
6
  License: Apache License
@@ -186,8 +186,8 @@ Classifier: Typing :: Typed
186
186
  Requires-Python: <3.15,>=3.10
187
187
  Description-Content-Type: text/markdown
188
188
  License-File: LICENSE
189
- Requires-Dist: tigrcorn-core==0.3.16.dev12
190
- Requires-Dist: tigrcorn-config==0.3.16.dev12
189
+ Requires-Dist: tigrcorn-core==0.3.17.dev1
190
+ Requires-Dist: tigrcorn-config==0.3.17.dev1
191
191
  Dynamic: license-file
192
192
 
193
193
  <div align="center">
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tigrcorn-observability"
7
- version = "0.3.16.dev12"
7
+ version = "0.3.17.dev1"
8
8
  description = "Logging, metrics, tracing, DoS warning events, and release-evidence metadata for the Tigrcorn Python web server."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10,<3.15"
@@ -29,8 +29,8 @@ classifiers = [
29
29
  "Typing :: Typed",
30
30
  ]
31
31
  dependencies = [
32
- "tigrcorn-core==0.3.16.dev12",
33
- "tigrcorn-config==0.3.16.dev12",
32
+ "tigrcorn-core==0.3.17.dev1",
33
+ "tigrcorn-config==0.3.17.dev1",
34
34
  ]
35
35
 
36
36
  [tool.setuptools]
@@ -0,0 +1,35 @@
1
+ """Logging, metrics, tracing helpers."""
2
+
3
+ from tigrcorn_observability.protocol import (
4
+ ObservabilityRecord,
5
+ ProtocolLabels,
6
+ ProtocolObservabilityError,
7
+ certification_evidence_links,
8
+ lifecycle_event,
9
+ make_observability_record,
10
+ protocol_label_schema,
11
+ protocol_transport_labels,
12
+ propagate_otel_context,
13
+ quic_operational_labels,
14
+ redact_and_bound_labels,
15
+ redact_mapping,
16
+ tls_alpn_labels,
17
+ validate_label_config,
18
+ )
19
+
20
+ __all__ = [
21
+ "ObservabilityRecord",
22
+ "ProtocolLabels",
23
+ "ProtocolObservabilityError",
24
+ "certification_evidence_links",
25
+ "lifecycle_event",
26
+ "make_observability_record",
27
+ "protocol_label_schema",
28
+ "protocol_transport_labels",
29
+ "propagate_otel_context",
30
+ "quic_operational_labels",
31
+ "redact_and_bound_labels",
32
+ "redact_mapping",
33
+ "tls_alpn_labels",
34
+ "validate_label_config",
35
+ ]
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  from typing import Any, Mapping
11
11
 
12
12
  from tigrcorn_config.files import ConfigFileError, load_config_source
13
+ from tigrcorn_observability.protocol import make_observability_record
13
14
 
14
15
 
15
16
  class LoggingConfigError(RuntimeError):
@@ -164,23 +165,132 @@ class AccessLogger:
164
165
  self.logger = logger
165
166
  self.enabled = enabled
166
167
  self.fmt = fmt or '{peer} "{method} {path} {proto}" {status}'
168
+ self.observability_records: list[dict[str, Any]] = []
167
169
 
168
170
  def _peer(self, client: tuple[str, int] | None) -> str:
169
171
  return f"{client[0]}:{client[1]}" if client else '-'
170
172
 
171
- def log_http(self, client: tuple[str, int] | None, method: str, path: str, status: int, proto: str) -> None:
173
+ def log_http(
174
+ self,
175
+ client: tuple[str, int] | None,
176
+ method: str,
177
+ path: str,
178
+ status: int,
179
+ proto: str,
180
+ *,
181
+ request_headers: list[tuple[bytes, bytes]] | tuple[tuple[bytes, bytes], ...] | None = None,
182
+ tls_version: str | None = None,
183
+ alpn: str | None = None,
184
+ transport: str | None = None,
185
+ ) -> None:
172
186
  if not self.enabled:
173
187
  return
174
188
  peer = self._peer(client)
175
189
  message = self.fmt.format(peer=peer, method=method, path=path, status=status, proto=proto)
176
- self.logger.info(message, extra={'event': 'access.http', 'peer': peer, 'method': method, 'path': path, 'status': status, 'proto': proto})
190
+ observability = self._http_observability_record(
191
+ peer=peer,
192
+ method=method,
193
+ path=path,
194
+ status=status,
195
+ proto=proto,
196
+ request_headers=request_headers,
197
+ tls_version=tls_version,
198
+ alpn=alpn,
199
+ transport=transport,
200
+ )
201
+ self.observability_records.append(observability)
202
+ self.logger.info(
203
+ message,
204
+ extra={
205
+ 'event': 'access.http',
206
+ 'peer': peer,
207
+ 'method': method,
208
+ 'path': path,
209
+ 'status': status,
210
+ 'proto': proto,
211
+ 'observability': observability,
212
+ },
213
+ )
177
214
 
178
215
  def log_ws(self, client: tuple[str, int] | None, path: str, result: str) -> None:
179
216
  if not self.enabled:
180
217
  return
181
218
  peer = self._peer(client)
182
219
  message = f'{peer} "WEBSOCKET {path}" {result}'
183
- self.logger.info(message, extra={'event': 'access.websocket', 'peer': peer, 'path': path, 'result': result})
220
+ observability = make_observability_record(
221
+ kind='event',
222
+ name=f'websocket.{result}',
223
+ labels={
224
+ 'protocol': 'websocket',
225
+ 'transport': 'tcp',
226
+ 'lifecycle': result,
227
+ 'peer': peer,
228
+ 'path': path,
229
+ },
230
+ attributes={'result': result},
231
+ )
232
+ self.observability_records.append(observability)
233
+ self.logger.info(
234
+ message,
235
+ extra={
236
+ 'event': 'access.websocket',
237
+ 'peer': peer,
238
+ 'path': path,
239
+ 'result': result,
240
+ 'observability': observability,
241
+ },
242
+ )
243
+
244
+ def _http_observability_record(
245
+ self,
246
+ *,
247
+ peer: str,
248
+ method: str,
249
+ path: str,
250
+ status: int,
251
+ proto: str,
252
+ request_headers: list[tuple[bytes, bytes]] | tuple[tuple[bytes, bytes], ...] | None,
253
+ tls_version: str | None,
254
+ alpn: str | None,
255
+ transport: str | None,
256
+ ) -> dict[str, Any]:
257
+ protocol = _protocol_from_http_proto(proto)
258
+ labels: dict[str, Any] = {
259
+ 'protocol': protocol,
260
+ 'transport': transport or ('quic' if protocol == 'http3' else 'tcp'),
261
+ 'peer': peer,
262
+ 'path': path,
263
+ }
264
+ if tls_version is not None:
265
+ labels['tls_version'] = tls_version
266
+ if alpn is not None:
267
+ labels['alpn'] = alpn
268
+ return make_observability_record(
269
+ kind='event',
270
+ name='http.request',
271
+ labels=labels,
272
+ attributes={
273
+ 'headers': _headers_to_mapping(request_headers or ()),
274
+ 'method': method,
275
+ 'status': int(status),
276
+ },
277
+ )
278
+
279
+
280
+ def _protocol_from_http_proto(proto: str) -> str:
281
+ normalized = str(proto).lower()
282
+ if normalized.endswith('/3'):
283
+ return 'http3'
284
+ if normalized.endswith('/2'):
285
+ return 'http2'
286
+ return 'http1'
287
+
288
+
289
+ def _headers_to_mapping(headers: list[tuple[bytes, bytes]] | tuple[tuple[bytes, bytes], ...]) -> dict[str, str]:
290
+ result: dict[str, str] = {}
291
+ for name, value in headers:
292
+ result[name.decode('latin1').lower()] = value.decode('latin1', 'replace')
293
+ return result
184
294
 
185
295
 
186
296
  def _coerce_level(level: str) -> int:
@@ -0,0 +1,225 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Mapping
7
+
8
+
9
+ class ProtocolObservabilityError(ValueError):
10
+ """Raised when protocol-aware observability would emit unsafe telemetry."""
11
+
12
+
13
+ PROTOCOL_LABEL_SCHEMA: tuple[tuple[str, str], ...] = (
14
+ ("protocol", "low-cardinality protocol name"),
15
+ ("transport", "low-cardinality transport domain"),
16
+ ("tls_version", "safe TLS protocol version"),
17
+ ("alpn", "safe negotiated ALPN token"),
18
+ ("profile", "deployment profile"),
19
+ ("listener_id", "bounded listener identifier"),
20
+ ("lifecycle", "protocol lifecycle event"),
21
+ )
22
+
23
+ _ALLOWED_LABELS = {name for name, _description in PROTOCOL_LABEL_SCHEMA} | {
24
+ "event_kind",
25
+ "metric",
26
+ "quic_loss_packets",
27
+ "quic_migration",
28
+ "quic_rtt_ms",
29
+ "span_id",
30
+ "trace_id",
31
+ }
32
+ _SENSITIVE_PATTERN = re.compile(r"(authorization|certificate|cookie|key|password|private|qlog|secret|token)", re.I)
33
+ _HIGH_CARDINALITY = {"client_ip", "connection_id", "path", "peer", "peer_id", "qlog_connection_id", "session_id"}
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ProtocolLabels:
38
+ protocol: str
39
+ transport: str
40
+ tls_version: str | None = None
41
+ alpn: str | None = None
42
+ profile: str | None = None
43
+ listener_id: str | None = None
44
+ lifecycle: str | None = None
45
+
46
+ def as_dict(self) -> dict[str, str | None]:
47
+ return {
48
+ "alpn": self.alpn,
49
+ "lifecycle": self.lifecycle,
50
+ "listener_id": self.listener_id,
51
+ "profile": self.profile,
52
+ "protocol": self.protocol,
53
+ "tls_version": self.tls_version,
54
+ "transport": self.transport,
55
+ }
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ObservabilityRecord:
60
+ kind: str
61
+ name: str
62
+ labels: dict[str, Any]
63
+ attributes: dict[str, Any] = field(default_factory=dict)
64
+
65
+ def as_dict(self) -> dict[str, Any]:
66
+ return {
67
+ "attributes": _stable_mapping(self.attributes),
68
+ "kind": self.kind,
69
+ "labels": _stable_mapping(self.labels),
70
+ "name": self.name,
71
+ "schema_version": 1,
72
+ }
73
+
74
+
75
+ def protocol_label_schema() -> dict[str, Any]:
76
+ return {
77
+ "cardinality": {
78
+ "bounded": tuple(sorted(_ALLOWED_LABELS)),
79
+ "hashed": tuple(sorted(_HIGH_CARDINALITY)),
80
+ },
81
+ "labels": tuple({"name": name, "description": description} for name, description in PROTOCOL_LABEL_SCHEMA),
82
+ "redaction": tuple(sorted(("authorization", "certificate", "cookie", "key", "password", "private", "qlog", "secret", "token"))),
83
+ "schema_version": 1,
84
+ }
85
+
86
+
87
+ def make_observability_record(
88
+ *,
89
+ kind: str,
90
+ name: str,
91
+ labels: Mapping[str, Any],
92
+ attributes: Mapping[str, Any] | None = None,
93
+ ) -> dict[str, Any]:
94
+ validated = validate_label_config(labels)
95
+ return ObservabilityRecord(
96
+ kind=kind,
97
+ name=name,
98
+ labels=redact_and_bound_labels(validated),
99
+ attributes=redact_mapping(attributes or {}),
100
+ ).as_dict()
101
+
102
+
103
+ def protocol_transport_labels(*, protocol: str, transport: str, profile: str = "default") -> dict[str, Any]:
104
+ return ProtocolLabels(protocol=protocol, transport=transport, profile=profile).as_dict()
105
+
106
+
107
+ def tls_alpn_labels(*, protocol: str, transport: str, tls_version: str, alpn: str) -> dict[str, Any]:
108
+ return ProtocolLabels(
109
+ protocol=protocol,
110
+ transport=transport,
111
+ tls_version=tls_version,
112
+ alpn=alpn,
113
+ ).as_dict()
114
+
115
+
116
+ def lifecycle_event(*, protocol: str, transport: str, lifecycle: str, session_id: str | None = None) -> dict[str, Any]:
117
+ labels = ProtocolLabels(protocol=protocol, transport=transport, lifecycle=lifecycle).as_dict()
118
+ if session_id is not None:
119
+ labels["session_id"] = session_id
120
+ return make_observability_record(kind="event", name=f"{protocol}.{lifecycle}", labels=labels)
121
+
122
+
123
+ def propagate_otel_context(context: Mapping[str, str], labels: Mapping[str, Any]) -> dict[str, Any]:
124
+ trace_id = context.get("traceparent", "").split("-")[1] if context.get("traceparent", "").count("-") >= 3 else None
125
+ span_id = context.get("traceparent", "").split("-")[2] if context.get("traceparent", "").count("-") >= 3 else None
126
+ merged = dict(labels)
127
+ if trace_id:
128
+ merged["trace_id"] = trace_id
129
+ if span_id:
130
+ merged["span_id"] = span_id
131
+ return make_observability_record(kind="trace", name="otel.context", labels=merged)
132
+
133
+
134
+ def quic_operational_labels(*, rtt_ms: float, loss_packets: int, migration: str, qlog_secret: str | None = None) -> dict[str, Any]:
135
+ labels: dict[str, Any] = {
136
+ "protocol": "quic",
137
+ "transport": "quic",
138
+ "quic_loss_packets": max(0, int(loss_packets)),
139
+ "quic_migration": migration,
140
+ "quic_rtt_ms": round(float(rtt_ms), 3),
141
+ }
142
+ attributes: dict[str, Any] = {}
143
+ if qlog_secret is not None:
144
+ attributes["qlog_secret"] = qlog_secret
145
+ return make_observability_record(kind="metric", name="quic.operational", labels=labels, attributes=attributes)
146
+
147
+
148
+ def redact_mapping(payload: Mapping[str, Any]) -> dict[str, Any]:
149
+ result: dict[str, Any] = {}
150
+ for key in sorted(payload):
151
+ value = payload[key]
152
+ if _SENSITIVE_PATTERN.search(str(key)):
153
+ result[key] = "[redacted]"
154
+ elif isinstance(value, Mapping):
155
+ result[key] = redact_mapping(value)
156
+ else:
157
+ result[key] = value
158
+ return result
159
+
160
+
161
+ def redact_and_bound_labels(labels: Mapping[str, Any]) -> dict[str, Any]:
162
+ result: dict[str, Any] = {}
163
+ for key in sorted(labels):
164
+ value = labels[key]
165
+ if _SENSITIVE_PATTERN.search(str(key)):
166
+ result[key] = "[redacted]"
167
+ elif key in _HIGH_CARDINALITY:
168
+ result[f"{key}_hash"] = _hash_label(value)
169
+ else:
170
+ result[key] = value
171
+ return result
172
+
173
+
174
+ def validate_label_config(labels: Mapping[str, Any]) -> dict[str, Any]:
175
+ for key, value in labels.items():
176
+ if _SENSITIVE_PATTERN.search(str(key)):
177
+ continue
178
+ if key in _HIGH_CARDINALITY:
179
+ continue
180
+ if key not in _ALLOWED_LABELS:
181
+ raise ProtocolObservabilityError(f"unsafe observability label: {key}")
182
+ if isinstance(value, str) and len(value) > 128:
183
+ raise ProtocolObservabilityError(f"unsafe observability label value: {key}")
184
+ return dict(labels)
185
+
186
+
187
+ def certification_evidence_links(
188
+ *,
189
+ evidence_id: str,
190
+ protocol_claim_ids: tuple[str, ...],
191
+ transport_claim_ids: tuple[str, ...],
192
+ ) -> dict[str, Any]:
193
+ if not protocol_claim_ids or not transport_claim_ids:
194
+ raise ProtocolObservabilityError("observability evidence requires protocol and transport claim links")
195
+ return {
196
+ "evidence_id": evidence_id,
197
+ "protocol_claim_ids": tuple(sorted(protocol_claim_ids)),
198
+ "transport_claim_ids": tuple(sorted(transport_claim_ids)),
199
+ }
200
+
201
+
202
+ def _hash_label(value: Any) -> str:
203
+ return hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:16]
204
+
205
+
206
+ def _stable_mapping(payload: Mapping[str, Any]) -> dict[str, Any]:
207
+ return {key: payload[key] for key in sorted(payload)}
208
+
209
+
210
+ __all__ = [
211
+ "ProtocolLabels",
212
+ "ProtocolObservabilityError",
213
+ "ObservabilityRecord",
214
+ "certification_evidence_links",
215
+ "lifecycle_event",
216
+ "make_observability_record",
217
+ "protocol_label_schema",
218
+ "protocol_transport_labels",
219
+ "propagate_otel_context",
220
+ "quic_operational_labels",
221
+ "redact_and_bound_labels",
222
+ "redact_mapping",
223
+ "tls_alpn_labels",
224
+ "validate_label_config",
225
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tigrcorn-observability
3
- Version: 0.3.16.dev12
3
+ Version: 0.3.17.dev1
4
4
  Summary: Logging, metrics, tracing, DoS warning events, and release-evidence metadata for the Tigrcorn Python web server.
5
5
  Author-email: Jacob Stewart <jacob@swarmauri.com>
6
6
  License: Apache License
@@ -186,8 +186,8 @@ Classifier: Typing :: Typed
186
186
  Requires-Python: <3.15,>=3.10
187
187
  Description-Content-Type: text/markdown
188
188
  License-File: LICENSE
189
- Requires-Dist: tigrcorn-core==0.3.16.dev12
190
- Requires-Dist: tigrcorn-config==0.3.16.dev12
189
+ Requires-Dist: tigrcorn-core==0.3.17.dev1
190
+ Requires-Dist: tigrcorn-config==0.3.17.dev1
191
191
  Dynamic: license-file
192
192
 
193
193
  <div align="center">
@@ -5,6 +5,7 @@ src/tigrcorn_observability/__init__.py
5
5
  src/tigrcorn_observability/events.py
6
6
  src/tigrcorn_observability/logging.py
7
7
  src/tigrcorn_observability/metrics.py
8
+ src/tigrcorn_observability/protocol.py
8
9
  src/tigrcorn_observability/py.typed
9
10
  src/tigrcorn_observability/tracing.py
10
11
  src/tigrcorn_observability.egg-info/PKG-INFO
@@ -0,0 +1,2 @@
1
+ tigrcorn-core==0.3.17.dev1
2
+ tigrcorn-config==0.3.17.dev1
@@ -1 +0,0 @@
1
- """Logging, metrics, tracing helpers."""
@@ -1,2 +0,0 @@
1
- tigrcorn-core==0.3.16.dev12
2
- tigrcorn-config==0.3.16.dev12