Email, logging, and observability primitives for Django projects.
harry is a set of independent modules that share one package. Each module is adoptable on its own — installing the package costs nothing until you wire a module into your settings, and no module requires another. The goal is that every project that installs harry sends email, logs, and answers "is it up?" the same way.
| Module | What it does | Needs |
|---|---|---|
harry.email |
Transactional email via anymail, persisted to your database with delivery tracking | the harry[email] extra + a transactional ESP |
harry.logconfig |
Structured logging config: readable in development, JSON in production | nothing (stdlib only) |
harry.middleware |
One structured access log line per request | nothing |
harry.views.health |
DB-checking healthcheck endpoint | nothing |
harry.observability |
OpenTelemetry tracing + log↔trace correlation (optional) | the harry[otel] extra |
harry is not on PyPI; install it from git. In the consuming project's
pyproject.toml:
dependencies = [
"harry @ git+https://github.com/hkhanna/django-harry",
]Each module's third-party dependencies ship as an extra, so a project only
installs what it wires up: Email needs the email extra,
Tracing the otel extra. Name what you use in the requirement:
dependencies = [
"harry[email,otel] @ git+https://github.com/hkhanna/django-harry",
]Transactional email through django-anymail, with every
message persisted as an EmailMessage record in your database and delivery
tracked via ESP webhooks.
Requires: the harry[email] extra (see Installation) and
a transactional ESP account. Nothing else from harry.
INSTALLED_APPS = [
# ...
"anymail",
"harry.email",
]harry uses anymail to send email through any transactional ESP. Configure your
provider in settings.py:
# Example using Mailgun
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"
ANYMAIL = {
"MAILGUN_API_KEY": env("MAILGUN_API_KEY"),
"MAILGUN_WEBHOOK_SIGNING_KEY": env("MAILGUN_WEBHOOK_SIGNING_KEY")
}See the anymail docs for the full list of supported providers and their settings.
harry pulls sender defaults and template context from a SITE_CONFIG dict in
settings. Values here are used as fallbacks when not provided per-message.
SITE_CONFIG = {
"name": "My App",
"default_from_name": "My App",
"default_from_email": "hello@myapp.com",
"company": "My App Inc.",
"company_address": "123 Main St",
"company_city_state_zip": "New York, NY 10001",
"contact_email": "support@myapp.com",
"logo_url": "https://myapp.com/logo.png",
"logo_url_link": "https://myapp.com",
}
MAX_SUBJECT_LENGTH = 78 # Subjects are truncated to this lengthEach email needs a template prefix that maps to template files in your Django template directories:
templates/
account/
welcome_subject.txt # Subject line (rendered as a template)
welcome_message.txt # Plain text body (required)
welcome_message.html # HTML body (optional)
Templates receive SITE_CONFIG values as context by default, plus any custom
context you pass. For example, welcome_message.txt:
Hi {{ to_name }},
Welcome to {{ site_name }}!
Thanks,
{{ company }}
{{ company_address }}
{{ company_city_state_zip }}
python manage.py migrateharry stores webhook events from your ESP so you can track whether emails were
delivered, opened, bounced, or marked as spam. Add anymail's webhook URLs to
your root urls.py:
from django.urls import include, path
urlpatterns = [
# ...
path("anymail/", include("anymail.urls")),
]Then configure a webhook secret in settings:
ANYMAIL = {
"MAILGUN_API_KEY": env("MAILGUN_API_KEY"),
"MAILGUN_WEBHOOK_SIGNING_KEY": env("MAILGUN_WEBHOOK_SIGNING_KEY")
"WEBHOOK_SECRET": env("ANYMAIL_WEBHOOK_SECRET"),
}Generate a webhook secret:
python -c "from django.utils.crypto import get_random_string; print(':'.join(get_random_string(16) for _ in range(2)))"Register the webhook URL with your ESP, using the secret as HTTP basic auth credentials:
https://<part1>:<part2>@yourdomain.com/anymail/mailgun/tracking/
The primary API is a set of service functions.
from harry.email.services import (
email_message_create,
email_message_queue,
)
email = email_message_create(
created_by=user,
to_name="Alice",
to_email="alice@example.com",
template_prefix="account/welcome",
template_context={"coupon_code": "HELLO10"},
)
email_message_queue(email_message=email)email_message_queue prepares the message (applies defaults, renders the
subject, validates) and sends it. It returns True if the email was sent, or
False if it was suppressed by cooldown (see below).
The subject is rendered from {template_prefix}_subject.txt by default. You
can also set it explicitly:
email = email_message_create(
to_name="Alice",
to_email="alice@example.com",
template_prefix="account/welcome",
subject="Welcome aboard!",
)email = email_message_create(
to_name="Alice",
to_email="alice@example.com",
template_prefix="account/welcome",
sender_name="Support Team",
sender_email="support@myapp.com",
reply_to_name="Support",
reply_to_email="support@myapp.com",
)If sender_name and sender_email are omitted, they fall back to
SITE_CONFIG["default_from_name"] and SITE_CONFIG["default_from_email"].
Attach files after preparing the message:
from harry.email.services import (
email_message_create,
email_message_prepare,
email_message_attach,
email_message_queue,
)
email = email_message_create(
to_name="Alice",
to_email="alice@example.com",
template_prefix="billing/invoice",
)
email_message_prepare(email_message=email)
# From a file object
with open("invoice.pdf", "rb") as f:
email_message_attach(
email_message=email,
file=f,
filename="invoice.pdf",
mimetype="application/pdf",
)
# From bytes
email_message_attach(
email_message=email,
file=b"Name,Amount\nAlice,100",
filename="data.csv",
mimetype="text/csv",
)
email_message_queue(email_message=email)Note: email_message_attach requires the message to be in READY status. Call
email_message_prepare first, then attach files, then call
email_message_queue (which skips re-preparing an already-READY message).
email_message_queue has built-in duplicate suppression. By default, it
prevents sending the same template to the same recipient by the same user more
than once within 180 seconds.
# Defaults: 180s cooldown, 1 allowed, scoped to user + template + recipient
email_message_queue(email_message=email)
# Custom cooldown: allow 3 of the same template to the same recipient in 60s
email_message_queue(
email_message=email,
cooldown_period=60,
cooldown_allowed=3,
scopes=["template_prefix", "to"],
)
# No cooldown scoping: suppress if ANY email was sent in the last 60s
email_message_queue(
email_message=email,
cooldown_period=60,
cooldown_allowed=1,
scopes=[],
)Available scopes: "created_by", "template_prefix", "to". Suppressed
emails are saved with status CANCELED.
from harry.email.services import email_message_duplicate
duplicate = email_message_duplicate(original=email)
# duplicate is a new EmailMessage in READY status with all attachments copied
email_message_queue(email_message=duplicate)All emails are persisted as Django model instances:
from harry.email.models import EmailMessage
# All emails to a recipient
EmailMessage.objects.filter(to_email="alice@example.com")
# Failed emails
EmailMessage.objects.filter(status="error")
# Bounced emails in the last 24 hours
from django.utils import timezone
from datetime import timedelta
EmailMessage.objects.filter(
status="bounced",
sent_at__gte=timezone.now() - timedelta(hours=24),
)Every EmailMessage moves through these statuses:
CANCELED
▲
│
NEW ──> READY ──┼──> PENDING ──> ACCEPTED ──> DELIVERED
│ │ │
▼ ▼ ├──> OPENED ──> CLICKED
ERROR ERROR ├──> BOUNCED
├──> REJECTED
├──> COMPLAINED
└──> UNSUBSCRIBED
Unrecognized ESP event types are stored as UNKNOWN.
| Status | Meaning |
|---|---|
NEW |
Created, not yet prepared |
READY |
Prepared with defaults applied, validated, ready to send |
PENDING |
Handed off to the email backend |
ACCEPTED |
Backend accepted the message |
DELIVERED |
ESP confirmed delivery to recipient's mail server |
OPENED |
Recipient opened the email (if tracking is enabled) |
CLICKED |
Recipient clicked a link in the email (if tracking is enabled) |
BOUNCED |
Delivery failed (hard or soft bounce) |
REJECTED |
ESP rejected the message before delivery |
COMPLAINED |
Recipient marked the email as spam |
UNSUBSCRIBED |
Recipient unsubscribed via email headers |
UNKNOWN |
ESP reported an unrecognized event type |
CANCELED |
Suppressed by cooldown |
ERROR |
Something went wrong during preparation or sending |
A reusable logging configuration so every project that installs harry logs the same way: human-readable console output in development, structured JSON in production.
Requires: nothing. It has no extra dependencies — it builds a standard
Django LOGGING
dictionary from stdlib logging.
In your project's settings.py:
from harry.logconfig import build_logging_config
LOGGING = build_logging_config()That's it. By default the configuration:
- logs human-readable, colorless console output in development and structured JSON in production;
- writes to stdout (12-factor) — no file handlers, so the platform (systemd, Docker, your log collector) owns persistence and rotation;
- keeps
disable_existing_loggersoff so Django's and third-party loggers keep working; - configures the
django,django.request,django.security, andharryloggers plus the root logger.
build_logging_config() resolves each setting from its keyword argument, then an environment
variable, then a per-environment default:
| Argument | Env var | Values | Default |
|---|---|---|---|
env |
DJANGO_ENV |
local / test / prod |
local |
level |
DJANGO_LOG_LEVEL |
any logging level | DEBUG (local), WARNING (test), INFO (prod) |
fmt |
DJANGO_LOG_FORMAT |
console / json |
console (local/test), json (prod) |
# Add or override loggers for your project without losing the defaults:
LOGGING = build_logging_config(
extra_loggers={"myapp": {"level": "INFO", "handlers": ["console"], "propagate": False}},
)The merge is shallow: an entry in extra_loggers replaces a built-in logger wholesale, so
when overriding one (say, django.request) spell out handlers and propagate too.
env is read from os.environ, not settings.DEBUG, because the builder runs while
settings.py is still being evaluated. Set DJANGO_ENV in each environment.
A production log line looks like:
{"ts": "2026-06-30T12:34:56.789012+00:00", "level": "INFO", "logger": "harry.email.services", "msg": "Sent email message", "message_id": "…"}Anything you pass via logger.info("…", extra={"message_id": mid}) is merged into the JSON.
Where the JSON goes after stdout is a deployment concern, not a logging one — see "Deployment".
RequestLogMiddleware emits one structured access line per request — readable
in development (GET /invoices/42 200 (12ms)), structured in production:
{"ts": "…", "level": "INFO", "logger": "harry.request", "msg": "GET /invoices/42 200 (12ms)",
"method": "GET", "path": "/invoices/42", "status": 200, "duration_ms": 12, "user_id": 7}Requires: nothing — it works with any LOGGING configuration, though the
structured output shown assumes harry's logging config.
Add the middleware to MIDDLEWARE, after AuthenticationMiddleware (it reads
request.user):
MIDDLEWARE = [
# ...
"django.contrib.auth.middleware.AuthenticationMiddleware",
# ...
"harry.middleware.RequestLogMiddleware",
]Each request now emits one line at INFO on the harry.request logger.
There is no on/off setting: membership in MIDDLEWARE is the switch. To silence access
lines in one environment without touching MIDDLEWARE, raise the logger's level:
LOGGING = build_logging_config(
extra_loggers={"harry.request": {"level": "WARNING", "handlers": ["console"], "propagate": False}},
)Field notes:
duration_msmeasures middleware entry to response return — for streaming responses that is time-to-headers, not time-to-last-byte.user_idis the authenticated user's primary key, elsenull.- Client IP, user agent, and response size are deliberately absent — your reverse proxy's
access log owns those. The query string is also excluded: it's the classic place
password-reset tokens and magic links leak into logs. The request id concept is absent
too — when tracing is on,
trace_idis the per-request id on every line.
Requests whose path is in REQUEST_LOG_IGNORE_PATHS get no access line. Entries ending
in / match as path prefixes; all other entries match exactly — never by substring, so
an entry can't silently swallow a real route. The default:
REQUEST_LOG_IGNORE_PATHS = {
"/favicon.ico",
"/robots.txt",
"/apple-touch-icon.png",
"/apple-touch-icon-precomposed.png",
"/.well-known/", # trailing slash → prefix match
}Healthcheck paths are deliberately not ignored: a probe every 30 seconds is a useful status/latency heartbeat. If you find it too chatty, add your healthcheck path to the set (overriding replaces the default set wholesale).
One shared healthcheck view so "is it up, can it reach its database" is answered identically in every project. It's the target for an external uptime monitor — the one alert internal tooling can't provide.
Requires: nothing.
Wire it yourself (nothing registers the URL automatically):
from django.urls import path
from harry.views import health
urlpatterns = [
# ...
path("health/", health),
]An unauthenticated GET /health/ (no CSRF token needed) returns 200 with
{"status": "ok"} when the default database answers SELECT 1, and 503 with
{"status": "error", "detail": "database unavailable"} when it doesn't — never a
stack trace or connection string. The check is deliberately database-only: cache,
storage, and external-API checks make healthchecks flaky and page you for
dependencies that have their own monitoring.
Point your uptime monitor at the URL as a deployment step; expect probes every 15–30 seconds — the view is fast and side-effect free.
Healthcheck probes do get access lines from RequestLogMiddleware (the path is
deliberately not in the default ignore set), giving you a steady status/latency
heartbeat. If that's too chatty, add your health path to
REQUEST_LOG_IGNORE_PATHS (see
"Ignoring noise endpoints" above).
Everything so far works without OpenTelemetry. This module is the opt-in
enhancer: with it, every request gets a distributed trace, and every log line —
access lines included — carries the request's trace_id/span_id, so your
logs link to their traces. Without it, nothing above loses any functionality.
Requires: the harry[otel] extra (see Installation). No
OpenTelemetry package is a hard dependency of harry. Enhances
Logging and Request logging; required by
neither.
Install the extra:
dependencies = [
"harry[otel] @ git+https://github.com/hkhanna/django-harry",
]Call init_observability() in settings.py:
# settings.py
from harry.observability import init_observability
init_observability() # OTEL_SERVICE_NAME + OTEL_EXPORTER_OTLP_ENDPOINT from envOTEL_SERVICE_NAME=myapp \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=prod \
gunicorn myproject.wsgiThat one call wires everything:
- a
TracerProviderwith an OTLP span exporter — endpoint, headers, and protocol (gRPC by default; setOTEL_EXPORTER_OTLP_PROTOCOL=http/protobuffor HTTP) come from the standardOTEL_EXPORTER_OTLP_*env vars, the service name from the argument (init_observability(service_name="myapp")) orOTEL_SERVICE_NAME; - Django, psycopg, and requests instrumentation — each enabled only when the library is installed;
- log↔trace correlation — trace ids are stamped onto every
LogRecord, and harry's JSON formatter promotes them totrace_id/span_id/service.name.
Because initialization is programmatic — there is no opentelemetry-instrument wrapper —
the same settings.py works identically under gunicorn, manage.py commands, and any
future task runner without changing how processes are launched. Calling it twice (e.g.
under the autoreloader) is a no-op, and calling it without the extra installed raises an
ImportError that tells you to install harry[otel]. The extra is also the single place
the interdependent opentelemetry-* package versions are managed — don't pin them
yourself.
Manual path (without the harry[otel] extra)
To assemble your own instrumentation set instead, OpenTelemetry's zero-code path works with harry unchanged — the formatter promotes the trace ids either way:
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install # detects Django etc. and installs instrumentationsOTEL_SERVICE_NAME=myapp \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=prod \
OTEL_PYTHON_LOG_CORRELATION=true \
opentelemetry-instrument gunicorn myproject.wsgiOTEL_PYTHON_LOG_CORRELATION=true is the piece that stamps trace ids onto log records;
without it you get traces but uncorrelated logs.
harry only writes JSON to stdout; shipping it anywhere is a deployment choice, made on the server rather than in the app. The production assumption is SigNoz. This section covers the pieces that live outside the app process: the host's OpenTelemetry Collector and the reverse proxy.
Because you'll already run an OpenTelemetry Collector on the host for OS/VPS metrics and system logs, the simplest setup is to let that same Collector pick up the application logs — no OpenTelemetry packages needed in the app itself:
- Run Django under systemd (e.g. gunicorn). harry's JSON goes to stdout, which systemd captures in the journal.
- Install the OpenTelemetry Collector on the host and give it one pipeline for host metrics and one for logs (full sketch below).
Four mappings in the log pipeline do the real work — they are the deployment
half of the contract harry.logconfig.JSONFormatter defines:
level→ severity. journald stamps everything on stdout as INFO, which would flatten an app ERROR to INFO in SigNoz — ajson_parseroperator re-parses the real level out of harry's JSON.ts→ record timestamp, so the record carries the time the app logged, not the time the collector read the journal.trace_id/span_id→ the log data model's trace fields. This mapping — not just a shared string attribute — is what makes log↔trace click-through work in SigNoz. The fields are present when Tracing is on; no in-app log exporter and no change to harry required.msg→ log body, giving a clean human message in SigNoz, with level/logger and anyextrafields remaining as queryable attributes.
The canonical collector configuration — kept in sync with JSONFormatter's
field names — lives in
docs/observability-signoz.md, alongside
everything else on the SigNoz side (alerts, uptime, retention).
For the proxy, app server, and Django to share a single trace id, the proxy must join
the trace. With Caddy, that's the tracing directive:
example.com {
tracing
reverse_proxy 127.0.0.1:8000
}
Caddy continues an inbound W3C trace context or starts a new trace, exports its own span
(honoring the standard OTEL_EXPORTER_OTLP_* / OTEL_SERVICE_NAME env vars), injects
traceparent into the proxied request — so Django's instrumentation continues the same
trace — and stamps traceID/spanID onto its own access logs. The result in SigNoz:
Caddy's span, Django's spans, and every app log line share one trace id.
To also surface the id in gunicorn's access log:
# gunicorn.conf.py
access_log_format = '%(h)s "%(r)s" %(s)s %(b)s %(D)sus traceparent=%({traceparent}i)s'Neither piece is required: any reverse proxy (or none) and any WSGI/ASGI server
work. Without Caddy tracing (or another tracing proxy), the trace originates in
Django — requests still trace and logs still correlate, but the proxy hop is not part of
the trace. "One id, full lifecycle" specifically requires the proxy to participate.
The sections above document the machinery harry ships. This section is the standard for
how projects that install harry use it — what the code should do, plus the launch
steps that can't be code (SigNoz alerts, uptime monitors, cron pings). This README is
the canonical home for these conventions: it travels with the package into every
project, so a consuming project's CLAUDE.md should carry a one-line pointer
("Observability follows django-harry's README conventions.") rather than restate them.
Log events with fields, not prose. The message is a snake_case verb phrase naming
what happened (payment_failed, user_registered); everything variable goes in
extra:
logger.info(
"payment_failed",
extra={"user_id": user.id, "order_id": order.id, "amount": amount},
)JSONFormatter lifts extra keys to top-level JSON keys, so user_id, order_id,
and amount are directly queryable in SigNoz. "Payment failed for user 7" is not.
No structlog — decided. harry.logconfig (stdlib logging + JSONFormatter)
already provides structured JSON events, trace-id promotion, and canonical-field
clobber protection with zero dependencies. The extra={} syntax is the accepted cost
of staying on stdlib. Do not re-open this without a concrete failure of the current
setup. The rationale is recorded in
ADR 0001.
Level semantics.
| Level | Means |
|---|---|
ERROR |
A human should eventually look at this |
WARNING |
Unexpected but handled |
INFO |
Notable business event |
DEBUG |
Off in production (the prod profile's default level is INFO) |
Always bind identifying context. Every event carries user_id and the primary
entity id involved (order_id, message_id, …). Request-level correlation comes from
trace_id (stamped by OpenTelemetry, see "Tracing"), not from hand-added
request-id fields.
Never log secrets — tokens, query strings, or full request bodies. These are the
same exclusions RequestLogMiddleware makes deliberately; don't reintroduce them via
extra.
No bare print() in app code. It bypasses levels, formatting, and shipping.
Auto-instrumentation from init_observability() already covers views, the ORM, and
outbound HTTP. Add a manual span only around a business operation worth timing on its
own:
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
def generate_invoice(order):
with tracer.start_as_current_span("generate_invoice"):
...Exceptions must reach the active span — auto-instrumentation records unhandled ones.
Never swallow an exception silently: if you catch and handle it, log it with context at
WARNING or ERROR so it still surfaces.
The SigNoz rules are fleet-wide: declared once in the infra repo's Terraform, grouped by service, they cover every project automatically the moment it ships telemetry. Only the external uptime checks are configured per project at launch (see the checklist below).
-
Alert on symptoms users feel — errors, latency, downtime — not causes like CPU or disk. Causes belong on dashboards, not pagers.
-
Every alert must be actionable. An alert ignored twice gets deleted or fixed.
-
The standard set: five fleet-wide SigNoz rules —
- Error rate above threshold for 5 minutes, per service
- New exception type, per service
- p95 latency above threshold, per service
- Any ERROR-level log for 5 minutes, per service (catches failures outside request spans — management commands, startup, cron)
- Any ERROR-level log carrying no service identity at all (catches an
execution context that forgot
OTEL_SERVICE_NAME)
— plus an external uptime check on
/health/(see "Health endpoint"). Definitions, thresholds, and the Terraform provisioning are in docs/observability-signoz.md.
Everything harry needs from a project that installs it. Each group maps to one feature — skip the group if the project skips the feature. Items are terse on purpose; the linked section is the source of how.
Email ("Email")
-
harry[email]installed;anymailandharry.emailinINSTALLED_APPS; ESP configured viaEMAIL_BACKEND+ANYMAIL -
SITE_CONFIGandMAX_SUBJECT_LENGTHset - Email templates created (
*_subject.txt,*_message.txt) - Migrations run
- Anymail webhook URLs wired; webhook secret registered with the ESP
Logging ("Logging")
-
LOGGING = build_logging_config()in settings -
DJANGO_ENV=prodset in production
Request logging ("Request logging")
-
RequestLogMiddlewareinMIDDLEWARE, afterAuthenticationMiddleware
Health endpoint ("Health endpoint")
-
/health/wired inurls.py
Tracing ("Tracing")
-
harry[otel]installed;init_observability()called in settings -
OTEL_SERVICE_NAME,OTEL_EXPORTER_OTLP_ENDPOINT, andOTEL_RESOURCE_ATTRIBUTES=deployment.environment=…set in every execution context — app unit, Caddy, cron jobs, systemd timers
Launch — configured outside the code ("Deployment", "Alerting principles"; the step-by-step SigNoz-side procedure is in docs/observability-signoz.md)
- Access lines and traces visible in SigNoz; log lines carry
trace_idand click through to their trace -
/health/registered with the external uptime monitor - Service appears in SigNoz and the fleet-wide standard alerts cover it — automatic, nothing to provision; add an override in the infra repo only if a threshold needs tuning
- Cron/scheduled jobs ping Healthchecks.io on completion
This project uses uv for dependency management.
# Install dependencies (--all-extras so the email and OpenTelemetry tests run)
uv sync --all-extrasuv run pytestFormat and lint code:
make formatRun type checking:
make mypyCreate new migrations:
make migrations