Skip to content

Commit e0c8dd0

Browse files
committed
feat(osf): ENG-11735 record download events for files and zips
1 parent d941b58 commit e0c8dd0

2 files changed

Lines changed: 360 additions & 0 deletions

File tree

osf/utils/download_telemetry.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import functools
2+
import logging
3+
4+
from addons.osfstorage.settings import DEFAULT_REGION_NAME
5+
from framework.celery_tasks import app
6+
from framework.postcommit_tasks.handlers import enqueue_postcommit_task
7+
8+
logger = logging.getLogger(__name__)
9+
10+
# Set on every user until they pick something in their profile, so it says nothing
11+
# about where they actually are.
12+
UNSET_USER_TIMEZONE = 'Etc/UTC'
13+
14+
15+
def never_breaks_downloads(fn):
16+
"""Swallow and log anything this raises.
17+
18+
Wraps the whole capture, not just the write — gathering the values is as capable of
19+
raising as storing them is, and neither is a reason for a download to fail.
20+
"""
21+
@functools.wraps(fn)
22+
def wrapped(*args, **kwargs):
23+
try:
24+
return fn(*args, **kwargs)
25+
except Exception:
26+
logger.exception('Failed to record a download event')
27+
return wrapped
28+
29+
30+
@never_breaks_downloads
31+
def record_download(**kwargs):
32+
"""Enqueue a :class:`DownloadEvent` write."""
33+
enqueue_postcommit_task(write_download_event, (), kwargs, celery=True)
34+
35+
36+
@app.task(max_retries=5, default_retry_delay=60)
37+
def write_download_event(
38+
download_type,
39+
resource_guid='',
40+
path='',
41+
file_id=None,
42+
version_identifier=None,
43+
size_bytes=None,
44+
storage_region_id=None,
45+
zip_completed=None,
46+
user_guid=None,
47+
ip=None,
48+
source_area='',
49+
tz='',
50+
):
51+
"""Resolve the expensive bits and write one row.
52+
53+
Callers hand over identifiers rather than loaded objects so that the download request
54+
itself does no extra queries — everything that needs a lookup is resolved here.
55+
"""
56+
from osf.models import BaseFileNode, DownloadEvent, OSFUser
57+
58+
user = OSFUser.load(user_guid) if user_guid else None
59+
file_node = BaseFileNode.load(file_id) if file_id else None
60+
file_version = _load_file_version(file_node, version_identifier)
61+
62+
if file_version is not None:
63+
if size_bytes is None:
64+
size_bytes = file_version.size
65+
if storage_region_id is None:
66+
storage_region_id = file_version.region_id
67+
68+
storage_region = _region_name(storage_region_id) or _resource_region_name(resource_guid)
69+
70+
if not path and file_node is not None:
71+
path = getattr(file_node, 'materialized_path', '') or ''
72+
73+
DownloadEvent.objects.create(
74+
download_type=download_type,
75+
resource_guid=_truncate(resource_guid, 255),
76+
path=path or '',
77+
size_bytes=size_bytes if size_bytes is not None and size_bytes >= 0 else None,
78+
zip_completed=zip_completed,
79+
storage_region=_truncate(storage_region, 64),
80+
user_region=_truncate(derive_user_region(tz, user, storage_region), 64),
81+
ip=ip or None,
82+
source_area=_truncate(source_area, 128),
83+
user=user,
84+
)
85+
86+
87+
def derive_user_region(tz, user, storage_region):
88+
"""Best available guess at where the user is, most to least trustworthy.
89+
90+
The live browser timezone is the only real signal; the rest are fallbacks so the
91+
dashboard isn't mostly blank. An empty string means we genuinely don't know, which
92+
is more useful than a wrong guess.
93+
"""
94+
if tz:
95+
return tz
96+
97+
profile_timezone = getattr(user, 'timezone', '')
98+
if profile_timezone and profile_timezone != UNSET_USER_TIMEZONE:
99+
return profile_timezone
100+
101+
# Everything defaults to the US region, so it only tells us something when it's been
102+
# deliberately changed.
103+
if storage_region and storage_region != DEFAULT_REGION_NAME:
104+
return storage_region
105+
106+
return ''
107+
108+
109+
def _load_file_version(file_node, version_identifier):
110+
"""The version that was served, for its size and region."""
111+
if file_node is None:
112+
return None
113+
114+
from osf.models import FileVersion
115+
116+
versions = FileVersion.objects.filter(basefilenode=file_node)
117+
if version_identifier:
118+
return versions.filter(identifier=version_identifier).first()
119+
return versions.order_by('-created').first()
120+
121+
122+
def _region_name(region_id):
123+
if not region_id:
124+
return ''
125+
126+
from addons.osfstorage.models import Region
127+
128+
region = Region.objects.filter(id=region_id).first()
129+
return region.name if region else ''
130+
131+
132+
def _resource_region_name(resource_guid):
133+
"""Where a zip was served from — zips have no single file version to read it off."""
134+
if not resource_guid:
135+
return ''
136+
137+
from osf.models import Guid
138+
139+
resource, _ = Guid.load_referent(resource_guid)
140+
region = getattr(resource, 'osfstorage_region', None)
141+
return getattr(region, 'name', '') or ''
142+
143+
144+
def _truncate(value, max_length):
145+
"""Keep user-controllable values inside their column.
146+
147+
``source`` and ``tz`` arrive off the query string, so they're whatever the caller
148+
put there.
149+
"""
150+
return (value or '')[:max_length]

tests/test_download_telemetry.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import time
2+
3+
import pytest
4+
5+
from addons.osfstorage.settings import DEFAULT_REGION_NAME
6+
from api_tests.utils import create_test_file
7+
from framework.auth import signing
8+
from osf.models import DownloadEvent
9+
from osf.utils.download_telemetry import derive_user_region, record_download
10+
from osf_tests.factories import AuthUserFactory, ProjectFactory
11+
from tests.base import OsfTestCase
12+
13+
14+
class TestZipDownloadTelemetry(OsfTestCase):
15+
"""Folder and project zips, recorded from the WaterButler callback.
16+
17+
Zips are requested straight from WaterButler, so this callback is the only place we
18+
hear about them.
19+
"""
20+
21+
def setUp(self):
22+
super().setUp()
23+
self.user = AuthUserFactory()
24+
self.node = ProjectFactory(creator=self.user)
25+
self.url = self.node.api_url_for('create_waterbutler_log')
26+
27+
def build_payload(self, materialized='/', action='download_zip', **action_meta):
28+
meta = dict(
29+
bytes_downloaded=2048,
30+
completed=True,
31+
ip='198.51.100.7',
32+
source='files',
33+
tz='Europe/Kyiv',
34+
)
35+
meta.update(action_meta)
36+
options = {
37+
'auth': {'id': self.user._id},
38+
'action': action,
39+
'provider': 'osfstorage',
40+
'time': time.time() + 1000,
41+
'metadata': {
42+
'nid': self.node._id,
43+
'materialized': materialized,
44+
'path': materialized,
45+
'kind': 'folder',
46+
'provider': 'osfstorage',
47+
},
48+
'action_meta': meta,
49+
}
50+
message, signature = signing.default_signer.sign_payload(options)
51+
return {'payload': message, 'signature': signature}
52+
53+
def test_project_zip_is_recorded(self):
54+
res = self.app.put(self.url, json=self.build_payload(materialized='/'))
55+
56+
assert res.status_code == 200
57+
event = DownloadEvent.objects.get()
58+
assert event.download_type == DownloadEvent.PROJECT
59+
assert event.resource_guid == self.node._id
60+
assert event.user == self.user
61+
assert event.size_bytes == 2048
62+
assert event.zip_completed is True
63+
assert event.ip == '198.51.100.7'
64+
assert event.source_area == 'files'
65+
assert event.user_region == 'Europe/Kyiv'
66+
67+
def test_folder_zip_is_recorded_with_its_path(self):
68+
self.app.put(self.url, json=self.build_payload(materialized='/data/raw/'))
69+
70+
event = DownloadEvent.objects.get()
71+
assert event.download_type == DownloadEvent.FOLDER_ZIP
72+
assert event.path == '/data/raw/'
73+
74+
def test_incomplete_zip_is_recorded_as_incomplete(self):
75+
self.app.put(self.url, json=self.build_payload(completed=False))
76+
77+
assert DownloadEvent.objects.get().zip_completed is False
78+
79+
def test_mfr_render_is_not_recorded(self):
80+
self.app.put(self.url, json=self.build_payload(is_mfr_render=True))
81+
82+
assert not DownloadEvent.objects.exists()
83+
84+
def test_single_file_action_is_not_recorded_here(self):
85+
"""Single files are caught at the redirect view — recording them here too would
86+
double count every one of them."""
87+
self.app.put(self.url, json=self.build_payload(action='download_file'))
88+
89+
assert not DownloadEvent.objects.exists()
90+
91+
def test_oversized_source_is_truncated_to_the_column(self):
92+
self.app.put(self.url, json=self.build_payload(source='f' * 500))
93+
94+
assert len(DownloadEvent.objects.get().source_area) == 128
95+
96+
def test_callback_still_succeeds_when_recording_fails(self, ):
97+
with pytest.MonkeyPatch.context() as patch:
98+
patch.setattr(
99+
'addons.base.views.record_download',
100+
lambda **kwargs: (_ for _ in ()).throw(ValueError('boom')),
101+
)
102+
res = self.app.put(self.url, json=self.build_payload())
103+
104+
assert res.status_code == 200
105+
106+
107+
class TestSingleFileDownloadTelemetry(OsfTestCase):
108+
"""Single files, recorded at the redirect view before we 302 on to WaterButler."""
109+
110+
def setUp(self):
111+
super().setUp()
112+
self.user = AuthUserFactory()
113+
self.node = ProjectFactory(creator=self.user)
114+
self.file = create_test_file(self.node, self.user, size=4096)
115+
self.guid = self.file.get_guid()._id
116+
117+
def test_download_is_recorded_with_link_tags(self):
118+
res = self.app.get(
119+
f'/download/{self.guid}/?source=file-detail&tz=Europe%2FKyiv',
120+
auth=self.user.auth,
121+
)
122+
123+
assert res.status_code == 302
124+
event = DownloadEvent.objects.get()
125+
assert event.download_type == DownloadEvent.FILE
126+
assert event.resource_guid == self.node._id
127+
assert event.user == self.user
128+
assert event.source_area == 'file-detail'
129+
assert event.user_region == 'Europe/Kyiv'
130+
131+
def test_size_and_region_come_from_the_file_version(self):
132+
self.app.get(f'/download/{self.guid}/', auth=self.user.auth)
133+
134+
event = DownloadEvent.objects.get()
135+
assert event.size_bytes == 4096
136+
assert event.storage_region == self.node.osfstorage_region.name
137+
138+
def test_zip_completed_is_unset_for_single_files(self):
139+
"""Only zips stream through WaterButler, so nothing reports completion here."""
140+
self.app.get(f'/download/{self.guid}/', auth=self.user.auth)
141+
142+
assert DownloadEvent.objects.get().zip_completed is None
143+
144+
def test_anonymous_download_is_recorded_without_a_user(self):
145+
self.node.is_public = True
146+
self.node.save()
147+
148+
self.app.get(f'/download/{self.guid}/')
149+
150+
event = DownloadEvent.objects.get()
151+
assert event.user is None
152+
153+
def test_mfr_render_is_not_recorded(self):
154+
self.app.get(f'/download/{self.guid}/?mode=render', auth=self.user.auth)
155+
156+
assert not DownloadEvent.objects.exists()
157+
158+
def test_download_still_succeeds_when_recording_fails(self):
159+
with pytest.MonkeyPatch.context() as patch:
160+
patch.setattr(
161+
'addons.base.views.record_download',
162+
lambda **kwargs: (_ for _ in ()).throw(ValueError('boom')),
163+
)
164+
res = self.app.get(f'/download/{self.guid}/', auth=self.user.auth)
165+
166+
assert res.status_code == 302
167+
168+
169+
class TestUserRegionDerivation:
170+
"""The fallback chain, most to least trustworthy."""
171+
172+
class FakeUser:
173+
def __init__(self, timezone):
174+
self.timezone = timezone
175+
176+
def test_live_browser_timezone_wins(self):
177+
user = self.FakeUser('America/New_York')
178+
assert derive_user_region('Europe/Kyiv', user, 'Germany') == 'Europe/Kyiv'
179+
180+
def test_falls_back_to_profile_timezone(self):
181+
user = self.FakeUser('America/New_York')
182+
assert derive_user_region('', user, 'Germany') == 'America/New_York'
183+
184+
def test_default_profile_timezone_is_not_a_signal(self):
185+
"""Every user has Etc/UTC until they change it, so it says nothing."""
186+
user = self.FakeUser('Etc/UTC')
187+
assert derive_user_region('', user, 'Germany') == 'Germany'
188+
189+
def test_falls_back_to_storage_region(self):
190+
assert derive_user_region('', None, 'Germany') == 'Germany'
191+
192+
def test_default_storage_region_is_not_a_signal(self):
193+
assert derive_user_region('', None, DEFAULT_REGION_NAME) == ''
194+
195+
def test_unknown_is_empty(self):
196+
assert derive_user_region('', None, '') == ''
197+
198+
199+
@pytest.mark.django_db
200+
class TestRecordDownloadNeverRaises:
201+
202+
def test_enqueue_failure_is_swallowed(self, monkeypatch):
203+
def explode(*args, **kwargs):
204+
raise ValueError('boom')
205+
206+
monkeypatch.setattr('osf.utils.download_telemetry.enqueue_postcommit_task', explode)
207+
208+
record_download(download_type=DownloadEvent.FILE, resource_guid='abcde')
209+
210+
assert not DownloadEvent.objects.exists()

0 commit comments

Comments
 (0)