diff --git a/README.md b/README.md index e45e46c..5589c9b 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,30 @@ harness-instrument python app.py library listed in `skip_libraries` is never instrumented even if its category is enabled. +### Enrich the current span + +Add runtime attributes while the target instrumented span is active: + +```python +from harness_sdk import set_span_attribute, set_span_attributes + +set_span_attribute("request.client.name", client_name) +set_span_attributes({ + "agent.action.type": action_type, + "custom.retry.count": retry_count, +}) +``` + +These helpers update only the current recording span. They silently do nothing when no +recording span is active. Attributes do not propagate to child spans or downstream +services. + +Keys may use any customer-defined name. Values must be valid OpenTelemetry attribute +values: strings, booleans, integers, floats, or homogeneous sequences of those types. +For duplicate keys, the last write wins; instrumentation can overwrite a customer value +if it writes the same key later. Because the span already exists, these attributes +cannot influence head sampling. + ## Plugins The SDK loads extensions via [setuptools entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html). Each plugin has a **name** (the entry-point key). Names are listed in config or environment variables; only installed plugins are loaded, in the order you configure. diff --git a/src/harness_sdk/__init__.py b/src/harness_sdk/__init__.py index 530459a..97bf80f 100644 --- a/src/harness_sdk/__init__.py +++ b/src/harness_sdk/__init__.py @@ -1,5 +1,6 @@ """Harness Python SDK — generic instrumentation and plugin architecture.""" from harness_sdk.agent import Agent +from harness_sdk.span_enrichment import set_span_attribute, set_span_attributes -__all__ = ["Agent"] +__all__ = ["Agent", "set_span_attribute", "set_span_attributes"] diff --git a/src/harness_sdk/span_enrichment.py b/src/harness_sdk/span_enrichment.py new file mode 100644 index 0000000..ae79761 --- /dev/null +++ b/src/harness_sdk/span_enrichment.py @@ -0,0 +1,22 @@ +"""Public helpers for enriching the current OpenTelemetry span.""" + +from typing import Mapping + +from opentelemetry import trace +from opentelemetry.util.types import AttributeValue + + +def set_span_attribute(key: str, value: AttributeValue) -> None: + """Set one attribute on the current recording span.""" + span = trace.get_current_span() + if span.is_recording(): + span.set_attribute(key, value) + + +def set_span_attributes(attributes: Mapping[str, AttributeValue]) -> None: + """Set attributes on the current recording span.""" + span = trace.get_current_span() + if not span.is_recording(): + return + for key, value in attributes.items(): + span.set_attribute(key, value) diff --git a/test/span_enrichment_test.py b/test/span_enrichment_test.py new file mode 100644 index 0000000..530328c --- /dev/null +++ b/test/span_enrichment_test.py @@ -0,0 +1,75 @@ +import asyncio + +from opentelemetry.sdk.trace import TracerProvider + +from harness_sdk import set_span_attribute, set_span_attributes + + +def _tracer(): + return TracerProvider().get_tracer(__name__) + + +def test_set_span_attribute_updates_current_recording_span(): + with _tracer().start_as_current_span("parent") as span: + set_span_attribute("request.client.name", "acme") + + assert span.attributes["request.client.name"] == "acme" + + +def test_set_span_attributes_preserves_supported_types(): + with _tracer().start_as_current_span("parent") as span: + set_span_attributes({ + "string": "value", + "boolean": True, + "integer": 7, + "float": 1.5, + "sequence": ("a", "b"), + }) + + assert dict(span.attributes) == { + "string": "value", + "boolean": True, + "integer": 7, + "float": 1.5, + "sequence": ("a", "b"), + } + + +def test_last_write_wins(): + with _tracer().start_as_current_span("parent") as span: + set_span_attribute("custom.key", "first") + set_span_attribute("custom.key", "second") + + assert span.attributes["custom.key"] == "second" + + +def test_no_active_recording_span_is_noop(): + assert set_span_attribute("custom.key", "value") is None + assert set_span_attributes({"custom.key": "value"}) is None + + +def test_empty_mapping_is_noop(): + with _tracer().start_as_current_span("parent") as span: + assert set_span_attributes({}) is None + assert not span.attributes + + +def test_attributes_do_not_inherit_to_child_span(): + tracer = _tracer() + with tracer.start_as_current_span("parent") as parent: + set_span_attribute("custom.key", "parent") + + with tracer.start_as_current_span("child") as child: + assert "custom.key" not in child.attributes + + assert parent.attributes["custom.key"] == "parent" + + +def test_async_code_updates_active_span(): + async def enrich(): + set_span_attribute("agent.action.type", "search") + + with _tracer().start_as_current_span("parent") as span: + asyncio.run(enrich()) + + assert span.attributes["agent.action.type"] == "search"