policyengine-observability 3.0.0__tar.gz → 3.0.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.
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/CHANGELOG.md +14 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/PKG-INFO +61 -10
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/README.md +60 -9
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/docs/engineering/skills/repository-guidance.md +17 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/__init__.py +2 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/config.py +31 -21
- policyengine_observability-3.0.2/policyengine_observability/identity.py +36 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/runtime.py +42 -12
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/schema.py +1 -3
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/pyproject.toml +1 -1
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_config_schema.py +30 -8
- policyengine_observability-3.0.2/tests/test_identity.py +38 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_otel.py +36 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_runtime.py +168 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/uv.lock +1 -1
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/bump_version.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/check-changelog.sh +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/copilot-instructions.md +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/fetch_version.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/get-changelog-diff.sh +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/publish-git-tag.sh +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/workflows/pr.yml +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/workflows/push.yml +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.gitignore +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/AGENTS.md +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/CLAUDE.md +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/LICENSE +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/docs/engineering/skills/README.md +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/docs/engineering/skills/github-prs.md +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/adapters/__init__.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/adapters/fastapi.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/adapters/flask.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/delivery.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/destinations/__init__.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/destinations/base.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/destinations/google_cloud.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/destinations/stdout.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/diagnostics.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/google_auth.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/google_credentials.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/integrations/__init__.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/integrations/httpx.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/otel.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/conftest.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_adapters.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_delivery.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_destination_strategies.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_diagnostics_public_api.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_google_credentials.py +0 -0
- {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_otel_destinations.py +0 -0
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [3.0.2] - 2026-09-30
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- Add process-scoped service instance identities and preserve dispatch attributes on nested operations.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## [3.0.1] - 2026-09-28
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Preserve configured dispatch attributes across asynchronous operations, include them in correlated structured logs and nested spans, accept safe scalar application attributes by default, and remove the attribute-count limit.
|
|
13
|
+
|
|
14
|
+
|
|
1
15
|
## [3.0.0] - 2026-09-23
|
|
2
16
|
|
|
3
17
|
### Breaking changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: policyengine-observability
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.2
|
|
4
4
|
Summary: Shared PolicyEngine observability runtime for logs, timings, metrics, and OpenTelemetry.
|
|
5
5
|
Author-email: PolicyEngine <hello@policyengine.org>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -106,12 +106,17 @@ config = ObservabilityConfig(
|
|
|
106
106
|
traces=OTLPExporterConfig(endpoint="collector:4317"),
|
|
107
107
|
metrics=OTLPExporterConfig(endpoint="collector:4317"),
|
|
108
108
|
),
|
|
109
|
-
application_attribute_keys=frozenset({"country_id", "backend"}),
|
|
110
109
|
dispatch_attribute_keys=frozenset({"job_id", "run_id"}),
|
|
111
110
|
)
|
|
112
111
|
runtime = configure(config)
|
|
113
112
|
```
|
|
114
113
|
|
|
114
|
+
Local logs and spans accept explicitly supplied safe scalar attributes by
|
|
115
|
+
default. Set `application_attribute_keys` to a `frozenset` only when a consumer
|
|
116
|
+
needs a strict local attribute allowlist. Asynchronous context still transports
|
|
117
|
+
only `dispatch_attribute_keys`, and metrics still use their separate
|
|
118
|
+
low-cardinality allowlist.
|
|
119
|
+
|
|
115
120
|
`configure` validates the complete configuration before it creates workers,
|
|
116
121
|
exporters, or logging handlers. Invalid values raise `ConfigurationError` with
|
|
117
122
|
the fields that must be corrected. Unavailable credentials or destinations
|
|
@@ -346,19 +351,65 @@ instrument_httpx(client, runtime)
|
|
|
346
351
|
The request hook injects active W3C context and the PolicyEngine request ID.
|
|
347
352
|
Other clients in the process remain unchanged.
|
|
348
353
|
|
|
349
|
-
For asynchronous dispatch,
|
|
350
|
-
|
|
354
|
+
For asynchronous dispatch, send the bounded observability context as transport
|
|
355
|
+
metadata beside the application payload and restore it around the worker
|
|
356
|
+
operation:
|
|
351
357
|
|
|
352
358
|
```python
|
|
353
|
-
|
|
359
|
+
observability_context = runtime.capture_context()
|
|
360
|
+
worker.spawn(
|
|
361
|
+
payload,
|
|
362
|
+
observability_context=observability_context,
|
|
363
|
+
)
|
|
354
364
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
365
|
+
def worker(payload, *, observability_context=None):
|
|
366
|
+
with runtime.operation(
|
|
367
|
+
"simulation.run",
|
|
368
|
+
remote_context=observability_context,
|
|
369
|
+
):
|
|
370
|
+
return run_simulation(payload)
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
`capture_context()` includes W3C trace context, its capture time, the active
|
|
374
|
+
PolicyEngine request ID, and scalar attributes named by
|
|
375
|
+
`dispatch_attribute_keys`. Starting the remote operation restores only those
|
|
376
|
+
configured dispatch attributes. They remain available to nested
|
|
377
|
+
`capture_context()` calls and are attached to logs, nested operations, and
|
|
378
|
+
nested spans inside the operation. They are never added to metric labels
|
|
379
|
+
unless separately included in `metric_attribute_keys`.
|
|
380
|
+
|
|
381
|
+
Keep this context separate from the application payload. Invalid or stale
|
|
382
|
+
trace context can reduce correlation, but it does not prevent the observed
|
|
383
|
+
application code from running. A recent direct dispatch continues the trace;
|
|
384
|
+
delayed, retry, and aggregate work starts a trace linked to the dispatch span.
|
|
385
|
+
|
|
386
|
+
## Process identity
|
|
387
|
+
|
|
388
|
+
OpenTelemetry resource attributes require `service.instance.id` to identify
|
|
389
|
+
one telemetry-producing process. Deployment revisions identify code shared by
|
|
390
|
+
multiple containers and workers, so they must not be used alone as the process
|
|
391
|
+
identity. Construct the deployment identity after the application process has
|
|
392
|
+
started:
|
|
393
|
+
|
|
394
|
+
```python
|
|
395
|
+
import os
|
|
396
|
+
|
|
397
|
+
from policyengine_observability import DeploymentIdentity, process_instance_id
|
|
398
|
+
|
|
399
|
+
service_name = "example-api"
|
|
400
|
+
deployment = DeploymentIdentity(
|
|
401
|
+
environment="production",
|
|
402
|
+
platform="google_cloud_run",
|
|
403
|
+
region="us-central1",
|
|
404
|
+
instance_id=process_instance_id(service_name, os.getenv("K_REVISION")),
|
|
405
|
+
)
|
|
360
406
|
```
|
|
361
407
|
|
|
408
|
+
The helper returns one stable value for a service within the current process.
|
|
409
|
+
It combines the optional platform identifier with a process ID and random UUID,
|
|
410
|
+
so separate workers and containers cannot publish cumulative metrics under the
|
|
411
|
+
same resource identity. A child process receives a new value on its first call.
|
|
412
|
+
|
|
362
413
|
## Process restoration and shutdown
|
|
363
414
|
|
|
364
415
|
After a process image or memory snapshot is restored, rebuild process-local
|
|
@@ -60,12 +60,17 @@ config = ObservabilityConfig(
|
|
|
60
60
|
traces=OTLPExporterConfig(endpoint="collector:4317"),
|
|
61
61
|
metrics=OTLPExporterConfig(endpoint="collector:4317"),
|
|
62
62
|
),
|
|
63
|
-
application_attribute_keys=frozenset({"country_id", "backend"}),
|
|
64
63
|
dispatch_attribute_keys=frozenset({"job_id", "run_id"}),
|
|
65
64
|
)
|
|
66
65
|
runtime = configure(config)
|
|
67
66
|
```
|
|
68
67
|
|
|
68
|
+
Local logs and spans accept explicitly supplied safe scalar attributes by
|
|
69
|
+
default. Set `application_attribute_keys` to a `frozenset` only when a consumer
|
|
70
|
+
needs a strict local attribute allowlist. Asynchronous context still transports
|
|
71
|
+
only `dispatch_attribute_keys`, and metrics still use their separate
|
|
72
|
+
low-cardinality allowlist.
|
|
73
|
+
|
|
69
74
|
`configure` validates the complete configuration before it creates workers,
|
|
70
75
|
exporters, or logging handlers. Invalid values raise `ConfigurationError` with
|
|
71
76
|
the fields that must be corrected. Unavailable credentials or destinations
|
|
@@ -300,19 +305,65 @@ instrument_httpx(client, runtime)
|
|
|
300
305
|
The request hook injects active W3C context and the PolicyEngine request ID.
|
|
301
306
|
Other clients in the process remain unchanged.
|
|
302
307
|
|
|
303
|
-
For asynchronous dispatch,
|
|
304
|
-
|
|
308
|
+
For asynchronous dispatch, send the bounded observability context as transport
|
|
309
|
+
metadata beside the application payload and restore it around the worker
|
|
310
|
+
operation:
|
|
305
311
|
|
|
306
312
|
```python
|
|
307
|
-
|
|
313
|
+
observability_context = runtime.capture_context()
|
|
314
|
+
worker.spawn(
|
|
315
|
+
payload,
|
|
316
|
+
observability_context=observability_context,
|
|
317
|
+
)
|
|
308
318
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
319
|
+
def worker(payload, *, observability_context=None):
|
|
320
|
+
with runtime.operation(
|
|
321
|
+
"simulation.run",
|
|
322
|
+
remote_context=observability_context,
|
|
323
|
+
):
|
|
324
|
+
return run_simulation(payload)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`capture_context()` includes W3C trace context, its capture time, the active
|
|
328
|
+
PolicyEngine request ID, and scalar attributes named by
|
|
329
|
+
`dispatch_attribute_keys`. Starting the remote operation restores only those
|
|
330
|
+
configured dispatch attributes. They remain available to nested
|
|
331
|
+
`capture_context()` calls and are attached to logs, nested operations, and
|
|
332
|
+
nested spans inside the operation. They are never added to metric labels
|
|
333
|
+
unless separately included in `metric_attribute_keys`.
|
|
334
|
+
|
|
335
|
+
Keep this context separate from the application payload. Invalid or stale
|
|
336
|
+
trace context can reduce correlation, but it does not prevent the observed
|
|
337
|
+
application code from running. A recent direct dispatch continues the trace;
|
|
338
|
+
delayed, retry, and aggregate work starts a trace linked to the dispatch span.
|
|
339
|
+
|
|
340
|
+
## Process identity
|
|
341
|
+
|
|
342
|
+
OpenTelemetry resource attributes require `service.instance.id` to identify
|
|
343
|
+
one telemetry-producing process. Deployment revisions identify code shared by
|
|
344
|
+
multiple containers and workers, so they must not be used alone as the process
|
|
345
|
+
identity. Construct the deployment identity after the application process has
|
|
346
|
+
started:
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
import os
|
|
350
|
+
|
|
351
|
+
from policyengine_observability import DeploymentIdentity, process_instance_id
|
|
352
|
+
|
|
353
|
+
service_name = "example-api"
|
|
354
|
+
deployment = DeploymentIdentity(
|
|
355
|
+
environment="production",
|
|
356
|
+
platform="google_cloud_run",
|
|
357
|
+
region="us-central1",
|
|
358
|
+
instance_id=process_instance_id(service_name, os.getenv("K_REVISION")),
|
|
359
|
+
)
|
|
314
360
|
```
|
|
315
361
|
|
|
362
|
+
The helper returns one stable value for a service within the current process.
|
|
363
|
+
It combines the optional platform identifier with a process ID and random UUID,
|
|
364
|
+
so separate workers and containers cannot publish cumulative metrics under the
|
|
365
|
+
same resource identity. A child process receives a new value on its first call.
|
|
366
|
+
|
|
316
367
|
## Process restoration and shutdown
|
|
317
368
|
|
|
318
369
|
After a process image or memory snapshot is restored, rebuild process-local
|
|
@@ -62,6 +62,20 @@ uv run --extra dev towncrier check --compare-with origin/main
|
|
|
62
62
|
without breaking the application operation being observed.
|
|
63
63
|
- Preserve structured log schemas. Make additive changes when possible; bump
|
|
64
64
|
schema versions for breaking payload changes.
|
|
65
|
+
- Treat `capture_context()` output as transport metadata beside an application
|
|
66
|
+
payload. Pass it to the receiver's outer `operation` through
|
|
67
|
+
`remote_context`; do not insert it into business request models.
|
|
68
|
+
- Restore only attributes explicitly listed in `dispatch_attribute_keys`.
|
|
69
|
+
Those attributes must remain available to nested dispatches and structured
|
|
70
|
+
logs and must be attached to nested operations and spans, but must not become
|
|
71
|
+
metric labels unless independently allowlisted in `metric_attribute_keys`.
|
|
72
|
+
- Accept explicitly supplied safe scalar attributes in local logs and spans by
|
|
73
|
+
default. Use `application_attribute_keys` only when a consumer requires a
|
|
74
|
+
strict local allowlist. Do not use that optional local policy to decide what
|
|
75
|
+
crosses a process boundary or becomes a metric label.
|
|
76
|
+
- Let explicitly supplied receiver attributes override matching remote
|
|
77
|
+
attributes. Malformed remote context may reduce telemetry but must not stop
|
|
78
|
+
the observed operation.
|
|
65
79
|
- Keep metric attributes bounded and low-cardinality. Do not put raw paths,
|
|
66
80
|
full URLs, request bodies, or unbounded user-provided values into metric
|
|
67
81
|
labels.
|
|
@@ -73,6 +87,9 @@ uv run --extra dev towncrier check --compare-with origin/main
|
|
|
73
87
|
Add focused tests for context behavior and failure paths whenever changing
|
|
74
88
|
the runtime or its components. The corresponding `tests/test_runtime_*.py`
|
|
75
89
|
modules cover operations, requests, spans, log emission, and tracing.
|
|
90
|
+
Remote-context tests must exercise two runtime instances and prove that
|
|
91
|
+
configured dispatch attributes survive capture, restoration, logs, spans, and
|
|
92
|
+
a subsequent capture. Include malformed context and local-override cases.
|
|
76
93
|
Adapter changes should include framework-level tests that exercise request
|
|
77
94
|
setup, response headers, error paths, and teardown behavior.
|
|
78
95
|
|
|
@@ -21,6 +21,7 @@ from .destinations import (
|
|
|
21
21
|
StdoutLogDestination,
|
|
22
22
|
)
|
|
23
23
|
from .google_auth import GoogleIdTokenAuth
|
|
24
|
+
from .identity import process_instance_id
|
|
24
25
|
from .integrations import instrument_httpx
|
|
25
26
|
from .runtime import (
|
|
26
27
|
REQUEST_ID_HEADER,
|
|
@@ -61,4 +62,5 @@ __all__ = [
|
|
|
61
62
|
"instrument_flask",
|
|
62
63
|
"instrument_httpx",
|
|
63
64
|
"instrument_logging",
|
|
65
|
+
"process_instance_id",
|
|
64
66
|
]
|
|
@@ -22,8 +22,6 @@ class ConfigurationError(ValueError):
|
|
|
22
22
|
super().__init__(f"Invalid observability configuration:\n{details}")
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
DEFAULT_APPLICATION_ATTRIBUTE_KEYS = frozenset(set())
|
|
26
|
-
|
|
27
25
|
DEFAULT_DISPATCH_ATTRIBUTE_KEYS = frozenset(set())
|
|
28
26
|
|
|
29
27
|
DEFAULT_METRIC_ATTRIBUTE_KEYS = frozenset(
|
|
@@ -106,7 +104,6 @@ class OTelConfig:
|
|
|
106
104
|
|
|
107
105
|
@dataclass(frozen=True, slots=True)
|
|
108
106
|
class TelemetryLimits:
|
|
109
|
-
max_attributes: int = 32
|
|
110
107
|
max_string_length: int = 1_024
|
|
111
108
|
max_error_message_length: int = 2_048
|
|
112
109
|
max_stack_length: int = 16_384
|
|
@@ -120,9 +117,7 @@ class ObservabilityConfig:
|
|
|
120
117
|
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
|
121
118
|
otel: OTelConfig = field(default_factory=OTelConfig)
|
|
122
119
|
limits: TelemetryLimits = field(default_factory=TelemetryLimits)
|
|
123
|
-
application_attribute_keys: frozenset[str] =
|
|
124
|
-
DEFAULT_APPLICATION_ATTRIBUTE_KEYS
|
|
125
|
-
)
|
|
120
|
+
application_attribute_keys: frozenset[str] | None = None
|
|
126
121
|
dispatch_attribute_keys: frozenset[str] = DEFAULT_DISPATCH_ATTRIBUTE_KEYS
|
|
127
122
|
metric_attribute_keys: frozenset[str] = DEFAULT_METRIC_ATTRIBUTE_KEYS
|
|
128
123
|
sensitive_values: tuple[str, ...] = ()
|
|
@@ -224,11 +219,7 @@ class ObservabilityConfig:
|
|
|
224
219
|
),
|
|
225
220
|
),
|
|
226
221
|
limits=limits or TelemetryLimits(),
|
|
227
|
-
application_attribute_keys=
|
|
228
|
-
application_attribute_keys
|
|
229
|
-
if application_attribute_keys is not None
|
|
230
|
-
else DEFAULT_APPLICATION_ATTRIBUTE_KEYS
|
|
231
|
-
),
|
|
222
|
+
application_attribute_keys=application_attribute_keys,
|
|
232
223
|
dispatch_attribute_keys=(
|
|
233
224
|
dispatch_attribute_keys
|
|
234
225
|
if dispatch_attribute_keys is not None
|
|
@@ -278,19 +269,18 @@ class ObservabilityConfig:
|
|
|
278
269
|
"string."
|
|
279
270
|
)
|
|
280
271
|
|
|
272
|
+
if self.application_attribute_keys is not None:
|
|
273
|
+
_attribute_key_errors(
|
|
274
|
+
errors,
|
|
275
|
+
"application_attribute_keys",
|
|
276
|
+
self.application_attribute_keys,
|
|
277
|
+
)
|
|
278
|
+
|
|
281
279
|
for name, values in (
|
|
282
|
-
("application_attribute_keys", self.application_attribute_keys),
|
|
283
280
|
("dispatch_attribute_keys", self.dispatch_attribute_keys),
|
|
284
281
|
("metric_attribute_keys", self.metric_attribute_keys),
|
|
285
282
|
):
|
|
286
|
-
|
|
287
|
-
errors.append(
|
|
288
|
-
f"{name} must be a frozenset of non-empty strings."
|
|
289
|
-
)
|
|
290
|
-
continue
|
|
291
|
-
for value in values:
|
|
292
|
-
if not isinstance(value, str) or not value.strip():
|
|
293
|
-
errors.append(f"{name} entries must be non-empty strings.")
|
|
283
|
+
_attribute_key_errors(errors, name, values)
|
|
294
284
|
|
|
295
285
|
_choice_error(
|
|
296
286
|
errors,
|
|
@@ -421,7 +411,6 @@ class ObservabilityConfig:
|
|
|
421
411
|
)
|
|
422
412
|
|
|
423
413
|
for name, value in {
|
|
424
|
-
"limits.max_attributes": self.limits.max_attributes,
|
|
425
414
|
"limits.max_string_length": self.limits.max_string_length,
|
|
426
415
|
"limits.max_error_message_length": self.limits.max_error_message_length,
|
|
427
416
|
"limits.max_stack_length": self.limits.max_stack_length,
|
|
@@ -436,6 +425,14 @@ class ObservabilityConfig:
|
|
|
436
425
|
)
|
|
437
426
|
return tuple(errors)
|
|
438
427
|
|
|
428
|
+
@property
|
|
429
|
+
def local_attribute_keys(self) -> frozenset[str] | None:
|
|
430
|
+
"""Return the optional strict allowlist for local logs and spans."""
|
|
431
|
+
|
|
432
|
+
if self.application_attribute_keys is None:
|
|
433
|
+
return None
|
|
434
|
+
return self.application_attribute_keys | self.dispatch_attribute_keys
|
|
435
|
+
|
|
439
436
|
def diagnostics(self) -> tuple[str, ...]:
|
|
440
437
|
messages: list[str] = []
|
|
441
438
|
if (
|
|
@@ -623,6 +620,19 @@ def _env_int(
|
|
|
623
620
|
return parsed
|
|
624
621
|
|
|
625
622
|
|
|
623
|
+
def _attribute_key_errors(
|
|
624
|
+
errors: list[str],
|
|
625
|
+
name: str,
|
|
626
|
+
values: object,
|
|
627
|
+
) -> None:
|
|
628
|
+
if not isinstance(values, frozenset):
|
|
629
|
+
errors.append(f"{name} must be a frozenset of non-empty strings.")
|
|
630
|
+
return
|
|
631
|
+
for value in values:
|
|
632
|
+
if not isinstance(value, str) or not value.strip():
|
|
633
|
+
errors.append(f"{name} entries must be non-empty strings.")
|
|
634
|
+
|
|
635
|
+
|
|
626
636
|
def _choice_error(
|
|
627
637
|
errors: list[str], name: str, value: Any, choices: set[str]
|
|
628
638
|
) -> None:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Stable identities for individual telemetry-producing processes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
_PROCESS_IDENTITIES: dict[tuple[int, str, str], str] = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def process_instance_id(
|
|
12
|
+
service_name: str,
|
|
13
|
+
platform_instance_id: str | None = None,
|
|
14
|
+
) -> str:
|
|
15
|
+
"""Return a stable, unique identity for one service process.
|
|
16
|
+
|
|
17
|
+
Platform identifiers such as a Cloud Run revision identify deployed code,
|
|
18
|
+
not an individual process. The process ID distinguishes local workers and
|
|
19
|
+
the UUID distinguishes processes in separate containers that reuse the
|
|
20
|
+
same operating-system process ID. Including ``os.getpid()`` in the cache
|
|
21
|
+
key causes a forked child to receive a new identity on its first call.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
service = service_name.strip()
|
|
25
|
+
if not service:
|
|
26
|
+
raise ValueError("service_name must be non-empty")
|
|
27
|
+
platform_id = (platform_instance_id or "unassigned").strip()
|
|
28
|
+
if not platform_id:
|
|
29
|
+
platform_id = "unassigned"
|
|
30
|
+
process_id = os.getpid()
|
|
31
|
+
key = (process_id, service, platform_id)
|
|
32
|
+
existing = _PROCESS_IDENTITIES.get(key)
|
|
33
|
+
if existing is not None:
|
|
34
|
+
return existing
|
|
35
|
+
generated = f"{platform_id}:{process_id}:{uuid4().hex}"
|
|
36
|
+
return _PROCESS_IDENTITIES.setdefault(key, generated)
|
|
@@ -317,10 +317,7 @@ class ObservabilityRuntime:
|
|
|
317
317
|
safe, omitted = normalize_attributes(
|
|
318
318
|
attributes,
|
|
319
319
|
self.config,
|
|
320
|
-
allowed_keys=
|
|
321
|
-
self.config.application_attribute_keys
|
|
322
|
-
| self.config.dispatch_attribute_keys
|
|
323
|
-
),
|
|
320
|
+
allowed_keys=self.config.local_attribute_keys,
|
|
324
321
|
)
|
|
325
322
|
request = self._request_state.get()
|
|
326
323
|
operation = self._operation_state.get()
|
|
@@ -507,10 +504,7 @@ class ObservabilityRuntime:
|
|
|
507
504
|
safe, omitted = normalize_attributes(
|
|
508
505
|
attributes,
|
|
509
506
|
self.config,
|
|
510
|
-
allowed_keys=
|
|
511
|
-
self.config.application_attribute_keys
|
|
512
|
-
| self.config.dispatch_attribute_keys
|
|
513
|
-
),
|
|
507
|
+
allowed_keys=self.config.local_attribute_keys,
|
|
514
508
|
)
|
|
515
509
|
if omitted:
|
|
516
510
|
self.diagnostics.increment("attributes.omitted", omitted)
|
|
@@ -519,6 +513,20 @@ class ObservabilityRuntime:
|
|
|
519
513
|
request_id: str | None = None
|
|
520
514
|
if remote_context:
|
|
521
515
|
request_id = _valid_request_id(remote_context.get("request_id"))
|
|
516
|
+
remote_attributes, remote_omitted = normalize_attributes(
|
|
517
|
+
{
|
|
518
|
+
key: remote_context.get(key)
|
|
519
|
+
for key in self.config.dispatch_attribute_keys
|
|
520
|
+
if key in remote_context
|
|
521
|
+
},
|
|
522
|
+
self.config,
|
|
523
|
+
allowed_keys=self.config.dispatch_attribute_keys,
|
|
524
|
+
)
|
|
525
|
+
if remote_omitted:
|
|
526
|
+
self.diagnostics.increment(
|
|
527
|
+
"attributes.omitted", remote_omitted
|
|
528
|
+
)
|
|
529
|
+
safe = {**remote_attributes, **safe}
|
|
522
530
|
carrier = {
|
|
523
531
|
key: str(value)
|
|
524
532
|
for key, value in remote_context.items()
|
|
@@ -540,6 +548,7 @@ class ObservabilityRuntime:
|
|
|
540
548
|
if link is not None:
|
|
541
549
|
links.append(link)
|
|
542
550
|
parent = self._otel.empty_context()
|
|
551
|
+
safe = {**safe, **self._active_dispatch_attributes()}
|
|
543
552
|
active_request = self._request_state.get()
|
|
544
553
|
if request_id is None and active_request is not None:
|
|
545
554
|
request_id = active_request.request_id
|
|
@@ -615,13 +624,11 @@ class ObservabilityRuntime:
|
|
|
615
624
|
safe, omitted = normalize_attributes(
|
|
616
625
|
attributes,
|
|
617
626
|
self.config,
|
|
618
|
-
allowed_keys=
|
|
619
|
-
self.config.application_attribute_keys
|
|
620
|
-
| self.config.dispatch_attribute_keys
|
|
621
|
-
),
|
|
627
|
+
allowed_keys=self.config.local_attribute_keys,
|
|
622
628
|
)
|
|
623
629
|
if omitted:
|
|
624
630
|
self.diagnostics.increment("attributes.omitted", omitted)
|
|
631
|
+
safe = {**safe, **self._active_dispatch_attributes()}
|
|
625
632
|
return _ChildSpanState(
|
|
626
633
|
name=name,
|
|
627
634
|
start_time=time.perf_counter(),
|
|
@@ -661,6 +668,20 @@ class ObservabilityRuntime:
|
|
|
661
668
|
fields.update(self._otel.current_correlation())
|
|
662
669
|
return fields
|
|
663
670
|
|
|
671
|
+
def _active_dispatch_attributes(self) -> dict[str, Any]:
|
|
672
|
+
attributes: dict[str, Any] = {}
|
|
673
|
+
request = self._request_state.get()
|
|
674
|
+
operation = self._operation_state.get()
|
|
675
|
+
if request is not None:
|
|
676
|
+
attributes.update(request.attributes)
|
|
677
|
+
if operation is not None:
|
|
678
|
+
attributes.update(operation.attributes)
|
|
679
|
+
return {
|
|
680
|
+
key: attributes[key]
|
|
681
|
+
for key in self.config.dispatch_attribute_keys
|
|
682
|
+
if key in attributes
|
|
683
|
+
}
|
|
684
|
+
|
|
664
685
|
def _metric_base(self) -> dict[str, Any]:
|
|
665
686
|
return {
|
|
666
687
|
"service.name": self.config.service.name,
|
|
@@ -673,6 +694,15 @@ class ObservabilityRuntime:
|
|
|
673
694
|
|
|
674
695
|
def _emit_record(self, **kwargs: Any) -> None:
|
|
675
696
|
try:
|
|
697
|
+
supplied_attributes = kwargs.get("attributes")
|
|
698
|
+
kwargs["attributes"] = {
|
|
699
|
+
**(
|
|
700
|
+
dict(supplied_attributes)
|
|
701
|
+
if supplied_attributes is not None
|
|
702
|
+
else {}
|
|
703
|
+
),
|
|
704
|
+
**self._active_dispatch_attributes(),
|
|
705
|
+
}
|
|
676
706
|
self._delivery.emit(build_record(self.config, **kwargs))
|
|
677
707
|
except Exception as exc:
|
|
678
708
|
self.diagnostics.report("record.emit", exc)
|
|
@@ -43,7 +43,6 @@ def normalize_attributes(
|
|
|
43
43
|
key = str(raw_key).strip()
|
|
44
44
|
if (
|
|
45
45
|
not key
|
|
46
|
-
or len(normalized) >= config.limits.max_attributes
|
|
47
46
|
or _prohibited_key(key)
|
|
48
47
|
or (allowed_keys is not None and key not in allowed_keys)
|
|
49
48
|
):
|
|
@@ -100,8 +99,7 @@ def build_record(
|
|
|
100
99
|
safe_attributes, omitted = normalize_attributes(
|
|
101
100
|
attributes,
|
|
102
101
|
config,
|
|
103
|
-
allowed_keys=config.
|
|
104
|
-
| config.dispatch_attribute_keys,
|
|
102
|
+
allowed_keys=config.local_attribute_keys,
|
|
105
103
|
)
|
|
106
104
|
if safe_attributes:
|
|
107
105
|
record["attributes"] = safe_attributes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "policyengine-observability"
|
|
3
|
-
version = "3.0.
|
|
3
|
+
version = "3.0.2"
|
|
4
4
|
description = "Shared PolicyEngine observability runtime for logs, timings, metrics, and OpenTelemetry."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "PolicyEngine", email = "hello@policyengine.org" }]
|
{policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_config_schema.py
RENAMED
|
@@ -97,7 +97,6 @@ def test_invalid_nested_limits_are_reported_together() -> None:
|
|
|
97
97
|
shutdown_timeout_seconds=100,
|
|
98
98
|
),
|
|
99
99
|
limits=TelemetryLimits(
|
|
100
|
-
max_attributes=0,
|
|
101
100
|
max_string_length=0,
|
|
102
101
|
max_error_message_length=0,
|
|
103
102
|
max_stack_length=0,
|
|
@@ -120,7 +119,6 @@ def test_invalid_nested_limits_are_reported_together() -> None:
|
|
|
120
119
|
"otel.traces.protocol",
|
|
121
120
|
"otel.traces.endpoint_mode",
|
|
122
121
|
"otel.traces.timeout_seconds",
|
|
123
|
-
"limits.max_attributes",
|
|
124
122
|
"limits.async_parent_max_age_seconds",
|
|
125
123
|
):
|
|
126
124
|
assert field in message
|
|
@@ -257,6 +255,7 @@ def test_from_env_reads_transport_but_not_identity(monkeypatch) -> None:
|
|
|
257
255
|
assert config.otel.span_batch_size == 99
|
|
258
256
|
assert config.otel.span_schedule_delay_seconds == 2.5
|
|
259
257
|
assert config.otel.metric_export_interval_seconds == 4.0
|
|
258
|
+
assert config.application_attribute_keys is None
|
|
260
259
|
|
|
261
260
|
|
|
262
261
|
def test_from_env_marks_signal_specific_endpoints_as_exact(
|
|
@@ -377,6 +376,26 @@ def test_schema_preserves_core_fields_and_namespaces_attributes() -> None:
|
|
|
377
376
|
json.dumps(record)
|
|
378
377
|
|
|
379
378
|
|
|
379
|
+
def test_default_application_policy_accepts_safe_scalar_attributes() -> None:
|
|
380
|
+
config = make_config(application_attribute_keys=None)
|
|
381
|
+
record = build_record(
|
|
382
|
+
config,
|
|
383
|
+
severity="INFO",
|
|
384
|
+
attributes={
|
|
385
|
+
"new_runtime_detail": "available",
|
|
386
|
+
"attempt": 3,
|
|
387
|
+
"authorization": "prohibited",
|
|
388
|
+
"structured": {"not": "scalar"},
|
|
389
|
+
},
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
assert record["attributes"] == {
|
|
393
|
+
"new_runtime_detail": "available",
|
|
394
|
+
"attempt": 3,
|
|
395
|
+
}
|
|
396
|
+
assert record["attributes.omitted_count"] == 2
|
|
397
|
+
|
|
398
|
+
|
|
380
399
|
def test_schema_redacts_and_truncates_errors() -> None:
|
|
381
400
|
config = make_config(
|
|
382
401
|
application_attribute_keys=frozenset({"backend"}),
|
|
@@ -442,16 +461,19 @@ def test_attribute_policy_omits_sensitive_non_scalar_and_nonfinite() -> None:
|
|
|
442
461
|
assert omitted == 3
|
|
443
462
|
|
|
444
463
|
|
|
445
|
-
def
|
|
464
|
+
def test_attribute_count_is_unbounded_and_strings_are_truncated() -> None:
|
|
465
|
+
keys = frozenset(f"attribute_{index}" for index in range(40))
|
|
446
466
|
config = make_config(
|
|
447
|
-
application_attribute_keys=
|
|
448
|
-
limits=TelemetryLimits(
|
|
467
|
+
application_attribute_keys=keys,
|
|
468
|
+
limits=TelemetryLimits(max_string_length=3),
|
|
449
469
|
)
|
|
470
|
+
values = {key: "abcdef" for key in keys}
|
|
450
471
|
safe, omitted = normalize_attributes(
|
|
451
|
-
|
|
472
|
+
values,
|
|
473
|
+
config,
|
|
452
474
|
)
|
|
453
|
-
assert safe == {
|
|
454
|
-
assert omitted ==
|
|
475
|
+
assert safe == {key: "abc" for key in keys}
|
|
476
|
+
assert omitted == 0
|
|
455
477
|
|
|
456
478
|
|
|
457
479
|
def test_google_trace_correlation_is_not_in_canonical_record() -> None:
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from policyengine_observability import identity, process_instance_id
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_process_instance_id_is_stable_within_one_process() -> None:
|
|
11
|
+
first = process_instance_id("example-api", "revision-1")
|
|
12
|
+
second = process_instance_id("example-api", "revision-1")
|
|
13
|
+
|
|
14
|
+
assert first == second
|
|
15
|
+
assert re.fullmatch(r"revision-1:\d+:[0-9a-f]{32}", first)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_process_instance_id_separates_services_and_deployments() -> None:
|
|
19
|
+
first = process_instance_id("example-api", "revision-1")
|
|
20
|
+
|
|
21
|
+
assert process_instance_id("other-api", "revision-1") != first
|
|
22
|
+
assert process_instance_id("example-api", "revision-2") != first
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_process_instance_id_changes_after_process_duplication(
|
|
26
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
27
|
+
) -> None:
|
|
28
|
+
monkeypatch.setattr(identity.os, "getpid", lambda: 10_001)
|
|
29
|
+
parent = process_instance_id("example-api", "revision-1")
|
|
30
|
+
monkeypatch.setattr(identity.os, "getpid", lambda: 10_002)
|
|
31
|
+
child = process_instance_id("example-api", "revision-1")
|
|
32
|
+
|
|
33
|
+
assert parent != child
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_process_instance_id_rejects_empty_service_name() -> None:
|
|
37
|
+
with pytest.raises(ValueError, match="service_name must be non-empty"):
|
|
38
|
+
process_instance_id(" ")
|
|
@@ -72,6 +72,42 @@ def test_owned_provider_creates_local_spans_metrics_and_resources() -> None:
|
|
|
72
72
|
runtime.shutdown()
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def test_default_policy_records_safe_application_attributes() -> None:
|
|
76
|
+
config = make_config(
|
|
77
|
+
otel=OTelConfig(enabled=True),
|
|
78
|
+
application_attribute_keys=None,
|
|
79
|
+
)
|
|
80
|
+
runtime = configure(config)
|
|
81
|
+
runtime._delivery._stdout = io.StringIO()
|
|
82
|
+
exporter = InMemorySpanExporter()
|
|
83
|
+
runtime._otel._tracer_provider.add_span_processor(
|
|
84
|
+
SimpleSpanProcessor(exporter)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
with runtime.operation(
|
|
88
|
+
"simulation.run",
|
|
89
|
+
attributes={
|
|
90
|
+
"new_runtime_detail": "available",
|
|
91
|
+
"authorization": "prohibited",
|
|
92
|
+
},
|
|
93
|
+
):
|
|
94
|
+
with runtime.span(
|
|
95
|
+
"simulation.calculate",
|
|
96
|
+
attributes={"partition_count": 12},
|
|
97
|
+
):
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
spans = {span.name: span for span in exporter.get_finished_spans()}
|
|
101
|
+
assert (
|
|
102
|
+
spans["simulation.run"].attributes["new_runtime_detail"] == "available"
|
|
103
|
+
)
|
|
104
|
+
assert "authorization" not in spans["simulation.run"].attributes
|
|
105
|
+
assert spans["simulation.calculate"].attributes["partition_count"] == 12
|
|
106
|
+
item = records(runtime._delivery._stdout)[0]
|
|
107
|
+
assert item["attributes"] == {"new_runtime_detail": "available"}
|
|
108
|
+
runtime.shutdown()
|
|
109
|
+
|
|
110
|
+
|
|
75
111
|
def test_sensitive_values_are_redacted_from_spans_and_logs() -> None:
|
|
76
112
|
config = make_config(
|
|
77
113
|
otel=OTelConfig(enabled=True),
|
|
@@ -196,6 +196,174 @@ def test_set_context_and_capture_context_are_allowlisted(runtime) -> None:
|
|
|
196
196
|
observed.end_request(status_code=202)
|
|
197
197
|
|
|
198
198
|
|
|
199
|
+
def test_remote_operation_restores_dispatch_context() -> None:
|
|
200
|
+
dispatch_keys = frozenset({"job_id", "observability_id"})
|
|
201
|
+
upstream, _upstream_output = make_runtime(
|
|
202
|
+
dispatch_attribute_keys=dispatch_keys
|
|
203
|
+
)
|
|
204
|
+
downstream, downstream_output = make_runtime(
|
|
205
|
+
dispatch_attribute_keys=dispatch_keys
|
|
206
|
+
)
|
|
207
|
+
upstream.begin_request(
|
|
208
|
+
headers={REQUEST_ID_HEADER: "request-1"},
|
|
209
|
+
method="POST",
|
|
210
|
+
route="/simulation",
|
|
211
|
+
)
|
|
212
|
+
upstream.set_context(
|
|
213
|
+
job_id="job-1",
|
|
214
|
+
observability_id="00000000-0000-4000-8000-000000000001",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
remote_context = upstream.capture_context()
|
|
218
|
+
with downstream.operation("simulation.run", remote_context=remote_context):
|
|
219
|
+
assert downstream.capture_context()["job_id"] == "job-1"
|
|
220
|
+
downstream.log("running")
|
|
221
|
+
|
|
222
|
+
emitted = records(downstream_output)
|
|
223
|
+
assert emitted[0]["attributes"] == {
|
|
224
|
+
"job_id": "job-1",
|
|
225
|
+
"observability_id": "00000000-0000-4000-8000-000000000001",
|
|
226
|
+
}
|
|
227
|
+
assert emitted[1]["attributes"] == emitted[0]["attributes"]
|
|
228
|
+
upstream.end_request(status_code=202)
|
|
229
|
+
upstream.shutdown()
|
|
230
|
+
downstream.shutdown()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_nested_span_inherits_active_dispatch_attributes(monkeypatch) -> None:
|
|
234
|
+
observability_id = "00000000-0000-4000-8000-000000000001"
|
|
235
|
+
observed, _output = make_runtime(
|
|
236
|
+
application_attribute_keys=frozenset({"backend"}),
|
|
237
|
+
dispatch_attribute_keys=frozenset({"observability_id"}),
|
|
238
|
+
)
|
|
239
|
+
child_span_attributes = []
|
|
240
|
+
|
|
241
|
+
with observed.operation(
|
|
242
|
+
"simulation.run",
|
|
243
|
+
remote_context={
|
|
244
|
+
"captured_at": "not-a-date",
|
|
245
|
+
"observability_id": observability_id,
|
|
246
|
+
},
|
|
247
|
+
):
|
|
248
|
+
monkeypatch.setattr(
|
|
249
|
+
observed._otel,
|
|
250
|
+
"start_span",
|
|
251
|
+
lambda _name, **kwargs: child_span_attributes.append(
|
|
252
|
+
kwargs["attributes"]
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
with observed.span(
|
|
256
|
+
"simulation.calculate",
|
|
257
|
+
attributes={
|
|
258
|
+
"backend": "modal",
|
|
259
|
+
"observability_id": "00000000-0000-4000-8000-000000000099",
|
|
260
|
+
},
|
|
261
|
+
):
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
assert child_span_attributes == [
|
|
265
|
+
{
|
|
266
|
+
"backend": "modal",
|
|
267
|
+
"observability_id": observability_id,
|
|
268
|
+
}
|
|
269
|
+
]
|
|
270
|
+
observed.shutdown()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_nested_operation_inherits_active_dispatch_attributes(
|
|
274
|
+
monkeypatch,
|
|
275
|
+
) -> None:
|
|
276
|
+
observability_id = "00000000-0000-4000-8000-000000000001"
|
|
277
|
+
observed, output = make_runtime(
|
|
278
|
+
application_attribute_keys=frozenset({"simulation_role"}),
|
|
279
|
+
dispatch_attribute_keys=frozenset({"observability_id"}),
|
|
280
|
+
)
|
|
281
|
+
operation_span_attributes = []
|
|
282
|
+
|
|
283
|
+
with observed.operation(
|
|
284
|
+
"simulation.run",
|
|
285
|
+
remote_context={
|
|
286
|
+
"captured_at": "not-a-date",
|
|
287
|
+
"observability_id": observability_id,
|
|
288
|
+
},
|
|
289
|
+
):
|
|
290
|
+
monkeypatch.setattr(
|
|
291
|
+
observed._otel,
|
|
292
|
+
"start_span",
|
|
293
|
+
lambda _name, **kwargs: operation_span_attributes.append(
|
|
294
|
+
kwargs["attributes"]
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
with observed.operation(
|
|
298
|
+
"simulation.plan",
|
|
299
|
+
attributes={
|
|
300
|
+
"simulation_role": "baseline",
|
|
301
|
+
"observability_id": "00000000-0000-4000-8000-000000000099",
|
|
302
|
+
},
|
|
303
|
+
):
|
|
304
|
+
assert observed.capture_context()["observability_id"] == (
|
|
305
|
+
observability_id
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
emitted = records(output)
|
|
309
|
+
nested_completion = next(
|
|
310
|
+
item
|
|
311
|
+
for item in emitted
|
|
312
|
+
if item.get("operation.name") == "simulation.plan"
|
|
313
|
+
)
|
|
314
|
+
assert nested_completion["attributes"] == {
|
|
315
|
+
"simulation_role": "baseline",
|
|
316
|
+
"observability_id": observability_id,
|
|
317
|
+
}
|
|
318
|
+
assert operation_span_attributes == [
|
|
319
|
+
{
|
|
320
|
+
"operation.name": "simulation.plan",
|
|
321
|
+
"operation.kind": "operation",
|
|
322
|
+
"simulation_role": "baseline",
|
|
323
|
+
"observability_id": observability_id,
|
|
324
|
+
}
|
|
325
|
+
]
|
|
326
|
+
observed.shutdown()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_local_operation_attributes_override_remote_dispatch_values() -> None:
|
|
330
|
+
observed, output = make_runtime(
|
|
331
|
+
dispatch_attribute_keys=frozenset({"job_id"})
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
with observed.operation(
|
|
335
|
+
"simulation.run",
|
|
336
|
+
attributes={"job_id": "local-job"},
|
|
337
|
+
remote_context={
|
|
338
|
+
"captured_at": "not-a-date",
|
|
339
|
+
"job_id": "remote-job",
|
|
340
|
+
"unapproved": "must-not-appear",
|
|
341
|
+
},
|
|
342
|
+
):
|
|
343
|
+
observed.event("simulation.started")
|
|
344
|
+
|
|
345
|
+
emitted = records(output)
|
|
346
|
+
assert emitted[0]["attributes"] == {"job_id": "local-job"}
|
|
347
|
+
assert emitted[1]["attributes"] == {"job_id": "local-job"}
|
|
348
|
+
assert observed.capture_context().get("unapproved") is None
|
|
349
|
+
observed.shutdown()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_malformed_remote_context_does_not_change_application_result() -> None:
|
|
353
|
+
observed, _output = make_runtime()
|
|
354
|
+
|
|
355
|
+
@observed.operation(
|
|
356
|
+
"simulation.run",
|
|
357
|
+
remote_context=["not", "a", "mapping"], # type: ignore[arg-type]
|
|
358
|
+
)
|
|
359
|
+
def calculate() -> int:
|
|
360
|
+
return 42
|
|
361
|
+
|
|
362
|
+
assert calculate() == 42
|
|
363
|
+
assert observed.diagnostics.count("failure.operation.start") == 1
|
|
364
|
+
observed.shutdown()
|
|
365
|
+
|
|
366
|
+
|
|
199
367
|
def test_two_runtimes_keep_identity_and_context_separate() -> None:
|
|
200
368
|
first, first_output = make_runtime()
|
|
201
369
|
second_config = make_config(
|
{policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/bump_version.py
RENAMED
|
File without changes
|
{policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/check-changelog.sh
RENAMED
|
File without changes
|
|
File without changes
|
{policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/fetch_version.py
RENAMED
|
File without changes
|
{policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/get-changelog-diff.sh
RENAMED
|
File without changes
|
{policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/publish-git-tag.sh
RENAMED
|
File without changes
|
{policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/workflows/pr.yml
RENAMED
|
File without changes
|
{policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/workflows/push.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_adapters.py
RENAMED
|
File without changes
|
{policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_delivery.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|