From 6eaa9e51ea3585ab767ad81ddf8141c88c42569d Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Sun, 23 Aug 2026 19:29:43 +0330 Subject: [PATCH 1/2] test(sdk): cover Resource carrying a bytes attribute value `bytes` is a valid attribute value type, and every OTLP encoder groups telemetry by using the Resource as a dict key. Assert that such a Resource stays hashable, usable as a dict key and serialisable, and that the span, metric and log export paths all survive it. These tests fail with "TypeError: Object of type bytes is not JSON serializable" against the current implementation. --- .../test_resource_bytes_attributes.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 opentelemetry-sdk/tests/resources/test_resource_bytes_attributes.py diff --git a/opentelemetry-sdk/tests/resources/test_resource_bytes_attributes.py b/opentelemetry-sdk/tests/resources/test_resource_bytes_attributes.py new file mode 100644 index 00000000000..dfc10f56ce1 --- /dev/null +++ b/opentelemetry-sdk/tests/resources/test_resource_bytes_attributes.py @@ -0,0 +1,95 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""`bytes` is a valid attribute value type, so a Resource carrying one must +stay hashable and serialisable. + +Every OTLP encoder groups telemetry by using the Resource as a dict key, so a +Resource that cannot be hashed takes the whole export path down with it. +""" + +import json +import unittest + +from opentelemetry.exporter.otlp.proto.common._log_encoder import encode_logs +from opentelemetry.exporter.otlp.proto.common.metrics_encoder import ( + encode_metrics, +) +from opentelemetry.exporter.otlp.proto.common.trace_encoder import encode_spans +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import ( + InMemoryLogExporter, + SimpleLogRecordProcessor, +) +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +_BYTES_RESOURCE = {"service.name": "svc", "build.id": b"\x01\x02\x03"} + + +class TestResourceWithBytesAttribute(unittest.TestCase): + def setUp(self): + self.resource = Resource.create(_BYTES_RESOURCE) + + def test_resource_is_hashable(self): + hash(self.resource) + + def test_resource_usable_as_dict_key(self): + """OTLP encoders group telemetry by Resource identity.""" + self.assertEqual({self.resource: "value"}[self.resource], "value") + + def test_resource_to_json_is_valid_json(self): + payload = json.loads(self.resource.to_json()) + self.assertEqual(payload["attributes"]["build.id"], "010203") + + def test_equal_resources_hash_equally(self): + self.assertEqual(hash(self.resource), hash(Resource.create(dict(_BYTES_RESOURCE)))) + + def test_differing_bytes_hash_differently(self): + other = Resource.create({"service.name": "svc", "build.id": b"\xff"}) + self.assertNotEqual(hash(self.resource), hash(other)) + + def test_bytes_and_equivalent_string_are_distinct(self): + """The hash fallback must not make b"\\x01\\x02\\x03" collide with "010203".""" + as_text = Resource.create({"service.name": "svc", "build.id": "010203"}) + self.assertNotEqual(self.resource, as_text) + + +class TestBytesResourceExports(unittest.TestCase): + """The full export path for each signal must survive a bytes resource.""" + + def setUp(self): + self.resource = Resource.create(_BYTES_RESOURCE) + + def test_encode_spans(self): + exporter = InMemorySpanExporter() + provider = TracerProvider(resource=self.resource, shutdown_on_exit=False) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + with provider.get_tracer(__name__).start_as_current_span("span"): + pass + request = encode_spans(exporter.get_finished_spans()) + self.assertEqual(len(request.resource_spans), 1) + provider.shutdown() + + def test_encode_metrics(self): + reader = InMemoryMetricReader() + provider = MeterProvider(metric_readers=[reader], resource=self.resource) + provider.get_meter(__name__).create_counter("counter").add(1) + request = encode_metrics(reader.get_metrics_data()) + self.assertEqual(len(request.resource_metrics), 1) + provider.shutdown() + + def test_encode_logs(self): + exporter = InMemoryLogExporter() + provider = LoggerProvider(resource=self.resource, shutdown_on_exit=False) + provider.add_log_record_processor(SimpleLogRecordProcessor(exporter)) + provider.get_logger(__name__).emit(body="body") + request = encode_logs(exporter.get_finished_logs()) + self.assertEqual(len(request.resource_logs), 1) + provider.shutdown() From 44efc773800f998a76a3c9a3379a30bd32514b9f Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Sun, 23 Aug 2026 19:29:43 +0330 Subject: [PATCH 2/2] fix(sdk): keep Resource hashable when an attribute value is bytes `Resource.__hash__` and `to_json` serialise the attributes with `json.dumps`. `types.AnyValue` admits `bytes`, which `json.dumps` refuses, so hashing a Resource that carries any bytes attribute raises TypeError. Every OTLP encoder groups telemetry by using the Resource as a dictionary key, so the failure lands in the export path for all three signals. Under `BatchSpanProcessor` the exception is swallowed by the processor's broad exception handling, turning it into a permanent silent export failure rather than a visible crash. Give both call sites a `default` that renders bytes as hex. `__eq__` still distinguishes b"\x01" from the string "01", so the resulting hash collision is harmless, and `to_json` stays valid JSON. --- .changelog/5563.fixed | 1 + .../src/opentelemetry/sdk/resources/__init__.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .changelog/5563.fixed diff --git a/.changelog/5563.fixed b/.changelog/5563.fixed new file mode 100644 index 00000000000..18de1cb4bfa --- /dev/null +++ b/.changelog/5563.fixed @@ -0,0 +1 @@ +`opentelemetry-sdk`: keep `Resource` hashable and serialisable when an attribute value is `bytes`, instead of raising `TypeError` from every OTLP encoder diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py index 36aa826c022..50b81efcd25 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py @@ -253,7 +253,7 @@ def __eq__(self, other: object) -> bool: return self._attributes == other._attributes and self._schema_url == other._schema_url def __hash__(self) -> int: - return hash(f"{dumps(self._attributes.copy(), sort_keys=True)}|{self._schema_url}") + return hash(f"{dumps(self._attributes.copy(), sort_keys=True, default=_json_default)}|{self._schema_url}") def to_json(self, indent: int | None = 4) -> str: return dumps( @@ -262,9 +262,23 @@ def to_json(self, indent: int | None = 4) -> str: "schema_url": self._schema_url, }, indent=indent, + default=_json_default, ) +def _json_default(value: object) -> str: + """Represent attribute values that `json` cannot encode natively. + + `types.AnyValue` admits `bytes`, which `json.dumps` rejects. Rendering it + as hex keeps `to_json` readable and keeps `__hash__` working; `__eq__` + still distinguishes `b"\x01"` from the string `"01"`, so the resulting + hash collision is harmless. + """ + if isinstance(value, (bytes, bytearray)): + return value.hex() + return str(value) + + _EMPTY_RESOURCE = Resource({}) _DEFAULT_RESOURCE = Resource(