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 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..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/