diff --git a/CHANGELOG.md b/CHANGELOG.md
index f451b86..a54203f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,11 @@
   ([PR #205](https://github.com/scoutapp/scout_apm_python/pull/205)).
 - Track Celery task time in queue with context tag `queue_time`
   ([PR #206](https://github.com/scoutapp/scout_apm_python/pull/206)).
+- Improve Django admin views' traced names. Before all admin classes' traces
+  would be merged by function name such as
+  `django.contrib.admin.options.changelist_view`. Now traces appear per admin
+  class, for example `django.contrib.auth.admin.UserAdmin.changelist_view`
+  ([PR #226](https://github.com/scoutapp/scout_apm_python/pull/226)).
 
 ## [2.3.0] 2019-08-04
 
diff --git a/src/scout_apm/django/middleware.py b/src/scout_apm/django/middleware.py
index 84292ab..cfc64da 100644
--- a/src/scout_apm/django/middleware.py
+++ b/src/scout_apm/django/middleware.py
@@ -11,7 +11,20 @@ logger = logging.getLogger(__name__)
 
 
 def get_operation_name(request):
+    view_func = request.resolver_match.func
     view_name = request.resolver_match._func_path
+
+    if hasattr(view_func, "model_admin"):
+        # Seems to comes from Django admin (attribute only set on Django 1.9+)
+        admin_class = view_func.model_admin.__class__
+        view_name = (
+            admin_class.__module__
+            + "."
+            + admin_class.__name__
+            + "."
+            + view_func.__name__
+        )
+
     return "Controller/" + view_name
 
 

Name Django Admin Views clearer
Currently, Django Admin views are captured with confusingly internal names. We should capture something clearer

![_django_object_actions_utils_change_view](https://user-images.githubusercontent.com/6119/41299370-829159f6-6e20-11e8-8bc1-a6a41fba324d.png)

diff --git a/tests/integration/django_app.py b/tests/integration/django_app.py
index a6bac45..662c09e 100644
--- a/tests/integration/django_app.py
+++ b/tests/integration/django_app.py
@@ -1,12 +1,8 @@
 # coding: utf-8
-
 from __future__ import absolute_import, division, print_function, unicode_literals
 
 import django
 from django.conf import settings
-from django.db import connection
-from django.http import HttpResponse
-from django.template import engines
 
 config = {
     "ALLOWED_HOSTS": ["*"],
@@ -18,21 +14,57 @@ config = {
     # "DEBUG_PROPAGATE_EXCEPTIONS": True,
     "ROOT_URLCONF": __name__,
     "SECRET_KEY": "********",
-    "TEMPLATES": [{"BACKEND": "django.template.backends.django.DjangoTemplates"}],
+    "TEMPLATES": [
+        {
+            "BACKEND": "django.template.backends.django.DjangoTemplates",
+            "APP_DIRS": True,
+            "OPTIONS": {
+                "context_processors": [
+                    "django.contrib.auth.context_processors.auth",
+                    "django.contrib.messages.context_processors.messages",
+                ]
+            },
+        }
+    ],
     "TIME_ZONE": "America/Chicago",
     # Setup as per https://docs.scoutapm.com/#django but *without* the settings
     # - these are temporarily set by app_with_scout() to avoid state leak
-    "INSTALLED_APPS": ["scout_apm.django"],
+    "INSTALLED_APPS": [
+        "django.contrib.admin",
+        "django.contrib.auth",
+        "django.contrib.contenttypes",
+        "django.contrib.messages",
+        "django.contrib.sessions",
+        "scout_apm.django",
+    ],
 }
 
 if django.VERSION > (1, 10):
-    config["MIDDLEWARE"] = []
+    config["MIDDLEWARE"] = [
+        "django.contrib.sessions.middleware.SessionMiddleware",
+        "django.contrib.auth.middleware.AuthenticationMiddleware",
+        "django.contrib.messages.middleware.MessageMiddleware",
+    ]
 else:
-    config["MIDDLEWARE_CLASSES"] = []
+    config["MIDDLEWARE_CLASSES"] = [
+        "django.contrib.sessions.middleware.SessionMiddleware",
+        "django.contrib.auth.middleware.AuthenticationMiddleware",
+        "django.contrib.messages.middleware.MessageMiddleware",
+    ]
 
 
 settings.configure(**config)
 
+if True:
+    # Old versions of Django, at least 1.8, need settings configured before
+    # other bits are imported such as Admin. Hence do the imports here, under
+    # an 'if True' to appease isort.
+    from django.contrib import admin
+    from django.db import connection
+    from django.http import HttpResponse
+    from django.template import engines
+    from django.utils.functional import SimpleLazyObject
+
 
 def home(request):
     return HttpResponse("Welcome home.")
@@ -65,23 +97,31 @@ def template(request):
     return HttpResponse(template.render(context))
 
 
-try:
-    from django.urls import path
-
-    urlpatterns = [
-        path("", home),
-        path("hello/", hello),
-        path("crash/", crash),
-        path("sql/", sql),
-        path("template/", template),
-    ]
-except ImportError:  # Django < 2.0
-    from django.conf.urls import url
-
-    urlpatterns = [
-        url(r"^$", home),
-        url(r"^hello/$", hello),
-        url(r"^crash/$", crash),
-        url(r"^sql/$", sql),
-        url(r"^template/$", template),
-    ]
+@SimpleLazyObject
+def urlpatterns():
+    """
+    URL's as a lazy object because they touch admin.site.urls and that isn't
+    ready until django.setup() has been called
+    """
+    try:
+        from django.urls import path
+
+        return [
+            path("", home),
+            path("hello/", hello),
+            path("crash/", crash),
+            path("sql/", sql),
+            path("template/", template),
+            path("admin/", admin.site.urls),
+        ]
+    except ImportError:  # Django < 2.0
+        from django.conf.urls import url
+
+        return [
+            url(r"^$", home),
+            url(r"^hello/$", hello),
+            url(r"^crash/$", crash),
+            url(r"^sql/$", sql),
+            url(r"^template/$", template),
+            url(r"^admin/", admin.site.urls),
+        ]
diff --git a/tests/integration/test_django.py b/tests/integration/test_django.py
index 748cbb1..da52591 100644
--- a/tests/integration/test_django.py
+++ b/tests/integration/test_django.py
@@ -9,6 +9,7 @@ import django
 import pytest
 from django.apps import apps
 from django.conf import settings
+from django.core.management import call_command
 from django.core.wsgi import get_wsgi_application
 from django.http import HttpResponse
 from django.test.utils import override_settings
@@ -57,7 +58,13 @@ def app_with_scout(**settings):
     with override_settings(**settings):
         # Have to create a new WSGI app each time because the middleware stack
         # within it is static
-        yield get_wsgi_application()
+        app = get_wsgi_application()
+        # Run Django checks on first use
+        if not getattr(app_with_scout, "startup_ran", False):
+            call_command("migrate")
+            call_command("check")
+            app_with_scout.startup_ran = True
+        yield app
 
 
 def test_on_setting_changed_application_root():
@@ -214,6 +221,48 @@ def test_template(tracked_requests):
     ]
 
 
+@pytest.mark.skipif(
+    django.VERSION < (1, 9),
+    reason="Django 1.9 added the model_admin attribute this functionality depends on",
+)
+@pytest.mark.parametrize(
+    "url, expected_op_name",
+    [
+        [
+            "/admin/auth/user/",
+            "Controller/django.contrib.auth.admin.UserAdmin.changelist_view",
+        ],
+        [
+            "/admin/auth/user/1/change/",
+            "Controller/django.contrib.auth.admin.UserAdmin.change_view",
+        ],
+    ],
+)
+def test_admin_view_operation_name(url, expected_op_name, tracked_requests):
+    with app_with_scout() as app:
+        from django.contrib.auth.models import User
+
+        User.objects.create_superuser(
+            id=1, username="admin", email="admin@example.com", password="password"
+        )
+        test_app = TestApp(app)
+        login_response = test_app.get("/admin/login/")
+        assert login_response.status_int == 200
+        form = login_response.form
+        form["username"] = "admin"
+        form["password"] = "password"
+        form.submit()
+        response = test_app.get(url)
+
+    assert response.status_int == 200
+    # 3 requests for login GET and POST, then admin page
+    assert len(tracked_requests) == 3
+    # We only care about the last
+    tracked_request = tracked_requests[-1]
+    span = tracked_request.complete_spans[-2]
+    assert span.operation == expected_op_name
+
+
 @pytest.mark.xfail(reason="Test setup doesn't reset state fully at the moment.")
 def test_no_monitor(tracked_requests):
     with app_with_scout(SCOUT_MONITOR=False) as app:
@@ -236,9 +285,9 @@ def fake_authentication_middleware(get_response):
 @skip_unless_new_style_middleware
 def test_username(tracked_requests):
     new_middleware = (
-        settings.MIDDLEWARE[:1]
+        settings.MIDDLEWARE[:-1]
         + [__name__ + ".fake_authentication_middleware"]
-        + settings.MIDDLEWARE[1:]
+        + settings.MIDDLEWARE[-1:]
     )
     with app_with_scout(MIDDLEWARE=new_middleware) as app:
         response = TestApp(app).get("/hello/")
@@ -262,9 +311,9 @@ def crashy_authentication_middleware(get_response):
 @skip_unless_new_style_middleware
 def test_username_exception(tracked_requests):
     new_middleware = (
-        settings.MIDDLEWARE[:1]
+        settings.MIDDLEWARE[:-1]
         + [__name__ + ".crashy_authentication_middleware"]
-        + settings.MIDDLEWARE[1:]
+        + settings.MIDDLEWARE[-1:]
     )
     with app_with_scout(MIDDLEWARE=new_middleware) as app:
         response = TestApp(app).get("/")
@@ -285,9 +334,9 @@ class FakeAuthenticationMiddleware(object):
 @skip_unless_old_style_middleware
 def test_old_style_username(tracked_requests):
     new_middleware = (
-        settings.MIDDLEWARE_CLASSES[:1]
+        settings.MIDDLEWARE_CLASSES[:-1]
         + [__name__ + ".FakeAuthenticationMiddleware"]
-        + settings.MIDDLEWARE_CLASSES[1:]
+        + settings.MIDDLEWARE_CLASSES[-1:]
     )
     with app_with_scout(MIDDLEWARE_CLASSES=new_middleware) as app:
         response = TestApp(app).get("/")
@@ -309,9 +358,9 @@ class CrashyAuthenticationMiddleware(object):
 @skip_unless_old_style_middleware
 def test_old_style_username_exception(tracked_requests):
     new_middleware = (
-        settings.MIDDLEWARE_CLASSES[:1]
+        settings.MIDDLEWARE_CLASSES[:-1]
         + [__name__ + ".CrashyAuthenticationMiddleware"]
-        + settings.MIDDLEWARE_CLASSES[1:]
+        + settings.MIDDLEWARE_CLASSES[-1:]
     )
     with app_with_scout(MIDDLEWARE_CLASSES=new_middleware) as app:
         response = TestApp(app).get("/")
@@ -398,7 +447,7 @@ class OldStyleOnRequestResponseMiddleware:
 
 
 @skip_unless_old_style_middleware
-@pytest.mark.parametrize("middleware_index", [0, 1, 2])
+@pytest.mark.parametrize("middleware_index", [0, 1, 999])
 def test_old_style_on_request_response_middleware(middleware_index, tracked_requests):
     """
     Test the case that a middleware got added/injected that generates a
@@ -425,7 +474,7 @@ class OldStyleOnResponseResponseMiddleware:
 
 
 @skip_unless_old_style_middleware
-@pytest.mark.parametrize("middleware_index", [0, 1, 2])
+@pytest.mark.parametrize("middleware_index", [0, 1, 999])
 def test_old_style_on_response_response_middleware(middleware_index, tracked_requests):
     """
     Test the case that a middleware got added/injected that generates a fresh
@@ -452,7 +501,7 @@ class OldStyleOnViewResponseMiddleware:
 
 
 @skip_unless_old_style_middleware
-@pytest.mark.parametrize("middleware_index", [0, 1, 2])
+@pytest.mark.parametrize("middleware_index", [0, 1, 999])
 def test_old_style_on_view_response_middleware(middleware_index, tracked_requests):
     """
     Test the case that a middleware got added/injected that generates a fresh
@@ -473,7 +522,7 @@ def test_old_style_on_view_response_middleware(middleware_index, tracked_request
     # If the middleware is before OldStyleViewMiddleware, its process_view
     # won't be called and we won't know to mark the request as real, so it
     # won't be tracked.
-    if middleware_index < 2:
+    if middleware_index != 999:
         assert len(tracked_requests) == 0
     else:
         assert len(tracked_requests) == 1
@@ -485,7 +534,7 @@ class OldStyleOnExceptionResponseMiddleware:
 
 
 @skip_unless_old_style_middleware
-@pytest.mark.parametrize("middleware_index", [0, 1, 2])
+@pytest.mark.parametrize("middleware_index", [0, 1, 999])
 def test_old_style_on_exception_response_middleware(middleware_index, tracked_requests):
     """
     Test the case that a middleware got added/injected that generates a
@@ -509,7 +558,7 @@ def test_old_style_on_exception_response_middleware(middleware_index, tracked_re
     # its process_exception won't be called so we won't know it's an error.
     # Nothing we can do there - but it's a rare case, since we programatically
     # add our middleware at the end of the stack.
-    if middleware_index != 2:
+    if middleware_index != 999:
         assert tracked_requests[0].tags["error"] == "true"
 
 
@@ -519,7 +568,7 @@ class OldStyleExceptionOnRequestMiddleware:
 
 
 @skip_unless_old_style_middleware
-@pytest.mark.parametrize("middleware_index", [0, 1, 2])
+@pytest.mark.parametrize("middleware_index", [0, 1, 999])
 def test_old_style_exception_on_request_middleware(middleware_index, tracked_requests):
     """
     Test the case that a middleware got added/injected that raises an exception

pip install -e .[dev]
parse_log_pytest
requirements.txt
black flake8 isort tox bottle celery Django elasticsearch flask flask-sqlalchemy jinja2 mock pymongo pyramid pytest pytest-travis-fold pytest-cov redis six sqlalchemy starlette urllib3 webtest
apt-get update apt-get install -y gcc
3.7
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
amqp==5.3.1
anyio==3.7.1
asgiref==3.7.2
async-timeout==4.0.3
beautifulsoup4==4.13.3
billiard==3.6.4.0
black==23.3.0
bottle==0.13.2
cached-property==1.5.2
celery==5.2.7
certifi @ file:///croot/certifi_1671487769961/work/certifi
charset-normalizer==3.4.1
click==8.1.8
click-didyoumean==0.3.1
click-plugins==1.1.1
click-repl==0.3.0
coverage==7.2.7
distlib==0.3.9
Django==3.2.25
dnspython==2.3.0
elastic-transport==8.13.1
elasticsearch==8.14.0
exceptiongroup==1.2.2
filelock==3.12.2
flake8==5.0.4
Flask==2.2.5
Flask-SQLAlchemy==3.0.5
greenlet==3.1.1
hupper==1.12.1
idna==3.10
importlib-metadata==4.2.0
iniconfig==2.0.0
isort==5.11.5
itsdangerous==2.1.2
Jinja2==3.1.6
kombu==5.2.4
MarkupSafe==2.1.5
mccabe==0.7.0
mock==5.2.0
mypy-extensions==1.0.0
packaging==24.0
PasteDeploy==3.1.0
pathspec==0.11.2
plaster==1.1.2
plaster-pastedeploy==1.0.1
platformdirs==2.6.2
pluggy==1.2.0
prompt_toolkit==3.0.48
psutil==7.0.0
py==1.11.0
pycodestyle==2.9.1
pyflakes==2.5.0
pymongo==4.7.3
pyramid==2.0.2
pytest==7.4.4
pytest-cov==4.1.0
pytest-travis-fold==1.3.0
pytz==2025.2
redis==5.0.8
requests==2.31.0
-e git+https://github.com/scoutapp/scout_apm_python.git@7835a02aa2cbfdaaaf5e5e77a67d5d5ff654d3a9#egg=scout_apm
six==1.17.0
sniffio==1.3.1
soupsieve==2.4.1
SQLAlchemy==2.0.40
sqlparse==0.4.4
starlette==0.29.0
tomli==2.0.1
tox==3.28.0
translationstring==1.4
typed-ast==1.5.5
typing_extensions==4.7.1
urllib3==2.0.7
venusian==3.1.1
vine==5.1.0
virtualenv==20.16.2
waitress==2.1.2
wcwidth==0.2.13
WebOb==1.8.9
WebTest==3.0.1
Werkzeug==2.2.3
zipp==3.15.0
zope.deprecation==5.0
zope.interface==6.4.post2

name: scout_apm_python
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=22.3.1=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - amqp==5.3.1
      - anyio==3.7.1
      - asgiref==3.7.2
      - async-timeout==4.0.3
      - beautifulsoup4==4.13.3
      - billiard==3.6.4.0
      - black==23.3.0
      - bottle==0.13.2
      - cached-property==1.5.2
      - celery==5.2.7
      - charset-normalizer==3.4.1
      - click==8.1.8
      - click-didyoumean==0.3.1
      - click-plugins==1.1.1
      - click-repl==0.3.0
      - coverage==7.2.7
      - distlib==0.3.9
      - django==3.2.25
      - dnspython==2.3.0
      - elastic-transport==8.13.1
      - elasticsearch==8.14.0
      - exceptiongroup==1.2.2
      - filelock==3.12.2
      - flake8==5.0.4
      - flask==2.2.5
      - flask-sqlalchemy==3.0.5
      - greenlet==3.1.1
      - hupper==1.12.1
      - idna==3.10
      - importlib-metadata==4.2.0
      - iniconfig==2.0.0
      - isort==5.11.5
      - itsdangerous==2.1.2
      - jinja2==3.1.6
      - kombu==5.2.4
      - markupsafe==2.1.5
      - mccabe==0.7.0
      - mock==5.2.0
      - mypy-extensions==1.0.0
      - packaging==24.0
      - pastedeploy==3.1.0
      - pathspec==0.11.2
      - plaster==1.1.2
      - plaster-pastedeploy==1.0.1
      - platformdirs==2.6.2
      - pluggy==1.2.0
      - prompt-toolkit==3.0.48
      - psutil==7.0.0
      - py==1.11.0
      - pycodestyle==2.9.1
      - pyflakes==2.5.0
      - pymongo==4.7.3
      - pyramid==2.0.2
      - pytest==7.4.4
      - pytest-cov==4.1.0
      - pytest-travis-fold==1.3.0
      - pytz==2025.2
      - redis==5.0.8
      - requests==2.31.0
      - six==1.17.0
      - sniffio==1.3.1
      - soupsieve==2.4.1
      - sqlalchemy==2.0.40
      - sqlparse==0.4.4
      - starlette==0.29.0
      - tomli==2.0.1
      - tox==3.28.0
      - translationstring==1.4
      - typed-ast==1.5.5
      - typing-extensions==4.7.1
      - urllib3==2.0.7
      - venusian==3.1.1
      - vine==5.1.0
      - virtualenv==20.16.2
      - waitress==2.1.2
      - wcwidth==0.2.13
      - webob==1.8.9
      - webtest==3.0.1
      - werkzeug==2.2.3
      - zipp==3.15.0
      - zope-deprecation==5.0
      - zope-interface==6.4.post2
prefix: /opt/conda/envs/scout_apm_python

tests/integration/test_django.py::test_admin_view_operation_name[/admin/auth/user/-Controller/django.contrib.auth.admin.UserAdmin.changelist_view] tests/integration/test_django.py::test_admin_view_operation_name[/admin/auth/user/1/change/-Controller/django.contrib.auth.admin.UserAdmin.change_view]
tests/integration/test_django.py::test_on_setting_changed_application_root tests/integration/test_django.py::test_on_setting_changed_monitor tests/integration/test_django.py::test_home tests/integration/test_django.py::test_home_ignored tests/integration/test_django.py::test_user_ip[headers0-extra_environ0-None] tests/integration/test_django.py::test_user_ip[headers1-extra_environ1-1.1.1.1] tests/integration/test_django.py::test_user_ip[headers2-extra_environ2-1.1.1.1] tests/integration/test_django.py::test_user_ip[headers3-extra_environ3-1.1.1.1] tests/integration/test_django.py::test_user_ip[headers4-extra_environ4-1.1.1.1] tests/integration/test_django.py::test_user_ip[headers5-extra_environ5-1.1.1.1] tests/integration/test_django.py::test_user_ip[headers6-extra_environ6-1.1.1.1] tests/integration/test_django.py::test_user_ip[headers7-extra_environ7-1.1.1.1] tests/integration/test_django.py::test_user_ip[headers8-extra_environ8-1.1.1.1] tests/integration/test_django.py::test_user_ip[headers9-extra_environ9-1.1.1.1] tests/integration/test_django.py::test_hello tests/integration/test_django.py::test_not_found tests/integration/test_django.py::test_server_error tests/integration/test_django.py::test_sql tests/integration/test_django.py::test_sql_capture_backtrace tests/integration/test_django.py::test_template tests/integration/test_django.py::test_username tests/integration/test_django.py::test_username_exception tests/integration/test_django.py::test_queue_time[X-Queue-Start] tests/integration/test_django.py::test_queue_time[X-Request-Start] tests/integration/test_django.py::test_install_middleware_new_style[True-list] tests/integration/test_django.py::test_install_middleware_new_style[True-tuple] tests/integration/test_django.py::test_install_middleware_new_style[False-list] tests/integration/test_django.py::test_install_middleware_new_style[False-tuple]
CHANGELOG.md src/scout_apm/django/middleware.py
CHANGELOG.md src/scout_apm/django/middleware.py