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