Skip to content

Logging

Everything goes to the django_aiogram logger. Values are attached as structured fields rather than interpolated into the message, so a JSON or structlog backend can index and filter them.

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'handlers': {
        'console': {'class': 'logging.StreamHandler'},
    },
    'loggers': {
        'django_aiogram': {
            'handlers': ['console'],
            'level': 'INFO',
            'propagate': False,
        },
    },
}

Drop to DEBUG to also see the sends a disabled process skips.

Fields

All prefixed with tg_, to avoid colliding with LogRecord attributes.

Field Where
tg_function the aiogram method being called
tg_retry_after seconds Telegram asked to wait
tg_retries attempts made so far
tg_max_retries the limit that was reached
tg_delivery the consumer that started, always blpop
tg_key the queue being consumed: a Redis list, a stream, an AMQP queue, or a Kafka topic
tg_timeout blocking-pop timeout, or how long a shutdown waited
tg_error the class name of a non-fatal error, not its text — a webhook secret or a chat id can end up in the message, and this field is what a log aggregator groups on
tg_crash_safe whether the consumer holds messages in flight. The transport answers for itself: false on a Redis without LMOVE, and always true on a stream, where the pending list is how delivery works
tg_mode polling or webhook
tg_update the update id being handled
tg_correlation_id the id every event about one message carries
tg_short_id that id as the admin shows it: twelve characters to paste into the log's search box
tg_alternative the awaitable method a synchronous send from a loop should move to
tg_pending work still in flight at shutdown: sends, or the updates a webhook process is answering
tg_low the first id of the range a copy is retrying, when a row landed under an id it was about to move
tg_drain_timeout how long shutdown gave them
tg_kind the event log kind of a row
tg_replay_of the correlation id a replayed send is standing in for
tg_claimed_by the process whose replay claim is being taken over, from django_aiogram_replay_claim.claimed_by
tg_replayed, tg_refused, tg_skipped how many sends one tgbot_replay run put back, how many it could not, and how many needed nothing — sent in the end, replayed already, or discarded on purpose past --grace
tg_receiver the events_recorded receiver that raised
tg_batch how many rows the batch held, when part of it was refused
tg_worker the worker name an in-flight list is keyed on
tg_messages how many messages a deferred publish was carrying when it failed: every chunk of a fan-out, not one of them
tg_published / tg_claimed how many scheduled sends one dispatch pass put on the queue, of how many it claimed
tg_overdue / tg_grace how late a scheduled send was, against the --grace that refused it
tg_attempts how many failed publishes a scheduled send had had when the mover gave up on it
tg_lease / tg_call_ceiling the --lease a mover is running with, against the deadline the transport puts on one publish — reported where the first is not longer than the second
tg_database the connection alias a decision about a transaction was made on
tg_entry the id of a stream entry this package did not write, left pending rather than acknowledged
tg_count events in the batch being written, or kafka messages left unsent when a producer was replaced or the process shut down
tg_lost stream entries that were pending and no longer exist, so that work is gone — the fingerprint of a MAXLEN trim or an XDEL reaching unacknowledged work
tg_setting the setting a message is about
tg_variable the environment variable a message is about
tg_dropped events lost because the buffer was full, or sends dropped at shutdown
tg_failures consecutive failures of the event writer
tg_reason why one of the healthcheck's two Redis-only extras could not answer — every such path carries it: the client could not be built, or the call it made failed. Not a verdict: the verdict is the broker's, and it has already been decided by the time this is written. The same text reaches the operator, as unknown in the probe's line or as the warning about an unfinished sweep

Events worth alerting on

Message Level Meaning
giving up on message ERROR retries exhausted, the message was dropped
handler failed for queued message ERROR the send itself raised
dropping undecodable queued message ERROR a payload could not be deserialized
blocking pop failed, retrying ERROR lost the Redis connection; it retries
a message finished after its channel was replaced, so it will be redelivered WARNING RabbitMQ: a send completed across a reconnect, and the delivery tag it held is meaningless on the new channel. Nothing is acknowledged, because the broker has already put the message back — so it arrives again, and a handler that is not idempotent sends it twice
a message finished after its partition was rewound, so it will be redelivered WARNING Kafka: a send completed after a release rewound its partition, so the delivery it named no longer exists. Nothing is committed — accepting it could commit past the messages the rewind put back — and it arrives again with them
kafka messages were accepted locally and never reached the broker WARNING Kafka: librdkafka accepted these and could not hand them over before its producer was replaced or the process went away. tg_count says how many; they cannot be recovered, and a settings change during a burst is the usual way there
entries were pending but no longer exist in the stream, so that work is lost WARNING Redis Streams: work that was taken and never settled has been deleted from the stream, so those messages are gone. Nothing in this package can cause it — a MAXLEN trim or an XDEL reached unacknowledged entries. tg_lost carries how many
a stream entry carries no payload field and was left pending WARNING Redis Streams: something else is writing to this stream. The entry is left pending rather than acknowledged, because settling it would be a guess about another producer's data. tg_entry names it
the delivery consumer did not stop in time WARNING the consumer outlived its join at shutdown; a message it holds may be redelivered
cancelling updates still in flight WARNING a webhook update outlasted the drain at shutdown; its request is answered 503, so Telegram redelivers it rather than a worker hanging on a stopped loop
webhook is not configured to serve updates ERROR MODE or WEBHOOK_SECRET could not be read, so the view answered 503 rather than raising an unauthenticated 500
webhook refused an update WARNING an update arrived while the process was shutting down; answered 503 so Telegram redelivers it
the event loop thread did not start in time WARNING a webhook process cannot hand updates to its loop; every request is refused with 503 until a thread starts
the event loop thread is gone; starting another WARNING that thread died and was replaced; the update that lost it was refused
the event loop thread did not stop in time WARNING it outlived its join at shutdown, so the teardown was skipped and close() can be retried
skipping close WARNING the loop was still running, so nothing was torn down; stop polling or the loop thread and call it again
skipping drain WARNING the same, for the drain alone: in-flight sends were left rather than waited for
scheduling a send on a loop nothing in this process runs WARNING nothing polls this process and no loop thread exists, so the send is created and never stepped
rate limited by telegram WARNING refused and backing off
a synchronous send was called from a running event loop WARNING send, enqueue or send_many from async code: correct, but it writes on the loop's own thread. tg_alternative names the awaitable form. Said once per process
an events_recorded receiver raised ERROR one of your metrics receivers raised; the batch reached the database if the event log is on and the write succeeded, and usually the other receivers too — send_robust isolates them, but the row below is the case where it cannot. tg_receiver names it
the prometheus exporter could not record an event ERROR the shipped exporter failed on one event and carried on with the rest of the batch. tg_kind names it. It contains itself rather than leaning on the two rows above, so a metric going wrong costs one observation and never the feed
publishing recorded events failed ERROR the signal dispatch itself raised, not a receiver — Django's own failure logging cannot name a callable instance. The batch reached the database if the event log is on; some receivers may have missed it
taking over a replay claim whose queue write never answered WARNING a claim older than --claim-lease that never recorded reaching the queue: the run that made it died, or its publish raised. tg_claimed_by names the process whose claim was taken over, tg_replay_of the failure. Taking it over may send a second copy, since the message may have reached the queue anyway
a replay claim was taken over while its message was being queued WARNING the queue write outlasted --claim-lease, so another run took the failure and may have sent the same message. tg_replay_of names it, tg_claimed_by this run's own process, which held the claim that was lost. The message this run queued still went, and the run carries on: the alternative is ending it at that row
could not replay a failed send ERROR one row's queue write raised, and the run carried on with the rest. tg_replay_of names it and tg_claimed_by the process holding the claim it keeps — a raise is not proof the message stayed out of the queue, so the row waits for --claim-lease rather than being offered to the next run. One bad row must not take a hundred-row replay down halfway, with no way to tell which half went
replayed a failed send INFO tgbot_replay queued one message again. tg_replay_of names the id it stands in for, tg_function the method
replay finished INFO one tgbot_replay run is done: tg_replayed put back, tg_refused it could not, tg_skipped needed nothing — delivered in the end, replayed already, or discarded past --grace. Neither count is an error: a refusal is usually a row whose arguments were never recorded in full, and a skip is usually a previous run's work
delivery started INFO the consumer is up
message sent INFO one call succeeded
the event log is falling behind; events are being dropped ERROR the writer cannot keep up; rows are being lost, messages are not
the event log is suspended after repeated failures ERROR five failed batches in a row, usually a missing migrate
leaving a refused pickle message in flight ERROR ALLOW_PICKLE is off and a pickled payload is waiting for it
leaving a message from a newer version in flight ERROR the web tier was deployed ahead of the bot container
could not close the client a settings change replaced ERROR a settings change replaced the async Redis client and closing the old one raised; its replacement is already in use, so nothing was refused

The database event log

This page is about the structured log: a stream, shipped somewhere, rotated. Event log is the other tool — an optional table you can query and join against your own models, off by default. Use the log for volume and alerting, and the table for the questions that outlive a retention window.

With structlog

ProcessorFormatter drops stdlib extra unless ExtraAdder is in its foreign_pre_chain, so wire that up:

import logging

import structlog

handler = logging.StreamHandler()
handler.setFormatter(
    structlog.stdlib.ProcessorFormatter(
        processor=structlog.processors.JSONRenderer(),
        foreign_pre_chain=[structlog.stdlib.ExtraAdder()],
    )
)
logging.getLogger('django_aiogram').addHandler(handler)

With it in place the tg_ fields arrive as event keys:

logger = logging.getLogger('django_aiogram')
logger.warning('rate limited by telegram', extra={'tg_function': 'send_message'})
# -> {"event": "rate limited by telegram", "tg_function": "send_message", ...}

The message text is a constant, so the same event groups together regardless of its values.