Skip to content

Commit 573d5f0

Browse files
authored
Merge pull request #1214 from newrelic/llm-custom-attrs-api
LLM Custom Attributes Context Manager API
2 parents 2d17237 + 182cc71 commit 573d5f0

20 files changed

+368
-187
lines changed

newrelic/agent.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ def __asgi_application(*args, **kwargs):
139139
from newrelic.api.html_insertion import verify_body_exists as __verify_body_exists
140140
from newrelic.api.lambda_handler import LambdaHandlerWrapper as __LambdaHandlerWrapper
141141
from newrelic.api.lambda_handler import lambda_handler as __lambda_handler
142+
from newrelic.api.llm_custom_attributes import (
143+
WithLlmCustomAttributes as __WithLlmCustomAttributes,
144+
)
142145
from newrelic.api.message_trace import MessageTrace as __MessageTrace
143146
from newrelic.api.message_trace import MessageTraceWrapper as __MessageTraceWrapper
144147
from newrelic.api.message_trace import message_trace as __message_trace
@@ -156,7 +159,9 @@ def __asgi_application(*args, **kwargs):
156159
from newrelic.api.ml_model import (
157160
record_llm_feedback_event as __record_llm_feedback_event,
158161
)
159-
from newrelic.api.ml_model import set_llm_token_count_callback as __set_llm_token_count_callback
162+
from newrelic.api.ml_model import (
163+
set_llm_token_count_callback as __set_llm_token_count_callback,
164+
)
160165
from newrelic.api.ml_model import wrap_mlmodel as __wrap_mlmodel
161166
from newrelic.api.profile_trace import ProfileTraceWrapper as __ProfileTraceWrapper
162167
from newrelic.api.profile_trace import profile_trace as __profile_trace
@@ -251,6 +256,7 @@ def __asgi_application(*args, **kwargs):
251256
record_custom_event = __wrap_api_call(__record_custom_event, "record_custom_event")
252257
record_log_event = __wrap_api_call(__record_log_event, "record_log_event")
253258
record_ml_event = __wrap_api_call(__record_ml_event, "record_ml_event")
259+
WithLlmCustomAttributes = __wrap_api_call(__WithLlmCustomAttributes, "WithLlmCustomAttributes")
254260
accept_distributed_trace_payload = __wrap_api_call(
255261
__accept_distributed_trace_payload, "accept_distributed_trace_payload"
256262
)

newrelic/api/llm_custom_attributes.py

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Copyright 2010 New Relic, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
17+
from newrelic.api.transaction import current_transaction
18+
19+
_logger = logging.getLogger(__name__)
20+
21+
22+
class WithLlmCustomAttributes(object):
23+
def __init__(self, custom_attr_dict):
24+
transaction = current_transaction()
25+
if not custom_attr_dict or not isinstance(custom_attr_dict, dict):
26+
raise TypeError(
27+
"custom_attr_dict must be a non-empty dictionary. Received type: %s" % type(custom_attr_dict)
28+
)
29+
30+
# Add "llm." prefix to all keys in attribute dictionary
31+
context_attrs = {k if k.startswith("llm.") else f"llm.{k}": v for k, v in custom_attr_dict.items()}
32+
33+
self.attr_dict = context_attrs
34+
self.transaction = transaction
35+
36+
def __enter__(self):
37+
if not self.transaction:
38+
_logger.warning("WithLlmCustomAttributes must be called within the scope of a transaction.")
39+
return self
40+
41+
self.transaction._llm_context_attrs = self.attr_dict
42+
return self
43+
44+
def __exit__(self, exc, value, tb):
45+
# Clear out context attributes once we leave the current context
46+
if self.transaction:
47+
del self.transaction._llm_context_attrs

newrelic/hooks/external_botocore.py

+4
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,10 @@ def handle_chat_completion_event(transaction, bedrock_attrs):
787787
custom_attrs_dict = transaction._custom_params
788788
llm_metadata_dict = {key: value for key, value in custom_attrs_dict.items() if key.startswith("llm.")}
789789

790+
llm_context_attrs = getattr(transaction, "_llm_context_attrs", None)
791+
if llm_context_attrs:
792+
llm_metadata_dict.update(llm_context_attrs)
793+
790794
span_id = bedrock_attrs.get("span_id", None)
791795
trace_id = bedrock_attrs.get("trace_id", None)
792796
request_id = bedrock_attrs.get("request_id", None)

newrelic/hooks/mlmodel_langchain.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,7 @@ def _get_run_manager_info(transaction, run_args, instance, completion_id):
697697
# metadata and tags are keys in the config parameter.
698698
metadata = {}
699699
metadata.update((run_args.get("config") or {}).get("metadata") or {})
700-
# Do not report intenral nr_completion_id in metadata.
700+
# Do not report internal nr_completion_id in metadata.
701701
metadata = {key: value for key, value in metadata.items() if key != "nr_completion_id"}
702702
tags = []
703703
tags.extend((run_args.get("config") or {}).get("tags") or [])
@@ -708,6 +708,10 @@ def _get_llm_metadata(transaction):
708708
# Grab LLM-related custom attributes off of the transaction to store as metadata on LLM events
709709
custom_attrs_dict = transaction._custom_params
710710
llm_metadata_dict = {key: value for key, value in custom_attrs_dict.items() if key.startswith("llm.")}
711+
llm_context_attrs = getattr(transaction, "_llm_context_attrs", None)
712+
if llm_context_attrs:
713+
llm_metadata_dict.update(llm_context_attrs)
714+
711715
return llm_metadata_dict
712716

713717

newrelic/hooks/mlmodel_openai.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -927,8 +927,13 @@ def is_stream(wrapped, args, kwargs):
927927
def _get_llm_attributes(transaction):
928928
"""Returns llm.* custom attributes off of the transaction."""
929929
custom_attrs_dict = transaction._custom_params
930-
llm_metadata = {key: value for key, value in custom_attrs_dict.items() if key.startswith("llm.")}
931-
return llm_metadata
930+
llm_metadata_dict = {key: value for key, value in custom_attrs_dict.items() if key.startswith("llm.")}
931+
932+
llm_context_attrs = getattr(transaction, "_llm_context_attrs", None)
933+
if llm_context_attrs:
934+
llm_metadata_dict.update(llm_context_attrs)
935+
936+
return llm_metadata_dict
932937

933938

934939
def instrument_openai_api_resources_embedding(module):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright 2010 New Relic, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
17+
from newrelic.api.background_task import background_task
18+
from newrelic.api.llm_custom_attributes import WithLlmCustomAttributes
19+
from newrelic.api.transaction import current_transaction
20+
21+
22+
@background_task()
23+
def test_llm_custom_attributes():
24+
transaction = current_transaction()
25+
with WithLlmCustomAttributes({"test": "attr", "test1": "attr1"}):
26+
assert transaction._llm_context_attrs == {"llm.test": "attr", "llm.test1": "attr1"}
27+
28+
assert not hasattr(transaction, "_llm_context_attrs")
29+
30+
31+
@pytest.mark.parametrize("context_attrs", (None, "not-a-dict"))
32+
@background_task()
33+
def test_llm_custom_attributes_no_attrs(context_attrs):
34+
transaction = current_transaction()
35+
36+
with pytest.raises(TypeError):
37+
with WithLlmCustomAttributes(context_attrs):
38+
pass
39+
40+
assert not hasattr(transaction, "_llm_context_attrs")
41+
42+
43+
@background_task()
44+
def test_llm_custom_attributes_prefixed_attrs():
45+
transaction = current_transaction()
46+
with WithLlmCustomAttributes({"llm.test": "attr", "test1": "attr1"}):
47+
# Validate API does not prefix attributes that already begin with "llm."
48+
assert transaction._llm_context_attrs == {"llm.test": "attr", "llm.test1": "attr1"}
49+
50+
assert not hasattr(transaction, "_llm_context_attrs")

tests/external_botocore/test_bedrock_chat_completion.py

+23-19
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
disabled_ai_monitoring_streaming_settings,
4646
events_sans_content,
4747
events_sans_llm_metadata,
48+
events_with_context_attrs,
4849
llm_token_count_callback,
4950
set_trace_info,
5051
)
@@ -58,6 +59,7 @@
5859
)
5960

6061
from newrelic.api.background_task import background_task
62+
from newrelic.api.llm_custom_attributes import WithLlmCustomAttributes
6163
from newrelic.api.transaction import add_custom_attribute
6264
from newrelic.common.object_names import callable_name
6365
from newrelic.hooks.external_botocore import MODEL_EXTRACTORS
@@ -161,7 +163,7 @@ def expected_invalid_access_key_error_events(model_id):
161163
def test_bedrock_chat_completion_in_txn_with_llm_metadata(
162164
set_trace_info, exercise_model, expected_events, expected_metrics
163165
):
164-
@validate_custom_events(expected_events)
166+
@validate_custom_events(events_with_context_attrs(expected_events))
165167
# One summary event, one user message, and one response message from the assistant
166168
@validate_custom_event_count(count=3)
167169
@validate_transaction_metrics(
@@ -180,7 +182,8 @@ def _test():
180182
add_custom_attribute("llm.conversation_id", "my-awesome-id")
181183
add_custom_attribute("llm.foo", "bar")
182184
add_custom_attribute("non_llm_attr", "python-agent")
183-
exercise_model(prompt=_test_bedrock_chat_completion_prompt, temperature=0.7, max_tokens=100)
185+
with WithLlmCustomAttributes({"context": "attr"}):
186+
exercise_model(prompt=_test_bedrock_chat_completion_prompt, temperature=0.7, max_tokens=100)
184187

185188
_test()
186189

@@ -320,7 +323,7 @@ def _test():
320323
def test_bedrock_chat_completion_error_invalid_model(
321324
bedrock_server, set_trace_info, response_streaming, expected_metrics
322325
):
323-
@validate_custom_events(chat_completion_invalid_model_error_events)
326+
@validate_custom_events(events_with_context_attrs(chat_completion_invalid_model_error_events))
324327
@validate_error_trace_attributes(
325328
"botocore.errorfactory:ValidationException",
326329
exact_attrs={
@@ -350,22 +353,23 @@ def _test():
350353
add_custom_attribute("non_llm_attr", "python-agent")
351354

352355
with pytest.raises(_client_error):
353-
if response_streaming:
354-
stream = bedrock_server.invoke_model_with_response_stream(
355-
body=b"{}",
356-
modelId="does-not-exist",
357-
accept="application/json",
358-
contentType="application/json",
359-
)
360-
for _ in stream:
361-
pass
362-
else:
363-
bedrock_server.invoke_model(
364-
body=b"{}",
365-
modelId="does-not-exist",
366-
accept="application/json",
367-
contentType="application/json",
368-
)
356+
with WithLlmCustomAttributes({"context": "attr"}):
357+
if response_streaming:
358+
stream = bedrock_server.invoke_model_with_response_stream(
359+
body=b"{}",
360+
modelId="does-not-exist",
361+
accept="application/json",
362+
contentType="application/json",
363+
)
364+
for _ in stream:
365+
pass
366+
else:
367+
bedrock_server.invoke_model(
368+
body=b"{}",
369+
modelId="does-not-exist",
370+
accept="application/json",
371+
contentType="application/json",
372+
)
369373

370374
_test()
371375

tests/external_botocore/test_bedrock_chat_completion_via_langchain.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@
1919
)
2020
from conftest import BOTOCORE_VERSION # pylint: disable=E0611
2121
from testing_support.fixtures import reset_core_stats_engine, validate_attributes
22-
from testing_support.ml_testing_utils import set_trace_info # noqa: F401
22+
from testing_support.ml_testing_utils import ( # noqa: F401
23+
events_with_context_attrs,
24+
set_trace_info,
25+
)
2326
from testing_support.validators.validate_custom_event import validate_custom_event_count
2427
from testing_support.validators.validate_custom_events import validate_custom_events
2528
from testing_support.validators.validate_transaction_metrics import (
2629
validate_transaction_metrics,
2730
)
2831

2932
from newrelic.api.background_task import background_task
33+
from newrelic.api.llm_custom_attributes import WithLlmCustomAttributes
3034
from newrelic.api.transaction import add_custom_attribute
3135

3236
UNSUPPORTED_LANGCHAIN_MODELS = [
@@ -105,7 +109,7 @@ def test_bedrock_chat_completion_in_txn_with_llm_metadata(
105109
expected_metrics,
106110
response_streaming,
107111
):
108-
@validate_custom_events(expected_events)
112+
@validate_custom_events(events_with_context_attrs(expected_events))
109113
# One summary event, one user message, and one response message from the assistant
110114
@validate_custom_event_count(count=6)
111115
@validate_transaction_metrics(
@@ -124,6 +128,7 @@ def _test():
124128
add_custom_attribute("llm.conversation_id", "my-awesome-id")
125129
add_custom_attribute("llm.foo", "bar")
126130
add_custom_attribute("non_llm_attr", "python-agent")
127-
exercise_model(prompt="Hi there!")
131+
with WithLlmCustomAttributes({"context": "attr"}):
132+
exercise_model(prompt="Hi there!")
128133

129134
_test()

0 commit comments

Comments
 (0)