Skip to content

Commit 597aebc

Browse files
authored
Hotfix: disable PageCounter, fix dashboard 502 / client IP / zip tooltip / filters banner (#11921)
Hotfix based on master, per ENG-12193 plus the download-dashboard production issues. ### Disable PageCounter (ENG-12193) `update_counter` returns early — no session load, no `select_for_update` row lock, no writes; `DownloadCountReporter` unplugged from the daily reporters. Reads keep serving the stored (frozen) numbers; `DailyDownloadCountReport` index/API unchanged. No migrations. ### Dashboard 502 on wide ranges The changelist feeds the dashboard an ordered queryset and Django folds ordering columns into the GROUP BY of `values().annotate()` — every breakdown returned ~one row per event (measured: 5k events → 5k rows from one breakdown; fixed → 3). Also: one aggregate pass instead of twelve scans, single ChangeList per view instead of two, no full-table COUNT(*), Outcome sort via order_field expression. ### IP column recorded the load balancer New `get_client_ip()` resolves the client from `X-Forwarded-For` right-to-left past infrastructure hops; deployment-specific LB addresses go in the new `OSF_TRUSTED_PROXY_CIDRS` env (devops: set the LB public address, e.g. `35.190.55.96/32`). Old rows can't be backfilled. ### ENG-12136 / ENG-12137 Zip-filter doughnut tooltip now hit-tests the arc under the cursor (was resolving to the invisible zero slice and showing nothing). Applied-filters banner is derived from the ChangeList's filter specs — same labels as the panel ('Yes', 'Single file'), and future filters appear automatically. ### Testing 286 passed / 8 skipped across dashboard, telemetry, analytics, osfstorage and addons suites on this combined branch. Metrics suites verified identical to a pristine master baseline (pre-existing failures there are unrelated). `makemigrations --check` and `manage.py check` clean. No schema changes anywhere.
1 parent 18602c9 commit 597aebc

13 files changed

Lines changed: 478 additions & 134 deletions

File tree

addons/base/views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
from osf.utils import permissions
5959
from osf.utils.download_telemetry import (
6060
classify_download_channel,
61+
get_client_ip,
6162
never_breaks_downloads,
6263
record_download,
6364
)
@@ -221,7 +222,9 @@ def _record_file_download(target, file_node, query_params, auth, version=None):
221222
version_identifier=getattr(version, 'identifier', None),
222223
storage_provider=getattr(file_node, 'provider', '') or '',
223224
user_guid=getattr(getattr(auth, 'user', None), '_id', None),
224-
ip=request.remote_addr,
225+
# remote_addr behind the load balancer is the balancer, not the client --
226+
# the client is recovered from X-Forwarded-For
227+
ip=get_client_ip(request.remote_addr, request.headers.get('X-Forwarded-For', '')),
225228
user_agent=request.headers.get('User-Agent', ''),
226229
source_area=source_area,
227230
download_channel=classify_download_channel(source_area, is_api_token=is_api_token),

addons/osfstorage/tests/test_models.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ def test_download_count_file_defaults(self):
184184
assert child.get_download_count() == 0
185185

186186
def test_download_count_file(self):
187+
# PageCounter is disabled (ENG-12193), so legacy counts stay at zero
187188
s = SessionStore()
188189
s.create()
189190
child = self.node_settings.get_root().append_file('Test')
@@ -192,10 +193,10 @@ def test_download_count_file(self):
192193
utils.update_analytics(self.project, child, 1, s.session_key)
193194
utils.update_analytics(self.project, child, 2, s.session_key)
194195

195-
assert child.get_download_count() == 3
196-
assert child.get_download_count(0) == 1
197-
assert child.get_download_count(1) == 1
198-
assert child.get_download_count(2) == 1
196+
assert child.get_download_count() == 0
197+
assert child.get_download_count(0) == 0
198+
assert child.get_download_count(1) == 0
199+
assert child.get_download_count(2) == 0
199200

200201
def test_create_version_locks_file_row(self):
201202

addons/osfstorage/tests/test_utils.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def setUp(self):
2727
self.record.save()
2828

2929
def test_serialize_revision(self):
30+
# PageCounter is disabled (ENG-12193), so legacy counts stay at zero
3031
s = SessionStore()
3132
s.create()
3233
utils.update_analytics(self.project, self.record, 0, s.session_key)
@@ -39,7 +40,7 @@ def test_serialize_revision(self):
3940
'url': self.user.url,
4041
},
4142
'date': self.versions[0].created.isoformat(),
42-
'downloads': 2,
43+
'downloads': 0,
4344
'md5': None,
4445
'sha256': None,
4546
}
@@ -50,9 +51,9 @@ def test_serialize_revision(self):
5051
0,
5152
)
5253
assert expected == observed
53-
assert self.record.get_download_count() == 3
54-
assert self.record.get_download_count(version=2) == 1
55-
assert self.record.get_download_count(version=0) == 2
54+
assert self.record.get_download_count() == 0
55+
assert self.record.get_download_count(version=2) == 0
56+
assert self.record.get_download_count(version=0) == 0
5657

5758
def test_anon_revisions(self):
5859
s = SessionStore()

admin/templates/download_events/download_events.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,13 +652,18 @@ <h4 style="margin:0 0 12px;color:#aaa;text-align:center;">
652652

653653
// A zero slice ("File" or "Zip") has no arc, so don't pop a "File 0" / "Zip 0"
654654
// tooltip for it — that reads as a bug when a filter zeroes one side out.
655+
// The tooltip must also hit-test the arc actually under the cursor
656+
// (nearest + intersect): the "index" mode inherited from commonOptions resolved
657+
// hovers to the invisible zero slice, so filtering to zips only showed no
658+
// tooltip at all instead of the Zip count/GB.
655659
const doughnutOptions = {
656660
...commonOptions,
657661
cutout: "60%",
658662
plugins: {
659663
...commonOptions.plugins,
660664
tooltip: {
661-
...commonOptions.plugins.tooltip,
665+
mode: "nearest",
666+
intersect: true,
662667
filter: (item) => item.parsed !== 0
663668
}
664669
}

osf/admin.py

Lines changed: 110 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,22 @@ def user(self, obj):
415415
# threshold is what separates a real failure from a cancel.
416416
DOWNLOAD_FAILURE_MIN_STATUS = 400
417417

418+
# Sort key for the computed Outcome column: Completed, Cancelled, Failed, then single
419+
# files (which have no outcome). Attached via ``admin_order_field``, so the CASE only
420+
# runs when someone actually sorts by Outcome -- annotating it onto every queryset made
421+
# all the dashboard aggregates and counts pay for it on every page load.
422+
OUTCOME_SORT_ORDER = Case(
423+
When(zip_completed=True, then=Value(0)), # Completed
424+
When(
425+
zip_completed=False,
426+
status_code__gte=DOWNLOAD_FAILURE_MIN_STATUS,
427+
then=Value(2), # Failed
428+
),
429+
When(zip_completed=False, then=Value(1)), # Cancelled
430+
default=Value(3), # single files have no outcome ('—')
431+
output_field=IntegerField(),
432+
)
433+
418434

419435
class DownloadOutcomeFilter(SimpleListFilter):
420436
"""Filter zips by how they ended: completed, cancelled mid-stream, or failed."""
@@ -552,27 +568,10 @@ class DownloadEventsView(admin.ModelAdmin):
552568
'user_agent'
553569
)
554570
search_help_text = 'Search by username, full name, user or node guid, ip, path, storage provider, user or storage region, source area, user agent.'
555-
556-
def get_queryset(self, request):
557-
"""Annotate an outcome rank so the computed Outcome column is sortable.
558-
559-
The rank mirrors :meth:`outcome` exactly. It's just an ordering key — it doesn't
560-
change what rows are returned, so the table and the dashboard aggregates are
561-
unaffected.
562-
"""
563-
return super().get_queryset(request).annotate(
564-
_outcome_rank=Case(
565-
When(zip_completed=True, then=Value(0)), # Completed
566-
When(
567-
zip_completed=False,
568-
status_code__gte=DOWNLOAD_FAILURE_MIN_STATUS,
569-
then=Value(2), # Failed
570-
),
571-
When(zip_completed=False, then=Value(1)), # Cancelled
572-
default=Value(3), # single files have no outcome ('—')
573-
output_field=IntegerField(),
574-
)
575-
)
571+
# Without this the admin runs a COUNT(*) over the ENTIRE table on every page load,
572+
# just to print "n total" next to the filtered count. At production volume that's a
573+
# full-table scan per view.
574+
show_full_result_count = False
576575

577576
@admin.display(description='User', ordering='user__username')
578577
def user_display(self, obj):
@@ -593,7 +592,7 @@ def user_agent_display(self, obj):
593592
return '—'
594593
return obj.user_agent if len(obj.user_agent) <= 80 else obj.user_agent[:79] + '…'
595594

596-
@admin.display(description='Outcome', ordering='_outcome_rank')
595+
@admin.display(description='Outcome', ordering=OUTCOME_SORT_ORDER)
597596
def outcome(self, obj):
598597
"""Human-readable end state. Single files have no outcome — they're recorded at the
599598
redirect before any bytes move, so they never report completion."""
@@ -630,33 +629,38 @@ def changelist_view(self, request, extra_context=None):
630629

631630
if extra_context is None:
632631
extra_context = {}
633-
changelist = self.get_changelist_instance(request)
634-
extra_context['download_events_dashboard'] = self.get_dashboard_data(changelist.get_queryset(request))
635-
extra_context['download_events_active_filters'] = self._active_filters(request)
636-
return super().changelist_view(request, extra_context=extra_context)
637-
638-
# query-string param -> human label, for the "applied filters" banner above the charts
639-
FILTER_LABELS = (
640-
('q', 'Search'),
641-
('project_guid', 'Project'),
642-
('download_user', 'User'),
643-
('download_type', 'Download type'),
644-
('outcome', 'Outcome'),
645-
('zip_completed__exact', 'Zip completed'),
646-
('storage_provider', 'Storage provider'),
647-
)
648-
649-
def _active_filters(self, request):
632+
response = super().changelist_view(request, extra_context=extra_context)
633+
634+
# Feed the charts from the ChangeList the admin just built rather than
635+
# constructing a second one -- every ChangeList runs the whole
636+
# filter/count/results pipeline in __init__, so two of them doubled all of
637+
# that work per page view. On non-changelist responses (e.g. the redirect
638+
# the admin issues for a bad filter value) there is no `cl` and the charts
639+
# are simply skipped, where the old code 500ed.
640+
context_data = getattr(response, 'context_data', None) or {}
641+
changelist = context_data.get('cl')
642+
if changelist is not None:
643+
response.context_data['download_events_dashboard'] = self.get_dashboard_data(changelist.queryset)
644+
response.context_data['download_events_active_filters'] = self._active_filters(request, changelist)
645+
return response
646+
647+
def _active_filters(self, request, changelist):
650648
"""The filters/search currently in effect, as ``[{'label', 'value'}]``, so the
651649
dashboard can show at a glance what its numbers are scoped to.
652650
653-
Purely presentational — it reads the same query string the changelist already
654-
filtered on; it never changes what's queried.
651+
Read off the ChangeList's own filter specs, so the banner shows exactly what
652+
the filter panel shows -- same titles, same choice labels ('Yes', not '1';
653+
'Single file', not 'file') -- and a filter added to ``list_filter`` shows up
654+
here on its own. A hand-kept param map drifted: it missed the ``__exact``
655+
params Django uses for choice fields and never learned about new filters.
656+
657+
Purely presentational; it never changes what's queried.
655658
"""
656659
params = request.GET
657660
active = []
658661

659-
# the date range arrives in date+time halves; recombine them into one readable line
662+
# The date range renders as a form, not choices, so its spec has nothing to
663+
# report -- recombine the date+time halves from the query string instead.
660664
date_from = ' '.join(
661665
part for part in (params.get('created__range__gte_0'), params.get('created__range__gte_1')) if part
662666
)
@@ -666,10 +670,31 @@ def _active_filters(self, request):
666670
if date_from or date_to:
667671
active.append({'label': 'Date (UTC)', 'value': f"{date_from or '…'}{date_to or 'now'}"})
668672

669-
for param, label in self.FILTER_LABELS:
670-
value = params.get(param)
671-
if value:
672-
active.append({'label': label, 'value': value})
673+
# the search box isn't a filter spec either
674+
if params.get('q'):
675+
active.append({'label': 'Search', 'value': params['q']})
676+
677+
for spec in changelist.filter_specs:
678+
title = str(spec.title)
679+
label = title[:1].upper() + title[1:]
680+
if isinstance(spec, InputFilter):
681+
# free-text filters override choices() for their form, so read the
682+
# typed value directly
683+
if spec.value():
684+
active.append({'label': label, 'value': spec.value()})
685+
continue
686+
try:
687+
choices = list(spec.choices(changelist))
688+
except Exception:
689+
# a spec that can't enumerate choices (form-based filters) simply
690+
# doesn't contribute a chip; never let the banner break the page
691+
continue
692+
active.extend(
693+
{'label': label, 'value': str(choice.get('display', ''))}
694+
# the first choice is always "All" -- selected means the filter is off
695+
for choice in choices[1:]
696+
if choice.get('selected') and choice.get('display')
697+
)
673698
return active
674699

675700
def _in_dashboard_group(self, request):
@@ -713,9 +738,6 @@ def has_delete_permission(self, request, obj=None):
713738
# genuinely terabyte-scale, so small numbers aren't cluttered with "(0.0 TB)".
714739
GB_PER_TB = 1024
715740

716-
def _sum_bytes(self, queryset):
717-
return queryset.aggregate(total_bytes=Sum('size_bytes'))['total_bytes'] or 0
718-
719741
def _to_gb(self, total_bytes):
720742
return round((total_bytes or 0) / (1024**3), 2)
721743

@@ -736,15 +758,39 @@ def _percent(self, part, whole):
736758
return round(part * 100 / whole, 2)
737759

738760
def get_dashboard_data(self, queryset):
739-
file_queryset = queryset.filter(download_type=DownloadEvent.FILE)
740-
zip_queryset = queryset.exclude(download_type=DownloadEvent.FILE)
741-
total_file_downloads = file_queryset.count()
742-
total_zip_downloads = zip_queryset.count()
743-
total_bytes = self._sum_bytes(queryset)
744-
745-
total_downloads = queryset.count()
746-
total_file_gb = self._to_gb(self._sum_bytes(file_queryset))
747-
total_zip_gb = self._to_gb(self._sum_bytes(zip_queryset))
761+
# The changelist hands this queryset over ordered by `created`, and Django folds
762+
# ordering columns into the GROUP BY of values().annotate(). Since `created` is
763+
# near-unique, that exploded every breakdown below into one row per EVENT rather
764+
# than one per group -- harmless on an hour of data, a timeout on a day of it.
765+
# Aggregates have no use for an ordering; drop it before anything else runs.
766+
queryset = queryset.order_by()
767+
768+
is_file = Q(download_type=DownloadEvent.FILE)
769+
# Every headline number in one pass over the range -- these were twelve separate
770+
# scans of the same rows before.
771+
totals = queryset.aggregate(
772+
total_downloads=Count('id'),
773+
total_bytes=Sum('size_bytes'),
774+
file_downloads=Count('id', filter=is_file),
775+
file_bytes=Sum('size_bytes', filter=is_file),
776+
zip_downloads=Count('id', filter=~is_file),
777+
zip_bytes=Sum('size_bytes', filter=~is_file),
778+
# Zip outcomes. Single files are recorded before any bytes move, so they
779+
# have no outcome and are left out of these entirely.
780+
completed_zips=Count('id', filter=~is_file & Q(zip_completed=True)),
781+
incomplete_zips=Count('id', filter=~is_file & Q(zip_completed=False)),
782+
failed_zips=Count('id', filter=~is_file & Q(
783+
zip_completed=False, status_code__gte=DOWNLOAD_FAILURE_MIN_STATUS)),
784+
# distinct non-null users; Count skips NULLs, so anonymous rows don't count
785+
unique_users=Count('user_id', distinct=True),
786+
)
787+
total_downloads = totals['total_downloads']
788+
total_file_downloads = totals['file_downloads']
789+
total_zip_downloads = totals['zip_downloads']
790+
failed_zips = totals['failed_zips']
791+
total_file_gb = self._to_gb(totals['file_bytes'])
792+
total_zip_gb = self._to_gb(totals['zip_bytes'])
793+
748794
time_series = self._build_time_series(queryset)
749795
# each breakdown returns the ranked *known* regions plus, separately, the
750796
# "Unknown" bucket — so a large Unknown doesn't crowd real regions off the chart
@@ -753,20 +799,14 @@ def get_dashboard_data(self, queryset):
753799
# downloads and GB grouped by where the bytes came from (osfstorage vs addons)
754800
storage_providers, storage_providers_unknown = self._build_region_breakdown(queryset, 'storage_provider')
755801

756-
# Zip outcomes. Single files are recorded before any bytes move, so they have no
757-
# outcome and are left out of this breakdown entirely.
758-
completed_zips = zip_queryset.filter(zip_completed=True).count()
759-
failed_zips = zip_queryset.filter(
760-
zip_completed=False, status_code__gte=DOWNLOAD_FAILURE_MIN_STATUS).count()
761-
incomplete_zips = zip_queryset.filter(zip_completed=False).count()
762802
zip_outcomes = {
763-
'completed': completed_zips,
803+
'completed': totals['completed_zips'],
764804
# everything that didn't complete and wasn't a server failure is a user cancel
765-
'cancelled': incomplete_zips - failed_zips,
805+
'cancelled': totals['incomplete_zips'] - failed_zips,
766806
'failed': failed_zips,
767807
}
768808

769-
total_gb = self._to_gb(total_bytes)
809+
total_gb = self._to_gb(totals['total_bytes'])
770810
split = {
771811
'file': {
772812
'count': total_file_downloads,
@@ -789,7 +829,7 @@ def get_dashboard_data(self, queryset):
789829
'total_downloads': total_downloads,
790830
'total_gb': total_gb,
791831
'total_gb_tb_suffix': self._tb_suffix(total_gb),
792-
'unique_users': queryset.exclude(user_id__isnull=True).values('user_id').distinct().count(),
832+
'unique_users': totals['unique_users'],
793833
'failed_zips': failed_zips,
794834
},
795835
'split': split,
@@ -970,8 +1010,8 @@ def _build_channel_breakdown(self, queryset):
9701010
total_bytes=Sum('size_bytes'),
9711011
)
9721012
labels = dict(DownloadEvent.DOWNLOAD_CHANNELS)
973-
# Fold by channel in Python: a pre-existing annotation on the queryset (e.g. the
974-
# sort's _outcome_rank) can leak into the GROUP BY and split a channel across rows,
1013+
# Fold by channel in Python: anything that sneaks extra columns into the GROUP BY
1014+
# (an inherited ordering, a stray annotation) would split a channel across rows,
9751015
# so sum them back together — same reason _build_region_breakdown folds.
9761016
folded = defaultdict(lambda: {'downloads': 0, 'bytes': 0})
9771017
for row in rows:

osf/metrics/reporters/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
# from .active_users import ActiveUserReporter
44
from .storage_addon_usage import StorageAddonUsageReporter
5-
from .download_count import DownloadCountReporter
5+
# reads PageCounter, which is disabled (ENG-12193)
6+
# from .download_count import DownloadCountReporter
67
from .institution_summary import InstitutionSummaryReporter
78
from .institutional_users import InstitutionalUsersReporter
89
from .institution_summary_monthly import InstitutionalSummaryMonthlyReporter
@@ -21,7 +22,7 @@
2122

2223
class AllDailyReporters(enum.Enum):
2324
# ACTIVE_USER = ActiveUserReporter
24-
DOWNLOAD_COUNT = DownloadCountReporter
25+
# DOWNLOAD_COUNT = DownloadCountReporter
2526
INSTITUTION_SUMMARY = InstitutionSummaryReporter
2627
NEW_USER_DOMAIN = NewUserDomainReporter
2728
NODE_COUNT = NodeCountReporter

osf/models/analytics.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ def clean_page(page):
9999

100100
@classmethod
101101
def update_counter(cls, resource, file, version, action, node_info, session_key):
102+
# ENG-12193: disabled. The metrics service owns these counts now, and the
103+
# select_for_update below piles up on hot rows under heavy download traffic.
104+
# Reads still work, numbers are frozen. Full removal is a follow-up.
105+
return
106+
102107
if version is not None:
103108
page = f'{action}:{resource._id}:{file._id}:{version}'
104109
else:

0 commit comments

Comments
 (0)