Run a Django management command and capture its stdout/stderr/traceback into the database — like Unix tee(1), but for cron jobs.
Project description
django-tee
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.
Why
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.
Features
- 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.
Installation
Using uv:
uv add django-tee
Using pip:
pip install django-tee
Add to INSTALLED_APPS:
INSTALLED_APPS = [
# ...
"django_tee",
]
Then run migrations:
python manage.py migrate
The 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.
Optional: error-reporting backends
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.
Rollbar
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.
Sentry
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.
Both at once
pip install "django-tee[all-backends]"
Configuration
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 entirely
Entries 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.
Usage
As a CLI wrapper
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>&1
Each invocation creates one Log row. Browse them at
/admin/tee/log/.
Programmatically
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)
Querying logs
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),
)
Pruning old logs
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.
Compatibility
Python × Django matrix
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 | ✓ | ✓ | ✓ | ✓ |
Django 4.2 LTS reaches end-of-life in April 2026 — newer projects should default to 5.2 LTS. Python 3.13 + Django 4.2 is excluded from CI because Django 4.2 does not officially support Python 3.13.
Database backend
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.
Development
git clone https://github.com/iplweb/django-tee
cd django-tee
uv venv
uv pip install -e ".[test,dev]"
pre-commit install
uv run pytest
The test suite spins up a real PostgreSQL container via testcontainers — you need a working Docker daemon.
License
MIT — see LICENSE.
Acknowledgements
Originally extracted from bpp (Bibliografia Publikacji Pracownikow), where it had been quietly recording the output of nightly import jobs since 2021.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_tee-0.2.0.tar.gz.
File metadata
- Download URL: django_tee-0.2.0.tar.gz
- Upload date:
- Size: 12.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e44495224910e9071f58a234830015fd908d4b4ae25bff1fd1448f83bb67386a
|
|
| MD5 |
1ecc12042bea305c698f2e2f72c4e4dd
|
|
| BLAKE2b-256 |
5806318c8eda7b5a627ebfb7553497e46f32c6ec2ddb3e128e621266c37f3949
|
File details
Details for the file django_tee-0.2.0-py3-none-any.whl.
File metadata
- Download URL: django_tee-0.2.0-py3-none-any.whl
- Upload date:
- Size: 17.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
37b4d5bd3352444e629dda70f0b025d7bfb5070284c53c2f08777859c954cdf3
|
|
| MD5 |
d8b070f82c4a16311d15ed3b1cb3e3ef
|
|
| BLAKE2b-256 |
852b38f44e49c4f458f7ce3ff46f0a16a616ac7fbf55ab0d60d5b72d352ca433
|