Run a Django management command and capture its stdout, stderr and
any traceback into the database — like Unix tee(1), but for cron jobs.
The output also still lands on the underlying terminal, so an
operator running the wrapper interactively keeps seeing the output
scroll by. The captured rows live in a single Django model (Log),
browseable through Django admin.
Cron jobs and systemd timers fail silently more often than anyone
likes to admit. By the time someone notices, the output is gone — the
wrapper script forgot to redirect, the log rotated, or MAILTO was
never set up.
django-tee gives you a Django-native, queryable record of what each
job emitted, when it ran, whether it crashed, and what its traceback
was — without changing the command itself. Wrap any existing
management command:
python manage.py tee my_command --any --args you --want…and the output is both printed and saved.
- Wraps any Django management command — no changes to the command itself.
- Captures
stdout,stderr, andtraceback(on exception). - Records start time, end time, and success / failure.
- Browseable & searchable through Django admin (read-only — logs are written by the wrapper, not by hand).
- Optional integration with error-reporting services — exceptions
raised by the wrapped command are forwarded to every configured
backend (currently Rollbar and
Sentry) before being recorded. Backends are
pluggable; ship your own by pointing
DJANGO_TEE_ERROR_BACKENDSat any callable. - Output is forwarded to the original
stdout/stderrand persisted, so interactive runs still feel normal.
Using uv:
uv add django-teeUsing pip:
pip install django-teeAdd to INSTALLED_APPS:
INSTALLED_APPS = [
# ...
"django_tee",
]Then run migrations:
python manage.py migrateThe Django app label is
tee(notdjango_tee) for backward compatibility with installations that previously had a hand-rolledteeapp inlined in their project. Tables are namedtee_log, admin URLs are under/admin/tee/log/. The Python import path isdjango_teeregardless.
When the wrapped command raises, django-tee can also forward the
exception to one or more error-reporting services before recording
the traceback. Two backends ship in the box — Rollbar and Sentry —
and custom callables are supported via dotted-path configuration.
pip install "django-tee[rollbar]"Once rollbar is importable and initialised in your project (see
the rollbar-pyrollbar docs),
exceptions are reported via rollbar.report_exc_info.
pip install "django-tee[sentry]"Once sentry-sdk is importable and you've called sentry_sdk.init(...)
somewhere in your startup (see the Sentry Django
docs),
exceptions are reported via sentry_sdk.capture_exception.
pip install "django-tee[all-backends]"By default, every built-in backend whose SDK is importable is used. You can pin the active set explicitly:
# settings.py
DJANGO_TEE_ERROR_BACKENDS = ["sentry"] # only Sentry
DJANGO_TEE_ERROR_BACKENDS = ["rollbar", "sentry"] # both, in order
DJANGO_TEE_ERROR_BACKENDS = [] # disabled entirelyEntries are either built-in names ("rollbar", "sentry") or
dotted paths to a callable that takes one argument — the
sys.exc_info() tuple:
# myapp/error_hooks.py
def to_slack(exc_info):
...
# settings.py
DJANGO_TEE_ERROR_BACKENDS = ["sentry", "myapp.error_hooks.to_slack"]A backend that fails (network down, misconfigured) does not
prevent other backends from running, and never masks the original
exception — its traceback is still recorded in Log.
If neither SDK is installed and the setting is unset, this is a
no-op — no try/except ImportError needed in your code.
python manage.py tee <command_name> [args...]Examples:
# Wrap a one-off:
python manage.py tee clearsessions
# Wrap your nightly batch job:
python manage.py tee send_daily_digest --batch-size=500
# In crontab:
0 4 * * * cd /srv/myapp && /srv/myapp/.venv/bin/python manage.py tee send_daily_digest >> /var/log/myapp/cron.log 2>&1Each invocation creates one Log row. Browse them at
/admin/tee/log/.
from django_tee.core import execute
execute(["manage.py", "send_daily_digest", "--batch-size=500"])execute() returns the persisted Log instance, so you can inspect
the result inline:
from django_tee.core import execute
log = execute(["manage.py", "rebuild_search_index"])
if log.finished_successfully:
print("rebuild OK")
else:
print("rebuild FAILED:", log.traceback)from django_tee.models import Log
# Latest failure of a given job:
last_fail = (
Log.objects
.filter(command_name__contains="send_daily_digest",
finished_successfully=False)
.order_by("-started_on")
.first()
)
# Long-running invocations:
from django.db.models import F
slow = Log.objects.filter(
finished_on__isnull=False,
).annotate(duration=F("finished_on") - F("started_on")).order_by("-duration")[:10]
# Anything that ran in the last hour:
from django.utils import timezone
from datetime import timedelta
recent = Log.objects.filter(
started_on__gte=timezone.now() - timedelta(hours=1),
)There's no built-in retention — Log.objects.filter(started_on__lt=...).delete()
works fine, run it from a cron job (wrapped, of course):
# myapp/management/commands/prune_tee_logs.py
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from django_tee.models import Log
class Command(BaseCommand):
help = "Delete tee log rows older than --days days (default: 30)."
def add_arguments(self, parser):
parser.add_argument("--days", type=int, default=30)
def handle(self, days, **opts):
cutoff = timezone.now() - timedelta(days=days)
deleted, _ = Log.objects.filter(started_on__lt=cutoff).delete()
self.stdout.write(f"Deleted {deleted} tee log rows.")Then:
python manage.py tee prune_tee_logs --days=90…which itself records a (small) audit trail.
Each cell marks a combination actually exercised in CI (test.yml).
| Django ↓ / Python → | 3.10 | 3.11 | 3.12 | 3.13 |
|---|---|---|---|---|
| 4.2 LTS | ✓ | ✓ | ✓ | — |
| 5.0 | ✓ | ✓ | ✓ | ✓ |
| 5.1 | ✓ | ✓ | ✓ | ✓ |
| 5.2 LTS | ✓ | ✓ | ✓ | ✓ |
| 6.0 | — | — | ✓ | ✓ |
Django 4.2 LTS reached end-of-life in April 2026 — newer projects should default to 5.2 LTS or 6.0. Python 3.13 + Django 4.2 is excluded from CI because Django 4.2 does not officially support Python 3.13. Django 6.0 requires Python 3.12+.
PostgreSQL is the primary target (the args column is JSONField
and the project has historically run only on Postgres). SQLite
should work for JSONField since Django 3.1 but is not part of
the CI matrix — file an issue if you need it.
git clone https://github.com/iplweb/django-tee
cd django-tee
uv venv
uv pip install -e ".[test,dev]"
pre-commit install
uv run pytestThe test suite spins up a real PostgreSQL container via testcontainers — you need a working Docker daemon.
MIT — see LICENSE.
Originally extracted from bpp (Bibliografia Publikacji Pracownikow), where it had been quietly recording the output of nightly import jobs since 2021.