From 2ac7e538adf37c9c807341214ef849200f5c8cc1 Mon Sep 17 00:00:00 2001 From: Payam Date: Tue, 28 Apr 2026 12:59:53 +0400 Subject: [PATCH] feat: #383 add metrics for job started/completed/failed --- django_email_learning/jobs/api/views.py | 64 +++++++++++++++---- django_email_learning/jobs/check_imap_job.py | 19 ++++-- .../deactivate_inactive_enrollments_job.py | 12 +++- .../jobs/deliver_contents_job.py | 8 +++ django_email_learning/jobs/job_metrics.py | 33 ++++++++++ .../jobs/send_reminders_job.py | 8 +++ .../commands/check_imap_connections.py | 6 ++ .../deactivate_inactive_enrollments.py | 9 +++ .../management/commands/deliver_contents.py | 7 ++ .../management/commands/send_reminders.py | 7 ++ .../ports/metric_recorder_protocol.py | 9 +++ .../defaults/log_based_metric_recorder.py | 28 ++++++++ .../services/metrics_service.py | 9 +++ .../test_views/test_check_imap_job_view.py | 24 +++++++ ...eactivate_inactive_enrollments_job_view.py | 27 ++++++++ .../test_deliver_contents_job_view.py | 24 +++++++ .../test_send_reminders_job_view.py | 24 +++++++ tests/jobs/test_check_imap_job.py | 54 +++++++++++++++- ...est_deactivate_inactive_enrollments_job.py | 43 +++++++++++-- tests/jobs/test_deliver_content_jobs.py | 47 ++++++++++++++ tests/jobs/test_job_metrics.py | 58 +++++++++++++++++ tests/jobs/test_send_reminders_job.py | 49 ++++++++++++++ 22 files changed, 541 insertions(+), 28 deletions(-) create mode 100644 django_email_learning/jobs/job_metrics.py create mode 100644 tests/jobs/test_job_metrics.py diff --git a/django_email_learning/jobs/api/views.py b/django_email_learning/jobs/api/views.py index 9248e33..43eba3e 100644 --- a/django_email_learning/jobs/api/views.py +++ b/django_email_learning/jobs/api/views.py @@ -9,36 +9,72 @@ from django.utils.decorators import method_decorator from django.http import JsonResponse +from django_email_learning.models import JobName +from django_email_learning.services.metrics_service import MetricsService + + +metric_service = MetricsService() + @method_decorator(check_api_key(), name="get") class DeliverContentsJobView(View): def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] - job = DeliverContentsJob() - job.run() - return JsonResponse({"status": "DeliverContentsJob triggered"}, status=202) + try: + job = DeliverContentsJob() + job.run() + return JsonResponse({"status": "DeliverContentsJob triggered"}, status=202) + except Exception as e: + metric_service.job_execution_failed(job_name=JobName.DELIVER_CONTENTS.value) + return JsonResponse( + {"status": "DeliverContentsJob failed", "error": str(e)}, status=500 + ) @method_decorator(check_api_key(), name="get") class CheckIMAPJobView(View): def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] - job = CheckIMAPJob() - job.run() - return JsonResponse({"status": "CheckIMAPJob triggered"}, status=202) + try: + job = CheckIMAPJob() + job.run() + return JsonResponse({"status": "CheckIMAPJob triggered"}, status=202) + except Exception as e: + metric_service.job_execution_failed(job_name=JobName.CHECK_IMAP.value) + return JsonResponse( + {"status": "CheckIMAPJob failed", "error": str(e)}, status=500 + ) @method_decorator(check_api_key(), name="get") class SendQuizRemindersJobView(View): def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] - job = SendRemindersJob() - job.run() - return JsonResponse({"status": "SendRemidersJob triggered"}, status=202) + try: + job = SendRemindersJob() + job.run() + return JsonResponse({"status": "SendRemidersJob triggered"}, status=202) + except Exception as e: + metric_service.job_execution_failed(job_name=JobName.SEND_REMINDERS.value) + return JsonResponse( + {"status": "SendRemidersJob failed", "error": str(e)}, status=500 + ) @method_decorator(check_api_key(), name="get") class DeactivateInactiveEnrollmentsJobView(View): def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] - job = DeactivateInactiveEnrollmentsJob() - job.run() - return JsonResponse( - {"status": "DeactivateInactiveEnrollmentsJob triggered"}, status=202 - ) + try: + job = DeactivateInactiveEnrollmentsJob() + job.run() + return JsonResponse( + {"status": "DeactivateInactiveEnrollmentsJob triggered"}, status=202 + ) + except Exception as e: + metric_service.job_execution_failed( + job_name=JobName.DEACTIVATE_ENROLLMENTS.value + ) + return JsonResponse( + { + "status": "DeactivateInactiveEnrollmentsJob failed", + "error": str(e), + }, + status=500, + ) diff --git a/django_email_learning/jobs/check_imap_job.py b/django_email_learning/jobs/check_imap_job.py index 3e33ef6..03bcde2 100644 --- a/django_email_learning/jobs/check_imap_job.py +++ b/django_email_learning/jobs/check_imap_job.py @@ -4,6 +4,7 @@ JobName, JobStatus, ) +from django_email_learning.jobs.job_metrics import track_job_execution from django_email_learning.services.metrics_service import MetricsService from django_email_learning.ports.imap_interface_protocol import ImapInterfaceProtocol from django.utils.module_loading import import_string @@ -15,7 +16,7 @@ import logging logger = logging.getLogger(__name__) -metricc_service = MetricsService() +metric_service = MetricsService() class CheckIMAPJob: @@ -54,6 +55,13 @@ def run(self) -> None: status=JobStatus.RUNNING.value, started_at=timezone.now(), ) + self._run_job(job_execution) + + @track_job_execution( + metric_service=metric_service, + job_name=JobName.CHECK_IMAP.value, + ) + def _run_job(self, job_execution: JobExecution) -> None: imap_connections = ImapConnection.objects.filter(course__enabled=True) for imap_connection in imap_connections: @@ -73,11 +81,12 @@ def run(self) -> None: for folder in folders: self._process_folder(account, folder, imap_connection) finally: - job_execution.status = JobStatus.COMPLETED.value - job_execution.finished_at = timezone.now() - job_execution.save() self._logout_account(account, imap_connection) + job_execution.status = JobStatus.COMPLETED.value + job_execution.finished_at = timezone.now() + job_execution.save() + def _connect_account( self, imap_connection: ImapConnection ) -> imaplib.IMAP4_SSL | None: @@ -145,7 +154,7 @@ def _process_email( f"Error processing email with ID {email_id} for connection {imap_connection.id}: {str(e)}", exc_info=True, ) - metricc_service.imap_command_handling_failed( + metric_service.imap_command_handling_failed( imap_connection_id=imap_connection.id, organization_id=imap_connection.organization.id, ) diff --git a/django_email_learning/jobs/deactivate_inactive_enrollments_job.py b/django_email_learning/jobs/deactivate_inactive_enrollments_job.py index aa8cde0..9546376 100644 --- a/django_email_learning/jobs/deactivate_inactive_enrollments_job.py +++ b/django_email_learning/jobs/deactivate_inactive_enrollments_job.py @@ -7,6 +7,7 @@ JobStatus, Quiz, ) +from django_email_learning.jobs.job_metrics import track_job_execution from django_email_learning.services.metrics_service import MetricsService from django_email_learning.services.email_sender_service import EmailSenderService from django.core.mail import EmailMultiAlternatives @@ -18,7 +19,7 @@ from django_email_learning.services.utils import mask_email logger = logging.getLogger(__name__) -metricc_service = MetricsService() +metric_service = MetricsService() class DeactivateInactiveEnrollmentsJob: @@ -37,6 +38,13 @@ def run(self) -> None: status=JobStatus.RUNNING.value, started_at=timezone.now(), ) + self._run_job(job_execution) + + @track_job_execution( + metric_service=metric_service, + job_name=JobName.DEACTIVATE_ENROLLMENTS.value, + ) + def _run_job(self, job_execution: JobExecution) -> None: deliveries = ContentDelivery.objects.filter( valid_until__lt=timezone.now(), enrollment__status=EnrollmentStatus.ACTIVE, @@ -75,7 +83,7 @@ def run(self) -> None: course_title, delivery.course_content.course.organization.name, ) - metricc_service.user_enrollment_deactivated( + metric_service.user_enrollment_deactivated( course_slug=delivery.course_content.course.slug, organization_id=delivery.course_content.course.organization.id, reason=DeactivationReason.INACTIVE.value, diff --git a/django_email_learning/jobs/deliver_contents_job.py b/django_email_learning/jobs/deliver_contents_job.py index 410f09c..9e6dc0f 100644 --- a/django_email_learning/jobs/deliver_contents_job.py +++ b/django_email_learning/jobs/deliver_contents_job.py @@ -8,6 +8,7 @@ SendQuizCommand, QuizNotFoundError, ) +from django_email_learning.jobs.job_metrics import track_job_execution from django_email_learning.services.metrics_service import MetricsService from django_email_learning.models import JobExecution, JobName, JobStatus from django.utils.module_loading import import_string @@ -39,6 +40,13 @@ def run(self) -> None: status=JobStatus.RUNNING.value, started_at=timezone.now(), ) + self._run_job(job_execution) + + @track_job_execution( + metric_service=METRIC_SERVICE, + job_name=JobName.DELIVER_CONTENTS.value, + ) + def _run_job(self, job_execution: JobExecution) -> None: should_check_next = True while should_check_next: delivery_schedule = self.delivery_queue.next_task() diff --git a/django_email_learning/jobs/job_metrics.py b/django_email_learning/jobs/job_metrics.py new file mode 100644 index 0000000..4a8022c --- /dev/null +++ b/django_email_learning/jobs/job_metrics.py @@ -0,0 +1,33 @@ +from collections.abc import Callable +from functools import wraps +from typing import ParamSpec, TypeVar + +from django.utils import timezone + + +P = ParamSpec("P") +R = TypeVar("R") + + +def track_job_execution( + *, + metric_service: object, + job_name: str, +) -> Callable[[Callable[P, R]], Callable[P, R]]: + def decorator(func: Callable[P, R]) -> Callable[P, R]: + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + start_time = timezone.now() + metric_service.job_execution_started(job_name=job_name) # type: ignore[attr-defined] + try: + return func(*args, **kwargs) + finally: + execution_time = int((timezone.now() - start_time).total_seconds()) + metric_service.job_execution_finished( # type: ignore[attr-defined] + job_name=job_name, + execution_time=execution_time, + ) + + return wrapper + + return decorator diff --git a/django_email_learning/jobs/send_reminders_job.py b/django_email_learning/jobs/send_reminders_job.py index 4460b8f..3a2bd71 100644 --- a/django_email_learning/jobs/send_reminders_job.py +++ b/django_email_learning/jobs/send_reminders_job.py @@ -1,5 +1,6 @@ from django_email_learning.ports.delivery_queue_protocol import DeliveryQueueProtocol from django_email_learning.models import ContentDelivery, DeliverySchedule +from django_email_learning.jobs.job_metrics import track_job_execution from django_email_learning.services.metrics_service import MetricsService from django_email_learning.models import JobExecution, JobName, JobStatus @@ -35,6 +36,13 @@ def run(self) -> None: status=JobStatus.RUNNING.value, started_at=timezone.now(), ) + self._run_job(job_execution) + + @track_job_execution( + metric_service=METRIC_SERVICE, + job_name=JobName.SEND_REMINDERS.value, + ) + def _run_job(self, job_execution: JobExecution) -> None: should_check_next = True while should_check_next: delivery_schedule = self.reminder_queue.next_task() diff --git a/django_email_learning/management/commands/check_imap_connections.py b/django_email_learning/management/commands/check_imap_connections.py index fe20e2c..4e14c34 100644 --- a/django_email_learning/management/commands/check_imap_connections.py +++ b/django_email_learning/management/commands/check_imap_connections.py @@ -1,9 +1,14 @@ from django.core.management.base import BaseCommand from django_email_learning.jobs.check_imap_job import CheckIMAPJob from django.core.management.base import CommandParser +from django_email_learning.services.metrics_service import MetricsService +from django_email_learning.models import JobName import logging +metric_service = MetricsService() + + class Command(BaseCommand): help = "Check the IMAP connection for all courses and execute valid email commands" @@ -45,6 +50,7 @@ def handle(self, *args, **options) -> None: # type: ignore[no-untyped-def] logger.warning("Check IMAP job interrupted by user") except Exception as e: + metric_service.job_execution_failed(job_name=JobName.CHECK_IMAP.value) self.stdout.write(self.style.ERROR(f"Check IMAP job failed: {str(e)}")) logger.error(f"Check IMAP job failed: {str(e)}", exc_info=True) raise diff --git a/django_email_learning/management/commands/deactivate_inactive_enrollments.py b/django_email_learning/management/commands/deactivate_inactive_enrollments.py index ce20ee0..417f883 100644 --- a/django_email_learning/management/commands/deactivate_inactive_enrollments.py +++ b/django_email_learning/management/commands/deactivate_inactive_enrollments.py @@ -5,6 +5,12 @@ from django.core.management.base import CommandParser import logging +from django_email_learning.models import JobName +from django_email_learning.services.metrics_service import MetricsService + + +metric_service = MetricsService() + class Command(BaseCommand): help = "Run the deactivate inactive enrollments job to deactivate enrollments that have missed quiz deadlines" @@ -53,6 +59,9 @@ def handle(self, *args, **options) -> None: # type: ignore[no-untyped-def] logger.warning("DeactivateInactiveEnrollmentsJob interrupted by user") except Exception as e: + metric_service.job_execution_failed( + job_name=JobName.DEACTIVATE_ENROLLMENTS.value + ) self.stdout.write( self.style.ERROR( f"Deactivate inactive enrollments job failed: {str(e)}" diff --git a/django_email_learning/management/commands/deliver_contents.py b/django_email_learning/management/commands/deliver_contents.py index d0263ba..1bf96c5 100644 --- a/django_email_learning/management/commands/deliver_contents.py +++ b/django_email_learning/management/commands/deliver_contents.py @@ -3,6 +3,12 @@ from django.core.management.base import CommandParser import logging +from django_email_learning.models import JobName +from django_email_learning.services.metrics_service import MetricsService + + +metric_service = MetricsService() + class Command(BaseCommand): help = "Run the content delivery job to process scheduled content deliveries" @@ -47,6 +53,7 @@ def handle(self, *args, **options) -> None: # type: ignore[no-untyped-def] logger.warning("DeliverContentsJob interrupted by user") except Exception as e: + metric_service.job_execution_failed(job_name=JobName.DELIVER_CONTENTS.value) self.stdout.write( self.style.ERROR(f"Content delivery job failed: {str(e)}") ) diff --git a/django_email_learning/management/commands/send_reminders.py b/django_email_learning/management/commands/send_reminders.py index a07c78a..ea435f4 100644 --- a/django_email_learning/management/commands/send_reminders.py +++ b/django_email_learning/management/commands/send_reminders.py @@ -3,6 +3,12 @@ from django.core.management.base import CommandParser import logging +from django_email_learning.models import JobName +from django_email_learning.services.metrics_service import MetricsService + + +metric_service = MetricsService() + class Command(BaseCommand): help = "Run the send reminders job to process scheduled reminders" @@ -47,6 +53,7 @@ def handle(self, *args, **options) -> None: # type: ignore[no-untyped-def] logger.warning("SendRemindersJob interrupted by user") except Exception as e: + metric_service.job_execution_failed(job_name=JobName.SEND_REMINDERS.value) self.stdout.write(self.style.ERROR(f"Send reminders job failed: {str(e)}")) logger.error(f"SendRemindersJob failed: {str(e)}", exc_info=True) raise diff --git a/django_email_learning/ports/metric_recorder_protocol.py b/django_email_learning/ports/metric_recorder_protocol.py index 438098d..a51b6c1 100644 --- a/django_email_learning/ports/metric_recorder_protocol.py +++ b/django_email_learning/ports/metric_recorder_protocol.py @@ -53,3 +53,12 @@ def imap_command_handling_failed( self, imap_connection_id: int, organization_id: int ) -> None: ... + + def job_execution_started(self, job_name: str) -> None: + ... + + def job_execution_finished(self, job_name: str, execution_time: int) -> None: + ... + + def job_execution_failed(self, job_name: str) -> None: + ... diff --git a/django_email_learning/services/defaults/log_based_metric_recorder.py b/django_email_learning/services/defaults/log_based_metric_recorder.py index 52d84c3..21c7ee2 100644 --- a/django_email_learning/services/defaults/log_based_metric_recorder.py +++ b/django_email_learning/services/defaults/log_based_metric_recorder.py @@ -145,3 +145,31 @@ def imap_command_handling_failed( "organization_id": organization_id, }, ) + + def job_execution_started(self, job_name: str) -> None: + logger.info( + "Job execution started", + extra={ + "metric": "job_execution_started", + "job_name": job_name, + }, + ) + + def job_execution_finished(self, job_name: str, execution_time: int) -> None: + logger.info( + "Job execution finished", + extra={ + "metric": "job_execution_finished", + "job_name": job_name, + "execution_time": execution_time, + }, + ) + + def job_execution_failed(self, job_name: str) -> None: + logger.error( + "Job execution failed", + extra={ + "metric": "job_execution_failed", + "job_name": job_name, + }, + ) diff --git a/django_email_learning/services/metrics_service.py b/django_email_learning/services/metrics_service.py index 79b99f4..958b870 100644 --- a/django_email_learning/services/metrics_service.py +++ b/django_email_learning/services/metrics_service.py @@ -79,3 +79,12 @@ def imap_command_handling_failed( self.metric_recorder.imap_command_handling_failed( imap_connection_id, organization_id ) + + def job_execution_started(self, job_name: str) -> None: + self.metric_recorder.job_execution_started(job_name) + + def job_execution_finished(self, job_name: str, execution_time: int) -> None: + self.metric_recorder.job_execution_finished(job_name, execution_time) + + def job_execution_failed(self, job_name: str) -> None: + self.metric_recorder.job_execution_failed(job_name) diff --git a/tests/jobs/api/test_views/test_check_imap_job_view.py b/tests/jobs/api/test_views/test_check_imap_job_view.py index ecdd1bc..02e86c0 100644 --- a/tests/jobs/api/test_views/test_check_imap_job_view.py +++ b/tests/jobs/api/test_views/test_check_imap_job_view.py @@ -59,3 +59,27 @@ def test_check_imap_with_valid_api_key(mock_run, superadmin_client): assert response.status_code == 202 assert response.json() == {"status": "CheckIMAPJob triggered"} assert mock_run.called + + +@mock.patch( + "django_email_learning.jobs.api.views.metric_service.job_execution_failed", +) +@mock.patch( + "django_email_learning.jobs.check_imap_job.CheckIMAPJob.run", + side_effect=Exception("boom"), +) +def test_check_imap_failed_triggers_job_execution_failed_metric( + mock_run, mock_job_execution_failed, superadmin_client +): + create_key_response = superadmin_client.post( + reverse("django_email_learning:api_platform:api_key_view") + ) + response = superadmin_client.get( + URL, + HTTP_AUTHORIZATION=f"Bearer {create_key_response.json()['key']}", + ) + + assert response.status_code == 500 + assert response.json() == {"status": "CheckIMAPJob failed", "error": "boom"} + mock_run.assert_called_once() + mock_job_execution_failed.assert_called_once_with(job_name="check_imap") diff --git a/tests/jobs/api/test_views/test_deactivate_inactive_enrollments_job_view.py b/tests/jobs/api/test_views/test_deactivate_inactive_enrollments_job_view.py index be14514..ebe95e0 100644 --- a/tests/jobs/api/test_views/test_deactivate_inactive_enrollments_job_view.py +++ b/tests/jobs/api/test_views/test_deactivate_inactive_enrollments_job_view.py @@ -69,3 +69,30 @@ def test_deactivate_inactive_enrollments_with_valid_api_key( assert response.status_code == 202 assert response.json() == {"status": "DeactivateInactiveEnrollmentsJob triggered"} assert mock_run.called + + +@mock.patch( + "django_email_learning.jobs.api.views.metric_service.job_execution_failed", +) +@mock.patch( + "django_email_learning.jobs.deactivate_inactive_enrollments_job.DeactivateInactiveEnrollmentsJob.run", + side_effect=Exception("boom"), +) +def test_deactivate_inactive_enrollments_failed_triggers_job_execution_failed_metric( + mock_run, mock_job_execution_failed, superadmin_client +): + create_key_response = superadmin_client.post( + reverse("django_email_learning:api_platform:api_key_view") + ) + response = superadmin_client.get( + URL, + HTTP_AUTHORIZATION=f"Bearer {create_key_response.json()['key']}", + ) + + assert response.status_code == 500 + assert response.json() == { + "status": "DeactivateInactiveEnrollmentsJob failed", + "error": "boom", + } + mock_run.assert_called_once() + mock_job_execution_failed.assert_called_once_with(job_name="deactivate_enrollments") diff --git a/tests/jobs/api/test_views/test_deliver_contents_job_view.py b/tests/jobs/api/test_views/test_deliver_contents_job_view.py index cddc14e..72f6258 100644 --- a/tests/jobs/api/test_views/test_deliver_contents_job_view.py +++ b/tests/jobs/api/test_views/test_deliver_contents_job_view.py @@ -59,3 +59,27 @@ def test_deliver_content_with_valid_api_key(mock_run, superadmin_client): assert response.status_code == 202 assert response.json() == {"status": "DeliverContentsJob triggered"} assert mock_run.called + + +@mock.patch( + "django_email_learning.jobs.api.views.metric_service.job_execution_failed", +) +@mock.patch( + "django_email_learning.jobs.deliver_contents_job.DeliverContentsJob.run", + side_effect=Exception("boom"), +) +def test_deliver_content_failed_triggers_job_execution_failed_metric( + mock_run, mock_job_execution_failed, superadmin_client +): + create_key_response = superadmin_client.post( + reverse("django_email_learning:api_platform:api_key_view") + ) + response = superadmin_client.get( + URL, + HTTP_AUTHORIZATION=f"Bearer {create_key_response.json()['key']}", + ) + + assert response.status_code == 500 + assert response.json() == {"status": "DeliverContentsJob failed", "error": "boom"} + mock_run.assert_called_once() + mock_job_execution_failed.assert_called_once_with(job_name="deliver_contents") diff --git a/tests/jobs/api/test_views/test_send_reminders_job_view.py b/tests/jobs/api/test_views/test_send_reminders_job_view.py index 4054de8..700c622 100644 --- a/tests/jobs/api/test_views/test_send_reminders_job_view.py +++ b/tests/jobs/api/test_views/test_send_reminders_job_view.py @@ -59,3 +59,27 @@ def test_send_reminders_with_valid_api_key(mock_run, superadmin_client): assert response.status_code == 202 assert response.json() == {"status": "SendRemidersJob triggered"} assert mock_run.called + + +@mock.patch( + "django_email_learning.jobs.api.views.metric_service.job_execution_failed", +) +@mock.patch( + "django_email_learning.jobs.send_reminders_job.SendRemindersJob.run", + side_effect=Exception("boom"), +) +def test_send_reminders_failed_triggers_job_execution_failed_metric( + mock_run, mock_job_execution_failed, superadmin_client +): + create_key_response = superadmin_client.post( + reverse("django_email_learning:api_platform:api_key_view") + ) + response = superadmin_client.get( + URL, + HTTP_AUTHORIZATION=f"Bearer {create_key_response.json()['key']}", + ) + + assert response.status_code == 500 + assert response.json() == {"status": "SendRemidersJob failed", "error": "boom"} + mock_run.assert_called_once() + mock_job_execution_failed.assert_called_once_with(job_name="send_reminders") diff --git a/tests/jobs/test_check_imap_job.py b/tests/jobs/test_check_imap_job.py index b98e04f..b440ad5 100644 --- a/tests/jobs/test_check_imap_job.py +++ b/tests/jobs/test_check_imap_job.py @@ -3,7 +3,7 @@ import django_email_learning.jobs.check_imap_job as check_imap_job_module from django_email_learning.jobs.check_imap_job import CheckIMAPJob -from django_email_learning.models import InboxFolder +from django_email_learning.models import InboxFolder, JobExecution, JobName, JobStatus def test_check_imap_job_processes_unseen_emails(db, course, imap_connection): @@ -98,7 +98,7 @@ def test_check_imap_job_tracks_metric_when_processing_fails( "django_email_learning.jobs.check_imap_job.imaplib.IMAP4_SSL", return_value=account_mock, ), patch.object( - check_imap_job_module.metricc_service, + check_imap_job_module.metric_service, "imap_command_handling_failed", ) as metric_spy: job = CheckIMAPJob() @@ -110,3 +110,53 @@ def test_check_imap_job_tracks_metric_when_processing_fails( ) account_mock.store.assert_not_called() account_mock.logout.assert_called_once() + + +def test_check_imap_job_triggers_started_metric(db): + with patch( + "django_email_learning.jobs.check_imap_job.imaplib.IMAP4_SSL", + side_effect=Exception("no connection"), + ), patch.object( + check_imap_job_module.metric_service, + "job_execution_started", + ) as metric_started_spy: + job = CheckIMAPJob() + job.run() + + metric_started_spy.assert_called_once_with(job_name="check_imap") + + +def test_check_imap_job_triggers_finished_metric(db): + with patch( + "django_email_learning.jobs.check_imap_job.imaplib.IMAP4_SSL", + side_effect=Exception("no connection"), + ), patch.object( + check_imap_job_module.metric_service, + "job_execution_finished", + ) as metric_finished_spy: + job = CheckIMAPJob() + job.run() + + metric_finished_spy.assert_called_once() + call_kwargs = metric_finished_spy.call_args + assert call_kwargs.kwargs["job_name"] == "check_imap" + assert isinstance(call_kwargs.kwargs["execution_time"], int) + + +def test_check_imap_job_does_not_emit_start_or_finish_metrics_when_already_running(db): + JobExecution.objects.create( + job_name=JobName.CHECK_IMAP.value, + status=JobStatus.RUNNING.value, + ) + + with patch.object( + check_imap_job_module.metric_service, + "job_execution_started", + ) as metric_started_spy, patch.object( + check_imap_job_module.metric_service, + "job_execution_finished", + ) as metric_finished_spy: + CheckIMAPJob().run() + + metric_started_spy.assert_not_called() + metric_finished_spy.assert_not_called() diff --git a/tests/jobs/test_deactivate_inactive_enrollments_job.py b/tests/jobs/test_deactivate_inactive_enrollments_job.py index aab14a3..69c7e3c 100644 --- a/tests/jobs/test_deactivate_inactive_enrollments_job.py +++ b/tests/jobs/test_deactivate_inactive_enrollments_job.py @@ -25,7 +25,15 @@ def test_deactivate_inactive_enrollments_job_exits_when_already_running(db): status=JobStatus.RUNNING.value, ) - with patch.object(deactivate_job_module.logger, "warning") as warning_spy: + with patch.object( + deactivate_job_module.logger, "warning" + ) as warning_spy, patch.object( + deactivate_job_module.metric_service, + "job_execution_started", + ) as metric_started_spy, patch.object( + deactivate_job_module.metric_service, + "job_execution_finished", + ) as metric_finished_spy: DeactivateInactiveEnrollmentsJob().run() assert ( @@ -36,6 +44,8 @@ def test_deactivate_inactive_enrollments_job_exits_when_already_running(db): == 1 ) warning_spy.assert_called_once() + metric_started_spy.assert_not_called() + metric_finished_spy.assert_not_called() def test_deactivate_inactive_enrollments_job_deactivates_expired_quiz_delivery( @@ -55,7 +65,7 @@ def test_deactivate_inactive_enrollments_job_deactivates_expired_quiz_delivery( DeactivateInactiveEnrollmentsJob, "send_deactivation_email", ) as send_email_spy, patch.object( - deactivate_job_module.metricc_service, + deactivate_job_module.metric_service, "user_enrollment_deactivated", ) as user_metric_spy: DeactivateInactiveEnrollmentsJob().run() @@ -100,7 +110,7 @@ def test_deactivate_inactive_enrollments_job_skips_non_quiz_deliveries( DeactivateInactiveEnrollmentsJob, "send_deactivation_email", ) as send_email_spy, patch.object( - deactivate_job_module.metricc_service, + deactivate_job_module.metric_service, "user_enrollment_deactivated", ) as user_metric_spy: DeactivateInactiveEnrollmentsJob().run() @@ -129,7 +139,7 @@ def test_deactivate_inactive_enrollments_job_does_not_deactivate_when_deadline_n DeactivateInactiveEnrollmentsJob, "send_deactivation_email", ) as send_email_spy, patch.object( - deactivate_job_module.metricc_service, + deactivate_job_module.metric_service, "user_enrollment_deactivated", ) as user_metric_spy: DeactivateInactiveEnrollmentsJob().run() @@ -176,7 +186,7 @@ def test_deactivate_inactive_enrollments_job_non_blocking_quiz_should_not_deacti DeactivateInactiveEnrollmentsJob, "send_deactivation_email", ) as send_email_spy, patch.object( - deactivate_job_module.metricc_service, + deactivate_job_module.metric_service, "user_enrollment_deactivated", ) as user_metric_spy: DeactivateInactiveEnrollmentsJob().run() @@ -190,3 +200,26 @@ def test_deactivate_inactive_enrollments_job_non_blocking_quiz_should_not_deacti ).exists() send_email_spy.assert_not_called() user_metric_spy.assert_not_called() + + +def test_deactivate_inactive_enrollments_job_triggers_started_metric(db): + with patch.object( + deactivate_job_module.metric_service, + "job_execution_started", + ) as metric_started_spy: + DeactivateInactiveEnrollmentsJob().run() + + metric_started_spy.assert_called_once_with(job_name="deactivate_enrollments") + + +def test_deactivate_inactive_enrollments_job_triggers_finished_metric(db): + with patch.object( + deactivate_job_module.metric_service, + "job_execution_finished", + ) as metric_finished_spy: + DeactivateInactiveEnrollmentsJob().run() + + metric_finished_spy.assert_called_once() + call_kwargs = metric_finished_spy.call_args + assert call_kwargs.kwargs["job_name"] == "deactivate_enrollments" + assert isinstance(call_kwargs.kwargs["execution_time"], int) diff --git a/tests/jobs/test_deliver_content_jobs.py b/tests/jobs/test_deliver_content_jobs.py index b5deb01..0fe8a57 100644 --- a/tests/jobs/test_deliver_content_jobs.py +++ b/tests/jobs/test_deliver_content_jobs.py @@ -8,6 +8,9 @@ EnrollmentStatus, Enrollment, DeliveryStatus, + JobExecution, + JobName, + JobStatus, ) from tests.jobs.delivery_queue_mock import DeliveryQueueMock from unittest.mock import patch @@ -217,3 +220,47 @@ def test_unhandled_exception_during_delivery_processing( # After running the job, the delivery schedule should be in BLOCKED status due to unhandled exception delivery_schedule.refresh_from_db() assert delivery_schedule.status == DeliveryStatus.BLOCKED + + +def test_deliver_contents_job_triggers_started_metric(db, delivery_queue_mock): + with patch.object( + deliver_contents_job_module.METRIC_SERVICE, + "job_execution_started", + ) as metric_started_spy: + DeliverContentsJob().run() + + metric_started_spy.assert_called_once_with(job_name="deliver_contents") + + +def test_deliver_contents_job_triggers_finished_metric(db, delivery_queue_mock): + with patch.object( + deliver_contents_job_module.METRIC_SERVICE, + "job_execution_finished", + ) as metric_finished_spy: + DeliverContentsJob().run() + + metric_finished_spy.assert_called_once() + call_kwargs = metric_finished_spy.call_args + assert call_kwargs.kwargs["job_name"] == "deliver_contents" + assert isinstance(call_kwargs.kwargs["execution_time"], int) + + +def test_deliver_contents_job_does_not_emit_start_or_finish_metrics_when_already_running( + db, delivery_queue_mock +): + JobExecution.objects.create( + job_name=JobName.DELIVER_CONTENTS.value, + status=JobStatus.RUNNING.value, + ) + + with patch.object( + deliver_contents_job_module.METRIC_SERVICE, + "job_execution_started", + ) as metric_started_spy, patch.object( + deliver_contents_job_module.METRIC_SERVICE, + "job_execution_finished", + ) as metric_finished_spy: + DeliverContentsJob().run() + + metric_started_spy.assert_not_called() + metric_finished_spy.assert_not_called() diff --git a/tests/jobs/test_job_metrics.py b/tests/jobs/test_job_metrics.py new file mode 100644 index 0000000..5539585 --- /dev/null +++ b/tests/jobs/test_job_metrics.py @@ -0,0 +1,58 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from django_email_learning.jobs.job_metrics import track_job_execution + + +class DummyMetrics: + def __init__(self) -> None: + self.job_execution_started = MagicMock() + self.job_execution_finished = MagicMock() + + +def test_track_job_execution_emits_started_and_finished_on_success(): + metrics = DummyMetrics() + start_time = datetime(2026, 4, 28, 12, 0, 0, tzinfo=timezone.utc) + end_time = start_time + timedelta(seconds=4) + + @track_job_execution(metric_service=metrics, job_name="test_job") + def decorated_function() -> str: + return "ok" + + with patch( + "django_email_learning.jobs.job_metrics.timezone.now", + side_effect=[start_time, end_time], + ): + result = decorated_function() + + assert result == "ok" + metrics.job_execution_started.assert_called_once_with(job_name="test_job") + metrics.job_execution_finished.assert_called_once_with( + job_name="test_job", + execution_time=4, + ) + + +def test_track_job_execution_emits_finished_when_wrapped_function_raises(): + metrics = DummyMetrics() + start_time = datetime(2026, 4, 28, 12, 0, 0, tzinfo=timezone.utc) + end_time = start_time + timedelta(seconds=5) + + @track_job_execution(metric_service=metrics, job_name="test_job") + def decorated_function() -> None: + raise RuntimeError("boom") + + with patch( + "django_email_learning.jobs.job_metrics.timezone.now", + side_effect=[start_time, end_time], + ): + with pytest.raises(RuntimeError, match="boom"): + decorated_function() + + metrics.job_execution_started.assert_called_once_with(job_name="test_job") + metrics.job_execution_finished.assert_called_once_with( + job_name="test_job", + execution_time=5, + ) diff --git a/tests/jobs/test_send_reminders_job.py b/tests/jobs/test_send_reminders_job.py index d2dcc22..a42ee98 100644 --- a/tests/jobs/test_send_reminders_job.py +++ b/tests/jobs/test_send_reminders_job.py @@ -11,6 +11,9 @@ ContentDelivery, DeliverySchedule, EnrollmentStatus, + JobExecution, + JobName, + JobStatus, ) from tests.jobs.delivery_queue_mock import DeliveryQueueMock @@ -82,6 +85,31 @@ def test_send_reminders_job_marks_not_applicable_when_quiz_not_found( assert delivery.reminder_state == ContentDelivery.ReminderStatus.NOT_APPLICABLE +def test_send_reminders_job_triggers_started_metric(db, reminder_queue_mock): + with patch.object( + send_reminders_job_module.METRIC_SERVICE, + "job_execution_started", + ) as metric_started_spy: + job = SendRemindersJob() + job.run() + + metric_started_spy.assert_called_once_with(job_name="send_reminders") + + +def test_send_reminders_job_triggers_finished_metric(db, reminder_queue_mock): + with patch.object( + send_reminders_job_module.METRIC_SERVICE, + "job_execution_finished", + ) as metric_finished_spy: + job = SendRemindersJob() + job.run() + + metric_finished_spy.assert_called_once() + call_kwargs = metric_finished_spy.call_args + assert call_kwargs.kwargs["job_name"] == "send_reminders" + assert isinstance(call_kwargs.kwargs["execution_time"], int) + + def test_send_reminders_job_blocks_on_unexpected_exception_and_tracks_metric( db, reminder_queue_mock, enrollment, course_quiz_content ): @@ -113,3 +141,24 @@ def test_send_reminders_job_blocks_on_unexpected_exception_and_tracks_metric( metric_blocked_spy.assert_called_once_with( delivery_schedule.delivery.course_content.id ) + + +def test_send_reminders_job_does_not_emit_start_or_finish_metrics_when_already_running( + db, reminder_queue_mock +): + JobExecution.objects.create( + job_name=JobName.SEND_REMINDERS.value, + status=JobStatus.RUNNING.value, + ) + + with patch.object( + send_reminders_job_module.METRIC_SERVICE, + "job_execution_started", + ) as metric_started_spy, patch.object( + send_reminders_job_module.METRIC_SERVICE, + "job_execution_finished", + ) as metric_finished_spy: + SendRemindersJob().run() + + metric_started_spy.assert_not_called() + metric_finished_spy.assert_not_called()