Skip to content
This repository was archived by the owner on Oct 14, 2024. It is now read-only.

fix(spans): adhere attribute name to otel semver #434

Merged
merged 3 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.3.4] - 2024-10-11

### Changed
- Updated HTTP span attributes to comply with updated OpenTelemetry semantic conventions. [#409](https://github.com/microsoft/kiota-http-python/issues/409)

## [1.3.3] - 2024-08-12

### Added
Expand Down
2 changes: 1 addition & 1 deletion kiota_http/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION: str = '1.3.3'
VERSION: str = "1.3.4"
30 changes: 18 additions & 12 deletions kiota_http/httpx_request_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@
)
from kiota_abstractions.store import BackingStoreFactory, BackingStoreFactorySingleton
from opentelemetry import trace
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.semconv.attributes.http_attributes import (
HTTP_RESPONSE_STATUS_CODE,
HTTP_REQUEST_METHOD,
)
from opentelemetry.semconv.attributes.network_attributes import NETWORK_PROTOCOL_NAME
from opentelemetry.semconv.attributes.server_attributes import SERVER_ADDRESS
from opentelemetry.semconv.attributes.url_attributes import URL_SCHEME, URL_FULL

from kiota_http._exceptions import (
BackingStoreError,
Expand Down Expand Up @@ -529,15 +535,15 @@ async def get_http_response_message(
resp = await self._http_client.send(request)
if not resp:
raise ResponseError("Unable to get response from request")
parent_span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, resp.status_code)
parent_span.set_attribute(HTTP_RESPONSE_STATUS_CODE, resp.status_code)
if http_version := resp.http_version:
parent_span.set_attribute(SpanAttributes.HTTP_FLAVOR, http_version)
parent_span.set_attribute(NETWORK_PROTOCOL_NAME, http_version)

if content_length := resp.headers.get("Content-Length", None):
parent_span.set_attribute(SpanAttributes.HTTP_RESPONSE_CONTENT_LENGTH, content_length)
parent_span.set_attribute("http.response.body.size", content_length)

if content_type := resp.headers.get("Content-Type", None):
parent_span.set_attribute("http.response_content_type", content_type)
parent_span.set_attribute("http.response.header.content-type", content_type)
_get_http_resp_span.end()
return await self.retry_cae_response_if_required(resp, request_info, claims)

Expand Down Expand Up @@ -586,15 +592,15 @@ def get_request_from_request_information(
)
url = parse.urlparse(request_info.url)
otel_attributes = {
SpanAttributes.HTTP_METHOD: request_info.http_method,
HTTP_REQUEST_METHOD: request_info.http_method,
"http.port": url.port,
SpanAttributes.HTTP_HOST: url.hostname,
SpanAttributes.HTTP_SCHEME: url.scheme,
"http.uri_template": request_info.url_template,
URL_SCHEME: url.hostname,
SERVER_ADDRESS: url.scheme,
"url.uri_template": request_info.url_template,
}

if self.observability_options.include_euii_attributes:
otel_attributes.update({"http.uri": url.geturl()})
otel_attributes.update({URL_FULL: url.geturl()})

request = self._http_client.build_request(
method=request_info.http_method.value,
Expand All @@ -610,10 +616,10 @@ def get_request_from_request_information(
setattr(request, "options", request_options)

if content_length := request.headers.get("Content-Length", None):
otel_attributes.update({SpanAttributes.HTTP_REQUEST_CONTENT_LENGTH: content_length})
otel_attributes.update({"http.request.body.size": content_length})

if content_type := request.headers.get("Content-Type", None):
otel_attributes.update({"http.request_content_type": content_type})
otel_attributes.update({"http.request.header.content-type": content_type})
attribute_span.set_attributes(otel_attributes)
_get_request_span.set_attributes(otel_attributes)
_get_request_span.end()
Expand Down
6 changes: 4 additions & 2 deletions kiota_http/middleware/redirect_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import httpx
from kiota_abstractions.request_option import RequestOption
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.semconv.attributes.http_attributes import (
HTTP_RESPONSE_STATUS_CODE,
)

from .._exceptions import RedirectError
from .middleware import BaseMiddleware
Expand Down Expand Up @@ -75,7 +77,7 @@ async def send(
request, f"RedirectHandler_send - redirect {len(history)}"
)
response = await super().send(request, transport)
_redirect_span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.status_code)
_redirect_span.set_attribute(HTTP_RESPONSE_STATUS_CODE, response.status_code)
redirect_location = self.get_redirect_location(response)

if redirect_location and current_options.should_redirect:
Expand Down
8 changes: 5 additions & 3 deletions kiota_http/middleware/retry_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

import httpx
from kiota_abstractions.request_option import RequestOption
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.semconv.attributes.http_attributes import (
HTTP_RESPONSE_STATUS_CODE,
)

from .middleware import BaseMiddleware
from .options import RetryHandlerOption
Expand Down Expand Up @@ -82,7 +84,7 @@ async def send(self, request: httpx.Request, transport: httpx.AsyncBaseTransport
while retry_valid:
start_time = time.time()
response = await super().send(request, transport)
_retry_span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.status_code)
_retry_span.set_attribute(HTTP_RESPONSE_STATUS_CODE, response.status_code)
# check that max retries has not been hit
retry_valid = self.check_retry_valid(retry_count, current_options)

Expand All @@ -99,7 +101,7 @@ async def send(self, request: httpx.Request, transport: httpx.AsyncBaseTransport
# increment the count for retries
retry_count += 1
request.headers.update({'retry-attempt': f'{retry_count}'})
_retry_span.set_attribute(SpanAttributes.HTTP_RETRY_COUNT, retry_count)
_retry_span.set_attribute('http.request.resend_count', retry_count)
continue
break
if response is None:
Expand Down
4 changes: 2 additions & 2 deletions kiota_http/middleware/url_replace_handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import httpx
from kiota_abstractions.request_option import RequestOption
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.semconv.attributes.url_attributes import (URL_FULL)

from .middleware import BaseMiddleware
from .options import UrlReplaceHandlerOption
Expand Down Expand Up @@ -40,7 +40,7 @@ async def send(
url_string: str = str(request.url) # type: ignore
url_string = self.replace_url_segment(url_string, current_options)
request.url = httpx.URL(url_string)
_enable_span.set_attribute(SpanAttributes.HTTP_URL, str(request.url))
_enable_span.set_attribute(URL_FULL, str(request.url))
response = await super().send(request, transport)
_enable_span.end()
return response
Expand Down
1 change: 0 additions & 1 deletion kiota_http/middleware/user_agent_handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from httpx import AsyncBaseTransport, Request, Response
from kiota_abstractions.request_option import RequestOption
from opentelemetry.semconv.trace import SpanAttributes

from .middleware import BaseMiddleware
from .options import UserAgentHandlerOption
Expand Down
16 changes: 11 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@

from .helpers import MockTransport, MockErrorObject, MockResponseObject, OfficeLocation


@pytest.fixture
def sample_headers():
return {"Content-Type": "application/json"}


@pytest.fixture
def auth_provider():
return AnonymousAuthenticationProvider()
Expand All @@ -26,6 +28,7 @@ def auth_provider():
def request_info():
return RequestInformation()


@pytest.fixture
def mock_async_transport():
return MockTransport()
Expand Down Expand Up @@ -57,27 +60,32 @@ def mock_error_500_map():
"500": Exception("Internal Server Error"),
}


@pytest.fixture
def mock_apierror_map(sample_headers):
return {
"400": APIError("Resource not found", 400, sample_headers),
"500": APIError("Custom Internal Server Error", 500, sample_headers)
}


@pytest.fixture
def mock_apierror_XXX_map(sample_headers):
return {"XXX": APIError("OdataError",400, sample_headers)}

return {"XXX": APIError("OdataError", 400, sample_headers)}


@pytest.fixture
def mock_request_adapter(sample_headers):
resp = httpx.Response(json={'error': 'not found'}, status_code=404, headers=sample_headers)
mock_request_adapter = AsyncMock
mock_request_adapter.get_http_response_message = AsyncMock(return_value=resp)


@pytest.fixture
def simple_error_response(sample_headers):
return httpx.Response(json={'error': 'not found'}, status_code=404, headers=sample_headers)


@pytest.fixture
def simple_success_response(sample_headers):
return httpx.Response(json={'message': 'Success!'}, status_code=200, headers=sample_headers)
Expand Down Expand Up @@ -153,9 +161,7 @@ def mock_users_response(mocker):

@pytest.fixture
def mock_primitive_collection_response(sample_headers):
return httpx.Response(
200, json=[12.1, 12.2, 12.3, 12.4, 12.5], headers=sample_headers
)
return httpx.Response(200, json=[12.1, 12.2, 12.3, 12.4, 12.5], headers=sample_headers)


@pytest.fixture
Expand Down
12 changes: 11 additions & 1 deletion tests/helpers/mock_async_transport.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import httpx


class MockTransport():

async def handle_async_request(self, request):
return httpx.Response(200, request=request, content=b'Hello World', headers={"Content-Type": "application/json", "test": "test_response_header"})
return httpx.Response(
200,
request=request,
content=b'Hello World',
headers={
"Content-Type": "application/json",
"test": "test_response_header"
}
)
1 change: 1 addition & 0 deletions tests/middleware_tests/test_base_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def test_next_is_none():
middleware = BaseMiddleware()
assert middleware.next is None


def test_span_created(request_info):
"""Ensures the current span is returned and the parent_span is not set."""
middleware = BaseMiddleware()
Expand Down
27 changes: 11 additions & 16 deletions tests/middleware_tests/test_headers_inspection_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,47 +26,42 @@ def test_custom_config():

options = HeadersInspectionHandlerOption(inspect_request_headers=False)
assert not options.inspect_request_headers


def test_headers_inspection_handler_construction():
"""
Ensures the Header Inspection handler instance is set.
"""
handler = HeadersInspectionHandler()
assert handler



@pytest.mark.asyncio
async def test_headers_inspection_handler_gets_headers():

def request_handler(request: httpx.Request):
return httpx.Response(
200,
json={"text": "Hello, world!"},
headers={'test_response': 'test_response_header'}
200, json={"text": "Hello, world!"}, headers={'test_response': 'test_response_header'}
)

handler = HeadersInspectionHandler()

# First request
request = httpx.Request(
'GET',
'https://localhost',
headers={'test_request': 'test_request_header'}
'GET', 'https://localhost', headers={'test_request': 'test_request_header'}
)
mock_transport = httpx.MockTransport(request_handler)
resp = await handler.send(request, mock_transport)
assert resp.status_code == 200
assert handler.options.request_headers.try_get('test_request') == {'test_request_header'}
assert handler.options.response_headers.try_get('test_response') == {'test_response_header'}

# Second request
request2 = httpx.Request(
'GET',
'https://localhost',
headers={'test_request_2': 'test_request_header_2'}
'GET', 'https://localhost', headers={'test_request_2': 'test_request_header_2'}
)
resp = await handler.send(request2, mock_transport)
assert resp.status_code == 200
assert not handler.options.request_headers.try_get('test_request') == {'test_request_header'}
assert handler.options.request_headers.try_get('test_request_2') == {'test_request_header_2'}
assert handler.options.response_headers.try_get('test_response') == {'test_response_header'}


52 changes: 34 additions & 18 deletions tests/middleware_tests/test_parameters_name_decoding_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from kiota_http.middleware.options import ParametersNameDecodingHandlerOption

OPTION_KEY = "ParametersNameDecodingHandlerOption"


def test_no_config():
"""
Test that default values are used if no custom confguration is passed
Expand All @@ -19,9 +21,7 @@ def test_custom_options():
"""
Test that default configuration is overrriden if custom configuration is provided
"""
options = ParametersNameDecodingHandlerOption(
enable=False, characters_to_decode=[".", "-"]
)
options = ParametersNameDecodingHandlerOption(enable=False, characters_to_decode=[".", "-"])
handler = ParametersNameDecodingHandler(options)

assert handler.options.enabled is not True
Expand All @@ -35,24 +35,40 @@ async def test_decodes_query_parameter_names_only():
Test that only query parameter names are decoded
"""
encoded_decoded = [
("http://localhost?%24select=diplayName&api%2Dversion=2", "http://localhost?$select=diplayName&api-version=2"),
("http://localhost?%24select=diplayName&api%7Eversion=2", "http://localhost?$select=diplayName&api~version=2"),
("http://localhost?%24select=diplayName&api%2Eversion=2", "http://localhost?$select=diplayName&api.version=2"),
("http://localhost:888?%24select=diplayName&api%2Dversion=2", "http://localhost:888?$select=diplayName&api-version=2"),
("http://localhost", "http://localhost"),
("https://google.com/?q=1%2b2", "https://google.com/?q=1%2b2"),
("https://google.com/?q=M%26A", "https://google.com/?q=M%26A"),
("https://google.com/?q=1%2B2", "https://google.com/?q=1%2B2"), # Values are not decoded
("https://google.com/?q=M%26A", "https://google.com/?q=M%26A"), # Values are not decoded
("https://google.com/?q%2D1=M%26A", "https://google.com/?q-1=M%26A"), # Values are not decoded but params are
("https://google.com/?q%2D1&q=M%26A=M%26A", "https://google.com/?q-1&q=M%26A=M%26A"), # Values are not decoded but params are
("https://graph.microsoft.com?%24count=true&query=%24top&created%2Din=2022-10-05&q=1%2b2&q2=M%26A&subject%2Ename=%7eWelcome&%24empty",
"https://graph.microsoft.com?$count=true&query=%24top&created-in=2022-10-05&q=1%2b2&q2=M%26A&subject.name=%7eWelcome&$empty")
(
"http://localhost?%24select=diplayName&api%2Dversion=2",
"http://localhost?$select=diplayName&api-version=2"
),
(
"http://localhost?%24select=diplayName&api%7Eversion=2",
"http://localhost?$select=diplayName&api~version=2"
),
(
"http://localhost?%24select=diplayName&api%2Eversion=2",
"http://localhost?$select=diplayName&api.version=2"
),
(
"http://localhost:888?%24select=diplayName&api%2Dversion=2",
"http://localhost:888?$select=diplayName&api-version=2"
),
("http://localhost", "http://localhost"),
("https://google.com/?q=1%2b2", "https://google.com/?q=1%2b2"),
("https://google.com/?q=M%26A", "https://google.com/?q=M%26A"),
("https://google.com/?q=1%2B2", "https://google.com/?q=1%2B2"), # Values are not decoded
("https://google.com/?q=M%26A", "https://google.com/?q=M%26A"), # Values are not decoded
("https://google.com/?q%2D1=M%26A",
"https://google.com/?q-1=M%26A"), # Values are not decoded but params are
("https://google.com/?q%2D1&q=M%26A=M%26A",
"https://google.com/?q-1&q=M%26A=M%26A"), # Values are not decoded but params are
(
"https://graph.microsoft.com?%24count=true&query=%24top&created%2Din=2022-10-05&q=1%2b2&q2=M%26A&subject%2Ename=%7eWelcome&%24empty",
"https://graph.microsoft.com?$count=true&query=%24top&created-in=2022-10-05&q=1%2b2&q2=M%26A&subject.name=%7eWelcome&$empty"
)
]

def request_handler(request: httpx.Request):
return httpx.Response(200, json={"text": "Hello, world!"})

handler = ParametersNameDecodingHandler()
for encoded, decoded in encoded_decoded:
request = httpx.Request('GET', encoded)
Expand Down
Loading
Loading