diff --git a/newrelic/api/opentelemetry.py b/newrelic/api/opentelemetry.py index 8cd009be6..86179c297 100644 --- a/newrelic/api/opentelemetry.py +++ b/newrelic/api/opentelemetry.py @@ -15,12 +15,14 @@ import logging import sys from contextlib import contextmanager +from types import MappingProxyType from opentelemetry import trace as otel_api_trace from opentelemetry.baggage.propagation import W3CBaggagePropagator 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 @@ -30,8 +32,11 @@ from newrelic.api.message_trace import MessageTrace from newrelic.api.message_transaction import MessageTransaction from newrelic.api.time_trace import current_trace, notice_error -from newrelic.api.transaction import Sentinel, current_transaction -from newrelic.api.web_transaction import WebTransaction +from newrelic.api.transaction import ( + Sentinel, + current_transaction, +) +from newrelic.api.web_transaction import WebTransaction, WSGIWebTransaction from newrelic.core.otlp_utils import create_resource _logger = logging.getLogger(__name__) @@ -87,7 +92,7 @@ def inject(self, carrier, context=None, setter=None): # Do NOT call super().inject() since we have already # inserted the headers here. It will not cause harm, # but it is redundant logic. - + # If distributed_trace_state == 2 or 3, do not inject headers. @@ -111,6 +116,8 @@ def __init__( nr_transaction=None, nr_trace_type=FunctionTrace, instrumenting_module=None, + record_exception=True, + set_status_on_exception=True, *args, **kwargs, ): @@ -123,6 +130,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 self.nr_parent = None current_nr_trace = current_trace() @@ -238,19 +248,53 @@ def is_recording(self): if getattr(self.nr_trace, "end_time", None): return False - return getattr(self.nr_transaction, "priority", 1) > 0 + # If priority is either not set at this point + # or greater than 0, we are recording. + priority = self.nr_transaction.priority + return (priority is None) or (priority > 0) def set_status(self, status, description=None): - # TODO: not implemented yet - raise NotImplementedError("Not implemented yet") + """ + This code is modeled after the OpenTelemetry SDK's + status implementation: + https://github.com/open-telemetry/opentelemetry-python/blob/main/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py#L979 + + Additional 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__) - if not hasattr(self, "nr_trace"): - notice_error(error_args, attributes=attributes) + # `escaped` indicates whether the exception has not + # been unhandled by the time the span has ended. + if attributes: + attributes.update({"exception.escaped": escaped}) else: - self.nr_trace.notice_error(error_args, attributes=attributes) + 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 @@ -281,7 +325,61 @@ def end(self, end_time=None, *args, **kwargs): # Set SpanKind attribute self._set_attributes_in_nr({"span.kind": self.kind}) - self.nr_trace.__exit__(*sys.exc_info()) + error = sys.exc_info() + self.nr_trace.__exit__(*error) + self.set_status(StatusCode.OK if not error[0] else StatusCode.ERROR) + + if ("exception.escaped" in self.attributes) or (self.kind in (otel_api_trace.SpanKind.SERVER, otel_api_trace.SpanKind.CONSUMER) and isinstance(current_trace(), Sentinel)): + # We need to end the transaction as well + self.nr_transaction.__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): + """ + 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): + """ + 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): @@ -311,6 +409,9 @@ def start_span( # 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 @@ -328,24 +429,28 @@ def start_span( # 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 - headers = self.attributes.pop("nr.http.headers", None) - scheme = self.attributes.get("http.scheme") - host = self.attributes.get("http.server_name") - port = self.attributes.get("net.host.port") - request_method = self.attributes.get("http.method") - request_path = self.attributes.get("http.route") - - transaction = WebTransaction( - self.nr_application, - name=name, - scheme=scheme, - host=host, - port=port, - request_method=request_method, - request_path=request_path, - headers=headers, - ) + if "nr.wsgi.environ" in self.attributes: + # This is a WSGI request + transaction = WSGIWebTransaction(self.nr_application, environ=self.attributes.pop("nr.wsgi.environ")) + else: + # This is a web request + headers = self.attributes.pop("nr.http.headers", None) + scheme = self.attributes.get("http.scheme") + host = self.attributes.get("http.server_name") + port = self.attributes.get("net.host.port") + request_method = self.attributes.get("http.method") + request_path = self.attributes.get("http.route") + + transaction = WebTransaction( + self.nr_application, + name=name, + scheme=scheme, + host=host, + port=port, + request_method=request_method, + request_path=request_path, + headers=headers, + ) elif kind in (otel_api_trace.SpanKind.PRODUCER, otel_api_trace.SpanKind.INTERNAL): transaction = BackgroundTask(self.nr_application, name=name) @@ -371,30 +476,34 @@ def start_span( if transaction: nr_trace_type = FunctionTrace elif not transaction: - # This is a web request - headers = self.attributes.pop("nr.http.headers", None) - scheme = self.attributes.get("http.scheme") - host = self.attributes.get("http.server_name") - port = self.attributes.get("net.host.port") - request_method = self.attributes.get("http.method") - request_path = self.attributes.get("http.route") - - transaction = WebTransaction( - self.nr_application, - name=name, - scheme=scheme, - host=host, - port=port, - request_method=request_method, - request_path=request_path, - headers=headers, - ) - - transaction._trace_id = ( - f"{parent_span_trace_id:x}" if parent_span_trace_id else transaction.trace_id - ) + if "nr.wsgi.environ" in self.attributes: + # This is a WSGI request + transaction = WSGIWebTransaction(self.nr_application, environ=self.attributes.pop("nr.wsgi.environ")) + else: + # This is a web request + headers = self.attributes.pop("nr.http.headers", None) + scheme = self.attributes.get("http.scheme") + host = self.attributes.get("http.server_name") + port = self.attributes.get("net.host.port") + request_method = self.attributes.get("http.method") + request_path = self.attributes.get("http.route") + + transaction = WebTransaction( + self.nr_application, + name=name, + scheme=scheme, + host=host, + port=port, + request_method=request_method, + request_path=request_path, + headers=headers, + ) + + transaction._trace_id = ( + f"{parent_span_trace_id:x}" if parent_span_trace_id else transaction.trace_id + ) - transaction.__enter__() + transaction.__enter__() elif kind == otel_api_trace.SpanKind.INTERNAL: if transaction: nr_trace_type = FunctionTrace @@ -439,6 +548,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 @@ -464,7 +575,7 @@ def start_as_current_span( set_status_on_exception=set_status_on_exception, ) - with otel_api_trace.use_span(span, end_on_exit=end_on_exit, record_exception=record_exception) as current_span: + with otel_api_trace.use_span(span, end_on_exit=end_on_exit, record_exception=record_exception, set_status_on_exception=set_status_on_exception) as current_span: yield current_span diff --git a/newrelic/config.py b/newrelic/config.py index 87fec8b3f..4d0a36f56 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -4443,7 +4443,15 @@ def _process_module_builtin_defaults(): ) _process_module_definition( - "opentelemetry.instrumentation.utils", "newrelic.hooks.hybridagent_opentelemetry", "instrument_utils" + "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 9cf800096..6c5fed8bd 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__) @@ -30,15 +31,15 @@ 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() - + application = application_instance(activate=False) + settings = global_settings() if not application else application.settings + if not settings.otel_bridge.enabled: return wrapped(*args, **kwargs) @@ -49,17 +50,17 @@ def wrap__load_runtime_context(wrapped, instance, args, kwargs): def wrap_get_global_response_propagator(wrapped, instance, args, kwargs): - settings = global_settings() - + application = application_instance(activate=False) + settings = global_settings() if not application else application.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 @@ -81,7 +82,8 @@ def instrument_global_propagators_api(module): def wrap_set_tracer_provider(wrapped, instance, args, kwargs): - settings = global_settings() + application = application_instance(activate=False) + settings = global_settings() if not application else application.settings if not settings.otel_bridge.enabled: return wrapped(*args, **kwargs) @@ -128,7 +130,12 @@ def wrap_get_current_span(wrapped, instance, args, kwargs): if not transaction: return span - settings = transaction.settings or global_settings() + # Do not allow the wrapper to continue if + # the Hybrid Agent setting is not enabled + application = application_instance(activate=False) + app_settings = global_settings() if not application else application.settings + settings = transaction.settings or app_settings + if not settings.otel_bridge.enabled: return span @@ -184,7 +191,8 @@ def wrap_start_internal_or_server_span(wrapped, instance, args, kwargs): # Do not allow the wrapper to continue if # the Hybrid Agent setting is not enabled - settings = global_settings() + application = application_instance(activate=False) + settings = global_settings() if not application else application.settings if not settings.otel_bridge.enabled: return wrapped(*args, **kwargs) @@ -196,8 +204,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) - attributes["nr.http.headers"] = context_carrier - + if "wsgi.version" in context_carrier: + attributes["nr.wsgi.environ"] = context_carrier + else: + attributes["nr.http.headers"] = context_carrier else: attributes["nr.nonhttp.headers"] = context_carrier diff --git a/tests/hybridagent_flask/_test_otel_application.py b/tests/hybridagent_flask/_test_otel_application.py new file mode 100644 index 000000000..a0c98f1e6 --- /dev/null +++ b/tests/hybridagent_flask/_test_otel_application.py @@ -0,0 +1,77 @@ +# 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") + + +@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 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..6ec9897f5 --- /dev/null +++ b/tests/hybridagent_flask/test_otel_application.py @@ -0,0 +1,181 @@ +# 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 pathlib import Path + +from conftest import async_handler_support, skip_if_not_async_handler_support +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"] = str(Path(__file__).parent / "newrelic_flask.ini") + +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) +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 +) +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 +) +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), +] + +@validate_transaction_errors(errors=["builtins:RuntimeError"]) +@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) +def test_otel_application_error(): + application = target_application() + application.get("/error", status=500, expect_errors=True) + + +_test_application_abort_404_scoped_metrics = [ + ("Function/GET /abort_404", 1), +] + +@validate_transaction_errors(errors=[]) +@validate_transaction_metrics( + "abort_404", group="Uri", scoped_metrics=_test_application_abort_404_scoped_metrics +) +def test_otel_application_abort_404(): + application = target_application() + application.get("/abort_404", status=404) + + +_test_application_exception_404_scoped_metrics = [ + ("Function/GET /exception_404", 1), +] + +@validate_transaction_errors(errors=[]) +@validate_transaction_metrics( + "exception_404", group="Uri", scoped_metrics=_test_application_exception_404_scoped_metrics +) +def test_application_exception_404(): + application = target_application() + application.get("/exception_404", status=404) + + +_test_application_not_found_scoped_metrics = [ + ("Function/GET /missing", 1), +] + +@validate_transaction_errors(errors=[]) +@validate_transaction_metrics( + "missing", group="Uri", 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/GET /template_string", 1), + ("Template/Compile/", 1), + ("Template/Render/", 1), +] + +@validate_transaction_errors(errors=[]) +@validate_transaction_metrics( + "template_string", group="Uri", scoped_metrics=_test_application_render_template_string_scoped_metrics +) +def test_application_render_template_string(): + application = target_application() + application.get("/template_string") + + +_test_application_render_template_not_found_scoped_metrics = [ + ("Function/GET /template_not_found", 1), +] + +@validate_transaction_errors(errors=["jinja2.exceptions:TemplateNotFound"]) +@validate_transaction_metrics( + "template_not_found", group="Uri", scoped_metrics=_test_application_render_template_not_found_scoped_metrics +) +def test_application_render_template_not_found(): + application = target_application() + application.get("/template_not_found", status=500, expect_errors=True) + diff --git a/tests/hybridagent_opentelemetry/test_attributes.py b/tests/hybridagent_opentelemetry/test_attributes.py index 77ebee68f..fd9933602 100644 --- a/tests/hybridagent_opentelemetry/test_attributes.py +++ b/tests/hybridagent_opentelemetry/test_attributes.py @@ -13,6 +13,7 @@ # 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 @@ -22,6 +23,7 @@ def test_trace_with_span_attributes(tracer): + @dt_enabled @validate_span_events( count=1, exact_intrinsics={ @@ -69,6 +71,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 48ae73dc3..8c95c80b8 100644 --- a/tests/hybridagent_opentelemetry/test_context_propagation.py +++ b/tests/hybridagent_opentelemetry/test_context_propagation.py @@ -25,8 +25,6 @@ _override_settings = {"trusted_account_key": "1", "distributed_tracing.enabled": True, "span_events.enabled": True} - -# @dt_enabled @pytest.mark.parametrize("telemetry", ["newrelic", "otel"]) @pytest.mark.parametrize("propagation", [accept_distributed_trace_headers, PROPAGATOR.extract]) def test_distributed_trace_header_compatibility_full_granularity(telemetry, propagation): diff --git a/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py b/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py index f2de9a748..bb72c406d 100644 --- a/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py +++ b/tests/hybridagent_opentelemetry/test_hybrid_cross_agent.py @@ -12,23 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -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 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.external_trace import ExternalTrace 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 -PROPAGATOR = otel_api_propagate.get_global_textmap() +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 +PROPAGATOR = otel_api_propagate.get_global_textmap() # Does not create segment without a transaction @validate_transaction_count(0) @@ -45,7 +45,18 @@ 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",) + 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): @@ -66,10 +77,18 @@ 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",) + 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"}) diff --git a/tests/hybridagent_opentelemetry/test_status.py b/tests/hybridagent_opentelemetry/test_status.py new file mode 100644 index 000000000..05f927c78 --- /dev/null +++ b/tests/hybridagent_opentelemetry/test_status.py @@ -0,0 +1,132 @@ +# 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.fixtures import dt_enabled +from testing_support.validators.validate_error_event_attributes import validate_error_event_attributes +from testing_support.util import 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): + @dt_enabled + @conditional_decorator( + condition=_record_exception, + decorator=validate_error_event_attributes( + exact_attrs={ + "agent": {}, + "intrinsic": {"error.message": "Test exception message", "error.class": "builtins:ValueError"}, + "user": {"exception.escaped": False}, + } + ) + ) + @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): + @dt_enabled + @conditional_decorator( + condition=_record_exception, + decorator=validate_error_event_attributes( + exact_attrs={ + "agent": {}, + "intrinsic": {"error.message": "Test exception message", "error.class": "builtins:ValueError"}, + "user": {"exception.escaped": True}, + } + ), + ) + @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() diff --git a/tox.ini b/tox.ini index 2e7585528..a819f9763 100644 --- a/tox.ini +++ b/tox.ini @@ -178,7 +178,8 @@ envlist = python-framework_tornado-{py38,py39,py310,py311,py312,py313,py314}-tornadolatest, ; Remove `python-framework_tornado-py314-tornadomaster` temporarily python-framework_tornado-{py310,py311,py312,py313}-tornadomaster, - python-hybridagent_opentelemetry-{py38,py39,py310,py311,py312,py313,py314,pypy311}-opentelemetrylatest, + python-hybridagent_opentelemetry-{py38,py39,py310,py311,py312,py313,py314,pypy311}, + python-hybridagent_flask-{py38,py39,py310,py311,py312,py313,py314,pypy311}, python-logger_logging-{py38,py39,py310,py311,py312,py313,py314,pypy311}, python-logger_loguru-{py38,py39,py310,py311,py312,py313,py314,pypy311}-logurulatest, python-logger_structlog-{py38,py39,py310,py311,py312,py313,py314,pypy311}-structloglatest, @@ -421,6 +422,13 @@ deps = framework_tornado: pycurl framework_tornado-tornadolatest: tornado framework_tornado-tornadomaster: https://github.com/tornadoweb/tornado/archive/master.zip + hybridagent_opentelemetry: opentelemetry-api + hybridagent_flask: opentelemetry-api + hybridagent_flask: opentelemetry-instrumentation-flask + hybridagent_flask: flask[async] + hybridagent_flask: Flask-Compress + hybridagent_flask: markupsafe + hybridagent_flask: jinja2 mlmodel_autogen-autogen061: autogen-agentchat<0.6.2 mlmodel_autogen-autogen061: autogen-core<0.6.2 mlmodel_autogen-autogen061: autogen-ext<0.6.2 @@ -429,7 +437,6 @@ deps = mlmodel_autogen-autogenlatest: autogen-agentchat mlmodel_autogen: mcp mlmodel_gemini: google-genai - hybridagent_opentelemetry: opentelemetry-api mlmodel_openai-openai0: openai[datalib]<1.0 mlmodel_openai-openai107: openai[datalib]<1.8 mlmodel_openai-openai107: httpx<0.28 @@ -579,6 +586,7 @@ changedir = framework_strawberry: tests/framework_strawberry framework_tornado: tests/framework_tornado hybridagent_opentelemetry: tests/hybridagent_opentelemetry + hybridagent_flask: tests/hybridagent_flask logger_logging: tests/logger_logging logger_loguru: tests/logger_loguru logger_structlog: tests/logger_structlog