021-python-observability 0.3.0__py3-none-any.whl
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.
- 021_python_observability-0.3.0.dist-info/METADATA +348 -0
- 021_python_observability-0.3.0.dist-info/RECORD +11 -0
- 021_python_observability-0.3.0.dist-info/WHEEL +4 -0
- 021_python_observability-0.3.0.dist-info/licenses/LICENSE +202 -0
- observability_021/__init__.py +8 -0
- observability_021/asgi.py +266 -0
- observability_021/config.py +132 -0
- observability_021/dependencies.py +165 -0
- observability_021/logging.py +209 -0
- observability_021/py.typed +0 -0
- observability_021/runtime.py +360 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""JSON stdout logs. Only structured, approved fields cross the logging boundary."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from collections import deque
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from traceback import walk_tb
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import cast
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
from opentelemetry import trace
|
|
15
|
+
|
|
16
|
+
_CORRELATION_FIELDS = (
|
|
17
|
+
"brain_id",
|
|
18
|
+
"job_id",
|
|
19
|
+
"file_id",
|
|
20
|
+
"file_version_id",
|
|
21
|
+
"entity_id",
|
|
22
|
+
"event_id",
|
|
23
|
+
"correlation_id",
|
|
24
|
+
)
|
|
25
|
+
_FIELDS = frozenset(
|
|
26
|
+
{
|
|
27
|
+
*_CORRELATION_FIELDS,
|
|
28
|
+
"event",
|
|
29
|
+
"level",
|
|
30
|
+
"timestamp",
|
|
31
|
+
"service_name",
|
|
32
|
+
"environment",
|
|
33
|
+
"service_version",
|
|
34
|
+
"request_id",
|
|
35
|
+
"trace_id",
|
|
36
|
+
"span_id",
|
|
37
|
+
"method",
|
|
38
|
+
"route",
|
|
39
|
+
"status_code",
|
|
40
|
+
"outcome",
|
|
41
|
+
"duration_ms",
|
|
42
|
+
"dependency",
|
|
43
|
+
"integration",
|
|
44
|
+
"operation",
|
|
45
|
+
"action",
|
|
46
|
+
"op",
|
|
47
|
+
"status",
|
|
48
|
+
"error_type",
|
|
49
|
+
"reason",
|
|
50
|
+
"signal",
|
|
51
|
+
"logger",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _exception_details(exc_info):
|
|
57
|
+
if exc_info is True:
|
|
58
|
+
exc_info = sys.exc_info()
|
|
59
|
+
if isinstance(exc_info, BaseException):
|
|
60
|
+
exc_info = (type(exc_info), exc_info, exc_info.__traceback__)
|
|
61
|
+
if not isinstance(exc_info, tuple) or len(exc_info) != 3:
|
|
62
|
+
return {}
|
|
63
|
+
_, error, tb = exc_info
|
|
64
|
+
if not isinstance(error, BaseException):
|
|
65
|
+
return {}
|
|
66
|
+
chain, seen = [], set()
|
|
67
|
+
# Bound log size while retaining the frames nearest each failure. Never call
|
|
68
|
+
# format_exception/extract_tb: source lines, locals and error messages stay out.
|
|
69
|
+
while error is not None and id(error) not in seen and len(chain) < 8:
|
|
70
|
+
seen.add(id(error))
|
|
71
|
+
frames = deque(maxlen=32)
|
|
72
|
+
count = 0
|
|
73
|
+
if isinstance(tb, TracebackType):
|
|
74
|
+
for frame, line in walk_tb(tb):
|
|
75
|
+
frames.append(
|
|
76
|
+
{
|
|
77
|
+
"file": Path(frame.f_code.co_filename).name[:256],
|
|
78
|
+
"function": frame.f_code.co_name[:128],
|
|
79
|
+
"line": line,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
count += 1
|
|
83
|
+
chain.append(
|
|
84
|
+
{
|
|
85
|
+
"type": type(error).__name__[:128],
|
|
86
|
+
"frames": list(frames),
|
|
87
|
+
"frames_truncated": count > len(frames),
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
cause = error.__cause__
|
|
91
|
+
error = (
|
|
92
|
+
cause
|
|
93
|
+
if cause is not None
|
|
94
|
+
else (None if error.__suppress_context__ else error.__context__)
|
|
95
|
+
)
|
|
96
|
+
tb = error.__traceback__ if error is not None else None
|
|
97
|
+
return {
|
|
98
|
+
"error_type": chain[0]["type"],
|
|
99
|
+
"exception": chain,
|
|
100
|
+
"exception_truncated": error is not None,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _sanitize(_logger, _method, event):
|
|
105
|
+
context = trace.get_current_span().get_span_context()
|
|
106
|
+
if context.is_valid:
|
|
107
|
+
event["trace_id"] = f"{context.trace_id:032x}"
|
|
108
|
+
event["span_id"] = f"{context.span_id:016x}"
|
|
109
|
+
# Never serialize raw exceptions, arbitrary extras, bodies, headers or SQL. Standard
|
|
110
|
+
# logging messages may interpolate secrets; retain only their source category.
|
|
111
|
+
if not event.get("_from_structlog") and "_record" in event:
|
|
112
|
+
record = event["_record"]
|
|
113
|
+
event["event"] = getattr(record, "event", "library.log")
|
|
114
|
+
if record.name.startswith("opentelemetry.exporter") and record.levelno >= logging.WARNING:
|
|
115
|
+
event["event"] = "telemetry.export_failed"
|
|
116
|
+
event["reason"] = "export_failed"
|
|
117
|
+
event["signal"] = "metrics" if "metric" in record.name else "traces"
|
|
118
|
+
name = event.get("event", "log")
|
|
119
|
+
if not isinstance(name, str) or not re.fullmatch(r"[a-z][a-z0-9_.]{0,95}", name):
|
|
120
|
+
event["event"] = "log"
|
|
121
|
+
result = {key: value for key, value in event.items() if key in _FIELDS}
|
|
122
|
+
for key in _CORRELATION_FIELDS:
|
|
123
|
+
value = result.pop(key, None)
|
|
124
|
+
if isinstance(value, UUID):
|
|
125
|
+
result[key] = str(value)
|
|
126
|
+
elif isinstance(value, str) and len(value) <= 36:
|
|
127
|
+
try:
|
|
128
|
+
result[key] = str(UUID(value))
|
|
129
|
+
except ValueError:
|
|
130
|
+
pass
|
|
131
|
+
# Diagnostic fields are generated here, never accepted from arbitrary extras.
|
|
132
|
+
result.update(_exception_details(event.get("exc_info")))
|
|
133
|
+
result["message"] = " ".join(
|
|
134
|
+
f"{key}={result[key]}"
|
|
135
|
+
for key in (
|
|
136
|
+
"event",
|
|
137
|
+
"request_id",
|
|
138
|
+
"trace_id",
|
|
139
|
+
"span_id",
|
|
140
|
+
*_CORRELATION_FIELDS,
|
|
141
|
+
"method",
|
|
142
|
+
"route",
|
|
143
|
+
"status_code",
|
|
144
|
+
"duration_ms",
|
|
145
|
+
"operation",
|
|
146
|
+
"action",
|
|
147
|
+
"op",
|
|
148
|
+
"integration",
|
|
149
|
+
"error_type",
|
|
150
|
+
"reason",
|
|
151
|
+
)
|
|
152
|
+
if key in result
|
|
153
|
+
)
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def configure_logging(level: str = "INFO") -> None:
|
|
158
|
+
shared = [
|
|
159
|
+
structlog.contextvars.merge_contextvars,
|
|
160
|
+
structlog.stdlib.add_log_level,
|
|
161
|
+
structlog.stdlib.add_logger_name,
|
|
162
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
|
163
|
+
]
|
|
164
|
+
structlog.configure(
|
|
165
|
+
processors=[
|
|
166
|
+
structlog.stdlib.filter_by_level,
|
|
167
|
+
*shared,
|
|
168
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
169
|
+
],
|
|
170
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
171
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
172
|
+
cache_logger_on_first_use=False,
|
|
173
|
+
)
|
|
174
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
175
|
+
handler.setFormatter(
|
|
176
|
+
structlog.stdlib.ProcessorFormatter(
|
|
177
|
+
processors=[_sanitize, structlog.processors.JSONRenderer(sort_keys=True)],
|
|
178
|
+
foreign_pre_chain=[*shared, structlog.stdlib.ExtraAdder(allow=_FIELDS)],
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
root = logging.getLogger()
|
|
182
|
+
root.handlers[:] = [handler]
|
|
183
|
+
root.setLevel(level)
|
|
184
|
+
# Uvicorn installs non-propagating handlers of its own. Route runtime errors
|
|
185
|
+
# through the same formatter so exception diagnostics use the safe frame schema.
|
|
186
|
+
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
|
187
|
+
runtime_logger = logging.getLogger(name)
|
|
188
|
+
runtime_logger.handlers.clear()
|
|
189
|
+
runtime_logger.propagate = True
|
|
190
|
+
for name in (
|
|
191
|
+
"httpx",
|
|
192
|
+
"httpcore",
|
|
193
|
+
"httpx2",
|
|
194
|
+
"httpcore2",
|
|
195
|
+
"neo4j",
|
|
196
|
+
"sqlalchemy.engine",
|
|
197
|
+
"uvicorn.access",
|
|
198
|
+
):
|
|
199
|
+
logging.getLogger(name).setLevel(logging.WARNING)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
|
203
|
+
"""Use static event names and structured fields; never interpolate user data."""
|
|
204
|
+
return cast(structlog.stdlib.BoundLogger, structlog.get_logger(name))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def current_request_id() -> str | None:
|
|
208
|
+
value = structlog.contextvars.get_contextvars().get("request_id")
|
|
209
|
+
return value if isinstance(value, str) else None
|
|
File without changes
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Providers, instruments and bounded lifecycle for one application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
import resource
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable, Coroutine, Iterator, Mapping
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from contextvars import ContextVar
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
|
|
21
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
22
|
+
from opentelemetry.metrics import Observation
|
|
23
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
24
|
+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
|
25
|
+
from opentelemetry.sdk.metrics.view import ExplicitBucketHistogramAggregation, View
|
|
26
|
+
from opentelemetry.sdk.resources import Resource
|
|
27
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
28
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
29
|
+
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
|
|
30
|
+
|
|
31
|
+
from .config import Settings, identifier
|
|
32
|
+
from .logging import get_logger
|
|
33
|
+
|
|
34
|
+
# The package version, kept in step by release-please.
|
|
35
|
+
INSTRUMENTATION_VERSION = "0.3.0" # x-release-please-version
|
|
36
|
+
|
|
37
|
+
# Seconds, with finite boundaries for both ordinary API traffic and long ingestion.
|
|
38
|
+
DURATION_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300)
|
|
39
|
+
BackgroundTask = Callable[[Coroutine[Any, Any, None]], Any]
|
|
40
|
+
logger = get_logger(__name__)
|
|
41
|
+
current_observability: ContextVar[Observability | None] = ContextVar(
|
|
42
|
+
"observability_021", default=None
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def error_kind(error: BaseException) -> str:
|
|
47
|
+
if isinstance(error, asyncio.CancelledError):
|
|
48
|
+
return "cancelled"
|
|
49
|
+
if isinstance(error, TimeoutError):
|
|
50
|
+
return "timeout"
|
|
51
|
+
if isinstance(error, OSError):
|
|
52
|
+
return "connection_error"
|
|
53
|
+
return "internal_error"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Observability:
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
settings: Settings,
|
|
60
|
+
*,
|
|
61
|
+
background_task: BackgroundTask | None = None,
|
|
62
|
+
prometheus: bool = False,
|
|
63
|
+
):
|
|
64
|
+
self.settings = settings
|
|
65
|
+
self.background_task = background_task
|
|
66
|
+
self._closed = False
|
|
67
|
+
self._instrument_lock = threading.Lock()
|
|
68
|
+
self._instruments: dict[tuple[str, str], Any] = {}
|
|
69
|
+
self._cleanup: list[Callable[[], None]] = []
|
|
70
|
+
self._pools: list[tuple[str, Any]] = []
|
|
71
|
+
self._started = time.monotonic()
|
|
72
|
+
self.resource = Resource(
|
|
73
|
+
{
|
|
74
|
+
"service.name": settings.service_name,
|
|
75
|
+
"service.version": settings.service_version,
|
|
76
|
+
"service.instance.id": settings.instance_id,
|
|
77
|
+
"deployment.environment.name": settings.environment,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
self.tracer_provider = TracerProvider(
|
|
81
|
+
resource=self.resource,
|
|
82
|
+
sampler=ParentBased(TraceIdRatioBased(settings.trace_sample_ratio)),
|
|
83
|
+
shutdown_on_exit=False,
|
|
84
|
+
)
|
|
85
|
+
# A nonempty explicit mapping prevents SDK fallback to ambient OTLP credentials.
|
|
86
|
+
# Content-Type is already required by OTLP/HTTP, so no extra header is introduced.
|
|
87
|
+
if settings.traces_endpoint:
|
|
88
|
+
self.tracer_provider.add_span_processor(
|
|
89
|
+
BatchSpanProcessor(
|
|
90
|
+
OTLPSpanExporter(
|
|
91
|
+
endpoint=settings.traces_endpoint,
|
|
92
|
+
headers={
|
|
93
|
+
"Content-Type": "application/x-protobuf",
|
|
94
|
+
**settings.traces_headers,
|
|
95
|
+
},
|
|
96
|
+
timeout=settings.export_timeout_seconds,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
readers = []
|
|
101
|
+
self.prometheus_registry = None
|
|
102
|
+
if prometheus:
|
|
103
|
+
from opentelemetry.exporter.prometheus import PrometheusMetricReader
|
|
104
|
+
from prometheus_client import CollectorRegistry
|
|
105
|
+
|
|
106
|
+
self.prometheus_registry = CollectorRegistry()
|
|
107
|
+
readers.append(PrometheusMetricReader(registry=self.prometheus_registry))
|
|
108
|
+
if settings.metrics_endpoint:
|
|
109
|
+
readers.append(
|
|
110
|
+
PeriodicExportingMetricReader(
|
|
111
|
+
OTLPMetricExporter(
|
|
112
|
+
endpoint=settings.metrics_endpoint,
|
|
113
|
+
headers={
|
|
114
|
+
"Content-Type": "application/x-protobuf",
|
|
115
|
+
**settings.metrics_headers,
|
|
116
|
+
},
|
|
117
|
+
timeout=settings.export_timeout_seconds,
|
|
118
|
+
),
|
|
119
|
+
export_interval_millis=settings.metric_interval_ms,
|
|
120
|
+
export_timeout_millis=settings.export_timeout_seconds * 1000,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
self.meter_provider = MeterProvider(
|
|
124
|
+
resource=self.resource,
|
|
125
|
+
metric_readers=readers,
|
|
126
|
+
shutdown_on_exit=False,
|
|
127
|
+
views=[
|
|
128
|
+
View(
|
|
129
|
+
instrument_name="*.duration",
|
|
130
|
+
aggregation=ExplicitBucketHistogramAggregation(DURATION_BUCKETS),
|
|
131
|
+
)
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
self.meter = self.meter_provider.get_meter("observability_021", INSTRUMENTATION_VERSION)
|
|
135
|
+
self.tracer = self.tracer_provider.get_tracer("observability_021", INSTRUMENTATION_VERSION)
|
|
136
|
+
self.requests = self.meter.create_counter("http.server.request.count", unit="{request}")
|
|
137
|
+
self.duration = self.meter.create_histogram("http.server.request.duration", unit="s")
|
|
138
|
+
self.active = self.meter.create_up_down_counter(
|
|
139
|
+
"http.server.active_requests", unit="{request}"
|
|
140
|
+
)
|
|
141
|
+
self.client_requests = self.meter.create_counter(
|
|
142
|
+
"http.client.request.count", unit="{request}"
|
|
143
|
+
)
|
|
144
|
+
self.client_duration = self.meter.create_histogram("http.client.request.duration", unit="s")
|
|
145
|
+
self.db_requests = self.meter.create_counter(
|
|
146
|
+
"db.client.operation.count", unit="{operation}"
|
|
147
|
+
)
|
|
148
|
+
self.db_duration = self.meter.create_histogram("db.client.operation.duration", unit="s")
|
|
149
|
+
self.operation_count = self.meter.create_counter("app.operation.count", unit="{operation}")
|
|
150
|
+
self.operation_duration = self.meter.create_histogram("app.operation.duration", unit="s")
|
|
151
|
+
self.meter.create_observable_counter(
|
|
152
|
+
"process.cpu.time", [lambda _: [Observation(time.process_time())]], unit="s"
|
|
153
|
+
)
|
|
154
|
+
self.meter.create_observable_gauge(
|
|
155
|
+
"process.uptime", [lambda _: [Observation(time.monotonic() - self._started)]], unit="s"
|
|
156
|
+
)
|
|
157
|
+
self.meter.create_observable_gauge(
|
|
158
|
+
"process.memory.peak_usage",
|
|
159
|
+
[
|
|
160
|
+
lambda _: [
|
|
161
|
+
Observation(
|
|
162
|
+
resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
163
|
+
* (1 if sys.platform == "darwin" else 1024)
|
|
164
|
+
)
|
|
165
|
+
]
|
|
166
|
+
],
|
|
167
|
+
unit="By",
|
|
168
|
+
)
|
|
169
|
+
self.meter.create_observable_gauge(
|
|
170
|
+
"process.thread.count",
|
|
171
|
+
[lambda _: [Observation(threading.active_count())]],
|
|
172
|
+
unit="{thread}",
|
|
173
|
+
)
|
|
174
|
+
# Keep providers local to this application. OTel's process-global setter is
|
|
175
|
+
# write-once and would silently bind subsequent create_app() calls elsewhere.
|
|
176
|
+
|
|
177
|
+
def increment(
|
|
178
|
+
self,
|
|
179
|
+
name: str,
|
|
180
|
+
value: int = 1,
|
|
181
|
+
*,
|
|
182
|
+
operation: str,
|
|
183
|
+
status: str,
|
|
184
|
+
attributes: Mapping[str, str] | None = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
187
|
+
raise ValueError("A counter increment cannot be negative")
|
|
188
|
+
self._instrument(name, "counter", "1").add(
|
|
189
|
+
value, self._attributes(operation, status, attributes)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def observe(
|
|
193
|
+
self,
|
|
194
|
+
name: str,
|
|
195
|
+
seconds: float,
|
|
196
|
+
*,
|
|
197
|
+
operation: str,
|
|
198
|
+
status: str,
|
|
199
|
+
attributes: Mapping[str, str] | None = None,
|
|
200
|
+
unit: str = "s",
|
|
201
|
+
) -> None:
|
|
202
|
+
if not math.isfinite(seconds) or seconds < 0:
|
|
203
|
+
raise ValueError("Duration must be finite and nonnegative")
|
|
204
|
+
self._instrument(name, "histogram", unit).record(
|
|
205
|
+
seconds, self._attributes(operation, status, attributes)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _instrument(self, name: str, kind: str, unit: str):
|
|
209
|
+
identifier(name)
|
|
210
|
+
if not name.startswith("app."):
|
|
211
|
+
raise ValueError("Domain metric names must start with app.")
|
|
212
|
+
if unit not in {"s", "1", "USD"}:
|
|
213
|
+
raise ValueError("Unsupported domain metric unit")
|
|
214
|
+
if kind == "histogram" and unit == "s" and not name.endswith(".duration"):
|
|
215
|
+
raise ValueError("Duration metric names must end with .duration")
|
|
216
|
+
with self._instrument_lock:
|
|
217
|
+
if (name, kind) not in self._instruments:
|
|
218
|
+
create = (
|
|
219
|
+
self.meter.create_counter if kind == "counter" else self.meter.create_histogram
|
|
220
|
+
)
|
|
221
|
+
self._instruments[name, kind] = create(name, unit=unit)
|
|
222
|
+
elif self._instruments[name, kind].unit != unit:
|
|
223
|
+
raise ValueError("A metric's unit cannot change")
|
|
224
|
+
return self._instruments[name, kind]
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _attributes(operation: str, status: str, attributes=None) -> dict[str, str]:
|
|
228
|
+
identifier(operation)
|
|
229
|
+
identifier(status)
|
|
230
|
+
# Static domain dimensions used by the APIs. Never accept identifiers or payloads.
|
|
231
|
+
allowed = {
|
|
232
|
+
"event",
|
|
233
|
+
"code",
|
|
234
|
+
"scope",
|
|
235
|
+
"permission",
|
|
236
|
+
"action",
|
|
237
|
+
"reason",
|
|
238
|
+
"mode",
|
|
239
|
+
"channel",
|
|
240
|
+
"provider",
|
|
241
|
+
"dry_run",
|
|
242
|
+
"error_type",
|
|
243
|
+
}
|
|
244
|
+
safe = {
|
|
245
|
+
k: v if re.fullmatch(r"[A-Za-z0-9_.:/-]{1,96}", v) else "_OTHER"
|
|
246
|
+
for k, v in (attributes or {}).items()
|
|
247
|
+
if k in allowed
|
|
248
|
+
}
|
|
249
|
+
return {**safe, "operation": operation, "status": status}
|
|
250
|
+
|
|
251
|
+
def prometheus_latest(self) -> tuple[bytes, str]:
|
|
252
|
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
|
253
|
+
|
|
254
|
+
if self.prometheus_registry is None:
|
|
255
|
+
raise RuntimeError("Enable the prometheus extra and reader at setup")
|
|
256
|
+
return generate_latest(self.prometheus_registry), CONTENT_TYPE_LATEST
|
|
257
|
+
|
|
258
|
+
def http_request(self, *, method: str, dependency: str, headers):
|
|
259
|
+
from .dependencies import observe_http_request
|
|
260
|
+
|
|
261
|
+
return observe_http_request(self, method=method, dependency=dependency, headers=headers)
|
|
262
|
+
|
|
263
|
+
@contextmanager
|
|
264
|
+
def operation(self, name: str) -> Iterator[None]:
|
|
265
|
+
"""Works around sync work or around `await` inside an async function."""
|
|
266
|
+
identifier(name)
|
|
267
|
+
started, status = time.perf_counter(), "ok"
|
|
268
|
+
try:
|
|
269
|
+
yield
|
|
270
|
+
except BaseException as exc:
|
|
271
|
+
status = "cancelled" if isinstance(exc, asyncio.CancelledError) else "error"
|
|
272
|
+
raise
|
|
273
|
+
finally:
|
|
274
|
+
attributes = self._attributes(name, status)
|
|
275
|
+
self.operation_count.add(1, attributes)
|
|
276
|
+
self.operation_duration.record(time.perf_counter() - started, attributes)
|
|
277
|
+
|
|
278
|
+
def schedule_flush(self) -> None:
|
|
279
|
+
if self.background_task is None or self._closed:
|
|
280
|
+
return
|
|
281
|
+
coroutine = self.flush_async()
|
|
282
|
+
try:
|
|
283
|
+
self.background_task(coroutine)
|
|
284
|
+
except Exception:
|
|
285
|
+
coroutine.close()
|
|
286
|
+
logger.warning("telemetry.flush_failed", reason="schedule_failed")
|
|
287
|
+
|
|
288
|
+
async def flush_async(self) -> None:
|
|
289
|
+
# Export off the application event loop. Both signals share one wall-clock
|
|
290
|
+
# wait budget. Transport timeouts also bound the SDK's work in its threads.
|
|
291
|
+
timeout = self.settings.export_timeout_seconds
|
|
292
|
+
try:
|
|
293
|
+
await asyncio.wait_for(
|
|
294
|
+
asyncio.gather(
|
|
295
|
+
asyncio.to_thread(self._flush_signal, "traces", self.tracer_provider),
|
|
296
|
+
asyncio.to_thread(self._flush_signal, "metrics", self.meter_provider),
|
|
297
|
+
),
|
|
298
|
+
timeout=timeout,
|
|
299
|
+
)
|
|
300
|
+
except TimeoutError:
|
|
301
|
+
logger.warning("telemetry.flush_failed", reason="timeout")
|
|
302
|
+
|
|
303
|
+
def _flush_signal(self, signal: str, provider) -> None:
|
|
304
|
+
try:
|
|
305
|
+
if not provider.force_flush(
|
|
306
|
+
timeout_millis=int(self.settings.export_timeout_seconds * 1000)
|
|
307
|
+
):
|
|
308
|
+
logger.warning("telemetry.flush_failed", signal=signal, reason="timeout")
|
|
309
|
+
except Exception:
|
|
310
|
+
logger.warning("telemetry.flush_failed", signal=signal, reason="export_failed")
|
|
311
|
+
|
|
312
|
+
async def shutdown(self) -> None:
|
|
313
|
+
if self._closed:
|
|
314
|
+
return
|
|
315
|
+
self._closed = True
|
|
316
|
+
await self.flush_async()
|
|
317
|
+
for cleanup in self._cleanup:
|
|
318
|
+
cleanup()
|
|
319
|
+
# The SDK has synchronous shutdown methods; never block the ASGI event loop.
|
|
320
|
+
await asyncio.to_thread(
|
|
321
|
+
self.meter_provider.shutdown,
|
|
322
|
+
timeout_millis=int(self.settings.export_timeout_seconds * 1000),
|
|
323
|
+
)
|
|
324
|
+
await asyncio.to_thread(self.tracer_provider.shutdown)
|
|
325
|
+
|
|
326
|
+
def instrument_sqlalchemy(self, engine, *, dependency: str = "database") -> None:
|
|
327
|
+
from .dependencies import instrument_sqlalchemy
|
|
328
|
+
|
|
329
|
+
instrument_sqlalchemy(self, engine, dependency=dependency)
|
|
330
|
+
|
|
331
|
+
def httpx_transport(
|
|
332
|
+
self, *, dependency: str, transport: httpx.BaseTransport | None = None
|
|
333
|
+
) -> httpx.BaseTransport:
|
|
334
|
+
from .dependencies import HTTPTransport
|
|
335
|
+
|
|
336
|
+
return HTTPTransport(self, dependency=dependency, transport=transport)
|
|
337
|
+
|
|
338
|
+
def async_httpx_transport(
|
|
339
|
+
self, *, dependency: str, transport: httpx.AsyncBaseTransport | None = None
|
|
340
|
+
) -> httpx.AsyncBaseTransport:
|
|
341
|
+
from .dependencies import AsyncHTTPTransport
|
|
342
|
+
|
|
343
|
+
return AsyncHTTPTransport(self, dependency=dependency, transport=transport)
|
|
344
|
+
|
|
345
|
+
def add_health_routes(self, app, *, readiness=None) -> None:
|
|
346
|
+
from .asgi import add_health_routes
|
|
347
|
+
|
|
348
|
+
add_health_routes(app, self, readiness=readiness or {})
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def vercel_background_task(settings: Settings) -> BackgroundTask | None:
|
|
352
|
+
if not settings.serverless or not (settings.traces_endpoint or settings.metrics_endpoint):
|
|
353
|
+
return None
|
|
354
|
+
try:
|
|
355
|
+
from vercel.functions import wait_until
|
|
356
|
+
except ImportError:
|
|
357
|
+
raise ValueError(
|
|
358
|
+
"Install 021-python-observability[vercel] for the Vercel runtime"
|
|
359
|
+
) from None
|
|
360
|
+
return wait_until
|