Skip to content

Conversation

@Lucius1274
Copy link
Collaborator

@Lucius1274 Lucius1274 commented Dec 2, 2025

Summary by CodeRabbit

  • New Features
    • API usage tracking added to record per-user request counts, total transfer size, and last-request time.
    • New "API Usage" section in the Admin UI showing user, last request time, formatted transfer size, and request totals.
    • Admin interface enhancements to view, filter, and sort API usage records.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 2, 2025

Walkthrough

Adds per-user API usage tracking: a new Django model (APIUsage) with migration, admin registration and UI display, a utility to collect recent usage, and view-layer session tracking (including streaming wrappers) that records transfer size, request counts, and last access times.

Changes

Cohort / File(s) Summary
Database Model & Migration
adit/dicom_web/models.py, adit/dicom_web/migrations/0004_apiusage.py
Adds APIUsage model (time_last_accessed, total_transfer_size, total_number_requests, owner FK), an index on -time_last_accessed, and a unique constraint on owner; migration creates corresponding table and indexes.
View Layer Tracking
adit/dicom_web/views.py
Introduces session tracking helpers (_create_statistic, _finalize_statistic, track_session context manager), a _StreamingSessionWrapper for streaming responses, integrates tracking into multiple API views (query and streaming endpoints), updates query-parameter helpers and some view signatures to pass study/series IDs for accurate accounting.
Admin Interface
adit/dicom_web/admin.py
Registers APIUsage with a custom APIUsageAdmin showing owner username (sortable), human-readable total transfer size, request count and last access time; adds list filters and search.
Admin UI Template
adit/core/templates/core/admin_section.html
Inserts an "API Usage" table section (User, Time of last request, Total response Size, Total number of requests) into the admin page rendering api_stats.
Utility Function
adit/dicom_web/apps.py
Adds collect_latest_api_usage() returning the latest three APIUsage records ordered by time_last_accessed descending.
Core Views Integration
adit/core/views.py
Imports and calls collect_latest_api_usage() in admin_section, adding api_stats to the template context.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant APIView as API View
    participant Track as track_session
    participant DB as APIUsage DB
    participant Stream as Stream Wrapper
    participant Renderer as Response Renderer
    participant AdminView as Admin Section View
    participant Util as collect_latest_api_usage
    participant Template as Admin Template

    Note over Client, APIView: API request handling with tracking
    Client->>APIView: HTTP request
    APIView->>Track: enter track_session(user)
    Track->>DB: get or create APIUsage for owner
    DB-->>Track: session object
    alt streaming response
        APIView->>Stream: wrap streaming iterator/response
        Stream->>Renderer: yield chunks
        Renderer-->>Stream: chunk sizes
        Stream->>Track: accumulate transfer size
    else non-streaming
        APIView->>Renderer: render full response
        Renderer-->>Track: report response size
    end
    APIView->>Track: exit track_session -> finalize
    Track->>DB: atomic update F(total_transfer_size += N, total_number_requests += 1), update time_last_accessed
    DB-->>Track: updated
    APIView-->>Client: return response

    Note over AdminView, Template: Admin display latest usage
    AdminView->>Util: collect_latest_api_usage()
    Util->>DB: query last 3 APIUsage ordered desc
    DB-->>Util: records
    Util-->>AdminView: api_stats
    AdminView->>Template: render admin_section with api_stats
    Template->>Template: iterate and format table rows
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

  • Areas needing extra attention:
    • adit/dicom_web/views.py: context manager, streaming wrapper, race conditions, and correct use of F() for atomic updates.
    • Migration 0004_apiusage.py: index and unique constraint correctness and backwards/upgrade implications.
    • Admin/admin template integration: ensure api_stats context shape matches template iteration and formatting helpers (e.g., filesizeformat).
    • Changes to view signatures (QueryImagesAPIView.get, _extract_query_parameters): verify all call sites and URL routing remain compatible.

Poem

🐇 I counted bytes by moonlit code,

Hopped through streams where data flowed,
Per user marks I gently sowed,
In admin rows the numbers glowed,
A tiny hop — statistics showed. 🎉

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Api statistics' directly describes the main feature added: comprehensive API usage statistics tracking across the codebase, including a new APIUsage model, admin interface, and integration throughout the views layer.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch api_statistics

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link

claude bot commented Dec 2, 2025

Pull Request Review: API Statistics

Overview

This PR adds API usage tracking for DICOM Web endpoints, including request counts, transfer sizes, and timestamps. The implementation adds a new APIUsage model and integrates tracking into all API views.


Code Quality & Best Practices

✅ Strengths

  • Good separation of concerns: The _StreamingSessionWrapper class cleanly handles size tracking for streaming responses
  • Consistent implementation: All API endpoints follow the same pattern with track_session context manager
  • Proper async patterns: Correct use of @database_sync_to_async and async context managers
  • Django best practices: Proper use of F() expressions to avoid race conditions in counter updates

⚠️ Areas for Improvement

  1. Missing database index (adit/dicom_web/models.py:18)

    class APIUsage(models.Model):
        # Add Meta class with indexes for better query performance
        class Meta:
            indexes = [
                models.Index(fields=["-time_last_accessed"]),
            ]

    The collect_latest_api_usage() function queries by -time_last_accessed, but there's no index on this field.

  2. Missing unique constraint (adit/dicom_web/models.py:22)

    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="api_usage",
    )

    The code uses get_or_create(owner=user) assuming one record per user, but there's no unique constraint enforcing this. Add:

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["owner"], name="unique_owner_api_usage")
        ]
  3. Redundant null check (adit/dicom_web/views.py:75)

    if transfer_size is not None:

    The parameter has a default value of 0, so it can never be None. Remove this check or change the default to None if that's the intended behavior.

  4. Empty finally block (adit/dicom_web/views.py:88-89)

    finally:
        pass

    This is unnecessary and should be removed. The context manager doesn't need a finally block if it does nothing.

  5. Function placement (adit/dicom_web/apps.py:19-22)

    def collect_latest_api_usage():
        from .models import APIUsage
        return APIUsage.objects.order_by("-time_last_accessed")[:3]

    This function is in apps.py but is a view helper. Consider moving it to a more appropriate location like utils.py or keeping it in models.py as a manager method.


Potential Bugs & Issues

🔴 Critical

  1. Incorrect size calculation for STOW-RS (adit/dicom_web/views.py:551)

    await self._finalize_statistic(session, transfer_size=sys.getsizeof(response_data))

    sys.getsizeof() returns the Python object's memory size, NOT the serialized response size. This is inconsistent with how other endpoints calculate size using len(json.dumps(response_data).encode()). This will severely underestimate the actual transfer size.

    Recommendation: Use the same approach as other endpoints for consistency.

  2. Missing import cleanup (adit/dicom_web/views.py:3)

    import sys

    The sys import appears to only be used for the problematic sys.getsizeof() call. Once that's fixed, this import should be removed if no longer needed.

🟡 Moderate

  1. Inconsistent size tracking for streaming responses
    The _StreamingSessionWrapper calculates size by summing chunks, which is correct. However, for non-streaming JSON responses, the size is calculated from the serialized JSON. These two approaches might give slightly different results for the same data due to HTTP chunking overhead. Consider documenting this difference or standardizing the approach.

  2. QueryImagesAPIView signature change (adit/dicom_web/views.py:167)
    The function signature changed from:

    async def get(self, request: AuthenticatedApiRequest, ae_title: str, series_uid: str)

    to:

    async def get(self, request: AuthenticatedApiRequest, ae_title: str, study_uid: str, series_uid: str)

    This is a breaking API change. Ensure the URL routing was updated accordingly and existing clients won't break.


Performance Considerations

🟢 Good Practices

  • F() expressions: Using F("total_transfer_size") + transfer_size prevents race conditions and performs the update in the database, which is efficient
  • Atomic operations: get_or_create() and filter().update() are atomic operations

⚠️ Potential Issues

  1. Database writes on every request
    Every API request now performs database writes (create + update). For high-traffic APIs, this could become a bottleneck. Consider:

    • Adding database connection pooling configuration
    • Using a caching layer (Redis) for real-time stats and periodic DB syncing
    • Adding indexes as mentioned above
  2. Admin page performance (adit/core/views.py:50)

    api_stats = collect_latest_api_usage()

    This queries the database on every admin page load. For better performance, consider:

    • Caching this result with a TTL
    • Loading it asynchronously via AJAX
    • Paginating the results if the table grows large
  3. Missing pagination
    The admin display shows only the top 3 most recent users (:3 slice), but the admin interface doesn't show pagination or total count information. Users can't see historical data beyond these 3 entries.


Security Concerns

🟢 Good Practices

  • Proper authorization: The existing get_accessible_servers() method ensures users can only track their own API usage
  • No sensitive data exposure: Only metadata is tracked (sizes, counts, timestamps)

⚠️ Minor Concerns

  1. Potential information disclosure
    The admin interface shows all users' API usage to staff members. Ensure this is the intended behavior and aligns with your privacy policies. Consider adding permission checks for viewing other users' statistics.

  2. No data retention policy
    There's no mechanism to clean up old APIUsage records. Consider adding:

    • A management command to archive/delete old statistics
    • Automatic cleanup based on age
    • Data retention policy documentation
  3. Missing rate limiting
    While tracking usage is good, there's no rate limiting implementation. A user could still overwhelm the API. Consider adding rate limiting based on the tracked statistics.


Test Coverage

❌ Missing Tests

The PR doesn't include any new tests for:

  • APIUsage model creation and updates
  • Statistics tracking in API views
  • The _StreamingSessionWrapper class
  • The collect_latest_api_usage() function
  • Edge cases (concurrent requests, failed requests, streaming interruptions)

Recommendation: Add comprehensive tests, especially for:

  1. Concurrent request handling (ensure F() expressions prevent race conditions)
  2. Streaming response interruptions (ensure stats are still recorded)
  3. Failed requests (should they be counted?)
  4. Admin interface display

Template Changes

The template changes in admin_section.html look good, but consider:

  1. Adding a link to view full API usage history
  2. Showing percentage or trending information
  3. Adding filters or search functionality
  4. The column header says "Time of last request" but might be more clear as "Last Accessed"

Migration

The migration file looks correct and follows Django best practices.


Summary & Recommendations

Must Fix Before Merge

  1. 🔴 Fix sys.getsizeof() in STOW-RS to use proper serialization size calculation
  2. 🔴 Add unique constraint on APIUsage.owner field
  3. 🔴 Verify URL routing changes for QueryImagesAPIView don't break existing clients
  4. 🔴 Add database index on time_last_accessed field

Should Fix Before Merge

  1. 🟡 Add comprehensive test coverage
  2. 🟡 Remove redundant null check and empty finally block
  3. 🟡 Move collect_latest_api_usage() to a more appropriate location
  4. 🟡 Document the API signature change and migration path for clients

Consider for Future Iterations

  1. Implement caching for admin page statistics
  2. Add rate limiting based on usage statistics
  3. Implement data retention/cleanup policy
  4. Add more detailed analytics (per-endpoint breakdown, error rates, etc.)
  5. Consider using a time-series database for better performance with large datasets

Conclusion

This is a useful feature that adds important API usage tracking. The implementation is generally sound with good use of Django patterns. However, there are critical bugs (size calculation) and missing constraints that should be addressed before merging. With the suggested fixes and added tests, this will be a solid addition to the codebase.

Overall assessment: Needs changes ⚠️

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (8)
adit/core/views.py (1)

34-34: Cross-app dependency introduced from core to dicom_web.

This import creates a dependency from adit.core to adit.dicom_web. While this works, it inverts the typical dependency direction where feature apps depend on core, not vice versa. Consider moving collect_latest_api_usage to a more neutral location or having the admin section dynamically discover available statistics collectors (similar to job_stats_collectors pattern on line 37).

adit/dicom_web/apps.py (1)

19-22: Add return type annotation for clarity.

The function lacks a type hint for its return value.

-def collect_latest_api_usage():
+def collect_latest_api_usage() -> "QuerySet[APIUsage]":
     from .models import APIUsage
 
     return APIUsage.objects.order_by("-time_last_accessed")[:3]

Alternatively, if you want to return a list instead of a sliced QuerySet:

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from .models import APIUsage

def collect_latest_api_usage() -> list["APIUsage"]:
    from .models import APIUsage
    return list(APIUsage.objects.order_by("-time_last_accessed")[:3])
adit/core/templates/core/admin_section.html (1)

42-49: Consider renaming loop variable and adding empty state.

The loop variable session is misleading since the model is APIUsage, not a session. Consider renaming to usage or api_usage for clarity.

Also, consider adding an empty state message when there's no API usage data:

     <tbody>
-        {% for session in api_stats %}
+        {% for usage in api_stats %}
             <tr>
-                <td>{{ session.owner }}</td>
-                <td>{{ session.time_last_accessed }}</td>
-                <td>{{ session.total_transfer_size | filesizeformat }}</td>
-                <td>{{ session.total_number_requests }}</td>
+                <td>{{ usage.owner }}</td>
+                <td>{{ usage.time_last_accessed }}</td>
+                <td>{{ usage.total_transfer_size | filesizeformat }}</td>
+                <td>{{ usage.total_number_requests }}</td>
             </tr>
+        {% empty %}
+            <tr>
+                <td colspan="4" class="text-muted text-center">No API usage data available</td>
+            </tr>
         {% endfor %}
     </tbody>
adit/dicom_web/migrations/0004_apiusage.py (1)

16-44: Consider adding database index on time_last_accessed.

The collect_latest_api_usage() function queries ORDER BY time_last_accessed DESC. As the table grows, this could benefit from an index.

Add an index in the model's Meta class or via a subsequent migration:

class Meta:
    indexes = [
        models.Index(fields=['-time_last_accessed']),
    ]
adit/dicom_web/admin.py (1)

16-16: Reconsider total_number_requests in list_filter.

Filtering by exact request count values isn't typically useful. Consider removing it or using a custom range-based filter if needed.

-    list_filter = ("total_number_requests", "owner")
+    list_filter = ("owner",)
adit/dicom_web/models.py (1)

18-26: Clarify if APIUsage should be one-per-user.

The views use get_or_create(owner=user) suggesting one APIUsage record per user, but there's no unique=True constraint on owner. Consider adding a unique constraint to enforce this at the database level:

 class APIUsage(models.Model):
     time_last_accessed = models.DateTimeField(default=timezone.now)
     total_transfer_size = models.BigIntegerField(default=0)
     total_number_requests = models.IntegerField(default=0)
     owner = models.ForeignKey(
         settings.AUTH_USER_MODEL,
         on_delete=models.CASCADE,
-        related_name="api_usage",
+        related_name="api_usage",
+        unique=True,
     )

Or use OneToOneField if the intent is strictly one record per user:

owner = models.OneToOneField(
    settings.AUTH_USER_MODEL,
    on_delete=models.CASCADE,
    related_name="api_usage",
)
adit/dicom_web/views.py (2)

73-80: Redundant None check for transfer_size.

The parameter transfer_size has a default value of 0, so it can never be None unless explicitly passed as such (which doesn't happen in the codebase).

     @database_sync_to_async
     def _finalize_statistic(self, statistic: APIUsage, transfer_size: int = 0) -> None:
-        if transfer_size is not None:
-            APIUsage.objects.filter(pk=statistic.pk).update(
-                total_transfer_size=F("total_transfer_size") + transfer_size,
-                total_number_requests=F("total_number_requests") + 1,
-                time_last_accessed=timezone.now(),
-            )
+        APIUsage.objects.filter(pk=statistic.pk).update(
+            total_transfer_size=F("total_transfer_size") + transfer_size,
+            total_number_requests=F("total_number_requests") + 1,
+            time_last_accessed=timezone.now(),
+        )

318-318: Replace getattr with direct attribute access.

Per static analysis: getattr(request, "accepted_renderer") with a constant string is equivalent to request.accepted_renderer but less readable. This pattern repeats on lines 355, 383, 421, 455, and 499.

-            renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer"))
+            renderer = cast(DicomWebWadoRenderer, request.accepted_renderer)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 094aeff and 3ebbbaf.

📒 Files selected for processing (7)
  • adit/core/templates/core/admin_section.html (1 hunks)
  • adit/core/views.py (2 hunks)
  • adit/dicom_web/admin.py (1 hunks)
  • adit/dicom_web/apps.py (1 hunks)
  • adit/dicom_web/migrations/0004_apiusage.py (1 hunks)
  • adit/dicom_web/models.py (2 hunks)
  • adit/dicom_web/views.py (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
adit/core/views.py (1)
adit/dicom_web/apps.py (1)
  • collect_latest_api_usage (19-22)
adit/dicom_web/apps.py (1)
adit/dicom_web/models.py (1)
  • APIUsage (18-29)
adit/dicom_web/admin.py (1)
adit/dicom_web/models.py (2)
  • APIUsage (18-29)
  • DicomWebSettings (8-15)
🪛 Ruff (0.14.7)
adit/dicom_web/migrations/0004_apiusage.py

11-14: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


16-45: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

adit/dicom_web/views.py

94-94: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


318-318: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)


355-355: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)


383-383: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)


421-421: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)


455-455: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)


499-499: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: ci
  • GitHub Check: claude-review
🔇 Additional comments (4)
adit/core/views.py (1)

47-55: LGTM - API stats integration into admin section.

The integration follows the existing pattern for job_stats. The query is lightweight (3 records max).

adit/dicom_web/views.py (3)

277-292: LGTM - Streaming wrapper properly tracks transfer size.

The _StreamingSessionWrapper correctly accumulates chunk sizes and finalizes statistics in the finally block, ensuring stats are recorded even if streaming is interrupted.


129-191: LGTM - Query views correctly track transfer size.

The query views consistently use len(json.dumps(response_data).encode()) to calculate transfer size before returning the response.


295-324: LGTM - Streaming retrieve views use wrapper correctly.

The streaming retrieve views properly wrap the renderer output with _StreamingSessionWrapper to track transfer size during streaming.

Comment on lines +25 to +32
def get_size(self, obj):
size = obj.total_transfer_size
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size < 1024:
return f"{size:.2f} {unit}"
size /= 1024

get_size.short_description = "Total Transfer Size"
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

get_size doesn't return a value for sizes >= 1024 TB.

If total_transfer_size exceeds 1024 TB, the loop completes without returning, causing None to be displayed. Also consider using Django's filesizeformat for consistency with the template.

+from django.template.defaultfilters import filesizeformat
+
+class APIUsageAdmin(admin.ModelAdmin):
+    # ...
+
     def get_size(self, obj):
-        size = obj.total_transfer_size
-        for unit in ["B", "KB", "MB", "GB", "TB"]:
-            if size < 1024:
-                return f"{size:.2f} {unit}"
-            size /= 1024
+        return filesizeformat(obj.total_transfer_size)
 
     get_size.short_description = "Total Transfer Size"

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In adit/dicom_web/admin.py around lines 25 to 32, get_size currently loops
through units and returns nothing when size is >= 1024 TB; update it to always
return a formatted string for extremely large sizes by adding a final fallback
return after the loop (e.g., format remaining size with "PB" or clamp to the
largest unit) so the function never returns None, and preferably replace the
manual loop with Django's filesizeformat (from django.template.defaultfilters
import filesizeformat) to ensure consistent formatting with templates.

Comment on lines +82 to +89
@asynccontextmanager
async def track_session(self, user: User):
"""Context manager to track API session for the duration of a request."""
session = await self._create_statistic(user)
try:
yield session
finally:
pass
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Empty finally block - statistics not recorded on exceptions.

The track_session context manager creates a statistic record but the finally block does nothing. If an exception occurs before _finalize_statistic is called, the request won't be counted. Consider whether failed requests should also be tracked.

If you want to track all requests (including failed ones):

     @asynccontextmanager
     async def track_session(self, user: User):
         """Context manager to track API session for the duration of a request."""
         session = await self._create_statistic(user)
+        finalized = False
         try:
             yield session
         finally:
-            pass
+            if not finalized:
+                await self._finalize_statistic(session, transfer_size=0)

Then set finalized = True before calling _finalize_statistic in each view, or restructure to pass transfer_size back to the context manager.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In adit/dicom_web/views.py around lines 82 to 89, the asynccontextmanager
track_session creates a statistic but leaves the finally block empty so
statistics aren’t finalized if an exception occurs; fix by ensuring
_finalize_statistic is always called in the finally block (and pass any needed
transfer_size or finalized flag back from the view) or by yielding a mutable
container (e.g., dict) from the context so the view can set transfer_size and
finalized=True, then in finally call await self._finalize_statistic(session,
transfer_size=container.get("transfer_size"),
finalized=container.get("finalized", False)); adjust callers to set
container["transfer_size"] and container["finalized"]=True when appropriate so
requests (including failures if desired) are recorded.

Comment on lines 550 to 551
response_data = [results]
await self._finalize_statistic(session, transfer_size=sys.getsizeof(response_data))
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Inconsistent transfer size calculation - sys.getsizeof vs serialized size.

sys.getsizeof(response_data) returns the Python object's memory footprint, not the actual response size sent to the client. Other views use len(json.dumps(response_data).encode()) for consistency.

-            response_data = [results]
-            await self._finalize_statistic(session, transfer_size=sys.getsizeof(response_data))
+            response_data = [results.to_json_dict()]
+            await self._finalize_statistic(
+                session, transfer_size=len(json.dumps(response_data).encode())
+            )

Note: You'll need to verify how results (a Dataset) should be serialized. If the renderer handles this differently, you may need to adjust the serialization approach accordingly.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
response_data = [results]
await self._finalize_statistic(session, transfer_size=sys.getsizeof(response_data))
response_data = [results]
await self._finalize_statistic(session, transfer_size=len(json.dumps(response_data, default=str).encode()))

@claude
Copy link

claude bot commented Dec 8, 2025

Code Review - PR #271: API Statistics

Thank you for implementing API usage tracking! This is a useful feature for monitoring user activity. I've reviewed the changes and have the following feedback:

Code Quality & Architecture

Strengths:

  • Clean implementation of the APIUsage model with proper indexing and constraints ✓
  • Good use of async context managers for tracking sessions
  • Proper use of database F() expressions to avoid race conditions in updates

Issues & Recommendations:

  1. Redundant check in _finalize_statistic (views.py:73-74)

    if transfer_size is not None:

    This check is unnecessary since transfer_size has a default value of 0 and can never be None. Consider removing this condition.

  2. Empty finally block (views.py:87-88)
    The track_session context manager has an empty finally block. If this is intentional, a comment explaining why would be helpful. Otherwise, consider removing it.

  3. Inconsistent session tracking for StoreImagesAPIView
    The StoreImagesAPIView only tracks the final response size, not the incoming request data size. For consistency with monitoring actual API usage (which should include uploads), consider tracking the size of uploaded DICOM files as well.

Performance Considerations

Issues:

  1. JSON serialization for size calculation is inefficient
    Throughout the code (lines 146, 167, 188, 351, 417, 495, 551), you're using:

    len(json.dumps(response_data).encode())

    This serializes the entire response just to calculate size, which:

    • Duplicates work since Django REST framework will serialize it again
    • Increases memory usage for large responses
    • Adds latency to every request

    Recommendation: Consider using sys.getsizeof() for approximation, or track the size after actual serialization in the rendering pipeline.

  2. Streaming response size tracking could fail silently
    In _StreamingSessionWrapper, if an exception occurs during streaming, the size tracking might be incomplete. Consider whether partial transfer sizes should be logged or if there should be error handling.

  3. No pagination for admin dashboard
    collect_latest_api_usage() only shows the top 3 most recent users, but the admin template displays all results without pagination. If you later want to show more data, this could cause performance issues.

Security Concerns

Medium Priority:

  1. No rate limiting mentioned
    While you're tracking API usage, there's no indication of rate limiting or alerts when usage thresholds are exceeded. Consider adding:

    • Rate limiting middleware
    • Alerts for unusual usage patterns
    • Maximum transfer size limits per user/time period
  2. Database growth over time
    The APIUsage table will grow indefinitely. Consider:

    • Adding a data retention policy
    • Archiving old statistics
    • Periodic cleanup tasks for historical data

Test Coverage

Critical Issue:

No tests added for the new functionality - This is a significant concern. The PR adds substantial new functionality but includes no tests. You should add:

  1. Unit tests:

    • Test APIUsage model creation and updates
    • Test _create_statistic and _finalize_statistic methods
    • Test track_session context manager
    • Test _StreamingSessionWrapper with various chunk sizes
  2. Integration tests:

    • Verify statistics are correctly recorded for each API endpoint
    • Test concurrent requests from the same user (race condition testing)
    • Test streaming response size calculation
    • Test admin dashboard displays correct data
  3. Edge cases:

    • Test behavior when requests fail mid-stream
    • Test with empty responses
    • Test with very large responses

Migration

The migration file looks good:

  • Proper indexes on frequently queried fields ✓
  • Unique constraint prevents duplicate entries per user ✓
  • Uses BigIntegerField for transfer sizes (handles up to ~9 exabytes) ✓

Admin Interface

Minor Issue:

In admin.py:25-30, the get_size method doesn't handle the edge case where size reaches PB or larger. Add a return statement after the loop to handle this case:

def get_size(self, obj):
    size = obj.total_transfer_size
    for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
        if size < 1024:
            return f"{size:.2f} {unit}"
        size /= 1024
    return f"{size:.2f} PB"  # Add this line

Summary

Before merging, please address:

  1. 🔴 Critical: Add comprehensive test coverage
  2. 🟡 Important: Optimize size calculation to avoid double serialization
  3. 🟡 Important: Consider rate limiting strategy
  4. 🟢 Minor: Clean up redundant code (empty finally, unnecessary None check)
  5. 🟢 Minor: Fix get_size edge case

The architecture and approach are solid, but the lack of tests and performance concerns with JSON serialization need to be addressed before merging.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
adit/dicom_web/views.py (1)

81-88: Empty finally block leaves requests untracked on exceptions.

The context manager's finally block does nothing. If an exception occurs before _finalize_statistic is called (e.g., during qido_find or wado_retrieve), the request won't be counted in statistics. For streaming responses, _StreamingSessionWrapper handles finalization separately, but non-streaming paths could lose tracking on errors.

Consider whether failed requests should also increment total_number_requests:

     @asynccontextmanager
     async def track_session(self, user: User):
         """Context manager to track API session for the duration of a request."""
         session = await self._create_statistic(user)
+        finalized = False
         try:
             yield session
         finally:
-            pass
+            if not finalized:
+                await self._finalize_statistic(session, transfer_size=0)

Then set finalized = True before the explicit _finalize_statistic calls in each view. Alternatively, if only successful requests should be tracked, document this behavior.

🧹 Nitpick comments (1)
adit/dicom_web/views.py (1)

317-317: Replace getattr with direct attribute access.

Using getattr with a constant attribute name provides no benefit over direct attribute access.

-            renderer = cast(DicomWebWadoRenderer, getattr(request, "accepted_renderer"))
+            renderer = cast(DicomWebWadoRenderer, request.accepted_renderer)

This same pattern appears at lines 354, 382, 420, 454, and 498.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3ebbbaf and b2c995c.

📒 Files selected for processing (4)
  • adit/core/templates/core/admin_section.html (1 hunks)
  • adit/dicom_web/migrations/0004_apiusage.py (1 hunks)
  • adit/dicom_web/models.py (2 hunks)
  • adit/dicom_web/views.py (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
adit/dicom_web/models.py (1)
adit/core/models.py (7)
  • Meta (28-29)
  • Meta (84-85)
  • Meta (113-120)
  • Meta (204-212)
  • Meta (364-371)
  • Meta (404-406)
  • Meta (480-481)
adit/dicom_web/views.py (3)
adit/core/utils/dicom_dataset.py (6)
  • QueryDataset (140-252)
  • dataset (126-127)
  • dataset (156-157)
  • get (129-130)
  • SOPInstanceUID (114-115)
  • StudyInstanceUID (53-54)
adit/dicom_web/models.py (1)
  • APIUsage (18-35)
adit/dicom_web/utils/wadors_utils.py (1)
  • wado_retrieve (19-97)
🪛 Ruff (0.14.7)
adit/dicom_web/models.py

29-31: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


32-32: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

adit/dicom_web/views.py

93-93: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


317-317: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)


354-354: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)


382-382: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)


420-420: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)


454-454: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)


498-498: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)

adit/dicom_web/migrations/0004_apiusage.py

11-14: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


16-58: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

🔇 Additional comments (7)
adit/dicom_web/migrations/0004_apiusage.py (1)

1-58: LGTM!

The migration is well-structured with appropriate field types, proper index on time_last_accessed for efficient ordering, and a unique constraint on owner to ensure one usage record per user. The static analysis warnings about ClassVar are false positives for auto-generated Django migrations.

adit/core/templates/core/admin_section.html (1)

31-51: LGTM!

The API Usage section follows the existing table pattern, uses appropriate Django template filters like filesizeformat for human-readable file sizes, and integrates cleanly with the existing admin section layout.

adit/dicom_web/models.py (1)

18-35: LGTM!

The model design is appropriate for aggregate per-user API statistics. Using BigIntegerField for transfer size handles large cumulative values well. The unique constraint on owner effectively creates a one-to-one relationship, though semantically a OneToOneField could also work here. The current approach is valid and aligns with patterns in adit/core/models.py.

adit/dicom_web/views.py (4)

67-79: Well-designed atomic update pattern.

Using F() expressions for atomic updates prevents race conditions when multiple concurrent requests update the same user's statistics. This is the correct approach for counter-like fields.


276-291: Good design for streaming transfer size tracking.

The _StreamingSessionWrapper correctly accumulates chunk sizes during streaming and ensures finalization happens in the finally block, guaranteeing statistics are recorded even if streaming is interrupted.


549-552: Consistent transfer size calculation for STOW-RS responses.

The calculation correctly serializes the Dataset to JSON for size measurement, matching the pattern used in other views.


318-323: Session object remains valid for finalization.

The session object is captured by _StreamingSessionWrapper before the context manager exits, and _finalize_statistic safely uses only its pk attribute via APIUsage.objects.filter(pk=statistic.pk).update(...). Since the primary key is immutable and the ORM update executes at the database level (not dependent on session attachment), the session remains usable for finalization even after the async with self.track_session(...) context exits. The __aiter__ finally block correctly ensures _finalize_statistic is called to complete the update.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants