-
Notifications
You must be signed in to change notification settings - Fork 3
feat: flatten dict values passed to set_span_attribute #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| """Thread-safe hand-off of dict span attributes from the hot path to span end. | ||
|
|
||
| ``set_span_attribute("agent", {...})`` cannot go through | ||
| ``span.set_attribute``: OTel rejects mapping values outright. Serializing on | ||
| the caller's thread would put JSON encoding on the application hot path, so | ||
| dict values are parked here untouched and flattened later by | ||
| ``FlattenDictSpanProcessor.on_end``. | ||
|
|
||
| Entries are keyed by span context rather than object identity because | ||
| ``Span.end()`` hands ``on_end`` a fresh ``ReadableSpan`` snapshot, not the | ||
| recording ``Span`` the enrichment helper saw. | ||
| """ | ||
| import threading | ||
| from collections import OrderedDict | ||
|
|
||
| from harness_sdk.custom_logger import get_custom_logger | ||
| from harness_sdk.env import get_env_value, is_env_flag_enabled | ||
|
|
||
| logger = get_custom_logger(__name__) | ||
|
|
||
| FLATTEN_ENABLED_ENV = "SPAN_ATTRIBUTE_FLATTEN_ENABLED" | ||
| FLATTEN_RAW_JSON_ENV = "SPAN_ATTRIBUTE_FLATTEN_RAW_JSON" | ||
|
|
||
| # Bound on spans holding pending dicts. A span that is never ended would | ||
| # otherwise leak its entry forever; oldest entries are evicted instead. | ||
| _MAX_TRACKED_SPANS = 2048 | ||
|
|
||
|
|
||
| def is_flatten_enabled(): | ||
| """Dict flattening is on by default; only explicit ``false`` disables it.""" | ||
| value = get_env_value(FLATTEN_ENABLED_ENV) | ||
| if value is None: | ||
| return True | ||
| return value.strip().lower() != "false" | ||
|
|
||
|
|
||
| def is_raw_json_enabled(): | ||
| """Opt in to additionally keeping the original key as a JSON string.""" | ||
| return is_env_flag_enabled(FLATTEN_RAW_JSON_ENV) | ||
|
|
||
|
|
||
| def _span_key(span): | ||
| get_context = getattr(span, "get_span_context", None) | ||
| if get_context is None: | ||
| return None | ||
| context = get_context() | ||
| if context is None or not context.trace_id: | ||
| return None | ||
| return (context.trace_id, context.span_id) | ||
|
|
||
|
|
||
| class FlattenDictRegistry: | ||
| """Maps span identity to the dict attributes awaiting flattening.""" | ||
|
|
||
| def __init__(self, max_tracked_spans=_MAX_TRACKED_SPANS): | ||
| self._lock = threading.Lock() | ||
| self._pending = OrderedDict() | ||
| self._max_tracked_spans = max_tracked_spans | ||
|
|
||
| def register(self, span, key, value): | ||
| """Park ``value`` under ``key`` for ``span``; last write wins.""" | ||
| span_key = _span_key(span) | ||
| if span_key is None: | ||
| return | ||
| with self._lock: | ||
| attributes = self._pending.get(span_key) | ||
| if attributes is None: | ||
| attributes = OrderedDict() | ||
| self._pending[span_key] = attributes | ||
| attributes[key] = value | ||
| self._pending.move_to_end(span_key) | ||
| while len(self._pending) > self._max_tracked_spans: | ||
| evicted, _ = self._pending.popitem(last=False) | ||
| logger.debug( | ||
| "Flatten: evicted pending dict attributes for span %s " | ||
| "(registry limit %s reached)", | ||
| evicted, | ||
| self._max_tracked_spans, | ||
| ) | ||
|
|
||
| def pop(self, span): | ||
| """Remove and return the pending dict attributes for ``span``.""" | ||
| span_key = _span_key(span) | ||
| if span_key is None: | ||
| return {} | ||
| with self._lock: | ||
| return self._pending.pop(span_key, {}) | ||
|
|
||
| def clear(self): | ||
| with self._lock: | ||
| self._pending.clear() | ||
|
|
||
|
|
||
| _REGISTRY = FlattenDictRegistry() | ||
|
|
||
|
|
||
| def get_registry(): | ||
| return _REGISTRY |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| """Span processor that expands dict span attributes into dot-notation keys. | ||
|
|
||
| ``set_span_attribute("agent", {"action": "generate"})`` parks the dict in | ||
| ``flatten_dict_registry`` instead of handing it to OTel, which would reject it. | ||
| This processor drains the registry at ``on_end`` and writes | ||
| ``agent.action=generate`` so the backend gets individually queryable | ||
| attributes instead of an opaque JSON blob. | ||
|
|
||
| It must be the outermost processor: downstream scrubbing and exclusion logic | ||
| matches on attribute keys, so the flattened keys have to exist before those | ||
| run. Mutating the ended span works the same way ``GenAiPayloadScrubSpanProcessor`` | ||
| relies on: ``ReadableSpan.attributes`` is a read-only view over the attribute | ||
| store the concrete SDK ``Span`` still owns at ``on_end`` time. | ||
| """ | ||
| import json | ||
| from typing import Mapping | ||
|
|
||
| from opentelemetry.sdk.trace import SpanProcessor | ||
|
|
||
| from harness_sdk.custom_logger import get_custom_logger | ||
| from harness_sdk.flatten_dict_registry import ( | ||
| get_registry, | ||
| is_raw_json_enabled, | ||
| ) | ||
|
|
||
| logger = get_custom_logger(__name__) | ||
|
|
||
| MAX_DEPTH = 3 | ||
| MAX_LEAF_ATTRIBUTES = 32 | ||
|
|
||
| _SCALAR_TYPES = (bool, int, float, str) | ||
|
|
||
|
|
||
| def _is_scalar(value): | ||
| return isinstance(value, _SCALAR_TYPES) | ||
|
|
||
|
|
||
| def _scalar_kind(value): | ||
| # bool is a subclass of int, but OTel treats them as distinct array types. | ||
| if isinstance(value, bool): | ||
| return bool | ||
| if isinstance(value, int): | ||
| return int | ||
| if isinstance(value, float): | ||
| return float | ||
| return str | ||
|
|
||
|
|
||
| def _to_json(value): | ||
| try: | ||
| return json.dumps(value, default=str) | ||
| except (TypeError, ValueError): | ||
| return str(value) | ||
|
|
||
|
|
||
| def _sequence_leaf(value): | ||
| """Homogeneous scalar sequences stay arrays; anything else becomes JSON.""" | ||
| items = tuple(value) | ||
| if not items: | ||
| return items | ||
| kinds = {_scalar_kind(item) for item in items if _is_scalar(item)} | ||
| if len(kinds) == 1 and all(_is_scalar(item) for item in items): | ||
| return items | ||
| return _to_json(value) | ||
|
|
||
|
|
||
| def _leaf_value(value): | ||
| """Convert a non-mapping value to something OTel accepts, or None to skip.""" | ||
| if value is None: | ||
| return None | ||
| if _is_scalar(value): | ||
| return value | ||
| if isinstance(value, (list, tuple, set, frozenset)): | ||
| return _sequence_leaf(value) | ||
| return str(value) | ||
|
|
||
|
|
||
| def _collect(prefix, mapping, depth, flattened): | ||
| """Walk ``mapping`` into ``flattened``; returns False once the cap is hit.""" | ||
| for key, value in mapping.items(): | ||
| if len(flattened) >= MAX_LEAF_ATTRIBUTES: | ||
| return False | ||
| flat_key = f"{prefix}.{key}" | ||
| if isinstance(value, Mapping): | ||
| if depth < MAX_DEPTH: | ||
| if not _collect(flat_key, value, depth + 1, flattened): | ||
| return False | ||
| else: | ||
| flattened[flat_key] = _to_json(value) | ||
| continue | ||
| leaf = _leaf_value(value) | ||
| if leaf is not None: | ||
| flattened[flat_key] = leaf | ||
| return True | ||
|
|
||
|
|
||
| class FlattenDictSpanProcessor(SpanProcessor): | ||
| """Flattens registered dict attributes onto the span before export.""" | ||
|
|
||
| def __init__(self, processor): | ||
| self._processor = processor | ||
|
|
||
| def on_start(self, span, parent_context=None): | ||
| self._processor.on_start(span, parent_context) | ||
|
|
||
| def on_end(self, span): | ||
| pending = get_registry().pop(span) | ||
| if pending: | ||
| try: | ||
| self._flatten(span, pending) | ||
| except Exception as err: # pylint: disable=W0703 | ||
| logger.debug( | ||
| "Flatten: failed to flatten dict attributes on span %s: %s", | ||
| getattr(span, "name", None), | ||
| err, | ||
| ) | ||
| self._processor.on_end(span) | ||
|
|
||
| @staticmethod | ||
| def _flatten(span, pending): | ||
| attributes = getattr(span, "_attributes", None) | ||
| if attributes is None: | ||
| return | ||
| raw_json = is_raw_json_enabled() | ||
| for root_key, value in pending.items(): | ||
| flattened = {} | ||
| if not _collect(root_key, value, 1, flattened): | ||
| logger.debug( | ||
| "Flatten: dict attribute %r on span %s exceeded %s leaf " | ||
| "attributes; remaining entries dropped", | ||
| root_key, | ||
| getattr(span, "name", None), | ||
| MAX_LEAF_ATTRIBUTES, | ||
| ) | ||
| for flat_key, leaf in flattened.items(): | ||
| # An explicitly set attribute always wins over a flattened one. | ||
| if flat_key in attributes: | ||
| continue | ||
| attributes[flat_key] = leaf | ||
| if raw_json and root_key not in attributes: | ||
| attributes[root_key] = _to_json(value) | ||
|
|
||
| def force_flush(self, timeout_millis=30000): | ||
| return self._processor.force_flush(timeout_millis) | ||
|
|
||
| def shutdown(self): | ||
| return self._processor.shutdown() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,35 @@ | ||
| """Public helpers for enriching the current OpenTelemetry span.""" | ||
|
|
||
| from typing import Mapping | ||
| from typing import Any, Mapping, Union | ||
|
|
||
| from opentelemetry import trace | ||
| from opentelemetry.util.types import AttributeValue | ||
|
|
||
| from harness_sdk.flatten_dict_registry import get_registry, is_flatten_enabled | ||
|
|
||
| def set_span_attribute(key: str, value: AttributeValue) -> None: | ||
| EnrichmentValue = Union[AttributeValue, Mapping[str, Any]] | ||
|
|
||
|
|
||
| def set_span_attribute(key: str, value: EnrichmentValue) -> None: | ||
| """Set one attribute on the current recording span.""" | ||
| span = trace.get_current_span() | ||
| if span.is_recording(): | ||
| span.set_attribute(key, value) | ||
| _set(span, key, value) | ||
|
|
||
|
|
||
| def set_span_attributes(attributes: Mapping[str, AttributeValue]) -> None: | ||
| def set_span_attributes(attributes: Mapping[str, EnrichmentValue]) -> 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) | ||
| _set(span, key, value) | ||
|
|
||
|
|
||
| def _set(span, key: str, value: EnrichmentValue) -> None: | ||
| # Dict values are parked for FlattenDictSpanProcessor to expand into | ||
| # dot-notation keys at span end; nothing is serialized on this thread. | ||
| if isinstance(value, Mapping) and is_flatten_enabled(): | ||
| get_registry().register(span, key, value) | ||
| return | ||
| span.set_attribute(key, value) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this should be configurable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense, updated