diff --git a/newrelic/api/opentelemetry.py b/newrelic/api/opentelemetry.py new file mode 100644 index 000000000..6a5c14da1 --- /dev/null +++ b/newrelic/api/opentelemetry.py @@ -0,0 +1,439 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import sys +from contextlib import contextmanager + +from opentelemetry import trace as otel_api_trace + +from newrelic.api.application import application_instance +from newrelic.api.background_task import BackgroundTask +from newrelic.api.datastore_trace import DatastoreTrace +from newrelic.api.external_trace import ExternalTrace +from newrelic.api.function_trace import FunctionTrace +from newrelic.api.message_trace import MessageTrace +from newrelic.api.message_transaction import MessageTransaction +from newrelic.api.time_trace import current_trace, notice_error +from newrelic.api.transaction import Sentinel, current_transaction +from newrelic.api.web_transaction import WebTransaction +from newrelic.core.otlp_utils import create_resource + +_logger = logging.getLogger(__name__) + + +# ---------------------------------------------- +# Custom OTel Spans and Traces +# ---------------------------------------------- + +# TracerProvider: we can think of this as the agent instance. Only one can exist +# SpanProcessor: we can think of this as an application. In NR, we can have multiple applications +# though right now, we can only do SpanProcessor and SynchronousMultiSpanProcessor +# Tracer: we can think of this as the transaction. +# Span: we can think of this as the trace. +# Links functionality has now been enabled but not implemented yet. Links are relationships +# between spans, but lateral in hierarchy. In NR we only have parent-child relationships. +# We may want to preserve this information with a custom attribute. We can also add this +# as a new attribute in a trace, but it will still not be seen in the UI other than a trace +# attribute. + + +class Span(otel_api_trace.Span): + def __init__( + self, + name=None, + parent=None, # SpanContext + resource=None, + attributes=None, + kind=otel_api_trace.SpanKind.INTERNAL, + nr_transaction=None, + nr_trace_type=FunctionTrace, + instrumenting_module=None, + *args, + **kwargs, + ): + self.name = name + self.otel_parent = parent + self.attributes = attributes or {} + self.kind = kind + self.nr_transaction = ( + nr_transaction or current_transaction() + ) # This attribute is purely to prevent garbage collection + self.nr_trace = None + self.instrumenting_module = instrumenting_module + + # Do not create a New Relic trace if parent + # is a remote span and it is not sampled + if self._remote() and not self._sampled(): + return + + self.nr_parent = None + current_nr_trace = current_trace() + if ( + not self.otel_parent + or (self.otel_parent and self.otel_parent.span_id == int(current_nr_trace.guid, 16)) + or (self.otel_parent and isinstance(current_nr_trace, Sentinel)) + ): + # Expected to come here if one of three scenarios have occured: + # 1. `start_as_current_span` was used. + # 2. `start_span` was used and the current span was explicitly set + # to the newly created one. + # 3. Only a Sentinel Trace exists so far while still having a + # remote parent. From OTel's end, this will be represented + # as a `NonRecordingSpan` (and be seen as `None` at this + # point). This covers cases where span is remote. + self.nr_parent = current_nr_trace + else: + # Not sure if there is a usecase where we could get in here + # but for debug purposes, we will raise an error + _logger.warning( + "OpenTelemetry span (%s) and NR trace (%s) do not match nor correspond to a remote span.", + self.otel_parent, + current_nr_trace, + ) + raise ValueError("Unexpected span parent scenario encountered") + + if nr_trace_type == FunctionTrace: + trace_kwargs = {"name": self.name, "params": self.attributes, "parent": self.nr_parent} + self.nr_trace = nr_trace_type(**trace_kwargs) + elif nr_trace_type == DatastoreTrace: + trace_kwargs = { + "product": self.instrumenting_module, + "target": None, + "operation": self.name, + "parent": self.nr_parent, + } + self.nr_trace = nr_trace_type(**trace_kwargs) + elif nr_trace_type == ExternalTrace: + trace_kwargs = { + "library": self.name or self.instrumenting_module, + "url": self.attributes.get("http.url"), + "method": self.attributes.get("http.method"), + "parent": self.nr_parent, + } + self.nr_trace = nr_trace_type(**trace_kwargs) + elif nr_trace_type == MessageTrace: + trace_kwargs = { + "library": self.instrumenting_module, + "operation": "Produce", + "destination_type": "Topic", + "destination_name": self.name, + "params": self.attributes, + "parent": self.nr_parent, + "terminal": False, + } + self.nr_trace = nr_trace_type(**trace_kwargs) + else: + trace_kwargs = {"name": self.name, "params": self.attributes, "parent": self.nr_parent} + self.nr_trace = nr_trace_type(**trace_kwargs) + + self.nr_trace.__enter__() + + def _sampled(self): + # Uses NR to determine if the trace is sampled + # + # transaction.sampled can be `None`, `True`, `False`. + # If `None`, this has not been computed by NR which + # can also mean the following: + # 1. There was not a context passed in that explicitly has sampling disabled. + # This flag would be found in the traceparent or traceparent and tracespan headers. + # 2. Transaction was not created where DT headers are accepted during __init__ + # Therefore, we will treat a value of `None` as `True` for now. + # + # The primary reason for this behavior is because Otel expects to + # only be able to record information like events and attributes + # when `is_recording()` == `True` + + if self.otel_parent: + return bool(self.otel_parent.trace_flags) + else: + return bool(self.nr_transaction and (self.nr_transaction.sampled or (self.nr_transaction.sampled is None))) + + def _remote(self): + # Remote span denotes if propagated from a remote parent + return bool(self.otel_parent and self.otel_parent.is_remote) + + def get_span_context(self): + if not getattr(self, "nr_trace", False): + return otel_api_trace.INVALID_SPAN_CONTEXT + + otel_tracestate_headers = None + + return otel_api_trace.SpanContext( + trace_id=int(self.nr_transaction.trace_id, 16), + span_id=int(self.nr_trace.guid, 16), + is_remote=self._remote(), + trace_flags=otel_api_trace.TraceFlags(0x01 if self._sampled() else 0x00), + trace_state=otel_api_trace.TraceState(otel_tracestate_headers), + ) + + def set_attribute(self, key, value): + self.attributes[key] = value + + def set_attributes(self, attributes): + for key, value in attributes.items(): + self.set_attribute(key, value) + + def _set_attributes_in_nr(self, otel_attributes=None): + if not (otel_attributes and hasattr(self, "nr_trace") and self.nr_trace): + return + for key, value in otel_attributes.items(): + self.nr_trace.add_custom_attribute(key, value) + + def add_event(self, name, attributes=None, timestamp=None): + # TODO: Not implemented yet. + # We can implement this as a log event + raise NotImplementedError("TODO: We can implement this as a log event.") + + def add_link(self, context=None, attributes=None): + # TODO: Not implemented yet. + raise NotImplementedError("Not implemented yet.") + + def update_name(self, name): + # Sentinel, MessageTrace, DatastoreTrace, and ExternalTrace + # types do not have a name attribute + self._name = name + if hasattr(self, "nr_trace") and hasattr(self.nr_trace, "name"): + self.nr_trace.name = self._name + + def is_recording(self): + return self._sampled() and not (getattr(self.nr_trace, None), "end_time", None) + + def set_status(self, status, description=None): + # TODO: not implemented yet + raise NotImplementedError("Not implemented yet") + + def record_exception(self, exception, attributes=None, timestamp=None, escaped=False): + error_args = sys.exc_info() if not exception else (type(exception), exception, exception.__traceback__) + + if not hasattr(self, "nr_trace"): + notice_error(error_args, attributes=attributes) + else: + self.nr_trace.notice_error(error_args, attributes=attributes) + + def end(self, end_time=None, *args, **kwargs): + # We will ignore the end_time parameter and use NR's end_time + + # Check to see if New Relic trace ever existed or, + # if it does, that trace has already ended + nr_trace = hasattr(self, "nr_trace") + if not nr_trace or (nr_trace and getattr(nr_trace, "end_time", None)): + return + + # Add attributes as Trace parameters + self._set_attributes_in_nr(self.attributes) + + # For each kind of NR Trace, we will need to add + # specific attributes since they were likely not + # available at the time of the trace's creation. + if self.instrumenting_module in ("Redis", "Mongodb"): + self.nr_trace.host = self.attributes.get("net.peer.name", self.attributes.get("server.address")) + self.nr_trace.port_path_or_id = self.attributes.get("net.peer.port", self.attributes.get("server.port")) + self.nr_trace.database_name = self.attributes.get("db.name") + self.nr_trace.product = self.attributes.get("db.system") + elif self.instrumenting_module == "Dynamodb": + self.nr_trace.database_name = self.attributes.get("db.name") + self.nr_trace.product = self.attributes.get("db.system") + self.nr_trace.port_path_or_id = self.attributes.get("net.peer.port") + self.nr_trace.host = self.attributes.get("dynamodb.{region}.amazonaws.com") + + # Set SpanKind attribute + self._set_attributes_in_nr({"span.kind": self.kind}) + + self.nr_trace.__exit__(*sys.exc_info()) + + +class Tracer(otel_api_trace.Tracer): + def __init__(self, resource=None, instrumentation_library=None, *args, **kwargs): + self.resource = resource + self.instrumentation_library = instrumentation_library.split(".")[-1].capitalize() + + def start_span( + self, + name, + context=None, # Optional[Context] + kind=otel_api_trace.SpanKind.INTERNAL, + attributes=None, + links=None, + start_time=None, + record_exception=True, + set_status_on_exception=True, + *args, + **kwargs, + ): + + nr_trace_type = FunctionTrace + transaction = current_transaction() + self.nr_application = application_instance() + self.attributes = attributes or {} + + if not self.nr_application.settings.otel_bridge.enabled: + return otel_api_trace.INVALID_SPAN + + # Retrieve parent span + parent_span_context = otel_api_trace.get_current_span(context).get_span_context() + + if parent_span_context is None or not parent_span_context.is_valid: + parent_span_context = None + + # If remote_parent, transaction must be created, regardless of kind type + if parent_span_context and parent_span_context.is_remote: + if kind in (otel_api_trace.SpanKind.SERVER, otel_api_trace.SpanKind.CLIENT): + # This is a web request + headers = self.attributes.pop("nr.http.headers", None) + scheme = self.attributes.get("http.scheme") + host = self.attributes.get("http.server_name") + port = self.attributes.get("net.host.port") + request_method = self.attributes.get("http.method") + request_path = self.attributes.get("http.route") + transaction = WebTransaction( + self.nr_application, + name=name, + scheme=scheme, + host=host, + port=port, + request_method=request_method, + request_path=request_path, + headers=headers, + ) + elif kind in (otel_api_trace.SpanKind.PRODUCER, otel_api_trace.SpanKind.INTERNAL): + transaction = BackgroundTask(self.nr_application, name=name) + elif kind == otel_api_trace.SpanKind.CONSUMER: + transaction = MessageTransaction( + library=self.instrumentation_library, + destination_type="Topic", + destination_name=name, + application=self.nr_application, + transport_type=self.instrumentation_library, + headers=headers, + ) + + transaction.__enter__() + + # If not parent_span_context or not parent_span_context.is_remote + # To simplify calculation logic, we will use DeMorgan's Theorem: + # (!parent_span_context or !parent_span_context.is_remote) + # !!(!parent_span_context or !parent_span_context.is_remote) + # !(parent_span_context and parent_span_context.is_remote) + elif not (parent_span_context and parent_span_context.is_remote): + if kind == otel_api_trace.SpanKind.SERVER: + if transaction: + nr_trace_type = FunctionTrace + elif not transaction: + # This is a web request + headers = self.attributes.pop("nr.http.headers", None) + scheme = self.attributes.get("http.scheme") + host = self.attributes.get("http.server_name") + port = self.attributes.get("net.host.port") + request_method = self.attributes.get("http.method") + request_path = self.attributes.get("http.route") + + transaction = WebTransaction( + self.nr_application, + name=name, + scheme=scheme, + host=host, + port=port, + request_method=request_method, + request_path=request_path, + headers=headers, + ) + transaction.__enter__() + elif kind == otel_api_trace.SpanKind.INTERNAL: + if transaction: + nr_trace_type = FunctionTrace + else: + return otel_api_trace.INVALID_SPAN + elif kind == otel_api_trace.SpanKind.CLIENT: + if transaction: + if self.attributes.get("http.url") or self.attributes.get("http.method"): + nr_trace_type = ExternalTrace + else: + nr_trace_type = DatastoreTrace + else: + return otel_api_trace.INVALID_SPAN + elif kind == otel_api_trace.SpanKind.CONSUMER: + if transaction: + nr_trace_type = FunctionTrace + elif not transaction: + transaction = MessageTransaction( + library=self.instrumentation_library, + destination_type="Topic", + destination_name=name, + application=self.nr_application, + transport_type=self.instrumentation_library, + headers=headers, + ) + transaction.__enter__() + elif kind == otel_api_trace.SpanKind.PRODUCER: + if transaction: + nr_trace_type = MessageTrace + else: + return otel_api_trace.INVALID_SPAN + + # Start transactions in this method, but start traces + # in Span. Span function will take in some Span args + # as well as info for NR applications/transactions + span = Span( + name=name, + parent=parent_span_context, + resource=self.resource, + attributes=attributes, + kind=kind, + nr_transaction=transaction, + nr_trace_type=nr_trace_type, + instrumenting_module=self.instrumentation_library, + ) + + return span + + @contextmanager + def start_as_current_span( + self, + name=None, + context=None, + kind=otel_api_trace.SpanKind.INTERNAL, + attributes=None, + links=None, + end_on_exit=True, + record_exception=True, + set_status_on_exception=True, + ): + span = self.start_span( + name, + context=context, + kind=kind, + attributes=attributes, + record_exception=record_exception, + set_status_on_exception=set_status_on_exception, + ) + + with otel_api_trace.use_span(span, end_on_exit=end_on_exit, record_exception=record_exception) as current_span: + yield current_span + + +class TracerProvider(otel_api_trace.TracerProvider): + def __init__(self, *args, **kwargs): + self._resource = create_resource(hybrid_bridge=True) + + def get_tracer( + self, + instrumenting_module_name="Default", + instrumenting_library_version=None, + schema_url=None, + attributes=None, + *args, + **kwargs, + ): + return Tracer(resource=self._resource, instrumentation_library=instrumenting_module_name, *args, **kwargs) \ No newline at end of file diff --git a/newrelic/config.py b/newrelic/config.py index 05981778b..cba1f3938 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -691,6 +691,7 @@ def _process_configuration(section): _process_setting(section, "instrumentation.middleware.django.enabled", "getboolean", None) _process_setting(section, "instrumentation.middleware.django.exclude", "get", _map_inc_excl_middleware) _process_setting(section, "instrumentation.middleware.django.include", "get", _map_inc_excl_middleware) + _process_setting(section, "otel_bridge.enabled", "getboolean", None) # Loading of configuration from specified file and for specified @@ -4367,6 +4368,15 @@ def _process_module_builtin_defaults(): "pyzeebe.worker.job_executor", "newrelic.hooks.external_pyzeebe", "instrument_pyzeebe_worker_job_executor" ) + # Hybrid Agent Hooks + _process_module_definition( + "opentelemetry.trace", "newrelic.hooks.hybridagent_opentelemetry", "instrument_trace_api" + ) + + _process_module_definition( + "opentelemetry.instrumentation.utils", "newrelic.hooks.hybridagent_opentelemetry", "instrument_utils" + ) + def _process_module_entry_points(): try: diff --git a/newrelic/core/config.py b/newrelic/core/config.py index 1330be8af..1923424af 100644 --- a/newrelic/core/config.py +++ b/newrelic/core/config.py @@ -534,6 +534,10 @@ class EventHarvestConfigHarvestLimitSettings(Settings): nested = True +class OtelBridgeSettings(Settings): + pass + + _settings = TopLevelSettings() _settings.agent_limits = AgentLimitsSettings() _settings.application_logging = ApplicationLoggingSettings() @@ -620,6 +624,7 @@ class EventHarvestConfigHarvestLimitSettings(Settings): _settings.instrumentation.middleware = InstrumentationMiddlewareSettings() _settings.instrumentation.middleware.django = InstrumentationDjangoMiddlewareSettings() _settings.message_tracer = MessageTracerSettings() +_settings.otel_bridge = OtelBridgeSettings() _settings.process_host = ProcessHostSettings() _settings.rum = RumSettings() _settings.serverless_mode = ServerlessModeSettings() @@ -1267,6 +1272,7 @@ def default_otlp_host(host): _settings.azure_operator.enabled = _environ_as_bool("NEW_RELIC_AZURE_OPERATOR_ENABLED", default=False) _settings.package_reporting.enabled = _environ_as_bool("NEW_RELIC_PACKAGE_REPORTING_ENABLED", default=True) _settings.ml_insights_events.enabled = _environ_as_bool("NEW_RELIC_ML_INSIGHTS_EVENTS_ENABLED", default=False) +_settings.otel_bridge.enabled = _environ_as_bool("NEW_RELIC_OTEL_BRIDGE_ENABLED", default=False) def global_settings(): diff --git a/newrelic/core/otlp_utils.py b/newrelic/core/otlp_utils.py index e34ba3e6c..e9753eb24 100644 --- a/newrelic/core/otlp_utils.py +++ b/newrelic/core/otlp_utils.py @@ -116,8 +116,9 @@ def create_key_values_from_iterable(iterable): return list(filter(lambda i: i is not None, (create_key_value(key, value) for key, value in iterable))) -def create_resource(attributes=None, attach_apm_entity=True): - attributes = attributes or {"instrumentation.provider": "newrelic-opentelemetry-python-ml"} +def create_resource(attributes=None, attach_apm_entity=True, hybrid_bridge=False): + instrumentation_provider = "newrelic-opentelemetry-bridge" if hybrid_bridge else "newrelic-opentelemetry-python-ml" + attributes = attributes or {"instrumentation.provider": instrumentation_provider} if attach_apm_entity: metadata = get_service_linking_metadata() attributes.update(metadata) diff --git a/newrelic/hooks/hybridagent_opentelemetry.py b/newrelic/hooks/hybridagent_opentelemetry.py new file mode 100644 index 000000000..4c5538d09 --- /dev/null +++ b/newrelic/hooks/hybridagent_opentelemetry.py @@ -0,0 +1,172 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from newrelic.api.application import application_instance +from newrelic.api.time_trace import add_custom_span_attribute, current_trace +from newrelic.api.transaction import Sentinel, current_transaction +from newrelic.common.object_wrapper import wrap_function_wrapper +from newrelic.common.signature import bind_args +from newrelic.core.config import global_settings + +_logger = logging.getLogger(__name__) +_TRACER_PROVIDER = None + +########################################### +# Trace Instrumentation +########################################### + + +def wrap_set_tracer_provider(wrapped, instance, args, kwargs): + settings = global_settings() + + if not settings.otel_bridge.enabled: + return wrapped(*args, **kwargs) + + global _TRACER_PROVIDER + + if _TRACER_PROVIDER is None: + bound_args = bind_args(wrapped, args, kwargs) + tracer_provider = bound_args.get("tracer_provider") + _TRACER_PROVIDER = tracer_provider + else: + _logger.warning("TracerProvider has already been set.") + + +def wrap_get_tracer_provider(wrapped, instance, args, kwargs): + settings = global_settings() + + if not settings.otel_bridge.enabled: + return wrapped(*args, **kwargs) + + # This needs to act as a singleton, like the agent instance. + # We should initialize the agent here as well, if there is + # not an instance already. + application = application_instance(activate=False) + if not application or (application and not application.active): + application_instance().activate() + + global _TRACER_PROVIDER + + if _TRACER_PROVIDER is None: + from newrelic.api.opentelemetry import TracerProvider + + hybrid_agent_tracer_provider = TracerProvider("hybrid_agent_tracer_provider") + _TRACER_PROVIDER = hybrid_agent_tracer_provider + return _TRACER_PROVIDER + + +def wrap_get_current_span(wrapped, instance, args, kwargs): + transaction = current_transaction() + trace = current_trace() + + # If a NR trace does not exist (aside from the Sentinel + # trace), return the original function's result. + if not transaction or isinstance(trace, Sentinel): + return wrapped(*args, **kwargs) + + # Do not allow the wrapper to continue if + # the Hybrid Agent setting is not enabled + settings = transaction.settings or global_settings() + + if not settings.otel_bridge.enabled: + return wrapped(*args, **kwargs) + + # If a NR trace does exist, check to see if the current + # OTel span corresponds to the current NR trace. If so, + # return the original function's result. + span = wrapped(*args, **kwargs) + + if span.get_span_context().span_id == int(trace.guid, 16): + return span + + # If the current OTel span does not match the current NR + # trace, this means that a NR trace was created either + # manually or through the NR agent. Either way, the OTel + # API was not used to create a span object. The Hybrid + # Agent's Span object creates a NR trace but since the NR + # trace has already been created, we just need a symbolic + # OTel span to represent it the span object. A LazySpan + # will be created. It will effectively be a NonRecordingSpan + # with the ability to add custom attributes. + + from opentelemetry import trace as otel_api_trace + + class LazySpan(otel_api_trace.NonRecordingSpan): + def set_attribute(self, key, value): + add_custom_span_attribute(key, value) + + def set_attributes(self, attributes): + for key, value in attributes.items(): + add_custom_span_attribute(key, value) + + otel_tracestate_headers = None + + span_context = otel_api_trace.SpanContext( + trace_id=int(transaction.trace_id, 16), + span_id=int(trace.guid, 16), + is_remote=span.get_span_context().is_remote, + trace_flags=otel_api_trace.TraceFlags(span.get_span_context().trace_flags), + trace_state=otel_api_trace.TraceState(otel_tracestate_headers), + ) + + return LazySpan(span_context) + + +def wrap_start_internal_or_server_span(wrapped, instance, args, kwargs): + # We want to take the NR version of the context_carrier + # and put that into the attributes. Keep the original + # context_carrier intact. + + # Do not allow the wrapper to continue if + # the Hybrid Agent setting is not enabled + settings = global_settings() + + if not settings.otel_bridge.enabled: + return wrapped(*args, **kwargs) + + bound_args = bind_args(wrapped, args, kwargs) + context_carrier = bound_args.get("context_carrier") + attributes = bound_args.get("attributes", {}) + + if context_carrier: + if ("HTTP_HOST" in context_carrier) or ("http_version" in context_carrier): + # This is an HTTP request (WSGI, ASGI, or otherwise) + nr_environ = context_carrier.copy() + attributes["nr.http.headers"] = nr_environ + + else: + nr_headers = context_carrier.copy() + attributes["nr.nonhttp.headers"] = nr_headers + + bound_args["attributes"] = attributes + + return wrapped(**bound_args) + + +def instrument_trace_api(module): + if hasattr(module, "set_tracer_provider"): + wrap_function_wrapper(module, "set_tracer_provider", wrap_set_tracer_provider) + + if hasattr(module, "get_tracer_provider"): + wrap_function_wrapper(module, "get_tracer_provider", wrap_get_tracer_provider) + + if hasattr(module, "get_current_span"): + wrap_function_wrapper(module, "get_current_span", wrap_get_current_span) + + +def instrument_utils(module): + if hasattr(module, "_start_internal_or_server_span"): + wrap_function_wrapper(module, "_start_internal_or_server_span", wrap_start_internal_or_server_span) diff --git a/tests/hybridagent_opentelemetry/conftest.py b/tests/hybridagent_opentelemetry/conftest.py new file mode 100644 index 000000000..fc5afe53b --- /dev/null +++ b/tests/hybridagent_opentelemetry/conftest.py @@ -0,0 +1,40 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from opentelemetry import trace +from testing_support.fixtures import collector_agent_registration_fixture, collector_available_fixture + +from newrelic.api.opentelemetry import TracerProvider + +_default_settings = { + "package_reporting.enabled": False, # Turn off package reporting for testing as it causes slow downs. + "transaction_tracer.explain_threshold": 0.0, + "transaction_tracer.transaction_threshold": 0.0, + "transaction_tracer.stack_trace_threshold": 0.0, + "debug.log_data_collector_payloads": True, + "debug.record_transaction_failure": True, + "otel_bridge.enabled": True, +} + +collector_agent_registration = collector_agent_registration_fixture( + app_name="Python Agent Test (Hybrid Agent)", default_settings=_default_settings +) + +@pytest.fixture(scope="session") +def tracer(): + trace_provider = TracerProvider() + trace.set_tracer_provider(trace_provider) + + return trace.get_tracer(__name__) \ No newline at end of file diff --git a/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py b/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py new file mode 100644 index 000000000..9a31d2bf6 --- /dev/null +++ b/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py @@ -0,0 +1,112 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from opentelemetry import trace as otel_api_trace +from testing_support.validators.validate_error_event_attributes import validate_error_event_attributes +from testing_support.validators.validate_span_events import validate_span_events +from testing_support.validators.validate_transaction_count import validate_transaction_count +from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics + +from newrelic.api.application import application_instance +from newrelic.api.background_task import BackgroundTask +from newrelic.api.function_trace import FunctionTrace +from newrelic.api.time_trace import current_trace +from newrelic.api.transaction import current_transaction + + +# Does not create segment without a transaction +@validate_transaction_count(0) +def test_does_not_create_segment_without_a_transaction(tracer): + with tracer.start_as_current_span(name="Bar", kind=otel_api_trace.SpanKind.INTERNAL): + # The OpenTelmetry span should not be created + assert otel_api_trace.get_current_span() == otel_api_trace.INVALID_SPAN + + # There should be no transaction + assert not current_transaction() + + +# Creates OpenTelemetry segment in a transaction +@validate_transaction_metrics(name="Foo", background_task=True) +@validate_span_events( + exact_intrinsics={"name": "Function/Bar", "category": "generic"}, expected_intrinsics=("parentId",) +) +@validate_span_events(exact_intrinsics={"name": "Function/Foo", "category": "generic", "nr.entryPoint": True}) +def test_creates_opentelemetry_segment_in_a_transaction(tracer): + application = application_instance(activate=False) + + with BackgroundTask(application, name="Foo"): + with tracer.start_as_current_span(name="Bar", kind=otel_api_trace.SpanKind.INTERNAL): + # OpenTelemetry API and New Relic API report the same traceId + assert otel_api_trace.get_current_span().get_span_context().trace_id == int( + current_transaction()._trace_id, 16 + ) + + # OpenTelemetry API and New Relic API report the same spanId + assert otel_api_trace.get_current_span().get_span_context().span_id == int(current_trace().guid, 16) + + +# Creates New Relic span as child of OpenTelemetry span +@validate_transaction_metrics(name="Foo", background_task=True) +@validate_span_events( + exact_intrinsics={"name": "Function/Baz", "category": "generic"}, expected_intrinsics=("parentId",) +) +@validate_span_events( + exact_intrinsics={"name": "Function/Bar", "category": "generic"}, expected_intrinsics=("parentId",) +) +@validate_span_events(exact_intrinsics={"name": "Function/Foo", "category": "generic"}) +def test_creates_new_relic_span_as_child_of_open_telemetry_span(tracer): + application = application_instance(activate=False) + + with BackgroundTask(application, name="Foo"): + with tracer.start_as_current_span(name="Bar", kind=otel_api_trace.SpanKind.INTERNAL): + with FunctionTrace(name="Baz"): + # OpenTelemetry API and New Relic API report the same traceId + assert otel_api_trace.get_current_span().get_span_context().trace_id == int( + current_transaction().trace_id, 16 + ) + + # OpenTelemetry API and New Relic API report the same spanId + assert otel_api_trace.get_current_span().get_span_context().span_id == int(current_trace().guid, 16) + + +# OpenTelemetry API can add custom attributes to spans +@validate_transaction_metrics(name="Foo", background_task=True) +@validate_span_events(exact_intrinsics={"name": "Function/Baz"}, exact_users={"spanNumber": 2}) +@validate_span_events(exact_intrinsics={"name": "Function/Bar"}, exact_users={"spanNumber": 1}) +def test_opentelemetry_api_can_add_custom_attributes_to_spans(tracer): + application = application_instance(activate=False) + + with BackgroundTask(application, name="Foo"): + with tracer.start_as_current_span(name="Bar", kind=otel_api_trace.SpanKind.INTERNAL): + with FunctionTrace(name="Baz"): + otel_api_trace.get_current_span().set_attribute("spanNumber", 2) + + otel_api_trace.get_current_span().set_attribute("spanNumber", 1) + + +# OpenTelemetry API can record errors +@validate_transaction_metrics(name="Foo", background_task=True) +@validate_error_event_attributes( + exact_attrs={"agent": {}, "intrinsic": {"error.message": "Test exception message"}, "user": {}} +) +@validate_span_events(exact_intrinsics={"name": "Function/Bar"}) +def test_opentelemetry_api_can_record_errors(tracer): + application = application_instance(activate=False) + + with BackgroundTask(application, name="Foo"): + with tracer.start_as_current_span(name="Bar", kind=otel_api_trace.SpanKind.INTERNAL): + try: + raise Exception("Test exception message") + except Exception as e: + otel_api_trace.get_current_span().record_exception(e) diff --git a/tests/hybridagent_opentelemetry/test_settings.py b/tests/hybridagent_opentelemetry/test_settings.py new file mode 100644 index 000000000..6b50b020f --- /dev/null +++ b/tests/hybridagent_opentelemetry/test_settings.py @@ -0,0 +1,74 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from newrelic.api.background_task import background_task +from newrelic.api.time_trace import current_trace +from newrelic.api.transaction import current_transaction + +from testing_support.validators.validate_span_events import validate_span_events +from testing_support.fixtures import override_application_settings + + +@pytest.mark.parametrize( + "enabled", + [True, False] +) +def test_distributed_tracing_enabled(tracer, enabled): + @override_application_settings({"otel_bridge.enabled": enabled}) + @validate_span_events( + count=1, + exact_intrinsics={ + "name": "Function/Foo" + } + ) + @validate_span_events( + count=1 if enabled else 0, + exact_intrinsics={ + "name": "Function/Bar" + } + ) + @validate_span_events( + count=1 if enabled else 0, + exact_intrinsics={ + "name": "Function/Baz" + } + ) + @background_task(name="Foo") + def _test(): + with tracer.start_as_current_span(name="Bar") as bar_span: + bar_span_id = bar_span.get_span_context().span_id + bar_trace_id = bar_span.get_span_context().trace_id + + with tracer.start_as_current_span(name="Baz") as baz_span: + nr_trace = current_trace() + nr_transaction = current_transaction() + baz_span_id = baz_span.get_span_context().span_id + baz_trace_id = baz_span.get_span_context().trace_id + + assert bar_trace_id == baz_trace_id + + if enabled: + assert bar_trace_id == baz_trace_id == int(nr_transaction.trace_id, 16) + assert baz_span_id == int(nr_trace.guid, 16) + assert bar_span_id == int(nr_trace.parent.guid, 16) + assert nr_trace is not nr_trace.root + + else: + assert bar_trace_id == baz_trace_id == 0 + assert baz_span_id == bar_span_id == 0 + assert nr_trace is nr_trace.root + + _test() diff --git a/tests/hybridagent_opentelemetry/test_traces_attributes.py b/tests/hybridagent_opentelemetry/test_traces_attributes.py new file mode 100644 index 000000000..b50a99e1b --- /dev/null +++ b/tests/hybridagent_opentelemetry/test_traces_attributes.py @@ -0,0 +1,100 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from opentelemetry import trace + +from newrelic.api.background_task import background_task +from newrelic.api.function_trace import function_trace +from newrelic.api.time_trace import add_custom_span_attribute +from newrelic.api.transaction import add_custom_attribute + +from testing_support.validators.validate_span_events import validate_span_events + + +def test_trace_with_span_attributes(tracer): + @validate_span_events( + count=1, + exact_intrinsics={ + "name": "Function/test_traces_attributes:test_trace_with_span_attributes.._test", + "transaction.name": "OtherTransaction/Function/test_traces_attributes:test_trace_with_span_attributes.._test", + "sampled": True, + }, + ) + @validate_span_events( + count=1, + exact_intrinsics={"name": "Function/otelspan", "sampled": True}, + expected_intrinsics={"priority": None, "traceId": None, "guid": None}, + exact_users={"otel_attribute": "bar", "nr_attribute": "foo"}, + ) + @background_task() + def _test(): + with tracer.start_as_current_span("otelspan", attributes={"otel_attribute": "bar"}): + add_custom_span_attribute("nr_attribute", "foo") + + _test() + + +def test_trace_with_otel_to_newrelic(tracer): + """ + This test adds custom attributes to the transaction and trace. + * `add_custom_attribute` adds custom attributes to the transaction. + * `add_custom_span_attribute` adds custom attributes to the trace. + NOTE: a transaction's custom attributes are added to the root + span's user attributes. + """ + + @function_trace() + def newrelic_function_trace(): + add_custom_attribute("NR_trace_attribute_from_function", "NR trace attribute") + add_custom_span_attribute("NR_span_attribute_from_function", "NR span attribute") + otel_span = trace.get_current_span() + otel_span.set_attribute("otel_span_attribute_from_function", "OTel span attribute from FT") + + @validate_span_events( + count=1, + exact_intrinsics={ + "name": "Function/test_traces_attributes:test_trace_with_otel_to_newrelic..newrelic_background_task", + "transaction.name": "OtherTransaction/Function/test_traces_attributes:test_trace_with_otel_to_newrelic..newrelic_background_task", + "sampled": True, + }, + exact_users={"NR_trace_attribute_from_function": "NR trace attribute"}, + ) + @validate_span_events( + count=1, + exact_intrinsics={"name": "Function/foo", "sampled": True}, + expected_intrinsics={"priority": None, "traceId": None, "guid": None}, + exact_users={ + "nr_trace_attribute": "NR span attribute from BG", + "otel_span_attribute_BG": "OTel span attribute from BG", + }, + ) + @validate_span_events( + count=1, + exact_intrinsics={ + "name": "Function/test_traces_attributes:test_trace_with_otel_to_newrelic..newrelic_function_trace", + "sampled": True, + }, + exact_users={ + "NR_span_attribute_from_function": "NR span attribute", + "otel_span_attribute_from_function": "OTel span attribute from FT", + }, + ) + @background_task() + def newrelic_background_task(): + with tracer.start_as_current_span("foo") as otel_span: + add_custom_span_attribute("nr_trace_attribute", "NR span attribute from BG") + otel_span.set_attribute("otel_span_attribute_BG", "OTel span attribute from BG") + newrelic_function_trace() + + newrelic_background_task() diff --git a/tox.ini b/tox.ini index 98cea6ee2..5017adceb 100644 --- a/tox.ini +++ b/tox.ini @@ -178,6 +178,7 @@ envlist = python-framework_tornado-{py38,py39,py310,py311,py312,py313,py314}-tornadolatest, ; Remove `python-framework_tornado-py314-tornadomaster` temporarily python-framework_tornado-{py310,py311,py312,py313}-tornadomaster, + python-hybridagent_opentelemetry-{py38,py39,py310,py311,py312,py313,py314,pypy311}-opentelemetrylatest, python-logger_logging-{py38,py39,py310,py311,py312,py313,py314,pypy311}, python-logger_loguru-{py38,py39,py310,py311,py312,py313,py314,pypy311}-logurulatest, python-logger_structlog-{py38,py39,py310,py311,py312,py313,py314,pypy311}-structloglatest, @@ -426,6 +427,7 @@ deps = mlmodel_autogen-autogenlatest: autogen-agentchat mlmodel_autogen: mcp mlmodel_gemini: google-genai + hybridagent_opentelemetry: opentelemetry-api mlmodel_openai-openai0: openai[datalib]<1.0 mlmodel_openai-openai107: openai[datalib]<1.8 mlmodel_openai-openai107: httpx<0.28 @@ -572,6 +574,7 @@ changedir = framework_starlette: tests/framework_starlette framework_strawberry: tests/framework_strawberry framework_tornado: tests/framework_tornado + hybridagent_opentelemetry: tests/hybridagent_opentelemetry logger_logging: tests/logger_logging logger_loguru: tests/logger_loguru logger_structlog: tests/logger_structlog