diff --git a/ddtrace/contrib/django/__init__.py b/ddtrace/contrib/django/__init__.py index 10f52a35a20..e206e4f62cc 100644 --- a/ddtrace/contrib/django/__init__.py +++ b/ddtrace/contrib/django/__init__.py @@ -184,9 +184,13 @@ with require_modules(required_modules) as missing_modules: if not missing_modules: - from . import patch as _patch - from .patch import get_version - from .patch import patch - from .patch import unpatch + # Required to allow users to import from `ddtrace.contrib.django.patch` directly + from . import patch as _ # noqa: F401, I001 + + # Expose public methods + from ..internal.django.patch import patch as _patch + from ..internal.django.patch import get_version + from ..internal.django.patch import patch + from ..internal.django.patch import unpatch __all__ = ["patch", "unpatch", "_patch", "get_version"] diff --git a/ddtrace/contrib/django/_asgi.py b/ddtrace/contrib/django/_asgi.py index 74f563dae01..8db82ebcba8 100644 --- a/ddtrace/contrib/django/_asgi.py +++ b/ddtrace/contrib/django/_asgi.py @@ -1,36 +1,15 @@ -""" -Module providing async hooks. Do not import this module unless using Python >= 3.6. -""" -from ddtrace.contrib.asgi import span_from_scope +from ddtrace.internal.utils.deprecations import DDTraceDeprecationWarning +from ddtrace.vendor.debtcollector import deprecate -from ...internal.utils import get_argument_value -from .. import trace_utils -from .utils import REQUEST_DEFAULT_RESOURCE -from .utils import _after_request_tags -from .utils import _before_request_tags +from ..internal.django._asgi import * # noqa: F401,F403 -@trace_utils.with_traced_module -async def traced_get_response_async(django, pin, func, instance, args, kwargs): - """Trace django.core.handlers.base.BaseHandler.get_response() (or other implementations). +def __getattr__(name): + deprecate( + ("%s.%s is deprecated" % (__name__, name)), + category=DDTraceDeprecationWarning, + ) - This is the main entry point for requests. - - Django requests are handled by a Handler.get_response method (inherited from base.BaseHandler). - This method invokes the middleware chain and returns the response generated by the chain. - """ - request = get_argument_value(args, kwargs, 0, "request") - span = span_from_scope(request.scope) - if span is None: - return await func(*args, **kwargs) - - # Reset the span resource so we can know if it was modified during the request or not - span.resource = REQUEST_DEFAULT_RESOURCE - _before_request_tags(pin, span, request) - response = None - try: - response = await func(*args, **kwargs) - finally: - # DEV: Always set these tags, this is where `span.resource` is set - _after_request_tags(pin, span, request, response) - return response + if name in globals(): + return globals()[name] + raise AttributeError("%s has no attribute %s", __name__, name) diff --git a/ddtrace/contrib/django/compat.py b/ddtrace/contrib/django/compat.py index 20f0a52fa8a..f3e79bd6c47 100644 --- a/ddtrace/contrib/django/compat.py +++ b/ddtrace/contrib/django/compat.py @@ -1,31 +1,15 @@ -import django +from ddtrace.internal.utils.deprecations import DDTraceDeprecationWarning +from ddtrace.vendor.debtcollector import deprecate +from ..internal.django.compat import * # noqa: F401,F403 -if django.VERSION >= (1, 10, 1): - from django.urls import get_resolver - def user_is_authenticated(user): - # Explicit comparison due to the following bug - # https://code.djangoproject.com/ticket/26988 - return user.is_authenticated == True # noqa E712 +def __getattr__(name): + deprecate( + ("%s.%s is deprecated" % (__name__, name)), + category=DDTraceDeprecationWarning, + ) -else: - from django.conf import settings - from django.core import urlresolvers - - def user_is_authenticated(user): - return user.is_authenticated() - - if django.VERSION >= (1, 9, 0): - - def get_resolver(urlconf=None): - urlconf = urlconf or settings.ROOT_URLCONF - urlresolvers.set_urlconf(urlconf) - return urlresolvers.get_resolver(urlconf) - - else: - - def get_resolver(urlconf=None): - urlconf = urlconf or settings.ROOT_URLCONF - urlresolvers.set_urlconf(urlconf) - return urlresolvers.RegexURLResolver(r"^/", urlconf) + if name in globals(): + return globals()[name] + raise AttributeError("%s has no attribute %s", __name__, name) diff --git a/ddtrace/contrib/django/patch.py b/ddtrace/contrib/django/patch.py index afc36c25b1c..f98d22e82e4 100644 --- a/ddtrace/contrib/django/patch.py +++ b/ddtrace/contrib/django/patch.py @@ -1,930 +1,4 @@ -""" -The Django patching works as follows: +from ..internal.django.patch import * # noqa: F401,F403 -Django internals are instrumented via normal `patch()`. -`django.apps.registry.Apps.populate` is patched to add instrumentation for any -specific Django apps like Django Rest Framework (DRF). -""" -import functools -from inspect import getmro -from inspect import isclass -from inspect import isfunction -import os - -from ddtrace import Pin -from ddtrace import config -from ddtrace._trace.trace_handlers import _ctype_from_headers -from ddtrace.constants import SPAN_KIND -from ddtrace.contrib import dbapi -from ddtrace.contrib import func_name -from ddtrace.ext import SpanKind -from ddtrace.ext import SpanTypes -from ddtrace.ext import http -from ddtrace.ext import sql as sqlx -from ddtrace.internal import core -from ddtrace.internal._exceptions import BlockingException -from ddtrace.internal.compat import Iterable -from ddtrace.internal.compat import maybe_stringify -from ddtrace.internal.constants import COMPONENT -from ddtrace.internal.constants import HTTP_REQUEST_BLOCKED -from ddtrace.internal.constants import STATUS_403_TYPE_AUTO -from ddtrace.internal.core.event_hub import ResultType -from ddtrace.internal.logger import get_logger -from ddtrace.internal.schema import schematize_service_name -from ddtrace.internal.schema import schematize_url_operation -from ddtrace.internal.schema.span_attribute_schema import SpanDirection -from ddtrace.internal.utils import http as http_utils -from ddtrace.internal.utils.formats import asbool -from ddtrace.settings.asm import config as asm_config -from ddtrace.settings.integration import IntegrationConfig -from ddtrace.vendor import wrapt -from ddtrace.vendor.packaging.version import parse as parse_version -from ddtrace.vendor.wrapt.importer import when_imported - -from ...appsec._utils import _UserInfoRetriever -from ...ext import db -from ...ext import net -from ...internal.utils import get_argument_value -from ...propagation._database_monitoring import _DBM_Propagator -from .. import trace_utils -from ..trace_utils import _get_request_header_user_agent - - -log = get_logger(__name__) - -config._add( - "django", - dict( - _default_service=schematize_service_name("django"), - cache_service_name=os.getenv("DD_DJANGO_CACHE_SERVICE_NAME", default="django"), - database_service_name_prefix=os.getenv("DD_DJANGO_DATABASE_SERVICE_NAME_PREFIX", default=""), - database_service_name=os.getenv("DD_DJANGO_DATABASE_SERVICE_NAME", default=""), - trace_fetch_methods=asbool(os.getenv("DD_DJANGO_TRACE_FETCH_METHODS", default=False)), - distributed_tracing_enabled=True, - instrument_middleware=asbool(os.getenv("DD_DJANGO_INSTRUMENT_MIDDLEWARE", default=True)), - instrument_templates=asbool(os.getenv("DD_DJANGO_INSTRUMENT_TEMPLATES", default=True)), - instrument_databases=asbool(os.getenv("DD_DJANGO_INSTRUMENT_DATABASES", default=True)), - instrument_caches=asbool(os.getenv("DD_DJANGO_INSTRUMENT_CACHES", default=True)), - analytics_enabled=None, # None allows the value to be overridden by the global config - analytics_sample_rate=None, - trace_query_string=None, # Default to global config - include_user_name=asbool(os.getenv("DD_DJANGO_INCLUDE_USER_NAME", default=True)), - use_handler_with_url_name_resource_format=asbool( - os.getenv("DD_DJANGO_USE_HANDLER_WITH_URL_NAME_RESOURCE_FORMAT", default=False) - ), - use_handler_resource_format=asbool(os.getenv("DD_DJANGO_USE_HANDLER_RESOURCE_FORMAT", default=False)), - use_legacy_resource_format=asbool(os.getenv("DD_DJANGO_USE_LEGACY_RESOURCE_FORMAT", default=False)), - _trace_asgi_websocket=os.getenv("DD_ASGI_TRACE_WEBSOCKET", default=False), - ), -) - -_NotSet = object() -psycopg_cursor_cls = Psycopg2TracedCursor = Psycopg3TracedCursor = _NotSet - - -DB_CONN_ATTR_BY_TAG = { - net.TARGET_HOST: "HOST", - net.TARGET_PORT: "PORT", - db.USER: "USER", - db.NAME: "NAME", -} - - -def get_version(): - # type: () -> str - import django - - return django.__version__ - - -def patch_conn(django, conn): - global psycopg_cursor_cls, Psycopg2TracedCursor, Psycopg3TracedCursor - - if psycopg_cursor_cls is _NotSet: - try: - from psycopg.cursor import Cursor as psycopg_cursor_cls - - from ddtrace.contrib.psycopg.cursor import Psycopg3TracedCursor - except ImportError: - Psycopg3TracedCursor = None - try: - from psycopg2._psycopg import cursor as psycopg_cursor_cls - - from ddtrace.contrib.psycopg.cursor import Psycopg2TracedCursor - except ImportError: - psycopg_cursor_cls = None - Psycopg2TracedCursor = None - - tags = {} - settings_dict = getattr(conn, "settings_dict", {}) - for tag, attr in DB_CONN_ATTR_BY_TAG.items(): - if attr in settings_dict: - tags[tag] = trace_utils._convert_to_string(conn.settings_dict.get(attr)) - - conn._datadog_tags = tags - - def cursor(django, pin, func, instance, args, kwargs): - alias = getattr(conn, "alias", "default") - - if config.django.database_service_name: - service = config.django.database_service_name - else: - database_prefix = config.django.database_service_name_prefix - service = "{}{}{}".format(database_prefix, alias, "db") - service = schematize_service_name(service) - - vendor = getattr(conn, "vendor", "db") - prefix = sqlx.normalize_vendor(vendor) - - tags = {"django.db.vendor": vendor, "django.db.alias": alias} - tags.update(getattr(conn, "_datadog_tags", {})) - - pin = Pin(service, tags=tags, tracer=pin.tracer) - - cursor = func(*args, **kwargs) - - traced_cursor_cls = dbapi.TracedCursor - try: - if cursor.cursor.__class__.__module__.startswith("psycopg2."): - # Import lazily to avoid importing psycopg2 if not already imported. - from ddtrace.contrib.psycopg.cursor import Psycopg2TracedCursor - - traced_cursor_cls = Psycopg2TracedCursor - elif type(cursor.cursor).__name__ == "Psycopg3TracedCursor": - # Import lazily to avoid importing psycopg if not already imported. - from ddtrace.contrib.psycopg.cursor import Psycopg3TracedCursor - - traced_cursor_cls = Psycopg3TracedCursor - except AttributeError: - pass - - # Each db alias will need its own config for dbapi - cfg = IntegrationConfig( - config.django.global_config, # global_config needed for analytics sample rate - "{}-{}".format("django", alias), # name not used but set anyway - _default_service=config.django._default_service, - _dbapi_span_name_prefix=prefix, - trace_fetch_methods=config.django.trace_fetch_methods, - analytics_enabled=config.django.analytics_enabled, - analytics_sample_rate=config.django.analytics_sample_rate, - _dbm_propagator=_DBM_Propagator(0, "query"), - ) - return traced_cursor_cls(cursor, pin, cfg) - - if not isinstance(conn.cursor, wrapt.ObjectProxy): - conn.cursor = wrapt.FunctionWrapper(conn.cursor, trace_utils.with_traced_module(cursor)(django)) - - -def instrument_dbs(django): - def get_connection(wrapped, instance, args, kwargs): - conn = wrapped(*args, **kwargs) - try: - patch_conn(django, conn) - except Exception: - log.debug("Error instrumenting database connection %r", conn, exc_info=True) - return conn - - if not isinstance(django.db.utils.ConnectionHandler.__getitem__, wrapt.ObjectProxy): - django.db.utils.ConnectionHandler.__getitem__ = wrapt.FunctionWrapper( - django.db.utils.ConnectionHandler.__getitem__, get_connection - ) - - -@trace_utils.with_traced_module -def traced_cache(django, pin, func, instance, args, kwargs): - from . import utils - - if not config.django.instrument_caches: - return func(*args, **kwargs) - - cache_backend = "{}.{}".format(instance.__module__, instance.__class__.__name__) - tags = {COMPONENT: config.django.integration_name, "django.cache.backend": cache_backend} - if args: - keys = utils.quantize_key_values(args[0]) - tags["django.cache.key"] = keys - - with core.context_with_data( - "django.cache", - span_name="django.cache", - span_type=SpanTypes.CACHE, - service=config.django.cache_service_name, - resource=utils.resource_from_cache_prefix(func_name(func), instance), - tags=tags, - pin=pin, - ) as ctx, ctx["call"]: - result = func(*args, **kwargs) - rowcount = 0 - if func.__name__ == "get_many": - rowcount = sum(1 for doc in result if doc) if result and isinstance(result, Iterable) else 0 - elif func.__name__ == "get": - try: - # check also for special case for Django~3.2 that returns an empty Sentinel - # object for empty results - # also check if result is Iterable first since some iterables return ambiguous - # truth results with ``==`` - if result is None or ( - not isinstance(result, Iterable) and result == getattr(instance, "_missing_key", None) - ): - rowcount = 0 - else: - rowcount = 1 - except (AttributeError, NotImplementedError, ValueError): - pass - core.dispatch("django.cache", (ctx, rowcount)) - return result - - -def instrument_caches(django): - cache_backends = set([cache["BACKEND"] for cache in django.conf.settings.CACHES.values()]) - for cache_path in cache_backends: - split = cache_path.split(".") - cache_module = ".".join(split[:-1]) - cache_cls = split[-1] - for method in ["get", "set", "add", "delete", "incr", "decr", "get_many", "set_many", "delete_many"]: - try: - cls = django.utils.module_loading.import_string(cache_path) - # DEV: this can be removed when we add an idempotent `wrap` - if not trace_utils.iswrapped(cls, method): - trace_utils.wrap(cache_module, "{0}.{1}".format(cache_cls, method), traced_cache(django)) - except Exception: - log.debug("Error instrumenting cache %r", cache_path, exc_info=True) - - -@trace_utils.with_traced_module -def traced_populate(django, pin, func, instance, args, kwargs): - """django.apps.registry.Apps.populate is the method used to populate all the apps. - - It is used as a hook to install instrumentation for 3rd party apps (like DRF). - - `populate()` works in 3 phases: - - - Phase 1: Initializes the app configs and imports the app modules. - - Phase 2: Imports models modules for each app. - - Phase 3: runs ready() of each app config. - - If all 3 phases successfully run then `instance.ready` will be `True`. - """ - - # populate() can be called multiple times, we don't want to instrument more than once - if instance.ready: - log.debug("Django instrumentation already installed, skipping.") - return func(*args, **kwargs) - - ret = func(*args, **kwargs) - - if not instance.ready: - log.debug("populate() failed skipping instrumentation.") - return ret - - settings = django.conf.settings - - # Instrument databases - if config.django.instrument_databases: - try: - instrument_dbs(django) - except Exception: - log.debug("Error instrumenting Django database connections", exc_info=True) - - # Instrument caches - if config.django.instrument_caches: - try: - instrument_caches(django) - except Exception: - log.debug("Error instrumenting Django caches", exc_info=True) - - # Instrument Django Rest Framework if it's installed - INSTALLED_APPS = getattr(settings, "INSTALLED_APPS", []) - - if "rest_framework" in INSTALLED_APPS: - try: - from .restframework import patch_restframework - - patch_restframework(django) - except Exception: - log.debug("Error patching rest_framework", exc_info=True) - - return ret - - -def traced_func(django, name, resource=None, ignored_excs=None): - def wrapped(django, pin, func, instance, args, kwargs): - tags = {COMPONENT: config.django.integration_name} - with core.context_with_data( - "django.func.wrapped", span_name=name, resource=resource, tags=tags, pin=pin - ) as ctx, ctx["call"]: - core.dispatch( - "django.func.wrapped", - ( - args, - kwargs, - django.core.handlers.wsgi.WSGIRequest if hasattr(django.core.handlers, "wsgi") else object, - ctx, - ignored_excs, - ), - ) - return func(*args, **kwargs) - - return trace_utils.with_traced_module(wrapped)(django) - - -def traced_process_exception(django, name, resource=None): - def wrapped(django, pin, func, instance, args, kwargs): - tags = {COMPONENT: config.django.integration_name} - with core.context_with_data( - "django.process_exception", span_name=name, resource=resource, tags=tags, pin=pin - ) as ctx, ctx["call"]: - resp = func(*args, **kwargs) - core.dispatch( - "django.process_exception", (ctx, hasattr(resp, "status_code") and 500 <= resp.status_code < 600) - ) - return resp - - return trace_utils.with_traced_module(wrapped)(django) - - -@trace_utils.with_traced_module -def traced_load_middleware(django, pin, func, instance, args, kwargs): - """ - Patches django.core.handlers.base.BaseHandler.load_middleware to instrument all - middlewares. - """ - settings_middleware = [] - # Gather all the middleware - if getattr(django.conf.settings, "MIDDLEWARE", None): - settings_middleware += django.conf.settings.MIDDLEWARE - if getattr(django.conf.settings, "MIDDLEWARE_CLASSES", None): - settings_middleware += django.conf.settings.MIDDLEWARE_CLASSES - - # Iterate over each middleware provided in settings.py - # Each middleware can either be a function or a class - for mw_path in settings_middleware: - mw = django.utils.module_loading.import_string(mw_path) - - # Instrument function-based middleware - if isfunction(mw) and not trace_utils.iswrapped(mw): - split = mw_path.split(".") - if len(split) < 2: - continue - base = ".".join(split[:-1]) - attr = split[-1] - - # DEV: We need to have a closure over `mw_path` for the resource name or else - # all function based middleware will share the same resource name - def _wrapper(resource): - # Function-based middleware is a factory which returns a handler function for - # requests. - # So instead of tracing the factory, we want to trace its returned value. - # We wrap the factory to return a traced version of the handler function. - def wrapped_factory(func, instance, args, kwargs): - # r is the middleware handler function returned from the factory - r = func(*args, **kwargs) - if r: - return wrapt.FunctionWrapper( - r, - traced_func(django, "django.middleware", resource=resource), - ) - # If r is an empty middleware function (i.e. returns None), don't wrap since - # NoneType cannot be called - else: - return r - - return wrapped_factory - - trace_utils.wrap(base, attr, _wrapper(resource=mw_path)) - - # Instrument class-based middleware - elif isclass(mw): - for hook in [ - "process_request", - "process_response", - "process_view", - "process_template_response", - "__call__", - ]: - if hasattr(mw, hook) and not trace_utils.iswrapped(mw, hook): - trace_utils.wrap( - mw, hook, traced_func(django, "django.middleware", resource=mw_path + ".{0}".format(hook)) - ) - # Do a little extra for `process_exception` - if hasattr(mw, "process_exception") and not trace_utils.iswrapped(mw, "process_exception"): - res = mw_path + ".{0}".format("process_exception") - trace_utils.wrap( - mw, "process_exception", traced_process_exception(django, "django.middleware", resource=res) - ) - - return func(*args, **kwargs) - - -def _gather_block_metadata(request, request_headers, ctx: core.ExecutionContext): - from . import utils - - try: - metadata = {http.STATUS_CODE: "403", http.METHOD: request.method} - url = utils.get_request_uri(request) - query = request.META.get("QUERY_STRING", "") - if query and config.django.trace_query_string: - metadata[http.QUERY_STRING] = query - user_agent = _get_request_header_user_agent(request_headers) - if user_agent: - metadata[http.USER_AGENT] = user_agent - except Exception as e: - log.warning("Could not gather some metadata on blocked request: %s", str(e)) # noqa: G200 - core.dispatch("django.block_request_callback", (ctx, metadata, config.django, url, query)) - - -def _block_request_callable(request, request_headers, ctx: core.ExecutionContext): - # This is used by user-id blocking to block responses. It could be called - # at any point so it's a callable stored in the ASM context. - from django.core.exceptions import PermissionDenied - - core.root.set_item(HTTP_REQUEST_BLOCKED, STATUS_403_TYPE_AUTO) - _gather_block_metadata(request, request_headers, ctx) - raise PermissionDenied() - - -@trace_utils.with_traced_module -def traced_get_response(django, pin, func, instance, args, kwargs): - """Trace django.core.handlers.base.BaseHandler.get_response() (or other implementations). - - This is the main entry point for requests. - - Django requests are handled by a Handler.get_response method (inherited from base.BaseHandler). - This method invokes the middleware chain and returns the response generated by the chain. - """ - from ddtrace.contrib.django.compat import get_resolver - - from . import utils - - request = get_argument_value(args, kwargs, 0, "request") - if request is None: - return func(*args, **kwargs) - - request_headers = utils._get_request_headers(request) - - with core.context_with_data( - "django.traced_get_response", - remote_addr=request.META.get("REMOTE_ADDR"), - headers=request_headers, - headers_case_sensitive=django.VERSION < (2, 2), - span_name=schematize_url_operation("django.request", protocol="http", direction=SpanDirection.INBOUND), - resource=utils.REQUEST_DEFAULT_RESOURCE, - service=trace_utils.int_service(pin, config.django), - span_type=SpanTypes.WEB, - tags={COMPONENT: config.django.integration_name, SPAN_KIND: SpanKind.SERVER}, - distributed_headers_config=config.django, - distributed_headers=request_headers, - pin=pin, - ) as ctx, ctx.get_item("call"): - core.dispatch( - "django.traced_get_response.pre", - ( - functools.partial(_block_request_callable, request, request_headers, ctx), - ctx, - request, - utils._before_request_tags, - ), - ) - - response = None - - def blocked_response(): - from django.http import HttpResponse - - block_config = core.get_item(HTTP_REQUEST_BLOCKED) or {} - desired_type = block_config.get("type", "auto") - status = block_config.get("status_code", 403) - if desired_type == "none": - response = HttpResponse("", status=status) - location = block_config.get("location", "") - if location: - response["location"] = location - else: - ctype = _ctype_from_headers(block_config, request_headers) - content = http_utils._get_blocked_template(ctype) - response = HttpResponse(content, content_type=ctype, status=status) - response.content = content - response["Content-Length"] = len(content.encode()) - utils._after_request_tags(pin, ctx["call"], request, response) - return response - - try: - if core.get_item(HTTP_REQUEST_BLOCKED): - response = blocked_response() - return response - - query = request.META.get("QUERY_STRING", "") - uri = utils.get_request_uri(request) - if uri is not None and query: - uri += "?" + query - resolver = get_resolver(getattr(request, "urlconf", None)) - if resolver: - try: - path = resolver.resolve(request.path_info).kwargs - log.debug("resolver.pattern %s", path) - except Exception: - path = None - - core.dispatch("django.start_response", (ctx, request, utils._extract_body, query, uri, path)) - core.dispatch("django.start_response.post", ("Django",)) - - if core.get_item(HTTP_REQUEST_BLOCKED): - response = blocked_response() - return response - - try: - response = func(*args, **kwargs) - except BlockingException as e: - core.set_item(HTTP_REQUEST_BLOCKED, e.args[0]) - response = blocked_response() - return response - - if core.get_item(HTTP_REQUEST_BLOCKED): - response = blocked_response() - return response - - return response - finally: - core.dispatch("django.finalize_response.pre", (ctx, utils._after_request_tags, request, response)) - if not core.get_item(HTTP_REQUEST_BLOCKED): - core.dispatch("django.finalize_response", ("Django",)) - if core.get_item(HTTP_REQUEST_BLOCKED): - response = blocked_response() - return response # noqa: B012 - - -@trace_utils.with_traced_module -def traced_template_render(django, pin, wrapped, instance, args, kwargs): - # DEV: Check here in case this setting is configured after a template has been instrumented - if not config.django.instrument_templates: - return wrapped(*args, **kwargs) - - template_name = maybe_stringify(getattr(instance, "name", None)) - if template_name: - resource = template_name - else: - resource = "{0}.{1}".format(func_name(instance), wrapped.__name__) - - tags = {COMPONENT: config.django.integration_name} - if template_name: - tags["django.template.name"] = template_name - engine = getattr(instance, "engine", None) - if engine: - tags["django.template.engine.class"] = func_name(engine) - - with core.context_with_data( - "django.template.render", - span_name="django.template.render", - resource=resource, - span_type=http.TEMPLATE, - tags=tags, - pin=pin, - ) as ctx, ctx["call"]: - return wrapped(*args, **kwargs) - - -def instrument_view(django, view): - """ - Helper to wrap Django views. - - We want to wrap all lifecycle/http method functions for every class in the MRO for this view - """ - if hasattr(view, "__mro__"): - for cls in reversed(getmro(view)): - _instrument_view(django, cls) - - return _instrument_view(django, view) - - -def _instrument_view(django, view): - """Helper to wrap Django views.""" - from . import utils - - # All views should be callable, double check before doing anything - if not callable(view): - return view - - # Patch view HTTP methods and lifecycle methods - http_method_names = getattr(view, "http_method_names", ("get", "delete", "post", "options", "head")) - lifecycle_methods = ("setup", "dispatch", "http_method_not_allowed") - for name in list(http_method_names) + list(lifecycle_methods): - try: - func = getattr(view, name, None) - if not func or isinstance(func, wrapt.ObjectProxy): - continue - - resource = "{0}.{1}".format(func_name(view), name) - op_name = "django.view.{0}".format(name) - trace_utils.wrap(view, name, traced_func(django, name=op_name, resource=resource)) - except Exception: - log.debug("Failed to instrument Django view %r function %s", view, name, exc_info=True) - - # Patch response methods - response_cls = getattr(view, "response_class", None) - if response_cls: - methods = ("render",) - for name in methods: - try: - func = getattr(response_cls, name, None) - # Do not wrap if the method does not exist or is already wrapped - if not func or isinstance(func, wrapt.ObjectProxy): - continue - - resource = "{0}.{1}".format(func_name(response_cls), name) - op_name = "django.response.{0}".format(name) - trace_utils.wrap(response_cls, name, traced_func(django, name=op_name, resource=resource)) - except Exception: - log.debug("Failed to instrument Django response %r function %s", response_cls, name, exc_info=True) - - # If the view itself is not wrapped, wrap it - if not isinstance(view, wrapt.ObjectProxy): - view = utils.DjangoViewProxy( - view, traced_func(django, "django.view", resource=func_name(view), ignored_excs=[django.http.Http404]) - ) - return view - - -@trace_utils.with_traced_module -def traced_urls_path(django, pin, wrapped, instance, args, kwargs): - """Wrapper for url path helpers to ensure all views registered as urls are traced.""" - try: - if "view" in kwargs: - kwargs["view"] = instrument_view(django, kwargs["view"]) - elif len(args) >= 2: - args = list(args) - args[1] = instrument_view(django, args[1]) - args = tuple(args) - except Exception: - log.debug("Failed to instrument Django url path %r %r", args, kwargs, exc_info=True) - return wrapped(*args, **kwargs) - - -@trace_utils.with_traced_module -def traced_as_view(django, pin, func, instance, args, kwargs): - """ - Wrapper for django's View.as_view class method - """ - try: - instrument_view(django, instance) - except Exception: - log.debug("Failed to instrument Django view %r", instance, exc_info=True) - view = func(*args, **kwargs) - return wrapt.FunctionWrapper(view, traced_func(django, "django.view", resource=func_name(instance))) - - -@trace_utils.with_traced_module -def traced_get_asgi_application(django, pin, func, instance, args, kwargs): - from ddtrace.contrib.asgi import TraceMiddleware - - def django_asgi_modifier(span, scope): - span.name = schematize_url_operation("django.request", protocol="http", direction=SpanDirection.INBOUND) - - return TraceMiddleware(func(*args, **kwargs), integration_config=config.django, span_modifier=django_asgi_modifier) - - -class _DjangoUserInfoRetriever(_UserInfoRetriever): - def __init__(self, user, credentials=None): - super(_DjangoUserInfoRetriever, self).__init__(user) - - self.credentials = credentials if credentials else {} - if self.credentials and not user: - self._try_load_user() - - def _try_load_user(self): - self.user_model = None - - try: - from django.contrib.auth import get_user_model - except ImportError: - log.debug("user_exist: Could not import Django get_user_model", exc_info=True) - return - - try: - self.user_model = get_user_model() - if not self.user_model: - return - except Exception: - log.debug("user_exist: Could not get the user model", exc_info=True) - return - - login_field = asm_config._user_model_login_field - login_field_value = self.credentials.get(login_field, None) if login_field else None - - if not login_field or not login_field_value: - # Try to get the username from the credentials - for possible_login_field in self.possible_login_fields: - if possible_login_field in self.credentials: - login_field = possible_login_field - login_field_value = self.credentials[login_field] - break - else: - # Could not get what the login field, so we can't check if the user exists - log.debug("try_load_user_model: could not get the login field from the credentials") - return - - try: - self.user = self.user_model.objects.get(**{login_field: login_field_value}) - except self.user_model.DoesNotExist: - log.debug("try_load_user_model: could not load user model", exc_info=True) - - def user_exists(self): - return self.user is not None - - def get_username(self): - if hasattr(self.user, "USERNAME_FIELD") and not asm_config._user_model_name_field: - user_type = type(self.user) - return getattr(self.user, user_type.USERNAME_FIELD, None) - - return super(_DjangoUserInfoRetriever, self).get_username() - - def get_name(self): - if not asm_config._user_model_name_field: - if hasattr(self.user, "get_full_name"): - try: - return self.user.get_full_name() - except Exception: - log.debug("User model get_full_name member produced an exception: ", exc_info=True) - - if hasattr(self.user, "first_name") and hasattr(self.user, "last_name"): - return "%s %s" % (self.user.first_name, self.user.last_name) - - return super(_DjangoUserInfoRetriever, self).get_name() - - def get_user_email(self): - if hasattr(self.user, "EMAIL_FIELD") and not asm_config._user_model_name_field: - user_type = type(self.user) - return getattr(self.user, user_type.EMAIL_FIELD, None) - - return super(_DjangoUserInfoRetriever, self).get_user_email() - - -@trace_utils.with_traced_module -def traced_login(django, pin, func, instance, args, kwargs): - func(*args, **kwargs) - mode = asm_config._user_event_mode - if mode == "disabled": - return - try: - request = get_argument_value(args, kwargs, 0, "request") - user = get_argument_value(args, kwargs, 1, "user") - core.dispatch("django.login", (pin, request, user, mode, _DjangoUserInfoRetriever(user))) - except Exception: - log.debug("Error while trying to trace Django login", exc_info=True) - - -@trace_utils.with_traced_module -def traced_authenticate(django, pin, func, instance, args, kwargs): - result_user = func(*args, **kwargs) - mode = asm_config._user_event_mode - if mode == "disabled": - return result_user - try: - result = core.dispatch_with_results( - "django.auth", (result_user, mode, kwargs, pin, _DjangoUserInfoRetriever(result_user, credentials=kwargs)) - ).user - if result and result.value[0]: - return result.value[1] - except Exception: - log.debug("Error while trying to trace Django authenticate", exc_info=True) - - return result_user - - -def unwrap_views(func, instance, args, kwargs): - """ - Django channels uses path() and re_path() to route asgi applications. This broke our initial - assumption that - django path/re_path/url functions only accept views. Here we unwrap ddtrace view - instrumentation from asgi - applications. - - Ex. ``channels.routing.URLRouter([path('', get_asgi_application())])`` - On startup ddtrace.contrib.django.path.instrument_view() will wrap get_asgi_application in a - DjangoViewProxy. - Since get_asgi_application is not a django view callback this function will unwrap it. - """ - from . import utils - - routes = get_argument_value(args, kwargs, 0, "routes") - for route in routes: - if isinstance(route.callback, utils.DjangoViewProxy): - route.callback = route.callback.__wrapped__ - - return func(*args, **kwargs) - - -def _patch(django): - Pin().onto(django) - - when_imported("django.apps.registry")(lambda m: trace_utils.wrap(m, "Apps.populate", traced_populate(django))) - - if config.django.instrument_middleware: - when_imported("django.core.handlers.base")( - lambda m: trace_utils.wrap(m, "BaseHandler.load_middleware", traced_load_middleware(django)) - ) - - when_imported("django.core.handlers.wsgi")(lambda m: trace_utils.wrap(m, "WSGIRequest.__init__", wrap_wsgi_environ)) - core.dispatch("django.patch", ()) - - @when_imported("django.core.handlers.base") - def _(m): - import django - - trace_utils.wrap(m, "BaseHandler.get_response", traced_get_response(django)) - if django.VERSION >= (3, 1): - # Have to inline this import as the module contains syntax incompatible with Python 3.5 and below - from ._asgi import traced_get_response_async - - trace_utils.wrap(m, "BaseHandler.get_response_async", traced_get_response_async(django)) - - @when_imported("django.contrib.auth") - def _(m): - trace_utils.wrap(m, "login", traced_login(django)) - trace_utils.wrap(m, "authenticate", traced_authenticate(django)) - - # Only wrap get_asgi_application if get_response_async exists. Otherwise we will effectively double-patch - # because get_response and get_asgi_application will be used. We must rely on the version instead of coalescing - # with the previous patching hook because of circular imports within `django.core.asgi`. - if django.VERSION >= (3, 1): - when_imported("django.core.asgi")( - lambda m: trace_utils.wrap(m, "get_asgi_application", traced_get_asgi_application(django)) - ) - - if config.django.instrument_templates: - when_imported("django.template.base")( - lambda m: trace_utils.wrap(m, "Template.render", traced_template_render(django)) - ) - - if django.VERSION < (4, 0, 0): - when_imported("django.conf.urls")(lambda m: trace_utils.wrap(m, "url", traced_urls_path(django))) - - if django.VERSION >= (2, 0, 0): - - @when_imported("django.urls") - def _(m): - trace_utils.wrap(m, "path", traced_urls_path(django)) - trace_utils.wrap(m, "re_path", traced_urls_path(django)) - - when_imported("django.views.generic.base")(lambda m: trace_utils.wrap(m, "View.as_view", traced_as_view(django))) - - @when_imported("channels.routing") - def _(m): - import channels - - channels_version = parse_version(channels.__version__) - if channels_version >= parse_version("3.0"): - # ASGI3 is only supported in channels v3.0+ - trace_utils.wrap(m, "URLRouter.__init__", unwrap_views) - - -def wrap_wsgi_environ(wrapped, _instance, args, kwargs): - result = core.dispatch_with_results("django.wsgi_environ", (wrapped, _instance, args, kwargs)).wrapped_result - # if the callback is registered and runs, return the result - if result: - return result.value - # if the callback is not registered, return the original result - elif result.response_type == ResultType.RESULT_UNDEFINED: - return wrapped(*args, **kwargs) - # if an exception occurs, raise it. It should never happen. - elif result.exception: - raise result.exception - - -def patch(): - import django - - if getattr(django, "_datadog_patch", False): - return - _patch(django) - - django._datadog_patch = True - - -def _unpatch(django): - trace_utils.unwrap(django.apps.registry.Apps, "populate") - trace_utils.unwrap(django.core.handlers.base.BaseHandler, "load_middleware") - trace_utils.unwrap(django.core.handlers.base.BaseHandler, "get_response") - trace_utils.unwrap(django.core.handlers.base.BaseHandler, "get_response_async") - trace_utils.unwrap(django.template.base.Template, "render") - trace_utils.unwrap(django.conf.urls.static, "static") - trace_utils.unwrap(django.conf.urls, "url") - trace_utils.unwrap(django.contrib.auth.login, "login") - trace_utils.unwrap(django.contrib.auth.authenticate, "authenticate") - if django.VERSION >= (2, 0, 0): - trace_utils.unwrap(django.urls, "path") - trace_utils.unwrap(django.urls, "re_path") - trace_utils.unwrap(django.views.generic.base.View, "as_view") - for conn in django.db.connections.all(): - trace_utils.unwrap(conn, "cursor") - trace_utils.unwrap(django.db.utils.ConnectionHandler, "__getitem__") - - -def unpatch(): - import django - - if not getattr(django, "_datadog_patch", False): - return - - _unpatch(django) - - django._datadog_patch = False +# TODO: deprecate and remove this module diff --git a/ddtrace/contrib/django/restframework.py b/ddtrace/contrib/django/restframework.py index ed0c789efa9..144f5e277ef 100644 --- a/ddtrace/contrib/django/restframework.py +++ b/ddtrace/contrib/django/restframework.py @@ -1,33 +1,15 @@ -import rest_framework.views +from ddtrace.internal.utils.deprecations import DDTraceDeprecationWarning +from ddtrace.vendor.debtcollector import deprecate -from ddtrace.vendor.wrapt import wrap_function_wrapper as wrap +from ..internal.django.restframework import * # noqa: F401,F403 -from ..trace_utils import iswrapped -from ..trace_utils import with_traced_module +def __getattr__(name): + deprecate( + ("%s.%s is deprecated" % (__name__, name)), + category=DDTraceDeprecationWarning, + ) -@with_traced_module -def _traced_handle_exception(django, pin, wrapped, instance, args, kwargs): - """Sets the error message, error type and exception stack trace to the current span - before calling the original exception handler. - """ - span = pin.tracer.current_span() - - if span is not None: - span.set_traceback() - - return wrapped(*args, **kwargs) - - -def patch_restframework(django): - """Patches rest_framework app. - - To trace exceptions occurring during view processing we currently use a TraceExceptionMiddleware. - However the rest_framework handles exceptions before they come to our middleware. - So we need to manually patch the rest_framework exception handler - to set the exception stack trace in the current span. - """ - - # trace the handle_exception method - if not iswrapped(rest_framework.views.APIView, "handle_exception"): - wrap("rest_framework.views", "APIView.handle_exception", _traced_handle_exception(django)) + if name in globals(): + return globals()[name] + raise AttributeError("%s has no attribute %s", __name__, name) diff --git a/ddtrace/contrib/django/utils.py b/ddtrace/contrib/django/utils.py index 4c6b1fdd78b..6d5f00b9a13 100644 --- a/ddtrace/contrib/django/utils.py +++ b/ddtrace/contrib/django/utils.py @@ -1,438 +1,15 @@ -import json -from typing import Any # noqa:F401 -from typing import Dict # noqa:F401 -from typing import List # noqa:F401 -from typing import Mapping # noqa:F401 -from typing import Text # noqa:F401 -from typing import Union # noqa:F401 +from ddtrace.internal.utils.deprecations import DDTraceDeprecationWarning +from ddtrace.vendor.debtcollector import deprecate -import django -from django.utils.functional import SimpleLazyObject -import xmltodict +from ..internal.django.utils import * # noqa: F401,F403 -from ddtrace import config -from ddtrace._trace.span import Span -from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY -from ddtrace.constants import SPAN_MEASURED_KEY -from ddtrace.contrib import func_name -from ddtrace.ext import SpanTypes -from ddtrace.ext import user as _user -from ddtrace.internal import compat -from ddtrace.internal.utils.http import parse_form_multipart -from ddtrace.internal.utils.http import parse_form_params -from ddtrace.propagation._utils import from_wsgi_header -from ...internal import core -from ...internal.logger import get_logger -from ...internal.utils.formats import stringify_cache_args -from ...vendor.wrapt import FunctionWrapper -from .. import trace_utils -from .compat import get_resolver -from .compat import user_is_authenticated +def __getattr__(name): + deprecate( + ("%s.%s is deprecated" % (__name__, name)), + category=DDTraceDeprecationWarning, + ) - -try: - from json import JSONDecodeError -except ImportError: - # handling python 2.X import error - JSONDecodeError = ValueError # type: ignore - - -log = get_logger(__name__) - -if django.VERSION < (1, 10, 0): - Resolver404 = django.core.urlresolvers.Resolver404 -else: - Resolver404 = django.urls.exceptions.Resolver404 - - -DJANGO22 = django.VERSION >= (2, 2, 0) - -REQUEST_DEFAULT_RESOURCE = "__django_request" -_BODY_METHODS = {"POST", "PUT", "DELETE", "PATCH"} - -_quantize_text = Union[Text, bytes] -_quantize_param = Union[_quantize_text, List[_quantize_text], Dict[_quantize_text, Any], Any] - - -def resource_from_cache_prefix(resource, cache): - """ - Combine the resource name with the cache prefix (if any) - """ - if getattr(cache, "key_prefix", None): - name = " ".join((resource, cache.key_prefix)) - else: - name = resource - - # enforce lowercase to make the output nicer to read - return name.lower() - - -def quantize_key_values(keys): - # type: (_quantize_param) -> Text - """ - Used for Django cache key normalization. - - If a dict is provided we return a list of keys as text. - - If a list or tuple is provided we convert each element to text. - - If text is provided we convert to text. - """ - args = [] # type: List[Union[Text, bytes, Any]] - - # Normalize input values into a List[Text, bytes] - if isinstance(keys, dict): - args = list(keys.keys()) - elif isinstance(keys, (list, tuple)): - args = keys - else: - args = [keys] - - return stringify_cache_args(args) - - -def get_django_2_route(request, resolver_match): - # Try to use `resolver_match.route` if available - # Otherwise, look for `resolver.pattern.regex.pattern` - route = resolver_match.route - if route: - return route - - resolver = get_resolver(getattr(request, "urlconf", None)) - if resolver: - try: - return resolver.pattern.regex.pattern - except AttributeError: - pass - - return None - - -def set_tag_array(span, prefix, value): - """Helper to set a span tag as a single value or an array""" - if not value: - return - - if len(value) == 1: - if value[0]: - span.set_tag_str(prefix, value[0]) - else: - for i, v in enumerate(value, start=0): - if v: - span.set_tag_str("".join((prefix, ".", str(i))), v) - - -def get_request_uri(request): - """ - Helper to rebuild the original request url - - query string or fragments are not included. - """ - # DEV: Use django.http.request.HttpRequest._get_raw_host() when available - # otherwise back-off to PEP 333 as done in django 1.8.x - if hasattr(request, "_get_raw_host"): - host = request._get_raw_host() - else: - try: - # Try to build host how Django would have - # https://github.com/django/django/blob/e8d0d2a5efc8012dcc8bf1809dec065ebde64c81/django/http/request.py#L85-L102 - if "HTTP_HOST" in request.META: - host = request.META["HTTP_HOST"] - else: - host = request.META["SERVER_NAME"] - port = str(request.META["SERVER_PORT"]) - if port != ("443" if request.is_secure() else "80"): - host = "".join((host, ":", port)) - except Exception: - # This really shouldn't ever happen, but lets guard here just in case - log.debug("Failed to build Django request host", exc_info=True) - host = "unknown" - - # If request scheme is missing, possible in case where wsgi.url_scheme - # environ has not been set, return None and skip providing a uri - if request.scheme is None: - return - - # Build request url from the information available - # DEV: We are explicitly omitting query strings since they may contain sensitive information - urlparts = {"scheme": request.scheme, "netloc": host, "path": request.path} - - # If any url part is a SimpleLazyObject, use its __class__ property to cast - # str/bytes and allow for _setup() to execute - for k, v in urlparts.items(): - if isinstance(v, SimpleLazyObject): - if issubclass(v.__class__, str): - v = str(v) - elif issubclass(v.__class__, bytes): - v = bytes(v) - else: - # lazy object that is not str or bytes should not happen here - # but if it does skip providing a uri - log.debug( - "Skipped building Django request uri, %s is SimpleLazyObject wrapping a %s class", - k, - v.__class__.__name__, - ) - return None - urlparts[k] = compat.ensure_text(v) - - return "".join((urlparts["scheme"], "://", urlparts["netloc"], urlparts["path"])) - - -def _set_resolver_tags(pin, span, request): - # Default to just the HTTP method when we cannot determine a reasonable resource - resource = request.method - - try: - # Get resolver match result and build resource name pieces - resolver_match = request.resolver_match - if not resolver_match: - # The request quite likely failed (e.g. 404) so we do the resolution anyway. - resolver = get_resolver(getattr(request, "urlconf", None)) - resolver_match = resolver.resolve(request.path_info) - - if hasattr(resolver_match[0], "view_class"): - # In django==4.0, view.__name__ defaults to .views.view - # Accessing view.view_class is equired for django>4.0 to get the name of underlying view - handler = func_name(resolver_match[0].view_class) - else: - handler = func_name(resolver_match[0]) - - route = None - # In Django >= 2.2.0 we can access the original route or regex pattern - # TODO: Validate if `resolver.pattern.regex.pattern` is available on django<2.2 - if DJANGO22: - # Determine the resolver and resource name for this request - route = get_django_2_route(request, resolver_match) - if route: - span.set_tag_str("http.route", route) - - if config.django.use_handler_resource_format: - resource = " ".join((request.method, handler)) - elif config.django.use_legacy_resource_format: - resource = handler - else: - if route: - resource = " ".join((request.method, route)) - else: - if config.django.use_handler_with_url_name_resource_format: - # Append url name in order to distinguish different routes of the same ViewSet - url_name = resolver_match.url_name - if url_name: - handler = ".".join([handler, url_name]) - - resource = " ".join((request.method, handler)) - - span.set_tag_str("django.view", resolver_match.view_name) - set_tag_array(span, "django.namespace", resolver_match.namespaces) - - # Django >= 2.0.0 - if hasattr(resolver_match, "app_names"): - set_tag_array(span, "django.app", resolver_match.app_names) - - except Resolver404: - # Normalize all 404 requests into a single resource name - # DEV: This is for potential cardinality issues - resource = " ".join((request.method, "404")) - except Exception: - log.debug( - "Failed to resolve request path %r with path info %r", - request, - getattr(request, "path_info", "not-set"), - exc_info=True, - ) - finally: - # Only update the resource name if it was not explicitly set - # by anyone during the request lifetime - if span.resource == REQUEST_DEFAULT_RESOURCE: - span.resource = resource - - -def _before_request_tags(pin, span, request): - # DEV: Do not set `span.resource` here, leave it as `None` - # until `_set_resolver_tags` so we can know if the user - # has explicitly set it during the request lifetime - span.service = trace_utils.int_service(pin, config.django) - span.span_type = SpanTypes.WEB - span._metrics[SPAN_MEASURED_KEY] = 1 - - analytics_sr = config.django.get_analytics_sample_rate(use_global_config=True) - if analytics_sr is not None: - span.set_tag(ANALYTICS_SAMPLE_RATE_KEY, analytics_sr) - - span.set_tag_str("django.request.class", func_name(request)) - - -def _extract_body(request): - # DEV: Do not use request.POST or request.data, this could prevent custom parser to be used after - if request.method in _BODY_METHODS: - req_body = None - content_type = request.content_type if hasattr(request, "content_type") else request.META.get("CONTENT_TYPE") - headers = core.dispatch_with_results("django.extract_body").headers.value - try: - if content_type == "application/x-www-form-urlencoded": - req_body = parse_form_params(request.body.decode("UTF-8", errors="ignore")) - elif content_type == "multipart/form-data": - req_body = parse_form_multipart(request.body.decode("UTF-8", errors="ignore"), headers) - elif content_type in ("application/json", "text/json"): - req_body = json.loads(request.body.decode("UTF-8", errors="ignore")) - elif content_type in ("application/xml", "text/xml"): - req_body = xmltodict.parse(request.body.decode("UTF-8", errors="ignore")) - else: # text/plain, others: don't use them - req_body = None - except Exception: - log.debug("Failed to parse request body", exc_info=True) - return req_body - - -def _get_request_headers(request): - # type: (Any) -> Mapping[str, str] - if DJANGO22: - request_headers = request.headers # type: Mapping[str, str] - else: - request_headers = {} # type: Mapping[str, str] - for header, value in request.META.items(): - name = from_wsgi_header(header) - if name: - request_headers[name] = value - - return request_headers - - -def _after_request_tags(pin, span: Span, request, response): - # Response can be None in the event that the request failed - # We still want to set additional request tags that are resolved - # during the request. - - try: - user = getattr(request, "user", None) - if user is not None: - # Note: getattr calls to user / user_is_authenticated may result in ImproperlyConfigured exceptions from - # Django's get_user_model(): - # https://github.com/django/django/blob/a464ead29db8bf6a27a5291cad9eb3f0f3f0472b/django/contrib/auth/__init__.py - # - # FIXME: getattr calls to user fail in async contexts. - # Sample Error: django.core.exceptions.SynchronousOnlyOperation: You cannot call this from an async context - # - use a thread or sync_to_async. - try: - if hasattr(user, "is_authenticated"): - span.set_tag_str("django.user.is_authenticated", str(user_is_authenticated(user))) - - uid = getattr(user, "pk", None) - if uid and isinstance(uid, int): - span.set_tag_str("django.user.id", str(uid)) - span.set_tag_str(_user.ID, str(uid)) - if config.django.include_user_name: - username = getattr(user, "username", None) - if username: - span.set_tag_str("django.user.name", username) - except Exception: - log.debug("Error retrieving authentication information for user", exc_info=True) - - # DEV: Resolve the view and resource name at the end of the request in case - # urlconf changes at any point during the request - _set_resolver_tags(pin, span, request) - if response: - status = response.status_code - span.set_tag_str("django.response.class", func_name(response)) - if hasattr(response, "template_name"): - # template_name is a bit of a misnomer, as it could be any of: - # a list of strings, a tuple of strings, a single string, or an instance of Template - # for more detail, see: - # https://docs.djangoproject.com/en/3.0/ref/template-response/#django.template.response.SimpleTemplateResponse.template_name - template = response.template_name - - if isinstance(template, str): - template_names = [template] - elif isinstance( - template, - ( - list, - tuple, - ), - ): - template_names = template - elif hasattr(template, "template"): - # ^ checking by attribute here because - # django backend implementations don't have a common base - # `.template` is also the most consistent across django versions - template_names = [template.template.name] - else: - template_names = None - - set_tag_array(span, "django.response.template", template_names) - - url = get_request_uri(request) - - request_headers = core.dispatch_with_results("django.after_request_headers").headers.value - if not request_headers: - request_headers = _get_request_headers(request) - - response_headers = dict(response.items()) if response else {} - - response_cookies = {} - if response.cookies: - for k, v in response.cookies.items(): - # `v` is a http.cookies.Morsel class instance in some scenarios: - # 'cookie_key=cookie_value; HttpOnly; Path=/; SameSite=Strict' - try: - i = 0 - result = "" - for element in v.OutputString().split(";"): - if i == 0: - # split cookie_key="cookie_value" - key, value = element.split("=", 1) - # Remove quotes "cookie_value" - result = value[1:-1] if value.startswith('"') and value[-1] == '"' else value - else: - result += ";" + element - i += 1 - response_cookies[k] = result - except Exception: - # parse cookies by the old way - response_cookies[k] = v.OutputString() - - raw_uri = url - if raw_uri and request.META.get("QUERY_STRING"): - raw_uri += "?" + request.META["QUERY_STRING"] - - core.dispatch( - "django.after_request_headers.post", - ( - request_headers, - response_headers, - span, - config.django, - request, - url, - raw_uri, - status, - response_cookies, - ), - ) - content = getattr(response, "content", None) - if content is None: - content = getattr(response, "streaming_content", None) - core.dispatch("django.after_request_headers.finalize", (content, None)) - finally: - if span.resource == REQUEST_DEFAULT_RESOURCE: - span.resource = request.method - - -class DjangoViewProxy(FunctionWrapper): - """ - This custom function wrapper is used to wrap the callback passed to django views handlers (path/re_path/url). - This allows us to distinguish between wrapped django views and wrapped asgi applications in django channels. - """ - - @property - def __module__(self): - """ - DjangoViewProxy.__module__ defaults to ddtrace.contrib.django when a wrapped function does not have - a __module__ attribute. This method ensures that DjangoViewProxy.__module__ always returns the module - attribute of the wrapped function or an empty string if this attribute is not available. - The function Django.urls.path() does not have a __module__ attribute and would require this override - to resolve the correct module name. - """ - return self.__wrapped__.__module__ + if name in globals(): + return globals()[name] + raise AttributeError("%s has no attribute %s", __name__, name) diff --git a/ddtrace/contrib/dogpile_cache/__init__.py b/ddtrace/contrib/dogpile_cache/__init__.py index 8666443da50..c55e86d1e20 100644 --- a/ddtrace/contrib/dogpile_cache/__init__.py +++ b/ddtrace/contrib/dogpile_cache/__init__.py @@ -43,8 +43,12 @@ def hello(name): with require_modules(required_modules) as missing_modules: if not missing_modules: - from .patch import get_version - from .patch import patch - from .patch import unpatch + # Required to allow users to import from `ddtrace.contrib.dogpile_cache.patch` directly + from . import patch as _ # noqa: F401, I001 + + # Expose public methods + from ..internal.dogpile_cache.patch import get_version + from ..internal.dogpile_cache.patch import patch + from ..internal.dogpile_cache.patch import unpatch __all__ = ["patch", "unpatch", "get_version"] diff --git a/ddtrace/contrib/dogpile_cache/lock.py b/ddtrace/contrib/dogpile_cache/lock.py index 8bf1c0e535d..dd0c854738a 100644 --- a/ddtrace/contrib/dogpile_cache/lock.py +++ b/ddtrace/contrib/dogpile_cache/lock.py @@ -1,39 +1,15 @@ -import dogpile +from ddtrace.internal.utils.deprecations import DDTraceDeprecationWarning +from ddtrace.vendor.debtcollector import deprecate -from ...internal.utils.formats import asbool -from ...pin import Pin +from ..internal.dogpile_cache.lock import * # noqa: F401,F403 -def _wrap_lock_ctor(func, instance, args, kwargs): - """ - This seems rather odd. But to track hits, we need to patch the wrapped function that - dogpile passes to the region and locks. Unfortunately it's a closure defined inside - the get_or_create* methods themselves, so we can't easily patch those. - """ - func(*args, **kwargs) - ori_backend_fetcher = instance.value_and_created_fn +def __getattr__(name): + deprecate( + ("%s.%s is deprecated" % (__name__, name)), + category=DDTraceDeprecationWarning, + ) - def wrapped_backend_fetcher(): - pin = Pin.get_from(dogpile.cache) - if not pin or not pin.enabled(): - return ori_backend_fetcher() - - hit = False - expired = True - try: - value, createdtime = ori_backend_fetcher() - hit = value is not dogpile.cache.api.NO_VALUE - # dogpile sometimes returns None, but only checks for truthiness. Coalesce - # to minimize APM users' confusion. - expired = instance._is_expired(createdtime) or False - return value, createdtime - finally: - # Keys are checked in random order so the 'final' answer for partial hits - # should really be false (ie. if any are 'negative', then the tag value - # should be). This means ANDing all hit values and ORing all expired values. - span = pin.tracer.current_span() - if span: - span.set_tag("hit", asbool(span.get_tag("hit") or "True") and hit) - span.set_tag("expired", asbool(span.get_tag("expired") or "False") or expired) - - instance.value_and_created_fn = wrapped_backend_fetcher + if name in globals(): + return globals()[name] + raise AttributeError("%s has no attribute %s", __name__, name) diff --git a/ddtrace/contrib/dogpile_cache/patch.py b/ddtrace/contrib/dogpile_cache/patch.py index 162151231be..3b318db1125 100644 --- a/ddtrace/contrib/dogpile_cache/patch.py +++ b/ddtrace/contrib/dogpile_cache/patch.py @@ -1,52 +1,4 @@ -try: - import dogpile.cache as dogpile_cache - import dogpile.lock as dogpile_lock -except AttributeError: - from dogpile import cache as dogpile_cache - from dogpile import lock as dogpile_lock +from ..internal.dogpile_cache.patch import * # noqa: F401,F403 -from ddtrace.internal.schema import schematize_service_name -from ddtrace.pin import _DD_PIN_NAME -from ddtrace.pin import _DD_PIN_PROXY_NAME -from ddtrace.pin import Pin -from ddtrace.vendor.wrapt import wrap_function_wrapper as _w -from .lock import _wrap_lock_ctor -from .region import _wrap_get_create -from .region import _wrap_get_create_multi - - -_get_or_create = dogpile_cache.region.CacheRegion.get_or_create -_get_or_create_multi = dogpile_cache.region.CacheRegion.get_or_create_multi -_lock_ctor = dogpile_lock.Lock.__init__ - - -def get_version(): - # type: () -> str - return getattr(dogpile_cache, "__version__", "") - - -def patch(): - if getattr(dogpile_cache, "_datadog_patch", False): - return - dogpile_cache._datadog_patch = True - - _w("dogpile.cache.region", "CacheRegion.get_or_create", _wrap_get_create) - _w("dogpile.cache.region", "CacheRegion.get_or_create_multi", _wrap_get_create_multi) - _w("dogpile.lock", "Lock.__init__", _wrap_lock_ctor) - - Pin(service=schematize_service_name("dogpile.cache")).onto(dogpile_cache) - - -def unpatch(): - if not getattr(dogpile_cache, "_datadog_patch", False): - return - dogpile_cache._datadog_patch = False - # This looks silly but the unwrap util doesn't support class instance methods, even - # though wrapt does. This was causing the patches to stack on top of each other - # during testing. - dogpile_cache.region.CacheRegion.get_or_create = _get_or_create - dogpile_cache.region.CacheRegion.get_or_create_multi = _get_or_create_multi - dogpile_lock.Lock.__init__ = _lock_ctor - setattr(dogpile_cache, _DD_PIN_NAME, None) - setattr(dogpile_cache, _DD_PIN_PROXY_NAME, None) +# TODO: deprecate and remove this module diff --git a/ddtrace/contrib/dogpile_cache/region.py b/ddtrace/contrib/dogpile_cache/region.py index 28e8dbb816e..2d8273d214d 100644 --- a/ddtrace/contrib/dogpile_cache/region.py +++ b/ddtrace/contrib/dogpile_cache/region.py @@ -1,55 +1,15 @@ -import dogpile +from ddtrace.internal.utils.deprecations import DDTraceDeprecationWarning +from ddtrace.vendor.debtcollector import deprecate -from ddtrace.ext import SpanTypes -from ddtrace.internal.constants import COMPONENT +from ..internal.dogpile_cache.region import * # noqa: F401,F403 -from ...constants import SPAN_MEASURED_KEY -from ...ext import db -from ...internal.schema import schematize_cache_operation -from ...internal.schema import schematize_service_name -from ...internal.utils import get_argument_value -from ...pin import Pin +def __getattr__(name): + deprecate( + ("%s.%s is deprecated" % (__name__, name)), + category=DDTraceDeprecationWarning, + ) -def _wrap_get_create(func, instance, args, kwargs): - pin = Pin.get_from(dogpile.cache) - if not pin or not pin.enabled(): - return func(*args, **kwargs) - - key = get_argument_value(args, kwargs, 0, "key") - with pin.tracer.trace( - schematize_cache_operation("dogpile.cache", cache_provider="dogpile"), - service=schematize_service_name(None), - resource="get_or_create", - span_type=SpanTypes.CACHE, - ) as span: - span.set_tag_str(COMPONENT, "dogpile_cache") - span.set_tag(SPAN_MEASURED_KEY) - span.set_tag("key", key) - span.set_tag("region", instance.name) - span.set_tag("backend", instance.actual_backend.__class__.__name__) - response = func(*args, **kwargs) - span.set_metric(db.ROWCOUNT, 1) - return response - - -def _wrap_get_create_multi(func, instance, args, kwargs): - pin = Pin.get_from(dogpile.cache) - if not pin or not pin.enabled(): - return func(*args, **kwargs) - - keys = get_argument_value(args, kwargs, 0, "keys") - with pin.tracer.trace( - schematize_cache_operation("dogpile.cache", cache_provider="dogpile"), - service=schematize_service_name(None), - resource="get_or_create_multi", - span_type="cache", - ) as span: - span.set_tag_str(COMPONENT, "dogpile_cache") - span.set_tag(SPAN_MEASURED_KEY) - span.set_tag("keys", keys) - span.set_tag("region", instance.name) - span.set_tag("backend", instance.actual_backend.__class__.__name__) - response = func(*args, **kwargs) - span.set_metric(db.ROWCOUNT, len(response)) - return response + if name in globals(): + return globals()[name] + raise AttributeError("%s has no attribute %s", __name__, name) diff --git a/ddtrace/contrib/dramatiq/__init__.py b/ddtrace/contrib/dramatiq/__init__.py index 4eeb82819e1..bb48ef86eb5 100644 --- a/ddtrace/contrib/dramatiq/__init__.py +++ b/ddtrace/contrib/dramatiq/__init__.py @@ -35,8 +35,12 @@ def my_other_task(content): with require_modules(required_modules) as missing_modules: if not missing_modules: - from .patch import get_version - from .patch import patch - from .patch import unpatch + # Required to allow users to import from `ddtrace.contrib.dramatiq.patch` directly + from . import patch as _ # noqa: F401, I001 + + # Expose public methods + from ..internal.dramatiq.patch import get_version + from ..internal.dramatiq.patch import patch + from ..internal.dramatiq.patch import unpatch __all__ = ["patch", "unpatch", "get_version"] diff --git a/ddtrace/contrib/dramatiq/patch.py b/ddtrace/contrib/dramatiq/patch.py index 08daad9d93c..97af21978d1 100644 --- a/ddtrace/contrib/dramatiq/patch.py +++ b/ddtrace/contrib/dramatiq/patch.py @@ -1,72 +1,4 @@ -from typing import Any -from typing import Callable -from typing import Dict -from typing import Tuple +from ..internal.dramatiq.patch import * # noqa: F401,F403 -import dramatiq -from ddtrace import config -from ddtrace import tracer -from ddtrace.constants import SPAN_KIND -from ddtrace.contrib import trace_utils -from ddtrace.ext import SpanKind -from ddtrace.ext import SpanTypes -from ddtrace.settings.config import Config - - -def get_version() -> str: - return str(dramatiq.__version__) - - -def patch() -> None: - """ - Instrument dramatiq so any new Actor is automatically instrumented. - """ - if getattr(dramatiq, "__datadog_patch", False): - return - dramatiq.__datadog_patch = True - - trace_utils.wrap("dramatiq", "Actor.send_with_options", _traced_send_with_options_function(config.dramatiq)) - - -def unpatch() -> None: - """ - Disconnect remove tracing capabilities from dramatiq Actors - """ - if not getattr(dramatiq, "__datadog_patch", False): - return - dramatiq.__datadog_patch = False - - trace_utils.unwrap(dramatiq.Actor, "send_with_options") - - -def _traced_send_with_options_function(integration_config: Config) -> Callable[[Any], Any]: - """ - NOTE: This accounts for both the send() and send_with_options() methods, - since send() just wraps around send_with_options() with empty options. - - In terms of expected behavior, this traces the send_with_options() calls, - but does not reflect the actual execution time of the background task - itself. The duration of this span is the duration of the send_with_options() - call itself. - """ - - def _traced_send_with_options( - func: Callable[[Any], Any], instance: dramatiq.Actor, args: Tuple[Any], kwargs: Dict[Any, Any] - ) -> Callable[[Any], Any]: - with tracer.trace( - "dramatiq.Actor.send_with_options", - span_type=SpanTypes.WORKER, - service=trace_utils.ext_service(pin=None, int_config=integration_config), - ) as span: - span.set_tags( - { - SPAN_KIND: SpanKind.PRODUCER, - "actor.name": instance.actor_name, - "actor.options": instance.options, - } - ) - - return func(*args, **kwargs) - - return _traced_send_with_options +# TODO: deprecate and remove this module diff --git a/ddtrace/contrib/elasticsearch/__init__.py b/ddtrace/contrib/elasticsearch/__init__.py index bd0deb99c7e..a7649151c0d 100644 --- a/ddtrace/contrib/elasticsearch/__init__.py +++ b/ddtrace/contrib/elasticsearch/__init__.py @@ -49,9 +49,9 @@ # Override service name config.elasticsearch['service'] = 'custom-service-name' """ -from .patch import get_version -from .patch import get_versions -from .patch import patch +from ..internal.elasticsearch.patch import get_version +from ..internal.elasticsearch.patch import get_versions +from ..internal.elasticsearch.patch import patch __all__ = ["patch", "get_version", "get_versions"] diff --git a/ddtrace/contrib/elasticsearch/patch.py b/ddtrace/contrib/elasticsearch/patch.py index 1c8e43f079b..021518fc655 100644 --- a/ddtrace/contrib/elasticsearch/patch.py +++ b/ddtrace/contrib/elasticsearch/patch.py @@ -1,269 +1,4 @@ -from importlib import import_module -from typing import List # noqa:F401 +from ..internal.elasticsearch.patch import * # noqa: F401,F403 -from ddtrace import config -from ddtrace._trace import _limits -from ddtrace.contrib.trace_utils import ext_service -from ddtrace.contrib.trace_utils import extract_netloc_and_query_info_from_url -from ddtrace.ext import net -from ddtrace.internal.constants import COMPONENT -from ddtrace.internal.logger import get_logger -from ddtrace.vendor.wrapt import wrap_function_wrapper as _w -from ...constants import ANALYTICS_SAMPLE_RATE_KEY -from ...constants import SPAN_KIND -from ...constants import SPAN_MEASURED_KEY -from ...ext import SpanKind -from ...ext import SpanTypes -from ...ext import elasticsearch as metadata -from ...ext import http -from ...internal.compat import parse -from ...internal.schema import schematize_service_name -from ...internal.utils.wrappers import unwrap as _u -from ...pin import Pin -from .quantize import quantize - - -log = get_logger(__name__) - -config._add( - "elasticsearch", - { - "_default_service": schematize_service_name("elasticsearch"), - }, -) - - -def _es_modules(): - module_names = ( - "elasticsearch", - "elasticsearch1", - "elasticsearch2", - "elasticsearch5", - "elasticsearch6", - "elasticsearch7", - # Starting with version 8, the default transport which is what we - # actually patch is found in the separate elastic_transport package - "elastic_transport", - "opensearchpy", - ) - for module_name in module_names: - try: - module = import_module(module_name) - versions[module_name] = getattr(module, "__versionstr__", "") - yield module - except ImportError: - pass - - -versions = {} - - -def get_version_tuple(elasticsearch): - return getattr(elasticsearch, "__version__", "") - - -def get_version(): - # type: () -> str - return "" - - -def get_versions(): - # type: () -> List[str] - return versions - - -def _get_transport_module(elasticsearch): - try: - # elasticsearch7/opensearch async - return elasticsearch._async.transport - except AttributeError: - try: - # elasticsearch<8/opensearch sync - return elasticsearch.transport - except AttributeError: - # elastic_transport (elasticsearch8) - return elasticsearch - - -# NB: We are patching the default elasticsearch transport module -def patch(): - for elasticsearch in _es_modules(): - _patch(_get_transport_module(elasticsearch)) - - -def _patch(transport): - if getattr(transport, "_datadog_patch", False): - return - if hasattr(transport, "Transport"): - transport._datadog_patch = True - _w(transport.Transport, "perform_request", _get_perform_request(transport)) - Pin().onto(transport.Transport) - if hasattr(transport, "AsyncTransport"): - transport._datadog_patch = True - _w(transport.AsyncTransport, "perform_request", _get_perform_request_async(transport)) - Pin().onto(transport.AsyncTransport) - - -def unpatch(): - for elasticsearch in _es_modules(): - _unpatch(_get_transport_module(elasticsearch)) - - -def _unpatch(transport): - if not getattr(transport, "_datadog_patch", False): - return - for classname in ("Transport", "AsyncTransport"): - try: - cls = getattr(transport, classname) - except AttributeError: - continue - transport._datadog_patch = False - _u(cls, "perform_request") - - -def _get_perform_request_coro(transport): - def _perform_request(func, instance, args, kwargs): - pin = Pin.get_from(instance) - if not pin or not pin.enabled(): - yield func(*args, **kwargs) - return - - with pin.tracer.trace( - "elasticsearch.query", service=ext_service(pin, config.elasticsearch), span_type=SpanTypes.ELASTICSEARCH - ) as span: - if pin.tags: - span.set_tags(pin.tags) - - span.set_tag_str(COMPONENT, config.elasticsearch.integration_name) - - # set span.kind to the type of request being performed - span.set_tag_str(SPAN_KIND, SpanKind.CLIENT) - - span.set_tag(SPAN_MEASURED_KEY) - - # Only instrument if trace is sampled or if we haven't tried to sample yet - if span.context.sampling_priority is not None and span.context.sampling_priority <= 0: - yield func(*args, **kwargs) - return - - method, target = args - params = kwargs.get("params") - body = kwargs.get("body") - - # elastic_transport gets target url with query params already appended - parsed = parse.urlparse(target) - url = parsed.path - if params: - encoded_params = parse.urlencode(params) - else: - encoded_params = parsed.query - - span.set_tag_str(metadata.METHOD, method) - span.set_tag_str(metadata.URL, url) - span.set_tag_str(metadata.PARAMS, encoded_params) - try: - # elasticsearch<8 - connections = instance.connection_pool.connections - except AttributeError: - # elastic_transport - connections = instance.node_pool.all() - for connection in connections: - hostname, _ = extract_netloc_and_query_info_from_url(connection.host) - if hostname: - span.set_tag_str(net.TARGET_HOST, hostname) - break - - if config.elasticsearch.trace_query_string: - span.set_tag_str(http.QUERY_STRING, encoded_params) - - if method in ["GET", "POST"]: - try: - # elasticsearch<8 - ser_body = instance.serializer.dumps(body) - except AttributeError: - # elastic_transport - ser_body = instance.serializers.dumps(body) - # Elasticsearch request bodies can be very large resulting in traces being too large - # to send. - # When this occurs, drop the value. - # Ideally the body should be truncated, however we cannot truncate as the obfuscation - # logic for the body lives in the agent and truncating would make the body undecodable. - if len(ser_body) <= _limits.MAX_SPAN_META_VALUE_LEN: - span.set_tag_str(metadata.BODY, ser_body) - else: - span.set_tag_str( - metadata.BODY, - "" % (len(ser_body), _limits.MAX_SPAN_META_VALUE_LEN), - ) - status = None - - # set analytics sample rate - span.set_tag(ANALYTICS_SAMPLE_RATE_KEY, config.elasticsearch.get_analytics_sample_rate()) - - span = quantize(span) - - try: - result = yield func(*args, **kwargs) - except transport.TransportError as e: - span.set_tag(http.STATUS_CODE, getattr(e, "status_code", 500)) - span.error = 1 - raise - - try: - # Optional metadata extraction with soft fail. - if isinstance(result, tuple): - try: - # elastic_transport returns a named tuple - meta, data = result.meta, result.body - status = meta.status - except AttributeError: - # elasticsearch<2.4; it returns both the status and the body - status, data = result - else: - # elasticsearch>=2.4,<8; internal change for ``Transport.perform_request`` - # that just returns the body - data = result - - took = data.get("took") - if took: - span.set_metric(metadata.TOOK, int(took)) - except Exception: - log.debug("Unexpected exception", exc_info=True) - - if status: - span.set_tag(http.STATUS_CODE, status) - - return - - return _perform_request - - -def _get_perform_request(transport): - _perform_request_coro = _get_perform_request_coro(transport) - - def _perform_request(func, instance, args, kwargs): - coro = _perform_request_coro(func, instance, args, kwargs) - result = next(coro) - try: - coro.send(result) - except StopIteration: - pass - return result - - return _perform_request - - -def _get_perform_request_async(transport): - _perform_request_coro = _get_perform_request_coro(transport) - - async def _perform_request(func, instance, args, kwargs): - coro = _perform_request_coro(func, instance, args, kwargs) - result = await next(coro) - try: - coro.send(result) - except StopIteration: - pass - return result - - return _perform_request +# TODO: deprecate and remove this module diff --git a/ddtrace/contrib/elasticsearch/quantize.py b/ddtrace/contrib/elasticsearch/quantize.py index 5b526a3fae9..170608be47e 100644 --- a/ddtrace/contrib/elasticsearch/quantize.py +++ b/ddtrace/contrib/elasticsearch/quantize.py @@ -1,35 +1,15 @@ -import re +from ddtrace.internal.utils.deprecations import DDTraceDeprecationWarning +from ddtrace.vendor.debtcollector import deprecate -from ...ext import elasticsearch as metadata +from ..internal.elasticsearch.quantize import * # noqa: F401,F403 -# Replace any ID -ID_REGEXP = re.compile(r"/([0-9]+)([/\?]|$)") -ID_PLACEHOLDER = r"/?\2" +def __getattr__(name): + deprecate( + ("%s.%s is deprecated" % (__name__, name)), + category=DDTraceDeprecationWarning, + ) -# Remove digits from potential timestamped indexes (should be an option). -# For now, let's say 2+ digits -INDEX_REGEXP = re.compile(r"[0-9]{2,}") -INDEX_PLACEHOLDER = r"?" - - -def quantize(span): - """Quantize an elasticsearch span - - We want to extract a meaningful `resource` from the request. - We do it based on the method + url, with some cleanup applied to the URL. - - The URL might a ID, but also it is common to have timestamped indexes. - While the first is easy to catch, the second should probably be configurable. - - All of this should probably be done in the Agent. Later. - """ - url = span.get_tag(metadata.URL) - method = span.get_tag(metadata.METHOD) - - quantized_url = ID_REGEXP.sub(ID_PLACEHOLDER, url) - quantized_url = INDEX_REGEXP.sub(INDEX_PLACEHOLDER, quantized_url) - - span.resource = "{method} {url}".format(method=method, url=quantized_url) - - return span + if name in globals(): + return globals()[name] + raise AttributeError("%s has no attribute %s", __name__, name) diff --git a/ddtrace/contrib/falcon/__init__.py b/ddtrace/contrib/falcon/__init__.py index 41fdf5533d4..363acf9edd2 100644 --- a/ddtrace/contrib/falcon/__init__.py +++ b/ddtrace/contrib/falcon/__init__.py @@ -51,8 +51,10 @@ def on_falcon_request(span, request, response): with require_modules(required_modules) as missing_modules: if not missing_modules: - from .middleware import TraceMiddleware - from .patch import get_version - from .patch import patch + # Required to allow users to import from `ddtrace.contrib.falcon.patch` directly + from . import patch as _ # noqa: F401, I001 + from ..internal.falcon.middleware import TraceMiddleware + from ..internal.falcon.patch import get_version + from ..internal.falcon.patch import patch __all__ = ["TraceMiddleware", "patch", "get_version"] diff --git a/ddtrace/contrib/falcon/middleware.py b/ddtrace/contrib/falcon/middleware.py index ba398f02bec..0844b9ad81c 100644 --- a/ddtrace/contrib/falcon/middleware.py +++ b/ddtrace/contrib/falcon/middleware.py @@ -1,123 +1,15 @@ -import sys +from ddtrace.internal.utils.deprecations import DDTraceDeprecationWarning +from ddtrace.vendor.debtcollector import deprecate -from ddtrace import config -from ddtrace.ext import SpanKind -from ddtrace.ext import SpanTypes -from ddtrace.ext import http as httpx -from ddtrace.internal.constants import COMPONENT +from ..internal.falcon.middleware import * # noqa: F401,F403 -from ...constants import ANALYTICS_SAMPLE_RATE_KEY -from ...constants import SPAN_KIND -from ...constants import SPAN_MEASURED_KEY -from ...internal.schema import SpanDirection -from ...internal.schema import schematize_service_name -from ...internal.schema import schematize_url_operation -from .. import trace_utils +def __getattr__(name): + deprecate( + ("%s.%s is deprecated" % (__name__, name)), + category=DDTraceDeprecationWarning, + ) -class TraceMiddleware(object): - def __init__(self, tracer, service=None, distributed_tracing=None): - if service is None: - service = schematize_service_name("falcon") - # store tracing references - self.tracer = tracer - self.service = service - if distributed_tracing is not None: - config.falcon["distributed_tracing"] = distributed_tracing - - def process_request(self, req, resp): - # Falcon uppercases all header names. - headers = dict((k.lower(), v) for k, v in req.headers.items()) - trace_utils.activate_distributed_headers(self.tracer, int_config=config.falcon, request_headers=headers) - - span = self.tracer.trace( - schematize_url_operation("falcon.request", protocol="http", direction=SpanDirection.INBOUND), - service=self.service, - span_type=SpanTypes.WEB, - ) - span.set_tag_str(COMPONENT, config.falcon.integration_name) - - # set span.kind to the type of operation being performed - span.set_tag_str(SPAN_KIND, SpanKind.SERVER) - - span.set_tag(SPAN_MEASURED_KEY) - - # set analytics sample rate with global config enabled - span.set_tag(ANALYTICS_SAMPLE_RATE_KEY, config.falcon.get_analytics_sample_rate(use_global_config=True)) - - trace_utils.set_http_meta( - span, config.falcon, method=req.method, url=req.url, query=req.query_string, request_headers=req.headers - ) - - def process_resource(self, req, resp, resource, params): - span = self.tracer.current_span() - if not span: - return # unexpected - span.resource = "%s %s" % (req.method, _name(resource)) - - def process_response(self, req, resp, resource, req_succeeded=None): - # req_succeded is not a kwarg in the API, but we need that to support - # Falcon 1.0 that doesn't provide this argument - span = self.tracer.current_span() - if not span: - return # unexpected - - status = resp.status.partition(" ")[0] - - # falcon does not map errors or unmatched routes - # to proper status codes, so we have to try to infer them - # here. - if resource is None: - status = "404" - span.resource = "%s 404" % req.method - span.set_tag(httpx.STATUS_CODE, status) - span.finish() - return - - err_type = sys.exc_info()[0] - if err_type is not None: - if req_succeeded is None: - # backward-compatibility with Falcon 1.0; any version - # greater than 1.0 has req_succeded in [True, False] - # TODO[manu]: drop the support at some point - status = _detect_and_set_status_error(err_type, span) - elif req_succeeded is False: - # Falcon 1.1+ provides that argument that is set to False - # if get an Exception (404 is still an exception) - status = _detect_and_set_status_error(err_type, span) - - route = req.root_path or "" + req.uri_template - - trace_utils.set_http_meta( - span, - config.falcon, - status_code=status, - response_headers=resp._headers, - route=route, - ) - - # Emit span hook for this response - # DEV: Emit before closing so they can overwrite `span.resource` if they want - config.falcon.hooks.emit("request", span, req, resp) - - # Close the span - span.finish() - - -def _is_404(err_type): - return "HTTPNotFound" in err_type.__name__ - - -def _detect_and_set_status_error(err_type, span): - """Detect the HTTP status code from the current stacktrace and - set the traceback to the given Span - """ - if not _is_404(err_type): - span.set_traceback() - return "500" - elif _is_404(err_type): - return "404" - - -def _name(r): - return "%s.%s" % (r.__module__, r.__class__.__name__) + if name in globals(): + return globals()[name] + raise AttributeError("%s has no attribute %s", __name__, name) diff --git a/ddtrace/contrib/falcon/patch.py b/ddtrace/contrib/falcon/patch.py index d8deeb6cdb3..13ec3f93779 100644 --- a/ddtrace/contrib/falcon/patch.py +++ b/ddtrace/contrib/falcon/patch.py @@ -1,52 +1,4 @@ -import os +from ..internal.falcon.patch import * # noqa: F401,F403 -import falcon -from ddtrace import config -from ddtrace import tracer -from ddtrace.vendor import wrapt - -from ...internal.utils.formats import asbool -from ...internal.utils.version import parse_version -from .middleware import TraceMiddleware - - -FALCON_VERSION = parse_version(falcon.__version__) - - -config._add( - "falcon", - dict( - distributed_tracing=asbool(os.getenv("DD_FALCON_DISTRIBUTED_TRACING", default=True)), - ), -) - - -def get_version(): - # type: () -> str - return getattr(falcon, "__version__", "") - - -def patch(): - """ - Patch falcon.API to include contrib.falcon.TraceMiddleware - by default - """ - if getattr(falcon, "_datadog_patch", False): - return - - falcon._datadog_patch = True - if FALCON_VERSION >= (3, 0, 0): - wrapt.wrap_function_wrapper("falcon", "App.__init__", traced_init) - if FALCON_VERSION < (4, 0, 0): - wrapt.wrap_function_wrapper("falcon", "API.__init__", traced_init) - - -def traced_init(wrapped, instance, args, kwargs): - mw = kwargs.pop("middleware", []) - service = config._get_service(default="falcon") - - mw.insert(0, TraceMiddleware(tracer, service)) - kwargs["middleware"] = mw - - wrapped(*args, **kwargs) +# TODO: deprecate and remove this module diff --git a/ddtrace/contrib/internal/django/_asgi.py b/ddtrace/contrib/internal/django/_asgi.py new file mode 100644 index 00000000000..a0dc8ff4cc2 --- /dev/null +++ b/ddtrace/contrib/internal/django/_asgi.py @@ -0,0 +1,35 @@ +""" +Module providing async hooks. Do not import this module unless using Python >= 3.6. +""" +from ddtrace.contrib import trace_utils +from ddtrace.contrib.asgi import span_from_scope +from ddtrace.contrib.internal.django.utils import REQUEST_DEFAULT_RESOURCE +from ddtrace.contrib.internal.django.utils import _after_request_tags +from ddtrace.contrib.internal.django.utils import _before_request_tags +from ddtrace.internal.utils import get_argument_value + + +@trace_utils.with_traced_module +async def traced_get_response_async(django, pin, func, instance, args, kwargs): + """Trace django.core.handlers.base.BaseHandler.get_response() (or other implementations). + + This is the main entry point for requests. + + Django requests are handled by a Handler.get_response method (inherited from base.BaseHandler). + This method invokes the middleware chain and returns the response generated by the chain. + """ + request = get_argument_value(args, kwargs, 0, "request") + span = span_from_scope(request.scope) + if span is None: + return await func(*args, **kwargs) + + # Reset the span resource so we can know if it was modified during the request or not + span.resource = REQUEST_DEFAULT_RESOURCE + _before_request_tags(pin, span, request) + response = None + try: + response = await func(*args, **kwargs) + finally: + # DEV: Always set these tags, this is where `span.resource` is set + _after_request_tags(pin, span, request, response) + return response diff --git a/ddtrace/contrib/internal/django/compat.py b/ddtrace/contrib/internal/django/compat.py new file mode 100644 index 00000000000..20f0a52fa8a --- /dev/null +++ b/ddtrace/contrib/internal/django/compat.py @@ -0,0 +1,31 @@ +import django + + +if django.VERSION >= (1, 10, 1): + from django.urls import get_resolver + + def user_is_authenticated(user): + # Explicit comparison due to the following bug + # https://code.djangoproject.com/ticket/26988 + return user.is_authenticated == True # noqa E712 + +else: + from django.conf import settings + from django.core import urlresolvers + + def user_is_authenticated(user): + return user.is_authenticated() + + if django.VERSION >= (1, 9, 0): + + def get_resolver(urlconf=None): + urlconf = urlconf or settings.ROOT_URLCONF + urlresolvers.set_urlconf(urlconf) + return urlresolvers.get_resolver(urlconf) + + else: + + def get_resolver(urlconf=None): + urlconf = urlconf or settings.ROOT_URLCONF + urlresolvers.set_urlconf(urlconf) + return urlresolvers.RegexURLResolver(r"^/", urlconf) diff --git a/ddtrace/contrib/internal/django/patch.py b/ddtrace/contrib/internal/django/patch.py new file mode 100644 index 00000000000..59f64f89ab9 --- /dev/null +++ b/ddtrace/contrib/internal/django/patch.py @@ -0,0 +1,929 @@ +""" +The Django patching works as follows: + +Django internals are instrumented via normal `patch()`. + +`django.apps.registry.Apps.populate` is patched to add instrumentation for any +specific Django apps like Django Rest Framework (DRF). +""" +import functools +from inspect import getmro +from inspect import isclass +from inspect import isfunction +import os + +from ddtrace import Pin +from ddtrace import config +from ddtrace._trace.trace_handlers import _ctype_from_headers +from ddtrace.appsec._utils import _UserInfoRetriever +from ddtrace.constants import SPAN_KIND +from ddtrace.contrib import dbapi +from ddtrace.contrib import func_name +from ddtrace.contrib import trace_utils +from ddtrace.contrib.trace_utils import _get_request_header_user_agent +from ddtrace.ext import SpanKind +from ddtrace.ext import SpanTypes +from ddtrace.ext import db +from ddtrace.ext import http +from ddtrace.ext import net +from ddtrace.ext import sql as sqlx +from ddtrace.internal import core +from ddtrace.internal._exceptions import BlockingException +from ddtrace.internal.compat import Iterable +from ddtrace.internal.compat import maybe_stringify +from ddtrace.internal.constants import COMPONENT +from ddtrace.internal.constants import HTTP_REQUEST_BLOCKED +from ddtrace.internal.constants import STATUS_403_TYPE_AUTO +from ddtrace.internal.core.event_hub import ResultType +from ddtrace.internal.logger import get_logger +from ddtrace.internal.schema import schematize_service_name +from ddtrace.internal.schema import schematize_url_operation +from ddtrace.internal.schema.span_attribute_schema import SpanDirection +from ddtrace.internal.utils import get_argument_value +from ddtrace.internal.utils import http as http_utils +from ddtrace.internal.utils.formats import asbool +from ddtrace.propagation._database_monitoring import _DBM_Propagator +from ddtrace.settings.asm import config as asm_config +from ddtrace.settings.integration import IntegrationConfig +from ddtrace.vendor import wrapt +from ddtrace.vendor.packaging.version import parse as parse_version +from ddtrace.vendor.wrapt.importer import when_imported + + +log = get_logger(__name__) + +config._add( + "django", + dict( + _default_service=schematize_service_name("django"), + cache_service_name=os.getenv("DD_DJANGO_CACHE_SERVICE_NAME", default="django"), + database_service_name_prefix=os.getenv("DD_DJANGO_DATABASE_SERVICE_NAME_PREFIX", default=""), + database_service_name=os.getenv("DD_DJANGO_DATABASE_SERVICE_NAME", default=""), + trace_fetch_methods=asbool(os.getenv("DD_DJANGO_TRACE_FETCH_METHODS", default=False)), + distributed_tracing_enabled=True, + instrument_middleware=asbool(os.getenv("DD_DJANGO_INSTRUMENT_MIDDLEWARE", default=True)), + instrument_templates=asbool(os.getenv("DD_DJANGO_INSTRUMENT_TEMPLATES", default=True)), + instrument_databases=asbool(os.getenv("DD_DJANGO_INSTRUMENT_DATABASES", default=True)), + instrument_caches=asbool(os.getenv("DD_DJANGO_INSTRUMENT_CACHES", default=True)), + analytics_enabled=None, # None allows the value to be overridden by the global config + analytics_sample_rate=None, + trace_query_string=None, # Default to global config + include_user_name=asbool(os.getenv("DD_DJANGO_INCLUDE_USER_NAME", default=True)), + use_handler_with_url_name_resource_format=asbool( + os.getenv("DD_DJANGO_USE_HANDLER_WITH_URL_NAME_RESOURCE_FORMAT", default=False) + ), + use_handler_resource_format=asbool(os.getenv("DD_DJANGO_USE_HANDLER_RESOURCE_FORMAT", default=False)), + use_legacy_resource_format=asbool(os.getenv("DD_DJANGO_USE_LEGACY_RESOURCE_FORMAT", default=False)), + _trace_asgi_websocket=os.getenv("DD_ASGI_TRACE_WEBSOCKET", default=False), + ), +) + +_NotSet = object() +psycopg_cursor_cls = Psycopg2TracedCursor = Psycopg3TracedCursor = _NotSet + + +DB_CONN_ATTR_BY_TAG = { + net.TARGET_HOST: "HOST", + net.TARGET_PORT: "PORT", + db.USER: "USER", + db.NAME: "NAME", +} + + +def get_version(): + # type: () -> str + import django + + return django.__version__ + + +def patch_conn(django, conn): + global psycopg_cursor_cls, Psycopg2TracedCursor, Psycopg3TracedCursor + + if psycopg_cursor_cls is _NotSet: + try: + from psycopg.cursor import Cursor as psycopg_cursor_cls + + from ddtrace.contrib.psycopg.cursor import Psycopg3TracedCursor + except ImportError: + Psycopg3TracedCursor = None + try: + from psycopg2._psycopg import cursor as psycopg_cursor_cls + + from ddtrace.contrib.psycopg.cursor import Psycopg2TracedCursor + except ImportError: + psycopg_cursor_cls = None + Psycopg2TracedCursor = None + + tags = {} + settings_dict = getattr(conn, "settings_dict", {}) + for tag, attr in DB_CONN_ATTR_BY_TAG.items(): + if attr in settings_dict: + tags[tag] = trace_utils._convert_to_string(conn.settings_dict.get(attr)) + + conn._datadog_tags = tags + + def cursor(django, pin, func, instance, args, kwargs): + alias = getattr(conn, "alias", "default") + + if config.django.database_service_name: + service = config.django.database_service_name + else: + database_prefix = config.django.database_service_name_prefix + service = "{}{}{}".format(database_prefix, alias, "db") + service = schematize_service_name(service) + + vendor = getattr(conn, "vendor", "db") + prefix = sqlx.normalize_vendor(vendor) + + tags = {"django.db.vendor": vendor, "django.db.alias": alias} + tags.update(getattr(conn, "_datadog_tags", {})) + + pin = Pin(service, tags=tags, tracer=pin.tracer) + + cursor = func(*args, **kwargs) + + traced_cursor_cls = dbapi.TracedCursor + try: + if cursor.cursor.__class__.__module__.startswith("psycopg2."): + # Import lazily to avoid importing psycopg2 if not already imported. + from ddtrace.contrib.psycopg.cursor import Psycopg2TracedCursor + + traced_cursor_cls = Psycopg2TracedCursor + elif type(cursor.cursor).__name__ == "Psycopg3TracedCursor": + # Import lazily to avoid importing psycopg if not already imported. + from ddtrace.contrib.psycopg.cursor import Psycopg3TracedCursor + + traced_cursor_cls = Psycopg3TracedCursor + except AttributeError: + pass + + # Each db alias will need its own config for dbapi + cfg = IntegrationConfig( + config.django.global_config, # global_config needed for analytics sample rate + "{}-{}".format("django", alias), # name not used but set anyway + _default_service=config.django._default_service, + _dbapi_span_name_prefix=prefix, + trace_fetch_methods=config.django.trace_fetch_methods, + analytics_enabled=config.django.analytics_enabled, + analytics_sample_rate=config.django.analytics_sample_rate, + _dbm_propagator=_DBM_Propagator(0, "query"), + ) + return traced_cursor_cls(cursor, pin, cfg) + + if not isinstance(conn.cursor, wrapt.ObjectProxy): + conn.cursor = wrapt.FunctionWrapper(conn.cursor, trace_utils.with_traced_module(cursor)(django)) + + +def instrument_dbs(django): + def get_connection(wrapped, instance, args, kwargs): + conn = wrapped(*args, **kwargs) + try: + patch_conn(django, conn) + except Exception: + log.debug("Error instrumenting database connection %r", conn, exc_info=True) + return conn + + if not isinstance(django.db.utils.ConnectionHandler.__getitem__, wrapt.ObjectProxy): + django.db.utils.ConnectionHandler.__getitem__ = wrapt.FunctionWrapper( + django.db.utils.ConnectionHandler.__getitem__, get_connection + ) + + +@trace_utils.with_traced_module +def traced_cache(django, pin, func, instance, args, kwargs): + from . import utils + + if not config.django.instrument_caches: + return func(*args, **kwargs) + + cache_backend = "{}.{}".format(instance.__module__, instance.__class__.__name__) + tags = {COMPONENT: config.django.integration_name, "django.cache.backend": cache_backend} + if args: + keys = utils.quantize_key_values(args[0]) + tags["django.cache.key"] = keys + + with core.context_with_data( + "django.cache", + span_name="django.cache", + span_type=SpanTypes.CACHE, + service=config.django.cache_service_name, + resource=utils.resource_from_cache_prefix(func_name(func), instance), + tags=tags, + pin=pin, + ) as ctx, ctx["call"]: + result = func(*args, **kwargs) + rowcount = 0 + if func.__name__ == "get_many": + rowcount = sum(1 for doc in result if doc) if result and isinstance(result, Iterable) else 0 + elif func.__name__ == "get": + try: + # check also for special case for Django~3.2 that returns an empty Sentinel + # object for empty results + # also check if result is Iterable first since some iterables return ambiguous + # truth results with ``==`` + if result is None or ( + not isinstance(result, Iterable) and result == getattr(instance, "_missing_key", None) + ): + rowcount = 0 + else: + rowcount = 1 + except (AttributeError, NotImplementedError, ValueError): + pass + core.dispatch("django.cache", (ctx, rowcount)) + return result + + +def instrument_caches(django): + cache_backends = set([cache["BACKEND"] for cache in django.conf.settings.CACHES.values()]) + for cache_path in cache_backends: + split = cache_path.split(".") + cache_module = ".".join(split[:-1]) + cache_cls = split[-1] + for method in ["get", "set", "add", "delete", "incr", "decr", "get_many", "set_many", "delete_many"]: + try: + cls = django.utils.module_loading.import_string(cache_path) + # DEV: this can be removed when we add an idempotent `wrap` + if not trace_utils.iswrapped(cls, method): + trace_utils.wrap(cache_module, "{0}.{1}".format(cache_cls, method), traced_cache(django)) + except Exception: + log.debug("Error instrumenting cache %r", cache_path, exc_info=True) + + +@trace_utils.with_traced_module +def traced_populate(django, pin, func, instance, args, kwargs): + """django.apps.registry.Apps.populate is the method used to populate all the apps. + + It is used as a hook to install instrumentation for 3rd party apps (like DRF). + + `populate()` works in 3 phases: + + - Phase 1: Initializes the app configs and imports the app modules. + - Phase 2: Imports models modules for each app. + - Phase 3: runs ready() of each app config. + + If all 3 phases successfully run then `instance.ready` will be `True`. + """ + + # populate() can be called multiple times, we don't want to instrument more than once + if instance.ready: + log.debug("Django instrumentation already installed, skipping.") + return func(*args, **kwargs) + + ret = func(*args, **kwargs) + + if not instance.ready: + log.debug("populate() failed skipping instrumentation.") + return ret + + settings = django.conf.settings + + # Instrument databases + if config.django.instrument_databases: + try: + instrument_dbs(django) + except Exception: + log.debug("Error instrumenting Django database connections", exc_info=True) + + # Instrument caches + if config.django.instrument_caches: + try: + instrument_caches(django) + except Exception: + log.debug("Error instrumenting Django caches", exc_info=True) + + # Instrument Django Rest Framework if it's installed + INSTALLED_APPS = getattr(settings, "INSTALLED_APPS", []) + + if "rest_framework" in INSTALLED_APPS: + try: + from .restframework import patch_restframework + + patch_restframework(django) + except Exception: + log.debug("Error patching rest_framework", exc_info=True) + + return ret + + +def traced_func(django, name, resource=None, ignored_excs=None): + def wrapped(django, pin, func, instance, args, kwargs): + tags = {COMPONENT: config.django.integration_name} + with core.context_with_data( + "django.func.wrapped", span_name=name, resource=resource, tags=tags, pin=pin + ) as ctx, ctx["call"]: + core.dispatch( + "django.func.wrapped", + ( + args, + kwargs, + django.core.handlers.wsgi.WSGIRequest if hasattr(django.core.handlers, "wsgi") else object, + ctx, + ignored_excs, + ), + ) + return func(*args, **kwargs) + + return trace_utils.with_traced_module(wrapped)(django) + + +def traced_process_exception(django, name, resource=None): + def wrapped(django, pin, func, instance, args, kwargs): + tags = {COMPONENT: config.django.integration_name} + with core.context_with_data( + "django.process_exception", span_name=name, resource=resource, tags=tags, pin=pin + ) as ctx, ctx["call"]: + resp = func(*args, **kwargs) + core.dispatch( + "django.process_exception", (ctx, hasattr(resp, "status_code") and 500 <= resp.status_code < 600) + ) + return resp + + return trace_utils.with_traced_module(wrapped)(django) + + +@trace_utils.with_traced_module +def traced_load_middleware(django, pin, func, instance, args, kwargs): + """ + Patches django.core.handlers.base.BaseHandler.load_middleware to instrument all + middlewares. + """ + settings_middleware = [] + # Gather all the middleware + if getattr(django.conf.settings, "MIDDLEWARE", None): + settings_middleware += django.conf.settings.MIDDLEWARE + if getattr(django.conf.settings, "MIDDLEWARE_CLASSES", None): + settings_middleware += django.conf.settings.MIDDLEWARE_CLASSES + + # Iterate over each middleware provided in settings.py + # Each middleware can either be a function or a class + for mw_path in settings_middleware: + mw = django.utils.module_loading.import_string(mw_path) + + # Instrument function-based middleware + if isfunction(mw) and not trace_utils.iswrapped(mw): + split = mw_path.split(".") + if len(split) < 2: + continue + base = ".".join(split[:-1]) + attr = split[-1] + + # DEV: We need to have a closure over `mw_path` for the resource name or else + # all function based middleware will share the same resource name + def _wrapper(resource): + # Function-based middleware is a factory which returns a handler function for + # requests. + # So instead of tracing the factory, we want to trace its returned value. + # We wrap the factory to return a traced version of the handler function. + def wrapped_factory(func, instance, args, kwargs): + # r is the middleware handler function returned from the factory + r = func(*args, **kwargs) + if r: + return wrapt.FunctionWrapper( + r, + traced_func(django, "django.middleware", resource=resource), + ) + # If r is an empty middleware function (i.e. returns None), don't wrap since + # NoneType cannot be called + else: + return r + + return wrapped_factory + + trace_utils.wrap(base, attr, _wrapper(resource=mw_path)) + + # Instrument class-based middleware + elif isclass(mw): + for hook in [ + "process_request", + "process_response", + "process_view", + "process_template_response", + "__call__", + ]: + if hasattr(mw, hook) and not trace_utils.iswrapped(mw, hook): + trace_utils.wrap( + mw, hook, traced_func(django, "django.middleware", resource=mw_path + ".{0}".format(hook)) + ) + # Do a little extra for `process_exception` + if hasattr(mw, "process_exception") and not trace_utils.iswrapped(mw, "process_exception"): + res = mw_path + ".{0}".format("process_exception") + trace_utils.wrap( + mw, "process_exception", traced_process_exception(django, "django.middleware", resource=res) + ) + + return func(*args, **kwargs) + + +def _gather_block_metadata(request, request_headers, ctx: core.ExecutionContext): + from . import utils + + try: + metadata = {http.STATUS_CODE: "403", http.METHOD: request.method} + url = utils.get_request_uri(request) + query = request.META.get("QUERY_STRING", "") + if query and config.django.trace_query_string: + metadata[http.QUERY_STRING] = query + user_agent = _get_request_header_user_agent(request_headers) + if user_agent: + metadata[http.USER_AGENT] = user_agent + except Exception as e: + log.warning("Could not gather some metadata on blocked request: %s", str(e)) # noqa: G200 + core.dispatch("django.block_request_callback", (ctx, metadata, config.django, url, query)) + + +def _block_request_callable(request, request_headers, ctx: core.ExecutionContext): + # This is used by user-id blocking to block responses. It could be called + # at any point so it's a callable stored in the ASM context. + from django.core.exceptions import PermissionDenied + + core.root.set_item(HTTP_REQUEST_BLOCKED, STATUS_403_TYPE_AUTO) + _gather_block_metadata(request, request_headers, ctx) + raise PermissionDenied() + + +@trace_utils.with_traced_module +def traced_get_response(django, pin, func, instance, args, kwargs): + """Trace django.core.handlers.base.BaseHandler.get_response() (or other implementations). + + This is the main entry point for requests. + + Django requests are handled by a Handler.get_response method (inherited from base.BaseHandler). + This method invokes the middleware chain and returns the response generated by the chain. + """ + from ddtrace.contrib.internal.django.compat import get_resolver + + from . import utils + + request = get_argument_value(args, kwargs, 0, "request") + if request is None: + return func(*args, **kwargs) + + request_headers = utils._get_request_headers(request) + + with core.context_with_data( + "django.traced_get_response", + remote_addr=request.META.get("REMOTE_ADDR"), + headers=request_headers, + headers_case_sensitive=django.VERSION < (2, 2), + span_name=schematize_url_operation("django.request", protocol="http", direction=SpanDirection.INBOUND), + resource=utils.REQUEST_DEFAULT_RESOURCE, + service=trace_utils.int_service(pin, config.django), + span_type=SpanTypes.WEB, + tags={COMPONENT: config.django.integration_name, SPAN_KIND: SpanKind.SERVER}, + distributed_headers_config=config.django, + distributed_headers=request_headers, + pin=pin, + ) as ctx, ctx.get_item("call"): + core.dispatch( + "django.traced_get_response.pre", + ( + functools.partial(_block_request_callable, request, request_headers, ctx), + ctx, + request, + utils._before_request_tags, + ), + ) + + response = None + + def blocked_response(): + from django.http import HttpResponse + + block_config = core.get_item(HTTP_REQUEST_BLOCKED) or {} + desired_type = block_config.get("type", "auto") + status = block_config.get("status_code", 403) + if desired_type == "none": + response = HttpResponse("", status=status) + location = block_config.get("location", "") + if location: + response["location"] = location + else: + ctype = _ctype_from_headers(block_config, request_headers) + content = http_utils._get_blocked_template(ctype) + response = HttpResponse(content, content_type=ctype, status=status) + response.content = content + response["Content-Length"] = len(content.encode()) + utils._after_request_tags(pin, ctx["call"], request, response) + return response + + try: + if core.get_item(HTTP_REQUEST_BLOCKED): + response = blocked_response() + return response + + query = request.META.get("QUERY_STRING", "") + uri = utils.get_request_uri(request) + if uri is not None and query: + uri += "?" + query + resolver = get_resolver(getattr(request, "urlconf", None)) + if resolver: + try: + path = resolver.resolve(request.path_info).kwargs + log.debug("resolver.pattern %s", path) + except Exception: + path = None + + core.dispatch("django.start_response", (ctx, request, utils._extract_body, query, uri, path)) + core.dispatch("django.start_response.post", ("Django",)) + + if core.get_item(HTTP_REQUEST_BLOCKED): + response = blocked_response() + return response + + try: + response = func(*args, **kwargs) + except BlockingException as e: + core.set_item(HTTP_REQUEST_BLOCKED, e.args[0]) + response = blocked_response() + return response + + if core.get_item(HTTP_REQUEST_BLOCKED): + response = blocked_response() + return response + + return response + finally: + core.dispatch("django.finalize_response.pre", (ctx, utils._after_request_tags, request, response)) + if not core.get_item(HTTP_REQUEST_BLOCKED): + core.dispatch("django.finalize_response", ("Django",)) + if core.get_item(HTTP_REQUEST_BLOCKED): + response = blocked_response() + return response # noqa: B012 + + +@trace_utils.with_traced_module +def traced_template_render(django, pin, wrapped, instance, args, kwargs): + # DEV: Check here in case this setting is configured after a template has been instrumented + if not config.django.instrument_templates: + return wrapped(*args, **kwargs) + + template_name = maybe_stringify(getattr(instance, "name", None)) + if template_name: + resource = template_name + else: + resource = "{0}.{1}".format(func_name(instance), wrapped.__name__) + + tags = {COMPONENT: config.django.integration_name} + if template_name: + tags["django.template.name"] = template_name + engine = getattr(instance, "engine", None) + if engine: + tags["django.template.engine.class"] = func_name(engine) + + with core.context_with_data( + "django.template.render", + span_name="django.template.render", + resource=resource, + span_type=http.TEMPLATE, + tags=tags, + pin=pin, + ) as ctx, ctx["call"]: + return wrapped(*args, **kwargs) + + +def instrument_view(django, view): + """ + Helper to wrap Django views. + + We want to wrap all lifecycle/http method functions for every class in the MRO for this view + """ + if hasattr(view, "__mro__"): + for cls in reversed(getmro(view)): + _instrument_view(django, cls) + + return _instrument_view(django, view) + + +def _instrument_view(django, view): + """Helper to wrap Django views.""" + from . import utils + + # All views should be callable, double check before doing anything + if not callable(view): + return view + + # Patch view HTTP methods and lifecycle methods + http_method_names = getattr(view, "http_method_names", ("get", "delete", "post", "options", "head")) + lifecycle_methods = ("setup", "dispatch", "http_method_not_allowed") + for name in list(http_method_names) + list(lifecycle_methods): + try: + func = getattr(view, name, None) + if not func or isinstance(func, wrapt.ObjectProxy): + continue + + resource = "{0}.{1}".format(func_name(view), name) + op_name = "django.view.{0}".format(name) + trace_utils.wrap(view, name, traced_func(django, name=op_name, resource=resource)) + except Exception: + log.debug("Failed to instrument Django view %r function %s", view, name, exc_info=True) + + # Patch response methods + response_cls = getattr(view, "response_class", None) + if response_cls: + methods = ("render",) + for name in methods: + try: + func = getattr(response_cls, name, None) + # Do not wrap if the method does not exist or is already wrapped + if not func or isinstance(func, wrapt.ObjectProxy): + continue + + resource = "{0}.{1}".format(func_name(response_cls), name) + op_name = "django.response.{0}".format(name) + trace_utils.wrap(response_cls, name, traced_func(django, name=op_name, resource=resource)) + except Exception: + log.debug("Failed to instrument Django response %r function %s", response_cls, name, exc_info=True) + + # If the view itself is not wrapped, wrap it + if not isinstance(view, wrapt.ObjectProxy): + view = utils.DjangoViewProxy( + view, traced_func(django, "django.view", resource=func_name(view), ignored_excs=[django.http.Http404]) + ) + return view + + +@trace_utils.with_traced_module +def traced_urls_path(django, pin, wrapped, instance, args, kwargs): + """Wrapper for url path helpers to ensure all views registered as urls are traced.""" + try: + if "view" in kwargs: + kwargs["view"] = instrument_view(django, kwargs["view"]) + elif len(args) >= 2: + args = list(args) + args[1] = instrument_view(django, args[1]) + args = tuple(args) + except Exception: + log.debug("Failed to instrument Django url path %r %r", args, kwargs, exc_info=True) + return wrapped(*args, **kwargs) + + +@trace_utils.with_traced_module +def traced_as_view(django, pin, func, instance, args, kwargs): + """ + Wrapper for django's View.as_view class method + """ + try: + instrument_view(django, instance) + except Exception: + log.debug("Failed to instrument Django view %r", instance, exc_info=True) + view = func(*args, **kwargs) + return wrapt.FunctionWrapper(view, traced_func(django, "django.view", resource=func_name(instance))) + + +@trace_utils.with_traced_module +def traced_get_asgi_application(django, pin, func, instance, args, kwargs): + from ddtrace.contrib.asgi import TraceMiddleware + + def django_asgi_modifier(span, scope): + span.name = schematize_url_operation("django.request", protocol="http", direction=SpanDirection.INBOUND) + + return TraceMiddleware(func(*args, **kwargs), integration_config=config.django, span_modifier=django_asgi_modifier) + + +class _DjangoUserInfoRetriever(_UserInfoRetriever): + def __init__(self, user, credentials=None): + super(_DjangoUserInfoRetriever, self).__init__(user) + + self.credentials = credentials if credentials else {} + if self.credentials and not user: + self._try_load_user() + + def _try_load_user(self): + self.user_model = None + + try: + from django.contrib.auth import get_user_model + except ImportError: + log.debug("user_exist: Could not import Django get_user_model", exc_info=True) + return + + try: + self.user_model = get_user_model() + if not self.user_model: + return + except Exception: + log.debug("user_exist: Could not get the user model", exc_info=True) + return + + login_field = asm_config._user_model_login_field + login_field_value = self.credentials.get(login_field, None) if login_field else None + + if not login_field or not login_field_value: + # Try to get the username from the credentials + for possible_login_field in self.possible_login_fields: + if possible_login_field in self.credentials: + login_field = possible_login_field + login_field_value = self.credentials[login_field] + break + else: + # Could not get what the login field, so we can't check if the user exists + log.debug("try_load_user_model: could not get the login field from the credentials") + return + + try: + self.user = self.user_model.objects.get(**{login_field: login_field_value}) + except self.user_model.DoesNotExist: + log.debug("try_load_user_model: could not load user model", exc_info=True) + + def user_exists(self): + return self.user is not None + + def get_username(self): + if hasattr(self.user, "USERNAME_FIELD") and not asm_config._user_model_name_field: + user_type = type(self.user) + return getattr(self.user, user_type.USERNAME_FIELD, None) + + return super(_DjangoUserInfoRetriever, self).get_username() + + def get_name(self): + if not asm_config._user_model_name_field: + if hasattr(self.user, "get_full_name"): + try: + return self.user.get_full_name() + except Exception: + log.debug("User model get_full_name member produced an exception: ", exc_info=True) + + if hasattr(self.user, "first_name") and hasattr(self.user, "last_name"): + return "%s %s" % (self.user.first_name, self.user.last_name) + + return super(_DjangoUserInfoRetriever, self).get_name() + + def get_user_email(self): + if hasattr(self.user, "EMAIL_FIELD") and not asm_config._user_model_name_field: + user_type = type(self.user) + return getattr(self.user, user_type.EMAIL_FIELD, None) + + return super(_DjangoUserInfoRetriever, self).get_user_email() + + +@trace_utils.with_traced_module +def traced_login(django, pin, func, instance, args, kwargs): + func(*args, **kwargs) + mode = asm_config._user_event_mode + if mode == "disabled": + return + try: + request = get_argument_value(args, kwargs, 0, "request") + user = get_argument_value(args, kwargs, 1, "user") + core.dispatch("django.login", (pin, request, user, mode, _DjangoUserInfoRetriever(user))) + except Exception: + log.debug("Error while trying to trace Django login", exc_info=True) + + +@trace_utils.with_traced_module +def traced_authenticate(django, pin, func, instance, args, kwargs): + result_user = func(*args, **kwargs) + mode = asm_config._user_event_mode + if mode == "disabled": + return result_user + try: + result = core.dispatch_with_results( + "django.auth", (result_user, mode, kwargs, pin, _DjangoUserInfoRetriever(result_user, credentials=kwargs)) + ).user + if result and result.value[0]: + return result.value[1] + except Exception: + log.debug("Error while trying to trace Django authenticate", exc_info=True) + + return result_user + + +def unwrap_views(func, instance, args, kwargs): + """ + Django channels uses path() and re_path() to route asgi applications. This broke our initial + assumption that + django path/re_path/url functions only accept views. Here we unwrap ddtrace view + instrumentation from asgi + applications. + + Ex. ``channels.routing.URLRouter([path('', get_asgi_application())])`` + On startup ddtrace.contrib.django.path.instrument_view() will wrap get_asgi_application in a + DjangoViewProxy. + Since get_asgi_application is not a django view callback this function will unwrap it. + """ + from . import utils + + routes = get_argument_value(args, kwargs, 0, "routes") + for route in routes: + if isinstance(route.callback, utils.DjangoViewProxy): + route.callback = route.callback.__wrapped__ + + return func(*args, **kwargs) + + +def _patch(django): + Pin().onto(django) + + when_imported("django.apps.registry")(lambda m: trace_utils.wrap(m, "Apps.populate", traced_populate(django))) + + if config.django.instrument_middleware: + when_imported("django.core.handlers.base")( + lambda m: trace_utils.wrap(m, "BaseHandler.load_middleware", traced_load_middleware(django)) + ) + + when_imported("django.core.handlers.wsgi")(lambda m: trace_utils.wrap(m, "WSGIRequest.__init__", wrap_wsgi_environ)) + core.dispatch("django.patch", ()) + + @when_imported("django.core.handlers.base") + def _(m): + import django + + trace_utils.wrap(m, "BaseHandler.get_response", traced_get_response(django)) + if django.VERSION >= (3, 1): + # Have to inline this import as the module contains syntax incompatible with Python 3.5 and below + from ._asgi import traced_get_response_async + + trace_utils.wrap(m, "BaseHandler.get_response_async", traced_get_response_async(django)) + + @when_imported("django.contrib.auth") + def _(m): + trace_utils.wrap(m, "login", traced_login(django)) + trace_utils.wrap(m, "authenticate", traced_authenticate(django)) + + # Only wrap get_asgi_application if get_response_async exists. Otherwise we will effectively double-patch + # because get_response and get_asgi_application will be used. We must rely on the version instead of coalescing + # with the previous patching hook because of circular imports within `django.core.asgi`. + if django.VERSION >= (3, 1): + when_imported("django.core.asgi")( + lambda m: trace_utils.wrap(m, "get_asgi_application", traced_get_asgi_application(django)) + ) + + if config.django.instrument_templates: + when_imported("django.template.base")( + lambda m: trace_utils.wrap(m, "Template.render", traced_template_render(django)) + ) + + if django.VERSION < (4, 0, 0): + when_imported("django.conf.urls")(lambda m: trace_utils.wrap(m, "url", traced_urls_path(django))) + + if django.VERSION >= (2, 0, 0): + + @when_imported("django.urls") + def _(m): + trace_utils.wrap(m, "path", traced_urls_path(django)) + trace_utils.wrap(m, "re_path", traced_urls_path(django)) + + when_imported("django.views.generic.base")(lambda m: trace_utils.wrap(m, "View.as_view", traced_as_view(django))) + + @when_imported("channels.routing") + def _(m): + import channels + + channels_version = parse_version(channels.__version__) + if channels_version >= parse_version("3.0"): + # ASGI3 is only supported in channels v3.0+ + trace_utils.wrap(m, "URLRouter.__init__", unwrap_views) + + +def wrap_wsgi_environ(wrapped, _instance, args, kwargs): + result = core.dispatch_with_results("django.wsgi_environ", (wrapped, _instance, args, kwargs)).wrapped_result + # if the callback is registered and runs, return the result + if result: + return result.value + # if the callback is not registered, return the original result + elif result.response_type == ResultType.RESULT_UNDEFINED: + return wrapped(*args, **kwargs) + # if an exception occurs, raise it. It should never happen. + elif result.exception: + raise result.exception + + +def patch(): + import django + + if getattr(django, "_datadog_patch", False): + return + _patch(django) + + django._datadog_patch = True + + +def _unpatch(django): + trace_utils.unwrap(django.apps.registry.Apps, "populate") + trace_utils.unwrap(django.core.handlers.base.BaseHandler, "load_middleware") + trace_utils.unwrap(django.core.handlers.base.BaseHandler, "get_response") + trace_utils.unwrap(django.core.handlers.base.BaseHandler, "get_response_async") + trace_utils.unwrap(django.template.base.Template, "render") + trace_utils.unwrap(django.conf.urls.static, "static") + trace_utils.unwrap(django.conf.urls, "url") + trace_utils.unwrap(django.contrib.auth.login, "login") + trace_utils.unwrap(django.contrib.auth.authenticate, "authenticate") + if django.VERSION >= (2, 0, 0): + trace_utils.unwrap(django.urls, "path") + trace_utils.unwrap(django.urls, "re_path") + trace_utils.unwrap(django.views.generic.base.View, "as_view") + for conn in django.db.connections.all(): + trace_utils.unwrap(conn, "cursor") + trace_utils.unwrap(django.db.utils.ConnectionHandler, "__getitem__") + + +def unpatch(): + import django + + if not getattr(django, "_datadog_patch", False): + return + + _unpatch(django) + + django._datadog_patch = False diff --git a/ddtrace/contrib/internal/django/restframework.py b/ddtrace/contrib/internal/django/restframework.py new file mode 100644 index 00000000000..91e314a609f --- /dev/null +++ b/ddtrace/contrib/internal/django/restframework.py @@ -0,0 +1,32 @@ +import rest_framework.views + +from ddtrace.contrib.trace_utils import iswrapped +from ddtrace.contrib.trace_utils import with_traced_module +from ddtrace.vendor.wrapt import wrap_function_wrapper as wrap + + +@with_traced_module +def _traced_handle_exception(django, pin, wrapped, instance, args, kwargs): + """Sets the error message, error type and exception stack trace to the current span + before calling the original exception handler. + """ + span = pin.tracer.current_span() + + if span is not None: + span.set_traceback() + + return wrapped(*args, **kwargs) + + +def patch_restframework(django): + """Patches rest_framework app. + + To trace exceptions occurring during view processing we currently use a TraceExceptionMiddleware. + However the rest_framework handles exceptions before they come to our middleware. + So we need to manually patch the rest_framework exception handler + to set the exception stack trace in the current span. + """ + + # trace the handle_exception method + if not iswrapped(rest_framework.views.APIView, "handle_exception"): + wrap("rest_framework.views", "APIView.handle_exception", _traced_handle_exception(django)) diff --git a/ddtrace/contrib/internal/django/utils.py b/ddtrace/contrib/internal/django/utils.py new file mode 100644 index 00000000000..b98f72cbad5 --- /dev/null +++ b/ddtrace/contrib/internal/django/utils.py @@ -0,0 +1,437 @@ +import json +from typing import Any # noqa:F401 +from typing import Dict # noqa:F401 +from typing import List # noqa:F401 +from typing import Mapping # noqa:F401 +from typing import Text # noqa:F401 +from typing import Union # noqa:F401 + +import django +from django.utils.functional import SimpleLazyObject +import xmltodict + +from ddtrace import config +from ddtrace._trace.span import Span +from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY +from ddtrace.constants import SPAN_MEASURED_KEY +from ddtrace.contrib import func_name +from ddtrace.contrib import trace_utils +from ddtrace.contrib.internal.django.compat import get_resolver +from ddtrace.contrib.internal.django.compat import user_is_authenticated +from ddtrace.ext import SpanTypes +from ddtrace.ext import user as _user +from ddtrace.internal import compat +from ddtrace.internal import core +from ddtrace.internal.logger import get_logger +from ddtrace.internal.utils.formats import stringify_cache_args +from ddtrace.internal.utils.http import parse_form_multipart +from ddtrace.internal.utils.http import parse_form_params +from ddtrace.propagation._utils import from_wsgi_header +from ddtrace.vendor.wrapt import FunctionWrapper + + +try: + from json import JSONDecodeError +except ImportError: + # handling python 2.X import error + JSONDecodeError = ValueError # type: ignore + + +log = get_logger(__name__) + +if django.VERSION < (1, 10, 0): + Resolver404 = django.core.urlresolvers.Resolver404 +else: + Resolver404 = django.urls.exceptions.Resolver404 + + +DJANGO22 = django.VERSION >= (2, 2, 0) + +REQUEST_DEFAULT_RESOURCE = "__django_request" +_BODY_METHODS = {"POST", "PUT", "DELETE", "PATCH"} + +_quantize_text = Union[Text, bytes] +_quantize_param = Union[_quantize_text, List[_quantize_text], Dict[_quantize_text, Any], Any] + + +def resource_from_cache_prefix(resource, cache): + """ + Combine the resource name with the cache prefix (if any) + """ + if getattr(cache, "key_prefix", None): + name = " ".join((resource, cache.key_prefix)) + else: + name = resource + + # enforce lowercase to make the output nicer to read + return name.lower() + + +def quantize_key_values(keys): + # type: (_quantize_param) -> Text + """ + Used for Django cache key normalization. + + If a dict is provided we return a list of keys as text. + + If a list or tuple is provided we convert each element to text. + + If text is provided we convert to text. + """ + args = [] # type: List[Union[Text, bytes, Any]] + + # Normalize input values into a List[Text, bytes] + if isinstance(keys, dict): + args = list(keys.keys()) + elif isinstance(keys, (list, tuple)): + args = keys + else: + args = [keys] + + return stringify_cache_args(args) + + +def get_django_2_route(request, resolver_match): + # Try to use `resolver_match.route` if available + # Otherwise, look for `resolver.pattern.regex.pattern` + route = resolver_match.route + if route: + return route + + resolver = get_resolver(getattr(request, "urlconf", None)) + if resolver: + try: + return resolver.pattern.regex.pattern + except AttributeError: + pass + + return None + + +def set_tag_array(span, prefix, value): + """Helper to set a span tag as a single value or an array""" + if not value: + return + + if len(value) == 1: + if value[0]: + span.set_tag_str(prefix, value[0]) + else: + for i, v in enumerate(value, start=0): + if v: + span.set_tag_str("".join((prefix, ".", str(i))), v) + + +def get_request_uri(request): + """ + Helper to rebuild the original request url + + query string or fragments are not included. + """ + # DEV: Use django.http.request.HttpRequest._get_raw_host() when available + # otherwise back-off to PEP 333 as done in django 1.8.x + if hasattr(request, "_get_raw_host"): + host = request._get_raw_host() + else: + try: + # Try to build host how Django would have + # https://github.com/django/django/blob/e8d0d2a5efc8012dcc8bf1809dec065ebde64c81/django/http/request.py#L85-L102 + if "HTTP_HOST" in request.META: + host = request.META["HTTP_HOST"] + else: + host = request.META["SERVER_NAME"] + port = str(request.META["SERVER_PORT"]) + if port != ("443" if request.is_secure() else "80"): + host = "".join((host, ":", port)) + except Exception: + # This really shouldn't ever happen, but lets guard here just in case + log.debug("Failed to build Django request host", exc_info=True) + host = "unknown" + + # If request scheme is missing, possible in case where wsgi.url_scheme + # environ has not been set, return None and skip providing a uri + if request.scheme is None: + return + + # Build request url from the information available + # DEV: We are explicitly omitting query strings since they may contain sensitive information + urlparts = {"scheme": request.scheme, "netloc": host, "path": request.path} + + # If any url part is a SimpleLazyObject, use its __class__ property to cast + # str/bytes and allow for _setup() to execute + for k, v in urlparts.items(): + if isinstance(v, SimpleLazyObject): + if issubclass(v.__class__, str): + v = str(v) + elif issubclass(v.__class__, bytes): + v = bytes(v) + else: + # lazy object that is not str or bytes should not happen here + # but if it does skip providing a uri + log.debug( + "Skipped building Django request uri, %s is SimpleLazyObject wrapping a %s class", + k, + v.__class__.__name__, + ) + return None + urlparts[k] = compat.ensure_text(v) + + return "".join((urlparts["scheme"], "://", urlparts["netloc"], urlparts["path"])) + + +def _set_resolver_tags(pin, span, request): + # Default to just the HTTP method when we cannot determine a reasonable resource + resource = request.method + + try: + # Get resolver match result and build resource name pieces + resolver_match = request.resolver_match + if not resolver_match: + # The request quite likely failed (e.g. 404) so we do the resolution anyway. + resolver = get_resolver(getattr(request, "urlconf", None)) + resolver_match = resolver.resolve(request.path_info) + + if hasattr(resolver_match[0], "view_class"): + # In django==4.0, view.__name__ defaults to .views.view + # Accessing view.view_class is equired for django>4.0 to get the name of underlying view + handler = func_name(resolver_match[0].view_class) + else: + handler = func_name(resolver_match[0]) + + route = None + # In Django >= 2.2.0 we can access the original route or regex pattern + # TODO: Validate if `resolver.pattern.regex.pattern` is available on django<2.2 + if DJANGO22: + # Determine the resolver and resource name for this request + route = get_django_2_route(request, resolver_match) + if route: + span.set_tag_str("http.route", route) + + if config.django.use_handler_resource_format: + resource = " ".join((request.method, handler)) + elif config.django.use_legacy_resource_format: + resource = handler + else: + if route: + resource = " ".join((request.method, route)) + else: + if config.django.use_handler_with_url_name_resource_format: + # Append url name in order to distinguish different routes of the same ViewSet + url_name = resolver_match.url_name + if url_name: + handler = ".".join([handler, url_name]) + + resource = " ".join((request.method, handler)) + + span.set_tag_str("django.view", resolver_match.view_name) + set_tag_array(span, "django.namespace", resolver_match.namespaces) + + # Django >= 2.0.0 + if hasattr(resolver_match, "app_names"): + set_tag_array(span, "django.app", resolver_match.app_names) + + except Resolver404: + # Normalize all 404 requests into a single resource name + # DEV: This is for potential cardinality issues + resource = " ".join((request.method, "404")) + except Exception: + log.debug( + "Failed to resolve request path %r with path info %r", + request, + getattr(request, "path_info", "not-set"), + exc_info=True, + ) + finally: + # Only update the resource name if it was not explicitly set + # by anyone during the request lifetime + if span.resource == REQUEST_DEFAULT_RESOURCE: + span.resource = resource + + +def _before_request_tags(pin, span, request): + # DEV: Do not set `span.resource` here, leave it as `None` + # until `_set_resolver_tags` so we can know if the user + # has explicitly set it during the request lifetime + span.service = trace_utils.int_service(pin, config.django) + span.span_type = SpanTypes.WEB + span._metrics[SPAN_MEASURED_KEY] = 1 + + analytics_sr = config.django.get_analytics_sample_rate(use_global_config=True) + if analytics_sr is not None: + span.set_tag(ANALYTICS_SAMPLE_RATE_KEY, analytics_sr) + + span.set_tag_str("django.request.class", func_name(request)) + + +def _extract_body(request): + # DEV: Do not use request.POST or request.data, this could prevent custom parser to be used after + if request.method in _BODY_METHODS: + req_body = None + content_type = request.content_type if hasattr(request, "content_type") else request.META.get("CONTENT_TYPE") + headers = core.dispatch_with_results("django.extract_body").headers.value + try: + if content_type == "application/x-www-form-urlencoded": + req_body = parse_form_params(request.body.decode("UTF-8", errors="ignore")) + elif content_type == "multipart/form-data": + req_body = parse_form_multipart(request.body.decode("UTF-8", errors="ignore"), headers) + elif content_type in ("application/json", "text/json"): + req_body = json.loads(request.body.decode("UTF-8", errors="ignore")) + elif content_type in ("application/xml", "text/xml"): + req_body = xmltodict.parse(request.body.decode("UTF-8", errors="ignore")) + else: # text/plain, others: don't use them + req_body = None + except Exception: + log.debug("Failed to parse request body", exc_info=True) + return req_body + + +def _get_request_headers(request): + # type: (Any) -> Mapping[str, str] + if DJANGO22: + request_headers = request.headers # type: Mapping[str, str] + else: + request_headers = {} # type: Mapping[str, str] + for header, value in request.META.items(): + name = from_wsgi_header(header) + if name: + request_headers[name] = value + + return request_headers + + +def _after_request_tags(pin, span: Span, request, response): + # Response can be None in the event that the request failed + # We still want to set additional request tags that are resolved + # during the request. + + try: + user = getattr(request, "user", None) + if user is not None: + # Note: getattr calls to user / user_is_authenticated may result in ImproperlyConfigured exceptions from + # Django's get_user_model(): + # https://github.com/django/django/blob/a464ead29db8bf6a27a5291cad9eb3f0f3f0472b/django/contrib/auth/__init__.py + # + # FIXME: getattr calls to user fail in async contexts. + # Sample Error: django.core.exceptions.SynchronousOnlyOperation: You cannot call this from an async context + # - use a thread or sync_to_async. + try: + if hasattr(user, "is_authenticated"): + span.set_tag_str("django.user.is_authenticated", str(user_is_authenticated(user))) + + uid = getattr(user, "pk", None) + if uid and isinstance(uid, int): + span.set_tag_str("django.user.id", str(uid)) + span.set_tag_str(_user.ID, str(uid)) + if config.django.include_user_name: + username = getattr(user, "username", None) + if username: + span.set_tag_str("django.user.name", username) + except Exception: + log.debug("Error retrieving authentication information for user", exc_info=True) + + # DEV: Resolve the view and resource name at the end of the request in case + # urlconf changes at any point during the request + _set_resolver_tags(pin, span, request) + if response: + status = response.status_code + span.set_tag_str("django.response.class", func_name(response)) + if hasattr(response, "template_name"): + # template_name is a bit of a misnomer, as it could be any of: + # a list of strings, a tuple of strings, a single string, or an instance of Template + # for more detail, see: + # https://docs.djangoproject.com/en/3.0/ref/template-response/#django.template.response.SimpleTemplateResponse.template_name + template = response.template_name + + if isinstance(template, str): + template_names = [template] + elif isinstance( + template, + ( + list, + tuple, + ), + ): + template_names = template + elif hasattr(template, "template"): + # ^ checking by attribute here because + # django backend implementations don't have a common base + # `.template` is also the most consistent across django versions + template_names = [template.template.name] + else: + template_names = None + + set_tag_array(span, "django.response.template", template_names) + + url = get_request_uri(request) + + request_headers = core.dispatch_with_results("django.after_request_headers").headers.value + if not request_headers: + request_headers = _get_request_headers(request) + + response_headers = dict(response.items()) if response else {} + + response_cookies = {} + if response.cookies: + for k, v in response.cookies.items(): + # `v` is a http.cookies.Morsel class instance in some scenarios: + # 'cookie_key=cookie_value; HttpOnly; Path=/; SameSite=Strict' + try: + i = 0 + result = "" + for element in v.OutputString().split(";"): + if i == 0: + # split cookie_key="cookie_value" + key, value = element.split("=", 1) + # Remove quotes "cookie_value" + result = value[1:-1] if value.startswith('"') and value[-1] == '"' else value + else: + result += ";" + element + i += 1 + response_cookies[k] = result + except Exception: + # parse cookies by the old way + response_cookies[k] = v.OutputString() + + raw_uri = url + if raw_uri and request.META.get("QUERY_STRING"): + raw_uri += "?" + request.META["QUERY_STRING"] + + core.dispatch( + "django.after_request_headers.post", + ( + request_headers, + response_headers, + span, + config.django, + request, + url, + raw_uri, + status, + response_cookies, + ), + ) + content = getattr(response, "content", None) + if content is None: + content = getattr(response, "streaming_content", None) + core.dispatch("django.after_request_headers.finalize", (content, None)) + finally: + if span.resource == REQUEST_DEFAULT_RESOURCE: + span.resource = request.method + + +class DjangoViewProxy(FunctionWrapper): + """ + This custom function wrapper is used to wrap the callback passed to django views handlers (path/re_path/url). + This allows us to distinguish between wrapped django views and wrapped asgi applications in django channels. + """ + + @property + def __module__(self): + """ + DjangoViewProxy.__module__ defaults to ddtrace.contrib.django when a wrapped function does not have + a __module__ attribute. This method ensures that DjangoViewProxy.__module__ always returns the module + attribute of the wrapped function or an empty string if this attribute is not available. + The function Django.urls.path() does not have a __module__ attribute and would require this override + to resolve the correct module name. + """ + return self.__wrapped__.__module__ diff --git a/ddtrace/contrib/internal/dogpile_cache/lock.py b/ddtrace/contrib/internal/dogpile_cache/lock.py new file mode 100644 index 00000000000..c592562f94f --- /dev/null +++ b/ddtrace/contrib/internal/dogpile_cache/lock.py @@ -0,0 +1,39 @@ +import dogpile + +from ddtrace.internal.utils.formats import asbool +from ddtrace.pin import Pin + + +def _wrap_lock_ctor(func, instance, args, kwargs): + """ + This seems rather odd. But to track hits, we need to patch the wrapped function that + dogpile passes to the region and locks. Unfortunately it's a closure defined inside + the get_or_create* methods themselves, so we can't easily patch those. + """ + func(*args, **kwargs) + ori_backend_fetcher = instance.value_and_created_fn + + def wrapped_backend_fetcher(): + pin = Pin.get_from(dogpile.cache) + if not pin or not pin.enabled(): + return ori_backend_fetcher() + + hit = False + expired = True + try: + value, createdtime = ori_backend_fetcher() + hit = value is not dogpile.cache.api.NO_VALUE + # dogpile sometimes returns None, but only checks for truthiness. Coalesce + # to minimize APM users' confusion. + expired = instance._is_expired(createdtime) or False + return value, createdtime + finally: + # Keys are checked in random order so the 'final' answer for partial hits + # should really be false (ie. if any are 'negative', then the tag value + # should be). This means ANDing all hit values and ORing all expired values. + span = pin.tracer.current_span() + if span: + span.set_tag("hit", asbool(span.get_tag("hit") or "True") and hit) + span.set_tag("expired", asbool(span.get_tag("expired") or "False") or expired) + + instance.value_and_created_fn = wrapped_backend_fetcher diff --git a/ddtrace/contrib/internal/dogpile_cache/patch.py b/ddtrace/contrib/internal/dogpile_cache/patch.py new file mode 100644 index 00000000000..162151231be --- /dev/null +++ b/ddtrace/contrib/internal/dogpile_cache/patch.py @@ -0,0 +1,52 @@ +try: + import dogpile.cache as dogpile_cache + import dogpile.lock as dogpile_lock +except AttributeError: + from dogpile import cache as dogpile_cache + from dogpile import lock as dogpile_lock + +from ddtrace.internal.schema import schematize_service_name +from ddtrace.pin import _DD_PIN_NAME +from ddtrace.pin import _DD_PIN_PROXY_NAME +from ddtrace.pin import Pin +from ddtrace.vendor.wrapt import wrap_function_wrapper as _w + +from .lock import _wrap_lock_ctor +from .region import _wrap_get_create +from .region import _wrap_get_create_multi + + +_get_or_create = dogpile_cache.region.CacheRegion.get_or_create +_get_or_create_multi = dogpile_cache.region.CacheRegion.get_or_create_multi +_lock_ctor = dogpile_lock.Lock.__init__ + + +def get_version(): + # type: () -> str + return getattr(dogpile_cache, "__version__", "") + + +def patch(): + if getattr(dogpile_cache, "_datadog_patch", False): + return + dogpile_cache._datadog_patch = True + + _w("dogpile.cache.region", "CacheRegion.get_or_create", _wrap_get_create) + _w("dogpile.cache.region", "CacheRegion.get_or_create_multi", _wrap_get_create_multi) + _w("dogpile.lock", "Lock.__init__", _wrap_lock_ctor) + + Pin(service=schematize_service_name("dogpile.cache")).onto(dogpile_cache) + + +def unpatch(): + if not getattr(dogpile_cache, "_datadog_patch", False): + return + dogpile_cache._datadog_patch = False + # This looks silly but the unwrap util doesn't support class instance methods, even + # though wrapt does. This was causing the patches to stack on top of each other + # during testing. + dogpile_cache.region.CacheRegion.get_or_create = _get_or_create + dogpile_cache.region.CacheRegion.get_or_create_multi = _get_or_create_multi + dogpile_lock.Lock.__init__ = _lock_ctor + setattr(dogpile_cache, _DD_PIN_NAME, None) + setattr(dogpile_cache, _DD_PIN_PROXY_NAME, None) diff --git a/ddtrace/contrib/internal/dogpile_cache/region.py b/ddtrace/contrib/internal/dogpile_cache/region.py new file mode 100644 index 00000000000..04b70402e3d --- /dev/null +++ b/ddtrace/contrib/internal/dogpile_cache/region.py @@ -0,0 +1,54 @@ +import dogpile + +from ddtrace.constants import SPAN_MEASURED_KEY +from ddtrace.ext import SpanTypes +from ddtrace.ext import db +from ddtrace.internal.constants import COMPONENT +from ddtrace.internal.schema import schematize_cache_operation +from ddtrace.internal.schema import schematize_service_name +from ddtrace.internal.utils import get_argument_value +from ddtrace.pin import Pin + + +def _wrap_get_create(func, instance, args, kwargs): + pin = Pin.get_from(dogpile.cache) + if not pin or not pin.enabled(): + return func(*args, **kwargs) + + key = get_argument_value(args, kwargs, 0, "key") + with pin.tracer.trace( + schematize_cache_operation("dogpile.cache", cache_provider="dogpile"), + service=schematize_service_name(None), + resource="get_or_create", + span_type=SpanTypes.CACHE, + ) as span: + span.set_tag_str(COMPONENT, "dogpile_cache") + span.set_tag(SPAN_MEASURED_KEY) + span.set_tag("key", key) + span.set_tag("region", instance.name) + span.set_tag("backend", instance.actual_backend.__class__.__name__) + response = func(*args, **kwargs) + span.set_metric(db.ROWCOUNT, 1) + return response + + +def _wrap_get_create_multi(func, instance, args, kwargs): + pin = Pin.get_from(dogpile.cache) + if not pin or not pin.enabled(): + return func(*args, **kwargs) + + keys = get_argument_value(args, kwargs, 0, "keys") + with pin.tracer.trace( + schematize_cache_operation("dogpile.cache", cache_provider="dogpile"), + service=schematize_service_name(None), + resource="get_or_create_multi", + span_type="cache", + ) as span: + span.set_tag_str(COMPONENT, "dogpile_cache") + span.set_tag(SPAN_MEASURED_KEY) + span.set_tag("keys", keys) + span.set_tag("region", instance.name) + span.set_tag("backend", instance.actual_backend.__class__.__name__) + response = func(*args, **kwargs) + span.set_metric(db.ROWCOUNT, len(response)) + return response diff --git a/ddtrace/contrib/internal/dramatiq/patch.py b/ddtrace/contrib/internal/dramatiq/patch.py new file mode 100644 index 00000000000..08daad9d93c --- /dev/null +++ b/ddtrace/contrib/internal/dramatiq/patch.py @@ -0,0 +1,72 @@ +from typing import Any +from typing import Callable +from typing import Dict +from typing import Tuple + +import dramatiq + +from ddtrace import config +from ddtrace import tracer +from ddtrace.constants import SPAN_KIND +from ddtrace.contrib import trace_utils +from ddtrace.ext import SpanKind +from ddtrace.ext import SpanTypes +from ddtrace.settings.config import Config + + +def get_version() -> str: + return str(dramatiq.__version__) + + +def patch() -> None: + """ + Instrument dramatiq so any new Actor is automatically instrumented. + """ + if getattr(dramatiq, "__datadog_patch", False): + return + dramatiq.__datadog_patch = True + + trace_utils.wrap("dramatiq", "Actor.send_with_options", _traced_send_with_options_function(config.dramatiq)) + + +def unpatch() -> None: + """ + Disconnect remove tracing capabilities from dramatiq Actors + """ + if not getattr(dramatiq, "__datadog_patch", False): + return + dramatiq.__datadog_patch = False + + trace_utils.unwrap(dramatiq.Actor, "send_with_options") + + +def _traced_send_with_options_function(integration_config: Config) -> Callable[[Any], Any]: + """ + NOTE: This accounts for both the send() and send_with_options() methods, + since send() just wraps around send_with_options() with empty options. + + In terms of expected behavior, this traces the send_with_options() calls, + but does not reflect the actual execution time of the background task + itself. The duration of this span is the duration of the send_with_options() + call itself. + """ + + def _traced_send_with_options( + func: Callable[[Any], Any], instance: dramatiq.Actor, args: Tuple[Any], kwargs: Dict[Any, Any] + ) -> Callable[[Any], Any]: + with tracer.trace( + "dramatiq.Actor.send_with_options", + span_type=SpanTypes.WORKER, + service=trace_utils.ext_service(pin=None, int_config=integration_config), + ) as span: + span.set_tags( + { + SPAN_KIND: SpanKind.PRODUCER, + "actor.name": instance.actor_name, + "actor.options": instance.options, + } + ) + + return func(*args, **kwargs) + + return _traced_send_with_options diff --git a/ddtrace/contrib/internal/elasticsearch/patch.py b/ddtrace/contrib/internal/elasticsearch/patch.py new file mode 100644 index 00000000000..036f64d491d --- /dev/null +++ b/ddtrace/contrib/internal/elasticsearch/patch.py @@ -0,0 +1,268 @@ +from importlib import import_module +from typing import List # noqa:F401 + +from ddtrace import config +from ddtrace._trace import _limits +from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY +from ddtrace.constants import SPAN_KIND +from ddtrace.constants import SPAN_MEASURED_KEY +from ddtrace.contrib.internal.elasticsearch.quantize import quantize +from ddtrace.contrib.trace_utils import ext_service +from ddtrace.contrib.trace_utils import extract_netloc_and_query_info_from_url +from ddtrace.ext import SpanKind +from ddtrace.ext import SpanTypes +from ddtrace.ext import elasticsearch as metadata +from ddtrace.ext import http +from ddtrace.ext import net +from ddtrace.internal.compat import parse +from ddtrace.internal.constants import COMPONENT +from ddtrace.internal.logger import get_logger +from ddtrace.internal.schema import schematize_service_name +from ddtrace.internal.utils.wrappers import unwrap as _u +from ddtrace.pin import Pin +from ddtrace.vendor.wrapt import wrap_function_wrapper as _w + + +log = get_logger(__name__) + +config._add( + "elasticsearch", + { + "_default_service": schematize_service_name("elasticsearch"), + }, +) + + +def _es_modules(): + module_names = ( + "elasticsearch", + "elasticsearch1", + "elasticsearch2", + "elasticsearch5", + "elasticsearch6", + "elasticsearch7", + # Starting with version 8, the default transport which is what we + # actually patch is found in the separate elastic_transport package + "elastic_transport", + "opensearchpy", + ) + for module_name in module_names: + try: + module = import_module(module_name) + versions[module_name] = getattr(module, "__versionstr__", "") + yield module + except ImportError: + pass + + +versions = {} + + +def get_version_tuple(elasticsearch): + return getattr(elasticsearch, "__version__", "") + + +def get_version(): + # type: () -> str + return "" + + +def get_versions(): + # type: () -> List[str] + return versions + + +def _get_transport_module(elasticsearch): + try: + # elasticsearch7/opensearch async + return elasticsearch._async.transport + except AttributeError: + try: + # elasticsearch<8/opensearch sync + return elasticsearch.transport + except AttributeError: + # elastic_transport (elasticsearch8) + return elasticsearch + + +# NB: We are patching the default elasticsearch transport module +def patch(): + for elasticsearch in _es_modules(): + _patch(_get_transport_module(elasticsearch)) + + +def _patch(transport): + if getattr(transport, "_datadog_patch", False): + return + if hasattr(transport, "Transport"): + transport._datadog_patch = True + _w(transport.Transport, "perform_request", _get_perform_request(transport)) + Pin().onto(transport.Transport) + if hasattr(transport, "AsyncTransport"): + transport._datadog_patch = True + _w(transport.AsyncTransport, "perform_request", _get_perform_request_async(transport)) + Pin().onto(transport.AsyncTransport) + + +def unpatch(): + for elasticsearch in _es_modules(): + _unpatch(_get_transport_module(elasticsearch)) + + +def _unpatch(transport): + if not getattr(transport, "_datadog_patch", False): + return + for classname in ("Transport", "AsyncTransport"): + try: + cls = getattr(transport, classname) + except AttributeError: + continue + transport._datadog_patch = False + _u(cls, "perform_request") + + +def _get_perform_request_coro(transport): + def _perform_request(func, instance, args, kwargs): + pin = Pin.get_from(instance) + if not pin or not pin.enabled(): + yield func(*args, **kwargs) + return + + with pin.tracer.trace( + "elasticsearch.query", service=ext_service(pin, config.elasticsearch), span_type=SpanTypes.ELASTICSEARCH + ) as span: + if pin.tags: + span.set_tags(pin.tags) + + span.set_tag_str(COMPONENT, config.elasticsearch.integration_name) + + # set span.kind to the type of request being performed + span.set_tag_str(SPAN_KIND, SpanKind.CLIENT) + + span.set_tag(SPAN_MEASURED_KEY) + + # Only instrument if trace is sampled or if we haven't tried to sample yet + if span.context.sampling_priority is not None and span.context.sampling_priority <= 0: + yield func(*args, **kwargs) + return + + method, target = args + params = kwargs.get("params") + body = kwargs.get("body") + + # elastic_transport gets target url with query params already appended + parsed = parse.urlparse(target) + url = parsed.path + if params: + encoded_params = parse.urlencode(params) + else: + encoded_params = parsed.query + + span.set_tag_str(metadata.METHOD, method) + span.set_tag_str(metadata.URL, url) + span.set_tag_str(metadata.PARAMS, encoded_params) + try: + # elasticsearch<8 + connections = instance.connection_pool.connections + except AttributeError: + # elastic_transport + connections = instance.node_pool.all() + for connection in connections: + hostname, _ = extract_netloc_and_query_info_from_url(connection.host) + if hostname: + span.set_tag_str(net.TARGET_HOST, hostname) + break + + if config.elasticsearch.trace_query_string: + span.set_tag_str(http.QUERY_STRING, encoded_params) + + if method in ["GET", "POST"]: + try: + # elasticsearch<8 + ser_body = instance.serializer.dumps(body) + except AttributeError: + # elastic_transport + ser_body = instance.serializers.dumps(body) + # Elasticsearch request bodies can be very large resulting in traces being too large + # to send. + # When this occurs, drop the value. + # Ideally the body should be truncated, however we cannot truncate as the obfuscation + # logic for the body lives in the agent and truncating would make the body undecodable. + if len(ser_body) <= _limits.MAX_SPAN_META_VALUE_LEN: + span.set_tag_str(metadata.BODY, ser_body) + else: + span.set_tag_str( + metadata.BODY, + "" % (len(ser_body), _limits.MAX_SPAN_META_VALUE_LEN), + ) + status = None + + # set analytics sample rate + span.set_tag(ANALYTICS_SAMPLE_RATE_KEY, config.elasticsearch.get_analytics_sample_rate()) + + span = quantize(span) + + try: + result = yield func(*args, **kwargs) + except transport.TransportError as e: + span.set_tag(http.STATUS_CODE, getattr(e, "status_code", 500)) + span.error = 1 + raise + + try: + # Optional metadata extraction with soft fail. + if isinstance(result, tuple): + try: + # elastic_transport returns a named tuple + meta, data = result.meta, result.body + status = meta.status + except AttributeError: + # elasticsearch<2.4; it returns both the status and the body + status, data = result + else: + # elasticsearch>=2.4,<8; internal change for ``Transport.perform_request`` + # that just returns the body + data = result + + took = data.get("took") + if took: + span.set_metric(metadata.TOOK, int(took)) + except Exception: + log.debug("Unexpected exception", exc_info=True) + + if status: + span.set_tag(http.STATUS_CODE, status) + + return + + return _perform_request + + +def _get_perform_request(transport): + _perform_request_coro = _get_perform_request_coro(transport) + + def _perform_request(func, instance, args, kwargs): + coro = _perform_request_coro(func, instance, args, kwargs) + result = next(coro) + try: + coro.send(result) + except StopIteration: + pass + return result + + return _perform_request + + +def _get_perform_request_async(transport): + _perform_request_coro = _get_perform_request_coro(transport) + + async def _perform_request(func, instance, args, kwargs): + coro = _perform_request_coro(func, instance, args, kwargs) + result = await next(coro) + try: + coro.send(result) + except StopIteration: + pass + return result + + return _perform_request diff --git a/ddtrace/contrib/internal/elasticsearch/quantize.py b/ddtrace/contrib/internal/elasticsearch/quantize.py new file mode 100644 index 00000000000..088407eae4c --- /dev/null +++ b/ddtrace/contrib/internal/elasticsearch/quantize.py @@ -0,0 +1,35 @@ +import re + +from ddtrace.ext import elasticsearch as metadata + + +# Replace any ID +ID_REGEXP = re.compile(r"/([0-9]+)([/\?]|$)") +ID_PLACEHOLDER = r"/?\2" + +# Remove digits from potential timestamped indexes (should be an option). +# For now, let's say 2+ digits +INDEX_REGEXP = re.compile(r"[0-9]{2,}") +INDEX_PLACEHOLDER = r"?" + + +def quantize(span): + """Quantize an elasticsearch span + + We want to extract a meaningful `resource` from the request. + We do it based on the method + url, with some cleanup applied to the URL. + + The URL might a ID, but also it is common to have timestamped indexes. + While the first is easy to catch, the second should probably be configurable. + + All of this should probably be done in the Agent. Later. + """ + url = span.get_tag(metadata.URL) + method = span.get_tag(metadata.METHOD) + + quantized_url = ID_REGEXP.sub(ID_PLACEHOLDER, url) + quantized_url = INDEX_REGEXP.sub(INDEX_PLACEHOLDER, quantized_url) + + span.resource = "{method} {url}".format(method=method, url=quantized_url) + + return span diff --git a/ddtrace/contrib/internal/falcon/middleware.py b/ddtrace/contrib/internal/falcon/middleware.py new file mode 100644 index 00000000000..a3591c3edf0 --- /dev/null +++ b/ddtrace/contrib/internal/falcon/middleware.py @@ -0,0 +1,122 @@ +import sys + +from ddtrace import config +from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY +from ddtrace.constants import SPAN_KIND +from ddtrace.constants import SPAN_MEASURED_KEY +from ddtrace.contrib import trace_utils +from ddtrace.ext import SpanKind +from ddtrace.ext import SpanTypes +from ddtrace.ext import http as httpx +from ddtrace.internal.constants import COMPONENT +from ddtrace.internal.schema import SpanDirection +from ddtrace.internal.schema import schematize_service_name +from ddtrace.internal.schema import schematize_url_operation + + +class TraceMiddleware(object): + def __init__(self, tracer, service=None, distributed_tracing=None): + if service is None: + service = schematize_service_name("falcon") + # store tracing references + self.tracer = tracer + self.service = service + if distributed_tracing is not None: + config.falcon["distributed_tracing"] = distributed_tracing + + def process_request(self, req, resp): + # Falcon uppercases all header names. + headers = dict((k.lower(), v) for k, v in req.headers.items()) + trace_utils.activate_distributed_headers(self.tracer, int_config=config.falcon, request_headers=headers) + + span = self.tracer.trace( + schematize_url_operation("falcon.request", protocol="http", direction=SpanDirection.INBOUND), + service=self.service, + span_type=SpanTypes.WEB, + ) + span.set_tag_str(COMPONENT, config.falcon.integration_name) + + # set span.kind to the type of operation being performed + span.set_tag_str(SPAN_KIND, SpanKind.SERVER) + + span.set_tag(SPAN_MEASURED_KEY) + + # set analytics sample rate with global config enabled + span.set_tag(ANALYTICS_SAMPLE_RATE_KEY, config.falcon.get_analytics_sample_rate(use_global_config=True)) + + trace_utils.set_http_meta( + span, config.falcon, method=req.method, url=req.url, query=req.query_string, request_headers=req.headers + ) + + def process_resource(self, req, resp, resource, params): + span = self.tracer.current_span() + if not span: + return # unexpected + span.resource = "%s %s" % (req.method, _name(resource)) + + def process_response(self, req, resp, resource, req_succeeded=None): + # req_succeded is not a kwarg in the API, but we need that to support + # Falcon 1.0 that doesn't provide this argument + span = self.tracer.current_span() + if not span: + return # unexpected + + status = resp.status.partition(" ")[0] + + # falcon does not map errors or unmatched routes + # to proper status codes, so we have to try to infer them + # here. + if resource is None: + status = "404" + span.resource = "%s 404" % req.method + span.set_tag(httpx.STATUS_CODE, status) + span.finish() + return + + err_type = sys.exc_info()[0] + if err_type is not None: + if req_succeeded is None: + # backward-compatibility with Falcon 1.0; any version + # greater than 1.0 has req_succeded in [True, False] + # TODO[manu]: drop the support at some point + status = _detect_and_set_status_error(err_type, span) + elif req_succeeded is False: + # Falcon 1.1+ provides that argument that is set to False + # if get an Exception (404 is still an exception) + status = _detect_and_set_status_error(err_type, span) + + route = req.root_path or "" + req.uri_template + + trace_utils.set_http_meta( + span, + config.falcon, + status_code=status, + response_headers=resp._headers, + route=route, + ) + + # Emit span hook for this response + # DEV: Emit before closing so they can overwrite `span.resource` if they want + config.falcon.hooks.emit("request", span, req, resp) + + # Close the span + span.finish() + + +def _is_404(err_type): + return "HTTPNotFound" in err_type.__name__ + + +def _detect_and_set_status_error(err_type, span): + """Detect the HTTP status code from the current stacktrace and + set the traceback to the given Span + """ + if not _is_404(err_type): + span.set_traceback() + return "500" + elif _is_404(err_type): + return "404" + + +def _name(r): + return "%s.%s" % (r.__module__, r.__class__.__name__) diff --git a/ddtrace/contrib/internal/falcon/patch.py b/ddtrace/contrib/internal/falcon/patch.py new file mode 100644 index 00000000000..ab8b9c9c342 --- /dev/null +++ b/ddtrace/contrib/internal/falcon/patch.py @@ -0,0 +1,52 @@ +import os + +import falcon + +from ddtrace import config +from ddtrace import tracer +from ddtrace.internal.utils.formats import asbool +from ddtrace.internal.utils.version import parse_version +from ddtrace.vendor import wrapt + +from .middleware import TraceMiddleware + + +FALCON_VERSION = parse_version(falcon.__version__) + + +config._add( + "falcon", + dict( + distributed_tracing=asbool(os.getenv("DD_FALCON_DISTRIBUTED_TRACING", default=True)), + ), +) + + +def get_version(): + # type: () -> str + return getattr(falcon, "__version__", "") + + +def patch(): + """ + Patch falcon.API to include contrib.falcon.TraceMiddleware + by default + """ + if getattr(falcon, "_datadog_patch", False): + return + + falcon._datadog_patch = True + if FALCON_VERSION >= (3, 0, 0): + wrapt.wrap_function_wrapper("falcon", "App.__init__", traced_init) + if FALCON_VERSION < (4, 0, 0): + wrapt.wrap_function_wrapper("falcon", "API.__init__", traced_init) + + +def traced_init(wrapped, instance, args, kwargs): + mw = kwargs.pop("middleware", []) + service = config._get_service(default="falcon") + + mw.insert(0, TraceMiddleware(tracer, service)) + kwargs["middleware"] = mw + + wrapped(*args, **kwargs) diff --git a/releasenotes/notes/move-integrations-to-internal-django-1554664491477408.yaml b/releasenotes/notes/move-integrations-to-internal-django-1554664491477408.yaml new file mode 100644 index 00000000000..171a079ff59 --- /dev/null +++ b/releasenotes/notes/move-integrations-to-internal-django-1554664491477408.yaml @@ -0,0 +1,12 @@ +--- +deprecations: + - | + django: Deprecates all modules in the ``ddtrace.contrib.django`` package. Use attributes exposed in ``ddtrace.contrib.django.__all__`` instead. + - | + dogpile_cache: Deprecates all modules in the ``ddtrace.contrib.dogpile_cache`` package. Use attributes exposed in ``ddtrace.contrib.dogpile_cache.__all__`` instead. + - | + dramatiq: Deprecates all modules in the ``ddtrace.contrib.dramatiq`` package. Use attributes exposed in ``ddtrace.contrib.dramatiq.__all__`` instead. + - | + elasticsearch: Deprecates all modules in the ``ddtrace.contrib.elasticsearch`` package. Use attributes exposed in ``ddtrace.contrib.elasticsearch.__all__`` instead. + - | + falcon: Deprecates all modules in the ``ddtrace.contrib.falcon`` package. Use attributes exposed in ``ddtrace.contrib.falcon.__all__`` instead. diff --git a/tests/contrib/django/test_django_utils.py b/tests/contrib/django/test_django_utils.py index dbf2d4b50ed..b6e4b582fb2 100644 --- a/tests/contrib/django/test_django_utils.py +++ b/tests/contrib/django/test_django_utils.py @@ -2,7 +2,7 @@ import pytest from ddtrace.contrib.django.utils import DJANGO22 -from ddtrace.contrib.django.utils import _get_request_headers +from ddtrace.contrib.internal.django.utils import _get_request_headers @pytest.mark.skipif(DJANGO22, reason="We only parse environ/headers on Django < 2.2.0")