From 2b2f909f4aead36a979826384f1009da84be75c6 Mon Sep 17 00:00:00 2001 From: Lalleh Rafeei Date: Fri, 12 Dec 2025 11:05:46 -0800 Subject: [PATCH 1/9] Merge propagation changes --- newrelic/api/opentelemetry.py | 210 +++++- newrelic/api/transaction.py | 12 + newrelic/config.py | 20 +- newrelic/core/config.py | 4 +- newrelic/hooks/hybridagent_opentelemetry.py | 108 ++- .../cross_agent/TestCaseDefinitions.json | 641 ++++++++++++++++++ ...races_attributes.py => test_attributes.py} | 39 +- .../test_context_propagation.py | 249 +++++++ .../test_hybrid_cross_agent.py | 235 ++++++- .../test_settings.py | 30 +- .../hybridagent_opentelemetry/test_status.py | 133 ++++ tox.ini | 1 + 12 files changed, 1603 insertions(+), 79 deletions(-) create mode 100644 tests/hybridagent_opentelemetry/cross_agent/TestCaseDefinitions.json rename tests/hybridagent_opentelemetry/{test_traces_attributes.py => test_attributes.py} (64%) create mode 100644 tests/hybridagent_opentelemetry/test_context_propagation.py create mode 100644 tests/hybridagent_opentelemetry/test_status.py diff --git a/newrelic/api/opentelemetry.py b/newrelic/api/opentelemetry.py index 56df7dedd..1748e88ba 100644 --- a/newrelic/api/opentelemetry.py +++ b/newrelic/api/opentelemetry.py @@ -17,6 +17,11 @@ from contextlib import contextmanager from opentelemetry import trace as otel_api_trace +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator +from opentelemetry.trace.status import Status, StatusCode +from opentelemetry.baggage.propagation import W3CBaggagePropagator +from opentelemetry.propagators.composite import CompositePropagator +from opentelemetry.propagate import set_global_textmap from newrelic.api.application import application_instance from newrelic.api.background_task import BackgroundTask @@ -26,29 +31,69 @@ 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.transaction import Sentinel, current_transaction, accept_distributed_trace_headers, insert_distributed_trace_headers from newrelic.api.web_transaction import WebTransaction + from newrelic.core.otlp_utils import create_resource _logger = logging.getLogger(__name__) +class NRTraceContextPropagator(TraceContextTextMapPropagator): + LIST_OF_TRACEPARENT_KEYS = ("traceparent", "HTTP_TRACEPARENT") + LIST_OF_TRACESTATE_KEYS = ("tracestate", "HTTP_TRACESTATE") + HEADER_KEY_MAPPING = dict((LIST_OF_TRACEPARENT_KEYS, LIST_OF_TRACESTATE_KEYS, ("newrelic", "HTTP_NEWRELIC"))) + + def extract(self, carrier, context=None, getter=None): + # If we are passing into New Relic, traceparent + # and/or tracestate's keys also need to be NR compatible. + nr_headers = {lowercase_name: carrier.get(lowercase_name, carrier.get(http_name, "")) for lowercase_name, http_name in self.HEADER_KEY_MAPPING.items()} + accept_distributed_trace_headers(nr_headers) + + return super().extract(carrier=carrier, context=context, getter=getter) + + + def inject(self, carrier, context=None, setter=None): + transaction = current_transaction() + # Only insert headers if we have not done so already this transaction + # Distributed Trace State will have the following states: + # 0 if not set + # 1 if already accepted + # 2 if inserted but not accepted + + if transaction and not transaction._distributed_trace_state: + if isinstance(carrier, dict): + nr_headers = list(carrier.items()) + insert_distributed_trace_headers(nr_headers) + elif isinstance(carrier, list): + insert_distributed_trace_headers(carrier) + else: + raise TypeError("Unsupported carrier type") + + return super().inject(carrier=carrier, context=context, setter=setter) + + elif not transaction: + return super().inject(carrier=carrier, context=context, setter=setter) + + else: + # Do NOT call inject in this case. Transaction has already received + # and/or received and inserted distributed trace headers. + pass + + +# Context and Context Propagator Setup +otel_context_propagator = CompositePropagator( + propagators=[ + NRTraceContextPropagator(), + W3CBaggagePropagator(), + ] +) +set_global_textmap(otel_context_propagator) + # ---------------------------------------------- # 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, @@ -60,6 +105,8 @@ def __init__( nr_transaction=None, nr_trace_type=FunctionTrace, instrumenting_module=None, + record_exception=True, + set_status_on_exception=True, *args, **kwargs, ): @@ -72,6 +119,9 @@ def __init__( ) # This attribute is purely to prevent garbage collection self.nr_trace = None self.instrumenting_module = instrumenting_module + self.status = Status(StatusCode.UNSET) + self._record_exception = record_exception + self.set_status_on_exception = set_status_on_exception # Do not create a New Relic trace if parent # is a remote span and it is not sampled @@ -141,6 +191,9 @@ def __init__( self.nr_trace.__enter__() def _sampled(self): + # NOTE: This logic is using the old logic from before + # the various samplers had been implemented. + # # Uses NR to determine if the trace is sampled # # transaction.sampled can be `None`, `True`, `False`. @@ -154,6 +207,12 @@ def _sampled(self): # 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` + # TODO: Provided that the trace has not already ended, + # configure based on sampler configuration. + # sampler==always_on => return True + # sampler==always_off => return False + # sampler in (default, adaptive, trace_id_ratio_based) + # => return (if remote parent, parent._sampled(), else transaction.sampled) if self.otel_parent: return bool(self.otel_parent.trace_flags) @@ -168,7 +227,17 @@ def get_span_context(self): if not getattr(self, "nr_trace", False): return otel_api_trace.INVALID_SPAN_CONTEXT - otel_tracestate_headers = None + if self.nr_transaction.settings.distributed_tracing.enabled: + nr_tracestate_headers = ( + self.nr_transaction._create_distributed_trace_data() + ) + + nr_tracestate_headers["sa"] = self._sampled() + otel_tracestate_headers = [ + (key, str(value)) for key, value in nr_tracestate_headers.items() + ] + else: + otel_tracestate_headers = None return otel_api_trace.SpanContext( trace_id=int(self.nr_transaction.trace_id, 16), @@ -208,15 +277,58 @@ def update_name(self, name): self.nr_trace.name = self._name def is_recording(self): - return self._sampled() and not (getattr(self.nr_trace, None), "end_time", None) + # TODO: Similar to self._sampled, we need to + # implement a compatible method now that + # samplers have been implemented. + return self._sampled() and not (getattr(self.nr_trace, "end_time", None)) def set_status(self, status, description=None): - # TODO: not implemented yet - raise NotImplementedError("Not implemented yet") + """ + Notes: + 1. Ignore future calls if status is already set to OK + since span should be completed if status is OK. + 2. Similarly, ignore calls to set to StatusCode.UNSET + since this will be either invalid or unnecessary. + """ + if isinstance(status, Status): + if ( + self.status + and self.status.status_code is StatusCode.OK + or status.is_unset + ): + return + if description is not None: + _logger.warning( + "Description %s ignored. Use either `Status` or `(StatusCode, Description)`", + description, + ) + self.status = status + elif isinstance(status, StatusCode): + if ( + self.status + and self.status.status_code is StatusCode.OK + or status is StatusCode.UNSET + ): + return + self.status = Status(status, description) + + # Add status as attribute + self.set_attribute("status_code", self.status.status_code.name) + self.set_attribute("status_description", self.status.description) + 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__) + # `escaped` indicates whether the exception has not + # been unhandled by the time the span has ended. + if attributes: + attributes.update({"exception.escaped": escaped}) + else: + attributes = {"exception.escaped": escaped} + + self.set_attributes(attributes) + if not hasattr(self, "nr_trace"): notice_error(error_args, attributes=attributes) else: @@ -252,6 +364,25 @@ def end(self, end_time=None, *args, **kwargs): self._set_attributes_in_nr({"span.kind": self.kind}) self.nr_trace.__exit__(*sys.exc_info()) + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Ends context manager and calls `end` on the `Span`. + This is used when span is called as a context manager + i.e. `with tracer.start_span() as span:` + """ + if exc_val and self.is_recording(): + if self._record_exception: + self.record_exception(exception=exc_val, escaped=True) + if self.set_status_on_exception: + self.set_status( + Status( + status_code=StatusCode.ERROR, + description=f"{exc_type.__name__}: {exc_val}", + ) + ) + + super().__exit__(exc_type, exc_val, exc_tb) class Tracer(otel_api_trace.Tracer): @@ -277,6 +408,13 @@ def start_span( self.nr_application = application_instance() self.attributes = attributes or {} + if not self.nr_application.active: + # Force application registration if not already active + self.nr_application.activate() + + self._record_exception = record_exception + self.set_status_on_exception = set_status_on_exception + if not self.nr_application.settings.otel_bridge.enabled: return otel_api_trace.INVALID_SPAN @@ -286,7 +424,17 @@ def start_span( if parent_span_context is None or not parent_span_context.is_valid: parent_span_context = None + # If parent_span_context exists, we can create traceparent + # and tracestate headers + _headers = {} + if parent_span_context and self.nr_application.settings.distributed_tracing.enabled: + parent_span_trace_id = parent_span_context.trace_id + parent_span_span_id = parent_span_context.span_id + parent_span_trace_flags = parent_span_context.trace_flags + + # If remote_parent, transaction must be created, regardless of kind type + # Make sure we transfer DT headers when we are here, if DT is enabled 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 @@ -296,6 +444,10 @@ def start_span( port = self.attributes.get("net.host.port") request_method = self.attributes.get("http.method") request_path = self.attributes.get("http.route") + + update_sampled_flag = False if headers else True + headers = headers if headers else _headers + transaction = WebTransaction( self.nr_application, name=name, @@ -306,7 +458,16 @@ def start_span( request_path=request_path, headers=headers, ) - elif kind in (otel_api_trace.SpanKind.PRODUCER, otel_api_trace.SpanKind.INTERNAL): + + # If headers do not contain the traceparent/tracestate + # the sampled flag needs to be updated to that of the + # parent span. + if update_sampled_flag and parent_span_context: + transaction._sampled = bool(parent_span_trace_flags) + 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( @@ -338,6 +499,9 @@ def start_span( request_method = self.attributes.get("http.method") request_path = self.attributes.get("http.route") + update_GUID_flag = False if headers else True + headers = headers if headers else _headers + transaction = WebTransaction( self.nr_application, name=name, @@ -348,6 +512,14 @@ def start_span( request_path=request_path, headers=headers, ) + + # If headers do not contain the traceparent/tracestate + # the transaction GUID needs to be updated to that of + # the parent span. + if update_GUID_flag and parent_span_context: + guid = parent_span_trace_id >> 64 + transaction.guid = f"{guid:x}" + transaction.__enter__() elif kind == otel_api_trace.SpanKind.INTERNAL: if transaction: @@ -393,6 +565,8 @@ def start_span( nr_transaction=transaction, nr_trace_type=nr_trace_type, instrumenting_module=self.instrumentation_library, + record_exception=self._record_exception, + set_status_on_exception=self.set_status_on_exception, ) return span diff --git a/newrelic/api/transaction.py b/newrelic/api/transaction.py index 3fcb0908e..d435b49a5 100644 --- a/newrelic/api/transaction.py +++ b/newrelic/api/transaction.py @@ -1438,6 +1438,18 @@ def accept_distributed_trace_headers(self, headers, transport_type="HTTP"): data.update(tracestate_data) else: self._record_supportability("Supportability/TraceContext/TraceState/InvalidNrEntry") + elif not payload and (tracestate == self.tracestate): + self._record_supportability("Supportability/TraceContext/TraceState/NoNrEntry") + self._record_supportability("Supportability/TraceContext/TraceState/OtelEntry") + try: + vendors["sa"] = True if vendors.get("sa").lower() == "true" else False + vendors["pr"] = float(vendors.get("pr")) + vendors["ti"] = int(vendors.get("ti")) + + self.trusted_parent_span = vendors.pop("id", None) + data.update(vendors) + except: + pass else: self._record_supportability("Supportability/TraceContext/TraceState/NoNrEntry") diff --git a/newrelic/config.py b/newrelic/config.py index ef78b1185..242c4cd17 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -4408,11 +4408,27 @@ def _process_module_builtin_defaults(): # Hybrid Agent Hooks _process_module_definition( - "opentelemetry.trace", "newrelic.hooks.hybridagent_opentelemetry", "instrument_trace_api" + "opentelemetry.context", + "newrelic.hooks.hybridagent_opentelemetry", + "instrument_context_api", ) _process_module_definition( - "opentelemetry.instrumentation.utils", "newrelic.hooks.hybridagent_opentelemetry", "instrument_utils" + "opentelemetry.instrumentation.propagators", + "newrelic.hooks.hybridagent_opentelemetry", + "instrument_global_propagators_api", + ) + + _process_module_definition( + "opentelemetry.trace", + "newrelic.hooks.hybridagent_opentelemetry", + "instrument_trace_api", + ) + + _process_module_definition( + "opentelemetry.instrumentation.utils", + "newrelic.hooks.hybridagent_opentelemetry", + "instrument_utils", ) diff --git a/newrelic/core/config.py b/newrelic/core/config.py index a05d8c85c..81d23ff69 100644 --- a/newrelic/core/config.py +++ b/newrelic/core/config.py @@ -1431,7 +1431,9 @@ 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) +_settings.otel_bridge.enabled = _environ_as_bool( + "NEW_RELIC_OTEL_BRIDGE_ENABLED", default=False +) def global_settings(): diff --git a/newrelic/hooks/hybridagent_opentelemetry.py b/newrelic/hooks/hybridagent_opentelemetry.py index 41248657f..fa9e54221 100644 --- a/newrelic/hooks/hybridagent_opentelemetry.py +++ b/newrelic/hooks/hybridagent_opentelemetry.py @@ -13,17 +13,67 @@ # limitations under the License. import logging +import os 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.api.transaction import current_transaction from newrelic.common.object_wrapper import wrap_function_wrapper from newrelic.common.signature import bind_args +from newrelic.common.encoding_utils import NrTraceState, W3CTraceState from newrelic.core.config import global_settings _logger = logging.getLogger(__name__) _TRACER_PROVIDER = None +# Enable OpenTelemetry Bridge to capture HTTP +# request/response headers as span attributes: +os.environ["OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST"] = ".*" +os.environ["OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE"] = ".*" + +########################################### +# Context Instrumentation +########################################### + +def wrap__load_runtime_context(wrapped, instance, args, kwargs): + settings = global_settings() + + if not settings.otel_bridge.enabled: + return wrapped(*args, **kwargs) + + from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext + + context = ContextVarsRuntimeContext() + return context + + +def wrap_get_global_response_propagator(wrapped, instance, args, kwargs): + settings = global_settings() + + if not settings.otel_bridge.enabled: + return wrapped(*args, **kwargs) + + from newrelic.api.opentelemetry import otel_context_propagator + from opentelemetry.instrumentation.propagators import set_global_response_propagator + + set_global_response_propagator(otel_context_propagator) + + return otel_context_propagator + + +def instrument_context_api(module): + + if hasattr(module, "_load_runtime_context"): + wrap_function_wrapper(module, "_load_runtime_context", wrap__load_runtime_context) + + +def instrument_global_propagators_api(module): + # Need to disable this instrumentation if settings.otel_bridge is disabled + + if hasattr(module, "get_global_response_propagator"): + wrap_function_wrapper(module, "get_global_response_propagator", wrap_get_global_response_propagator) + + ########################################### # Trace Instrumentation ########################################### @@ -31,7 +81,6 @@ def wrap_set_tracer_provider(wrapped, instance, args, kwargs): settings = global_settings() - if not settings.otel_bridge.enabled: return wrapped(*args, **kwargs) @@ -46,17 +95,17 @@ def wrap_set_tracer_provider(wrapped, instance, args, kwargs): 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() + + settings = global_settings() + + if not settings.otel_bridge.enabled: + return wrapped(*args, **kwargs) global _TRACER_PROVIDER @@ -71,24 +120,18 @@ def wrap_get_tracer_provider(wrapped, instance, args, kwargs): def wrap_get_current_span(wrapped, instance, args, kwargs): transaction = current_transaction() trace = current_trace() + span = wrapped(*args, **kwargs) + + if not transaction: + return span - # 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) + return span # 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 @@ -112,13 +155,32 @@ def set_attributes(self, attributes): for key, value in attributes.items(): add_custom_span_attribute(key, value) - otel_tracestate_headers = None + if transaction.settings.distributed_tracing.enabled: + if not isinstance(span, otel_api_trace.NonRecordingSpan): + # Use the Otel trace and span ids if current span + # is not a NonRecordingSpan. Otherwise, we need to + # override the current span with the transaction + # and trace guids. + transaction._trace_id = f'{span.get_span_context().trace_id:x}' + guid = span.get_span_context().trace_id >> 64 + transaction.guid = f'{guid:x}' + + nr_tracestate_headers = ( + transaction._create_distributed_trace_data() + ) + transaction._distributed_trace_state = 0 # Make sure to reset DT state here + + otel_tracestate_headers = [ + (key, str(value)) for key, value in nr_tracestate_headers.items() + ] + else: + 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_flags=otel_api_trace.TraceFlags(0x01 if nr_tracestate_headers["sa"] else 0x00), trace_state=otel_api_trace.TraceState(otel_tracestate_headers), ) @@ -144,12 +206,10 @@ def wrap_start_internal_or_server_span(wrapped, instance, args, kwargs): 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 + attributes["nr.http.headers"] = context_carrier else: - nr_headers = context_carrier.copy() - attributes["nr.nonhttp.headers"] = nr_headers + attributes["nr.nonhttp.headers"] = context_carrier bound_args["attributes"] = attributes diff --git a/tests/hybridagent_opentelemetry/cross_agent/TestCaseDefinitions.json b/tests/hybridagent_opentelemetry/cross_agent/TestCaseDefinitions.json new file mode 100644 index 000000000..ff91707c8 --- /dev/null +++ b/tests/hybridagent_opentelemetry/cross_agent/TestCaseDefinitions.json @@ -0,0 +1,641 @@ +[ + { + "testDescription": "Does not create segment without a transaction", + + "operations": [ + { + "command": "DoWorkInSpan", + "parameters": { + "spanName": "Bar", + "spanKind": "Internal" + }, + "assertions": [ + { + "description": "The OpenTelmetry span should not be created", + "rule": { + "operator": "NotValid", + "parameters": { + "object": "currentOTelSpan" + } + } + }, + { + "description": "There should be no transaction", + "rule": { + "operator": "NotValid", + "parameters": { + "object": "currentTransaction" + } + } + } + ] + } + ], + + "agentOutput": { + "transactions": [], + "spans": [] + } + }, + + { + "testDescription": "Creates OpenTelemetry segment in a transaction", + + "operations": [ + { + "command": "DoWorkInTransaction", + "parameters": { + "transactionName": "Foo" + }, + "childOperations": [ + { + "command": "DoWorkInSpan", + "parameters": { + "spanName": "Bar", + "spanKind": "Internal" + }, + "assertions": [ + { + "description": "OpenTelemetry API and New Relic API report the same traceId", + "rule": { + "operator": "Equals", + "parameters": { + "left": "currentOTelSpan.traceId", + "right": "currentTransaction.traceId" + } + } + }, + { + "description": "OpenTelemetry API and New Relic API report the same spanId", + "rule": { + "operator": "Equals", + "parameters": { + "left": "currentOTelSpan.spanId", + "right": "currentSegment.spanId" + } + } + } + ] + } + ] + } + ], + + "agentOutput": { + "transactions": [ + { + "name": "Foo" + } + ], + "spans": [ + { + "name": "Bar", + "category": "generic", + "parentName": "Foo" + }, + { + "name": "Foo", + "category": "generic", + "entryPoint": true + } + ] + } + }, + + { + "testDescription": "Creates New Relic span as child of OpenTelemetry span", + + "operations": [ + { + "command": "DoWorkInTransaction", + "parameters": { + "transactionName": "Foo" + }, + "childOperations": [ + { + "command": "DoWorkInSpan", + "parameters": { + "spanName": "Bar", + "spanKind": "Internal" + }, + "childOperations": [ + { + "command": "DoWorkInSegment", + "parameters": { + "segmentName": "Baz" + }, + "assertions": [ + { + "description": "OpenTelemetry API and New Relic API report the same traceId", + "rule": { + "operator": "Equals", + "parameters": { + "left": "currentOTelSpan.traceId", + "right": "currentTransaction.traceId" + } + } + }, + { + "description": "OpenTelemetry API and New Relic API report the same spanId", + "rule": { + "operator": "Equals", + "parameters": { + "left": "currentOTelSpan.spanId", + "right": "currentSegment.spanId" + } + } + } + ] + } + ] + } + ] + } + ], + + "agentOutput": { + "transactions": [ + { + "name": "Foo" + } + ], + "spans": [ + { + "name": "Baz", + "category": "generic", + "parentName": "Bar" + }, + { + "name": "Bar", + "category": "generic", + "parentName": "Foo" + }, + { + "name": "Foo", + "category": "generic", + "entryPoint": true + } + ] + } + }, + + { + "testDescription": "OpenTelemetry API can add custom attributes to spans", + + "operations": [ + { + "command": "DoWorkInTransaction", + "parameters": { + "transactionName": "Foo" + }, + "childOperations": [ + { + "command": "DoWorkInSpan", + "parameters": { + "spanName": "Bar", + "spanKind": "Internal" + }, + "childOperations": [ + { + "command": "DoWorkInSegment", + "parameters": { + "segmentName": "Baz" + }, + "childOperations": [ + { + "command": "AddOTelAttribute", + "parameters": { + "name": "spanNumber", + "value": 2 + } + } + ] + }, + { + "command": "AddOTelAttribute", + "parameters": { + "name": "spanNumber", + "value": 1 + } + } + ] + } + ] + } + ], + + "agentOutput": { + "transactions": [ + { + "name": "Foo" + } + ], + "spans": [ + { + "name": "Baz", + "attributes": { + "spanNumber": 2 + } + }, + { + "name": "Bar", + "attributes": { + "spanNumber": 1 + } + } + ] + } + }, + + { + "testDescription": "OpenTelemetry API can record errors", + + "operations": [ + { + "command": "DoWorkInTransaction", + "parameters": { + "transactionName": "Foo" + }, + "childOperations": [ + { + "command": "DoWorkInSpan", + "parameters": { + "spanName": "Bar", + "spanKind": "Internal" + }, + "childOperations": [ + { + "command": "RecordExceptionOnSpan", + "parameters": { + "errorMessage": "Test exception message" + } + } + ] + } + ] + } + ], + + "agentOutput": { + "transactions": [ + { + "name": "Foo" + } + ], + "spans": [ + { + "name": "Bar", + "attributes": { + "error.message": "Test exception message" + } + } + ] + } + }, + + { + "testDescription": "OpenTelemetry API and New Relic API can inject outbound trace context", + + "operations": [ + { + "command": "DoWorkInTransaction", + "parameters": { + "transactionName": "Foo" + }, + "childOperations": [ + { + "command": "DoWorkInSpan", + "parameters": { + "spanName": "OTelSpan1", + "spanKind": "Client" + }, + "childOperations": [ + { + "command": "SimulateExternalCall", + "parameters": { + "url": "url1" + }, + "childOperations": [ + { + "command": "OTelInjectHeaders", + "assertions": [ + { + "description": "Correct traceId was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.traceId", + "right": "currentTransaction.traceId" + } + } + }, + { + "description": "Correct spanId was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.spanId", + "right": "currentSegment.spanId" + } + } + }, + { + "description": "Correct sampled flag was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.sampled", + "right": "currentTransaction.sampled" + } + } + } + ] + } + ] + } + ] + }, + { + "command": "DoWorkInSegment", + "parameters": { + "segmentName": "segment1" + }, + "childOperations": [ + { + "command": "SimulateExternalCall", + "parameters": { + "url": "url2" + }, + "childOperations": [ + { + "command": "OTelInjectHeaders", + "assertions": [ + { + "description": "Correct traceId was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.traceId", + "right": "currentTransaction.traceId" + } + } + }, + { + "description": "Correct spanId was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.spanId", + "right": "currentSegment.spanId" + } + } + }, + { + "description": "Correct sampled flag was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.sampled", + "right": "currentTransaction.sampled" + } + } + } + ] + } + ] + } + ] + }, + { + "command": "DoWorkInSpan", + "parameters": { + "spanName": "OTelSpan2", + "spanKind": "Client" + }, + "childOperations": [ + { + "command": "SimulateExternalCall", + "parameters": { + "url": "url3" + }, + "childOperations": [ + { + "command": "NRInjectHeaders", + "assertions": [ + { + "description": "Correct traceId was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.traceId", + "right": "currentTransaction.traceId" + } + } + }, + { + "description": "Correct spanId was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.spanId", + "right": "currentSegment.spanId" + } + } + }, + { + "description": "Correct sampled flag was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.sampled", + "right": "currentTransaction.sampled" + } + } + } + ] + } + ] + } + ] + }, + { + "command": "DoWorkInSegment", + "parameters": { + "segmentName": "segment2" + }, + "childOperations": [ + { + "command": "SimulateExternalCall", + "parameters": { + "url": "url4" + }, + "childOperations": [ + { + "command": "NRInjectHeaders", + "assertions": [ + { + "description": "Correct traceId was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.traceId", + "right": "currentTransaction.traceId" + } + } + }, + { + "description": "Correct spanId was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.spanId", + "right": "currentSegment.spanId" + } + } + }, + { + "description": "Correct sampled flag was injected", + "rule": { + "operator": "Equals", + "parameters": { + "left": "injected.sampled", + "right": "currentTransaction.sampled" + } + } + } + ] + } + ] + } + ] + } + ] + } + ], + + "agentOutput": { + "transactions": [ + { + "name": "Foo" + } + ], + "spans": [ + { + "name": "OTelSpan1", + "parentName": "Foo" + }, + { + "name": "segment1", + "parentName": "Foo" + }, + { + "name": "OTelSpan2", + "parentName": "Foo" + }, + { + "name": "segment2", + "parentName": "Foo" + } + ] + } + }, + + { + "testDescription": "Starting transaction tests", + + "operations": [ + { + "command": "DoWorkInSpan", + "parameters": { + "spanName": "Foo", + "spanKind": "Server" + } + }, + { + "command": "DoWorkInSpanWithRemoteParent", + "parameters": { + "spanName": "Bar", + "spanKind": "Server" + } + }, + { + "command": "DoWorkInTransaction", + "parameters": { + "transactionName": "Baz" + }, + "childOperations": [ + { + "command": "DoWorkInSpanWithRemoteParent", + "parameters": { + "spanName": "EdgeCase", + "spanKind": "Server" + } + } + ] + } + ], + + "agentOutput": { + "transactions": [ + { + "name": "Foo" + }, + { + "name": "Bar" + }, + { + "name": "Baz" + } + ], + "spans": [ + { + "name": "EdgeCase", + "parentName": "Baz" + }, + { + "name": "Baz", + "entryPoint": true + } + ] + } + }, + + { + "testDescription": "Inbound distributed tracing tests", + + "operations": [ + { + "command": "DoWorkInSpanWithInboundContext", + "parameters": { + "spanName": "Foo", + "spanKind": "Server", + "traceIdInHeader": "da8bc8cc6d062849b0efcf3c169afb5a", + "spanIdInHeader": "7d3efb1b173fecfa", + "sampledFlagInHeader": "0" + }, + "assertions": [ + { + "description": "Current span has expected traceId", + "rule": { + "operator": "Matches", + "parameters": { + "object": "currentOTelSpan.traceId", + "value": "da8bc8cc6d062849b0efcf3c169afb5a" + } + } + } + ] + } + ], + + "agentOutput": { + "transactions": [ + { + "name": "Foo" + } + ], + "spans": [] + } + } + +] diff --git a/tests/hybridagent_opentelemetry/test_traces_attributes.py b/tests/hybridagent_opentelemetry/test_attributes.py similarity index 64% rename from tests/hybridagent_opentelemetry/test_traces_attributes.py rename to tests/hybridagent_opentelemetry/test_attributes.py index b9f370593..77ebee68f 100644 --- a/tests/hybridagent_opentelemetry/test_traces_attributes.py +++ b/tests/hybridagent_opentelemetry/test_attributes.py @@ -25,8 +25,8 @@ 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", + "name": "Function/test_attributes:test_trace_with_span_attributes.._test", + "transaction.name": "OtherTransaction/Function/test_attributes:test_trace_with_span_attributes.._test", "sampled": True, }, ) @@ -49,50 +49,59 @@ 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. + NOTE: + 1. Distinction between trace and span attributes is given for + whether they are added to the transaction or the span. A + transaction's custom attributes are added to the root span's + user attributes. + 2. Notation for attributes: + - NR trace attributes: "NR_trace_attribute_" + - NR span attributes: "NR_span_attribute_" + - OTel span attributes: "otel_span_attribute_" + Where is either `FT` or `BG`, for FunctionTrace + or BackgroundTask, respectively. """ @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") + add_custom_attribute("NR_trace_attribute_FT", "NR trace attribute from FT") + add_custom_span_attribute("NR_span_attribute_FT", "NR span attribute from FT") otel_span = trace.get_current_span() - otel_span.set_attribute("otel_span_attribute_from_function", "OTel span attribute from FT") + otel_span.set_attribute("otel_span_attribute_FT", "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", + "name": "Function/test_attributes:test_trace_with_otel_to_newrelic..newrelic_background_task", + "transaction.name": "OtherTransaction/Function/test_attributes:test_trace_with_otel_to_newrelic..newrelic_background_task", "sampled": True, }, - exact_users={"NR_trace_attribute_from_function": "NR trace attribute"}, + exact_users={"NR_trace_attribute_FT": "NR trace attribute from FT"}, ) @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", + "NR_span_attribute_BG": "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", + "name": "Function/test_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", + "NR_span_attribute_FT": "NR span attribute from FT", + "otel_span_attribute_FT": "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") + add_custom_span_attribute("NR_span_attribute_BG", "NR span attribute from BG") otel_span.set_attribute("otel_span_attribute_BG", "OTel span attribute from BG") newrelic_function_trace() diff --git a/tests/hybridagent_opentelemetry/test_context_propagation.py b/tests/hybridagent_opentelemetry/test_context_propagation.py new file mode 100644 index 000000000..ccc3b6e07 --- /dev/null +++ b/tests/hybridagent_opentelemetry/test_context_propagation.py @@ -0,0 +1,249 @@ +# 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 as otel_api_trace, propagate as otel_api_propagate + +from newrelic.api.transaction import ( + accept_distributed_trace_headers, + current_transaction, +) +from newrelic.api.background_task import background_task + +from testing_support.fixtures import ( + override_application_settings, +) + +PROPAGATOR = otel_api_propagate.get_global_textmap() + +_override_settings = { + "trusted_account_key": "1", + "distributed_tracing.enabled": True, + "span_events.enabled": True, +} + + +@pytest.mark.parametrize( + "telemetry,web_headers,propagation", + ( + ( + "newrelic", + False, + accept_distributed_trace_headers, + ), + ( + "newrelic", + True, + PROPAGATOR.extract, + ), + ( + "newrelic", + False, + PROPAGATOR.extract, + ), + ( + "hybrid_otel", + False, + accept_distributed_trace_headers, + ), + ( + "hybrid_otel", + True, + PROPAGATOR.extract, + ), + ( + "hybrid_otel", + False, + PROPAGATOR.extract, + ), + ( + "pure_otel", + False, + accept_distributed_trace_headers, + ), + ( + "pure_otel", + True, + PROPAGATOR.extract, + ), + ( + "pure_otel", + False, + PROPAGATOR.extract, + ), + ) +) +def test_distributed_trace_tracestate_compatibility_full_granularity( + telemetry, web_headers, propagation +): + """ + Args: + telemetry (str): either "newrelic", "hybrid_otel", or "pure_otel" + Denotes which propagation function was used to + insert/inject the distributed trace headers. + "newrelic" => `insert_distributed_trace_headers` + "hybrid_otel" => `PROPAGATOR.inject` from Hybrid Agent + "pure_otel" => `PROPAGATOR.inject` from OTel SDK + web_headers (bool): For OTel web framework instrumentation, + header keys will be captalized and prepended with "HTTP_". + Only applicable for headers coming from OTel + propagation (func): The propagation function to use. + Either `accept_distributed_trace_headers` + or `PROPAGATOR.extract`. Note: If using + `accept_distributed_trace_headers`, the web_headers + flag must be false. + + """ + @override_application_settings(_override_settings) + @background_task(name="test_distributed_trace_attributes") + def _test(): + transaction = current_transaction() + + headers = { + f"{'HTTP_TRACEPARENT' if web_headers else 'traceparent'}": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01", + f"{'HTTP_NEWRELIC' if web_headers else 'newrelic'}": '{"v":[0,1],"d":{"ty":"Mobile","ac":"123","ap":"51424","id":"5f474d64b9cc9b2a","tr":"6e2fea0b173fdad0","pr":0.1234,"sa":True,"ti":1482959525577,"tx":"27856f70d3d314b7"}}', # This header should be ignored. + } + if telemetry == "newrelic": + headers[ + f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}" + ] = "1@nr=0-0-1-2827902-0af7651916cd43dd-00f067aa0ba902b7-1-1.23456-1518469636035" + elif telemetry == "hybrid_otel": + headers[ + f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}" + ] = "ac=1,ap=2827902,tx=0af7651916cd43dd,id=00f067aa0ba902b7,sa=True,pr=1.23456,ti=1518469636035,tr=0af7651916cd43dd8448eb211c80319c" + # "pure_otel" has no tracestate headers + + propagation(headers) + current_span = otel_api_trace.get_current_span() + assert transaction.parent_span == "00f067aa0ba902b7" + assert transaction.trace_id == "0af7651916cd43dd8448eb211c80319c" + current_span.get_span_context().trace_id == int("0af7651916cd43dd8448eb211c80319c", 16) + + # Ensure that priority gets set despite having + # the sample flag set but no priority set + assert transaction.priority + + _test() + + +@pytest.mark.parametrize( + "telemetry,web_headers,propagation", + ( + ( + "newrelic", + False, + accept_distributed_trace_headers, + ), + ( + "newrelic", + True, + PROPAGATOR.extract, + ), + ( + "newrelic", + False, + PROPAGATOR.extract, + ), + ( + "hybrid_otel", + False, + accept_distributed_trace_headers, + ), + ( + "hybrid_otel", + True, + PROPAGATOR.extract, + ), + ( + "hybrid_otel", + False, + PROPAGATOR.extract, + ), + ( + "pure_otel", + False, + accept_distributed_trace_headers, + ), + ( + "pure_otel", + True, + PROPAGATOR.extract, + ), + ( + "pure_otel", + False, + PROPAGATOR.extract, + ), + ) +) +def test_distributed_trace_tracestate_compatibility_partial_granularity( + telemetry, web_headers, propagation +): + """ + Args: + telemetry (str): either "newrelic", "hybrid_otel", or "pure_otel" + Denotes which propagation function was used to + insert/inject the distributed trace headers. + "newrelic" => `insert_distributed_trace_headers` + "hybrid_otel" => `PROPAGATOR.inject` from Hybrid Agent + "pure_otel" => `PROPAGATOR.inject` from OTel SDK + web_headers (bool): For OTel web framework instrumentation, + header keys will be captalized and prepended with "HTTP_". + Only applicable for headers coming from OTel + propagation (func): The propagation function to use. + Either `accept_distributed_trace_headers` + or `PROPAGATOR.extract`. Note: If using + `accept_distributed_trace_headers`, the web_headers + flag must be false. + + """ + test_settings = _override_settings.copy() + test_settings.update( + { + "distributed_tracing.sampler.full_granularity.enabled": False, + "distributed_tracing.sampler.partial_granularity.enabled": True, + } + ) + @override_application_settings(test_settings) + @background_task(name="test_distributed_trace_attributes") + def _test(): + transaction = current_transaction() + + headers = { + f"{'HTTP_TRACEPARENT' if web_headers else 'traceparent'}": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01", + f"{'HTTP_NEWRELIC' if web_headers else 'newrelic'}": '{"v":[0,1],"d":{"ty":"Mobile","ac":"123","ap":"51424","id":"5f474d64b9cc9b2a","tr":"6e2fea0b173fdad0","pr":0.1234,"sa":True,"ti":1482959525577,"tx":"27856f70d3d314b7"}}', # This header should be ignored. + } + if telemetry == "newrelic": + headers[ + f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}" + ] = "1@nr=0-0-1-2827902-0af7651916cd43dd-00f067aa0ba902b7-1-1.23456-1518469636035" + elif telemetry == "hybrid_otel": + headers[ + f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}" + ] = "ac=1,ap=2827902,tx=0af7651916cd43dd,id=00f067aa0ba902b7,sa=True,pr=1.23456,ti=1518469636035,tr=0af7651916cd43dd8448eb211c80319c" + # "pure_otel" has no tracestate headers + + propagation(headers) + current_span = otel_api_trace.get_current_span() + assert transaction.parent_span == "00f067aa0ba902b7" + assert transaction.trace_id == "0af7651916cd43dd8448eb211c80319c" + current_span.get_span_context().trace_id == int("0af7651916cd43dd8448eb211c80319c", 16) + + # Ensure that priority gets set despite having + # the sample flag set but no priority set + assert transaction.priority + + _test() + diff --git a/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py b/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py index 9a31d2bf6..564f9e717 100644 --- a/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py +++ b/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py @@ -12,17 +12,22 @@ # 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 opentelemetry import trace as otel_api_trace, propagate as otel_api_propagate from newrelic.api.application import application_instance +from newrelic.api.transaction import current_transaction +from newrelic.api.time_trace import current_trace 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 +from newrelic.api.external_trace import ExternalTrace + +from testing_support.validators.validate_transaction_count import validate_transaction_count +from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics +from testing_support.validators.validate_span_events import validate_span_events +from testing_support.validators.validate_error_event_attributes import validate_error_event_attributes +from testing_support.fixtures import override_application_settings, dt_enabled + +PROPAGATOR = otel_api_propagate.get_global_textmap() # Does not create segment without a transaction @@ -37,11 +42,22 @@ def test_does_not_create_segment_without_a_transaction(tracer): # Creates OpenTelemetry segment in a transaction +@dt_enabled @validate_transaction_metrics(name="Foo", background_task=True) @validate_span_events( - exact_intrinsics={"name": "Function/Bar", "category": "generic"}, expected_intrinsics=("parentId",) + exact_intrinsics={ + "name": "Function/Bar", + "category": "generic", + }, + expected_intrinsics=("parentId",) +) +@validate_span_events( + exact_intrinsics={ + "name": "Function/Foo", + "category": "generic", + "nr.entryPoint": True + }, ) -@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) @@ -49,7 +65,7 @@ def test_creates_opentelemetry_segment_in_a_transaction(tracer): 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 + current_transaction().trace_id, 16 ) # OpenTelemetry API and New Relic API report the same spanId @@ -57,12 +73,28 @@ def test_creates_opentelemetry_segment_in_a_transaction(tracer): # Creates New Relic span as child of OpenTelemetry span +@dt_enabled @validate_transaction_metrics(name="Foo", background_task=True) @validate_span_events( - exact_intrinsics={"name": "Function/Baz", "category": "generic"}, expected_intrinsics=("parentId",) + exact_intrinsics={ + "name": "Function/Baz", + "category": "generic", + }, + expected_intrinsics=("parentId",) ) @validate_span_events( - exact_intrinsics={"name": "Function/Bar", "category": "generic"}, expected_intrinsics=("parentId",) + exact_intrinsics={ + "name": "Function/Bar", + "category": "generic", + }, + expected_intrinsics=("parentId",) +) +@validate_span_events( + exact_intrinsics={ + "name": "Function/Foo", + "category": "generic", + "nr.entryPoint": True + }, ) @validate_span_events(exact_intrinsics={"name": "Function/Foo", "category": "generic"}) def test_creates_new_relic_span_as_child_of_open_telemetry_span(tracer): @@ -81,6 +113,7 @@ def test_creates_new_relic_span_as_child_of_open_telemetry_span(tracer): # OpenTelemetry API can add custom attributes to spans +@dt_enabled @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}) @@ -96,6 +129,7 @@ def test_opentelemetry_api_can_add_custom_attributes_to_spans(tracer): # OpenTelemetry API can record errors +@dt_enabled @validate_transaction_metrics(name="Foo", background_task=True) @validate_error_event_attributes( exact_attrs={"agent": {}, "intrinsic": {"error.message": "Test exception message"}, "user": {}} @@ -110,3 +144,180 @@ def test_opentelemetry_api_can_record_errors(tracer): raise Exception("Test exception message") except Exception as e: otel_api_trace.get_current_span().record_exception(e) + + +# OpenTelemetry API and New Relic API can inject outbound trace context +@dt_enabled +@validate_transaction_metrics(name="Foo", background_task=True) +@validate_span_events( + exact_intrinsics={ + "name": "External/url1/OtelSpan1/GET", + }, + expected_intrinsics=("parentId",) +) +@validate_span_events( + exact_intrinsics={ + "name": "External/url2/segment1/GET", + }, + expected_intrinsics=("parentId",) +) +@validate_span_events( + exact_intrinsics={ + "name": "External/url3/OtelSpan2/GET", + }, + expected_intrinsics=("parentId",) +) +@validate_span_events( + exact_intrinsics={ + "name": "External/url4/segment2/GET", + }, + expected_intrinsics=("parentId",) +) +def test_opentelemetry_api_and_new_relic_api_can_inject_outbound_trace_context(tracer): + application = application_instance(activate=False) + + with BackgroundTask(application, name="Foo"): + transaction = current_transaction() + with tracer.start_as_current_span( + name="OtelSpan1", + kind=otel_api_trace.SpanKind.CLIENT, + attributes={"http.url": "http://url1", "http.method": "GET"} + ): + headers = {} + PROPAGATOR.inject(carrier=headers) + _, trace_id, span_id, sampled = headers["traceparent"].split("-") + + # Correct traceId was injected + assert transaction.trace_id == trace_id + + # Correct spanId was injected + assert current_trace().guid == span_id + + # Correct sampled flag was injected + assert transaction.sampled == (sampled == "01") + + # Reset the distributed trace state for the purposes of this test + transaction._distributed_trace_state = 0 + + with ExternalTrace(library="segment1", url="http://url2", method="GET"): + headers = {} + PROPAGATOR.inject(carrier=headers) + _, trace_id, span_id, sampled = headers["traceparent"].split("-") + + # Correct traceId was injected + assert current_transaction().trace_id == trace_id + + # Correct spanId was injected + assert current_trace().guid == span_id + + # Correct sampled flag was injected + assert current_transaction().sampled == (sampled == "01") + + # Reset the distributed trace state for the purposes of this test + transaction._distributed_trace_state = 0 + + with tracer.start_as_current_span( + name="OtelSpan2", + kind=otel_api_trace.SpanKind.CLIENT, + attributes={"http.url": "http://url3", "http.method": "GET"} + ): + headers = [] + transaction.insert_distributed_trace_headers(headers) + _, trace_id, span_id, sampled = headers[0][1].split("-") + + # Correct traceId was injected + assert transaction.trace_id == trace_id + + # Correct spanId was injected + assert current_trace().guid == span_id + + # Correct sampled flag was injected + assert transaction.sampled == (sampled == "01") + + # Reset the distributed trace state for the purposes of this test + transaction._distributed_trace_state = 0 + + with ExternalTrace(library="segment2", url="http://url4", method="GET"): + headers = [] + transaction.insert_distributed_trace_headers(headers) + _, trace_id, span_id, sampled = headers[0][1].split("-") + + # Correct traceId was injected + assert current_transaction().trace_id == trace_id + + # Correct spanId was injected + assert current_trace().guid == span_id + + # Correct sampled flag was injected + assert current_transaction().sampled == (sampled == "01") + + +# Starting transaction tests +@dt_enabled +@validate_transaction_metrics(name="Foo", index=-3) +@validate_transaction_metrics(name="Bar", index=-2) +@validate_transaction_metrics(name="Baz", background_task=True, index=-1) +@validate_span_events( + exact_intrinsics={ + "name": "Function/EdgeCase", + }, + expected_intrinsics=("parentId",) +) +@validate_span_events( + exact_intrinsics={ + "name": "Function/Baz", + "nr.entryPoint": True + }, +) +def test_starting_transaction_tests(tracer): + application = application_instance(activate=False) + + with tracer.start_as_current_span(name="Foo", kind=otel_api_trace.SpanKind.SERVER): + pass + + # Create remote span context and remote context + remote_span_context = otel_api_trace.SpanContext( + trace_id=0x1234567890abcdef1234567890abcdef, + span_id=0x1234567890abcdef, + is_remote=True, + trace_flags=otel_api_trace.TraceFlags.SAMPLED, + trace_state=otel_api_trace.TraceState() + ) + remote_context = otel_api_trace.set_span_in_context(otel_api_trace.NonRecordingSpan(remote_span_context)) + + with tracer.start_as_current_span(name="Bar", kind=otel_api_trace.SpanKind.SERVER, context=remote_context): + pass + + with BackgroundTask(application, name="Baz"): + with tracer.start_as_current_span(name="EdgeCase", kind=otel_api_trace.SpanKind.SERVER, context=remote_context): + pass + + +# Inbound distributed tracing tests +@dt_enabled +@validate_transaction_metrics(name="Foo") +@validate_span_events(count=0) +@override_application_settings( + { + "trusted_account_key": "1", + "account_id": "1", + } +) +def test_inbound_distributed_tracing_tests(tracer): + """ + This test intends to check for a scenario where an external call + span is made outside the context of an existing transaction. + By flagging that span as not sampled in OTel, it means that the OTel + api will reflect that our agent is also ignoring that span. In + this case, it will create the server transaction, but no spans. + """ + with tracer.start_as_current_span(name="Foo", kind=otel_api_trace.SpanKind.SERVER): + carrier = { + "traceparent": "00-da8bc8cc6d062849b0efcf3c169afb5a-7d3efb1b173fecfa-00", + "tracestate": "1@nr=0-0-1-12345678-7d3efb1b173fecfa-da8bc8cc6d062849-0-0.23456-1011121314151", + } + PROPAGATOR.extract(carrier=carrier) + + current_span = otel_api_trace.get_current_span() + + assert current_span.get_span_context().trace_id == 0xda8bc8cc6d062849b0efcf3c169afb5a diff --git a/tests/hybridagent_opentelemetry/test_settings.py b/tests/hybridagent_opentelemetry/test_settings.py index 1ccd109ab..a9f399f63 100644 --- a/tests/hybridagent_opentelemetry/test_settings.py +++ b/tests/hybridagent_opentelemetry/test_settings.py @@ -13,20 +13,36 @@ # limitations under the License. import pytest -from testing_support.fixtures import override_application_settings -from testing_support.validators.validate_span_events import validate_span_events 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.validators.validate_transaction_metrics import validate_transaction_metrics +from testing_support.fixtures import override_application_settings, dt_enabled + -@pytest.mark.parametrize("enabled", [True, False]) -def test_distributed_tracing_enabled(tracer, enabled): +@pytest.mark.parametrize( + "enabled", + [True, False] +) +def test_opentelemetry_bridge_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"}) + @dt_enabled + @validate_transaction_metrics(name="Foo", background_task=True) + @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: diff --git a/tests/hybridagent_opentelemetry/test_status.py b/tests/hybridagent_opentelemetry/test_status.py new file mode 100644 index 000000000..dc8918aa2 --- /dev/null +++ b/tests/hybridagent_opentelemetry/test_status.py @@ -0,0 +1,133 @@ +# 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.trace.status import Status, StatusCode +from newrelic.api.background_task import background_task + +from testing_support.validators.validate_error_event_attributes import validate_error_event_attributes + + +def conditional_decorator(decorator, condition): + def _conditional_decorator(func): + if not condition: + return func + return decorator(func) + + return _conditional_decorator + + +@pytest.mark.parametrize( + "current_status,status_to_set,expected_status_code", [ + (Status(StatusCode.UNSET), Status(StatusCode.OK), StatusCode.OK), # current_status==UNSET -> status_to_set + (Status(StatusCode.UNSET), Status(StatusCode.ERROR), StatusCode.ERROR), # current_status==UNSET -> status_to_set + (Status(StatusCode.OK), Status(StatusCode.UNSET), StatusCode.OK), # current_status==OK -> No-Op / status_to_set==UNSET -> No-Op + (Status(StatusCode.OK), Status(StatusCode.ERROR), StatusCode.OK), # current_status==OK -> No-Op + (Status(StatusCode.ERROR), Status(StatusCode.UNSET), StatusCode.ERROR), # status_to_set==UNSET -> No-Op + (Status(StatusCode.ERROR), Status(StatusCode.OK), StatusCode.OK), # current_status==ERROR -> status_to_set + (Status(StatusCode.UNSET), StatusCode.OK, StatusCode.OK), # current_status==UNSET -> status_to_set + (Status(StatusCode.UNSET), StatusCode.ERROR, StatusCode.ERROR), # current_status==UNSET -> status_to_set + (Status(StatusCode.OK), StatusCode.UNSET, StatusCode.OK), # current_status==OK -> No-Op / status_to_set==UNSET -> No-Op + (Status(StatusCode.OK), StatusCode.ERROR, StatusCode.OK), # current_status==OK -> No-Op + (Status(StatusCode.ERROR), StatusCode.UNSET, StatusCode.ERROR), # status_to_set==UNSET -> No-Op + (Status(StatusCode.ERROR), StatusCode.OK, StatusCode.OK), # current_status==ERROR -> status_to_set + ], + ids=( + "status_unset_to_ok", + "status_unset_to_error", + "status_ok_to_unset", + "status_ok_to_error", + "status_error_to_unset", + "status_error_to_ok", + "status_code_unset_to_ok", + "status_code_unset_to_error", + "status_code_ok_to_unset", + "status_code_ok_to_error", + "status_code_error_to_unset", + "status_code_error_to_ok", + ) +) +def test_status_setting(tracer, current_status, status_to_set, expected_status_code): + @background_task() + def _test(): + with tracer.start_as_current_span(name="TestSpan") as span: + # First, set to the current status to simulate the initial state + span.set_status(current_status) + + # Then, attempt to set the new status + span.set_status(status_to_set) + assert span.status.status_code == expected_status_code + + _test() + + +@pytest.mark.parametrize( + "_record_exception", + [True, False] +) +@pytest.mark.parametrize( + "_set_status_on_exception", + [True, False] +) +def test_set_status_with_start_as_current_span(tracer, _record_exception, _set_status_on_exception): + @conditional_decorator( + decorator=validate_error_event_attributes( + exact_attrs={ + "agent": {}, + "intrinsic": {"error.message": "Test exception message", "error.class": "builtins:ValueError"}, + "user": {"exception.escaped": False}, + } + ), + condition=_record_exception + ) + @background_task() + def _test(): + with pytest.raises(ValueError): + with tracer.start_as_current_span(name="TestSpan", record_exception=_record_exception, set_status_on_exception=_set_status_on_exception) as span: + raise ValueError("Test exception message") + + assert span.status.status_code == StatusCode.ERROR if _set_status_on_exception else StatusCode.UNSET + + _test() + + +@pytest.mark.parametrize( + "_record_exception", + [True, False] +) +@pytest.mark.parametrize( + "_set_status_on_exception", + [True, False] +) +def test_set_status_with_start_span(tracer, _record_exception, _set_status_on_exception): + @conditional_decorator( + decorator=validate_error_event_attributes( + exact_attrs={ + "agent": {}, + "intrinsic": {"error.message": "Test exception message", "error.class": "builtins:ValueError"}, + "user": {"exception.escaped": True}, + } + ), + condition=_record_exception + ) + @background_task() + def _test(): + with pytest.raises(ValueError): + with tracer.start_span(name="TestSpan", record_exception=_record_exception, set_status_on_exception=_set_status_on_exception) as span: + raise ValueError("Test exception message") + + assert span.status.status_code == StatusCode.ERROR if _set_status_on_exception else StatusCode.UNSET + + _test() \ No newline at end of file diff --git a/tox.ini b/tox.ini index 5017adceb..4187b6f4a 100644 --- a/tox.ini +++ b/tox.ini @@ -491,6 +491,7 @@ commands = framework_azurefunctions: {toxinidir}/.github/scripts/install_azure_functions_worker.sh + coverage run -m pytest -v [] allowlist_externals = From 626c0943b6001bb04c6df52f7e4c82721f68dddb Mon Sep 17 00:00:00 2001 From: Lalleh Rafeei Date: Fri, 12 Dec 2025 12:26:37 -0800 Subject: [PATCH 2/9] Megalinter errors --- newrelic/api/opentelemetry.py | 11 ++++------- .../test_attributes.py | 6 +++++- .../test_context_propagation.py | 18 ++++++++---------- tests/hybridagent_opentelemetry/test_status.py | 3 +++ 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/newrelic/api/opentelemetry.py b/newrelic/api/opentelemetry.py index 1748e88ba..b55db1401 100644 --- a/newrelic/api/opentelemetry.py +++ b/newrelic/api/opentelemetry.py @@ -15,6 +15,7 @@ import logging import sys from contextlib import contextmanager +from types import MappingProxyType from opentelemetry import trace as otel_api_trace from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator @@ -42,7 +43,7 @@ class NRTraceContextPropagator(TraceContextTextMapPropagator): LIST_OF_TRACEPARENT_KEYS = ("traceparent", "HTTP_TRACEPARENT") LIST_OF_TRACESTATE_KEYS = ("tracestate", "HTTP_TRACESTATE") - HEADER_KEY_MAPPING = dict((LIST_OF_TRACEPARENT_KEYS, LIST_OF_TRACESTATE_KEYS, ("newrelic", "HTTP_NEWRELIC"))) + HEADER_KEY_MAPPING = MappingProxyType(dict((LIST_OF_TRACEPARENT_KEYS, LIST_OF_TRACESTATE_KEYS, ("newrelic", "HTTP_NEWRELIC")))) def extract(self, carrier, context=None, getter=None): # If we are passing into New Relic, traceparent @@ -329,10 +330,8 @@ def record_exception(self, exception, attributes=None, timestamp=None, escaped=F self.set_attributes(attributes) - if not hasattr(self, "nr_trace"): - notice_error(error_args, attributes=attributes) - else: - self.nr_trace.notice_error(error_args, attributes=attributes) + 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 @@ -429,10 +428,8 @@ def start_span( _headers = {} if parent_span_context and self.nr_application.settings.distributed_tracing.enabled: parent_span_trace_id = parent_span_context.trace_id - parent_span_span_id = parent_span_context.span_id parent_span_trace_flags = parent_span_context.trace_flags - # If remote_parent, transaction must be created, regardless of kind type # Make sure we transfer DT headers when we are here, if DT is enabled if parent_span_context and parent_span_context.is_remote: diff --git a/tests/hybridagent_opentelemetry/test_attributes.py b/tests/hybridagent_opentelemetry/test_attributes.py index 77ebee68f..97f8c6855 100644 --- a/tests/hybridagent_opentelemetry/test_attributes.py +++ b/tests/hybridagent_opentelemetry/test_attributes.py @@ -13,15 +13,18 @@ # limitations under the License. from opentelemetry import trace -from testing_support.validators.validate_span_events import validate_span_events 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 +from testing_support.fixtures import dt_enabled + def test_trace_with_span_attributes(tracer): + @dt_enabled @validate_span_events( count=1, exact_intrinsics={ @@ -69,6 +72,7 @@ def newrelic_function_trace(): otel_span = trace.get_current_span() otel_span.set_attribute("otel_span_attribute_FT", "OTel span attribute from FT") + @dt_enabled @validate_span_events( count=1, exact_intrinsics={ diff --git a/tests/hybridagent_opentelemetry/test_context_propagation.py b/tests/hybridagent_opentelemetry/test_context_propagation.py index ccc3b6e07..e88fb396b 100644 --- a/tests/hybridagent_opentelemetry/test_context_propagation.py +++ b/tests/hybridagent_opentelemetry/test_context_propagation.py @@ -91,20 +91,19 @@ def test_distributed_trace_tracestate_compatibility_full_granularity( """ Args: telemetry (str): either "newrelic", "hybrid_otel", or "pure_otel" - Denotes which propagation function was used to + Denotes which propagation function was used to insert/inject the distributed trace headers. "newrelic" => `insert_distributed_trace_headers` "hybrid_otel" => `PROPAGATOR.inject` from Hybrid Agent "pure_otel" => `PROPAGATOR.inject` from OTel SDK - web_headers (bool): For OTel web framework instrumentation, + web_headers (bool): For OTel web framework instrumentation, header keys will be captalized and prepended with "HTTP_". Only applicable for headers coming from OTel propagation (func): The propagation function to use. Either `accept_distributed_trace_headers` - or `PROPAGATOR.extract`. Note: If using + or `PROPAGATOR.extract`. Note: If using `accept_distributed_trace_headers`, the web_headers flag must be false. - """ @override_application_settings(_override_settings) @background_task(name="test_distributed_trace_attributes") @@ -129,7 +128,7 @@ def _test(): current_span = otel_api_trace.get_current_span() assert transaction.parent_span == "00f067aa0ba902b7" assert transaction.trace_id == "0af7651916cd43dd8448eb211c80319c" - current_span.get_span_context().trace_id == int("0af7651916cd43dd8448eb211c80319c", 16) + assert current_span.get_span_context().trace_id == int("0af7651916cd43dd8448eb211c80319c", 16) # Ensure that priority gets set despite having # the sample flag set but no priority set @@ -194,20 +193,19 @@ def test_distributed_trace_tracestate_compatibility_partial_granularity( """ Args: telemetry (str): either "newrelic", "hybrid_otel", or "pure_otel" - Denotes which propagation function was used to + Denotes which propagation function was used to insert/inject the distributed trace headers. "newrelic" => `insert_distributed_trace_headers` "hybrid_otel" => `PROPAGATOR.inject` from Hybrid Agent "pure_otel" => `PROPAGATOR.inject` from OTel SDK - web_headers (bool): For OTel web framework instrumentation, + web_headers (bool): For OTel web framework instrumentation, header keys will be captalized and prepended with "HTTP_". Only applicable for headers coming from OTel propagation (func): The propagation function to use. Either `accept_distributed_trace_headers` - or `PROPAGATOR.extract`. Note: If using + or `PROPAGATOR.extract`. Note: If using `accept_distributed_trace_headers`, the web_headers flag must be false. - """ test_settings = _override_settings.copy() test_settings.update( @@ -239,7 +237,7 @@ def _test(): current_span = otel_api_trace.get_current_span() assert transaction.parent_span == "00f067aa0ba902b7" assert transaction.trace_id == "0af7651916cd43dd8448eb211c80319c" - current_span.get_span_context().trace_id == int("0af7651916cd43dd8448eb211c80319c", 16) + assert current_span.get_span_context().trace_id == int("0af7651916cd43dd8448eb211c80319c", 16) # Ensure that priority gets set despite having # the sample flag set but no priority set diff --git a/tests/hybridagent_opentelemetry/test_status.py b/tests/hybridagent_opentelemetry/test_status.py index dc8918aa2..e0a093275 100644 --- a/tests/hybridagent_opentelemetry/test_status.py +++ b/tests/hybridagent_opentelemetry/test_status.py @@ -18,6 +18,7 @@ from newrelic.api.background_task import background_task from testing_support.validators.validate_error_event_attributes import validate_error_event_attributes +from testing_support.fixtures import dt_enabled def conditional_decorator(decorator, condition): @@ -82,6 +83,7 @@ def _test(): [True, False] ) def test_set_status_with_start_as_current_span(tracer, _record_exception, _set_status_on_exception): + @dt_enabled @conditional_decorator( decorator=validate_error_event_attributes( exact_attrs={ @@ -112,6 +114,7 @@ def _test(): [True, False] ) def test_set_status_with_start_span(tracer, _record_exception, _set_status_on_exception): + @dt_enabled @conditional_decorator( decorator=validate_error_event_attributes( exact_attrs={ From 399806ebe95a6e54804faf71f1717af9bbd66df6 Mon Sep 17 00:00:00 2001 From: lrafeei <84813886+lrafeei@users.noreply.github.com> Date: Fri, 12 Dec 2025 20:28:09 +0000 Subject: [PATCH 3/9] [MegaLinter] Apply linters fixes --- newrelic/api/opentelemetry.py | 111 +++++------ newrelic/api/transaction.py | 2 +- newrelic/config.py | 14 +- newrelic/core/config.py | 4 +- newrelic/hooks/hybridagent_opentelemetry.py | 42 ++-- .../test_attributes.py | 5 +- .../test_context_propagation.py | 181 +++++------------- .../test_hybrid_cross_agent.py | 167 ++++++---------- .../test_settings.py | 26 +-- .../hybridagent_opentelemetry/test_status.py | 84 ++++---- 10 files changed, 225 insertions(+), 411 deletions(-) diff --git a/newrelic/api/opentelemetry.py b/newrelic/api/opentelemetry.py index b55db1401..5ca4c6d78 100644 --- a/newrelic/api/opentelemetry.py +++ b/newrelic/api/opentelemetry.py @@ -18,11 +18,11 @@ from types import MappingProxyType from opentelemetry import trace as otel_api_trace -from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator -from opentelemetry.trace.status import Status, StatusCode from opentelemetry.baggage.propagation import W3CBaggagePropagator -from opentelemetry.propagators.composite import CompositePropagator from opentelemetry.propagate import set_global_textmap +from opentelemetry.propagators.composite import CompositePropagator +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator +from opentelemetry.trace.status import Status, StatusCode from newrelic.api.application import application_instance from newrelic.api.background_task import BackgroundTask @@ -32,9 +32,13 @@ 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, accept_distributed_trace_headers, insert_distributed_trace_headers +from newrelic.api.transaction import ( + Sentinel, + accept_distributed_trace_headers, + current_transaction, + insert_distributed_trace_headers, +) from newrelic.api.web_transaction import WebTransaction - from newrelic.core.otlp_utils import create_resource _logger = logging.getLogger(__name__) @@ -43,16 +47,20 @@ class NRTraceContextPropagator(TraceContextTextMapPropagator): LIST_OF_TRACEPARENT_KEYS = ("traceparent", "HTTP_TRACEPARENT") LIST_OF_TRACESTATE_KEYS = ("tracestate", "HTTP_TRACESTATE") - HEADER_KEY_MAPPING = MappingProxyType(dict((LIST_OF_TRACEPARENT_KEYS, LIST_OF_TRACESTATE_KEYS, ("newrelic", "HTTP_NEWRELIC")))) + HEADER_KEY_MAPPING = MappingProxyType( + dict((LIST_OF_TRACEPARENT_KEYS, LIST_OF_TRACESTATE_KEYS, ("newrelic", "HTTP_NEWRELIC"))) + ) def extract(self, carrier, context=None, getter=None): - # If we are passing into New Relic, traceparent + # If we are passing into New Relic, traceparent # and/or tracestate's keys also need to be NR compatible. - nr_headers = {lowercase_name: carrier.get(lowercase_name, carrier.get(http_name, "")) for lowercase_name, http_name in self.HEADER_KEY_MAPPING.items()} + nr_headers = { + lowercase_name: carrier.get(lowercase_name, carrier.get(http_name, "")) + for lowercase_name, http_name in self.HEADER_KEY_MAPPING.items() + } accept_distributed_trace_headers(nr_headers) - + return super().extract(carrier=carrier, context=context, getter=getter) - def inject(self, carrier, context=None, setter=None): transaction = current_transaction() @@ -70,12 +78,12 @@ def inject(self, carrier, context=None, setter=None): insert_distributed_trace_headers(carrier) else: raise TypeError("Unsupported carrier type") - + return super().inject(carrier=carrier, context=context, setter=setter) - + elif not transaction: return super().inject(carrier=carrier, context=context, setter=setter) - + else: # Do NOT call inject in this case. Transaction has already received # and/or received and inserted distributed trace headers. @@ -83,18 +91,14 @@ def inject(self, carrier, context=None, setter=None): # Context and Context Propagator Setup -otel_context_propagator = CompositePropagator( - propagators=[ - NRTraceContextPropagator(), - W3CBaggagePropagator(), - ] -) +otel_context_propagator = CompositePropagator(propagators=[NRTraceContextPropagator(), W3CBaggagePropagator()]) set_global_textmap(otel_context_propagator) # ---------------------------------------------- # Custom OTel Spans and Traces # ---------------------------------------------- + class Span(otel_api_trace.Span): def __init__( self, @@ -208,11 +212,11 @@ def _sampled(self): # 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` - # TODO: Provided that the trace has not already ended, + # TODO: Provided that the trace has not already ended, # configure based on sampler configuration. # sampler==always_on => return True # sampler==always_off => return False - # sampler in (default, adaptive, trace_id_ratio_based) + # sampler in (default, adaptive, trace_id_ratio_based) # => return (if remote parent, parent._sampled(), else transaction.sampled) if self.otel_parent: @@ -229,14 +233,10 @@ def get_span_context(self): return otel_api_trace.INVALID_SPAN_CONTEXT if self.nr_transaction.settings.distributed_tracing.enabled: - nr_tracestate_headers = ( - self.nr_transaction._create_distributed_trace_data() - ) - + nr_tracestate_headers = self.nr_transaction._create_distributed_trace_data() + nr_tracestate_headers["sa"] = self._sampled() - otel_tracestate_headers = [ - (key, str(value)) for key, value in nr_tracestate_headers.items() - ] + otel_tracestate_headers = [(key, str(value)) for key, value in nr_tracestate_headers.items()] else: otel_tracestate_headers = None @@ -285,53 +285,42 @@ def is_recording(self): def set_status(self, status, description=None): """ - Notes: - 1. Ignore future calls if status is already set to OK - since span should be completed if status is OK. - 2. Similarly, ignore calls to set to StatusCode.UNSET - since this will be either invalid or unnecessary. + Notes: + 1. Ignore future calls if status is already set to OK + since span should be completed if status is OK. + 2. Similarly, ignore calls to set to StatusCode.UNSET + since this will be either invalid or unnecessary. """ if isinstance(status, Status): - if ( - self.status - and self.status.status_code is StatusCode.OK - or status.is_unset - ): + if (self.status and self.status.status_code is StatusCode.OK) or status.is_unset: return if description is not None: _logger.warning( - "Description %s ignored. Use either `Status` or `(StatusCode, Description)`", - description, + "Description %s ignored. Use either `Status` or `(StatusCode, Description)`", description ) self.status = status elif isinstance(status, StatusCode): - if ( - self.status - and self.status.status_code is StatusCode.OK - or status is StatusCode.UNSET - ): + if (self.status and self.status.status_code is StatusCode.OK) or status is StatusCode.UNSET: return self.status = Status(status, description) - + # Add status as attribute self.set_attribute("status_code", self.status.status_code.name) self.set_attribute("status_description", self.status.description) - 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__) - # `escaped` indicates whether the exception has not + # `escaped` indicates whether the exception has not # been unhandled by the time the span has ended. if attributes: attributes.update({"exception.escaped": escaped}) else: attributes = {"exception.escaped": escaped} - + self.set_attributes(attributes) 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 @@ -363,7 +352,7 @@ def end(self, end_time=None, *args, **kwargs): self._set_attributes_in_nr({"span.kind": self.kind}) self.nr_trace.__exit__(*sys.exc_info()) - + def __exit__(self, exc_type, exc_val, exc_tb): """ Ends context manager and calls `end` on the `Span`. @@ -374,12 +363,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): if self._record_exception: self.record_exception(exception=exc_val, escaped=True) if self.set_status_on_exception: - self.set_status( - Status( - status_code=StatusCode.ERROR, - description=f"{exc_type.__name__}: {exc_val}", - ) - ) + self.set_status(Status(status_code=StatusCode.ERROR, description=f"{exc_type.__name__}: {exc_val}")) super().__exit__(exc_type, exc_val, exc_tb) @@ -413,7 +397,7 @@ def start_span( self._record_exception = record_exception self.set_status_on_exception = set_status_on_exception - + if not self.nr_application.settings.otel_bridge.enabled: return otel_api_trace.INVALID_SPAN @@ -429,7 +413,7 @@ def start_span( if parent_span_context and self.nr_application.settings.distributed_tracing.enabled: parent_span_trace_id = parent_span_context.trace_id parent_span_trace_flags = parent_span_context.trace_flags - + # If remote_parent, transaction must be created, regardless of kind type # Make sure we transfer DT headers when we are here, if DT is enabled if parent_span_context and parent_span_context.is_remote: @@ -455,16 +439,13 @@ def start_span( request_path=request_path, headers=headers, ) - + # If headers do not contain the traceparent/tracestate # the sampled flag needs to be updated to that of the # parent span. if update_sampled_flag and parent_span_context: transaction._sampled = bool(parent_span_trace_flags) - elif kind in ( - otel_api_trace.SpanKind.PRODUCER, - otel_api_trace.SpanKind.INTERNAL, - ): + 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( @@ -516,7 +497,7 @@ def start_span( if update_GUID_flag and parent_span_context: guid = parent_span_trace_id >> 64 transaction.guid = f"{guid:x}" - + transaction.__enter__() elif kind == otel_api_trace.SpanKind.INTERNAL: if transaction: diff --git a/newrelic/api/transaction.py b/newrelic/api/transaction.py index d435b49a5..1d28022a8 100644 --- a/newrelic/api/transaction.py +++ b/newrelic/api/transaction.py @@ -1445,7 +1445,7 @@ def accept_distributed_trace_headers(self, headers, transport_type="HTTP"): vendors["sa"] = True if vendors.get("sa").lower() == "true" else False vendors["pr"] = float(vendors.get("pr")) vendors["ti"] = int(vendors.get("ti")) - + self.trusted_parent_span = vendors.pop("id", None) data.update(vendors) except: diff --git a/newrelic/config.py b/newrelic/config.py index 242c4cd17..20cfd8989 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -4408,9 +4408,7 @@ def _process_module_builtin_defaults(): # Hybrid Agent Hooks _process_module_definition( - "opentelemetry.context", - "newrelic.hooks.hybridagent_opentelemetry", - "instrument_context_api", + "opentelemetry.context", "newrelic.hooks.hybridagent_opentelemetry", "instrument_context_api" ) _process_module_definition( @@ -4420,15 +4418,11 @@ def _process_module_builtin_defaults(): ) _process_module_definition( - "opentelemetry.trace", - "newrelic.hooks.hybridagent_opentelemetry", - "instrument_trace_api", + "opentelemetry.trace", "newrelic.hooks.hybridagent_opentelemetry", "instrument_trace_api" ) - + _process_module_definition( - "opentelemetry.instrumentation.utils", - "newrelic.hooks.hybridagent_opentelemetry", - "instrument_utils", + "opentelemetry.instrumentation.utils", "newrelic.hooks.hybridagent_opentelemetry", "instrument_utils" ) diff --git a/newrelic/core/config.py b/newrelic/core/config.py index 81d23ff69..a05d8c85c 100644 --- a/newrelic/core/config.py +++ b/newrelic/core/config.py @@ -1431,9 +1431,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 -) +_settings.otel_bridge.enabled = _environ_as_bool("NEW_RELIC_OTEL_BRIDGE_ENABLED", default=False) def global_settings(): diff --git a/newrelic/hooks/hybridagent_opentelemetry.py b/newrelic/hooks/hybridagent_opentelemetry.py index fa9e54221..f8b606b77 100644 --- a/newrelic/hooks/hybridagent_opentelemetry.py +++ b/newrelic/hooks/hybridagent_opentelemetry.py @@ -20,13 +20,12 @@ from newrelic.api.transaction import current_transaction from newrelic.common.object_wrapper import wrap_function_wrapper from newrelic.common.signature import bind_args -from newrelic.common.encoding_utils import NrTraceState, W3CTraceState from newrelic.core.config import global_settings _logger = logging.getLogger(__name__) _TRACER_PROVIDER = None -# Enable OpenTelemetry Bridge to capture HTTP +# Enable OpenTelemetry Bridge to capture HTTP # request/response headers as span attributes: os.environ["OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST"] = ".*" os.environ["OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE"] = ".*" @@ -35,12 +34,13 @@ # Context Instrumentation ########################################### + def wrap__load_runtime_context(wrapped, instance, args, kwargs): settings = global_settings() - + if not settings.otel_bridge.enabled: return wrapped(*args, **kwargs) - + from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext context = ContextVarsRuntimeContext() @@ -49,20 +49,20 @@ def wrap__load_runtime_context(wrapped, instance, args, kwargs): def wrap_get_global_response_propagator(wrapped, instance, args, kwargs): settings = global_settings() - + if not settings.otel_bridge.enabled: return wrapped(*args, **kwargs) - from newrelic.api.opentelemetry import otel_context_propagator from opentelemetry.instrumentation.propagators import set_global_response_propagator - + + from newrelic.api.opentelemetry import otel_context_propagator + set_global_response_propagator(otel_context_propagator) - + return otel_context_propagator def instrument_context_api(module): - if hasattr(module, "_load_runtime_context"): wrap_function_wrapper(module, "_load_runtime_context", wrap__load_runtime_context) @@ -101,7 +101,7 @@ def wrap_get_tracer_provider(wrapped, instance, args, kwargs): application = application_instance(activate=False) if not application or (application and not application.active): application_instance().activate() - + settings = global_settings() if not settings.otel_bridge.enabled: @@ -121,7 +121,7 @@ def wrap_get_current_span(wrapped, instance, args, kwargs): transaction = current_transaction() trace = current_trace() span = wrapped(*args, **kwargs) - + if not transaction: return span @@ -158,21 +158,17 @@ def set_attributes(self, attributes): if transaction.settings.distributed_tracing.enabled: if not isinstance(span, otel_api_trace.NonRecordingSpan): # Use the Otel trace and span ids if current span - # is not a NonRecordingSpan. Otherwise, we need to + # is not a NonRecordingSpan. Otherwise, we need to # override the current span with the transaction # and trace guids. - transaction._trace_id = f'{span.get_span_context().trace_id:x}' + transaction._trace_id = f"{span.get_span_context().trace_id:x}" guid = span.get_span_context().trace_id >> 64 - transaction.guid = f'{guid:x}' - - nr_tracestate_headers = ( - transaction._create_distributed_trace_data() - ) - transaction._distributed_trace_state = 0 # Make sure to reset DT state here - - otel_tracestate_headers = [ - (key, str(value)) for key, value in nr_tracestate_headers.items() - ] + transaction.guid = f"{guid:x}" + + nr_tracestate_headers = transaction._create_distributed_trace_data() + transaction._distributed_trace_state = 0 # Make sure to reset DT state here + + otel_tracestate_headers = [(key, str(value)) for key, value in nr_tracestate_headers.items()] else: otel_tracestate_headers = None diff --git a/tests/hybridagent_opentelemetry/test_attributes.py b/tests/hybridagent_opentelemetry/test_attributes.py index 97f8c6855..fd9933602 100644 --- a/tests/hybridagent_opentelemetry/test_attributes.py +++ b/tests/hybridagent_opentelemetry/test_attributes.py @@ -13,15 +13,14 @@ # limitations under the License. from opentelemetry import trace +from testing_support.fixtures import dt_enabled +from testing_support.validators.validate_span_events import validate_span_events 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 -from testing_support.fixtures import dt_enabled - def test_trace_with_span_attributes(tracer): @dt_enabled diff --git a/tests/hybridagent_opentelemetry/test_context_propagation.py b/tests/hybridagent_opentelemetry/test_context_propagation.py index e88fb396b..14044e5cb 100644 --- a/tests/hybridagent_opentelemetry/test_context_propagation.py +++ b/tests/hybridagent_opentelemetry/test_context_propagation.py @@ -13,81 +13,33 @@ # limitations under the License. import pytest +from opentelemetry import propagate as otel_api_propagate +from opentelemetry import trace as otel_api_trace +from testing_support.fixtures import override_application_settings -from opentelemetry import trace as otel_api_trace, propagate as otel_api_propagate - -from newrelic.api.transaction import ( - accept_distributed_trace_headers, - current_transaction, -) from newrelic.api.background_task import background_task - -from testing_support.fixtures import ( - override_application_settings, -) +from newrelic.api.transaction import accept_distributed_trace_headers, current_transaction PROPAGATOR = otel_api_propagate.get_global_textmap() -_override_settings = { - "trusted_account_key": "1", - "distributed_tracing.enabled": True, - "span_events.enabled": True, -} +_override_settings = {"trusted_account_key": "1", "distributed_tracing.enabled": True, "span_events.enabled": True} @pytest.mark.parametrize( "telemetry,web_headers,propagation", ( - ( - "newrelic", - False, - accept_distributed_trace_headers, - ), - ( - "newrelic", - True, - PROPAGATOR.extract, - ), - ( - "newrelic", - False, - PROPAGATOR.extract, - ), - ( - "hybrid_otel", - False, - accept_distributed_trace_headers, - ), - ( - "hybrid_otel", - True, - PROPAGATOR.extract, - ), - ( - "hybrid_otel", - False, - PROPAGATOR.extract, - ), - ( - "pure_otel", - False, - accept_distributed_trace_headers, - ), - ( - "pure_otel", - True, - PROPAGATOR.extract, - ), - ( - "pure_otel", - False, - PROPAGATOR.extract, - ), - ) + ("newrelic", False, accept_distributed_trace_headers), + ("newrelic", True, PROPAGATOR.extract), + ("newrelic", False, PROPAGATOR.extract), + ("hybrid_otel", False, accept_distributed_trace_headers), + ("hybrid_otel", True, PROPAGATOR.extract), + ("hybrid_otel", False, PROPAGATOR.extract), + ("pure_otel", False, accept_distributed_trace_headers), + ("pure_otel", True, PROPAGATOR.extract), + ("pure_otel", False, PROPAGATOR.extract), + ), ) -def test_distributed_trace_tracestate_compatibility_full_granularity( - telemetry, web_headers, propagation -): +def test_distributed_trace_tracestate_compatibility_full_granularity(telemetry, web_headers, propagation): """ Args: telemetry (str): either "newrelic", "hybrid_otel", or "pure_otel" @@ -105,91 +57,54 @@ def test_distributed_trace_tracestate_compatibility_full_granularity( `accept_distributed_trace_headers`, the web_headers flag must be false. """ + @override_application_settings(_override_settings) @background_task(name="test_distributed_trace_attributes") def _test(): transaction = current_transaction() - + headers = { f"{'HTTP_TRACEPARENT' if web_headers else 'traceparent'}": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01", f"{'HTTP_NEWRELIC' if web_headers else 'newrelic'}": '{"v":[0,1],"d":{"ty":"Mobile","ac":"123","ap":"51424","id":"5f474d64b9cc9b2a","tr":"6e2fea0b173fdad0","pr":0.1234,"sa":True,"ti":1482959525577,"tx":"27856f70d3d314b7"}}', # This header should be ignored. } if telemetry == "newrelic": - headers[ - f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}" - ] = "1@nr=0-0-1-2827902-0af7651916cd43dd-00f067aa0ba902b7-1-1.23456-1518469636035" + headers[f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}"] = ( + "1@nr=0-0-1-2827902-0af7651916cd43dd-00f067aa0ba902b7-1-1.23456-1518469636035" + ) elif telemetry == "hybrid_otel": - headers[ - f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}" - ] = "ac=1,ap=2827902,tx=0af7651916cd43dd,id=00f067aa0ba902b7,sa=True,pr=1.23456,ti=1518469636035,tr=0af7651916cd43dd8448eb211c80319c" + headers[f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}"] = ( + "ac=1,ap=2827902,tx=0af7651916cd43dd,id=00f067aa0ba902b7,sa=True,pr=1.23456,ti=1518469636035,tr=0af7651916cd43dd8448eb211c80319c" + ) # "pure_otel" has no tracestate headers - + propagation(headers) current_span = otel_api_trace.get_current_span() assert transaction.parent_span == "00f067aa0ba902b7" assert transaction.trace_id == "0af7651916cd43dd8448eb211c80319c" assert current_span.get_span_context().trace_id == int("0af7651916cd43dd8448eb211c80319c", 16) - + # Ensure that priority gets set despite having # the sample flag set but no priority set assert transaction.priority _test() - + @pytest.mark.parametrize( "telemetry,web_headers,propagation", ( - ( - "newrelic", - False, - accept_distributed_trace_headers, - ), - ( - "newrelic", - True, - PROPAGATOR.extract, - ), - ( - "newrelic", - False, - PROPAGATOR.extract, - ), - ( - "hybrid_otel", - False, - accept_distributed_trace_headers, - ), - ( - "hybrid_otel", - True, - PROPAGATOR.extract, - ), - ( - "hybrid_otel", - False, - PROPAGATOR.extract, - ), - ( - "pure_otel", - False, - accept_distributed_trace_headers, - ), - ( - "pure_otel", - True, - PROPAGATOR.extract, - ), - ( - "pure_otel", - False, - PROPAGATOR.extract, - ), - ) + ("newrelic", False, accept_distributed_trace_headers), + ("newrelic", True, PROPAGATOR.extract), + ("newrelic", False, PROPAGATOR.extract), + ("hybrid_otel", False, accept_distributed_trace_headers), + ("hybrid_otel", True, PROPAGATOR.extract), + ("hybrid_otel", False, PROPAGATOR.extract), + ("pure_otel", False, accept_distributed_trace_headers), + ("pure_otel", True, PROPAGATOR.extract), + ("pure_otel", False, PROPAGATOR.extract), + ), ) -def test_distributed_trace_tracestate_compatibility_partial_granularity( - telemetry, web_headers, propagation -): +def test_distributed_trace_tracestate_compatibility_partial_granularity(telemetry, web_headers, propagation): """ Args: telemetry (str): either "newrelic", "hybrid_otel", or "pure_otel" @@ -214,34 +129,34 @@ def test_distributed_trace_tracestate_compatibility_partial_granularity( "distributed_tracing.sampler.partial_granularity.enabled": True, } ) + @override_application_settings(test_settings) @background_task(name="test_distributed_trace_attributes") def _test(): transaction = current_transaction() - + headers = { f"{'HTTP_TRACEPARENT' if web_headers else 'traceparent'}": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01", f"{'HTTP_NEWRELIC' if web_headers else 'newrelic'}": '{"v":[0,1],"d":{"ty":"Mobile","ac":"123","ap":"51424","id":"5f474d64b9cc9b2a","tr":"6e2fea0b173fdad0","pr":0.1234,"sa":True,"ti":1482959525577,"tx":"27856f70d3d314b7"}}', # This header should be ignored. } if telemetry == "newrelic": - headers[ - f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}" - ] = "1@nr=0-0-1-2827902-0af7651916cd43dd-00f067aa0ba902b7-1-1.23456-1518469636035" + headers[f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}"] = ( + "1@nr=0-0-1-2827902-0af7651916cd43dd-00f067aa0ba902b7-1-1.23456-1518469636035" + ) elif telemetry == "hybrid_otel": - headers[ - f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}" - ] = "ac=1,ap=2827902,tx=0af7651916cd43dd,id=00f067aa0ba902b7,sa=True,pr=1.23456,ti=1518469636035,tr=0af7651916cd43dd8448eb211c80319c" + headers[f"{'HTTP_TRACESTATE' if web_headers else 'tracestate'}"] = ( + "ac=1,ap=2827902,tx=0af7651916cd43dd,id=00f067aa0ba902b7,sa=True,pr=1.23456,ti=1518469636035,tr=0af7651916cd43dd8448eb211c80319c" + ) # "pure_otel" has no tracestate headers - + propagation(headers) current_span = otel_api_trace.get_current_span() assert transaction.parent_span == "00f067aa0ba902b7" assert transaction.trace_id == "0af7651916cd43dd8448eb211c80319c" assert current_span.get_span_context().trace_id == int("0af7651916cd43dd8448eb211c80319c", 16) - + # Ensure that priority gets set despite having # the sample flag set but no priority set assert transaction.priority _test() - diff --git a/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py b/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py index 564f9e717..f2de9a748 100644 --- a/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py +++ b/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py @@ -12,20 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -from opentelemetry import trace as otel_api_trace, propagate as otel_api_propagate +from opentelemetry import propagate as otel_api_propagate +from opentelemetry import trace as otel_api_trace +from testing_support.fixtures import dt_enabled, override_application_settings +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.transaction import current_transaction -from newrelic.api.time_trace import current_trace from newrelic.api.background_task import BackgroundTask -from newrelic.api.function_trace import FunctionTrace from newrelic.api.external_trace import ExternalTrace - -from testing_support.validators.validate_transaction_count import validate_transaction_count -from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics -from testing_support.validators.validate_span_events import validate_span_events -from testing_support.validators.validate_error_event_attributes import validate_error_event_attributes -from testing_support.fixtures import override_application_settings, dt_enabled +from newrelic.api.function_trace import FunctionTrace +from newrelic.api.time_trace import current_trace +from newrelic.api.transaction import current_transaction PROPAGATOR = otel_api_propagate.get_global_textmap() @@ -45,19 +45,9 @@ def test_does_not_create_segment_without_a_transaction(tracer): @dt_enabled @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 - }, + 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) @@ -76,26 +66,12 @@ def test_creates_opentelemetry_segment_in_a_transaction(tracer): @dt_enabled @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",) + exact_intrinsics={"name": "Function/Baz", "category": "generic"}, expected_intrinsics=("parentId",) ) @validate_span_events( - exact_intrinsics={ - "name": "Function/Foo", - "category": "generic", - "nr.entryPoint": True - }, + exact_intrinsics={"name": "Function/Bar", "category": "generic"}, expected_intrinsics=("parentId",) ) +@validate_span_events(exact_intrinsics={"name": "Function/Foo", "category": "generic", "nr.entryPoint": True}) @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) @@ -149,77 +125,57 @@ def test_opentelemetry_api_can_record_errors(tracer): # OpenTelemetry API and New Relic API can inject outbound trace context @dt_enabled @validate_transaction_metrics(name="Foo", background_task=True) -@validate_span_events( - exact_intrinsics={ - "name": "External/url1/OtelSpan1/GET", - }, - expected_intrinsics=("parentId",) -) -@validate_span_events( - exact_intrinsics={ - "name": "External/url2/segment1/GET", - }, - expected_intrinsics=("parentId",) -) -@validate_span_events( - exact_intrinsics={ - "name": "External/url3/OtelSpan2/GET", - }, - expected_intrinsics=("parentId",) -) -@validate_span_events( - exact_intrinsics={ - "name": "External/url4/segment2/GET", - }, - expected_intrinsics=("parentId",) -) +@validate_span_events(exact_intrinsics={"name": "External/url1/OtelSpan1/GET"}, expected_intrinsics=("parentId",)) +@validate_span_events(exact_intrinsics={"name": "External/url2/segment1/GET"}, expected_intrinsics=("parentId",)) +@validate_span_events(exact_intrinsics={"name": "External/url3/OtelSpan2/GET"}, expected_intrinsics=("parentId",)) +@validate_span_events(exact_intrinsics={"name": "External/url4/segment2/GET"}, expected_intrinsics=("parentId",)) def test_opentelemetry_api_and_new_relic_api_can_inject_outbound_trace_context(tracer): application = application_instance(activate=False) - + with BackgroundTask(application, name="Foo"): transaction = current_transaction() with tracer.start_as_current_span( name="OtelSpan1", kind=otel_api_trace.SpanKind.CLIENT, - attributes={"http.url": "http://url1", "http.method": "GET"} + attributes={"http.url": "http://url1", "http.method": "GET"}, ): headers = {} PROPAGATOR.inject(carrier=headers) _, trace_id, span_id, sampled = headers["traceparent"].split("-") - + # Correct traceId was injected assert transaction.trace_id == trace_id - + # Correct spanId was injected assert current_trace().guid == span_id - + # Correct sampled flag was injected assert transaction.sampled == (sampled == "01") - + # Reset the distributed trace state for the purposes of this test transaction._distributed_trace_state = 0 - + with ExternalTrace(library="segment1", url="http://url2", method="GET"): headers = {} PROPAGATOR.inject(carrier=headers) _, trace_id, span_id, sampled = headers["traceparent"].split("-") - + # Correct traceId was injected assert current_transaction().trace_id == trace_id - + # Correct spanId was injected assert current_trace().guid == span_id - + # Correct sampled flag was injected assert current_transaction().sampled == (sampled == "01") - + # Reset the distributed trace state for the purposes of this test transaction._distributed_trace_state = 0 - + with tracer.start_as_current_span( name="OtelSpan2", kind=otel_api_trace.SpanKind.CLIENT, - attributes={"http.url": "http://url3", "http.method": "GET"} + attributes={"http.url": "http://url3", "http.method": "GET"}, ): headers = [] transaction.insert_distributed_trace_headers(headers) @@ -227,27 +183,27 @@ def test_opentelemetry_api_and_new_relic_api_can_inject_outbound_trace_context(t # Correct traceId was injected assert transaction.trace_id == trace_id - + # Correct spanId was injected assert current_trace().guid == span_id - + # Correct sampled flag was injected - assert transaction.sampled == (sampled == "01") - + assert transaction.sampled == (sampled == "01") + # Reset the distributed trace state for the purposes of this test transaction._distributed_trace_state = 0 - + with ExternalTrace(library="segment2", url="http://url4", method="GET"): headers = [] transaction.insert_distributed_trace_headers(headers) _, trace_id, span_id, sampled = headers[0][1].split("-") - + # Correct traceId was injected assert current_transaction().trace_id == trace_id - + # Correct spanId was injected assert current_trace().guid == span_id - + # Correct sampled flag was injected assert current_transaction().sampled == (sampled == "01") @@ -257,52 +213,37 @@ def test_opentelemetry_api_and_new_relic_api_can_inject_outbound_trace_context(t @validate_transaction_metrics(name="Foo", index=-3) @validate_transaction_metrics(name="Bar", index=-2) @validate_transaction_metrics(name="Baz", background_task=True, index=-1) -@validate_span_events( - exact_intrinsics={ - "name": "Function/EdgeCase", - }, - expected_intrinsics=("parentId",) -) -@validate_span_events( - exact_intrinsics={ - "name": "Function/Baz", - "nr.entryPoint": True - }, -) +@validate_span_events(exact_intrinsics={"name": "Function/EdgeCase"}, expected_intrinsics=("parentId",)) +@validate_span_events(exact_intrinsics={"name": "Function/Baz", "nr.entryPoint": True}) def test_starting_transaction_tests(tracer): application = application_instance(activate=False) - + with tracer.start_as_current_span(name="Foo", kind=otel_api_trace.SpanKind.SERVER): pass - + # Create remote span context and remote context remote_span_context = otel_api_trace.SpanContext( - trace_id=0x1234567890abcdef1234567890abcdef, - span_id=0x1234567890abcdef, + trace_id=0x1234567890ABCDEF1234567890ABCDEF, + span_id=0x1234567890ABCDEF, is_remote=True, trace_flags=otel_api_trace.TraceFlags.SAMPLED, - trace_state=otel_api_trace.TraceState() + trace_state=otel_api_trace.TraceState(), ) remote_context = otel_api_trace.set_span_in_context(otel_api_trace.NonRecordingSpan(remote_span_context)) - + with tracer.start_as_current_span(name="Bar", kind=otel_api_trace.SpanKind.SERVER, context=remote_context): pass - + with BackgroundTask(application, name="Baz"): with tracer.start_as_current_span(name="EdgeCase", kind=otel_api_trace.SpanKind.SERVER, context=remote_context): pass - + # Inbound distributed tracing tests @dt_enabled @validate_transaction_metrics(name="Foo") @validate_span_events(count=0) -@override_application_settings( - { - "trusted_account_key": "1", - "account_id": "1", - } -) +@override_application_settings({"trusted_account_key": "1", "account_id": "1"}) def test_inbound_distributed_tracing_tests(tracer): """ This test intends to check for a scenario where an external call @@ -317,7 +258,7 @@ def test_inbound_distributed_tracing_tests(tracer): "tracestate": "1@nr=0-0-1-12345678-7d3efb1b173fecfa-da8bc8cc6d062849-0-0.23456-1011121314151", } PROPAGATOR.extract(carrier=carrier) - + current_span = otel_api_trace.get_current_span() - - assert current_span.get_span_context().trace_id == 0xda8bc8cc6d062849b0efcf3c169afb5a + + assert current_span.get_span_context().trace_id == 0xDA8BC8CC6D062849B0EFCF3C169AFB5A diff --git a/tests/hybridagent_opentelemetry/test_settings.py b/tests/hybridagent_opentelemetry/test_settings.py index a9f399f63..7e0af1857 100644 --- a/tests/hybridagent_opentelemetry/test_settings.py +++ b/tests/hybridagent_opentelemetry/test_settings.py @@ -13,36 +13,22 @@ # limitations under the License. import pytest +from testing_support.fixtures import dt_enabled, override_application_settings +from testing_support.validators.validate_span_events import validate_span_events +from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics 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.validators.validate_transaction_metrics import validate_transaction_metrics -from testing_support.fixtures import override_application_settings, dt_enabled - -@pytest.mark.parametrize( - "enabled", - [True, False] -) +@pytest.mark.parametrize("enabled", [True, False]) def test_opentelemetry_bridge_enabled(tracer, enabled): @override_application_settings({"otel_bridge.enabled": enabled}) @dt_enabled @validate_transaction_metrics(name="Foo", background_task=True) - @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" - } - ) + @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: diff --git a/tests/hybridagent_opentelemetry/test_status.py b/tests/hybridagent_opentelemetry/test_status.py index e0a093275..5b07c0bb8 100644 --- a/tests/hybridagent_opentelemetry/test_status.py +++ b/tests/hybridagent_opentelemetry/test_status.py @@ -13,12 +13,11 @@ # limitations under the License. import pytest - from opentelemetry.trace.status import Status, StatusCode -from newrelic.api.background_task import background_task - -from testing_support.validators.validate_error_event_attributes import validate_error_event_attributes from testing_support.fixtures import dt_enabled +from testing_support.validators.validate_error_event_attributes import validate_error_event_attributes + +from newrelic.api.background_task import background_task def conditional_decorator(decorator, condition): @@ -31,19 +30,32 @@ def _conditional_decorator(func): @pytest.mark.parametrize( - "current_status,status_to_set,expected_status_code", [ - (Status(StatusCode.UNSET), Status(StatusCode.OK), StatusCode.OK), # current_status==UNSET -> status_to_set - (Status(StatusCode.UNSET), Status(StatusCode.ERROR), StatusCode.ERROR), # current_status==UNSET -> status_to_set - (Status(StatusCode.OK), Status(StatusCode.UNSET), StatusCode.OK), # current_status==OK -> No-Op / status_to_set==UNSET -> No-Op - (Status(StatusCode.OK), Status(StatusCode.ERROR), StatusCode.OK), # current_status==OK -> No-Op - (Status(StatusCode.ERROR), Status(StatusCode.UNSET), StatusCode.ERROR), # status_to_set==UNSET -> No-Op - (Status(StatusCode.ERROR), Status(StatusCode.OK), StatusCode.OK), # current_status==ERROR -> status_to_set - (Status(StatusCode.UNSET), StatusCode.OK, StatusCode.OK), # current_status==UNSET -> status_to_set - (Status(StatusCode.UNSET), StatusCode.ERROR, StatusCode.ERROR), # current_status==UNSET -> status_to_set - (Status(StatusCode.OK), StatusCode.UNSET, StatusCode.OK), # current_status==OK -> No-Op / status_to_set==UNSET -> No-Op - (Status(StatusCode.OK), StatusCode.ERROR, StatusCode.OK), # current_status==OK -> No-Op - (Status(StatusCode.ERROR), StatusCode.UNSET, StatusCode.ERROR), # status_to_set==UNSET -> No-Op - (Status(StatusCode.ERROR), StatusCode.OK, StatusCode.OK), # current_status==ERROR -> status_to_set + "current_status,status_to_set,expected_status_code", + [ + (Status(StatusCode.UNSET), Status(StatusCode.OK), StatusCode.OK), # current_status==UNSET -> status_to_set + ( + Status(StatusCode.UNSET), + Status(StatusCode.ERROR), + StatusCode.ERROR, + ), # current_status==UNSET -> status_to_set + ( + Status(StatusCode.OK), + Status(StatusCode.UNSET), + StatusCode.OK, + ), # current_status==OK -> No-Op / status_to_set==UNSET -> No-Op + (Status(StatusCode.OK), Status(StatusCode.ERROR), StatusCode.OK), # current_status==OK -> No-Op + (Status(StatusCode.ERROR), Status(StatusCode.UNSET), StatusCode.ERROR), # status_to_set==UNSET -> No-Op + (Status(StatusCode.ERROR), Status(StatusCode.OK), StatusCode.OK), # current_status==ERROR -> status_to_set + (Status(StatusCode.UNSET), StatusCode.OK, StatusCode.OK), # current_status==UNSET -> status_to_set + (Status(StatusCode.UNSET), StatusCode.ERROR, StatusCode.ERROR), # current_status==UNSET -> status_to_set + ( + Status(StatusCode.OK), + StatusCode.UNSET, + StatusCode.OK, + ), # current_status==OK -> No-Op / status_to_set==UNSET -> No-Op + (Status(StatusCode.OK), StatusCode.ERROR, StatusCode.OK), # current_status==OK -> No-Op + (Status(StatusCode.ERROR), StatusCode.UNSET, StatusCode.ERROR), # status_to_set==UNSET -> No-Op + (Status(StatusCode.ERROR), StatusCode.OK, StatusCode.OK), # current_status==ERROR -> status_to_set ], ids=( "status_unset_to_ok", @@ -58,7 +70,7 @@ def _conditional_decorator(func): "status_code_ok_to_error", "status_code_error_to_unset", "status_code_error_to_ok", - ) + ), ) def test_status_setting(tracer, current_status, status_to_set, expected_status_code): @background_task() @@ -66,7 +78,7 @@ def _test(): with tracer.start_as_current_span(name="TestSpan") as span: # First, set to the current status to simulate the initial state span.set_status(current_status) - + # Then, attempt to set the new status span.set_status(status_to_set) assert span.status.status_code == expected_status_code @@ -74,14 +86,8 @@ def _test(): _test() -@pytest.mark.parametrize( - "_record_exception", - [True, False] -) -@pytest.mark.parametrize( - "_set_status_on_exception", - [True, False] -) +@pytest.mark.parametrize("_record_exception", [True, False]) +@pytest.mark.parametrize("_set_status_on_exception", [True, False]) def test_set_status_with_start_as_current_span(tracer, _record_exception, _set_status_on_exception): @dt_enabled @conditional_decorator( @@ -92,12 +98,14 @@ def test_set_status_with_start_as_current_span(tracer, _record_exception, _set_s "user": {"exception.escaped": False}, } ), - condition=_record_exception + condition=_record_exception, ) @background_task() def _test(): with pytest.raises(ValueError): - with tracer.start_as_current_span(name="TestSpan", record_exception=_record_exception, set_status_on_exception=_set_status_on_exception) as span: + with tracer.start_as_current_span( + name="TestSpan", record_exception=_record_exception, set_status_on_exception=_set_status_on_exception + ) as span: raise ValueError("Test exception message") assert span.status.status_code == StatusCode.ERROR if _set_status_on_exception else StatusCode.UNSET @@ -105,14 +113,8 @@ def _test(): _test() -@pytest.mark.parametrize( - "_record_exception", - [True, False] -) -@pytest.mark.parametrize( - "_set_status_on_exception", - [True, False] -) +@pytest.mark.parametrize("_record_exception", [True, False]) +@pytest.mark.parametrize("_set_status_on_exception", [True, False]) def test_set_status_with_start_span(tracer, _record_exception, _set_status_on_exception): @dt_enabled @conditional_decorator( @@ -123,14 +125,16 @@ def test_set_status_with_start_span(tracer, _record_exception, _set_status_on_ex "user": {"exception.escaped": True}, } ), - condition=_record_exception + condition=_record_exception, ) @background_task() def _test(): with pytest.raises(ValueError): - with tracer.start_span(name="TestSpan", record_exception=_record_exception, set_status_on_exception=_set_status_on_exception) as span: + with tracer.start_span( + name="TestSpan", record_exception=_record_exception, set_status_on_exception=_set_status_on_exception + ) as span: raise ValueError("Test exception message") assert span.status.status_code == StatusCode.ERROR if _set_status_on_exception else StatusCode.UNSET - _test() \ No newline at end of file + _test() From 6d340a0ce82e21b38748303661e8c99cb9942686 Mon Sep 17 00:00:00 2001 From: Lalleh Rafeei Date: Fri, 12 Dec 2025 12:37:51 -0800 Subject: [PATCH 4/9] Remove extra space --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index 4187b6f4a..5017adceb 100644 --- a/tox.ini +++ b/tox.ini @@ -491,7 +491,6 @@ commands = framework_azurefunctions: {toxinidir}/.github/scripts/install_azure_functions_worker.sh - coverage run -m pytest -v [] allowlist_externals = From f599eedfedb79e7a2f39dd83e9e5cc79900b540c Mon Sep 17 00:00:00 2001 From: Lalleh Rafeei Date: Tue, 2 Dec 2025 12:49:00 -0800 Subject: [PATCH 5/9] Context propagation/DT enabling Add set_status and record_exception temp --- newrelic/api/opentelemetry.py | 24 ++ newrelic/api/transaction.py | 1 - newrelic/config.py | 16 +- newrelic/core/config.py | 4 +- newrelic/hooks/hybridagent_opentelemetry.py | 10 +- .../_test_otel_application.py | 78 ++++++ .../_test_otel_application_async.py | 27 ++ tests/hybridagent_flask/conftest.py | 52 ++++ tests/hybridagent_flask/newrelic_flask.ini | 14 + .../test_otel_application.py | 265 ++++++++++++++++++ .../test_hybrid_cross_agent.py | 51 +++- .../hybridagent_opentelemetry/test_status.py | 1 - tox.ini | 1 + 13 files changed, 527 insertions(+), 17 deletions(-) create mode 100644 tests/hybridagent_flask/_test_otel_application.py create mode 100644 tests/hybridagent_flask/_test_otel_application_async.py create mode 100644 tests/hybridagent_flask/conftest.py create mode 100644 tests/hybridagent_flask/newrelic_flask.ini create mode 100644 tests/hybridagent_flask/test_otel_application.py diff --git a/newrelic/api/opentelemetry.py b/newrelic/api/opentelemetry.py index 5ca4c6d78..694a3c2bf 100644 --- a/newrelic/api/opentelemetry.py +++ b/newrelic/api/opentelemetry.py @@ -39,6 +39,11 @@ insert_distributed_trace_headers, ) from newrelic.api.web_transaction import WebTransaction + +from newrelic.common.encoding_utils import ( + W3CTraceState, + NrTraceState, +) from newrelic.core.otlp_utils import create_resource _logger = logging.getLogger(__name__) @@ -352,6 +357,25 @@ def end(self, end_time=None, *args, **kwargs): self._set_attributes_in_nr({"span.kind": self.kind}) self.nr_trace.__exit__(*sys.exc_info()) + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Ends context manager and calls `end` on the `Span`. + This is used when span is called as a context manager + i.e. `with tracer.start_span() as span:` + """ + if exc_val and self.is_recording(): + if self._record_exception: + self.record_exception(exception=exc_val, escaped=True) + if self.set_status_on_exception: + self.set_status( + Status( + status_code=StatusCode.ERROR, + description=f"{exc_type.__name__}: {exc_val}", + ) + ) + + super().__exit__(exc_type, exc_val, exc_tb) def __exit__(self, exc_type, exc_val, exc_tb): """ diff --git a/newrelic/api/transaction.py b/newrelic/api/transaction.py index 1d28022a8..9ca17e768 100644 --- a/newrelic/api/transaction.py +++ b/newrelic/api/transaction.py @@ -1445,7 +1445,6 @@ def accept_distributed_trace_headers(self, headers, transport_type="HTTP"): vendors["sa"] = True if vendors.get("sa").lower() == "true" else False vendors["pr"] = float(vendors.get("pr")) vendors["ti"] = int(vendors.get("ti")) - self.trusted_parent_span = vendors.pop("id", None) data.update(vendors) except: diff --git a/newrelic/config.py b/newrelic/config.py index 20cfd8989..156790838 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -4422,7 +4422,21 @@ def _process_module_builtin_defaults(): ) _process_module_definition( - "opentelemetry.instrumentation.utils", "newrelic.hooks.hybridagent_opentelemetry", "instrument_utils" + "opentelemetry.instrumentation.propagators", + "newrelic.hooks.hybridagent_opentelemetry", + "instrument_global_propagators_api", + ) + + _process_module_definition( + "opentelemetry.trace", + "newrelic.hooks.hybridagent_opentelemetry", + "instrument_trace_api", + ) + + _process_module_definition( + "opentelemetry.instrumentation.utils", + "newrelic.hooks.hybridagent_opentelemetry", + "instrument_utils", ) diff --git a/newrelic/core/config.py b/newrelic/core/config.py index a05d8c85c..81d23ff69 100644 --- a/newrelic/core/config.py +++ b/newrelic/core/config.py @@ -1431,7 +1431,9 @@ 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) +_settings.otel_bridge.enabled = _environ_as_bool( + "NEW_RELIC_OTEL_BRIDGE_ENABLED", default=False +) def global_settings(): diff --git a/newrelic/hooks/hybridagent_opentelemetry.py b/newrelic/hooks/hybridagent_opentelemetry.py index f8b606b77..8ad7c0e5a 100644 --- a/newrelic/hooks/hybridagent_opentelemetry.py +++ b/newrelic/hooks/hybridagent_opentelemetry.py @@ -20,6 +20,7 @@ from newrelic.api.transaction import current_transaction from newrelic.common.object_wrapper import wrap_function_wrapper from newrelic.common.signature import bind_args +from newrelic.common.encoding_utils import NrTraceState, W3CTraceState from newrelic.core.config import global_settings _logger = logging.getLogger(__name__) @@ -49,16 +50,15 @@ def wrap__load_runtime_context(wrapped, instance, args, kwargs): def wrap_get_global_response_propagator(wrapped, instance, args, kwargs): settings = global_settings() - + if not settings.otel_bridge.enabled: return wrapped(*args, **kwargs) - from opentelemetry.instrumentation.propagators import set_global_response_propagator - from newrelic.api.opentelemetry import otel_context_propagator - + from opentelemetry.instrumentation.propagators import set_global_response_propagator + set_global_response_propagator(otel_context_propagator) - + return otel_context_propagator diff --git a/tests/hybridagent_flask/_test_otel_application.py b/tests/hybridagent_flask/_test_otel_application.py new file mode 100644 index 000000000..9fc5e68c2 --- /dev/null +++ b/tests/hybridagent_flask/_test_otel_application.py @@ -0,0 +1,78 @@ +# 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 webtest +from flask import Flask, abort, render_template, render_template_string +# from opentelemetry import trace +from opentelemetry.instrumentation.flask import FlaskInstrumentor +from werkzeug.exceptions import NotFound +from werkzeug.routing import Rule + +# from tests.framework_flask._test_application import application (instead of redefining application and functions?) + +application = Flask(__name__) +FlaskInstrumentor().instrument_app(application) + +# tracer = trace.get_tracer(__name__) + + +@application.route("/index") +def index_page(): + return "INDEX RESPONSE" + + +application.url_map.add(Rule("/endpoint", endpoint="endpoint")) + + +@application.endpoint("endpoint") +def endpoint_page(): + return "ENDPOINT RESPONSE" + + +@application.route("/error") +def error_page(): + # raise RuntimeError("RUNTIME ERROR") + raise ValueError("VALUE ERROR") + + +@application.route("/abort_404") +def abort_404_page(): + abort(404) + + +@application.route("/exception_404") +def exception_404_page(): + raise NotFound + + +@application.route("/template_string") +def template_string(): + return render_template_string("

INDEX RESPONSE

") + + +@application.route("/template_not_found") +def template_not_found(): + return render_template("not_found") + + +@application.route("/html_insertion") +def html_insertion(): + return ( + "Some header" + "

My First Heading

My first paragraph.

" + "" + ) + + +_test_application = webtest.TestApp(application) diff --git a/tests/hybridagent_flask/_test_otel_application_async.py b/tests/hybridagent_flask/_test_otel_application_async.py new file mode 100644 index 000000000..519b5831b --- /dev/null +++ b/tests/hybridagent_flask/_test_otel_application_async.py @@ -0,0 +1,27 @@ +# 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 webtest +from _test_otel_application import application +from conftest import async_handler_support + +# Async handlers only supported in Flask >2.0.0 +if async_handler_support: + + @application.route("/async") + async def async_page(): + return "ASYNC RESPONSE" + + +_test_application = webtest.TestApp(application) diff --git a/tests/hybridagent_flask/conftest.py b/tests/hybridagent_flask/conftest.py new file mode 100644 index 000000000..3985e3034 --- /dev/null +++ b/tests/hybridagent_flask/conftest.py @@ -0,0 +1,52 @@ +# 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 platform +import pytest +from opentelemetry import trace +from testing_support.fixtures import collector_agent_registration_fixture, collector_available_fixture + +from newrelic.api.opentelemetry import TracerProvider +from newrelic.common.package_version_utils import get_package_version_tuple + +FLASK_VERSION = get_package_version_tuple("flask") + +is_flask_v2 = FLASK_VERSION[0] >= 2 +is_not_flask_v2_3 = FLASK_VERSION < (2, 3, 0) +is_pypy = platform.python_implementation() == "PyPy" +async_handler_support = is_flask_v2 and not is_pypy +skip_if_not_async_handler_support = pytest.mark.skipif( + not async_handler_support, reason="Requires async handler support. (Flask >=v2.0.0, CPython)" +) + +_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, Flask)", 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_flask/newrelic_flask.ini b/tests/hybridagent_flask/newrelic_flask.ini new file mode 100644 index 000000000..c042a69e0 --- /dev/null +++ b/tests/hybridagent_flask/newrelic_flask.ini @@ -0,0 +1,14 @@ +[newrelic] +developer_mode = True + +[import-hook:flask.app] +enabled = False + +[import-hook:flask.templating] +enabled = False + +[import-hook:flask.blueprints] +enabled = False + +[import-hook:flask.views] +enabled = False diff --git a/tests/hybridagent_flask/test_otel_application.py b/tests/hybridagent_flask/test_otel_application.py new file mode 100644 index 000000000..2ceba2cb3 --- /dev/null +++ b/tests/hybridagent_flask/test_otel_application.py @@ -0,0 +1,265 @@ +# 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 +import os + +from conftest import async_handler_support, skip_if_not_async_handler_support +from testing_support.fixtures import override_application_settings, collector_agent_registration_fixture, collector_available_fixture +from testing_support.validators.validate_transaction_errors import validate_transaction_errors +from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics +from testing_support.validators.validate_error_event_attributes import validate_error_event_attributes + +os.environ["NEW_RELIC_CONFIG_FILE"] = "/Users/lrafeei/repo/newrelic-python-agent/tests/hybridagent_flask/newrelic_flask.ini" +# os.environ["NEW_RELIC_CONFIG_FILE"] = "newrelic_flask.ini" + + +# _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, +# "debug.log_autorum_middleware": True, +# "otel_bridge.enabled": True, +# } + +# collector_agent_registration = collector_agent_registration_fixture( +# app_name="Python Hybrid Agent Test (framework_flask)", default_settings=_default_settings, scope="module" +# ) + +try: + # The __version__ attribute was only added in 0.7.0. + # Flask team does not use semantic versioning during development. + from flask import __version__ as flask_version + + flask_version = tuple([int(v) for v in flask_version.split(".")]) + is_gt_flask060 = True + is_dev_version = False +except ValueError: + is_gt_flask060 = True + is_dev_version = True +except ImportError: + is_gt_flask060 = False + is_dev_version = False + +requires_endpoint_decorator = pytest.mark.skipif(not is_gt_flask060, reason="The endpoint decorator is not supported.") + +def target_application(): + # We need to delay Flask application creation because of ordering + # issues whereby the agent needs to be initialised before Flask is + # imported and the routes configured. Normally pytest only runs the + # global fixture which will initialise the agent after each test + # file is imported, which is too late. + + if not async_handler_support: + from _test_otel_application import _test_application + else: + from _test_otel_application_async import _test_application + return _test_application + + +_test_application_index_scoped_metrics = [ + ("Function/GET /index", 1), +] + +@validate_transaction_errors(errors=[]) +@validate_transaction_metrics("index", group="Uri", scoped_metrics=_test_application_index_scoped_metrics) +@override_application_settings({"otel_bridge.enabled": True}) +def test_otel_application_index(): + application = target_application() + response = application.get("/index") + response.mustcontain("INDEX RESPONSE") + + +_test_application_async_scoped_metrics = [ + ("Function/GET /async", 1), +] + +@skip_if_not_async_handler_support +@validate_transaction_errors(errors=[]) +@validate_transaction_metrics( + "async", group="Uri", scoped_metrics=_test_application_async_scoped_metrics +) +@override_application_settings({"otel_bridge.enabled": True}) +def test_otel_application_async(): + application = target_application() + response = application.get("/async") + response.mustcontain("ASYNC RESPONSE") + + +_test_application_endpoint_scoped_metrics = [ + ("Function/GET /endpoint", 1), +] + +@validate_transaction_errors(errors=[]) +@validate_transaction_metrics( + "endpoint", group="Uri", scoped_metrics=_test_application_endpoint_scoped_metrics +) +@override_application_settings({"otel_bridge.enabled": True}) +def test_otel_application_endpoint(): + application = target_application() + response = application.get("/endpoint") + response.mustcontain("ENDPOINT RESPONSE") + + +_test_application_error_scoped_metrics = [ + ("Function/GET /error", 1), +] + +# @reset_core_stats_engine() +# @validate_transaction_errors(errors=["builtins:RuntimeError"]) +@validate_transaction_errors(errors=["builtins:ValueError"]) +# @validate_error_event_attributes( +# exact_attrs={ +# "agent": {}, +# "intrinsic": {"error.message": "RUNTIME ERROR", "error.class": "builtins:RuntimeError"}, +# "user": {"exception.escaped": False}, +# } +# ) +# @validate_transaction_metrics("error", group="Uri", scoped_metrics=_test_application_error_scoped_metrics) +# @override_application_settings({"otel_bridge.enabled": True}) +def test_otel_application_error(): + application = target_application() + application.get("/error", status=500, expect_errors=True) + # with pytest.raises(RuntimeError): + # application = target_application() + # application.get("/error", status=500, expect_errors=True) + + +# _test_application_abort_404_scoped_metrics = [ +# ("Function/flask.app:Flask.wsgi_app", 1), +# ("Python/WSGI/Application", 1), +# ("Python/WSGI/Response", 1), +# ("Python/WSGI/Finalize", 1), +# ("Function/_test_application:abort_404_page", 1), +# ("Function/flask.app:Flask.handle_http_exception", 1), +# ("Function/werkzeug.wsgi:ClosingIterator.close", 1), +# ("Function/flask.app:Flask.handle_user_exception", 1), +# ] + + +# @validate_transaction_errors(errors=[]) +# @validate_transaction_metrics( +# "_test_application:abort_404_page", scoped_metrics=_test_application_abort_404_scoped_metrics +# ) +# # @validate_code_level_metrics("_test_application", "abort_404_page") +# def test_application_abort_404(): +# application = target_application() +# application.get("/abort_404", status=404) + + +# _test_application_exception_404_scoped_metrics = [ +# ("Function/flask.app:Flask.wsgi_app", 1), +# ("Python/WSGI/Application", 1), +# ("Python/WSGI/Response", 1), +# ("Python/WSGI/Finalize", 1), +# ("Function/_test_application:exception_404_page", 1), +# ("Function/flask.app:Flask.handle_http_exception", 1), +# ("Function/werkzeug.wsgi:ClosingIterator.close", 1), +# ("Function/flask.app:Flask.handle_user_exception", 1), +# ] + + +# @validate_transaction_errors(errors=[]) +# @validate_transaction_metrics( +# "_test_application:exception_404_page", scoped_metrics=_test_application_exception_404_scoped_metrics +# ) +# # @validate_code_level_metrics("_test_application", "exception_404_page") +# def test_application_exception_404(): +# application = target_application() +# application.get("/exception_404", status=404) + + +# _test_application_not_found_scoped_metrics = [ +# ("Function/flask.app:Flask.wsgi_app", 1), +# ("Python/WSGI/Application", 1), +# ("Python/WSGI/Response", 1), +# ("Python/WSGI/Finalize", 1), +# ("Function/flask.app:Flask.handle_http_exception", 1), +# ("Function/werkzeug.wsgi:ClosingIterator.close", 1), +# ("Function/flask.app:Flask.handle_user_exception", 1), +# ] + + +# @validate_transaction_errors(errors=[]) +# @validate_transaction_metrics( +# "flask.app:Flask.handle_http_exception", scoped_metrics=_test_application_not_found_scoped_metrics +# ) +# def test_application_not_found(): +# application = target_application() +# application.get("/missing", status=404) + + +# _test_application_render_template_string_scoped_metrics = [ +# ("Function/flask.app:Flask.wsgi_app", 1), +# ("Python/WSGI/Application", 1), +# ("Python/WSGI/Response", 1), +# ("Python/WSGI/Finalize", 1), +# ("Function/_test_application:template_string", 1), +# ("Function/werkzeug.wsgi:ClosingIterator.close", 1), +# ("Template/Compile/