policyengine-observability 3.0.1__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.1 → policyengine_observability-3.0.2}/CHANGELOG.md +7 -0
  2. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/PKG-INFO +31 -4
  3. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/README.md +30 -3
  4. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/docs/engineering/skills/repository-guidance.md +2 -2
  5. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/__init__.py +2 -0
  6. policyengine_observability-3.0.2/policyengine_observability/identity.py +36 -0
  7. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/runtime.py +1 -0
  8. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/pyproject.toml +1 -1
  9. policyengine_observability-3.0.2/tests/test_identity.py +38 -0
  10. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/tests/test_runtime.py +56 -0
  11. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/uv.lock +1 -1
  12. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/.github/bump_version.py +0 -0
  13. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/.github/check-changelog.sh +0 -0
  14. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/.github/copilot-instructions.md +0 -0
  15. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/.github/fetch_version.py +0 -0
  16. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/.github/get-changelog-diff.sh +0 -0
  17. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/.github/publish-git-tag.sh +0 -0
  18. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/.github/workflows/pr.yml +0 -0
  19. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/.github/workflows/push.yml +0 -0
  20. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/.gitignore +0 -0
  21. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/AGENTS.md +0 -0
  22. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/CLAUDE.md +0 -0
  23. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/LICENSE +0 -0
  24. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/docs/engineering/skills/README.md +0 -0
  25. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/docs/engineering/skills/github-prs.md +0 -0
  26. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/adapters/__init__.py +0 -0
  27. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/adapters/fastapi.py +0 -0
  28. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/adapters/flask.py +0 -0
  29. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/config.py +0 -0
  30. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/delivery.py +0 -0
  31. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/destinations/__init__.py +0 -0
  32. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/destinations/base.py +0 -0
  33. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/destinations/google_cloud.py +0 -0
  34. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/destinations/stdout.py +0 -0
  35. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/diagnostics.py +0 -0
  36. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/google_auth.py +0 -0
  37. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/google_credentials.py +0 -0
  38. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/integrations/__init__.py +0 -0
  39. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/integrations/httpx.py +0 -0
  40. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/otel.py +0 -0
  41. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/policyengine_observability/schema.py +0 -0
  42. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/tests/conftest.py +0 -0
  43. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/tests/test_adapters.py +0 -0
  44. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/tests/test_config_schema.py +0 -0
  45. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/tests/test_delivery.py +0 -0
  46. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/tests/test_destination_strategies.py +0 -0
  47. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/tests/test_diagnostics_public_api.py +0 -0
  48. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/tests/test_google_credentials.py +0 -0
  49. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/tests/test_otel.py +0 -0
  50. {policyengine_observability-3.0.1 → policyengine_observability-3.0.2}/tests/test_otel_destinations.py +0 -0
@@ -1,3 +1,10 @@
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
+
1
8
  ## [3.0.1] - 2026-09-28
2
9
 
3
10
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: policyengine-observability
3
- Version: 3.0.1
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
@@ -374,15 +374,42 @@ def worker(payload, *, observability_context=None):
374
374
  PolicyEngine request ID, and scalar attributes named by
375
375
  `dispatch_attribute_keys`. Starting the remote operation restores only those
376
376
  configured dispatch attributes. They remain available to nested
377
- `capture_context()` calls and are attached to logs and every nested span inside
378
- the operation. They are never added to metric labels unless separately
379
- included in `metric_attribute_keys`.
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
380
 
381
381
  Keep this context separate from the application payload. Invalid or stale
382
382
  trace context can reduce correlation, but it does not prevent the observed
383
383
  application code from running. A recent direct dispatch continues the trace;
384
384
  delayed, retry, and aggregate work starts a trace linked to the dispatch span.
385
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
+ )
406
+ ```
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
+
386
413
  ## Process restoration and shutdown
387
414
 
388
415
  After a process image or memory snapshot is restored, rebuild process-local
@@ -328,15 +328,42 @@ def worker(payload, *, observability_context=None):
328
328
  PolicyEngine request ID, and scalar attributes named by
329
329
  `dispatch_attribute_keys`. Starting the remote operation restores only those
330
330
  configured dispatch attributes. They remain available to nested
331
- `capture_context()` calls and are attached to logs and every nested span inside
332
- the operation. They are never added to metric labels unless separately
333
- included in `metric_attribute_keys`.
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
334
 
335
335
  Keep this context separate from the application payload. Invalid or stale
336
336
  trace context can reduce correlation, but it does not prevent the observed
337
337
  application code from running. A recent direct dispatch continues the trace;
338
338
  delayed, retry, and aggregate work starts a trace linked to the dispatch span.
339
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
+ )
360
+ ```
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
+
340
367
  ## Process restoration and shutdown
341
368
 
342
369
  After a process image or memory snapshot is restored, rebuild process-local
@@ -67,8 +67,8 @@ uv run --extra dev towncrier check --compare-with origin/main
67
67
  `remote_context`; do not insert it into business request models.
68
68
  - Restore only attributes explicitly listed in `dispatch_attribute_keys`.
69
69
  Those attributes must remain available to nested dispatches and structured
70
- logs and must be attached to nested spans, but must not become metric labels
71
- unless independently allowlisted in `metric_attribute_keys`.
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
72
  - Accept explicitly supplied safe scalar attributes in local logs and spans by
73
73
  default. Use `application_attribute_keys` only when a consumer requires a
74
74
  strict local allowlist. Do not use that optional local policy to decide what
@@ -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
  ]
@@ -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)
@@ -548,6 +548,7 @@ class ObservabilityRuntime:
548
548
  if link is not None:
549
549
  links.append(link)
550
550
  parent = self._otel.empty_context()
551
+ safe = {**safe, **self._active_dispatch_attributes()}
551
552
  active_request = self._request_state.get()
552
553
  if request_id is None and active_request is not None:
553
554
  request_id = active_request.request_id
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "policyengine-observability"
3
- version = "3.0.1"
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" }]
@@ -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(" ")
@@ -270,6 +270,62 @@ def test_nested_span_inherits_active_dispatch_attributes(monkeypatch) -> None:
270
270
  observed.shutdown()
271
271
 
272
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
+
273
329
  def test_local_operation_attributes_override_remote_dispatch_values() -> None:
274
330
  observed, output = make_runtime(
275
331
  dispatch_attribute_keys=frozenset({"job_id"})
@@ -895,7 +895,7 @@ wheels = [
895
895
 
896
896
  [[package]]
897
897
  name = "policyengine-observability"
898
- version = "3.0.1"
898
+ version = "3.0.2"
899
899
  source = { editable = "." }
900
900
 
901
901
  [package.optional-dependencies]