Updating linting, adding tests, defining data type for UI.

This commit is contained in:
Josh Bonczkowski 2026-04-23 14:23:24 -04:00
parent ccaa8c5556
commit ddf74b1b84
6 changed files with 395 additions and 117 deletions

View file

@ -32,7 +32,7 @@ In order for the New Relic Python Agent to report telemetry to New Relic, there
The `NEW_RELIC_APP_NAME` environment variable should have a value for the name that you wish the LiteLLM server to appear as in New Relic’s UI. The `NEW_RELIC_LICENSE_KEY` environment variable value is a license key for the New Relic account you want the telemetry to be reported to.
The `USE_NEWRELIC` is required in order to enable the New Relic Python Agent with the LiteLLM proxy. This must be sent in order for the New Relic Python Agent to report telemetry to New Relic.
When running the LiteLLM proxy container image, `USE_NEWRELIC=true` is required to enable the New Relic Python Agent. This instructs the container entrypoint to start the proxy via `newrelic-admin`, which initializes the agent before the proxy process begins. If you are running the proxy outside of the container image, you will need to start the process with `newrelic-admin run-program` instead.
```shell
NEW_RELIC_APP_NAME=<app name>

View file

@ -49,12 +49,13 @@ import litellm
from litellm._logging import verbose_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.types.integrations.newrelic import NewRelicInitParams
from litellm.types.utils import ModelResponse, Message
from litellm.types.integrations.base_health_check import IntegrationHealthCheckStatus
from litellm.types.utils import ModelResponse, Message, StandardLoggingPayload
# Global state for supportability metric emission
# Protected by _metric_lock to ensure thread-safe access
_last_metric_emission_time: float = 0.0
_metric_lock = threading.Lock()
try:
import newrelic.agent as _newrelic_agent
except ImportError:
_newrelic_agent = None # type: ignore
class NewRelicLogger(CustomLogger):
@ -66,6 +67,11 @@ class NewRelicLogger(CustomLogger):
2. LlmChatCompletionMessage - One per message (request and response)
"""
# Class-level state for supportability metric emission, shared across all instances.
# Protected by _metric_lock to ensure thread-safe access.
_last_metric_emission_time: float = 0.0
_metric_lock = threading.Lock()
def __init__(self, **kwargs):
#########################################################
# Handle newrelic_params set as litellm.newrelic_params
@ -102,25 +108,22 @@ class NewRelicLogger(CustomLogger):
"NEW_RELIC_APP_NAME environment variables. Integration will be disabled."
)
self.enabled = False
elif _newrelic_agent is None:
verbose_logger.error(
"New Relic Python agent not installed. "
"Install with: pip install newrelic "
"Integration will be disabled."
)
self.enabled = False
else:
# Validate that newrelic package is available
try:
import newrelic.agent
newrelic.agent.register_application()
_newrelic_agent.register_application()
self.enabled = True
verbose_logger.info(
f"New Relic AI Monitoring initialized for app: {self.app_name}, "
f"content recording: {self.record_content}"
)
except ImportError:
verbose_logger.error(
"New Relic Python agent not installed. "
"Install with: pip install newrelic "
"Integration will be disabled."
)
self.enabled = False
except Exception as e:
verbose_logger.error(
f"Failed to initialize New Relic agent: {e}. "
@ -176,24 +179,20 @@ class NewRelicLogger(CustomLogger):
to indicate the library is in use. Format:
Supportability/Python/ML/LiteLLM/{version}
This method updates the global _last_metric_emission_time and should
This method updates _last_metric_emission_time and should
be called within a lock when checking periodic emission.
"""
global _last_metric_emission_time
try:
import newrelic.agent
litellm_version = self._get_litellm_version()
metric_name = f"Supportability/Python/ML/LiteLLM/{litellm_version}"
# Record metric with value of 1 (will be aggregated by New Relic)
app = newrelic.agent.application()
app = _newrelic_agent.application()
# Always update the timestamp so the 27-hour back-off applies
# regardless of whether the app is ready, preventing lock contention
# on every request when the agent is slow to register or never starts.
_last_metric_emission_time = time.time()
NewRelicLogger._last_metric_emission_time = time.time()
if app and app.enabled:
app.record_custom_metric(metric_name, 1)
@ -215,43 +214,44 @@ class NewRelicLogger(CustomLogger):
Uses a mutex to ensure only one thread emits the metric even if multiple
requests are being processed concurrently.
"""
global _last_metric_emission_time
# Quick check without lock to avoid unnecessary locking
current_time = time.time()
time_since_last_emission = current_time - _last_metric_emission_time
time_since_last_emission = (
current_time - NewRelicLogger._last_metric_emission_time
)
if time_since_last_emission >= 97200: # 27 hours = 97200 seconds
# Acquire lock to ensure only one thread emits
with _metric_lock:
with NewRelicLogger._metric_lock:
# Double-check inside lock in case another thread just emitted
current_time = time.time()
time_since_last_emission = current_time - _last_metric_emission_time
time_since_last_emission = (
current_time - NewRelicLogger._last_metric_emission_time
)
if time_since_last_emission >= 97200:
self._emit_supportability_metric()
def _should_record_content(self) -> bool:
"""Check if message content should be recorded."""
return self.record_content
def _get_trace_context(self, kwargs: Dict) -> Tuple[Optional[str], Optional[str]]:
def _get_trace_context(
self,
kwargs: Dict,
standard_logging_object: Optional[StandardLoggingPayload] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""
Get current New Relic trace ID and span ID from distributed tracing headers.
Get current New Relic trace ID and span ID.
Resolution order for trace_id:
1. W3C traceparent header (litellm_params.metadata.headers.traceparent)
2. StandardLoggingPayload.trace_id (LiteLLM's internal trace for retry/fallback grouping)
3. Generated UUID for event grouping
This integration runs asynchronously from the actual request (via logging worker),
so we cannot use the New Relic agent to pull the current traceId and spanId.
Instead, we extract from request headers if available.
For the trace ID, we look in kwargs for:
- litellm_params.metadata.headers.traceparent (W3C Trace Context)
If no trace_id is found in headers, generates a random UUID for event grouping.
Returns:
Tuple of (trace_id, span_id):
- trace_id: str or None (str if found/generated, None only on error)
- span_id: str or None (only present if found in headers)
- span_id: always None (no LiteLLM span IDs available)
"""
try:
litellm_params = kwargs.get("litellm_params") or {}
@ -272,12 +272,18 @@ class NewRelicLogger(CustomLogger):
if len(parts) == 4:
trace_id = parts[1]
if not trace_id and standard_logging_object:
slo_trace_id = standard_logging_object.get("trace_id")
if slo_trace_id:
trace_id = slo_trace_id
if not trace_id:
# Generate a random trace_id for grouping AI monitoring events
trace_id = uuid.uuid4().hex
verbose_logger.debug(
f"New Relic trace_id not available from distributed tracing headers. "
f"Generated trace_id={trace_id} for AI monitoring event grouping."
f"New Relic trace_id not available from distributed tracing headers or "
f"StandardLoggingPayload. Generated trace_id={trace_id} for AI monitoring "
f"event grouping."
)
return trace_id, span_id
@ -304,26 +310,59 @@ class NewRelicLogger(CustomLogger):
return completion_id
def _get_vendor(self, kwargs: Dict) -> str:
"""Extract vendor/provider from kwargs."""
def _get_vendor(
self,
kwargs: Dict,
standard_logging_object: Optional[StandardLoggingPayload] = None,
) -> str:
"""Extract vendor/provider, preferring StandardLoggingPayload."""
if standard_logging_object:
vendor = standard_logging_object.get("custom_llm_provider")
if vendor:
return vendor
litellm_params = kwargs.get("litellm_params", {}) or {}
return litellm_params.get("custom_llm_provider") or "litellm"
def _get_model_names(
self, kwargs: Dict, response_obj: ModelResponse
self,
kwargs: Dict,
response_obj: ModelResponse,
standard_logging_object: Optional[StandardLoggingPayload] = None,
) -> Tuple[str, str]:
"""
Extract request and response model names.
Extract request and response model names, preferring StandardLoggingPayload
for the request model.
Returns:
Tuple of (request_model, response_model)
"""
request_model: str = str(kwargs.get("model") or "unknown")
request_model = None
if standard_logging_object:
slo_model = standard_logging_object.get("model")
if slo_model:
request_model = str(slo_model)
if not request_model:
request_model = str(kwargs.get("model") or "unknown")
response_model: str = str(response_obj.get("model") or request_model)
return request_model, response_model
def _extract_usage(self, response_obj: ModelResponse) -> Dict[str, int]:
"""Extract usage statistics from response."""
def _extract_usage(
self,
response_obj: ModelResponse,
standard_logging_object: Optional[StandardLoggingPayload] = None,
) -> Dict[str, int]:
"""Extract usage statistics, preferring StandardLoggingPayload."""
if standard_logging_object:
prompt = standard_logging_object.get("prompt_tokens")
completion = standard_logging_object.get("completion_tokens")
total = standard_logging_object.get("total_tokens")
if any(x is not None for x in [prompt, completion, total]):
return {
"prompt_tokens": prompt or 0,
"completion_tokens": completion or 0,
"total_tokens": total or 0,
}
usage = response_obj.get("usage", None)
if not usage:
return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
@ -352,39 +391,59 @@ class NewRelicLogger(CustomLogger):
return float(t) * 1000.0
def _get_duration(
self, kwargs: Dict, start_time: Any, end_time: Any
self,
kwargs: Dict,
start_time: Any,
end_time: Any,
standard_logging_object: Optional[StandardLoggingPayload] = None,
) -> Optional[float]:
"""
Extract duration in milliseconds.
First tries to get llm_api_duration_ms from kwargs, then falls back to
calculating from start_time and end_time if available.
Resolution order:
1. StandardLoggingPayload.response_time (already computed by LiteLLM)
2. llm_api_duration_ms from kwargs
3. Calculated from start_time and end_time
"""
# Try to get pre-calculated duration from kwargs
if standard_logging_object:
response_time = standard_logging_object.get("response_time")
if response_time is not None:
return (
float(response_time) * 1000.0
) # SLO stores seconds; convert to ms
duration_ms = kwargs.get("llm_api_duration_ms")
if duration_ms is not None:
return float(duration_ms)
# Fall back to calculating from timestamps
if start_time is not None and end_time is not None:
return self._to_epoch_ms(end_time) - self._to_epoch_ms(start_time)
return None
def _get_request_params(self, kwargs: Dict) -> Dict[str, Any]:
def _get_request_params(
self,
kwargs: Dict,
standard_logging_object: Optional[StandardLoggingPayload] = None,
) -> Dict[str, Any]:
"""
Extract request parameters like temperature and max_tokens.
Extract request parameters like temperature and max_tokens, preferring
StandardLoggingPayload.model_parameters.
Returns dict with available parameters, omitting those not present.
"""
optional_params = kwargs.get("optional_params") or {}
if standard_logging_object:
source_params = standard_logging_object.get("model_parameters") or {}
else:
source_params = kwargs.get("optional_params") or {}
params = {}
temperature = optional_params.get("temperature")
temperature = source_params.get("temperature")
if temperature is not None:
params["temperature"] = temperature
max_tokens = optional_params.get("max_tokens")
max_tokens = source_params.get("max_tokens")
if max_tokens is not None:
params["max_tokens"] = max_tokens
@ -429,23 +488,42 @@ class NewRelicLogger(CustomLogger):
response_obj: ModelResponse,
response_model: str,
vendor: str,
standard_logging_object: Optional[StandardLoggingPayload] = None,
) -> List[Dict[str, Any]]:
"""
Extract all messages (request + response) with sequence numbers and timestamps.
Processes request messages from kwargs["messages"] and response messages
from response_obj["choices"]. Assigns sequential numbers starting at 0.
Adds timestamps from kwargs if available (converted to epoch milliseconds).
Processes request messages from StandardLoggingPayload.messages (preferred) or
kwargs["messages"] (fallback), and response messages from response_obj["choices"].
Assigns sequential numbers starting at 0.
Adds timestamps from StandardLoggingPayload (preferred) or kwargs if available
(converted to epoch milliseconds).
"""
messages = []
sequence = 0
# Extract timestamps from kwargs and convert to milliseconds
start_time = kwargs.get("start_time")
end_time = kwargs.get("end_time")
# Extract timestamps, preferring StandardLoggingPayload
start_time = None
if standard_logging_object:
start_time = standard_logging_object.get("startTime")
if not start_time:
start_time = kwargs.get("start_time")
# Extract request messages
request_messages = kwargs.get("messages") or []
end_time = None
if standard_logging_object:
end_time = standard_logging_object.get("endTime")
if not end_time:
end_time = kwargs.get("end_time")
# Extract request messages, preferring StandardLoggingPayload.
# SLO messages can be a string (serialized/redacted), so only use it when it's a list.
slo_messages = (
standard_logging_object.get("messages") if standard_logging_object else None
)
if isinstance(slo_messages, list):
request_messages = slo_messages
else:
request_messages = kwargs.get("messages") or []
for msg in request_messages:
message_data = {
"role": msg.get("role") or "user",
@ -459,7 +537,7 @@ class NewRelicLogger(CustomLogger):
message_data["timestamp"] = int(self._to_epoch_ms(start_time))
# Only add content if recording is enabled
if self._should_record_content():
if self.record_content:
message_data["content"] = self._extract_message_content(msg)
messages.append(message_data)
@ -469,7 +547,8 @@ class NewRelicLogger(CustomLogger):
choices = response_obj.get("choices") or []
if choices and len(choices) > 0:
for choice in choices:
message = choice.get("message", None)
# Prefer "message" (non-streaming); fall back to "delta" (streaming-assembled)
message = choice.get("message", None) or choice.get("delta", None)
if message:
message_data = {
"role": message.get("role") or "assistant",
@ -484,7 +563,7 @@ class NewRelicLogger(CustomLogger):
message_data["timestamp"] = int(self._to_epoch_ms(end_time))
# Only add content if recording is enabled
if self._should_record_content():
if self.record_content:
message_data["content"] = self._extract_message_content(message)
messages.append(message_data)
@ -508,8 +587,6 @@ class NewRelicLogger(CustomLogger):
):
"""Record LlmChatCompletionSummary event to New Relic."""
try:
import newrelic.agent
event_data = {
"id": request_id,
"request_id": request_id,
@ -540,7 +617,7 @@ class NewRelicLogger(CustomLogger):
if "max_tokens" in request_params:
event_data["request.max_tokens"] = request_params["max_tokens"]
app = newrelic.agent.application()
app = _newrelic_agent.application()
if app and app.enabled:
app.record_custom_event("LlmChatCompletionSummary", event_data)
@ -571,9 +648,7 @@ class NewRelicLogger(CustomLogger):
messages: List of message dicts to record
"""
try:
import newrelic.agent
app = newrelic.agent.application()
app = _newrelic_agent.application()
if not (app and app.enabled):
verbose_logger.warning(
@ -630,9 +705,7 @@ class NewRelicLogger(CustomLogger):
self._check_and_emit_periodic_metric()
import newrelic.agent
app = newrelic.agent.application()
app = _newrelic_agent.application()
if app and app.enabled:
app.record_custom_metric("LLM/LiteLLM/Error", 1)
except Exception as e:
@ -657,8 +730,13 @@ class NewRelicLogger(CustomLogger):
# Check and emit periodic supportability metric if 27 hours have passed
self._check_and_emit_periodic_metric()
# Use StandardLoggingPayload where available for normalized, pre-computed values
standard_logging_object: Optional[StandardLoggingPayload] = kwargs.get(
"standard_logging_object"
)
# Get trace context
trace_id, span_id = self._get_trace_context(kwargs)
trace_id, span_id = self._get_trace_context(kwargs, standard_logging_object)
if not trace_id:
verbose_logger.warning(
"Failed to get trace context; skipping New Relic event recording."
@ -670,18 +748,22 @@ class NewRelicLogger(CustomLogger):
# Extract data from response
llm_response_id = self._extract_completion_id(kwargs, response_obj)
vendor = self._get_vendor(kwargs)
request_model, response_model = self._get_model_names(kwargs, response_obj)
usage = self._extract_usage(response_obj)
vendor = self._get_vendor(kwargs, standard_logging_object)
request_model, response_model = self._get_model_names(
kwargs, response_obj, standard_logging_object
)
usage = self._extract_usage(response_obj, standard_logging_object)
finish_reason = self._get_finish_reason(response_obj)
# Extract additional summary event fields
duration = self._get_duration(kwargs, start_time, end_time)
request_params = self._get_request_params(kwargs)
duration = self._get_duration(
kwargs, start_time, end_time, standard_logging_object
)
request_params = self._get_request_params(kwargs, standard_logging_object)
# Extract all messages
messages = self._extract_all_messages(
kwargs, response_obj, response_model, vendor
kwargs, response_obj, response_model, vendor, standard_logging_object
)
# Record summary event
@ -708,6 +790,38 @@ class NewRelicLogger(CustomLogger):
messages=messages,
)
async def async_health_check(self) -> IntegrationHealthCheckStatus:
"""
Check if the New Relic integration is healthy.
Verifies that the integration is enabled and the New Relic agent
has an active, connected application.
"""
if not self.enabled:
return IntegrationHealthCheckStatus(
status="unhealthy",
error_message="New Relic integration is disabled. Check that "
"NEW_RELIC_LICENSE_KEY and NEW_RELIC_APP_NAME are set and the "
"newrelic package is installed.",
)
try:
app = _newrelic_agent.application()
if app and app.enabled:
return IntegrationHealthCheckStatus(
status="healthy", error_message=None
)
return IntegrationHealthCheckStatus(
status="unhealthy",
error_message="New Relic agent application is not enabled. "
"Ensure the process was started with the New Relic agent initialized.",
)
except Exception as e:
return IntegrationHealthCheckStatus(
status="unhealthy",
error_message=str(e),
)
# CustomLogger interface implementation
def log_pre_api_call(self, model, messages, kwargs):
@ -751,9 +865,6 @@ class NewRelicLogger(CustomLogger):
Per spec: Do not send AI events on failure, only record error metric.
"""
try:
if not self.enabled:
return
self._record_error_metric()
except Exception as e:
@ -767,9 +878,6 @@ class NewRelicLogger(CustomLogger):
Per spec: Do not send AI events on failure, only record error metric.
"""
try:
if not self.enabled:
return
self._record_error_metric()
except Exception as e:

View file

@ -3307,6 +3307,16 @@ class AllCallbacks(LiteLLMPydanticObjectBase):
ui_callback_name="Traceloop",
)
newrelic: CallbackOnUI = CallbackOnUI(
litellm_callback_name="newrelic",
ui_callback_name="New Relic",
litellm_callback_params=[
"NEW_RELIC_APP_NAME",
"NEW_RELIC_LICENSE_KEY",
"NEW_RELIC_AI_MONITORING_RECORD_CONTENT_ENABLED",
],
)
class SpendLogsMetadata(TypedDict):
"""

View file

@ -108,6 +108,7 @@ proxy-runtime = [
"pypdf==6.7.5; python_version < '3.14'",
"llm-sandbox==0.3.31",
"detect-secrets==1.5.0",
"newrelic==12.1.0",
]
[project.scripts]

View file

@ -18,11 +18,14 @@ _mock_newrelic.agent = _mock_newrelic_agent
sys.modules["newrelic"] = _mock_newrelic
sys.modules["newrelic.agent"] = _mock_newrelic_agent
sys.path.insert(0, os.path.abspath("../.."))
import litellm.integrations.newrelic.newrelic as nr_module
from litellm.integrations.newrelic.newrelic import NewRelicLogger
# The module may have been imported before sys.modules was patched (e.g. via
# litellm's own startup imports), leaving _newrelic_agent=None. Point it at
# the mock agent so all tests see a non-None agent.
nr_module._newrelic_agent = _mock_newrelic_agent
# ---------------------------------------------------------------------------
# Shared fixtures
@ -93,7 +96,7 @@ def make_response(
# ---------------------------------------------------------------------------
# 1. Init / configuration
# Init / configuration
# ---------------------------------------------------------------------------
@ -177,7 +180,7 @@ class TestNewRelicLoggerInit:
# ---------------------------------------------------------------------------
# 2. _parse_bool_env
# _parse_bool_env
# ---------------------------------------------------------------------------
@ -204,7 +207,7 @@ class TestParseBoolEnv:
# ---------------------------------------------------------------------------
# 3. _get_trace_context
# _get_trace_context
# ---------------------------------------------------------------------------
@ -246,7 +249,7 @@ class TestGetTraceContext:
# ---------------------------------------------------------------------------
# 4. _extract_message_content edge cases
# _extract_message_content edge cases
# ---------------------------------------------------------------------------
@ -285,7 +288,7 @@ class TestExtractMessageContent:
# ---------------------------------------------------------------------------
# 5. _extract_all_messages — record_content=False path
# _extract_all_messages — record_content=False path
# ---------------------------------------------------------------------------
@ -353,7 +356,125 @@ class TestExtractAllMessagesTimestamps:
# ---------------------------------------------------------------------------
# 6. Explicit-None defensive tests
# Streaming response handling
# ---------------------------------------------------------------------------
def make_streaming_response(
model="gpt-4",
response_id="chatcmpl-stream123",
content="Hello from streaming!",
finish_reason="stop",
prompt_tokens=8,
completion_tokens=15,
):
"""Build a streaming-assembled response dict using 'delta' instead of 'message'."""
return {
"id": response_id,
"model": model,
"choices": [
{
"delta": {"role": "assistant", "content": content},
"finish_reason": finish_reason,
}
],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
}
class TestStreamingResponse:
"""Verify graceful handling of streaming-assembled responses.
When LiteLLM assembles a streaming response, some providers produce a
final choice dict with a 'delta' key instead of 'message'. The integration
must extract content from either key without raising.
"""
def setup_method(self):
self.logger = make_logger()
def test_extracts_content_from_delta_key(self):
kwargs = make_kwargs(messages=[{"role": "user", "content": "Hi"}])
response = make_streaming_response(content="Streamed reply")
messages = self.logger._extract_all_messages(
kwargs, response, response_model="gpt-4", vendor="openai"
)
response_msgs = [m for m in messages if m.get("is_response")]
assert len(response_msgs) == 1
assert response_msgs[0]["content"] == "Streamed reply"
assert response_msgs[0]["role"] == "assistant"
def test_streaming_response_records_summary_and_message_events(self):
mock_app = MagicMock()
mock_app.enabled = True
kwargs = make_kwargs(
traceparent="00-aabbccddeeff00112233445566778899-0011223344556677-01",
messages=[{"role": "user", "content": "Hi"}],
)
response = make_streaming_response(
response_id="chatcmpl-stream123",
content="Streamed reply",
finish_reason="stop",
prompt_tokens=8,
completion_tokens=15,
)
with patch("newrelic.agent.application", return_value=mock_app):
self.logger._process_success(kwargs, response, start_time=1.0, end_time=2.0)
calls = mock_app.record_custom_event.call_args_list
event_types = [c[0][0] for c in calls]
assert "LlmChatCompletionSummary" in event_types
assert "LlmChatCompletionMessage" in event_types
message_events = [
c[0][1] for c in calls if c[0][0] == "LlmChatCompletionMessage"
]
response_msg = next((e for e in message_events if e.get("is_response")), None)
assert response_msg is not None
assert response_msg["content"] == "Streamed reply"
@pytest.mark.asyncio
async def test_async_log_success_event_streaming(self):
"""async_log_success_event is the primary entry point for streaming calls."""
mock_app = MagicMock()
mock_app.enabled = True
kwargs = make_kwargs(messages=[{"role": "user", "content": "Hi"}])
response = make_streaming_response()
with patch("newrelic.agent.application", return_value=mock_app):
await self.logger.async_log_success_event(
kwargs, response, start_time=1.0, end_time=2.0
)
calls = mock_app.record_custom_event.call_args_list
event_types = [c[0][0] for c in calls]
assert "LlmChatCompletionSummary" in event_types
assert "LlmChatCompletionMessage" in event_types
def test_no_content_when_recording_disabled_streaming(self):
logger = make_logger(turn_off_message_logging=True)
kwargs = make_kwargs(messages=[{"role": "user", "content": "secret"}])
response = make_streaming_response(content="also secret")
messages = logger._extract_all_messages(
kwargs, response, response_model="gpt-4", vendor="openai"
)
for msg in messages:
assert "content" not in msg
# ---------------------------------------------------------------------------
# Explicit-None defensive tests
# ---------------------------------------------------------------------------
@ -422,7 +543,7 @@ class TestExplicitNoneValues:
# ---------------------------------------------------------------------------
# 7. Helper edge cases
# Helper edge cases
# ---------------------------------------------------------------------------
@ -514,7 +635,7 @@ class TestGetRequestParams:
# ---------------------------------------------------------------------------
# 8. _process_success — comprehensive happy-path
# _process_success — comprehensive happy-path
# ---------------------------------------------------------------------------
@ -582,7 +703,7 @@ class TestProcessSuccess:
# ---------------------------------------------------------------------------
# 9. _record_error_metric
# _record_error_metric
# ---------------------------------------------------------------------------
@ -621,14 +742,14 @@ class TestRecordErrorMetric:
# ---------------------------------------------------------------------------
# 10. _emit_supportability_metric
# _emit_supportability_metric
# ---------------------------------------------------------------------------
class TestEmitSupportabilityMetric:
def setup_method(self):
self.logger = make_logger()
nr_module._last_metric_emission_time = 0.0
NewRelicLogger._last_metric_emission_time = 0.0
def test_records_metric_with_correct_name_and_value(self):
mock_app = MagicMock()
@ -652,7 +773,7 @@ class TestEmitSupportabilityMetric:
return_value=fake_now,
):
self.logger._emit_supportability_metric()
assert nr_module._last_metric_emission_time == fake_now
assert NewRelicLogger._last_metric_emission_time == fake_now
def test_skips_when_app_disabled(self):
mock_app = MagicMock()
@ -661,25 +782,25 @@ class TestEmitSupportabilityMetric:
self.logger._emit_supportability_metric()
mock_app.record_custom_metric.assert_not_called()
# Timestamp is still updated to back off lock contention during registration.
assert nr_module._last_metric_emission_time != 0.0
assert NewRelicLogger._last_metric_emission_time != 0.0
def test_skips_when_no_app(self):
with patch("newrelic.agent.application", return_value=None):
self.logger._emit_supportability_metric()
# Timestamp is updated even when app is None to back off lock contention
# if the agent never starts or is slow to initialise.
assert nr_module._last_metric_emission_time != 0.0
assert NewRelicLogger._last_metric_emission_time != 0.0
# ---------------------------------------------------------------------------
# 11. _check_and_emit_periodic_metric
# _check_and_emit_periodic_metric
# ---------------------------------------------------------------------------
class TestCheckAndEmitPeriodicMetric:
def setup_method(self):
self.logger = make_logger()
nr_module._last_metric_emission_time = 0.0
NewRelicLogger._last_metric_emission_time = 0.0
def test_emits_on_first_call(self):
"""_last_metric_emission_time starts at 0.0; any real time satisfies 27-hour window."""
@ -693,7 +814,7 @@ class TestCheckAndEmitPeriodicMetric:
def test_does_not_re_emit_within_27_hours(self):
recent = 1_000_000.0
nr_module._last_metric_emission_time = recent
NewRelicLogger._last_metric_emission_time = recent
with patch.object(self.logger, "_emit_supportability_metric") as mock_emit:
with patch(
"litellm.integrations.newrelic.newrelic.time.time",
@ -704,7 +825,7 @@ class TestCheckAndEmitPeriodicMetric:
def test_re_emits_after_27_hours(self):
old = 1_000_000.0
nr_module._last_metric_emission_time = old
NewRelicLogger._last_metric_emission_time = old
with patch.object(self.logger, "_emit_supportability_metric") as mock_emit:
with patch(
"litellm.integrations.newrelic.newrelic.time.time",
@ -715,7 +836,7 @@ class TestCheckAndEmitPeriodicMetric:
def test_boundary_exactly_27_hours_triggers_emission(self):
old = 1_000_000.0
nr_module._last_metric_emission_time = old
NewRelicLogger._last_metric_emission_time = old
with patch.object(self.logger, "_emit_supportability_metric") as mock_emit:
with patch(
"litellm.integrations.newrelic.newrelic.time.time",

42
uv.lock generated
View file

@ -3165,6 +3165,7 @@ proxy-runtime = [
{ name = "langfuse" },
{ name = "llm-sandbox" },
{ name = "mangum" },
{ name = "newrelic" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp" },
{ name = "opentelemetry-sdk" },
@ -3298,8 +3299,9 @@ requires-dist = [
{ name = "litellm-proxy-extras", marker = "extra == 'proxy'", editable = "litellm-proxy-extras" },
{ name = "llm-sandbox", marker = "extra == 'proxy-runtime'", specifier = "==0.3.31" },
{ name = "mangum", marker = "extra == 'proxy-runtime'", specifier = "==0.17.0" },
{ name = "mcp", marker = "extra == 'proxy'", specifier = "==1.26.0" },
{ name = "mlflow", marker = "extra == 'mlflow'", specifier = "==3.9.0" },
{ name = "mcp", marker = "python_full_version >= '3.10' and extra == 'proxy'", specifier = "==1.26.0" },
{ name = "mlflow", marker = "python_full_version >= '3.10' and extra == 'mlflow'", specifier = "==3.9.0" },
{ name = "newrelic", marker = "extra == 'proxy-runtime'", specifier = "==12.1.0" },
{ name = "numpydoc", marker = "extra == 'utils'", specifier = "==1.8.0" },
{ name = "openai", specifier = "==2.24.0" },
{ name = "opentelemetry-api", marker = "extra == 'proxy-runtime'", specifier = "==1.28.0" },
@ -4030,6 +4032,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "newrelic"
version = "12.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/16/4dd1efc8443e50ddb7f3b83ea35a45b08ded6e9bd4694e73720d90df9b8b/newrelic-12.1.0.tar.gz", hash = "sha256:309e515ae3cb7981919dd2eaf9d2c0510e46e3e3a60b915e5c79b8257ececa82", size = 1379969, upload-time = "2026-03-26T21:50:12.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/6a/4cde031ed6e21fcec1a9e3ba294487225aa553ed46d3fc268f0ce82530c8/newrelic-12.1.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec2dda00fbd1bf6d6b499bb49ed7698b54d893f375e77f0a43bf4fc6e7eceb95", size = 912980, upload-time = "2026-03-26T21:49:08.678Z" },
{ url = "https://files.pythonhosted.org/packages/79/94/065d6f5deee4a778505fe79d8667847eb436591201aac46b664d52594fab/newrelic-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36e99ee022661f0b83d33d10efde7538cfe9c75e104ae2260edb3831887e35f3", size = 916232, upload-time = "2026-03-26T21:49:10.384Z" },
{ url = "https://files.pythonhosted.org/packages/79/a7/0f06e8589c9cc1c3ab2da7ec4c7d9f670f09df05450cd09515d3a4c9f25d/newrelic-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff29d0d32f52638b63fae969d4d8fdd574277f6b97411cbfaaae6c95f538dcab", size = 914863, upload-time = "2026-03-26T21:49:11.732Z" },
{ url = "https://files.pythonhosted.org/packages/5b/5b/33dc281831673605c993d5d9e90b5e2c58ed6777ad54a54ae44345bc1136/newrelic-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:26a3b2b45b28f2cec8ff220930a2d8edef96b3f70be95be1fdb3e1089adb3a40", size = 912617, upload-time = "2026-03-26T21:49:13.293Z" },
{ url = "https://files.pythonhosted.org/packages/13/ed/42511d2044eeabfe42e4f7f53b2f6454e351e9cebbc24ce5f96a1621ca8a/newrelic-12.1.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d45a99ea8499d9a922ce61dba490c50ed603b5d19f66e7927c8f7bc8543abb21", size = 913421, upload-time = "2026-03-26T21:49:15.003Z" },
{ url = "https://files.pythonhosted.org/packages/43/ef/51a02c8ef47d1544f886948d85a3531b289bc8fa8cd118644401760b51d3/newrelic-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:977865ee5f9852963463d9eb4d5efc6c1147d1d0469b9d081604335de1b345fa", size = 916691, upload-time = "2026-03-26T21:49:16.319Z" },
{ url = "https://files.pythonhosted.org/packages/7e/8a/9746bff80776710d03af4d0811ebfad907375b901b86b705c1d0d0061684/newrelic-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d3da859a78a8a4033ecab5d65c861fc45d13bd641edcabe3cc69d2894c9a1f8c", size = 915442, upload-time = "2026-03-26T21:49:17.668Z" },
{ url = "https://files.pythonhosted.org/packages/d1/4d/decefdf71004637470b01ff4df3f3fefdbf99a87ec5d20e8bbc160a7fd1e/newrelic-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef292df6ee99202bd49b9757546ed7f76e413c10a0a6d4d608d07833d6cdf375", size = 913152, upload-time = "2026-03-26T21:49:18.957Z" },
{ url = "https://files.pythonhosted.org/packages/29/f2/379a75db8f974b941019effc6756539f4fb816e6dc0fb816c4b7ba70ddea/newrelic-12.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:38121407bf50c2acfd25a3d4fc2f09d81a43adb23b746b8671dfd1be785946cf", size = 921495, upload-time = "2026-03-26T21:49:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/9f/bb/00784e122376da6482733ff9404c051bb382e20da4c477263b1f291f25b3/newrelic-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b926f57e8845a0a29524e9c3ca3bb14bbfc90caac7ea481f0fe9f94fadb3998", size = 924190, upload-time = "2026-03-26T21:49:21.951Z" },
{ url = "https://files.pythonhosted.org/packages/d0/77/eda9e5864b2ef6f1ce39ae51c2762b6cdc9125ff2fb74804973f913aa155/newrelic-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9c561c3d80c6193683b354c424228fb9904b82efe408ded56b2093f39458ba2", size = 922079, upload-time = "2026-03-26T21:49:23.556Z" },
{ url = "https://files.pythonhosted.org/packages/09/a9/2e7a7dbcb807e7d0ac4ae926ec4b47e2818c01721f681d1ecaf8c4672adf/newrelic-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4441a44afdc399b72db338a17c1d7aa5a111047c011a8883a318fafe858fcb23", size = 920637, upload-time = "2026-03-26T21:49:24.895Z" },
{ url = "https://files.pythonhosted.org/packages/f2/64/1b675cf46b18f99f940a374056654bfd7cf622fc26f087f06ac4e55ea78d/newrelic-12.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9295aed7fb26304cd6efc5d7d56ce6426251349f18889d2e0ef265583b65aa62", size = 921556, upload-time = "2026-03-26T21:49:26.357Z" },
{ url = "https://files.pythonhosted.org/packages/fc/03/1cfa7279245f815bf0bcb168e7592bb7e1821153ebc3af3ff2d7c79fbd2a/newrelic-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fdef1de8c7c505bc7a8fb3e7ce443b3faa93bf95b3a66179cce52de558c28aa", size = 924298, upload-time = "2026-03-26T21:49:27.997Z" },
{ url = "https://files.pythonhosted.org/packages/68/f8/6463c67f16c661cb02ab7797d0f8fc70d3842213aede2c7342ed5311eb07/newrelic-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee10572d76136d3f2975bb02870a133ed1ddf203ff210a96de3081c620eddad3", size = 922161, upload-time = "2026-03-26T21:49:29.336Z" },
{ url = "https://files.pythonhosted.org/packages/51/e3/33e6f03ac5d105616dfdc7140d4d191a9c7957ee8f7c7d4e521c5cff4fd6/newrelic-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42620fffffade5852cd48ea52aaf594b3da280341f16bd2573b1664643bf30c5", size = 920696, upload-time = "2026-03-26T21:49:30.736Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e7/d4e5fa2a8b8c66732bd8bbe02f74c1ea565128a3a60413701baca0d19083/newrelic-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21308e5cc63359e646c3322f2272f66b125e5c2e537ae3aad8f266e762ee2f7", size = 854286, upload-time = "2026-03-26T21:49:31.999Z" },
{ url = "https://files.pythonhosted.org/packages/39/a0/10a3861a27597430ecb9df0cd4d28dbc5f03075d7854d7fc39105d20e8ee/newrelic-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:bdef608293bd28ca658baa5c775cb26695bdcbbd226ed16771aed6f1a4e71e7a", size = 851665, upload-time = "2026-03-26T21:49:33.826Z" },
{ url = "https://files.pythonhosted.org/packages/d4/04/86025e13562e45234dfb7ba4a5115e4b7ddf56b6154ce079ed89bd419b6e/newrelic-12.1.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:820de18c42571949c0f3f9a4601a36cde1915fc8f1debea6f57ce405c3ae423f", size = 955006, upload-time = "2026-03-26T21:49:35.557Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/aca49eddfd6ce128ee9de84eaefc6e3efd23cc1935316f261a3dd5da3bba/newrelic-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44b44b792ea5bd90fbc54e6b2a87fbba0c26d2a1b1d219a44ee1154e86aa4f75", size = 962899, upload-time = "2026-03-26T21:49:36.93Z" },
{ url = "https://files.pythonhosted.org/packages/68/c8/c5cee15cbee6ff7da6de61f0b3c593f0b8115e5e0e09a5a652a4fb81a2f0/newrelic-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4dafb7806c67b39e32559ab4845a0fc91a8f5fa137c6e7df9b8951b8478e75ac", size = 958837, upload-time = "2026-03-26T21:49:38.771Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/0486249881c921ad9e854fea1f0ccc58ce476a6f9f695c92b4fdaabcd86e/newrelic-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:03f1a9709a807365bfb1d3fb9ced04c9e4fe052f0214010d7f40c6cb31c033b2", size = 952457, upload-time = "2026-03-26T21:49:40.459Z" },
{ url = "https://files.pythonhosted.org/packages/31/73/a46c488c06f383b842dc9a6622f827d8cd29a1ad64b4cb984191a788ad39/newrelic-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0f08278968698ab03983959b09fa4ad71e85d3c110c550a9162644fc499c4167", size = 857330, upload-time = "2026-03-26T21:49:41.885Z" },
{ url = "https://files.pythonhosted.org/packages/59/37/58ae0d6fd2e3469493e709354ef457eef855a858612546eb6b98589e5c54/newrelic-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7764cab572224217931a7bd473c39038d316781ba0693c7adfb50a1da04b78fe", size = 853401, upload-time = "2026-03-26T21:49:43.25Z" },
{ url = "https://files.pythonhosted.org/packages/86/43/abcc6930d0893473beb8a0e0a0006e70a1c19afc5986f297563c7612cc47/newrelic-12.1.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b1655c4012f616b512fb014549458b091f3ca5665f71fc42c6bf46de5fa3ae5b", size = 912506, upload-time = "2026-03-26T21:50:05.665Z" },
{ url = "https://files.pythonhosted.org/packages/b8/01/1e54fc4397b5537974d6a24e87c9546d4967d08776b5d4f021e25cc5975f/newrelic-12.1.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8a83ae8e1ca6e3cd5b24602c0e539d73ec01fca7de621d3a7b486d21df82d5", size = 915721, upload-time = "2026-03-26T21:50:07.978Z" },
{ url = "https://files.pythonhosted.org/packages/10/05/587fe5236958f79f58b46de95d43a1670c0035a7615e4be277e28a260ff8/newrelic-12.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f7da837f5ca33158e79b5993f7d9e4691f44fcbdecbf418583293e67265bb0e2", size = 914361, upload-time = "2026-03-26T21:50:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/fe/68/ed99d4bd5b84af7d81adcc8e9eec1e63888c8f4deaa41765f8325bbb5d91/newrelic-12.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:443b6526ec00af730d68a199fa756b09063c81295a0cfbc658e4a5e02c64dd56", size = 912231, upload-time = "2026-03-26T21:50:11.232Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"