diff --git a/newrelic/agent.py b/newrelic/agent.py index b0bf115e2c..95a540780e 100644 --- a/newrelic/agent.py +++ b/newrelic/agent.py @@ -155,7 +155,9 @@ def __asgi_application(*args, **kwargs): from newrelic.api.profile_trace import ProfileTraceWrapper as __ProfileTraceWrapper from newrelic.api.profile_trace import profile_trace as __profile_trace from newrelic.api.profile_trace import wrap_profile_trace as __wrap_profile_trace +from newrelic.api.settings import set_error_group_callback as __set_error_group_callback from newrelic.api.supportability import wrap_api_call as __wrap_api_call +from newrelic.api.transaction import set_user_id as __set_user_id from newrelic.api.transaction_name import ( TransactionNameWrapper as __TransactionNameWrapper, ) @@ -223,6 +225,8 @@ def __asgi_application(*args, **kwargs): get_linking_metadata = __wrap_api_call(__get_linking_metadata, "get_linking_metadata") add_custom_span_attribute = __wrap_api_call(__add_custom_span_attribute, "add_custom_span_attribute") current_transaction = __wrap_api_call(__current_transaction, "current_transaction") +set_user_id = __wrap_api_call(__set_user_id, "set_user_id") +set_error_group_callback = __wrap_api_call(__set_error_group_callback, "set_error_group_callback") set_transaction_name = __wrap_api_call(__set_transaction_name, "set_transaction_name") end_of_transaction = __wrap_api_call(__end_of_transaction, "end_of_transaction") set_background_task = __wrap_api_call(__set_background_task, "set_background_task") diff --git a/newrelic/api/settings.py b/newrelic/api/settings.py index fc70eb0d49..5cc9ba79f3 100644 --- a/newrelic/api/settings.py +++ b/newrelic/api/settings.py @@ -12,10 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging + import newrelic.core.config settings = newrelic.core.config.global_settings +_logger = logging.getLogger(__name__) + + RECORDSQL_OFF = 'off' RECORDSQL_RAW = 'raw' RECORDSQL_OBFUSCATED = 'obfuscated' @@ -23,5 +28,26 @@ COMPRESSED_CONTENT_ENCODING_DEFLATE = 'deflate' COMPRESSED_CONTENT_ENCODING_GZIP = 'gzip' -STRIP_EXCEPTION_MESSAGE = ("Message removed by New Relic " - "'strip_exception_messages' setting") +STRIP_EXCEPTION_MESSAGE = ("Message removed by New Relic 'strip_exception_messages' setting") + + +def set_error_group_callback(callback, application=None): + """Set the current callback to be used to determine error groups.""" + from newrelic.api.application import application_instance + + if callback is not None and not callable(callback): + _logger.error("Error group callback must be a callable, or None to unset this setting.") + return + + # Check for activated application if it exists and was not given. + application = application_instance(activate=False) if application is None else application + + # Get application settings if it exists, or fallback to global settings object + _settings = application.settings if application is not None else settings() + + if _settings is None: + _logger.error("Failed to set error_group_callback in application settings. Report this issue to New Relic support.") + return + + if _settings.error_collector: + _settings.error_collector._error_group_callback = callback diff --git a/newrelic/api/time_trace.py b/newrelic/api/time_trace.py index 31de73536f..24be0e00f6 100644 --- a/newrelic/api/time_trace.py +++ b/newrelic/api/time_trace.py @@ -30,6 +30,8 @@ from newrelic.core.config import is_expected_error, should_ignore_error from newrelic.core.trace_cache import trace_cache +from newrelic.packages import six + _logger = logging.getLogger(__name__) @@ -255,13 +257,15 @@ def _observe_exception(self, exc_info=None, ignore=None, expected=None, status_c if getattr(value, "_nr_ignored", None): return - module, name, fullnames, message = parse_exc_info((exc, value, tb)) + module, name, fullnames, message_raw = parse_exc_info((exc, value, tb)) fullname = fullnames[0] # Check to see if we need to strip the message before recording it. if settings.strip_exception_messages.enabled and fullname not in settings.strip_exception_messages.allowlist: message = STRIP_EXCEPTION_MESSAGE + else: + message = message_raw # Where expected or ignore are a callable they should return a # tri-state variable with the following behavior. @@ -344,7 +348,7 @@ def _observe_exception(self, exc_info=None, ignore=None, expected=None, status_c is_expected = is_expected_error(exc_info, status_code=status_code, settings=settings) # Record a supportability metric if error attributes are being - # overiden. + # overridden. if "error.class" in self.agent_attributes: transaction._record_supportability("Supportability/SpanEvent/Errors/Dropped") @@ -353,11 +357,23 @@ def _observe_exception(self, exc_info=None, ignore=None, expected=None, status_c self._add_agent_attribute("error.message", message) self._add_agent_attribute("error.expected", is_expected) - return fullname, message, tb, is_expected + return fullname, message, message_raw, tb, is_expected def notice_error(self, error=None, attributes=None, expected=None, ignore=None, status_code=None): attributes = attributes if attributes is not None else {} + # If no exception details provided, use current exception. + + # Pull from sys.exc_info if no exception is passed + if not error or None in error: + error = sys.exc_info() + + # If no exception to report, exit + if not error or None in error: + return + + exc, value, tb = error + recorded = self._observe_exception( error, ignore=ignore, @@ -365,7 +381,7 @@ def notice_error(self, error=None, attributes=None, expected=None, ignore=None, status_code=status_code, ) if recorded: - fullname, message, tb, is_expected = recorded + fullname, message, message_raw, tb, is_expected = recorded transaction = self.transaction settings = transaction and transaction.settings @@ -392,16 +408,45 @@ def notice_error(self, error=None, attributes=None, expected=None, ignore=None, ) custom_params = {} - if settings and settings.code_level_metrics and settings.code_level_metrics.enabled: - source = extract_code_from_traceback(tb) - else: - source = None + # Extract additional details about the exception + + source = None + error_group_name = None + if settings: + if settings.code_level_metrics and settings.code_level_metrics.enabled: + source = extract_code_from_traceback(tb) + + if settings.error_collector and settings.error_collector.error_group_callback is not None: + try: + # Call callback to obtain error group name + input_attributes = {} + input_attributes.update(transaction._custom_params) + input_attributes.update(attributes) + error_group_name_raw = settings.error_collector.error_group_callback(value, { + "traceback": tb, + "error.class": exc, + "error.message": message_raw, + "error.expected": is_expected, + "custom_params": input_attributes, + "transactionName": getattr(transaction, "name", None), + "response.status": getattr(transaction, "_response_code", None), + "request.method": getattr(transaction, "_request_method", None), + "request.uri": getattr(transaction, "_request_uri", None), + }) + if error_group_name_raw: + _, error_group_name = process_user_attribute("error.group.name", error_group_name_raw) + if error_group_name is None or not isinstance(error_group_name, six.string_types): + raise ValueError("Invalid attribute value for error.group.name. Expected string, got: %s" % repr(error_group_name_raw)) + except Exception: + _logger.error("Encountered error when calling error group callback:\n%s", "".join(traceback.format_exception(*sys.exc_info()))) + error_group_name = None transaction._create_error_node( settings, fullname, message, is_expected, + error_group_name, custom_params, self.guid, tb, @@ -634,6 +679,7 @@ def get_service_linking_metadata(application=None, settings=None): if not settings: if application is None: from newrelic.api.application import application_instance + application = application_instance(activate=False) if application is not None: diff --git a/newrelic/api/transaction.py b/newrelic/api/transaction.py index 08638e0566..f04bcba849 100644 --- a/newrelic/api/transaction.py +++ b/newrelic/api/transaction.py @@ -46,6 +46,7 @@ obfuscate, ) from newrelic.core.attribute import ( + MAX_ATTRIBUTE_LENGTH, MAX_LOG_MESSAGE_LENGTH, MAX_NUM_USER_ATTRIBUTES, create_agent_attributes, @@ -1547,7 +1548,9 @@ def notice_error(self, error=None, attributes=None, expected=None, ignore=None, status_code=status_code, ) - def _create_error_node(self, settings, fullname, message, expected, custom_params, span_id, tb, source): + def _create_error_node( + self, settings, fullname, message, expected, error_group_name, custom_params, span_id, tb, source + ): # Only remember up to limit of what can be caught for a # single transaction. This could be trimmed further # later if there are already recorded errors and would @@ -1576,9 +1579,8 @@ def _create_error_node(self, settings, fullname, message, expected, custom_param span_id=span_id, stack_trace=exception_stack(tb), custom_params=custom_params, - file_name=None, - line_number=None, source=source, + error_group_name=error_group_name, ) # TODO: Errors are recorded in time order. If @@ -1812,6 +1814,21 @@ def add_custom_parameters(items): return add_custom_attributes(items) +def set_user_id(user_id): + transaction = current_transaction() + + if not user_id or not transaction: + return + + if not isinstance(user_id, six.string_types): + _logger.warning("The set_user_id API requires a string-based user ID.") + return + + user_id = truncate(user_id, MAX_ATTRIBUTE_LENGTH) + + transaction._add_agent_attribute("enduser.id", user_id) + + def add_framework_info(name, version=None): transaction = current_transaction() if transaction: diff --git a/newrelic/core/attribute.py b/newrelic/core/attribute.py index c5f19e4c06..372711369c 100644 --- a/newrelic/core/attribute.py +++ b/newrelic/core/attribute.py @@ -42,46 +42,48 @@ _TRANSACTION_EVENT_DEFAULT_ATTRIBUTES = set( ( - "host.displayName", - "request.method", - "request.headers.contentType", - "request.headers.contentLength", - "request.uri", - "response.status", - "request.headers.accept", - "response.headers.contentLength", - "response.headers.contentType", - "request.headers.host", - "request.headers.userAgent", - "message.queueName", - "message.routingKey", - "http.url", - "http.statusCode", - "aws.requestId", - "aws.operation", "aws.lambda.arn", "aws.lambda.coldStart", "aws.lambda.eventSource.arn", + "aws.operation", + "aws.requestId", + "code.filepath", + "code.function", + "code.lineno", + "code.namespace", "db.collection", "db.instance", "db.operation", "db.statement", + "enduser.id", "error.class", - "error.message", "error.expected", - "peer.hostname", - "peer.address", + "error.message", + "error.group.name", "graphql.field.name", "graphql.field.parentType", "graphql.field.path", "graphql.field.returnType", "graphql.operation.name", - "graphql.operation.type", "graphql.operation.query", - "code.filepath", - "code.function", - "code.lineno", - "code.namespace", + "graphql.operation.type", + "host.displayName", + "http.statusCode", + "http.url", + "message.queueName", + "message.routingKey", + "peer.address", + "peer.hostname", + "request.headers.accept", + "request.headers.contentLength", + "request.headers.contentType", + "request.headers.host", + "request.headers.userAgent", + "request.method", + "request.uri", + "response.headers.contentLength", + "response.headers.contentType", + "response.status", ) ) diff --git a/newrelic/core/config.py b/newrelic/core/config.py index 80e9ccec05..7489be2229 100644 --- a/newrelic/core/config.py +++ b/newrelic/core/config.py @@ -138,7 +138,9 @@ class TransactionTracerAttributesSettings(Settings): class ErrorCollectorSettings(Settings): - pass + @property + def error_group_callback(self): + return self._error_group_callback class ErrorCollectorAttributesSettings(Settings): @@ -698,6 +700,7 @@ def default_host(license_key): _settings.error_collector.ignore_status_codes = _parse_status_codes("100-102 200-208 226 300-308 404", set()) _settings.error_collector.expected_classes = [] _settings.error_collector.expected_status_codes = set() +_settings.error_collector._error_group_callback = None _settings.error_collector.attributes.enabled = True _settings.error_collector.attributes.exclude = [] _settings.error_collector.attributes.include = [] diff --git a/newrelic/core/error_node.py b/newrelic/core/error_node.py index 67c1b449a2..fe0157b819 100644 --- a/newrelic/core/error_node.py +++ b/newrelic/core/error_node.py @@ -24,8 +24,7 @@ "span_id", "stack_trace", "custom_params", - "file_name", - "line_number", "source", + "error_group_name", ], ) diff --git a/newrelic/core/stats_engine.py b/newrelic/core/stats_engine.py index 959d4ffae8..203e3e7960 100644 --- a/newrelic/core/stats_engine.py +++ b/newrelic/core/stats_engine.py @@ -26,6 +26,7 @@ import random import sys import time +import traceback import warnings import zlib from heapq import heapify, heapreplace @@ -38,6 +39,7 @@ from newrelic.common.streaming_utils import StreamBuffer from newrelic.core.attribute import ( MAX_LOG_MESSAGE_LENGTH, + create_agent_attributes, create_user_attributes, process_user_attribute, truncate, @@ -610,13 +612,15 @@ def notice_error(self, error=None, attributes=None, expected=None, ignore=None, if getattr(value, "_nr_ignored", None): return - module, name, fullnames, message = parse_exc_info(error) + module, name, fullnames, message_raw = parse_exc_info(error) fullname = fullnames[0] # Check to see if we need to strip the message before recording it. if settings.strip_exception_messages.enabled and fullname not in settings.strip_exception_messages.allowlist: message = STRIP_EXCEPTION_MESSAGE + else: + message = message_raw # Where expected or ignore are a callable they should return a # tri-state variable with the following behavior. @@ -712,6 +716,42 @@ def notice_error(self, error=None, attributes=None, expected=None, ignore=None, user_attributes = create_user_attributes(custom_attributes, settings.attribute_filter) + + # Extract additional details about the exception as agent attributes + agent_attributes = {} + + if settings: + if settings.code_level_metrics and settings.code_level_metrics.enabled: + extract_code_from_traceback(tb).add_attrs(agent_attributes.__setitem__) + + if settings.error_collector and settings.error_collector.error_group_callback is not None: + error_group_name = None + try: + # Call callback to obtain error group name + error_group_name_raw = settings.error_collector.error_group_callback(value, { + "traceback": tb, + "error.class": exc, + "error.message": message_raw, + "error.expected": is_expected, + "custom_params": attributes, + # Transaction specific items should be set to None + "transactionName": None, + "response.status": None, + "request.method": None, + "request.uri": None, + }) + if error_group_name_raw: + _, error_group_name = process_user_attribute("error.group.name", error_group_name_raw) + if error_group_name is None or not isinstance(error_group_name, six.string_types): + raise ValueError("Invalid attribute value for error.group.name. Expected string, got: %s" % repr(error_group_name_raw)) + else: + agent_attributes["error.group.name"] = error_group_name + + except Exception: + _logger.error("Encountered error when calling error group callback:\n%s", "".join(traceback.format_exception(*sys.exc_info()))) + + agent_attributes = create_agent_attributes(agent_attributes, settings.attribute_filter) + # Record the exception details. attributes = {} @@ -731,9 +771,10 @@ def notice_error(self, error=None, attributes=None, expected=None, ignore=None, # set source code attributes attributes["agentAttributes"] = {} - if settings and settings.code_level_metrics and settings.code_level_metrics.enabled: - extract_code_from_traceback(tb).add_attrs(attributes["agentAttributes"].__setitem__) - + for attr in agent_attributes: + if attr.destinations & DST_ERROR_COLLECTOR: + attributes["agentAttributes"][attr.name] = attr.value + error_details = TracedError( start_time=time.time(), path="Exception", message=message, type=fullname, parameters=attributes ) @@ -771,7 +812,11 @@ def _error_event(self, error): # Leave agent attributes field blank since not a transaction - error_event = [error.parameters["intrinsics"], error.parameters["userAttributes"], {}] + error_event = [ + error.parameters["intrinsics"], + error.parameters["userAttributes"], + error.parameters["agentAttributes"], + ] return error_event diff --git a/newrelic/core/transaction_node.py b/newrelic/core/transaction_node.py index 11b254b8aa..0faae37909 100644 --- a/newrelic/core/transaction_node.py +++ b/newrelic/core/transaction_node.py @@ -22,35 +22,81 @@ import newrelic.core.error_collector import newrelic.core.trace_node - +from newrelic.common.streaming_utils import SpanProtoAttrs +from newrelic.core.attribute import create_agent_attributes, create_user_attributes +from newrelic.core.attribute_filter import ( + DST_ERROR_COLLECTOR, + DST_TRANSACTION_EVENTS, + DST_TRANSACTION_TRACER, +) from newrelic.core.metric import ApdexMetric, TimeMetric from newrelic.core.string_table import StringTable -from newrelic.core.attribute import create_user_attributes -from newrelic.core.attribute_filter import (DST_ERROR_COLLECTOR, - DST_TRANSACTION_TRACER, DST_TRANSACTION_EVENTS) - -from newrelic.common.streaming_utils import SpanProtoAttrs try: from newrelic.core.infinite_tracing_pb2 import Span except: pass -_TransactionNode = namedtuple('_TransactionNode', - ['settings', 'path', 'type', 'group', 'base_name', 'name_for_metric', - 'port', 'request_uri', 'queue_start', 'start_time', - 'end_time', 'last_byte_time', 'response_time', 'total_time', - 'duration', 'exclusive', 'root', 'errors', 'slow_sql', - 'custom_events', 'log_events', 'apdex_t', 'suppress_apdex', 'custom_metrics', - 'guid', 'cpu_time', 'suppress_transaction_trace', 'client_cross_process_id', - 'referring_transaction_guid', 'record_tt', 'synthetics_resource_id', - 'synthetics_job_id', 'synthetics_monitor_id', 'synthetics_header', - 'is_part_of_cat', 'trip_id', 'path_hash', 'referring_path_hash', - 'alternate_path_hashes', 'trace_intrinsics', 'agent_attributes', - 'distributed_trace_intrinsics', 'user_attributes', 'priority', - 'sampled', 'parent_transport_duration', 'parent_span', 'parent_type', - 'parent_account', 'parent_app', 'parent_tx', 'parent_transport_type', - 'root_span_guid', 'trace_id', 'loop_time']) +_TransactionNode = namedtuple( + "_TransactionNode", + [ + "settings", + "path", + "type", + "group", + "base_name", + "name_for_metric", + "port", + "request_uri", + "queue_start", + "start_time", + "end_time", + "last_byte_time", + "response_time", + "total_time", + "duration", + "exclusive", + "root", + "errors", + "slow_sql", + "custom_events", + "log_events", + "apdex_t", + "suppress_apdex", + "custom_metrics", + "guid", + "cpu_time", + "suppress_transaction_trace", + "client_cross_process_id", + "referring_transaction_guid", + "record_tt", + "synthetics_resource_id", + "synthetics_job_id", + "synthetics_monitor_id", + "synthetics_header", + "is_part_of_cat", + "trip_id", + "path_hash", + "referring_path_hash", + "alternate_path_hashes", + "trace_intrinsics", + "agent_attributes", + "distributed_trace_intrinsics", + "user_attributes", + "priority", + "sampled", + "parent_transport_duration", + "parent_span", + "parent_type", + "parent_account", + "parent_app", + "parent_tx", + "parent_transport_type", + "root_span_guid", + "trace_id", + "loop_time", + ], +) class TransactionNode(_TransactionNode): @@ -71,7 +117,7 @@ def __hash__(self): @property def string_table(self): - result = getattr(self, '_string_table', None) + result = getattr(self, "_string_table", None) if result is not None: return result self._string_table = StringTable() @@ -96,7 +142,7 @@ def time_metrics(self, stats): if not self.base_name: return - if self.type == 'WebTransaction': + if self.type == "WebTransaction": # Report time taken by request dispatcher. We don't # know upstream time distinct from actual request # time so can't report time exclusively in the @@ -109,11 +155,7 @@ def time_metrics(self, stats): # and how the exclusive component would appear in # the overview graphs. - yield TimeMetric( - name='HttpDispatcher', - scope='', - duration=self.response_time, - exclusive=None) + yield TimeMetric(name="HttpDispatcher", scope="", duration=self.response_time, exclusive=None) # Upstream queue time within any web server front end. @@ -128,114 +170,84 @@ def time_metrics(self, stats): if queue_wait < 0: queue_wait = 0 - yield TimeMetric( - name='WebFrontend/QueueTime', - scope='', - duration=queue_wait, - exclusive=None) + yield TimeMetric(name="WebFrontend/QueueTime", scope="", duration=queue_wait, exclusive=None) # Generate the full transaction metric. - yield TimeMetric( - name=self.path, - scope='', - duration=self.response_time, - exclusive=self.exclusive) + yield TimeMetric(name=self.path, scope="", duration=self.response_time, exclusive=self.exclusive) # Generate the rollup metric. - if self.type != 'WebTransaction': - rollup = '%s/all' % self.type + if self.type != "WebTransaction": + rollup = "%s/all" % self.type else: rollup = self.type - yield TimeMetric( - name=rollup, - scope='', - duration=self.response_time, - exclusive=self.exclusive) + yield TimeMetric(name=rollup, scope="", duration=self.response_time, exclusive=self.exclusive) # Generate Unscoped Total Time metrics. - if self.type == 'WebTransaction': - metric_prefix = 'WebTransactionTotalTime' - metric_suffix = 'Web' + if self.type == "WebTransaction": + metric_prefix = "WebTransactionTotalTime" + metric_suffix = "Web" else: - metric_prefix = 'OtherTransactionTotalTime' - metric_suffix = 'Other' + metric_prefix = "OtherTransactionTotalTime" + metric_suffix = "Other" yield TimeMetric( - name='%s/%s' % (metric_prefix, self.name_for_metric), - scope='', - duration=self.total_time, - exclusive=self.total_time) + name="%s/%s" % (metric_prefix, self.name_for_metric), + scope="", + duration=self.total_time, + exclusive=self.total_time, + ) - yield TimeMetric( - name=metric_prefix, - scope='', - duration=self.total_time, - exclusive=self.total_time) + yield TimeMetric(name=metric_prefix, scope="", duration=self.total_time, exclusive=self.total_time) # Generate Distributed Tracing metrics if self.settings.distributed_tracing.enabled: dt_tag = "%s/%s/%s/%s/all" % ( - self.parent_type or 'Unknown', - self.parent_account or 'Unknown', - self.parent_app or 'Unknown', - self.parent_transport_type or 'Unknown') + self.parent_type or "Unknown", + self.parent_account or "Unknown", + self.parent_app or "Unknown", + self.parent_transport_type or "Unknown", + ) - for bonus_tag in ('', metric_suffix): + for bonus_tag in ("", metric_suffix): yield TimeMetric( name="DurationByCaller/%s%s" % (dt_tag, bonus_tag), - scope='', + scope="", duration=self.duration, - exclusive=self.duration) + exclusive=self.duration, + ) if self.parent_transport_duration is not None: yield TimeMetric( name="TransportDuration/%s%s" % (dt_tag, bonus_tag), - scope='', + scope="", duration=self.parent_transport_duration, - exclusive=self.parent_transport_duration) + exclusive=self.parent_transport_duration, + ) if self.errors: yield TimeMetric( - name='ErrorsByCaller/%s%s' % (dt_tag, bonus_tag), - scope='', - duration=0.0, - exclusive=None) + name="ErrorsByCaller/%s%s" % (dt_tag, bonus_tag), scope="", duration=0.0, exclusive=None + ) # Generate Error metrics if self.errors: if False in (error.expected for error in self.errors): # Generate overall rollup metric indicating if errors present. - yield TimeMetric( - name='Errors/all', - scope='', - duration=0.0, - exclusive=None) + yield TimeMetric(name="Errors/all", scope="", duration=0.0, exclusive=None) # Generate individual error metric for transaction. - yield TimeMetric( - name='Errors/%s' % self.path, - scope='', - duration=0.0, - exclusive=None) + yield TimeMetric(name="Errors/%s" % self.path, scope="", duration=0.0, exclusive=None) # Generate rollup metric for WebTransaction errors. - yield TimeMetric( - name='Errors/all%s' % metric_suffix, - scope='', - duration=0.0, - exclusive=None) + yield TimeMetric(name="Errors/all%s" % metric_suffix, scope="", duration=0.0, exclusive=None) else: - yield TimeMetric( - name='ErrorsExpected/all', - scope='', - duration=0.0, - exclusive=None) + yield TimeMetric(name="ErrorsExpected/all", scope="", duration=0.0, exclusive=None) # Now for the children. for child in self.root.children: @@ -243,9 +255,7 @@ def time_metrics(self, stats): yield metric def apdex_metrics(self, stats): - """Return a generator yielding the apdex metrics for this node. - - """ + """Return a generator yielding the apdex metrics for this node.""" if not self.base_name: return @@ -255,7 +265,7 @@ def apdex_metrics(self, stats): # The apdex metrics are only relevant to web transactions. - if self.type != 'WebTransaction': + if self.type != "WebTransaction": return # The magic calculations based on apdex_t. The apdex_t @@ -280,20 +290,18 @@ def apdex_metrics(self, stats): # Generate the full apdex metric. yield ApdexMetric( - name='Apdex/%s' % self.name_for_metric, - satisfying=satisfying, - tolerating=tolerating, - frustrating=frustrating, - apdex_t=self.apdex_t) + name="Apdex/%s" % self.name_for_metric, + satisfying=satisfying, + tolerating=tolerating, + frustrating=frustrating, + apdex_t=self.apdex_t, + ) # Generate the rollup metric. yield ApdexMetric( - name='Apdex', - satisfying=satisfying, - tolerating=tolerating, - frustrating=frustrating, - apdex_t=self.apdex_t) + name="Apdex", satisfying=satisfying, tolerating=tolerating, frustrating=frustrating, apdex_t=self.apdex_t + ) def error_details(self): """Return a generator yielding the details for each unique error @@ -318,38 +326,49 @@ def error_details(self): params = {} params["stack_trace"] = error.stack_trace - intrinsics = {'spanId': error.span_id, 'error.expected': error.expected} + intrinsics = {"spanId": error.span_id, "error.expected": error.expected} intrinsics.update(self.trace_intrinsics) - params['intrinsics'] = intrinsics + params["intrinsics"] = intrinsics - params['agentAttributes'] = {} + params["agentAttributes"] = {} for attr in self.agent_attributes: if attr.destinations & DST_ERROR_COLLECTOR: - params['agentAttributes'][attr.name] = attr.value + params["agentAttributes"][attr.name] = attr.value - params['userAttributes'] = {} + params["userAttributes"] = {} for attr in self.user_attributes: if attr.destinations & DST_ERROR_COLLECTOR: - params['userAttributes'][attr.name] = attr.value + params["userAttributes"][attr.name] = attr.value - # add source context attrs for error - if self.settings and self.settings.code_level_metrics and self.settings.code_level_metrics.enabled and getattr(error, "source", None): - error.source.add_attrs(params['agentAttributes'].__setitem__) + # add error specific agent attributes to this error's agentAttributes - # add error specific custom params to this error's userAttributes + err_agent_attrs = {} + error_group_name = error.error_group_name + if error_group_name: + err_agent_attrs["error.group.name"] = error_group_name + + # add source context attrs for error + if ( + self.settings + and self.settings.code_level_metrics + and self.settings.code_level_metrics.enabled + and getattr(error, "source", None) + ): + error.source.add_attrs(err_agent_attrs.__setitem__) + + err_agent_attrs = create_agent_attributes(err_agent_attrs, self.settings.attribute_filter) + for attr in err_agent_attrs: + if attr.destinations & DST_ERROR_COLLECTOR: + params["agentAttributes"][attr.name] = attr.value - err_attrs = create_user_attributes(error.custom_params, - self.settings.attribute_filter) + err_attrs = create_user_attributes(error.custom_params, self.settings.attribute_filter) for attr in err_attrs: if attr.destinations & DST_ERROR_COLLECTOR: - params['userAttributes'][attr.name] = attr.value + params["userAttributes"][attr.name] = attr.value yield newrelic.core.error_collector.TracedError( - start_time=error.timestamp, - path=self.path, - message=error.message, - type=error.type, - parameters=params) + start_time=error.timestamp, path=self.path, message=error.message, type=error.type, parameters=params + ) def transaction_trace(self, stats, limit, connections): @@ -362,19 +381,19 @@ def transaction_trace(self, stats, limit, connections): attributes = {} - attributes['intrinsics'] = self.trace_intrinsics + attributes["intrinsics"] = self.trace_intrinsics - attributes['agentAttributes'] = {} + attributes["agentAttributes"] = {} for attr in self.agent_attributes: if attr.destinations & DST_TRANSACTION_TRACER: - attributes['agentAttributes'][attr.name] = attr.value - if attr.name == 'request.uri': + attributes["agentAttributes"][attr.name] = attr.value + if attr.name == "request.uri": self.include_transaction_trace_request_uri = True - attributes['userAttributes'] = {} + attributes["userAttributes"] = {} for attr in self.user_attributes: if attr.destinations & DST_TRANSACTION_TRACER: - attributes['userAttributes'][attr.name] = attr.value + attributes["userAttributes"][attr.name] = attr.value # There is an additional trace node labeled as 'ROOT' # that needs to be inserted below the root node object @@ -382,19 +401,17 @@ def transaction_trace(self, stats, limit, connections): # from the actual top node for the transaction. root = newrelic.core.trace_node.TraceNode( - start_time=trace_node.start_time, - end_time=trace_node.end_time, - name='ROOT', - params={}, - children=[trace_node], - label=None) + start_time=trace_node.start_time, + end_time=trace_node.end_time, + name="ROOT", + params={}, + children=[trace_node], + label=None, + ) return newrelic.core.trace_node.RootNode( - start_time=start_time, - empty0={}, - empty1={}, - root=root, - attributes=attributes) + start_time=start_time, empty0={}, empty1={}, root=root, attributes=attributes + ) def slow_sql_nodes(self, stats): for item in self.slow_sql: @@ -405,18 +422,18 @@ def apdex_perf_zone(self): # Apdex is only valid for WebTransactions. - if self.type != 'WebTransaction': + if self.type != "WebTransaction": return None if self.errors and False in (error.expected for error in self.errors): - return 'F' + return "F" else: if self.duration <= self.apdex_t: - return 'S' + return "S" elif self.duration <= 4 * self.apdex_t: - return 'T' + return "T" else: - return 'F' + return "F" def transaction_event(self, stats_table): # Create the transaction event, which is a list of attributes. @@ -445,41 +462,38 @@ def transaction_event_intrinsics(self, stats_table): intrinsics = self._event_intrinsics(stats_table) - intrinsics['type'] = 'Transaction' - intrinsics['name'] = self.path - intrinsics['totalTime'] = self.total_time + intrinsics["type"] = "Transaction" + intrinsics["name"] = self.path + intrinsics["totalTime"] = self.total_time def _add_if_not_empty(key, value): if value: intrinsics[key] = value - + apdex_perf_zone = self.apdex_perf_zone() - _add_if_not_empty('apdexPerfZone', apdex_perf_zone) - _add_if_not_empty('nr.apdexPerfZone', apdex_perf_zone) + _add_if_not_empty("apdexPerfZone", apdex_perf_zone) + _add_if_not_empty("nr.apdexPerfZone", apdex_perf_zone) if self.errors: - intrinsics['error'] = True + intrinsics["error"] = True if self.path_hash: - intrinsics['nr.guid'] = self.guid - intrinsics['nr.tripId'] = self.trip_id - intrinsics['nr.pathHash'] = self.path_hash + intrinsics["nr.guid"] = self.guid + intrinsics["nr.tripId"] = self.trip_id + intrinsics["nr.pathHash"] = self.path_hash - _add_if_not_empty('nr.referringPathHash', - self.referring_path_hash) - _add_if_not_empty('nr.alternatePathHashes', - ','.join(self.alternate_path_hashes)) - _add_if_not_empty('nr.referringTransactionGuid', - self.referring_transaction_guid) + _add_if_not_empty("nr.referringPathHash", self.referring_path_hash) + _add_if_not_empty("nr.alternatePathHashes", ",".join(self.alternate_path_hashes)) + _add_if_not_empty("nr.referringTransactionGuid", self.referring_transaction_guid) if self.synthetics_resource_id: - intrinsics['nr.guid'] = self.guid + intrinsics["nr.guid"] = self.guid if self.parent_tx: - intrinsics['parentId'] = self.parent_tx + intrinsics["parentId"] = self.parent_tx if self.parent_span: - intrinsics['parentSpanId'] = self.parent_span + intrinsics["parentSpanId"] = self.parent_span return intrinsics @@ -502,10 +516,21 @@ def error_events(self, stats_table): if attr.destinations & DST_ERROR_COLLECTOR: user_attributes[attr.name] = attr.value + # add error specific agent attributes to this error's agentAttributes + + err_agent_attrs = {} + error_group_name = error.error_group_name + if error_group_name: + err_agent_attrs["error.group.name"] = error_group_name + + err_agent_attrs = create_agent_attributes(err_agent_attrs, self.settings.attribute_filter) + for attr in err_agent_attrs: + if attr.destinations & DST_ERROR_COLLECTOR: + agent_attributes[attr.name] = attr.value + # add error specific custom params to this error's userAttributes - err_attrs = create_user_attributes(error.custom_params, - self.settings.attribute_filter) + err_attrs = create_user_attributes(error.custom_params, self.settings.attribute_filter) for attr in err_attrs: if attr.destinations & DST_ERROR_COLLECTOR: user_attributes[attr.name] = attr.value @@ -519,24 +544,24 @@ def error_event_intrinsics(self, error, stats_table): intrinsics = self._event_intrinsics(stats_table) - intrinsics['type'] = "TransactionError" - intrinsics['error.class'] = error.type - intrinsics['error.message'] = error.message - intrinsics['error.expected'] = error.expected - intrinsics['transactionName'] = self.path - intrinsics['spanId'] = error.span_id + intrinsics["type"] = "TransactionError" + intrinsics["error.class"] = error.type + intrinsics["error.message"] = error.message + intrinsics["error.expected"] = error.expected + intrinsics["transactionName"] = self.path + intrinsics["spanId"] = error.span_id - intrinsics['nr.transactionGuid'] = self.guid + intrinsics["nr.transactionGuid"] = self.guid if self.referring_transaction_guid: guid = self.referring_transaction_guid - intrinsics['nr.referringTransactionGuid'] = guid + intrinsics["nr.referringTransactionGuid"] = guid return intrinsics def _event_intrinsics(self, stats_table): """Common attributes for analytics events""" - cache = getattr(self, '_event_intrinsics_cache', None) + cache = getattr(self, "_event_intrinsics_cache", None) if cache is not None: # We don't want to execute this function more than once, since @@ -546,24 +571,24 @@ def _event_intrinsics(self, stats_table): intrinsics = self.distributed_trace_intrinsics.copy() - intrinsics['timestamp'] = int(1000.0 * self.start_time) - intrinsics['duration'] = self.response_time + intrinsics["timestamp"] = int(1000.0 * self.start_time) + intrinsics["duration"] = self.response_time if self.port: - intrinsics['port'] = self.port + intrinsics["port"] = self.port # Add the Synthetics attributes to the intrinsics dict. if self.synthetics_resource_id: - intrinsics['nr.syntheticsResourceId'] = self.synthetics_resource_id - intrinsics['nr.syntheticsJobId'] = self.synthetics_job_id - intrinsics['nr.syntheticsMonitorId'] = self.synthetics_monitor_id + intrinsics["nr.syntheticsResourceId"] = self.synthetics_resource_id + intrinsics["nr.syntheticsJobId"] = self.synthetics_job_id + intrinsics["nr.syntheticsMonitorId"] = self.synthetics_monitor_id def _add_call_time(source, target): # include time for keys previously added to stats table via # stats_engine.record_transaction - if (source, '') in stats_table: - call_time = stats_table[(source, '')].total_call_time + if (source, "") in stats_table: + call_time = stats_table[(source, "")].total_call_time if target in intrinsics: intrinsics[target] += call_time else: @@ -572,45 +597,43 @@ def _add_call_time(source, target): def _add_call_count(source, target): # include counts for keys previously added to stats table via # stats_engine.record_transaction - if (source, '') in stats_table: - call_count = stats_table[(source, '')].call_count + if (source, "") in stats_table: + call_count = stats_table[(source, "")].call_count if target in intrinsics: intrinsics[target] += call_count else: intrinsics[target] = call_count - _add_call_time('WebFrontend/QueueTime', 'queueDuration') + _add_call_time("WebFrontend/QueueTime", "queueDuration") - _add_call_time('External/all', 'externalDuration') - _add_call_time('Datastore/all', 'databaseDuration') - _add_call_time('Memcache/all', 'memcacheDuration') + _add_call_time("External/all", "externalDuration") + _add_call_time("Datastore/all", "databaseDuration") + _add_call_time("Memcache/all", "memcacheDuration") - _add_call_count('External/all', 'externalCallCount') - _add_call_count('Datastore/all', 'databaseCallCount') + _add_call_count("External/all", "externalCallCount") + _add_call_count("Datastore/all", "databaseCallCount") if self.loop_time: - intrinsics['eventLoopTime'] = self.loop_time - _add_call_time('EventLoop/Wait/all', 'eventLoopWait') + intrinsics["eventLoopTime"] = self.loop_time + _add_call_time("EventLoop/Wait/all", "eventLoopWait") self._event_intrinsics_cache = intrinsics.copy() return intrinsics def span_protos(self, settings): - for i_attrs, u_attrs, a_attrs in self.span_events( - settings, attr_class=SpanProtoAttrs): - yield Span(trace_id=self.trace_id, - intrinsics=i_attrs, - user_attributes=u_attrs, - agent_attributes=a_attrs) + for i_attrs, u_attrs, a_attrs in self.span_events(settings, attr_class=SpanProtoAttrs): + yield Span(trace_id=self.trace_id, intrinsics=i_attrs, user_attributes=u_attrs, agent_attributes=a_attrs) def span_events(self, settings, attr_class=dict): - base_attrs = attr_class(( - ('transactionId', self.guid), - ('traceId', self.trace_id), - ('sampled', self.sampled), - ('priority', self.priority), - )) + base_attrs = attr_class( + ( + ("transactionId", self.guid), + ("traceId", self.trace_id), + ("sampled", self.sampled), + ("priority", self.priority), + ) + ) for event in self.root.span_events( settings, diff --git a/tests/agent_features/test_attributes_in_action.py b/tests/agent_features/test_attributes_in_action.py index aa44d3e2d5..e56994d0a1 100644 --- a/tests/agent_features/test_attributes_in_action.py +++ b/tests/agent_features/test_attributes_in_action.py @@ -25,6 +25,9 @@ validate_error_event_attributes_outside_transaction, validate_error_trace_attributes_outside_transaction, ) +from testing_support.validators.validate_error_trace_attributes import ( + validate_error_trace_attributes, +) from testing_support.validators.validate_span_events import validate_span_events from testing_support.validators.validate_transaction_error_trace_attributes import ( validate_transaction_error_trace_attributes, @@ -37,9 +40,10 @@ ) from newrelic.api.application import application_instance as application +from newrelic.api.background_task import background_task from newrelic.api.message_transaction import message_transaction from newrelic.api.time_trace import notice_error -from newrelic.api.transaction import add_custom_attribute +from newrelic.api.transaction import add_custom_attribute, current_transaction, set_user_id from newrelic.api.wsgi_application import wsgi_application from newrelic.common.object_names import callable_name @@ -93,7 +97,16 @@ AGENT_KEYS_ALL = TRACE_ERROR_AGENT_KEYS + REQ_PARAMS -TRANS_EVENT_INTRINSICS = ("name", "duration", "type", "timestamp", "totalTime", "error", "nr.apdexPerfZone", "apdexPerfZone") +TRANS_EVENT_INTRINSICS = ( + "name", + "duration", + "type", + "timestamp", + "totalTime", + "error", + "nr.apdexPerfZone", + "apdexPerfZone", +) TRANS_EVENT_AGENT_KEYS = [ "response.status", "request.method", @@ -911,3 +924,45 @@ def test_routing_key_agent_attribute(): @message_transaction(library="RabbitMQ", destination_type="Exchange", destination_name="x") def test_none_type_routing_key_agent_attribute(): pass + + +_required_agent_attributes = ["enduser.id"] +_forgone_agent_attributes = [] + + +@pytest.mark.parametrize('input_user_id, reported_user_id, high_security',( + ("1234", "1234", True), + ("a" * 260, "a" * 255, False), +)) +def test_enduser_id_attribute_api_valid_types(input_user_id, reported_user_id, high_security): + @reset_core_stats_engine() + @validate_error_trace_attributes( + callable_name(ValueError), exact_attrs={"user": {}, "intrinsic": {}, "agent": {"enduser.id": reported_user_id}} + ) + @validate_error_event_attributes(exact_attrs={"user": {}, "intrinsic": {}, "agent": {"enduser.id": reported_user_id}}) + @validate_attributes("agent", _required_agent_attributes, _forgone_agent_attributes) + @background_task() + @override_application_settings({"high_security": high_security}) + def _test(): + set_user_id(input_user_id) + + try: + raise ValueError() + except Exception: + notice_error() + _test() + + +@pytest.mark.parametrize('input_user_id',(None, '', 123)) +def test_enduser_id_attribute_api_invalid_types(input_user_id): + @reset_core_stats_engine() + @validate_attributes("agent", [], ["enduser.id"]) + @background_task() + def _test(): + set_user_id(input_user_id) + + try: + raise ValueError() + except Exception: + notice_error() + _test() diff --git a/tests/agent_features/test_error_events.py b/tests/agent_features/test_error_events.py index 99b3935bef..72bdb14f7c 100644 --- a/tests/agent_features/test_error_events.py +++ b/tests/agent_features/test_error_events.py @@ -16,6 +16,7 @@ import time import webtest + from testing_support.fixtures import ( cat_enabled, make_cross_agent_headers, @@ -26,6 +27,9 @@ validate_transaction_error_event_count, ) from testing_support.sample_applications import fully_featured_app +from testing_support.validators.validate_error_trace_attributes import ( + validate_error_trace_attributes, +) from testing_support.validators.validate_non_transaction_error_event import ( validate_non_transaction_error_event, ) diff --git a/tests/agent_features/test_error_group_callback.py b/tests/agent_features/test_error_group_callback.py new file mode 100644 index 0000000000..742391162c --- /dev/null +++ b/tests/agent_features/test_error_group_callback.py @@ -0,0 +1,238 @@ +# 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 threading +import traceback +import sys + +import pytest + +from testing_support.fixtures import ( + override_application_settings, + reset_core_stats_engine, + validate_error_event_attributes, + validate_error_event_attributes_outside_transaction, + validate_error_trace_attributes_outside_transaction, +) +from testing_support.validators.validate_error_trace_attributes import ( + validate_error_trace_attributes, +) + +from newrelic.api.application import application_instance as application +from newrelic.api.background_task import background_task +from newrelic.api.time_trace import notice_error +from newrelic.api.transaction import current_transaction +from newrelic.api.settings import set_error_group_callback +from newrelic.api.web_transaction import web_transaction +from newrelic.common.object_names import callable_name + + +_callback_called = threading.Event() +_truncated_value = "A" * 300 + +def error_group_callback(exc, data): + _callback_called.set() + + if isinstance(exc, ValueError): + return "value" + elif isinstance(exc, ZeroDivisionError): + return _truncated_value + elif isinstance(exc, IndexError): + return [] + elif isinstance(exc, LookupError): + return 123 + elif isinstance(exc, TypeError): + return "" + + +def test_clear_error_group_callback(): + settings = application().settings + set_error_group_callback(lambda x, y: None) + assert settings.error_collector.error_group_callback is not None, "Failed to set callback." + set_error_group_callback(None) + assert settings.error_collector.error_group_callback is None, "Failed to clear callback." + + +@pytest.mark.parametrize("callback,accepted", [ + (error_group_callback, True), + (lambda x, y: None, True), + (None, False), + ("string", False) +]) +def test_set_error_group_callback(callback, accepted): + try: + set_error_group_callback(callback) + settings = application().settings + if accepted: + assert settings.error_collector.error_group_callback is not None, "Failed to set callback." + else: + assert settings.error_collector.error_group_callback is None, "Accepted bad callback." + finally: + set_error_group_callback(None) + + +@pytest.mark.parametrize("exc_class,group_name,high_security", [ + (ValueError, "value", False), + (ValueError, "value", True), + (TypeError, None, False), + (RuntimeError, None, False), + (IndexError, None, False), + (LookupError, None, False), + (ZeroDivisionError, _truncated_value[:255], False), +], ids=("standard", "high-security", "empty-string", "None-value", "list-type", "int-type", "truncated-value")) +@reset_core_stats_engine() +def test_error_group_name_callback(exc_class, group_name, high_security): + _callback_called.clear() + + if group_name is not None: + exact = {"user": {}, "intrinsic": {}, "agent": {"error.group.name": group_name}} + forgone = None + else: + exact = None + forgone = {"user": [], "intrinsic": [], "agent": ["error.group.name"]} + + @validate_error_trace_attributes( + callable_name(exc_class), forgone_params=forgone, exact_attrs=exact + ) + @validate_error_event_attributes(forgone_params=forgone, exact_attrs=exact) + @override_application_settings({"high_security": high_security}) + @background_task() + def _test(): + + try: + raise exc_class() + except Exception: + notice_error() + + assert _callback_called.is_set() + + try: + set_error_group_callback(error_group_callback) + _test() + finally: + set_error_group_callback(None) + + +@pytest.mark.parametrize("exc_class,group_name,high_security", [ + (ValueError, "value", False), + (ValueError, "value", True), + (TypeError, None, False), + (RuntimeError, None, False), + (IndexError, None, False), + (LookupError, None, False), + (ZeroDivisionError, _truncated_value[:255], False), +], ids=("standard", "high-security", "empty-string", "None-value", "list-type", "int-type", "truncated-value")) +@reset_core_stats_engine() +def test_error_group_name_callback_outside_transaction(exc_class, group_name, high_security): + _callback_called.clear() + + if group_name is not None: + exact = {"user": {}, "intrinsic": {}, "agent": {"error.group.name": group_name}} + forgone = None + else: + exact = None + forgone = {"user": [], "intrinsic": [], "agent": ["error.group.name"]} + + @validate_error_trace_attributes_outside_transaction( + callable_name(exc_class), forgone_params=forgone, exact_attrs=exact + ) + @validate_error_event_attributes_outside_transaction(forgone_params=forgone, exact_attrs=exact) + @override_application_settings({"high_security": high_security}) + def _test(): + try: + raise exc_class() + except Exception: + app = application() + notice_error(application=app) + + assert _callback_called.is_set() + + try: + set_error_group_callback(error_group_callback) + _test() + finally: + set_error_group_callback(None) + + +@pytest.mark.parametrize("transaction_decorator", [ + background_task(name="TestBackgroundTask"), + web_transaction(name="TestWebTransaction", host="localhost", port=1234, request_method="GET", request_path="/", headers=[],), + None, +], ids=("background_task", "web_transation", "outside_transaction")) +@reset_core_stats_engine() +def test_error_group_name_callback_attributes(transaction_decorator): + callback_errors = [] + _data = [] + + def callback(error, data): + def _callback(): + import types + _data.append(data) + txn = current_transaction() + + # Standard attributes + assert isinstance(error, Exception) + assert isinstance(data["traceback"], types.TracebackType) + assert data["error.class"] is type(error) + assert data["error.message"] == "text" + assert data["error.expected"] is False + + # All attributes should always be included, but set to None when not relevant. + if txn is None: # Outside transaction + assert data["transactionName"] is None + assert data["custom_params"] == {'notice_error_attribute': 1} + assert data["response.status"] is None + assert data["request.method"] is None + assert data["request.uri"] is None + elif txn.background_task: # Background task + assert data["transactionName"] == "TestBackgroundTask" + assert data["custom_params"] == {'notice_error_attribute': 1, 'txn_attribute': 2} + assert data["response.status"] is None + assert data["request.method"] is None + assert data["request.uri"] is None + else: # Web transaction + assert data["transactionName"] == "TestWebTransaction" + assert data["custom_params"] == {'notice_error_attribute': 1, 'txn_attribute': 2} + assert data["response.status"] == 200 + assert data["request.method"] == "GET" + assert data["request.uri"] == "/" + + try: + _callback() + except Exception: + callback_errors.append(sys.exc_info()) + raise + + def _test(): + try: + txn = current_transaction() + if txn: + txn.add_custom_attribute("txn_attribute", 2) + if not txn.background_task: + txn.process_response(200, []) + raise Exception("text") + except Exception: + app = application() if transaction_decorator is None else None # Only set outside transaction + notice_error(application=app, attributes={"notice_error_attribute": 1}) + + assert not callback_errors, "Callback inputs failed to validate.\nerror: %s\ndata: %s" % (traceback.format_exception(*callback_errors[0]), str(_data[0])) + + if transaction_decorator is not None: + _test = transaction_decorator(_test) # Manually decorate test function + + try: + set_error_group_callback(callback) + _test() + finally: + set_error_group_callback(None) diff --git a/tests/agent_unittests/test_harvest_loop.py b/tests/agent_unittests/test_harvest_loop.py index 0df575b9f8..3056221079 100644 --- a/tests/agent_unittests/test_harvest_loop.py +++ b/tests/agent_unittests/test_harvest_loop.py @@ -61,9 +61,8 @@ def transaction_node(request): expected=False, span_id=None, stack_trace="", + error_group_name=None, custom_params={}, - file_name=None, - line_number=None, source=None, ) diff --git a/tests/testing_support/fixtures.py b/tests/testing_support/fixtures.py index 8d05bf4053..07de22cf0a 100644 --- a/tests/testing_support/fixtures.py +++ b/tests/testing_support/fixtures.py @@ -742,6 +742,8 @@ def validate_error_trace_attributes_outside_transaction( forgone_params = forgone_params or {} exact_attrs = exact_attrs or {} + target_error = [] + @transient_function_wrapper("newrelic.core.stats_engine", "StatsEngine.notice_error") def _validate_error_trace_attributes_outside_transaction(wrapped, instance, args, kwargs): try: @@ -749,15 +751,22 @@ def _validate_error_trace_attributes_outside_transaction(wrapped, instance, args except: raise else: - target_error = core_application_stats_engine_error(err_name) + target_error.append(core_application_stats_engine_error(err_name)) + + return result - check_error_attributes( - target_error.parameters, required_params, forgone_params, exact_attrs, is_transaction=False - ) + + @function_wrapper + def _validator_wrapper(wrapped, instance, args, kwargs): + result = _validate_error_trace_attributes_outside_transaction(wrapped)(*args, **kwargs) + + assert target_error and target_error[0] is not None, "No error found with name %s" % err_name + check_error_attributes(target_error[0].parameters, required_params, forgone_params, exact_attrs) return result - return _validate_error_trace_attributes_outside_transaction + + return _validator_wrapper def validate_error_event_attributes_outside_transaction( diff --git a/tests/testing_support/validators/validate_error_trace_attributes.py b/tests/testing_support/validators/validate_error_trace_attributes.py new file mode 100644 index 0000000000..2b4ddf4ae1 --- /dev/null +++ b/tests/testing_support/validators/validate_error_trace_attributes.py @@ -0,0 +1,47 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from testing_support.fixtures import check_error_attributes + +from newrelic.common.object_wrapper import transient_function_wrapper, function_wrapper + + +def validate_error_trace_attributes(err_name, required_params=None, forgone_params=None, exact_attrs=None): + required_params = required_params or {} + forgone_params = forgone_params or {} + exact_attrs = exact_attrs or {} + + target_error = [] + + @transient_function_wrapper("newrelic.core.stats_engine", "StatsEngine.record_transaction") + def _validate_error_trace_attributes(wrapped, instance, args, kwargs): + try: + result = wrapped(*args, **kwargs) + except Exception: + raise + else: + target_error.append(next((e for e in instance.error_data() if e.type == err_name), None)) + + return result + + @function_wrapper + def _validator_wrapper(wrapped, instance, args, kwargs): + result = _validate_error_trace_attributes(wrapped)(*args, **kwargs) + + assert target_error and target_error[0] is not None, "No error found with name %s" % err_name + check_error_attributes(target_error[0].parameters, required_params, forgone_params, exact_attrs) + + return result + + return _validator_wrapper