Skip to content
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
49 changes: 37 additions & 12 deletions solarwinds_apm/trace/response_time_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,17 +185,38 @@ def enhance_meter_attrs_with_http_span_attrs(
self._HTTP_RESPONSE_STATUS_CODE, None
)
status_code_old = span.attributes.get(self._HTTP_STATUS_CODE, None)
if status_code_new and status_code_new > 0:
meter_attrs.update(
{self._HTTP_RESPONSE_STATUS_CODE: status_code_new}
)
elif status_code_old and status_code_old > 0:
meter_attrs.update(
{self._HTTP_RESPONSE_STATUS_CODE: status_code_old}
)
# Something went wrong in OTel or instrumented service crashed early
# if no status_code, current nor deprecated, in attributes of HTTP span

# Convert to int (compliant) if str (non-compliant but can happen)
# RFC 9110: status-code should be 3-digit (integer)
status_code = None
if status_code_new is not None:
try:
status_code = int(status_code_new)
if status_code <= 0:
status_code = None
except (ValueError, TypeError):
logger.debug(
"Expected HTTP status code as int (RFC 9110), but got %s. Ignoring invalid value.",
type(status_code_new).__name__,
)

# Fall back to deprecated attribute if new attribute was invalid/missing
if status_code is None and status_code_old is not None:
try:
status_code = int(status_code_old)
if status_code <= 0:
status_code = None
except (ValueError, TypeError):
logger.debug(
"Expected HTTP status code as int (RFC 9110), but got %s. Ignoring invalid value.",
type(status_code_old).__name__,
)

if status_code is not None and status_code > 0:
meter_attrs.update({self._HTTP_RESPONSE_STATUS_CODE: status_code})
else:
# Something went wrong in OTel or instrumented service crashed early
# if no status_code, current nor deprecated, in attributes of HTTP span
meter_attrs.update(
{
self._HTTP_RESPONSE_STATUS_CODE: self._HTTP_SPAN_STATUS_UNAVAILABLE
Expand All @@ -207,9 +228,13 @@ def enhance_meter_attrs_with_http_span_attrs(
)
request_method_old = span.attributes.get(self._HTTP_METHOD, None)
if request_method_new:
meter_attrs.update({self._HTTP_REQUEST_METHOD: request_method_new})
meter_attrs.update(
{self._HTTP_REQUEST_METHOD: str(request_method_new)}
)
elif request_method_old:
meter_attrs.update({self._HTTP_REQUEST_METHOD: request_method_old})
meter_attrs.update(
{self._HTTP_REQUEST_METHOD: str(request_method_old)}
)

return meter_attrs

Expand Down
125 changes: 125 additions & 0 deletions tests/unit/test_processors/test_response_time_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,131 @@ def test_enhance_meter_attrs_with_http_span_attrs_no_http_attrs(self, mocker):
assert "http.request.method" not in result
assert result["existing"] == "value"

def test_enhance_meter_attrs_with_http_span_attrs_string_status_code_new_attr(self, mocker):
"""Test that string status codes are converted to int (new attr)"""
mock_apm_config = self.get_mock_apm_config(mocker)
processor = ResponseTimeProcessor(mock_apm_config)

mock_span = mocker.Mock()
mock_span.configure_mock(**{
"attributes": {
"http.request.method": "GET",
"http.response.status_code": "200" # String instead of int
}
})

meter_attrs = {}
result = processor.enhance_meter_attrs_with_http_span_attrs(mock_span, meter_attrs)

assert result["http.response.status_code"] == 200
assert result["http.request.method"] == "GET"

def test_enhance_meter_attrs_with_http_span_attrs_string_status_code_old_attr(self, mocker):
"""Test that string status codes are converted to int (deprecated attr)"""
mock_apm_config = self.get_mock_apm_config(mocker)
processor = ResponseTimeProcessor(mock_apm_config)

mock_span = mocker.Mock()
mock_span.configure_mock(**{
"attributes": {
"http.method": "POST",
"http.status_code": "404" # String instead of int
}
})

meter_attrs = {}
result = processor.enhance_meter_attrs_with_http_span_attrs(mock_span, meter_attrs)

assert result["http.response.status_code"] == 404
assert result["http.request.method"] == "POST"

def test_enhance_meter_attrs_with_http_span_attrs_invalid_string_status_code(self, mocker):
"""Test that invalid string status codes fall back to unavailable (0)"""
mock_apm_config = self.get_mock_apm_config(mocker)
processor = ResponseTimeProcessor(mock_apm_config)

mock_span = mocker.Mock()
mock_span.configure_mock(**{
"attributes": {
"http.request.method": "GET",
"http.response.status_code": "invalid" # Cannot convert to int
}
})

meter_attrs = {}
mock_logger = mocker.patch("solarwinds_apm.trace.response_time_processor.logger")

result = processor.enhance_meter_attrs_with_http_span_attrs(mock_span, meter_attrs)

assert result["http.response.status_code"] == 0 # Fallback to unavailable
assert result["http.request.method"] == "GET"
mock_logger.debug.assert_called_once()
assert "Expected HTTP status code as int" in mock_logger.debug.call_args[0][0]

def test_enhance_meter_attrs_with_http_span_attrs_invalid_type_status_code(self, mocker):
"""Test that non-string/non-int status codes fall back to unavailable (0)"""
mock_apm_config = self.get_mock_apm_config(mocker)
processor = ResponseTimeProcessor(mock_apm_config)

mock_span = mocker.Mock()
mock_span.configure_mock(**{
"attributes": {
"http.request.method": "GET",
"http.response.status_code": ["200"] # List instead of int
}
})

meter_attrs = {}
mock_logger = mocker.patch("solarwinds_apm.trace.response_time_processor.logger")

result = processor.enhance_meter_attrs_with_http_span_attrs(mock_span, meter_attrs)

assert result["http.response.status_code"] == 0 # Fallback to unavailable
assert result["http.request.method"] == "GET"
mock_logger.debug.assert_called_once()
assert "Expected HTTP status code as int" in mock_logger.debug.call_args[0][0]

def test_enhance_meter_attrs_with_http_span_attrs_string_zero_status_code(self, mocker):
"""Test that string '0' is converted but treated as invalid (fallback to old attr)"""
mock_apm_config = self.get_mock_apm_config(mocker)
processor = ResponseTimeProcessor(mock_apm_config)

mock_span = mocker.Mock()
mock_span.configure_mock(**{
"attributes": {
"http.request.method": "GET",
"http.response.status_code": "0",
"http.status_code": 200
}
})

meter_attrs = {}
result = processor.enhance_meter_attrs_with_http_span_attrs(mock_span, meter_attrs)

# String "0" converts to int 0, which is <= 0, so falls back to old attr
assert result["http.response.status_code"] == 200
assert result["http.request.method"] == "GET"

def test_enhance_meter_attrs_with_http_span_attrs_negative_string_status_code(self, mocker):
"""Test that negative string status codes are treated as invalid"""
mock_apm_config = self.get_mock_apm_config(mocker)
processor = ResponseTimeProcessor(mock_apm_config)

mock_span = mocker.Mock()
mock_span.configure_mock(**{
"attributes": {
"http.request.method": "GET",
"http.response.status_code": "-1"
}
})

meter_attrs = {}
result = processor.enhance_meter_attrs_with_http_span_attrs(mock_span, meter_attrs)

# Negative values are treated as invalid, fallback to unavailable
assert result["http.response.status_code"] == 0
assert result["http.request.method"] == "GET"

def test_on_end_valid_local_parent_span(self, mocker):
"""Only scenario to skip OTLP metrics generation (not entry span)"""
mock_txname_manager, \
Expand Down
Loading