Skip to content

Commit ab9a180

Browse files
authored
[Core] Tracing updates (#39563)
This introduces native OpenTelemetry tracing to Azure Core. Signed-off-by: Paul Van Eck <paulvaneck@microsoft.com>
1 parent b4a508b commit ab9a180

25 files changed

+2281
-527
lines changed

Diff for: .vscode/cspell.json

+1
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@
354354
"onmicrosoft",
355355
"openai",
356356
"OPENAI",
357+
"otel",
357358
"otlp",
358359
"OTLP",
359360
"owasp",

Diff for: sdk/core/azure-core/CHANGELOG.md

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
11
# Release History
22

3-
## 1.32.1 (Unreleased)
3+
## 1.33.0 (Unreleased)
44

55
### Features Added
66

7+
- Added native OpenTelemetry tracing to Azure Core which enables users to use OpenTelemetry to trace Azure SDK operations without needing to install a plugin. #39563
8+
- To enable native OpenTelemetry tracing, users need to:
9+
1. Have `opentelemetry-api` installed.
10+
2. Ensure that `settings.tracing_implementation` is not set.
11+
3. Ensure that `settings.tracing_enabled` is set to `True`.
12+
- If `setting.tracing_implementation` is set, the tracing plugin will be used instead of the native tracing.
13+
- If `settings.tracing_enabled` is set to `False`, tracing will be disabled.
14+
- The `OpenTelemetryTracer` class was added to the `azure.core.tracing.opentelemetry` module. This is a wrapper around the OpenTelemetry tracer that is used to create spans for Azure SDK operations.
15+
- Added a `get_tracer` method to the new `azure.core.instrumentation` module. This method returns an instance of the `OpenTelemetryTracer` class if OpenTelemetry is available.
16+
- A `TracingOptions` TypedDict class was added to define the options that SDK users can use to configure tracing per-operation. These options include the ability to enable or disable tracing and set additional attributes on spans.
17+
- Example usage: `client.method(tracing_options={"enabled": True, "attributes": {"foo": "bar"}})`
18+
- The `DistributedTracingPolicy` and `distributed_trace`/`distributed_trace_async` decorators now uses the OpenTelemetry tracer if it is available and native tracing is enabled.
19+
- SDK clients can define an `_instrumentation_config` class variable to configure the OpenTelemetry tracer used in method span creation. Possible configuration options are `library_name`, `library_version`, `schema_url`, and `attributes`.
20+
- `DistributedTracingPolicy` now accepts a `instrumentation_config` keyword argument to configure the OpenTelemetry tracer used in HTTP span creation.
21+
722
### Breaking Changes
823

24+
- Removed automatic tracing enablement for the OpenTelemetry plugin if `opentelemetry` was imported. To enable tracing with the plugin, please import `azure.core.settings.settings` and set `settings.tracing_implementation` to `"opentelemetry"`. #39563
25+
926
### Bugs Fixed
1027

1128
### Other Changes
1229

30+
- Added `opentelemetry-api` as an optional dependency for tracing. This can be installed with `pip install azure-core[tracing]`. #39563
31+
1332
## 1.32.0 (2024-10-31)
1433

1534
### Features Added

Diff for: sdk/core/azure-core/azure/core/_version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@
99
# regenerated.
1010
# --------------------------------------------------------------------------
1111

12-
VERSION = "1.32.1"
12+
VERSION = "1.33.0"

Diff for: sdk/core/azure-core/azure/core/instrumentation.py

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ------------------------------------
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT License.
4+
# ------------------------------------
5+
from typing import Optional, Union, Mapping, TYPE_CHECKING
6+
from functools import lru_cache
7+
8+
if TYPE_CHECKING:
9+
from .tracing.opentelemetry import OpenTelemetryTracer
10+
11+
12+
def _get_tracer_impl():
13+
# Check if OpenTelemetry is available/installed.
14+
try:
15+
from .tracing.opentelemetry import OpenTelemetryTracer
16+
17+
return OpenTelemetryTracer
18+
except ImportError:
19+
return None
20+
21+
22+
@lru_cache
23+
def _get_tracer_cached(
24+
library_name: Optional[str],
25+
library_version: Optional[str],
26+
schema_url: Optional[str],
27+
attributes_key: Optional[frozenset],
28+
) -> Optional["OpenTelemetryTracer"]:
29+
tracer_impl = _get_tracer_impl()
30+
if tracer_impl:
31+
# Convert attributes_key back to dict if needed
32+
attributes = dict(attributes_key) if attributes_key else None
33+
return tracer_impl(
34+
library_name=library_name,
35+
library_version=library_version,
36+
schema_url=schema_url,
37+
attributes=attributes,
38+
)
39+
return None
40+
41+
42+
def get_tracer(
43+
*,
44+
library_name: Optional[str] = None,
45+
library_version: Optional[str] = None,
46+
schema_url: Optional[str] = None,
47+
attributes: Optional[Mapping[str, Union[str, bool, int, float]]] = None,
48+
) -> Optional["OpenTelemetryTracer"]:
49+
"""Get the OpenTelemetry tracer instance if available.
50+
51+
If OpenTelemetry is not available, this method will return None. This method caches
52+
the tracer instance for each unique set of parameters.
53+
54+
:keyword library_name: The name of the library to use in the tracer.
55+
:paramtype library_name: str
56+
:keyword library_version: The version of the library to use in the tracer.
57+
:paramtype library_version: str
58+
:keyword schema_url: Specifies the Schema URL of the emitted spans. Defaults to
59+
"https://opentelemetry.io/schemas/1.23.1".
60+
:paramtype schema_url: str
61+
:keyword attributes: Attributes to add to the emitted spans.
62+
:paramtype attributes: Mapping[str, Union[str, bool, int, float]]
63+
:return: The OpenTelemetry tracer instance if available.
64+
:rtype: Optional[~azure.core.tracing.opentelemetry.OpenTelemetryTracer]
65+
"""
66+
attributes_key = frozenset(attributes.items()) if attributes else None
67+
return _get_tracer_cached(library_name, library_version, schema_url, attributes_key)

Diff for: sdk/core/azure-core/azure/core/pipeline/_base.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353

5454
def cleanup_kwargs_for_transport(kwargs: Dict[str, str]) -> None:
5555
"""Remove kwargs that are not meant for the transport layer.
56+
5657
:param kwargs: The keyword arguments.
5758
:type kwargs: dict
5859
@@ -62,8 +63,9 @@ def cleanup_kwargs_for_transport(kwargs: Dict[str, str]) -> None:
6263
to the transport layer. This code is needed to handle the case that the
6364
SensitiveHeaderCleanupPolicy is not added into the pipeline and "insecure_domain_change" is not popped.
6465
"enable_cae" is added to the `get_token` method of the `TokenCredential` protocol.
66+
"tracing_options" is used in the DistributedTracingPolicy and tracing decorators.
6567
"""
66-
kwargs_to_remove = ["insecure_domain_change", "enable_cae"]
68+
kwargs_to_remove = ["insecure_domain_change", "enable_cae", "tracing_options"]
6769
if not kwargs:
6870
return
6971
for key in kwargs_to_remove:

Diff for: sdk/core/azure-core/azure/core/pipeline/policies/_distributed_tracing.py

+146-25
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
import logging
2828
import sys
2929
import urllib.parse
30-
from typing import TYPE_CHECKING, Optional, Tuple, TypeVar, Union, Any, Type
30+
from typing import TYPE_CHECKING, Optional, Tuple, TypeVar, Union, Any, Type, Mapping, Dict
3131
from types import TracebackType
3232

3333
from azure.core.pipeline import PipelineRequest, PipelineResponse
@@ -39,11 +39,11 @@
3939
from azure.core.rest import HttpResponse, HttpRequest
4040
from azure.core.settings import settings
4141
from azure.core.tracing import SpanKind
42+
from azure.core.instrumentation import get_tracer
43+
from azure.core.tracing._models import TracingOptions
4244

4345
if TYPE_CHECKING:
44-
from azure.core.tracing._abstract_span import (
45-
AbstractSpan,
46-
)
46+
from opentelemetry.trace import Span
4747

4848
HTTPResponseType = TypeVar("HTTPResponseType", HttpResponse, LegacyHttpResponse)
4949
HTTPRequestType = TypeVar("HTTPRequestType", HttpRequest, LegacyHttpRequest)
@@ -74,37 +74,91 @@ class DistributedTracingPolicy(SansIOHTTPPolicy[HTTPRequestType, HTTPResponseTyp
7474
:type network_span_namer: callable[[~azure.core.pipeline.transport.HttpRequest], str]
7575
:keyword tracing_attributes: Attributes to set on all created spans
7676
:type tracing_attributes: dict[str, str]
77+
:keyword instrumentation_config: Configuration for the instrumentation providers
78+
:type instrumentation_config: dict[str, Any]
7779
"""
7880

7981
TRACING_CONTEXT = "TRACING_CONTEXT"
82+
_SUPPRESSION_TOKEN = "SUPPRESSION_TOKEN"
83+
84+
# Current stable HTTP semantic conventions
85+
_HTTP_RESEND_COUNT = "http.request.resend_count"
86+
_USER_AGENT_ORIGINAL = "user_agent.original"
87+
_HTTP_REQUEST_METHOD = "http.request.method"
88+
_URL_FULL = "url.full"
89+
_HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"
90+
_SERVER_ADDRESS = "server.address"
91+
_SERVER_PORT = "server.port"
92+
_ERROR_TYPE = "error.type"
93+
94+
# Azure attributes
8095
_REQUEST_ID = "x-ms-client-request-id"
8196
_RESPONSE_ID = "x-ms-request-id"
82-
_HTTP_RESEND_COUNT = "http.request.resend_count"
8397

84-
def __init__(self, **kwargs: Any):
98+
def __init__(self, *, instrumentation_config: Optional[Mapping[str, Any]] = None, **kwargs: Any):
8599
self._network_span_namer = kwargs.get("network_span_namer", _default_network_span_namer)
86100
self._tracing_attributes = kwargs.get("tracing_attributes", {})
101+
self._instrumentation_config = instrumentation_config
87102

88103
def on_request(self, request: PipelineRequest[HTTPRequestType]) -> None:
89104
ctxt = request.context.options
90105
try:
91-
span_impl_type = settings.tracing_implementation()
92-
if span_impl_type is None:
106+
tracing_options: TracingOptions = ctxt.pop("tracing_options", {})
107+
tracing_enabled = settings.tracing_enabled()
108+
109+
# User can explicitly disable tracing for this request.
110+
user_enabled = tracing_options.get("enabled")
111+
if user_enabled is False:
112+
return
113+
114+
# If tracing is disabled globally and user didn't explicitly enable it, don't trace.
115+
if not tracing_enabled and user_enabled is None:
93116
return
94117

118+
span_impl_type = settings.tracing_implementation()
95119
namer = ctxt.pop("network_span_namer", self._network_span_namer)
96120
tracing_attributes = ctxt.pop("tracing_attributes", self._tracing_attributes)
97121
span_name = namer(request.http_request)
98122

99-
span = span_impl_type(name=span_name, kind=SpanKind.CLIENT)
100-
for attr, value in tracing_attributes.items():
101-
span.add_attribute(attr, value)
102-
span.start()
123+
span_attributes = {**tracing_attributes, **tracing_options.get("attributes", {})}
103124

104-
headers = span.to_header()
105-
request.http_request.headers.update(headers)
125+
if span_impl_type:
126+
# If the plugin is enabled, prioritize it over the core tracing.
127+
span = span_impl_type(name=span_name, kind=SpanKind.CLIENT)
128+
for attr, value in span_attributes.items():
129+
span.add_attribute(attr, value) # type: ignore
130+
131+
headers = span.to_header()
132+
request.http_request.headers.update(headers)
133+
request.context[self.TRACING_CONTEXT] = span
134+
else:
135+
# Otherwise, use the core tracing.
136+
config = self._instrumentation_config or {}
137+
tracer = get_tracer(
138+
library_name=config.get("library_name"),
139+
library_version=config.get("library_version"),
140+
attributes=config.get("attributes"),
141+
)
142+
if not tracer:
143+
_LOGGER.warning(
144+
"Tracing is enabled, but not able to get an OpenTelemetry tracer. "
145+
"Please ensure that `opentelemetry-api` is installed."
146+
)
147+
return
148+
149+
otel_span = tracer.start_span(
150+
name=span_name,
151+
kind=SpanKind.CLIENT,
152+
attributes=span_attributes,
153+
)
154+
155+
trace_context_headers = tracer.get_trace_context()
156+
request.http_request.headers.update(trace_context_headers)
157+
request.context[self.TRACING_CONTEXT] = otel_span
158+
159+
token = tracer._suppress_auto_http_instrumentation() # pylint: disable=protected-access
160+
request.context[self._SUPPRESSION_TOKEN] = token
106161

107-
request.context[self.TRACING_CONTEXT] = span
108162
except Exception as err: # pylint: disable=broad-except
109163
_LOGGER.warning("Unable to start network span: %s", err)
110164

@@ -126,21 +180,52 @@ def end_span(
126180
if self.TRACING_CONTEXT not in request.context:
127181
return
128182

129-
span: "AbstractSpan" = request.context[self.TRACING_CONTEXT]
183+
span = request.context[self.TRACING_CONTEXT]
184+
if not span:
185+
return
186+
130187
http_request: Union[HttpRequest, LegacyHttpRequest] = request.http_request
131-
if span is not None:
132-
span.set_http_attributes(http_request, response=response)
133-
if request.context.get("retry_count"):
134-
span.add_attribute(self._HTTP_RESEND_COUNT, request.context["retry_count"])
135-
request_id = http_request.headers.get(self._REQUEST_ID)
136-
if request_id is not None:
137-
span.add_attribute(self._REQUEST_ID, request_id)
138-
if response and self._RESPONSE_ID in response.headers:
139-
span.add_attribute(self._RESPONSE_ID, response.headers[self._RESPONSE_ID])
188+
189+
attributes: Dict[str, Any] = {}
190+
if request.context.get("retry_count"):
191+
attributes[self._HTTP_RESEND_COUNT] = request.context["retry_count"]
192+
if http_request.headers.get(self._REQUEST_ID):
193+
attributes[self._REQUEST_ID] = http_request.headers[self._REQUEST_ID]
194+
if response and self._RESPONSE_ID in response.headers:
195+
attributes[self._RESPONSE_ID] = response.headers[self._RESPONSE_ID]
196+
197+
# We'll determine if the span is from a plugin or the core tracing library based on the presence of the
198+
# `set_http_attributes` method.
199+
if hasattr(span, "set_http_attributes"):
200+
# Plugin-based tracing
201+
span.set_http_attributes(request=http_request, response=response)
202+
for key, value in attributes.items():
203+
span.add_attribute(key, value)
140204
if exc_info:
141205
span.__exit__(*exc_info)
142206
else:
143207
span.finish()
208+
else:
209+
# Native tracing
210+
self._set_http_client_span_attributes(span, request=http_request, response=response)
211+
span.set_attributes(attributes)
212+
if exc_info:
213+
# If there was an exception, set the error.type attribute.
214+
exception_type = exc_info[0]
215+
if exception_type:
216+
module = exception_type.__module__ if exception_type.__module__ != "builtins" else ""
217+
error_type = f"{module}.{exception_type.__qualname__}" if module else exception_type.__qualname__
218+
span.set_attribute(self._ERROR_TYPE, error_type)
219+
220+
span.__exit__(*exc_info)
221+
else:
222+
span.end()
223+
224+
suppression_token = request.context.get(self._SUPPRESSION_TOKEN)
225+
if suppression_token:
226+
tracer = get_tracer()
227+
if tracer:
228+
tracer._detach_from_context(suppression_token) # pylint: disable=protected-access
144229

145230
def on_response(
146231
self,
@@ -151,3 +236,39 @@ def on_response(
151236

152237
def on_exception(self, request: PipelineRequest[HTTPRequestType]) -> None:
153238
self.end_span(request, exc_info=sys.exc_info())
239+
240+
def _set_http_client_span_attributes(
241+
self,
242+
span: "Span",
243+
request: Union[HttpRequest, LegacyHttpRequest],
244+
response: Optional[HTTPResponseType] = None,
245+
) -> None:
246+
"""Add attributes to an HTTP client span.
247+
248+
:param span: The span to add attributes to.
249+
:type span: ~opentelemetry.trace.Span
250+
:param request: The request made
251+
:type request: ~azure.core.rest.HttpRequest
252+
:param response: The response received from the server. Is None if no response received.
253+
:type response: ~azure.core.rest.HTTPResponse or ~azure.core.pipeline.transport.HttpResponse
254+
"""
255+
attributes: Dict[str, Any] = {
256+
self._HTTP_REQUEST_METHOD: request.method,
257+
self._URL_FULL: request.url,
258+
}
259+
260+
parsed_url = urllib.parse.urlparse(request.url)
261+
if parsed_url.hostname:
262+
attributes[self._SERVER_ADDRESS] = parsed_url.hostname
263+
if parsed_url.port:
264+
attributes[self._SERVER_PORT] = parsed_url.port
265+
266+
user_agent = request.headers.get("User-Agent")
267+
if user_agent:
268+
attributes[self._USER_AGENT_ORIGINAL] = user_agent
269+
if response and response.status_code:
270+
attributes[self._HTTP_RESPONSE_STATUS_CODE] = response.status_code
271+
if response.status_code >= 400:
272+
attributes[self._ERROR_TYPE] = str(response.status_code)
273+
274+
span.set_attributes(attributes)

0 commit comments

Comments
 (0)