policyengine-observability 0.2.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.
- policyengine_observability/__init__.py +166 -0
- policyengine_observability/adapters/__init__.py +1 -0
- policyengine_observability/adapters/fastapi.py +286 -0
- policyengine_observability/adapters/flask.py +132 -0
- policyengine_observability/config.py +164 -0
- policyengine_observability/context.py +238 -0
- policyengine_observability/integrations/__init__.py +1 -0
- policyengine_observability/integrations/httpx.py +8 -0
- policyengine_observability/logging.py +17 -0
- policyengine_observability/runtime.py +1784 -0
- policyengine_observability/segments.py +35 -0
- policyengine_observability-0.2.0.dist-info/METADATA +52 -0
- policyengine_observability-0.2.0.dist-info/RECORD +14 -0
- policyengine_observability-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
DEFAULT_METRIC_ATTRIBUTE_KEYS = (
|
|
9
|
+
"service.name",
|
|
10
|
+
"service.role",
|
|
11
|
+
"deployment.environment",
|
|
12
|
+
"operation",
|
|
13
|
+
"flavor",
|
|
14
|
+
"route",
|
|
15
|
+
"method",
|
|
16
|
+
"endpoint",
|
|
17
|
+
"status_code",
|
|
18
|
+
"country_id",
|
|
19
|
+
"backend",
|
|
20
|
+
"requested_version",
|
|
21
|
+
"resolved_channel",
|
|
22
|
+
"auth_result",
|
|
23
|
+
"segment",
|
|
24
|
+
"event",
|
|
25
|
+
"error_type",
|
|
26
|
+
"model",
|
|
27
|
+
"tool",
|
|
28
|
+
"stop_reason",
|
|
29
|
+
"iteration",
|
|
30
|
+
"provider",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def bool_from_env(name: str, default: bool) -> bool:
|
|
35
|
+
raw_value = os.getenv(name)
|
|
36
|
+
if raw_value is None:
|
|
37
|
+
return default
|
|
38
|
+
return raw_value.strip().lower() not in {"0", "false", "no", "off"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def csv_from_env(name: str) -> tuple[str, ...]:
|
|
42
|
+
raw_value = os.getenv(name)
|
|
43
|
+
if raw_value is None:
|
|
44
|
+
return ()
|
|
45
|
+
return tuple(part.strip() for part in raw_value.split(",") if part.strip())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def float_from_env(name: str, default: float) -> float:
|
|
49
|
+
raw_value = os.getenv(name)
|
|
50
|
+
if raw_value is None:
|
|
51
|
+
return default
|
|
52
|
+
try:
|
|
53
|
+
return float(raw_value)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_environment() -> str:
|
|
59
|
+
return (
|
|
60
|
+
os.getenv("OBSERVABILITY_ENVIRONMENT")
|
|
61
|
+
or os.getenv("DEPLOYMENT_ENVIRONMENT")
|
|
62
|
+
or os.getenv("APP_ENV")
|
|
63
|
+
or os.getenv("ENVIRONMENT")
|
|
64
|
+
or "development"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ObservabilityConfig:
|
|
70
|
+
service_name: str = "policyengine-service"
|
|
71
|
+
service_role: str = "api"
|
|
72
|
+
environment: str = "development"
|
|
73
|
+
enabled: bool = True
|
|
74
|
+
request_logs_enabled: bool = True
|
|
75
|
+
log_raw_ip: bool = True
|
|
76
|
+
log_level: int = logging.INFO
|
|
77
|
+
otel_enabled: bool = False
|
|
78
|
+
otlp_endpoint: str | None = None
|
|
79
|
+
otlp_protocol: str = "grpc"
|
|
80
|
+
span_prefix: str | None = None
|
|
81
|
+
tracer_name: str | None = None
|
|
82
|
+
meter_name: str | None = None
|
|
83
|
+
shutdown_timeout_seconds: float = 3.0
|
|
84
|
+
instrument_fastapi: bool = False
|
|
85
|
+
instrument_httpx: bool = False
|
|
86
|
+
metric_attribute_keys: tuple[str, ...] = DEFAULT_METRIC_ATTRIBUTE_KEYS
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_env(
|
|
90
|
+
cls,
|
|
91
|
+
*,
|
|
92
|
+
service_name: str,
|
|
93
|
+
service_role: str = "api",
|
|
94
|
+
enabled_default: bool = True,
|
|
95
|
+
otel_enabled_default: bool = False,
|
|
96
|
+
span_prefix: str | None = None,
|
|
97
|
+
instrument_fastapi: bool = False,
|
|
98
|
+
instrument_httpx: bool = False,
|
|
99
|
+
metric_attribute_keys: Sequence[str] | None = None,
|
|
100
|
+
extra_metric_attribute_keys: Sequence[str] = (),
|
|
101
|
+
) -> ObservabilityConfig:
|
|
102
|
+
level_name = os.getenv("OBSERVABILITY_LOG_LEVEL", "INFO").upper()
|
|
103
|
+
log_level = getattr(logging, level_name, logging.INFO)
|
|
104
|
+
otlp_protocol = (
|
|
105
|
+
os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL")
|
|
106
|
+
or os.getenv("OBSERVABILITY_OTLP_PROTOCOL")
|
|
107
|
+
or cls.otlp_protocol
|
|
108
|
+
)
|
|
109
|
+
env_metric_keys = csv_from_env("OBSERVABILITY_METRIC_ATTRIBUTE_KEYS")
|
|
110
|
+
env_extra_metric_keys = csv_from_env(
|
|
111
|
+
"OBSERVABILITY_EXTRA_METRIC_ATTRIBUTE_KEYS"
|
|
112
|
+
)
|
|
113
|
+
resolved_metric_keys = _dedupe(
|
|
114
|
+
env_metric_keys
|
|
115
|
+
or metric_attribute_keys
|
|
116
|
+
or DEFAULT_METRIC_ATTRIBUTE_KEYS,
|
|
117
|
+
(*extra_metric_attribute_keys, *env_extra_metric_keys),
|
|
118
|
+
)
|
|
119
|
+
return cls(
|
|
120
|
+
service_name=os.getenv("OBSERVABILITY_SERVICE_NAME")
|
|
121
|
+
or os.getenv("OTEL_SERVICE_NAME")
|
|
122
|
+
or service_name,
|
|
123
|
+
service_role=service_role,
|
|
124
|
+
environment=default_environment(),
|
|
125
|
+
enabled=bool_from_env("OBSERVABILITY_ENABLED", enabled_default),
|
|
126
|
+
request_logs_enabled=bool_from_env(
|
|
127
|
+
"OBSERVABILITY_REQUEST_LOGS_ENABLED",
|
|
128
|
+
True,
|
|
129
|
+
),
|
|
130
|
+
log_raw_ip=bool_from_env("OBSERVABILITY_LOG_RAW_IP", True),
|
|
131
|
+
log_level=log_level,
|
|
132
|
+
otel_enabled=bool_from_env(
|
|
133
|
+
"OTEL_ENABLED",
|
|
134
|
+
bool_from_env(
|
|
135
|
+
"OBSERVABILITY_OTEL_ENABLED",
|
|
136
|
+
otel_enabled_default,
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
otlp_endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") or None,
|
|
140
|
+
otlp_protocol=otlp_protocol,
|
|
141
|
+
span_prefix=span_prefix,
|
|
142
|
+
tracer_name=os.getenv("OBSERVABILITY_TRACER_NAME"),
|
|
143
|
+
meter_name=os.getenv("OBSERVABILITY_METER_NAME"),
|
|
144
|
+
shutdown_timeout_seconds=float_from_env(
|
|
145
|
+
"OBSERVABILITY_SHUTDOWN_TIMEOUT_SECONDS",
|
|
146
|
+
3.0,
|
|
147
|
+
),
|
|
148
|
+
instrument_fastapi=bool_from_env(
|
|
149
|
+
"OBSERVABILITY_INSTRUMENT_FASTAPI",
|
|
150
|
+
instrument_fastapi,
|
|
151
|
+
),
|
|
152
|
+
instrument_httpx=bool_from_env(
|
|
153
|
+
"OBSERVABILITY_INSTRUMENT_HTTPX",
|
|
154
|
+
instrument_httpx,
|
|
155
|
+
),
|
|
156
|
+
metric_attribute_keys=resolved_metric_keys,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _dedupe(
|
|
161
|
+
base: Sequence[str],
|
|
162
|
+
extra: Sequence[str] = (),
|
|
163
|
+
) -> tuple[str, ...]:
|
|
164
|
+
return tuple(dict.fromkeys((*base, *extra)))
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .config import ObservabilityConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ErrorRecord:
|
|
13
|
+
type: str
|
|
14
|
+
message: str
|
|
15
|
+
handled: bool
|
|
16
|
+
stack: str | None = None
|
|
17
|
+
|
|
18
|
+
def as_dict(self) -> dict[str, Any]:
|
|
19
|
+
return {
|
|
20
|
+
"type": self.type,
|
|
21
|
+
"message": self.message,
|
|
22
|
+
"handled": self.handled,
|
|
23
|
+
"stack": self.stack,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class OperationObservabilityContext:
|
|
29
|
+
config: ObservabilityConfig
|
|
30
|
+
name: str
|
|
31
|
+
flavor: str | None = None
|
|
32
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
timings_ms: dict[str, float] = field(default_factory=dict)
|
|
34
|
+
emit_log: bool = True
|
|
35
|
+
record_metric: bool = True
|
|
36
|
+
started_at: float = field(default_factory=time.perf_counter)
|
|
37
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
38
|
+
error: ErrorRecord | None = None
|
|
39
|
+
emitted: bool = False
|
|
40
|
+
metric_recorded: bool = False
|
|
41
|
+
span_handle: Any = None
|
|
42
|
+
context_token: Any = None
|
|
43
|
+
|
|
44
|
+
def set_attribute(self, key: str, value: Any) -> None:
|
|
45
|
+
if value is None:
|
|
46
|
+
return
|
|
47
|
+
if hasattr(value, "value"):
|
|
48
|
+
value = value.value
|
|
49
|
+
self.attributes[key] = value
|
|
50
|
+
|
|
51
|
+
def duration_seconds(self) -> float:
|
|
52
|
+
return time.perf_counter() - self.started_at
|
|
53
|
+
|
|
54
|
+
def metric_attributes(self, **extra: Any) -> dict[str, str]:
|
|
55
|
+
attrs: dict[str, Any] = {
|
|
56
|
+
"service.name": self.config.service_name,
|
|
57
|
+
"service.role": self.config.service_role,
|
|
58
|
+
"deployment.environment": self.config.environment,
|
|
59
|
+
"operation": self.name,
|
|
60
|
+
"flavor": self.flavor,
|
|
61
|
+
}
|
|
62
|
+
for key in self.config.metric_attribute_keys:
|
|
63
|
+
if key in self.attributes:
|
|
64
|
+
attrs[key] = self.attributes[key]
|
|
65
|
+
attrs.update(extra)
|
|
66
|
+
return _metric_attrs(attrs, self.config.metric_attribute_keys)
|
|
67
|
+
|
|
68
|
+
def span_attributes(self, **extra: Any) -> dict[str, Any]:
|
|
69
|
+
attrs: dict[str, Any] = {
|
|
70
|
+
"service.name": self.config.service_name,
|
|
71
|
+
"service.role": self.config.service_role,
|
|
72
|
+
"deployment.environment": self.config.environment,
|
|
73
|
+
"policyengine.operation": self.name,
|
|
74
|
+
"policyengine.flavor": self.flavor,
|
|
75
|
+
}
|
|
76
|
+
attrs.update(
|
|
77
|
+
{
|
|
78
|
+
f"policyengine.{key}": value
|
|
79
|
+
for key, value in self.attributes.items()
|
|
80
|
+
if value is not None
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
attrs.update(extra)
|
|
84
|
+
return {
|
|
85
|
+
key: value for key, value in attrs.items() if value is not None
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def as_log_record(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
trace_id: str | None,
|
|
92
|
+
span_id: str | None,
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
event = "operation_failed" if self.error else "operation_completed"
|
|
95
|
+
return {
|
|
96
|
+
"schema_version": "policyengine.observability.operation.v1",
|
|
97
|
+
"event": event,
|
|
98
|
+
"service_name": self.config.service_name,
|
|
99
|
+
"service_role": self.config.service_role,
|
|
100
|
+
"environment": self.config.environment,
|
|
101
|
+
"created_at": self.created_at.isoformat(),
|
|
102
|
+
"operation": self.name,
|
|
103
|
+
"flavor": self.flavor,
|
|
104
|
+
"trace_id": trace_id,
|
|
105
|
+
"span_id": span_id,
|
|
106
|
+
"duration_ms": round(self.duration_seconds() * 1000, 3),
|
|
107
|
+
"timings_ms": dict(self.timings_ms),
|
|
108
|
+
**self.attributes,
|
|
109
|
+
"error": self.error.as_dict() if self.error else None,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class RequestObservabilityContext:
|
|
115
|
+
config: ObservabilityConfig
|
|
116
|
+
request_id: str
|
|
117
|
+
method: str
|
|
118
|
+
route: str
|
|
119
|
+
path: str
|
|
120
|
+
endpoint: str | None
|
|
121
|
+
query_keys: list[str]
|
|
122
|
+
content_length_bytes: int | None
|
|
123
|
+
inbound: dict[str, Any]
|
|
124
|
+
internal_dispatch: bool = False
|
|
125
|
+
started_at: float = field(default_factory=time.perf_counter)
|
|
126
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
127
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
128
|
+
timings_ms: dict[str, float] = field(default_factory=dict)
|
|
129
|
+
status_code: int | None = None
|
|
130
|
+
error: ErrorRecord | None = None
|
|
131
|
+
emitted: bool = False
|
|
132
|
+
request_metric_recorded: bool = False
|
|
133
|
+
active_closed: bool = False
|
|
134
|
+
span_closed: bool = False
|
|
135
|
+
server_span_cm: Any = None
|
|
136
|
+
server_span: Any = None
|
|
137
|
+
context_token: Any = None
|
|
138
|
+
operation_context: OperationObservabilityContext | None = None
|
|
139
|
+
operation_token: Any = None
|
|
140
|
+
|
|
141
|
+
def set_attribute(self, key: str, value: Any) -> None:
|
|
142
|
+
if value is None:
|
|
143
|
+
return
|
|
144
|
+
if hasattr(value, "value"):
|
|
145
|
+
value = value.value
|
|
146
|
+
self.attributes[key] = value
|
|
147
|
+
|
|
148
|
+
def duration_seconds(self) -> float:
|
|
149
|
+
return time.perf_counter() - self.started_at
|
|
150
|
+
|
|
151
|
+
def metric_attributes(self, **extra: Any) -> dict[str, str]:
|
|
152
|
+
attrs: dict[str, Any] = {
|
|
153
|
+
"service.name": self.config.service_name,
|
|
154
|
+
"service.role": self.config.service_role,
|
|
155
|
+
"deployment.environment": self.config.environment,
|
|
156
|
+
"route": self.route,
|
|
157
|
+
"method": self.method,
|
|
158
|
+
"endpoint": self.endpoint,
|
|
159
|
+
}
|
|
160
|
+
if self.status_code is not None:
|
|
161
|
+
attrs["status_code"] = str(self.status_code)
|
|
162
|
+
for key in self.config.metric_attribute_keys:
|
|
163
|
+
if key in self.attributes:
|
|
164
|
+
attrs[key] = self.attributes[key]
|
|
165
|
+
attrs.update(extra)
|
|
166
|
+
return _metric_attrs(attrs, self.config.metric_attribute_keys)
|
|
167
|
+
|
|
168
|
+
def span_attributes(self, **extra: Any) -> dict[str, Any]:
|
|
169
|
+
attrs: dict[str, Any] = {
|
|
170
|
+
"service.name": self.config.service_name,
|
|
171
|
+
"service.role": self.config.service_role,
|
|
172
|
+
"deployment.environment": self.config.environment,
|
|
173
|
+
"http.request.method": self.method,
|
|
174
|
+
"http.route": self.route,
|
|
175
|
+
"url.path": self.path,
|
|
176
|
+
"policyengine.endpoint": self.endpoint,
|
|
177
|
+
"policyengine.request_id": self.request_id,
|
|
178
|
+
}
|
|
179
|
+
if self.status_code is not None:
|
|
180
|
+
attrs["http.response.status_code"] = self.status_code
|
|
181
|
+
for key in (
|
|
182
|
+
"country_id",
|
|
183
|
+
"backend",
|
|
184
|
+
"requested_version",
|
|
185
|
+
"resolved_channel",
|
|
186
|
+
"auth_result",
|
|
187
|
+
):
|
|
188
|
+
if key in self.attributes:
|
|
189
|
+
attrs[f"policyengine.{key}"] = self.attributes[key]
|
|
190
|
+
attrs.update(extra)
|
|
191
|
+
return {
|
|
192
|
+
key: value for key, value in attrs.items() if value is not None
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def as_log_record(
|
|
196
|
+
self,
|
|
197
|
+
*,
|
|
198
|
+
trace_id: str | None,
|
|
199
|
+
span_id: str | None,
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
event = (
|
|
202
|
+
"http_request_failed" if self.error else "http_request_completed"
|
|
203
|
+
)
|
|
204
|
+
status_code = self.status_code or (500 if self.error else None)
|
|
205
|
+
return {
|
|
206
|
+
"schema_version": "policyengine.observability.request.v1",
|
|
207
|
+
"event": event,
|
|
208
|
+
"service_name": self.config.service_name,
|
|
209
|
+
"service_role": self.config.service_role,
|
|
210
|
+
"environment": self.config.environment,
|
|
211
|
+
"created_at": self.created_at.isoformat(),
|
|
212
|
+
"request_id": self.request_id,
|
|
213
|
+
"trace_id": trace_id,
|
|
214
|
+
"span_id": span_id,
|
|
215
|
+
"method": self.method,
|
|
216
|
+
"route": self.route,
|
|
217
|
+
"path": self.path,
|
|
218
|
+
"query_keys": self.query_keys,
|
|
219
|
+
"endpoint": self.endpoint,
|
|
220
|
+
"status_code": status_code,
|
|
221
|
+
"duration_ms": round(self.duration_seconds() * 1000, 3),
|
|
222
|
+
**self.inbound,
|
|
223
|
+
"timings_ms": dict(self.timings_ms),
|
|
224
|
+
**self.attributes,
|
|
225
|
+
"error": self.error.as_dict() if self.error else None,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _metric_attrs(
|
|
230
|
+
attrs: dict[str, Any],
|
|
231
|
+
metric_attribute_keys: tuple[str, ...],
|
|
232
|
+
) -> dict[str, str]:
|
|
233
|
+
result: dict[str, str] = {}
|
|
234
|
+
for key in metric_attribute_keys:
|
|
235
|
+
value = attrs.get(key)
|
|
236
|
+
if value is not None:
|
|
237
|
+
result[key] = str(value)
|
|
238
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional instrumentation integrations."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PlainMessageFormatter(logging.Formatter):
|
|
7
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
8
|
+
return record.getMessage()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def configure_plain_logger(logger: logging.Logger, level: int) -> None:
|
|
12
|
+
logger.setLevel(level)
|
|
13
|
+
logger.propagate = False
|
|
14
|
+
if not logger.handlers:
|
|
15
|
+
handler = logging.StreamHandler()
|
|
16
|
+
handler.setFormatter(PlainMessageFormatter())
|
|
17
|
+
logger.addHandler(handler)
|