From 3c6596de8f0bfb9fcd404de646dbab37c0278351 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Mar 2026 13:39:08 -0500 Subject: [PATCH 1/2] Closes #20916: Record a stack trace in the job log for unhandled exceptions --- netbox/core/tables/jobs.py | 8 ++++++++ netbox/netbox/jobs.py | 7 +++++++ netbox/netbox/tests/test_jobs.py | 4 ++++ 3 files changed, 19 insertions(+) diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index 2406101a1..e97fb0ac4 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -1,4 +1,6 @@ import django_tables2 as tables +from django.utils.html import conditional_escape +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from core.constants import JOB_LOG_ENTRY_LEVELS @@ -82,3 +84,9 @@ class JobLogEntryTable(BaseTable): class Meta(BaseTable.Meta): empty_text = _('No log entries') fields = ('timestamp', 'level', 'message') + + def render_message(self, record, value): + if record.get('level') == 'error' and '\n' in value: + value = conditional_escape(value) + return mark_safe(f'
{value}
') + return value diff --git a/netbox/netbox/jobs.py b/netbox/netbox/jobs.py index 30afba6fc..8942b831f 100644 --- a/netbox/netbox/jobs.py +++ b/netbox/netbox/jobs.py @@ -1,4 +1,5 @@ import logging +import traceback from abc import ABC, abstractmethod from datetime import timedelta @@ -107,6 +108,12 @@ class JobRunner(ABC): job.terminate(status=JobStatusChoices.STATUS_FAILED) except Exception as e: + tb_record = logging.makeLogRecord({ + 'levelno': logging.ERROR, + 'levelname': 'ERROR', + 'msg': traceback.format_exc(), + }) + job.log(tb_record) job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e)) if type(e) is JobTimeoutException: logger.error(e) diff --git a/netbox/netbox/tests/test_jobs.py b/netbox/netbox/tests/test_jobs.py index 085047bfc..93bae0e24 100644 --- a/netbox/netbox/tests/test_jobs.py +++ b/netbox/netbox/tests/test_jobs.py @@ -83,6 +83,10 @@ class JobRunnerTest(JobRunnerTestCase): self.assertEqual(job.status, JobStatusChoices.STATUS_ERRORED) self.assertEqual(job.error, repr(ErroredJobRunner.EXP)) + self.assertEqual(len(job.log_entries), 1) + self.assertEqual(job.log_entries[0]['level'], 'error') + self.assertIn('Traceback', job.log_entries[0]['message']) + self.assertIn('Test error', job.log_entries[0]['message']) class EnqueueTest(JobRunnerTestCase): From c40640af81b2e3d3469a589844288bc252f8c2c3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 4 Mar 2026 13:47:54 -0500 Subject: [PATCH 2/2] Omit the system filepath north of the installation root --- netbox/netbox/jobs.py | 10 +++++++++- netbox/netbox/tests/test_jobs.py | 7 +++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/jobs.py b/netbox/netbox/jobs.py index 8942b831f..e685cfee8 100644 --- a/netbox/netbox/jobs.py +++ b/netbox/netbox/jobs.py @@ -1,7 +1,9 @@ import logging +import os import traceback from abc import ABC, abstractmethod from datetime import timedelta +from pathlib import Path from django.core.exceptions import ImproperlyConfigured from django.utils import timezone @@ -22,6 +24,11 @@ __all__ = ( 'system_job', ) +# The installation root, e.g. "/opt/netbox/". Used to strip absolute path +# prefixes from traceback file paths before recording them in the job log. +# jobs.py lives at /netbox/netbox/jobs.py, so parents[2] is the root. +_INSTALL_ROOT = str(Path(__file__).resolve().parents[2]) + os.sep + def system_job(interval): """ @@ -108,10 +115,11 @@ class JobRunner(ABC): job.terminate(status=JobStatusChoices.STATUS_FAILED) except Exception as e: + tb_str = traceback.format_exc().replace(_INSTALL_ROOT, '') tb_record = logging.makeLogRecord({ 'levelno': logging.ERROR, 'levelname': 'ERROR', - 'msg': traceback.format_exc(), + 'msg': tb_str, }) job.log(tb_record) job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e)) diff --git a/netbox/netbox/tests/test_jobs.py b/netbox/netbox/tests/test_jobs.py index 93bae0e24..5bb073951 100644 --- a/netbox/netbox/tests/test_jobs.py +++ b/netbox/netbox/tests/test_jobs.py @@ -10,6 +10,7 @@ from core.models import DataSource, Job from utilities.testing import disable_warnings from ..jobs import * +from ..jobs import _INSTALL_ROOT class TestJobRunner(JobRunner): @@ -85,8 +86,10 @@ class JobRunnerTest(JobRunnerTestCase): self.assertEqual(job.error, repr(ErroredJobRunner.EXP)) self.assertEqual(len(job.log_entries), 1) self.assertEqual(job.log_entries[0]['level'], 'error') - self.assertIn('Traceback', job.log_entries[0]['message']) - self.assertIn('Test error', job.log_entries[0]['message']) + tb_message = job.log_entries[0]['message'] + self.assertIn('Traceback', tb_message) + self.assertIn('Test error', tb_message) + self.assertNotIn(_INSTALL_ROOT, tb_message) class EnqueueTest(JobRunnerTestCase):