Skip to main content

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

tests PyPI version Python versions Django versions License: MIT

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, and traceback (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_BACKENDS at any callable.
  • Output is forwarded to the original stdout / stderr and 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 (not django_tee) for backward compatibility with installations that previously had a hand-rolled tee app inlined in their project. Tables are named tee_log, admin URLs are under /admin/tee/log/. The Python import path is django_tee regardless.

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_tee-0.2.0.tar.gz (12.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_tee-0.2.0-py3-none-any.whl (17.5 kB view details)

Uploaded Python 3

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

Hashes for django_tee-0.2.0.tar.gz
Algorithm Hash digest
SHA256 e44495224910e9071f58a234830015fd908d4b4ae25bff1fd1448f83bb67386a
MD5 1ecc12042bea305c698f2e2f72c4e4dd
BLAKE2b-256 5806318c8eda7b5a627ebfb7553497e46f32c6ec2ddb3e128e621266c37f3949

See more details on using hashes here.

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

Hashes for django_tee-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 37b4d5bd3352444e629dda70f0b025d7bfb5070284c53c2f08777859c954cdf3
MD5 d8b070f82c4a16311d15ed3b1cb3e3ef
BLAKE2b-256 852b38f44e49c4f458f7ce3ff46f0a16a616ac7fbf55ab0d60d5b72d352ca433

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page