Skip to content

Commit ba022a5

Browse files
(Redo) Add experimental Labeler to store custom attributes in OTel Context (#4288)
1 parent 605ffec commit ba022a5

5 files changed

Lines changed: 557 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6666
([#4049](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4049))
6767
- `opentelemetry-instrumentation-sqlalchemy`: implement new semantic convention opt-in migration
6868
([#4110](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4110))
69+
- `opentelemetry-instrumentation`: Add experimental metrics attributes Labeler utility
70+
([#4288](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4288))
6971

7072
### Fixed
7173

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""
5+
OpenTelemetry Labeler
6+
=====================
7+
8+
The labeler utility provides a way to add custom attributes to metrics.
9+
10+
This was inspired by OpenTelemetry Go's net/http instrumentation Labeler
11+
https://github.com/open-telemetry/opentelemetry-go-contrib/pull/306
12+
13+
Usage
14+
-----
15+
16+
The labeler is typically used within the context of an instrumented request
17+
or operation. Use ``get_labeler`` to obtain a labeler instance for the
18+
current context, then add attributes using the ``add`` or
19+
``add_attributes`` methods.
20+
21+
Example
22+
-------
23+
24+
Here's a framework-agnostic example showing manual use of the labeler:
25+
26+
.. code-block:: python
27+
28+
from opentelemetry.instrumentation._labeler import (
29+
enrich_metric_attributes,
30+
get_labeler,
31+
)
32+
from opentelemetry.metrics import get_meter
33+
34+
meter = get_meter("example.manual")
35+
duration_histogram = meter.create_histogram(
36+
name="http.server.request.duration",
37+
unit="s",
38+
description="Duration of HTTP server requests.",
39+
)
40+
41+
def record_request(user_id: str, duration_s: float) -> None:
42+
labeler = get_labeler()
43+
labeler.add("user_id", user_id)
44+
labeler.add_attributes(
45+
{
46+
"has_premium": user_id in ["123", "456"],
47+
"experiment_group": "control",
48+
"feature_enabled": True,
49+
"user_segment": "active",
50+
}
51+
)
52+
53+
base_attributes = {
54+
"http.request.method": "GET",
55+
"http.response.status_code": 200,
56+
}
57+
duration_histogram.record(
58+
max(duration_s, 0),
59+
enrich_metric_attributes(base_attributes),
60+
)
61+
62+
This package introduces the shared Labeler API and helper utilities.
63+
Framework-specific integration points that call
64+
``enrich_metric_attributes`` (for example before ``Histogram.record``)
65+
can be added by individual instrumentors.
66+
67+
When instrumentors use ``enrich_metric_attributes``, it does not
68+
overwrite base attributes that exist at the same keys.
69+
"""
70+
71+
from opentelemetry.instrumentation._labeler._internal import (
72+
Labeler,
73+
clear_labeler,
74+
enrich_metric_attributes,
75+
get_labeler,
76+
get_labeler_attributes,
77+
set_labeler,
78+
)
79+
80+
__all__ = [
81+
"Labeler",
82+
"get_labeler",
83+
"set_labeler",
84+
"clear_labeler",
85+
"get_labeler_attributes",
86+
"enrich_metric_attributes",
87+
]
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import logging
5+
import threading
6+
from types import MappingProxyType
7+
from typing import Any, Dict, Mapping, Optional, Union
8+
9+
from opentelemetry.context import attach, create_key, get_value, set_value
10+
from opentelemetry.util.types import AttributeValue
11+
12+
LABELER_CONTEXT_KEY = create_key("otel_labeler")
13+
14+
_logger = logging.getLogger(__name__)
15+
16+
17+
class Labeler:
18+
"""
19+
Stores custom attributes for the current OTel context.
20+
21+
This feature is experimental and unstable.
22+
"""
23+
24+
def __init__(
25+
self, max_custom_attrs: int = 20, max_attr_value_length: int = 100
26+
):
27+
"""
28+
Initialize a new Labeler instance.
29+
30+
Args:
31+
max_custom_attrs: Maximum number of custom attributes to store.
32+
When this limit is reached, new attributes will be ignored;
33+
existing attributes can still be updated.
34+
max_attr_value_length: Maximum length for string attribute values.
35+
String values exceeding this length will be truncated.
36+
"""
37+
self._lock = threading.Lock()
38+
self._attributes: dict[str, Union[str, int, float, bool]] = {}
39+
self._max_custom_attrs = max_custom_attrs
40+
self._max_attr_value_length = max_attr_value_length
41+
42+
def add(self, key: str, value: Any) -> None:
43+
"""
44+
Add a single attribute to the labeler, subject to the labeler's limits:
45+
- If max_custom_attrs limit is reached and this is a new key, the attribute is ignored
46+
- String values exceeding max_attr_value_length are truncated
47+
48+
Args:
49+
key: attribute key
50+
value: attribute value, must be a primitive type: str, int, float, or bool
51+
"""
52+
if not isinstance(value, (str, int, float, bool)):
53+
_logger.warning(
54+
"Skipping attribute '%s': value must be str, int, float, or bool, got %s",
55+
key,
56+
type(value).__name__,
57+
)
58+
return
59+
60+
with self._lock:
61+
if (
62+
len(self._attributes) >= self._max_custom_attrs
63+
and key not in self._attributes
64+
):
65+
return
66+
67+
if (
68+
isinstance(value, str)
69+
and len(value) > self._max_attr_value_length
70+
):
71+
value = value[: self._max_attr_value_length]
72+
73+
self._attributes[key] = value
74+
75+
def add_attributes(self, attributes: Dict[str, Any]) -> None:
76+
"""
77+
Add multiple attributes to the labeler, subject to the labeler's limits:
78+
- If max_custom_attrs limit is reached and this is a new key, the attribute is ignored
79+
- Existing attributes can still be updated
80+
- String values exceeding max_attr_value_length are truncated
81+
82+
Args:
83+
attributes: Dictionary of attributes to add. Values must be primitive types
84+
(str, int, float, or bool)
85+
"""
86+
with self._lock:
87+
for key, value in attributes.items():
88+
if not isinstance(value, (str, int, float, bool)):
89+
_logger.warning(
90+
"Skipping attribute '%s': value must be str, int, float, or bool, got %s",
91+
key,
92+
type(value).__name__,
93+
)
94+
continue
95+
96+
if (
97+
len(self._attributes) >= self._max_custom_attrs
98+
and key not in self._attributes
99+
):
100+
continue
101+
102+
if (
103+
isinstance(value, str)
104+
and len(value) > self._max_attr_value_length
105+
):
106+
value = value[: self._max_attr_value_length]
107+
108+
self._attributes[key] = value
109+
110+
def get_attributes(self) -> Mapping[str, Union[str, int, float, bool]]:
111+
"""
112+
Return a read-only mapping view of attributes in this labeler.
113+
"""
114+
with self._lock:
115+
return MappingProxyType(self._attributes)
116+
117+
def clear(self) -> None:
118+
with self._lock:
119+
self._attributes.clear()
120+
121+
def __len__(self) -> int:
122+
with self._lock:
123+
return len(self._attributes)
124+
125+
126+
def _attach_context_value(value: Optional[Labeler]) -> None:
127+
"""
128+
Attach a new OpenTelemetry context containing the given labeler value.
129+
130+
This helper is fail-safe: context attach errors are suppressed and
131+
logged at debug level.
132+
133+
Args:
134+
value: Labeler instance to store in context, or ``None`` to clear it.
135+
"""
136+
try:
137+
updated_context = set_value(LABELER_CONTEXT_KEY, value)
138+
attach(updated_context)
139+
except Exception: # pylint: disable=broad-exception-caught
140+
_logger.debug("Failed to attach labeler context", exc_info=True)
141+
142+
143+
def get_labeler() -> Labeler:
144+
"""
145+
Get the Labeler instance for the current OTel context.
146+
147+
If no Labeler exists in the current context, a new one is created
148+
and stored in the context.
149+
150+
Returns:
151+
Labeler instance for the current OTel context, or a new empty Labeler
152+
if no Labeler is currently stored in context.
153+
"""
154+
try:
155+
current_value = get_value(LABELER_CONTEXT_KEY)
156+
except Exception: # pylint: disable=broad-exception-caught
157+
_logger.debug("Failed to read labeler from context", exc_info=True)
158+
current_value = None
159+
160+
if isinstance(current_value, Labeler):
161+
return current_value
162+
163+
labeler = Labeler()
164+
_attach_context_value(labeler)
165+
return labeler
166+
167+
168+
def set_labeler(labeler: Any) -> None:
169+
"""
170+
Set the Labeler instance for the current OTel context.
171+
172+
Args:
173+
labeler: The Labeler instance to set
174+
"""
175+
if not isinstance(labeler, Labeler):
176+
_logger.warning(
177+
"Skipping set_labeler: value must be Labeler, got %s",
178+
type(labeler).__name__,
179+
)
180+
return
181+
_attach_context_value(labeler)
182+
183+
184+
def clear_labeler() -> None:
185+
"""
186+
Clear the Labeler instance from the current OTel context.
187+
188+
This is primarily intended for test isolation or manual context-lifecycle
189+
management. In typical framework-instrumented request handling,
190+
applications generally should not need to call this directly.
191+
"""
192+
_attach_context_value(None)
193+
194+
195+
def get_labeler_attributes() -> Mapping[str, Union[str, int, float, bool]]:
196+
"""
197+
Get attributes from the current labeler, if any.
198+
199+
Returns:
200+
Read-only mapping of custom attributes, or an empty read-only mapping
201+
if no labeler exists.
202+
"""
203+
empty_attributes: Dict[str, Union[str, int, float, bool]] = {}
204+
try:
205+
current_value = get_value(LABELER_CONTEXT_KEY)
206+
except Exception: # pylint: disable=broad-exception-caught
207+
_logger.debug(
208+
"Failed to read labeler attributes from context", exc_info=True
209+
)
210+
return MappingProxyType(empty_attributes)
211+
212+
if not isinstance(current_value, Labeler):
213+
return MappingProxyType(empty_attributes)
214+
return current_value.get_attributes()
215+
216+
217+
def enrich_metric_attributes(
218+
base_attributes: Dict[str, Any],
219+
enrich_enabled: bool = True,
220+
) -> Dict[str, AttributeValue]:
221+
"""
222+
Combines base_attributes with custom attributes from the current labeler,
223+
returning a new dictionary of attributes according to the labeler configuration:
224+
- Attributes that would override base_attributes are skipped
225+
- If max_custom_attrs limit is reached and this is a new key, the attribute is ignored
226+
- String values exceeding max_attr_value_length are truncated
227+
228+
Args:
229+
base_attributes: The base attributes for the metric
230+
enrich_enabled: Whether to include custom labeler attributes
231+
232+
Returns:
233+
Dictionary combining base and custom attributes. If no custom attributes,
234+
returns a copy of the original base attributes.
235+
"""
236+
if not enrich_enabled:
237+
return base_attributes.copy()
238+
239+
labeler_attributes = get_labeler_attributes()
240+
if not labeler_attributes:
241+
return base_attributes.copy()
242+
243+
try:
244+
labeler = get_value(LABELER_CONTEXT_KEY)
245+
except Exception: # pylint: disable=broad-exception-caught
246+
labeler = None
247+
248+
if not isinstance(labeler, Labeler):
249+
return base_attributes.copy()
250+
251+
enriched_attributes = base_attributes.copy()
252+
added_count = 0
253+
for key, value in labeler_attributes.items():
254+
if added_count >= labeler._max_custom_attrs:
255+
break
256+
if key in base_attributes:
257+
continue
258+
259+
if (
260+
isinstance(value, str)
261+
and len(value) > labeler._max_attr_value_length
262+
):
263+
value = value[: labeler._max_attr_value_length]
264+
265+
enriched_attributes[key] = value
266+
added_count += 1
267+
268+
return enriched_attributes

0 commit comments

Comments
 (0)