Skip to content

Commit c0c8e05

Browse files
Add experimental labeler in otel context
1 parent 26ae9ca commit c0c8e05

File tree

4 files changed

+582
-0
lines changed

4 files changed

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

0 commit comments

Comments
 (0)