diff --git a/readthedocs/api/v2/views/integrations.py b/readthedocs/api/v2/views/integrations.py index be9236c5df8..1ff9884f404 100644 --- a/readthedocs/api/v2/views/integrations.py +++ b/readthedocs/api/v2/views/integrations.py @@ -71,7 +71,7 @@ class WebhookMixin: renderer_classes = (JSONRenderer,) integration = None integration_type = None - invalid_payload_msg = 'Payload not valid' + invalid_payload_msg = "Payload not valid" missing_secret_deprecated_msg = dedent( """ This webhook doesn't have a secret configured. @@ -84,7 +84,7 @@ def post(self, request, project_slug): """Set up webhook post view with request and project objects.""" self.request = request - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project_slug, integration_type=self.integration_type, ) @@ -101,7 +101,7 @@ def post(self, request, project_slug): try: self.project = self.get_project(slug=project_slug) if not Project.objects.is_active(self.project): - resp = {'detail': 'This project is currently disabled'} + resp = {"detail": "This project is currently disabled"} return Response(resp, status=status.HTTP_406_NOT_ACCEPTABLE) except Project.DoesNotExist as exc: raise NotFound("Project not found") from exc @@ -115,15 +115,15 @@ def post(self, request, project_slug): ) if not self.is_payload_valid(): - log.warning('Invalid payload for project and integration.') + log.warning("Invalid payload for project and integration.") return Response( - {'detail': self.invalid_payload_msg}, + {"detail": self.invalid_payload_msg}, status=HTTP_400_BAD_REQUEST, ) resp = self.handle_webhook() if resp is None: - log.info('Unhandled webhook event') - resp = {'detail': 'Unhandled webhook event'} + log.info("Unhandled webhook event") + resp = {"detail": "Unhandled webhook event"} # The response can be a DRF Response with with the status code already set. # In that case, we just return it as is. @@ -143,7 +143,7 @@ def get_project(self, **kwargs): def finalize_response(self, req, *args, **kwargs): """If the project was set on POST, store an HTTP exchange.""" resp = super().finalize_response(req, *args, **kwargs) - if hasattr(self, 'project') and self.project: + if hasattr(self, "project") and self.project: HttpExchange.objects.from_exchange( req, resp, @@ -222,14 +222,14 @@ def get_response_push(self, project, branches): to_build, not_building = build_branches(project, branches) if not_building: log.info( - 'Skipping project branches.', + "Skipping project branches.", branches=branches, ) triggered = bool(to_build) return { - 'build_triggered': triggered, - 'project': project.slug, - 'versions': list(to_build), + "build_triggered": triggered, + "project": project.slug, + "versions": list(to_build), } def sync_versions_response(self, project, sync=True): @@ -242,10 +242,10 @@ def sync_versions_response(self, project, sync=True): if sync: version = trigger_sync_versions(project) return { - 'build_triggered': False, - 'project': project.slug, - 'versions': [version] if version else [], - 'versions_synced': version is not None, + "build_triggered": False, + "project": project.slug, + "versions": [version] if version else [], + "versions_synced": version is not None, } def get_external_version_response(self, project): @@ -372,12 +372,12 @@ class GitHubWebhookView(WebhookMixin, APIView): """ integration_type = Integration.GITHUB_WEBHOOK - invalid_payload_msg = 'Payload not valid, invalid or missing signature' + invalid_payload_msg = "Payload not valid, invalid or missing signature" def get_data(self): - if self.request.content_type == 'application/x-www-form-urlencoded': + if self.request.content_type == "application/x-www-form-urlencoded": try: - return json.loads(self.request.data['payload']) + return json.loads(self.request.data["payload"]) except (ValueError, KeyError): pass return super().get_data() @@ -446,11 +446,11 @@ def handle_webhook(self): """ # Get event and trigger other webhook events - action = self.data.get('action', None) - created = self.data.get('created', False) - deleted = self.data.get('deleted', False) + action = self.data.get("action", None) + created = self.data.get("created", False) + deleted = self.data.get("deleted", False) event = self.request.headers.get(GITHUB_EVENT_HEADER, GITHUB_PUSH) - log.bind(webhook_event=event) + structlog.contextvars.bind_contextvars(webhook_event=event) webhook_github.send( Project, project=self.project, @@ -469,7 +469,7 @@ def handle_webhook(self): # Sync versions when a branch/tag was created/deleted if event in (GITHUB_CREATE, GITHUB_DELETE): - log.debug('Triggered sync_versions.') + log.debug("Triggered sync_versions.") return self.sync_versions_response(self.project) integration = self.get_integration() @@ -489,22 +489,30 @@ def handle_webhook(self): return self.get_closed_external_version_response(self.project) # Sync versions when push event is created/deleted action - if all([ + if all( + [ event == GITHUB_PUSH, (created or deleted), - ]): - events = integration.provider_data.get('events', []) if integration.provider_data else [] # noqa - if any([ + ] + ): + events = ( + integration.provider_data.get("events", []) + if integration.provider_data + else [] + ) # noqa + if any( + [ GITHUB_CREATE in events, GITHUB_DELETE in events, - ]): + ] + ): # GitHub will send PUSH **and** CREATE/DELETE events on a creation/deletion in newer # webhooks. If we receive a PUSH event we need to check if the webhook doesn't # already have the CREATE/DELETE events. So we don't trigger the sync twice. return self.sync_versions_response(self.project, sync=False) log.debug( - 'Triggered sync_versions.', + "Triggered sync_versions.", integration_events=events, ) return self.sync_versions_response(self.project) @@ -521,8 +529,8 @@ def handle_webhook(self): def _normalize_ref(self, ref): """Remove `ref/(heads|tags)/` from the reference to match a Version on the db.""" - pattern = re.compile(r'^refs/(heads|tags)/') - return pattern.sub('', ref) + pattern = re.compile(r"^refs/(heads|tags)/") + return pattern.sub("", ref) class GitLabWebhookView(WebhookMixin, APIView): @@ -565,7 +573,7 @@ class GitLabWebhookView(WebhookMixin, APIView): """ integration_type = Integration.GITLAB_WEBHOOK - invalid_payload_msg = 'Payload not valid, invalid or missing token' + invalid_payload_msg = "Payload not valid, invalid or missing token" def is_payload_valid(self): """ @@ -602,9 +610,9 @@ def handle_webhook(self): instead, it sets the before/after field to 0000000000000000000000000000000000000000 ('0' * 40) """ - event = self.request.data.get('object_kind', GITLAB_PUSH) - action = self.data.get('object_attributes', {}).get('action', None) - log.bind(webhook_event=event) + event = self.request.data.get("object_kind", GITLAB_PUSH) + action = self.data.get("object_attributes", {}).get("action", None) + structlog.contextvars.bind_contextvars(webhook_event=event) webhook_gitlab.send( Project, project=self.project, @@ -621,12 +629,12 @@ def handle_webhook(self): # Handle push events and trigger builds if event in (GITLAB_PUSH, GITLAB_TAG_PUSH): data = self.request.data - before = data.get('before') - after = data.get('after') + before = data.get("before") + after = data.get("after") # Tag/branch created/deleted if GITLAB_NULL_HASH in (before, after): log.debug( - 'Triggered sync_versions.', + "Triggered sync_versions.", before=before, after=after, ) @@ -653,8 +661,8 @@ def handle_webhook(self): return None def _normalize_ref(self, ref): - pattern = re.compile(r'^refs/(heads|tags)/') - return pattern.sub('', ref) + pattern = re.compile(r"^refs/(heads|tags)/") + return pattern.sub("", ref) class BitbucketWebhookView(WebhookMixin, APIView): @@ -700,7 +708,7 @@ def handle_webhook(self): attribute (null if it is a creation). """ event = self.request.headers.get(BITBUCKET_EVENT_HEADER, BITBUCKET_PUSH) - log.bind(webhook_event=event) + structlog.contextvars.bind_contextvars(webhook_event=event) webhook_bitbucket.send( Project, project=self.project, @@ -715,14 +723,14 @@ def handle_webhook(self): if event == BITBUCKET_PUSH: try: data = self.request.data - changes = data['push']['changes'] + changes = data["push"]["changes"] branches = [] for change in changes: - old = change['old'] - new = change['new'] + old = change["old"] + new = change["new"] # Normal push to master if old is not None and new is not None: - branches.append(new['name']) + branches.append(new["name"]) # BitBuck returns an array of changes rather than # one webhook per change. If we have at least one normal push # we don't trigger the sync versions, because that @@ -770,7 +778,7 @@ class IsAuthenticatedOrHasToken(permissions.IsAuthenticated): def has_permission(self, request, view): has_perm = super().has_permission(request, view) - return has_perm or 'token' in request.data + return has_perm or "token" in request.data class APIWebhookView(WebhookMixin, APIView): @@ -799,15 +807,13 @@ def get_project(self, **kwargs): # If the user is not an admin of the project, fall back to token auth if self.request.user.is_authenticated: try: - return ( - Project.objects.for_admin_user( - self.request.user, - ).get(**kwargs) - ) + return Project.objects.for_admin_user( + self.request.user, + ).get(**kwargs) except Project.DoesNotExist: pass # Recheck project and integration relationship during token auth check - token = self.request.data.get('token') + token = self.request.data.get("token") if token: integration = self.get_integration() obj = Project.objects.get(**kwargs) @@ -821,7 +827,7 @@ def get_project(self, **kwargs): def handle_webhook(self): try: branches = self.request.data.get( - 'branches', + "branches", [self.project.get_default_branch()], ) default_branch = self.request.data.get("default_branch", None) diff --git a/readthedocs/builds/tasks.py b/readthedocs/builds/tasks.py index c7a177d9e45..28c15e852b5 100644 --- a/readthedocs/builds/tasks.py +++ b/readthedocs/builds/tasks.py @@ -403,7 +403,7 @@ def send_build_status(build_pk, commit, status): provider_name = build.project.git_provider_name - log.bind( + structlog.contextvars.bind_contextvars( build_id=build.pk, project_slug=build.project.slug, commit=commit, diff --git a/readthedocs/core/logs.py b/readthedocs/core/logs.py index 8c095cc3c23..6c31b27c577 100644 --- a/readthedocs/core/logs.py +++ b/readthedocs/core/logs.py @@ -204,6 +204,7 @@ def __call__(self, logger, method_name, event_dict): shared_processors = [ + structlog.contextvars.merge_contextvars, structlog.stdlib.add_logger_name, structlog.stdlib.add_log_level, structlog.stdlib.PositionalArgumentsFormatter(), @@ -219,7 +220,6 @@ def __call__(self, logger, method_name, event_dict): structlog.stdlib.ProcessorFormatter.wrap_for_formatter, ] ), - context_class=structlog.threadlocal.wrap_dict(dict), logger_factory=structlog.stdlib.LoggerFactory(), wrapper_class=structlog.stdlib.BoundLogger, cache_logger_on_first_use=True, diff --git a/readthedocs/core/signals.py b/readthedocs/core/signals.py index 15040c9cd4f..57de1989c66 100644 --- a/readthedocs/core/signals.py +++ b/readthedocs/core/signals.py @@ -41,7 +41,7 @@ def process_email_confirmed(request, email_address, **kwargs): profile = UserProfile.objects.filter(user=user).first() if profile and profile.mailing_list: # TODO: Unsubscribe users if they unset `mailing_list`. - log.bind( + structlog.contextvars.bind_contextvars( email=email_address.email, username=user.username, ) diff --git a/readthedocs/core/unresolver.py b/readthedocs/core/unresolver.py index aaaeffa0f26..be42a9a21ca 100644 --- a/readthedocs/core/unresolver.py +++ b/readthedocs/core/unresolver.py @@ -628,7 +628,7 @@ def unresolve_domain_from_request(self, request): :returns: A UnresolvedDomain object. """ host = self.get_domain_from_host(request.get_host()) - log.bind(host=host) + structlog.contextvars.bind_contextvars(host=host) # Explicit Project slug being passed in. header_project_slug = request.headers.get("X-RTD-Slug", "").lower() diff --git a/readthedocs/core/utils/__init__.py b/readthedocs/core/utils/__init__.py index 7e30678fd63..4c3431515fd 100644 --- a/readthedocs/core/utils/__init__.py +++ b/readthedocs/core/utils/__init__.py @@ -51,7 +51,7 @@ def prepare_build( from readthedocs.projects.tasks.builds import update_docs_task from readthedocs.projects.tasks.utils import send_external_build_status - log.bind(project_slug=project.slug) + structlog.contextvars.bind_contextvars(project_slug=project.slug) if not Project.objects.is_active(project): log.warning( @@ -72,7 +72,7 @@ def prepare_build( commit=commit, ) - log.bind( + structlog.contextvars.bind_contextvars( build_id=build.id, version_slug=version.slug, ) @@ -101,7 +101,7 @@ def prepare_build( options["time_limit"] = int(time_limit * 1.2) if commit: - log.bind(commit=commit) + structlog.contextvars.bind_contextvars(commit=commit) # Send pending Build Status using Git Status API for External Builds. send_external_build_status( @@ -194,7 +194,7 @@ def trigger_build(project, version=None, commit=None): :returns: Celery AsyncResult promise and Build instance :rtype: tuple """ - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, version_slug=version.slug if version else None, commit=commit, diff --git a/readthedocs/core/utils/filesystem.py b/readthedocs/core/utils/filesystem.py index 805120c409f..65e8619da54 100644 --- a/readthedocs/core/utils/filesystem.py +++ b/readthedocs/core/utils/filesystem.py @@ -83,7 +83,7 @@ def safe_open( path = Path(path).absolute() - log.bind( + structlog.contextvars.bind_contextvars( path_resolved=str(path.absolute().resolve()), ) diff --git a/readthedocs/doc_builder/environments.py b/readthedocs/doc_builder/environments.py index c1f723fb423..8233bb64797 100644 --- a/readthedocs/doc_builder/environments.py +++ b/readthedocs/doc_builder/environments.py @@ -10,13 +10,13 @@ import structlog from django.conf import settings from django.utils.translation import gettext_lazy as _ -from docker import APIClient from docker.errors import APIError as DockerAPIError from docker.errors import DockerException from docker.errors import NotFound as DockerNotFoundError from requests.exceptions import ConnectionError, ReadTimeout from requests_toolbelt.multipart.encoder import MultipartEncoder +from docker import APIClient from readthedocs.builds.models import BuildCommandResultMixin from readthedocs.core.utils import slugify from readthedocs.projects.models import Feature @@ -104,7 +104,7 @@ def __init__( # When using `project.vcs_repo` on tests we are passing `environment=False`. # See https://github.com/readthedocs/readthedocs.org/pull/6482#discussion_r367694530 if self.build_env.project and self.build_env.version: - log.bind( + structlog.contextvars.bind_contextvars( project_slug=self.build_env.project.slug, version_slug=self.build_env.version.slug, ) @@ -112,7 +112,7 @@ def __init__( # NOTE: `self.build_env.build` is not available when using this class # from `sync_repository_task` since it's not associated to any build if self.build_env.build: - log.bind( + structlog.contextvars.bind_contextvars( build_id=self.build_env.build.get("id"), ) @@ -593,7 +593,7 @@ def __init__(self, *args, **kwargs): if self.project.container_time_limit: self.container_time_limit = self.project.container_time_limit - log.bind( + structlog.contextvars.bind_contextvars( project_slug=self.project.slug, version_slug=self.version.slug, ) @@ -601,7 +601,7 @@ def __init__(self, *args, **kwargs): # NOTE: as this environment is used for `sync_repository_task` it may # not have a build associated if self.build: - log.bind( + structlog.contextvars.bind_contextvars( build_id=self.build.get("id"), ) diff --git a/readthedocs/doc_builder/python_environments.py b/readthedocs/doc_builder/python_environments.py index 4af234e64b3..9e1a4f16a25 100644 --- a/readthedocs/doc_builder/python_environments.py +++ b/readthedocs/doc_builder/python_environments.py @@ -31,7 +31,7 @@ def __init__(self, version, build_env, config=None): self.config = load_yaml_config(version) # Compute here, since it's used a lot self.checkout_path = self.project.checkout_path(self.version.slug) - log.bind( + structlog.contextvars.bind_contextvars( project_slug=self.project.slug, version_slug=self.version.slug, ) diff --git a/readthedocs/gold/views.py b/readthedocs/gold/views.py index f1fbde87252..2378767aacf 100644 --- a/readthedocs/gold/views.py +++ b/readthedocs/gold/views.py @@ -219,7 +219,7 @@ class StripeEventView(APIView): def post(self, request, format=None): try: event = stripe.Event.construct_from(request.data, settings.STRIPE_SECRET) - log.bind(event=event.type) + structlog.contextvars.bind_contextvars(event=event.type) if event.type not in self.EVENTS: log.warning("Unhandled Stripe event.", event_type=event.type) return Response( @@ -227,11 +227,11 @@ def post(self, request, format=None): ) stripe_customer = event.data.object.customer - log.bind(stripe_customer=stripe_customer) + structlog.contextvars.bind_contextvars(stripe_customer=stripe_customer) if event.type == self.EVENT_CHECKOUT_COMPLETED: username = event.data.object.client_reference_id - log.bind(user_username=username) + structlog.contextvars.bind_contextvars(user_username=username) mode = event.data.object.mode if mode == "subscription": # Gold Membership @@ -240,7 +240,7 @@ def post(self, request, format=None): event.data.object.subscription ) stripe_price = self._get_stripe_price(subscription) - log.bind(stripe_price=stripe_price.id) + structlog.contextvars.bind_contextvars(stripe_price=stripe_price.id) log.info("Gold Membership subscription.") gold, _ = GoldUser.objects.get_or_create( user=user, @@ -275,7 +275,7 @@ def post(self, request, format=None): elif event.type == self.EVENT_CHECKOUT_PAYMENT_FAILED: username = event.data.object.client_reference_id - log.bind(user_username=username) + structlog.contextvars.bind_contextvars(user_username=username) # TODO: add user notification saying it failed log.exception("Gold User payment failed.") diff --git a/readthedocs/oauth/services/base.py b/readthedocs/oauth/services/base.py index 23a3fad81f9..0d48932ce3b 100644 --- a/readthedocs/oauth/services/base.py +++ b/readthedocs/oauth/services/base.py @@ -46,7 +46,7 @@ def __init__(self, user, account): self.session = None self.user = user self.account = account - log.bind( + structlog.contextvars.bind_contextvars( user_username=self.user.username, social_provider=self.provider_id, social_account_id=self.account.pk, diff --git a/readthedocs/oauth/services/bitbucket.py b/readthedocs/oauth/services/bitbucket.py index 7ffab66f3e8..993b996996f 100644 --- a/readthedocs/oauth/services/bitbucket.py +++ b/readthedocs/oauth/services/bitbucket.py @@ -240,7 +240,7 @@ def get_provider_data(self, project, integration): rtd_webhook_url = self.get_webhook_url(project, integration) - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, integration_id=integration.pk, url=url, @@ -294,7 +294,7 @@ def setup_webhook(self, project, integration=None): data = self.get_webhook_data(project, integration) resp = None - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, integration_id=integration.pk, url=url, @@ -346,7 +346,7 @@ def update_webhook(self, project, integration): :returns: boolean based on webhook set up success, and requests Response object :rtype: (Bool, Response) """ - log.bind(project_slug=project.slug) + structlog.contextvars.bind_contextvars(project_slug=project.slug) provider_data = self.get_provider_data(project, integration) # Handle the case where we don't have a proper provider_data set diff --git a/readthedocs/oauth/services/github.py b/readthedocs/oauth/services/github.py index fb95492a81d..7f173308e26 100644 --- a/readthedocs/oauth/services/github.py +++ b/readthedocs/oauth/services/github.py @@ -242,7 +242,7 @@ def get_provider_data(self, project, integration): session = self.get_session() owner, repo = build_utils.get_github_username_repo(url=project.repo) url = f"https://api.github.com/repos/{owner}/{repo}/hooks" - log.bind( + structlog.contextvars.bind_contextvars( url=url, project_slug=project.slug, integration_id=integration.pk, @@ -297,7 +297,7 @@ def setup_webhook(self, project, integration=None): data = self.get_webhook_data(project, integration) url = f"https://api.github.com/repos/{owner}/{repo}/hooks" - log.bind( + structlog.contextvars.bind_contextvars( url=url, project_slug=project.slug, integration_id=integration.pk, @@ -309,7 +309,7 @@ def setup_webhook(self, project, integration=None): data=data, headers={"content-type": "application/json"}, ) - log.bind(http_status_code=resp.status_code) + structlog.contextvars.bind_contextvars(http_status_code=resp.status_code) # GitHub will return 200 if already synced if resp.status_code in [200, 201]: @@ -356,7 +356,7 @@ def update_webhook(self, project, integration): resp = None provider_data = self.get_provider_data(project, integration) - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, integration_id=integration.pk, ) @@ -373,7 +373,7 @@ def update_webhook(self, project, integration): data=data, headers={"content-type": "application/json"}, ) - log.bind( + structlog.contextvars.bind_contextvars( http_status_code=resp.status_code, url=url, ) @@ -445,7 +445,7 @@ def send_build_status(self, build, commit, status): "context": context, } - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, commit_status=github_build_status, user_username=self.user.username, @@ -460,7 +460,7 @@ def send_build_status(self, build, commit, status): data=json.dumps(data), headers={"content-type": "application/json"}, ) - log.bind(http_status_code=resp.status_code) + structlog.contextvars.bind_contextvars(http_status_code=resp.status_code) if resp.status_code == 201: log.debug("GitHub commit status created for project.") return True diff --git a/readthedocs/oauth/services/gitlab.py b/readthedocs/oauth/services/gitlab.py index 1af481193d0..d4e793d3a93 100644 --- a/readthedocs/oauth/services/gitlab.py +++ b/readthedocs/oauth/services/gitlab.py @@ -326,7 +326,7 @@ def get_provider_data(self, project, integration): return None session = self.get_session() - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, integration_id=integration.pk, ) @@ -388,7 +388,7 @@ def setup_webhook(self, project, integration=None): if repo_id is None: return (False, resp) - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, integration_id=integration.pk, url=url, @@ -401,7 +401,7 @@ def setup_webhook(self, project, integration=None): data=data, headers={"content-type": "application/json"}, ) - log.bind(http_status_code=resp.status_code) + structlog.contextvars.bind_contextvars(http_status_code=resp.status_code) if resp.status_code == 201: integration.provider_data = resp.json() @@ -454,7 +454,7 @@ def update_webhook(self, project, integration): data = self.get_webhook_data(repo_id, project, integration) - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, integration_id=integration.pk, ) @@ -539,7 +539,7 @@ def send_build_status(self, build, commit, status): } url = f"{self.adapter.provider_base_url}/api/v4/projects/{repo_id}/statuses/{commit}" - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, commit_status=gitlab_build_state, user_username=self.user.username, @@ -552,7 +552,7 @@ def send_build_status(self, build, commit, status): headers={"content-type": "application/json"}, ) - log.bind(http_status_code=resp.status_code) + structlog.contextvars.bind_contextvars(http_status_code=resp.status_code) if resp.status_code == 201: log.debug("GitLab commit status created for project.") return True diff --git a/readthedocs/oauth/tasks.py b/readthedocs/oauth/tasks.py index c98ba45ce80..572bc2c678d 100644 --- a/readthedocs/oauth/tasks.py +++ b/readthedocs/oauth/tasks.py @@ -132,11 +132,11 @@ def sync_active_users_remote_repositories(): ) users_count = users.count() - log.bind(total_users=users_count) + structlog.contextvars.bind_contextvars(total_users=users_count) log.info("Triggering re-sync of RemoteRepository for active users.") for i, user in enumerate(users): - log.bind( + structlog.contextvars.bind_contextvars( user_username=user.username, progress=f"{i}/{users_count}", ) diff --git a/readthedocs/projects/tasks/builds.py b/readthedocs/projects/tasks/builds.py index 69570d2994a..64554bf6c19 100644 --- a/readthedocs/projects/tasks/builds.py +++ b/readthedocs/projects/tasks/builds.py @@ -136,7 +136,7 @@ class SyncRepositoryTask(SyncRepositoryMixin, Task): in our database. """ - name = __name__ + '.sync_repository_task' + name = __name__ + ".sync_repository_task" max_retries = 5 default_retry_delay = 7 * 60 throws = ( @@ -145,7 +145,7 @@ class SyncRepositoryTask(SyncRepositoryMixin, Task): ) def before_start(self, task_id, args, kwargs): - log.info('Running task.', name=self.name) + log.info("Running task.", name=self.name) # Create the object to store all the task-related data self.data = TaskData() @@ -168,9 +168,9 @@ def before_start(self, task_id, args, kwargs): # Also note there are builds that are triggered without a commit # because they just build the latest commit for that version - self.data.build_commit = kwargs.get('build_commit') + self.data.build_commit = kwargs.get("build_commit") - log.bind( + structlog.contextvars.bind_contextvars( project_slug=self.data.project.slug, version_slug=self.data.version.slug, ) @@ -179,7 +179,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo): # Do not log as error handled exceptions if isinstance(exc, RepositoryError): log.warning( - 'There was an error with the repository.', + "There was an error with the repository.", ) elif isinstance(exc, SyncRepositoryLocked): log.warning( @@ -274,10 +274,8 @@ class UpdateDocsTask(SyncRepositoryMixin, Task): build all the documentation formats and upload them to the storage. """ - name = __name__ + '.update_docs_task' - autoretry_for = ( - BuildMaxConcurrencyError, - ) + name = __name__ + ".update_docs_task" + autoretry_for = (BuildMaxConcurrencyError,) max_retries = settings.RTD_BUILDS_MAX_RETRIES default_retry_delay = settings.RTD_BUILDS_RETRY_DELAY retry_backoff = False @@ -320,10 +318,12 @@ class UpdateDocsTask(SyncRepositoryMixin, Task): def _setup_sigterm(self): def sigterm_received(*args, **kwargs): - log.warning('SIGTERM received. Waiting for build to stop gracefully after it finishes.') + log.warning( + "SIGTERM received. Waiting for build to stop gracefully after it finishes." + ) def sigint_received(*args, **kwargs): - log.warning('SIGINT received. Canceling the build running.') + log.warning("SIGINT received. Canceling the build running.") # Only allow to cancel the build if it's not already uploading the files. # This is to protect our users to end up with half of the documentation uploaded. @@ -347,12 +347,12 @@ def _check_concurrency_limit(self): ) concurrency_limit_reached = response.get("limit_reached", False) max_concurrent_builds = response.get( - 'max_concurrent', + "max_concurrent", settings.RTD_MAX_CONCURRENT_BUILDS, ) except Exception: log.exception( - 'Error while hitting/parsing API for concurrent limit checks from builder.', + "Error while hitting/parsing API for concurrent limit checks from builder.", project_slug=self.data.project.slug, version_slug=self.data.version.slug, ) @@ -375,7 +375,7 @@ def _check_concurrency_limit(self): def _check_project_disabled(self): if self.data.project.skip: - log.warning('Project build skipped.') + log.warning("Project build skipped.") raise BuildAppError(BuildAppError.BUILDS_DISABLED) def before_start(self, task_id, args, kwargs): @@ -386,7 +386,7 @@ def before_start(self, task_id, args, kwargs): # required arguments. self.data.version_pk, self.data.build_pk = args - log.bind(build_id=self.data.build_pk) + structlog.contextvars.bind_contextvars(build_id=self.data.build_pk) log.info("Running task.", name=self.name) self.data.start_time = timezone.now() @@ -403,21 +403,21 @@ def before_start(self, task_id, args, kwargs): self.data.project = self.data.version.project # Save the builder instance's name into the build object - self.data.build['builder'] = socket.gethostname() + self.data.build["builder"] = socket.gethostname() # Reset any previous build error reported to the user - self.data.build['error'] = '' + self.data.build["error"] = "" # Also note there are builds that are triggered without a commit # because they just build the latest commit for that version - self.data.build_commit = kwargs.get('build_commit') + self.data.build_commit = kwargs.get("build_commit") self.data.build_director = BuildDirector( data=self.data, ) - log.bind( + structlog.contextvars.bind_contextvars( # NOTE: ``self.data.build`` is just a regular dict, not an APIBuild :'( - builder=self.data.build['builder'], + builder=self.data.build["builder"], commit=self.data.build_commit, project_slug=self.data.project.slug, version_slug=self.data.version.slug, @@ -470,7 +470,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo): # # So, we create the `self.data.build` with the minimum required data. self.data.build = { - 'id': self.data.build_pk, + "id": self.data.build_pk, } # Known errors in our application code (e.g. we couldn't connect to @@ -513,7 +513,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo): if message_id not in self.exceptions_without_notifications: self.send_notifications( self.data.version_pk, - self.data.build['id'], + self.data.build["id"], event=WebHookEvent.BUILD_FAILED, ) @@ -541,7 +541,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo): send_external_build_status( version_type=version_type, - build_pk=self.data.build['id'], + build_pk=self.data.build["id"], commit=self.data.build_commit, status=status, ) @@ -661,20 +661,20 @@ def on_success(self, retval, task_id, args, kwargs): self.send_notifications( self.data.version.pk, - self.data.build['id'], + self.data.build["id"], event=WebHookEvent.BUILD_PASSED, ) if self.data.build_commit: send_external_build_status( version_type=self.data.version.type, - build_pk=self.data.build['id'], + build_pk=self.data.build["id"], commit=self.data.build_commit, status=BUILD_STATUS_SUCCESS, ) # Update build object - self.data.build['success'] = True + self.data.build["success"] = True def on_retry(self, exc, task_id, args, kwargs, einfo): """ @@ -686,11 +686,11 @@ def on_retry(self, exc, task_id, args, kwargs, einfo): See https://docs.celeryproject.org/en/master/userguide/tasks.html#retrying """ - log.info('Retrying this task.') + log.info("Retrying this task.") if isinstance(exc, BuildMaxConcurrencyError): log.warning( - 'Delaying tasks due to concurrency limit.', + "Delaying tasks due to concurrency limit.", project_slug=self.data.project.slug, version_slug=self.data.version.slug, ) @@ -713,7 +713,7 @@ def after_return(self, status, retval, task_id, args, kwargs, einfo): so some attributes from the `self.data` object may not be defined. """ # Update build object - self.data.build['length'] = (timezone.now() - self.data.start_time).seconds + self.data.build["length"] = (timezone.now() - self.data.start_time).seconds build_state = None # The state key might not be defined @@ -742,9 +742,9 @@ def after_return(self, status, retval, task_id, args, kwargs, einfo): ) log.info( - 'Build finished.', - length=self.data.build['length'], - success=self.data.build['success'] + "Build finished.", + length=self.data.build["length"], + success=self.data.build["success"], ) def update_build(self, state=None): @@ -856,23 +856,20 @@ def get_build(self, build_pk): if build_pk: build = self.data.api_client.build(build_pk).get() private_keys = [ - 'project', - 'version', - 'resource_uri', - 'absolute_uri', + "project", + "version", + "resource_uri", + "absolute_uri", ] # TODO: try to use the same technique than for ``APIProject``. - return { - key: val - for key, val in build.items() if key not in private_keys - } + return {key: val for key, val in build.items() if key not in private_keys} # NOTE: this can be just updated on `self.data.build['']` and sent once the # build has finished to reduce API calls. def set_valid_clone(self): """Mark on the project that it has been cloned properly.""" self.data.api_client.project(self.data.project.pk).patch( - {'has_valid_clone': True} + {"has_valid_clone": True} ) self.data.project.has_valid_clone = True self.data.version.project.has_valid_clone = True @@ -887,11 +884,11 @@ def store_build_artifacts(self): Remove build artifacts of types not included in this build (PDF, ePub, zip only). """ time_before_store_build_artifacts = timezone.now() - log.info('Writing build artifacts to media storage') + log.info("Writing build artifacts to media storage") self.update_build(state=BUILD_STATE_UPLOADING) valid_artifacts = self.get_valid_artifact_types() - log.bind(artifacts=valid_artifacts) + structlog.contextvars.bind_contextvars(artifacts=valid_artifacts) types_to_copy = [] types_to_delete = [] diff --git a/readthedocs/projects/tasks/search.py b/readthedocs/projects/tasks/search.py index 4168fb61c8d..4dde1f22a3c 100644 --- a/readthedocs/projects/tasks/search.py +++ b/readthedocs/projects/tasks/search.py @@ -37,7 +37,7 @@ def index_build(build_id): ) return - log.bind( + structlog.contextvars.bind_contextvars( project_slug=version.project.slug, version_slug=version.slug, build_id=build.id, @@ -87,7 +87,7 @@ def reindex_version(version_id, search_index_name=None): ) return - log.bind( + structlog.contextvars.bind_contextvars( project_slug=version.project.slug, version_slug=version.slug, build_id=latest_successful_build.id, diff --git a/readthedocs/projects/tasks/utils.py b/readthedocs/projects/tasks/utils.py index e157f14051b..9e07a6fe61a 100644 --- a/readthedocs/projects/tasks/utils.py +++ b/readthedocs/projects/tasks/utils.py @@ -172,7 +172,9 @@ def set_builder_scale_in_protection(instance, protected_from_scale_in): This way, AWS will not scale-in this instance while it's building the documentation. This is pretty useful for long running tasks. """ - log.bind(instance=instance, protected_from_scale_in=protected_from_scale_in) + structlog.contextvars.bind_contextvars( + instance=instance, protected_from_scale_in=protected_from_scale_in + ) if settings.DEBUG or settings.RTD_DOCKER_COMPOSE: log.info( @@ -211,7 +213,7 @@ class BuildRequest(Request): def on_timeout(self, soft, timeout): super().on_timeout(soft, timeout) - log.bind( + structlog.contextvars.bind_contextvars( task_name=self.task.name, project_slug=self.task.data.project.slug, build_id=self.task.data.build["id"], diff --git a/readthedocs/projects/views/private.py b/readthedocs/projects/views/private.py index f7280f1fb0b..4156b83b411 100644 --- a/readthedocs/projects/views/private.py +++ b/readthedocs/projects/views/private.py @@ -293,7 +293,7 @@ def _set_initial_dict(self): def post(self, *args, **kwargs): self._set_initial_dict() - log.bind(user_username=self.request.user.username) + structlog.contextvars.bind_contextvars(user_username=self.request.user.username) if self.request.user.profile.banned: log.info("Rejecting project POST from shadowbanned user.") diff --git a/readthedocs/proxito/views/serve.py b/readthedocs/proxito/views/serve.py index 4f44d51fa07..126aeae68cd 100644 --- a/readthedocs/proxito/views/serve.py +++ b/readthedocs/proxito/views/serve.py @@ -290,7 +290,7 @@ def serve_path(self, request, path): # A false positive was detected, continue with our normal serve. pass - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, version_slug=version.slug, filename=filename, @@ -308,7 +308,7 @@ def serve_path(self, request, path): # All public versions can be cached. self.cache_response = version.is_public - log.bind(cache_response=self.cache_response) + structlog.contextvars.bind_contextvars(cache_response=self.cache_response) log.debug("Serving docs.") # Verify if the project is marked as spam and return a 401 in that case @@ -394,7 +394,7 @@ def get(self, request, proxito_path): with the default version and finally, if none of them are found, the Read the Docs default page (Maze Found) is rendered by Django and served. """ - log.bind(proxito_path=proxito_path) + structlog.contextvars.bind_contextvars(proxito_path=proxito_path) log.debug("Executing 404 handler.") unresolved_domain = request.unresolved_domain # We force all storage calls to use the external versions storage, @@ -459,7 +459,7 @@ def get(self, request, proxito_path): filename = exc.path # TODO: Use a contextualized 404 - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, version_slug=version_slug, ) @@ -744,7 +744,7 @@ def get(self, request): # ... we do return a 404 raise Http404() - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project.slug, version_slug=version.slug, ) diff --git a/readthedocs/search/utils.py b/readthedocs/search/utils.py index 81f50e63f8c..b081db57e97 100644 --- a/readthedocs/search/utils.py +++ b/readthedocs/search/utils.py @@ -41,7 +41,7 @@ def remove_indexed_files( :param build_id: Build id. If isn't given, all index from `version` are deleted. """ - log.bind( + structlog.contextvars.bind_contextvars( project_slug=project_slug, version_slug=version_slug, ) diff --git a/readthedocs/settings/base.py b/readthedocs/settings/base.py index 95b3c1c9601..d2264dcb331 100644 --- a/readthedocs/settings/base.py +++ b/readthedocs/settings/base.py @@ -238,6 +238,7 @@ def INSTALLED_APPS(self): # noqa 'djstripe', 'django_celery_beat', "django_safemigrate.apps.SafeMigrateConfig", + "django_structlog", # our apps 'readthedocs.projects', @@ -320,13 +321,12 @@ def MIDDLEWARE(self): 'readthedocs.core.middleware.ReferrerPolicyMiddleware', 'simple_history.middleware.HistoryRequestMiddleware', 'readthedocs.core.logs.ReadTheDocsRequestMiddleware', - 'django_structlog.middlewares.CeleryMiddleware', ] if self.SHOW_DEBUG_TOOLBAR: middlewares.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware') return middlewares - + DJANGO_STRUCTLOG_CELERY_ENABLED = True AUTHENTICATION_BACKENDS = ( # Needed to login by username in Django admin, regardless of `allauth` diff --git a/readthedocs/subscriptions/event_handlers.py b/readthedocs/subscriptions/event_handlers.py index cdeec2c7338..c4cc09189ab 100644 --- a/readthedocs/subscriptions/event_handlers.py +++ b/readthedocs/subscriptions/event_handlers.py @@ -48,7 +48,9 @@ def subscription_created_event(event): we re-enable it, since the user just subscribed to a plan. """ stripe_subscription_id = event.data["object"]["id"] - log.bind(stripe_subscription_id=stripe_subscription_id) + structlog.contextvars.bind_contextvars( + stripe_subscription_id=stripe_subscription_id + ) stripe_subscription = djstripe.Subscription.objects.filter( id=stripe_subscription_id @@ -95,7 +97,9 @@ def subscription_updated_event(event): in case it changed. """ stripe_subscription_id = event.data["object"]["id"] - log.bind(stripe_subscription_id=stripe_subscription_id) + structlog.contextvars.bind_contextvars( + stripe_subscription_id=stripe_subscription_id + ) stripe_subscription = djstripe.Subscription.objects.filter( id=stripe_subscription_id ).first() @@ -159,7 +163,9 @@ def subscription_canceled(event): since those are from new users. """ stripe_subscription_id = event.data["object"]["id"] - log.bind(stripe_subscription_id=stripe_subscription_id) + structlog.contextvars.bind_contextvars( + stripe_subscription_id=stripe_subscription_id + ) stripe_subscription = djstripe.Subscription.objects.filter( id=stripe_subscription_id ).first() @@ -174,7 +180,7 @@ def subscription_canceled(event): log.error("Subscription isn't attached to an organization") return - log.bind(organization_slug=organization.slug) + structlog.contextvars.bind_contextvars(organization_slug=organization.slug) is_trial_subscription = stripe_subscription.items.filter( price__id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE ).exists() @@ -196,7 +202,7 @@ def subscription_canceled(event): def customer_updated_event(event): """Update the organization with the new information from the stripe customer.""" stripe_customer = event.data["object"] - log.bind(stripe_customer_id=stripe_customer["id"]) + structlog.contextvars.bind_contextvars(stripe_customer_id=stripe_customer["id"]) organization = Organization.objects.filter(stripe_id=stripe_customer["id"]).first() if not organization: log.error("Customer isn't attached to an organization.") diff --git a/readthedocs/subscriptions/signals.py b/readthedocs/subscriptions/signals.py index 49e2f56df44..ab7ebec9472 100644 --- a/readthedocs/subscriptions/signals.py +++ b/readthedocs/subscriptions/signals.py @@ -18,7 +18,7 @@ def update_stripe_customer(sender, instance, created, **kwargs): return organization = instance - log.bind(organization_slug=organization.slug) + structlog.contextvars.bind_contextvars(organization_slug=organization.slug) stripe_customer = organization.stripe_customer if not stripe_customer: diff --git a/readthedocs/subscriptions/utils.py b/readthedocs/subscriptions/utils.py index 2d0e984e5c8..b399a980dd1 100644 --- a/readthedocs/subscriptions/utils.py +++ b/readthedocs/subscriptions/utils.py @@ -27,7 +27,7 @@ def get_or_create_stripe_customer(organization): If `organization.stripe_customer` is `None`, a new customer is created. """ - log.bind(organization_slug=organization.slug) + structlog.contextvars.bind_contextvars(organization_slug=organization.slug) stripe_customer = organization.stripe_customer if stripe_customer: return stripe_customer diff --git a/readthedocs/telemetry/collectors.py b/readthedocs/telemetry/collectors.py index 4b32644654e..5ef6fee5504 100644 --- a/readthedocs/telemetry/collectors.py +++ b/readthedocs/telemetry/collectors.py @@ -28,7 +28,7 @@ def __init__(self, environment): self.config = self.environment.config self.checkout_path = self.project.checkout_path(self.version.slug) - log.bind( + structlog.contextvars.bind_contextvars( build_id=self.build["id"], project_slug=self.project.slug, version_slug=self.version.slug, diff --git a/requirements/deploy.txt b/requirements/deploy.txt index 1b549c6ae38..1ecb43cbc79 100644 --- a/requirements/deploy.txt +++ b/requirements/deploy.txt @@ -13,6 +13,7 @@ asgiref==3.7.2 # -r requirements/pip.txt # django # django-cors-headers + # django-structlog asttokens==2.4.1 # via stack-data async-timeout==4.0.3 @@ -36,6 +37,7 @@ celery==5.2.7 # via # -r requirements/pip.txt # django-celery-beat + # django-structlog certifi==2024.2.2 # via # -r requirements/pip.txt @@ -145,7 +147,7 @@ django-formtools==2.3 # via -r requirements/pip.txt django-gravatar2==1.4.4 # via -r requirements/pip.txt -django-ipware==5.0.2 +django-ipware==6.0.4 # via # -r requirements/pip.txt # django-structlog @@ -157,7 +159,7 @@ django-simple-history==3.0.0 # via -r requirements/pip.txt django-storages[boto3]==1.14.2 # via -r requirements/pip.txt -django-structlog==2.2.0 +django-structlog[celery]==7.1.0 # via -r requirements/pip.txt django-taggit==5.0.1 # via -r requirements/pip.txt @@ -299,6 +301,10 @@ python-dateutil==2.8.2 # botocore # elasticsearch-dsl # python-crontab +python-ipware==2.0.1 + # via + # -r requirements/pip.txt + # django-ipware python3-openid==3.2.0 # via # -r requirements/pip.txt @@ -362,7 +368,7 @@ stripe==4.2.0 # via # -r requirements/pip.txt # dj-stripe -structlog==23.2.0 +structlog==24.1.0 # via # -r requirements/pip.txt # django-structlog diff --git a/requirements/docker.txt b/requirements/docker.txt index 7ca95ba2c81..909d8fb2c9a 100644 --- a/requirements/docker.txt +++ b/requirements/docker.txt @@ -13,6 +13,7 @@ asgiref==3.7.2 # -r requirements/pip.txt # django # django-cors-headers + # django-structlog asttokens==2.4.1 # via stack-data async-timeout==4.0.3 @@ -40,6 +41,7 @@ celery==5.2.7 # via # -r requirements/pip.txt # django-celery-beat + # django-structlog certifi==2024.2.2 # via # -r requirements/pip.txt @@ -156,7 +158,7 @@ django-formtools==2.3 # via -r requirements/pip.txt django-gravatar2==1.4.4 # via -r requirements/pip.txt -django-ipware==5.0.2 +django-ipware==6.0.4 # via # -r requirements/pip.txt # django-structlog @@ -168,7 +170,7 @@ django-simple-history==3.0.0 # via -r requirements/pip.txt django-storages[boto3]==1.14.2 # via -r requirements/pip.txt -django-structlog==2.2.0 +django-structlog[celery]==7.1.0 # via -r requirements/pip.txt django-taggit==5.0.1 # via -r requirements/pip.txt @@ -332,6 +334,10 @@ python-dateutil==2.8.2 # botocore # elasticsearch-dsl # python-crontab +python-ipware==2.0.1 + # via + # -r requirements/pip.txt + # django-ipware python3-openid==3.2.0 # via # -r requirements/pip.txt @@ -395,7 +401,7 @@ stripe==4.2.0 # via # -r requirements/pip.txt # dj-stripe -structlog==23.2.0 +structlog==24.1.0 # via # -r requirements/pip.txt # django-structlog diff --git a/requirements/pip.in b/requirements/pip.in index ce480229257..ac6568735a1 100644 --- a/requirements/pip.in +++ b/requirements/pip.in @@ -161,17 +161,8 @@ django-debug-toolbar # For enabling content-security-policy django-csp -# Upgrading to 3.x requires some extra work -# https://django-structlog.readthedocs.io/en/latest/upgrade_guide.html#upgrading-to-3-0 -# NOTE: that django-structlog is in version 6.x now, -# so we should probably consider migrating to avoid incompatibility issues. -django-structlog==2.2.0 -# Pining due to a Sentry error we started getting -# https://read-the-docs.sentry.io/issues/4678167578/events/2d9d348729874d67b120b153908ca54c/ -django-ipware<6.0.0 - -# https://github.com/readthedocs/readthedocs.org/issues/10990 -structlog==23.2.0 +django-structlog[celery] +structlog dparse gunicorn diff --git a/requirements/pip.txt b/requirements/pip.txt index 4e0e79084cc..663647cb536 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -10,6 +10,7 @@ asgiref==3.7.2 # via # django # django-cors-headers + # django-structlog async-timeout==4.0.3 # via redis billiard==3.6.4.0 @@ -24,6 +25,7 @@ celery==5.2.7 # via # -r requirements/pip.in # django-celery-beat + # django-structlog certifi==2024.2.2 # via # elastic-transport @@ -108,10 +110,8 @@ django-formtools==2.3 # via -r requirements/pip.in django-gravatar2==1.4.4 # via -r requirements/pip.in -django-ipware==5.0.2 - # via - # -r requirements/pip.in - # django-structlog +django-ipware==6.0.4 + # via django-structlog django-polymorphic==3.1.0 # via -r requirements/pip.in django-safemigrate==4.2 @@ -120,7 +120,7 @@ django-simple-history==3.0.0 # via -r requirements/pip.in django-storages[boto3]==1.14.2 # via -r requirements/pip.in -django-structlog==2.2.0 +django-structlog[celery]==7.1.0 # via -r requirements/pip.in django-taggit==5.0.1 # via -r requirements/pip.in @@ -212,6 +212,8 @@ python-dateutil==2.8.2 # botocore # elasticsearch-dsl # python-crontab +python-ipware==2.0.1 + # via django-ipware python3-openid==3.2.0 # via django-allauth pytz==2024.1 @@ -264,7 +266,7 @@ stripe==4.2.0 # via # -r requirements/pip.in # dj-stripe -structlog==23.2.0 +structlog==24.1.0 # via # -r requirements/pip.in # django-structlog diff --git a/requirements/testing.txt b/requirements/testing.txt index db10d059ee3..47d6170f24f 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -15,6 +15,7 @@ asgiref==3.7.2 # -r requirements/pip.txt # django # django-cors-headers + # django-structlog async-timeout==4.0.3 # via # -r requirements/pip.txt @@ -38,6 +39,7 @@ celery==5.2.7 # via # -r requirements/pip.txt # django-celery-beat + # django-structlog certifi==2024.2.2 # via # -r requirements/pip.txt @@ -148,7 +150,7 @@ django-formtools==2.3 # via -r requirements/pip.txt django-gravatar2==1.4.4 # via -r requirements/pip.txt -django-ipware==5.0.2 +django-ipware==6.0.4 # via # -r requirements/pip.txt # django-structlog @@ -160,7 +162,7 @@ django-simple-history==3.0.0 # via -r requirements/pip.txt django-storages[boto3]==1.14.2 # via -r requirements/pip.txt -django-structlog==2.2.0 +django-structlog[celery]==7.1.0 # via -r requirements/pip.txt django-taggit==5.0.1 # via -r requirements/pip.txt @@ -314,6 +316,10 @@ python-dateutil==2.8.2 # botocore # elasticsearch-dsl # python-crontab +python-ipware==2.0.1 + # via + # -r requirements/pip.txt + # django-ipware python3-openid==3.2.0 # via # -r requirements/pip.txt @@ -395,7 +401,7 @@ stripe==4.2.0 # via # -r requirements/pip.txt # dj-stripe -structlog==23.2.0 +structlog==24.1.0 # via # -r requirements/pip.txt # django-structlog