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

Commit

Permalink
Merge pull request #434 from Strazz1337/fix-adhere-to-otel-span
Browse files Browse the repository at this point in the history
fix(spans): adhere attribute name to otel semver
  • Loading branch information
baywet authored Oct 11, 2024
2 parents afce579 + dac0eec commit ba1af4d
Show file tree
Hide file tree
Showing 16 changed files with 199 additions and 157 deletions.
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

0 comments on commit ba1af4d

Please sign in to comment.