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.
Files changed (50) hide show
  1. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/CHANGELOG.md +14 -0
  2. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/PKG-INFO +61 -10
  3. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/README.md +60 -9
  4. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/docs/engineering/skills/repository-guidance.md +17 -0
  5. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/__init__.py +2 -0
  6. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/config.py +31 -21
  7. policyengine_observability-3.0.2/policyengine_observability/identity.py +36 -0
  8. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/runtime.py +42 -12
  9. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/schema.py +1 -3
  10. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/pyproject.toml +1 -1
  11. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_config_schema.py +30 -8
  12. policyengine_observability-3.0.2/tests/test_identity.py +38 -0
  13. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_otel.py +36 -0
  14. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_runtime.py +168 -0
  15. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/uv.lock +1 -1
  16. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/bump_version.py +0 -0
  17. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/check-changelog.sh +0 -0
  18. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/copilot-instructions.md +0 -0
  19. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/fetch_version.py +0 -0
  20. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/get-changelog-diff.sh +0 -0
  21. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/publish-git-tag.sh +0 -0
  22. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/workflows/pr.yml +0 -0
  23. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.github/workflows/push.yml +0 -0
  24. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/.gitignore +0 -0
  25. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/AGENTS.md +0 -0
  26. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/CLAUDE.md +0 -0
  27. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/LICENSE +0 -0
  28. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/docs/engineering/skills/README.md +0 -0
  29. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/docs/engineering/skills/github-prs.md +0 -0
  30. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/adapters/__init__.py +0 -0
  31. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/adapters/fastapi.py +0 -0
  32. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/adapters/flask.py +0 -0
  33. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/delivery.py +0 -0
  34. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/destinations/__init__.py +0 -0
  35. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/destinations/base.py +0 -0
  36. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/destinations/google_cloud.py +0 -0
  37. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/destinations/stdout.py +0 -0
  38. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/diagnostics.py +0 -0
  39. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/google_auth.py +0 -0
  40. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/google_credentials.py +0 -0
  41. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/integrations/__init__.py +0 -0
  42. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/integrations/httpx.py +0 -0
  43. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/policyengine_observability/otel.py +0 -0
  44. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/conftest.py +0 -0
  45. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_adapters.py +0 -0
  46. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_delivery.py +0 -0
  47. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_destination_strategies.py +0 -0
  48. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_diagnostics_public_api.py +0 -0
  49. {policyengine_observability-3.0.0 → policyengine_observability-3.0.2}/tests/test_google_credentials.py +0 -0
  50. {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.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, serialize the bounded correlation context with the
350
- job request and restore it around the worker operation:
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
- request.observability_context = runtime.capture_context()
359
+ observability_context = runtime.capture_context()
360
+ worker.spawn(
361
+ payload,
362
+ observability_context=observability_context,
363
+ )
354
364
 
355
- with runtime.operation(
356
- "simulation.run",
357
- remote_context=request.observability_context,
358
- ):
359
- return run_simulation(request)
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, serialize the bounded correlation context with the
304
- job request and restore it around the worker operation:
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
- request.observability_context = runtime.capture_context()
313
+ observability_context = runtime.capture_context()
314
+ worker.spawn(
315
+ payload,
316
+ observability_context=observability_context,
317
+ )
308
318
 
309
- with runtime.operation(
310
- "simulation.run",
311
- remote_context=request.observability_context,
312
- ):
313
- return run_simulation(request)
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
- if not isinstance(values, frozenset):
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.application_attribute_keys
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.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" }]
@@ -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 test_attribute_count_and_string_length_are_bounded() -> None:
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=frozenset({"one", "two", "three"}),
448
- limits=TelemetryLimits(max_attributes=2, max_string_length=3),
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
- {"one": "abcdef", "two": 2, "three": 3}, config
472
+ values,
473
+ config,
452
474
  )
453
- assert safe == {"one": "abc", "two": 2}
454
- assert omitted == 1
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(
@@ -895,7 +895,7 @@ wheels = [
895
895
 
896
896
  [[package]]
897
897
  name = "policyengine-observability"
898
- version = "3.0.0"
898
+ version = "3.0.2"
899
899
  source = { editable = "." }
900
900
 
901
901
  [package.optional-dependencies]