Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Django ASGI support #391

Merged
merged 9 commits into from
Oct 12, 2021
Merged

Add Django ASGI support #391

merged 9 commits into from
Oct 12, 2021

Conversation

adamantike
Copy link
Contributor

@adamantike adamantike commented Mar 31, 2021

Description

This diff adds asgi as an extra, and uses its methods if the current request is an ASGIRequest. Also adds support for traced request attributes, by retrieving them from request scope headers.

Fixes #165, fixes #185, fixes #280, fixes #334.

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration

  • tox -e test-instrumentation-django -- -k TestMiddlewareAsgi

Does This PR Require a Core Repo Change?

  • No.

Checklist:

See contributing.md for styleguide, changelog guidelines, and more.

  • Followed the style guidelines of this project
  • Changelogs have been updated
  • Unit tests have been added
  • Documentation has been updated

@adamantike adamantike force-pushed the django-asgi branch 3 times, most recently from c605154 to 5cf5b16 Compare April 6, 2021 16:47
@adamantike adamantike marked this pull request as ready for review April 6, 2021 16:48
@adamantike adamantike requested a review from a team as a code owner April 6, 2021 16:48
@adamantike adamantike requested review from aabmass and ocelotl and removed request for a team April 6, 2021 16:48
@adamantike
Copy link
Contributor Author

adamantike commented Apr 6, 2021

Marking this as ready for review. There are still a few things to investigate:

  • Tests are passing, but triggering log error: opentelemetry.context:__init__.py:146 Failed to detach context.
  • ASGI intrumentation's set_status_code method does not populate the status_text field. I think this should be tackled in a separate PR. Not needed as of 370952f
  • Traced request attributes are not supported in this PR yet.

cc @lonewolf3739, because of the context provided in #334.

@ocelotl
Copy link
Contributor

ocelotl commented May 27, 2021

@adamantike is this still WIP? If so, please mark it as draft.

@ocelotl
Copy link
Contributor

ocelotl commented May 27, 2021

@adamantike is this still WIP? If so, please mark it as draft.

Oh, I see, please edit the PR description instead.

@adamantike
Copy link
Contributor Author

adamantike commented May 30, 2021

I have rebased, fixed some comments, and added support for traced request attributes! The only issue I still find is this one:

  • Tests are passing, but triggering log error: opentelemetry.context:__init__.py:146 Failed to detach context.

However, it could be related to how Pytest handles ContextVars and contexts related to attached tokens, based on pytest-dev/pytest-asyncio#127

@adamantike adamantike force-pushed the django-asgi branch 4 times, most recently from d7956d5 to f33b9cd Compare May 30, 2021 19:19
@adamantike
Copy link
Contributor Author

adamantike commented Jun 3, 2021

Please do not merge yet. I just found that the Failed to detach context log messages also appear in a real Django project, when manually installing this branch, for a project using Gunicorn with Uvicorn workers.

I will need to debug what's going on. I'm open to any insights about what could be generating this issue.

@adamantike
Copy link
Contributor Author

I have filed https://code.djangoproject.com/ticket/32815, to determine if the bug is in Django's codebase instead.

adamantike added a commit to adamantike/opentelemetry-python-contrib that referenced this pull request Jun 5, 2021
New-style middlewares were [introduced][django_1_10_changelog] in Django
`1.10`, and also `settings.MIDDLEWARE_CLASSES` was
[removed][django_2_0_changelog] in Django 2.0.

This change migrates the Django middleware to conform with the new
style. This is useful because it will help us solve the pending issue in
open-telemetry#391.

By having a single entrypoint to the middleware, `__call__`, which is
[wrapped with `sync_to_async` just once][call_wrapped] for async
requests, we avoid the [issue][asgiref_issue] where a `ContextVar`
cannot be reset from a different context.

With the current deprecated `MiddlewareMixin` way, both `process_request`
and `process_response` were being
[wrapped separately with `sync_to_async`][mixin_wrapped], which was the
source of the mentioned issue.

[django_1_10_changelog]: https://docs.djangoproject.com/en/3.2/releases/1.10/#new-style-middleware
[django_2_0_changelog]: https://docs.djangoproject.com/en/3.2/releases/2.0/#features-removed-in-2-0
[call_wrapped]: https://github.com/django/django/blob/213850b4b9641bdcb714172999725ec9aa9c9e84/django/core/handlers/base.py#L54-L57
[mixin_wrapped]: https://github.com/django/django/blob/213850b4b9641bdcb714172999725ec9aa9c9e84/django/utils/deprecation.py#L137-L147
[asgiref_issue]: django/asgiref#267
adamantike added a commit to adamantike/opentelemetry-python-contrib that referenced this pull request Jun 5, 2021
New-style middlewares were [introduced][django_1_10_changelog] in Django
`1.10`, and also `settings.MIDDLEWARE_CLASSES` was
[removed][django_2_0_changelog] in Django 2.0.

This change migrates the Django middleware to conform with the new
style. This is useful because it will help us solve the pending issue in
open-telemetry#391.

By having a single entrypoint to the middleware, `__call__`, which is
[wrapped with `sync_to_async` just once][call_wrapped] for async
requests, we avoid the [issue][asgiref_issue] where a `ContextVar`
cannot be reset from a different context.

With the current deprecated `MiddlewareMixin` way, both `process_request`
and `process_response` were being
[wrapped separately with `sync_to_async`][mixin_wrapped], which was the
source of the mentioned issue.

[django_1_10_changelog]: https://docs.djangoproject.com/en/3.2/releases/1.10/#new-style-middleware
[django_2_0_changelog]: https://docs.djangoproject.com/en/3.2/releases/2.0/#features-removed-in-2-0
[call_wrapped]: https://github.com/django/django/blob/213850b4b9641bdcb714172999725ec9aa9c9e84/django/core/handlers/base.py#L54-L57
[mixin_wrapped]: https://github.com/django/django/blob/213850b4b9641bdcb714172999725ec9aa9c9e84/django/utils/deprecation.py#L137-L147
[asgiref_issue]: django/asgiref#267
@adamantike
Copy link
Contributor Author

Filed #533, which circumvents the Failed to detach context error by simplifying how the Django middleware is wrapped with sync_to_async. With that change applied, the issue doesn't occur anymore!

@adamantike
Copy link
Contributor Author

Rebased now that #533 was merged. All the Failed to detach context errors have been fixed, and this is now ready for another review!

@@ -45,6 +45,8 @@ install_requires =
opentelemetry-semantic-conventions == 0.23.dev0

[options.extras_require]
asgi =
opentelemetry-instrumentation-asgi == 0.23.dev0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be documented so people using ASGI with Django realize they need to install these extra deps to make it work.

How do ASGI deps work with Django? Do regular Django projects need to install additional packages to make ASGI work or does Django now ship with (or depend on) bundled ASGI module. If some level of ASGI support is always present in Django now, may be we can add this as a main dependency instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good discussion topic. Django doesn't require any additional/extra dependencies for ASGI to work (aside from running it using Uvicorn/Daphne), and bundles asgiref since the async introduction in Django 3.0: django/django@a415ce7#diff-60f61ab7a8d1910d86d9fda2261620314edcae5894d5aaa236b821c7256badd7

This dependency doesn't include other heavy dependencies that aren't already required by opentelemetry-instrumentation-wsgi. However, we also have to consider that ASGI is only present since Django 3.x, while this project tries to maintain support for older Django versions.

I think the only missing change on this PR, if we go with adding this to install_requires, is to make _is_asgi_supported also depend on DJANGO_3_0 being True, as we won't receive any ImportError anymore when importing ASGI dependencies.

@@ -141,12 +166,23 @@ def process_request(self, request):
if self._excluded_urls.url_disabled(request.build_absolute_uri("?")):
return

is_asgi_request = _is_asgi_request(request)
if is_asgi_request and not _is_asgi_supported:
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can a django service/middleware handle both WSGI and ASGI requests at the same time? If it can only handle one kind, may be this can can run in middleware init and result can be stored in an instance variable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least is_asgi_supported can be called before _is_asgi_request and that way we can avoid running the type check for non-ASGI requests.

Copy link
Contributor Author

@adamantike adamantike Sep 21, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can a django service/middleware handle both WSGI and ASGI requests at the same time?

Yes, it can handle both, so we need to check which kind of request we're receiving on each call.

At least is_asgi_supported can be called before _is_asgi_request and that way we can avoid running the type check for non-ASGI requests.

As we're using is_asgi_request multiple times on this method, I don't think we can avoid the type-check call.

  • If ASGI is not supported, we need to know if the current request is ASGI.
  • If ASGI is supported, we need to know it nevertheless, to decide on which carrier, carrier_getter, etc. to use.

if is_asgi_request:
attributes = collect_request_attributes(request.scope)
else:
attributes = collect_request_attributes(request_meta)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also set a variable named carrier to request.scope or request_meta in the similar is_asgi_request check above and here just call the function on carrier

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, applied!

# ASGI requests include extra attributes in request.scope.headers. For this reason,
# we need to build an object with the union of `request` and `request.scope.headers`
# contents, for the extract_attributes_from_object function to be able to retrieve
# attributes from it.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't have to use extract_attribute_from_object. I think it'd be simpler to just write a dedicated extract function for Django ASGIRequest objects instead of doing all this just to be able to use the existing func.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make more sense, to keep using the extract_attributes_from_object util, to go with a composition approach?

attributes = extract_attributes_from_object(
    request, self._traced_request_attrs, attributes
)
if is_asgi_request:
    # ASGI requests include extra attributes in request.scope.headers.
    attributes = extract_attributes_from_object(
        types.SimpleNamespace(**{
            name.decode("latin1"): value.decode("latin1")
            for name, value in request.scope.get("headers", [])
        }),
        self._traced_request_attrs,
        attributes,
    )

"{} {}".format(response.status_code, response.reason_phrase),
response,
)
if is_asgi_request:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does add_response_attributes only set a status code? The name suggests it does more than that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does actually. It adds HTTP status code attribute in addition to setting status on the span. Plus is also has some additional logic which we miss out on here. Any reason we cannot just use that function for ASGI as well?

def add_response_attributes(
span, start_response_status, response_headers
): # pylint: disable=unused-argument
"""Adds HTTP response attributes to span using the arguments
passed to a PEP3333-conforming start_response callable."""
if not span.is_recording():
return
status_code, _ = start_response_status.split(" ", 1)
try:
status_code = int(status_code)
except ValueError:
span.set_status(
Status(
StatusCode.ERROR,
"Non-integer HTTP status: " + repr(status_code),
)
)
else:
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code)
span.set_status(Status(http_status_to_status_code(status_code)))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the functions are right now, I don't see any needed changes for both WSGI and ASGI to make the same changes to the current span:

  • WSGI:
    def add_response_attributes(
    span, start_response_status, response_headers
    ): # pylint: disable=unused-argument
    """Adds HTTP response attributes to span using the arguments
    passed to a PEP3333-conforming start_response callable."""
    if not span.is_recording():
    return
    status_code, _ = start_response_status.split(" ", 1)
    try:
    status_code = int(status_code)
    except ValueError:
    span.set_status(
    Status(
    StatusCode.ERROR,
    "Non-integer HTTP status: " + repr(status_code),
    )
    )
    else:
    span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code)
    span.set_status(Status(http_status_to_status_code(status_code)))
  • ASGI:
    def set_status_code(span, status_code):
    """Adds HTTP response attributes to span using the status_code argument."""
    if not span.is_recording():
    return
    try:
    status_code = int(status_code)
    except ValueError:
    span.set_status(
    Status(
    StatusCode.ERROR,
    "Non-integer HTTP status: " + repr(status_code),
    )
    )
    else:
    span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code)
    span.set_status(Status(http_status_to_status_code(status_code)))

response,
)
if is_asgi_request:
set_status_code(span, response.status_code)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTP status codes need to be translated to OpenTelemetry status codes before setting the span status e.g,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also think this is already fixed in the current set_status_code implementation.

@owais owais self-assigned this Aug 19, 2021
@owais
Copy link
Contributor

owais commented Aug 19, 2021

@adamantike Hi, thanks for taking this up. Are you still planning to finish this?

@adamantike
Copy link
Contributor Author

@owais, I'll be tackling your comments this week, so we can move forward! Thanks for all the feedback

@adamantike
Copy link
Contributor Author

@owais @ocelotl, sorry for the delay. This is now ready for another review!

@owais
Copy link
Contributor

owais commented Oct 11, 2021

@adamantike sorry for the delay. can you please resolve the conflicts and rebase/merge with main? We can try to include it in the release this week.

This diff adds `asgi` as an extra, and uses its methods if the current
request is an `ASGIRequest`.

I still need to dig deeper in the current test suite, to find a way to
duplicate the tests in
`instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py`,
but using an
[`AsyncClient`](https://docs.djangoproject.com/en/3.1/topics/testing/tools/#testing-asynchronous-code).

Fixes open-telemetry#165, open-telemetry#185, open-telemetry#280, open-telemetry#334.
Add support for the `extract_attributes_from_object` function to
retrieve traced attributes that are added to `request.scope.headers`.

This is added based on the code found in [Django's `AsyncRequestFactory`](
https://github.com/django/django/blob/a948d9df394aafded78d72b1daa785a0abfeab48/django/test/client.py#L550-L553),
which indicates that any extra argument sent to the `AsyncClient` is set
in the `request.scope.headers` list.

Also:
* Stop inheriting from `SimpleTestCase` in async Django tests, to
  simplify test inheritance.
* Correctly configure Django settings in tests, before calling parent's
  `setUpClass` method.
* Rebase and port the latest changes to Django WSGI tests, to be
  supported by ASGI ones.
@adamantike
Copy link
Contributor Author

No problem! I have rebased and resolved existing conflicts.
I think the pending discussion here is regarding keeping opentelemetry-instrumentation-asgi in extras_require (and where/how to document it), or move it to install_requires.

@owais
Copy link
Contributor

owais commented Oct 11, 2021

extra_requires sounds better to me if we are pulling in deps that non-asgi users won't need.

@adamantike
Copy link
Contributor Author

I agree, though by that same approach, a future breaking change should be introduced for wsgi to also become an extra, so users exclusively having ASGI don't get WSGI dependencies.

This is where pypa/setuptools#1139 would come useful if implemented. If no extras are specified, you get both WSGI and ASGI. If you want to cut down on dependencies, you need to specify which flow your app is going to use.

@owais
Copy link
Contributor

owais commented Oct 12, 2021

Oh I've been waiting on pypa/setuptools#1139 for longer than I remember. It's such an obvious feature to have :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
4 participants