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.
@@ -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,8 @@
1
+ from __future__ import annotations
2
+
3
+ from ..runtime import ObservabilityRuntime, observability_runtime
4
+
5
+
6
+ def instrument_httpx(runtime: ObservabilityRuntime | None = None) -> None:
7
+ runtime = runtime or observability_runtime()
8
+ runtime.instrument_httpx()
@@ -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)