From f8a4de9af2377472e344a3e8cf8fa4b1436947ed Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:16:08 +0400 Subject: [PATCH 1/4] Preserve asynchronous dispatch context --- changelog.d/async-context.fixed.md | 1 + policyengine_observability/runtime.py | 37 ++++++++++++++ tests/test_runtime.py | 72 +++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 changelog.d/async-context.fixed.md diff --git a/changelog.d/async-context.fixed.md b/changelog.d/async-context.fixed.md new file mode 100644 index 0000000..4e95414 --- /dev/null +++ b/changelog.d/async-context.fixed.md @@ -0,0 +1 @@ +Preserve configured dispatch attributes across asynchronous operations and include them in correlated structured logs. diff --git a/policyengine_observability/runtime.py b/policyengine_observability/runtime.py index 1b1d55c..d94f158 100644 --- a/policyengine_observability/runtime.py +++ b/policyengine_observability/runtime.py @@ -519,6 +519,20 @@ def _start_operation( request_id: str | None = None if remote_context: request_id = _valid_request_id(remote_context.get("request_id")) + remote_attributes, remote_omitted = normalize_attributes( + { + key: remote_context.get(key) + for key in self.config.dispatch_attribute_keys + if key in remote_context + }, + self.config, + allowed_keys=self.config.dispatch_attribute_keys, + ) + if remote_omitted: + self.diagnostics.increment( + "attributes.omitted", remote_omitted + ) + safe = {**remote_attributes, **safe} carrier = { key: str(value) for key, value in remote_context.items() @@ -661,6 +675,20 @@ def _active_context_fields(self) -> dict[str, Any]: fields.update(self._otel.current_correlation()) return fields + def _active_dispatch_attributes(self) -> dict[str, Any]: + attributes: dict[str, Any] = {} + request = self._request_state.get() + operation = self._operation_state.get() + if request is not None: + attributes.update(request.attributes) + if operation is not None: + attributes.update(operation.attributes) + return { + key: attributes[key] + for key in self.config.dispatch_attribute_keys + if key in attributes + } + def _metric_base(self) -> dict[str, Any]: return { "service.name": self.config.service.name, @@ -673,6 +701,15 @@ def _metric_base(self) -> dict[str, Any]: def _emit_record(self, **kwargs: Any) -> None: try: + supplied_attributes = kwargs.get("attributes") + kwargs["attributes"] = { + **( + dict(supplied_attributes) + if supplied_attributes is not None + else {} + ), + **self._active_dispatch_attributes(), + } self._delivery.emit(build_record(self.config, **kwargs)) except Exception as exc: self.diagnostics.report("record.emit", exc) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 49db755..e354b23 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -196,6 +196,78 @@ def test_set_context_and_capture_context_are_allowlisted(runtime) -> None: observed.end_request(status_code=202) +def test_remote_operation_restores_dispatch_context() -> None: + dispatch_keys = frozenset({"job_id", "observability_id"}) + upstream, _upstream_output = make_runtime( + dispatch_attribute_keys=dispatch_keys + ) + downstream, downstream_output = make_runtime( + dispatch_attribute_keys=dispatch_keys + ) + upstream.begin_request( + headers={REQUEST_ID_HEADER: "request-1"}, + method="POST", + route="/simulation", + ) + upstream.set_context( + job_id="job-1", + observability_id="00000000-0000-4000-8000-000000000001", + ) + + remote_context = upstream.capture_context() + with downstream.operation("simulation.run", remote_context=remote_context): + assert downstream.capture_context()["job_id"] == "job-1" + downstream.log("running") + + emitted = records(downstream_output) + assert emitted[0]["attributes"] == { + "job_id": "job-1", + "observability_id": "00000000-0000-4000-8000-000000000001", + } + assert emitted[1]["attributes"] == emitted[0]["attributes"] + upstream.end_request(status_code=202) + upstream.shutdown() + downstream.shutdown() + + +def test_local_operation_attributes_override_remote_dispatch_values() -> None: + observed, output = make_runtime( + dispatch_attribute_keys=frozenset({"job_id"}) + ) + + with observed.operation( + "simulation.run", + attributes={"job_id": "local-job"}, + remote_context={ + "captured_at": "not-a-date", + "job_id": "remote-job", + "unapproved": "must-not-appear", + }, + ): + observed.event("simulation.started") + + emitted = records(output) + assert emitted[0]["attributes"] == {"job_id": "local-job"} + assert emitted[1]["attributes"] == {"job_id": "local-job"} + assert observed.capture_context().get("unapproved") is None + observed.shutdown() + + +def test_malformed_remote_context_does_not_change_application_result() -> None: + observed, _output = make_runtime() + + @observed.operation( + "simulation.run", + remote_context=["not", "a", "mapping"], # type: ignore[arg-type] + ) + def calculate() -> int: + return 42 + + assert calculate() == 42 + assert observed.diagnostics.count("failure.operation.start") == 1 + observed.shutdown() + + def test_two_runtimes_keep_identity_and_context_separate() -> None: first, first_output = make_runtime() second_config = make_config( From bae6bdd51feed0a2d818e99013bc537f776b7961 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:16:11 +0400 Subject: [PATCH 2/4] Document asynchronous context propagation --- README.md | 35 ++++++++++++++----- .../engineering/skills/repository-guidance.md | 13 +++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4aa7d87..eb9a085 100644 --- a/README.md +++ b/README.md @@ -300,19 +300,38 @@ instrument_httpx(client, runtime) The request hook injects active W3C context and the PolicyEngine request ID. Other clients in the process remain unchanged. -For asynchronous dispatch, serialize the bounded correlation context with the -job request and restore it around the worker operation: +For asynchronous dispatch, send the bounded observability context as transport +metadata beside the application payload and restore it around the worker +operation: ```python -request.observability_context = runtime.capture_context() +observability_context = runtime.capture_context() +worker.spawn( + payload, + observability_context=observability_context, +) -with runtime.operation( - "simulation.run", - remote_context=request.observability_context, -): - return run_simulation(request) +def worker(payload, *, observability_context=None): + with runtime.operation( + "simulation.run", + remote_context=observability_context, + ): + return run_simulation(payload) ``` +`capture_context()` includes W3C trace context, its capture time, the active +PolicyEngine request ID, and scalar attributes named by +`dispatch_attribute_keys`. Starting the remote operation restores only those +configured dispatch attributes. They remain available to nested +`capture_context()` calls and are attached to logs and spans inside the +operation. They are never added to metric labels unless separately included in +`metric_attribute_keys`. + +Keep this context separate from the application payload. Invalid or stale +trace context can reduce correlation, but it does not prevent the observed +application code from running. A recent direct dispatch continues the trace; +delayed, retry, and aggregate work starts a trace linked to the dispatch span. + ## Process restoration and shutdown After a process image or memory snapshot is restored, rebuild process-local diff --git a/docs/engineering/skills/repository-guidance.md b/docs/engineering/skills/repository-guidance.md index f17df7c..37933c6 100644 --- a/docs/engineering/skills/repository-guidance.md +++ b/docs/engineering/skills/repository-guidance.md @@ -62,6 +62,16 @@ uv run --extra dev towncrier check --compare-with origin/main without breaking the application operation being observed. - Preserve structured log schemas. Make additive changes when possible; bump schema versions for breaking payload changes. +- Treat `capture_context()` output as transport metadata beside an application + payload. Pass it to the receiver's outer `operation` through + `remote_context`; do not insert it into business request models. +- Restore only attributes explicitly listed in `dispatch_attribute_keys`. + Those attributes must remain available to nested dispatches and structured + logs, but must not become metric labels unless independently allowlisted in + `metric_attribute_keys`. +- Let explicitly supplied receiver attributes override matching remote + attributes. Malformed remote context may reduce telemetry but must not stop + the observed operation. - Keep metric attributes bounded and low-cardinality. Do not put raw paths, full URLs, request bodies, or unbounded user-provided values into metric labels. @@ -73,6 +83,9 @@ uv run --extra dev towncrier check --compare-with origin/main Add focused tests for context behavior and failure paths whenever changing the runtime or its components. The corresponding `tests/test_runtime_*.py` modules cover operations, requests, spans, log emission, and tracing. +Remote-context tests must exercise two runtime instances and prove that +configured dispatch attributes survive capture, restoration, logs, spans, and +a subsequent capture. Include malformed context and local-override cases. Adapter changes should include framework-level tests that exercise request setup, response headers, error paths, and teardown behavior. From e6633cd4bfa6a3544c109b2810f5ec73df73df1a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:19:06 +0400 Subject: [PATCH 3/4] Propagate correlation across nested spans --- README.md | 6 +-- changelog.d/async-context.fixed.md | 2 +- .../engineering/skills/repository-guidance.md | 4 +- policyengine_observability/config.py | 2 - policyengine_observability/runtime.py | 1 + policyengine_observability/schema.py | 1 - tests/test_config_schema.py | 17 ++++---- tests/test_runtime.py | 40 +++++++++++++++++++ 8 files changed, 56 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index eb9a085..aff0bff 100644 --- a/README.md +++ b/README.md @@ -323,9 +323,9 @@ def worker(payload, *, observability_context=None): PolicyEngine request ID, and scalar attributes named by `dispatch_attribute_keys`. Starting the remote operation restores only those configured dispatch attributes. They remain available to nested -`capture_context()` calls and are attached to logs and spans inside the -operation. They are never added to metric labels unless separately included in -`metric_attribute_keys`. +`capture_context()` calls and are attached to logs and every nested span inside +the operation. They are never added to metric labels unless separately +included in `metric_attribute_keys`. Keep this context separate from the application payload. Invalid or stale trace context can reduce correlation, but it does not prevent the observed diff --git a/changelog.d/async-context.fixed.md b/changelog.d/async-context.fixed.md index 4e95414..9bb44a9 100644 --- a/changelog.d/async-context.fixed.md +++ b/changelog.d/async-context.fixed.md @@ -1 +1 @@ -Preserve configured dispatch attributes across asynchronous operations and include them in correlated structured logs. +Preserve configured dispatch attributes across asynchronous operations, include them in correlated structured logs and nested spans, and remove the attribute-count limit. diff --git a/docs/engineering/skills/repository-guidance.md b/docs/engineering/skills/repository-guidance.md index 37933c6..6cdd835 100644 --- a/docs/engineering/skills/repository-guidance.md +++ b/docs/engineering/skills/repository-guidance.md @@ -67,8 +67,8 @@ uv run --extra dev towncrier check --compare-with origin/main `remote_context`; do not insert it into business request models. - Restore only attributes explicitly listed in `dispatch_attribute_keys`. Those attributes must remain available to nested dispatches and structured - logs, but must not become metric labels unless independently allowlisted in - `metric_attribute_keys`. + logs and must be attached to nested spans, but must not become metric labels + unless independently allowlisted in `metric_attribute_keys`. - Let explicitly supplied receiver attributes override matching remote attributes. Malformed remote context may reduce telemetry but must not stop the observed operation. diff --git a/policyengine_observability/config.py b/policyengine_observability/config.py index 6bada68..35c9141 100644 --- a/policyengine_observability/config.py +++ b/policyengine_observability/config.py @@ -106,7 +106,6 @@ class OTelConfig: @dataclass(frozen=True, slots=True) class TelemetryLimits: - max_attributes: int = 32 max_string_length: int = 1_024 max_error_message_length: int = 2_048 max_stack_length: int = 16_384 @@ -421,7 +420,6 @@ def validation_errors(self) -> tuple[str, ...]: ) for name, value in { - "limits.max_attributes": self.limits.max_attributes, "limits.max_string_length": self.limits.max_string_length, "limits.max_error_message_length": self.limits.max_error_message_length, "limits.max_stack_length": self.limits.max_stack_length, diff --git a/policyengine_observability/runtime.py b/policyengine_observability/runtime.py index d94f158..840912a 100644 --- a/policyengine_observability/runtime.py +++ b/policyengine_observability/runtime.py @@ -636,6 +636,7 @@ def _start_child_span( ) if omitted: self.diagnostics.increment("attributes.omitted", omitted) + safe = {**safe, **self._active_dispatch_attributes()} return _ChildSpanState( name=name, start_time=time.perf_counter(), diff --git a/policyengine_observability/schema.py b/policyengine_observability/schema.py index 7133b1c..44fb230 100644 --- a/policyengine_observability/schema.py +++ b/policyengine_observability/schema.py @@ -43,7 +43,6 @@ def normalize_attributes( key = str(raw_key).strip() if ( not key - or len(normalized) >= config.limits.max_attributes or _prohibited_key(key) or (allowed_keys is not None and key not in allowed_keys) ): diff --git a/tests/test_config_schema.py b/tests/test_config_schema.py index 5a5c699..784cb88 100644 --- a/tests/test_config_schema.py +++ b/tests/test_config_schema.py @@ -97,7 +97,6 @@ def test_invalid_nested_limits_are_reported_together() -> None: shutdown_timeout_seconds=100, ), limits=TelemetryLimits( - max_attributes=0, max_string_length=0, max_error_message_length=0, max_stack_length=0, @@ -120,7 +119,6 @@ def test_invalid_nested_limits_are_reported_together() -> None: "otel.traces.protocol", "otel.traces.endpoint_mode", "otel.traces.timeout_seconds", - "limits.max_attributes", "limits.async_parent_max_age_seconds", ): assert field in message @@ -442,16 +440,19 @@ def test_attribute_policy_omits_sensitive_non_scalar_and_nonfinite() -> None: assert omitted == 3 -def test_attribute_count_and_string_length_are_bounded() -> None: +def test_attribute_count_is_unbounded_and_strings_are_truncated() -> None: + keys = frozenset(f"attribute_{index}" for index in range(40)) config = make_config( - application_attribute_keys=frozenset({"one", "two", "three"}), - limits=TelemetryLimits(max_attributes=2, max_string_length=3), + application_attribute_keys=keys, + limits=TelemetryLimits(max_string_length=3), ) + values = {key: "abcdef" for key in keys} safe, omitted = normalize_attributes( - {"one": "abcdef", "two": 2, "three": 3}, config + values, + config, ) - assert safe == {"one": "abc", "two": 2} - assert omitted == 1 + assert safe == {key: "abc" for key in keys} + assert omitted == 0 def test_google_trace_correlation_is_not_in_canonical_record() -> None: diff --git a/tests/test_runtime.py b/tests/test_runtime.py index e354b23..993877a 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -230,6 +230,46 @@ def test_remote_operation_restores_dispatch_context() -> None: downstream.shutdown() +def test_nested_span_inherits_active_dispatch_attributes(monkeypatch) -> None: + observability_id = "00000000-0000-4000-8000-000000000001" + observed, _output = make_runtime( + application_attribute_keys=frozenset({"backend"}), + dispatch_attribute_keys=frozenset({"observability_id"}), + ) + child_span_attributes = [] + + with observed.operation( + "simulation.run", + remote_context={ + "captured_at": "not-a-date", + "observability_id": observability_id, + }, + ): + monkeypatch.setattr( + observed._otel, + "start_span", + lambda _name, **kwargs: child_span_attributes.append( + kwargs["attributes"] + ), + ) + with observed.span( + "simulation.calculate", + attributes={ + "backend": "modal", + "observability_id": "00000000-0000-4000-8000-000000000099", + }, + ): + pass + + assert child_span_attributes == [ + { + "backend": "modal", + "observability_id": observability_id, + } + ] + observed.shutdown() + + def test_local_operation_attributes_override_remote_dispatch_values() -> None: observed, output = make_runtime( dispatch_attribute_keys=frozenset({"job_id"}) From ee6c0bd14c08b023ecf233278ebb1e1cfcb4a08c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:38:30 +0400 Subject: [PATCH 4/4] Accept safe application attributes by default --- README.md | 7 ++- changelog.d/async-context.fixed.md | 2 +- .../engineering/skills/repository-guidance.md | 4 ++ policyengine_observability/config.py | 50 ++++++++++++------- policyengine_observability/runtime.py | 15 ++---- policyengine_observability/schema.py | 3 +- tests/test_config_schema.py | 21 ++++++++ tests/test_otel.py | 36 +++++++++++++ 8 files changed, 103 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index aff0bff..235141d 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,17 @@ config = ObservabilityConfig( traces=OTLPExporterConfig(endpoint="collector:4317"), metrics=OTLPExporterConfig(endpoint="collector:4317"), ), - application_attribute_keys=frozenset({"country_id", "backend"}), dispatch_attribute_keys=frozenset({"job_id", "run_id"}), ) runtime = configure(config) ``` +Local logs and spans accept explicitly supplied safe scalar attributes by +default. Set `application_attribute_keys` to a `frozenset` only when a consumer +needs a strict local attribute allowlist. Asynchronous context still transports +only `dispatch_attribute_keys`, and metrics still use their separate +low-cardinality allowlist. + `configure` validates the complete configuration before it creates workers, exporters, or logging handlers. Invalid values raise `ConfigurationError` with the fields that must be corrected. Unavailable credentials or destinations diff --git a/changelog.d/async-context.fixed.md b/changelog.d/async-context.fixed.md index 9bb44a9..c2ce254 100644 --- a/changelog.d/async-context.fixed.md +++ b/changelog.d/async-context.fixed.md @@ -1 +1 @@ -Preserve configured dispatch attributes across asynchronous operations, include them in correlated structured logs and nested spans, and remove the attribute-count limit. +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. diff --git a/docs/engineering/skills/repository-guidance.md b/docs/engineering/skills/repository-guidance.md index 6cdd835..9a84f1b 100644 --- a/docs/engineering/skills/repository-guidance.md +++ b/docs/engineering/skills/repository-guidance.md @@ -69,6 +69,10 @@ uv run --extra dev towncrier check --compare-with origin/main Those attributes must remain available to nested dispatches and structured logs and must be attached to nested spans, but must not become metric labels unless independently allowlisted in `metric_attribute_keys`. +- Accept explicitly supplied safe scalar attributes in local logs and spans by + default. Use `application_attribute_keys` only when a consumer requires a + strict local allowlist. Do not use that optional local policy to decide what + crosses a process boundary or becomes a metric label. - Let explicitly supplied receiver attributes override matching remote attributes. Malformed remote context may reduce telemetry but must not stop the observed operation. diff --git a/policyengine_observability/config.py b/policyengine_observability/config.py index 35c9141..530ed6e 100644 --- a/policyengine_observability/config.py +++ b/policyengine_observability/config.py @@ -22,8 +22,6 @@ def __init__(self, errors: tuple[str, ...]) -> None: super().__init__(f"Invalid observability configuration:\n{details}") -DEFAULT_APPLICATION_ATTRIBUTE_KEYS = frozenset(set()) - DEFAULT_DISPATCH_ATTRIBUTE_KEYS = frozenset(set()) DEFAULT_METRIC_ATTRIBUTE_KEYS = frozenset( @@ -119,9 +117,7 @@ class ObservabilityConfig: logging: LoggingConfig = field(default_factory=LoggingConfig) otel: OTelConfig = field(default_factory=OTelConfig) limits: TelemetryLimits = field(default_factory=TelemetryLimits) - application_attribute_keys: frozenset[str] = ( - DEFAULT_APPLICATION_ATTRIBUTE_KEYS - ) + application_attribute_keys: frozenset[str] | None = None dispatch_attribute_keys: frozenset[str] = DEFAULT_DISPATCH_ATTRIBUTE_KEYS metric_attribute_keys: frozenset[str] = DEFAULT_METRIC_ATTRIBUTE_KEYS sensitive_values: tuple[str, ...] = () @@ -223,11 +219,7 @@ def from_env( ), ), limits=limits or TelemetryLimits(), - application_attribute_keys=( - application_attribute_keys - if application_attribute_keys is not None - else DEFAULT_APPLICATION_ATTRIBUTE_KEYS - ), + application_attribute_keys=application_attribute_keys, dispatch_attribute_keys=( dispatch_attribute_keys if dispatch_attribute_keys is not None @@ -277,19 +269,18 @@ def validation_errors(self) -> tuple[str, ...]: "string." ) + if self.application_attribute_keys is not None: + _attribute_key_errors( + errors, + "application_attribute_keys", + self.application_attribute_keys, + ) + for name, values in ( - ("application_attribute_keys", self.application_attribute_keys), ("dispatch_attribute_keys", self.dispatch_attribute_keys), ("metric_attribute_keys", self.metric_attribute_keys), ): - if not isinstance(values, frozenset): - errors.append( - f"{name} must be a frozenset of non-empty strings." - ) - continue - for value in values: - if not isinstance(value, str) or not value.strip(): - errors.append(f"{name} entries must be non-empty strings.") + _attribute_key_errors(errors, name, values) _choice_error( errors, @@ -434,6 +425,14 @@ def validation_errors(self) -> tuple[str, ...]: ) return tuple(errors) + @property + def local_attribute_keys(self) -> frozenset[str] | None: + """Return the optional strict allowlist for local logs and spans.""" + + if self.application_attribute_keys is None: + return None + return self.application_attribute_keys | self.dispatch_attribute_keys + def diagnostics(self) -> tuple[str, ...]: messages: list[str] = [] if ( @@ -621,6 +620,19 @@ def _env_int( return parsed +def _attribute_key_errors( + errors: list[str], + name: str, + values: object, +) -> None: + if not isinstance(values, frozenset): + errors.append(f"{name} must be a frozenset of non-empty strings.") + return + for value in values: + if not isinstance(value, str) or not value.strip(): + errors.append(f"{name} entries must be non-empty strings.") + + def _choice_error( errors: list[str], name: str, value: Any, choices: set[str] ) -> None: diff --git a/policyengine_observability/runtime.py b/policyengine_observability/runtime.py index 840912a..684c9b1 100644 --- a/policyengine_observability/runtime.py +++ b/policyengine_observability/runtime.py @@ -317,10 +317,7 @@ def set_context(self, **attributes: Any) -> None: safe, omitted = normalize_attributes( attributes, self.config, - allowed_keys=( - self.config.application_attribute_keys - | self.config.dispatch_attribute_keys - ), + allowed_keys=self.config.local_attribute_keys, ) request = self._request_state.get() operation = self._operation_state.get() @@ -507,10 +504,7 @@ def _start_operation( safe, omitted = normalize_attributes( attributes, self.config, - allowed_keys=( - self.config.application_attribute_keys - | self.config.dispatch_attribute_keys - ), + allowed_keys=self.config.local_attribute_keys, ) if omitted: self.diagnostics.increment("attributes.omitted", omitted) @@ -629,10 +623,7 @@ def _start_child_span( safe, omitted = normalize_attributes( attributes, self.config, - allowed_keys=( - self.config.application_attribute_keys - | self.config.dispatch_attribute_keys - ), + allowed_keys=self.config.local_attribute_keys, ) if omitted: self.diagnostics.increment("attributes.omitted", omitted) diff --git a/policyengine_observability/schema.py b/policyengine_observability/schema.py index 44fb230..b845152 100644 --- a/policyengine_observability/schema.py +++ b/policyengine_observability/schema.py @@ -99,8 +99,7 @@ def build_record( safe_attributes, omitted = normalize_attributes( attributes, config, - allowed_keys=config.application_attribute_keys - | config.dispatch_attribute_keys, + allowed_keys=config.local_attribute_keys, ) if safe_attributes: record["attributes"] = safe_attributes diff --git a/tests/test_config_schema.py b/tests/test_config_schema.py index 784cb88..72d9c67 100644 --- a/tests/test_config_schema.py +++ b/tests/test_config_schema.py @@ -255,6 +255,7 @@ def test_from_env_reads_transport_but_not_identity(monkeypatch) -> None: assert config.otel.span_batch_size == 99 assert config.otel.span_schedule_delay_seconds == 2.5 assert config.otel.metric_export_interval_seconds == 4.0 + assert config.application_attribute_keys is None def test_from_env_marks_signal_specific_endpoints_as_exact( @@ -375,6 +376,26 @@ def test_schema_preserves_core_fields_and_namespaces_attributes() -> None: json.dumps(record) +def test_default_application_policy_accepts_safe_scalar_attributes() -> None: + config = make_config(application_attribute_keys=None) + record = build_record( + config, + severity="INFO", + attributes={ + "new_runtime_detail": "available", + "attempt": 3, + "authorization": "prohibited", + "structured": {"not": "scalar"}, + }, + ) + + assert record["attributes"] == { + "new_runtime_detail": "available", + "attempt": 3, + } + assert record["attributes.omitted_count"] == 2 + + def test_schema_redacts_and_truncates_errors() -> None: config = make_config( application_attribute_keys=frozenset({"backend"}), diff --git a/tests/test_otel.py b/tests/test_otel.py index 3c249b1..3cbb037 100644 --- a/tests/test_otel.py +++ b/tests/test_otel.py @@ -72,6 +72,42 @@ def test_owned_provider_creates_local_spans_metrics_and_resources() -> None: runtime.shutdown() +def test_default_policy_records_safe_application_attributes() -> None: + config = make_config( + otel=OTelConfig(enabled=True), + application_attribute_keys=None, + ) + runtime = configure(config) + runtime._delivery._stdout = io.StringIO() + exporter = InMemorySpanExporter() + runtime._otel._tracer_provider.add_span_processor( + SimpleSpanProcessor(exporter) + ) + + with runtime.operation( + "simulation.run", + attributes={ + "new_runtime_detail": "available", + "authorization": "prohibited", + }, + ): + with runtime.span( + "simulation.calculate", + attributes={"partition_count": 12}, + ): + pass + + spans = {span.name: span for span in exporter.get_finished_spans()} + assert ( + spans["simulation.run"].attributes["new_runtime_detail"] == "available" + ) + assert "authorization" not in spans["simulation.run"].attributes + assert spans["simulation.calculate"].attributes["partition_count"] == 12 + item = records(runtime._delivery._stdout)[0] + assert item["attributes"] == {"new_runtime_detail": "available"} + runtime.shutdown() + + def test_sensitive_values_are_redacted_from_spans_and_logs() -> None: config = make_config( otel=OTelConfig(enabled=True),