Logging and notifications
As eventyay is handling monetary transactions, we are very careful to make it possible to review all changes in the system that lead to the current state.
Logging changes
We log data changes to the database in a format that makes it possible to display those logs to a human, if
required. eventyay stores all those logs centrally in a model called eventyay.base.models.LogEntry.
We recommend all relevant models to inherit from LoggedModel as it simplifies creating new log entries:
- class eventyay.base.models.LoggedModel(*args, **kwargs)
Bases:
Model,LoggingMixin- all_logentries()
Returns all log entries that are attached to this object.
- Returns:
A QuerySet of LogEntry objects
- all_logentries_link
- logs_content_type
- top_logentries()
- top_logentries_has_more()
To actually log an action, you can just call the log_action method on your object:
order.log_action('eventyay.event.order.canceled', user=user, data={})
The positional action argument should represent the type of action and should be globally unique, we
recommend to prefix it with your package name, e.g. paypal.payment.rejected. The user argument is
optional and may contain the user who performed the action. The optional data argument can contain
additional information about this action.
Logging form actions
A very common use case is to log the changes to a model that have been done in a ModelForm. In this case,
we generally use a custom form_valid method on our FormView that looks like this:
@transaction.atomic
def form_valid(self, form):
if form.has_changed():
self.request.event.log_action('eventyay.event.changed', user=self.request.user, data={
k: getattr(self.request.event, k) for k in form.changed_data
})
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
It gets a little bit more complicated if your form allows file uploads:
@transaction.atomic
def form_valid(self, form):
if form.has_changed():
self.request.event.log_action(
'eventyay.event.changed', user=self.request.user, data={
k: (form.cleaned_data.get(k).name
if isinstance(form.cleaned_data.get(k), File)
else form.cleaned_data.get(k))
for k in form.changed_data
}
)
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
Displaying logs
If you want to display the logs of a particular object to a user in the backend, you can use the following ready-to-include template:
{% include "eventyaycontrol/includes/logs.html" with obj=order %}
We now need a way to translate the action codes like eventyay.event.changed into human-readable
strings. The eventyay.base.signals.logentry_display signals allows you to do so. A simple
implementation could look like:
from django.utils.translation import gettext as _
from eventyay.base.signals import logentry_display
@receiver(signal=logentry_display)
def eventyaycontrol_logentry_display(sender, logentry, **kwargs):
plains = {
'eventyay.event.order.paid': _('The order has been marked as paid.'),
'eventyay.event.order.refunded': _('The order has been refunded.'),
'eventyay.event.order.canceled': _('The order has been canceled.'),
...
}
if logentry.action_type in plains:
return plains[logentry.action_type]
Sending notifications
If you think that the logged information might be important or urgent enough to send out a notification to interested
organizers. In this case, you should listen for the eventyay.base.signals.register_notification_types signal
to register a notification type:
@receiver(register_notification_types)
def register_my_notification_types(sender, **kwargs):
return [MyNotificationType(sender)]
Note that this event is different than other events send out by eventyay: sender may be an event or None. The
latter case is required to let the user define global notification preferences for all events.
You also need to implement a custom class that specifies how notifications should be handled for your notification type.
You should subclass the base NotificationType class and implement all its members:
- class eventyay.base.notifications.NotificationType(event=None)
Bases:
object- property action_type
The action_type string that this notification handles, for example
"pretix.event.order.paid". Only one notification type should be registered per action type.
- build_notification(logentry)
This is the main function that you should override. It is supposed to turn a log entry object into a notification object that can then be rendered e.g. into an email.
- property required_permission
The permission a user needs to hold for the related event to receive this notification.
- property verbose_name
A human-readable name of this notification type.
A simple implementation could look like this:
class MyNotificationType(NotificationType):
required_permission = "can_view_orders"
action_type = "eventyay.event.order.paid"
verbose_name = _("Order has been paid")
def build_notification(self, logentry: LogEntry):
order = logentry.content_object
order_url = build_absolute_uri(
'control:event.order',
kwargs={
'organizer': logentry.event.organizer.slug,
'event': logentry.event.slug,
'code': order.code
}
)
n = Notification(
event=logentry.event,
title=_('Order {code} has been marked as paid').format(code=order.code),
url=order_url
)
n.add_attribute(_('Order code'), order.code)
n.add_action(_('View order details'), order_url)
return n
As you can see, the relevant code is in the build_notification method that is supposed to create a Notification
method that has a title, description, URL, attributes, and actions. The full definition of Notification is the
following:
- class eventyay.base.notifications.Notification(event, title, detail=None, url=None)
Bases:
objectRepresents a notification that is sent/shown to a user. A notification consists of:
one
eventreferenceone
titletext that is shown e.g. in the email subject or in a headlineoptionally one
detailtext that may or may not be shown depending on the notification methodoptionally one
urlthat should be absolute and point to the context of an notification (e.g. an order)optionally a number of attributes consisting of a title and a value that can be used to add additional details to the notification (e.g. “Customer: ABC”)
optionally a number of actions that may or may not be shown as buttons depending on the notification method, each consisting of a button label and an absolute URL to point to.
- add_action(label, url)
Add an action to the notification, defined by a label and an url. An example could be a label of “View order” and an url linking to the order detail page.
- add_attribute(title, value)
Add an attribute to the notification, defined by a title and a value. An example could be a title of “Date” and a value of “2017-12-14”.
Logging technical information
If you just want to log technical information to a log file on disk that does not need to be parsed
and displayed later, you can just use Python’s logging module:
import logging
logger = logging.getLogger(__name__)
logger.info('Startup complete.')
This is also very useful to provide debugging information when an exception occurs:
try:
foo()
except:
logger.exception('Error when calling foo()') # Traceback will automatically be appended
messages.error(request, _('An error occured.'))
Operational logs (privacy-safe)
Most product areas already persist an audit trail as LogEntry
via log_action (tickets LoggingMixin and talk/video/mail LogMixin).
The process logger mirrors allowlisted log_action types. Unexpected
faults still use logger.exception / logger.error. log_event only
attaches allowlisted extra fields (IDs, outcome, correlation); it is not a
second logging framework. Call sites do not need a helper in every file.
Each operational line includes:
An ISO-8601 UTC
datetimecomponent(tickets,talk,video,mail,plugins,core)outcome=successoroutcome=failureaction(thelog_actiontype, or a choke-point name such ascart.error)Opaque IDs only:
event_id,order_id,order_code,user_id,object_id,voucher_idCorrelation IDs:
request_id(HTTPX-Request-IDor generated) andjob_id(Celery task id)
Do not log emails, names, phones, addresses, payment instrument data,
tokens, cookies, API keys, webhook secrets, voucher secrets, or raw
request/response bodies. log_action data payloads are never copied
to the process log. Only allowlisted keys such as provider (payment
provider identifier) and numeric IDs are promoted onto the log line.
Every HTTP route is covered by CorrelationIdMiddleware (first in
MIDDLEWARE): correlation IDs on all requests, core.request.start
on entry, core.request.end on completion (route name, status, duration;
no query strings), and core.auth.denied / core.permission.denied /
core.request.error for 401, 403, and 5xx. Static/media and health
paths are skipped here; health dependency failures use health.check.
Live WebSocket connect/abnormal close
and ConsumerException cover video commands; mutating APIs go through
log_action prefixes, video AuditLog.save, or choke-point log_event
calls. Video AuditLog rows are mirrored without copying data (chat
message edits and profile dumps stay out of the process log).
Levels: INFO for successful lifecycle transitions, WARNING for
expected business failures, ERROR (via logger.exception) only for
unexpected faults.
Skipped to keep volume low: scanner heartbeat eventyay.device.updated,
generic eventyay.event.settings saves, organizer/order comments (not
submission review comments), static/media/health-success probes,
and per-user chat join/leave. Occupancy is logged at aggregate room-count
changes only. Page views still emit request.end with route/status/duration
and no query string. Attendee profile dumps are not mirrored.
Areas:
tickets: attendee cart/checkout/order/payment/refund, products, quotas, vouchers, waiting list, check-in, devices/gates, payment-provider config, ticket/badge PDF layouts, lock timeouts/release, geocoding request failures (no address text), EU VIES VAT lookup unavailability (no VAT ID), ticket PDF generate failures (order code only), API quota-exceeded, and data-shred failures.
talk: speaker/attendee CfP and submissions, reviews, schedule publish, tracks, access codes, organizer event/talk-data updates, public schedule and schedule-editor HTTP failures (status only, no JSON/PII), invalid API tokens, Etherpad pad create and misconfiguration, talk-schedule widget fetch failures (no URL), and speaker-import avatar download failures (no URL).
video: room create/update/delete (REST and
AuditLogevent.room.*), aggregate room occupancy (count only, no user ids), stream-schedule create/update/delete, BBB recording start/stop when a recorded call is created or ended, recording listing (recording.fetch, no XML/URLs), LoungeMesh token exchange/refresh asmedia.callback(type only), feature-flag writes and disabled-provider evaluation (flag name only), live WebSocket connect/abnormal close, BBB/Janus/Jitsi/Zoom/LoungeMesh connection errors, WHEP/HLS/captions, live JWT/kiosk auth failures, and browser-reported failures viaevent.client_log. There is no BBB/Janus/Zoom inbound recording webhook in this repository.mail: enqueue (
QueuedMail/EmailQueue, ids and recipient counts only), outbox worker dispatch, send success/failure, template render failures, SMTP 554/571 asmail.complaint, other recipient 5xx and Gmail permanent rejection asmail.bounce(smtp code, no addresses), order-email actions, Gmail OAuth connect/callback/revoke (no tokens), CID image fetch 5xx, and test-send failures. There is no SES/SNS complaint inbound webhook in this repository.plugins: plugin enable/disable, outbound webhook status/duration (no URL/body), inbound Stripe signature validation, billing Stripe API errors (no card or customer payloads), meetup RSVP Stripe confirm/refund (order code only), bank-import parse and lock-timeout retries.
core:
request.startandrequest.end(route when resolved, status, duration), 401/403/5xx, control and common login without credentials, Celeryjob.enqueue/job.start/job.finish/job.retry/job.fail(task name and id, not args), config load (no setting values), DB/cache/redis/queue health failures (one structuredlogger.exceptionline with extras), social-login provider errors, failed 2FA, Turnstile misconfig/network, telemetry HTTP failures.
Production format (console handler verbose). The logger message is the
action name; outcome lives in extras:
LEVEL <iso-8601-utc> eventyay.<component>: <action> datetime=... component=... outcome=... action=... [error_code=...] [event_id=...] [order_code=...] [request_id=...] [job_id=...]
Examples (PII-free):
INFO 2026-09-20T15:45:01Z eventyay.tickets: eventyay.event.order.placed datetime=2026-09-20T15:45:01Z component=tickets outcome=success action=eventyay.event.order.placed event_id=12 order_id=34 order_code=ABC12 model=Order request_id=req-7f3a9c
INFO 2026-09-20T15:45:08Z eventyay.tickets: eventyay.event.order.paid datetime=2026-09-20T15:45:08Z component=tickets outcome=success action=eventyay.event.order.paid event_id=12 order_id=34 order_code=ABC12 model=Order request_id=req-7f3a9c
INFO 2026-09-20T15:45:04Z eventyay.tickets: eventyay.event.order.payment.started datetime=2026-09-20T15:45:04Z component=tickets outcome=success action=eventyay.event.order.payment.started event_id=12 order_code=ABC12 payment_provider=stripe payment_id=88 payment_local_id=2 request_id=req-7f3a9c
INFO 2026-09-20T15:45:05Z eventyay.tickets: payment.handoff datetime=2026-09-20T15:45:05Z component=tickets outcome=success action=payment.handoff event_id=12 payment_provider=stripe request_id=req-7f3a9c
WARNING 2026-09-20T15:45:06Z eventyay.tickets: eventyay.event.order.payment.failed datetime=2026-09-20T15:45:06Z component=tickets outcome=failure action=eventyay.event.order.payment.failed event_id=12 order_code=ABC12 payment_provider=stripe request_id=req-7f3a9c
INFO 2026-09-20T15:46:01Z eventyay.tickets: eventyay.event.order.canceled datetime=2026-09-20T15:46:01Z component=tickets outcome=success action=eventyay.event.order.canceled event_id=12 order_code=ABC12 user_id=9 is_orga_action=True request_id=req-7f3a9c
INFO 2026-09-20T15:46:10Z eventyay.tickets: eventyay.event.order.refunded datetime=2026-09-20T15:46:10Z component=tickets outcome=success action=eventyay.event.order.refunded event_id=12 order_code=ABC12 request_id=req-7f3a9c
INFO 2026-09-20T16:00:00Z eventyay.tickets: eventyay.event.checkin datetime=2026-09-20T16:00:00Z component=tickets outcome=success action=eventyay.event.checkin event_id=12 checkin_list_id=5 position_id=77 request_id=req-7f3a9c
WARNING 2026-09-20T16:00:02Z eventyay.tickets: checkin.error datetime=2026-09-20T16:00:02Z component=tickets outcome=failure action=checkin.error error_code=already_redeemed event_id=12 request_id=req-7f3a9c
INFO 2026-09-20T15:44:50Z eventyay.tickets: voucher.apply datetime=2026-09-20T15:44:50Z component=tickets outcome=success action=voucher.apply event_id=12 job_id=c0ffee12-task
WARNING 2026-09-20T15:44:40Z eventyay.tickets: cart.error datetime=2026-09-20T15:44:40Z component=tickets outcome=failure action=cart.error error_code=cart_error event_id=12 request_id=req-7f3a9c
WARNING 2026-09-20T15:44:41Z eventyay.tickets: checkout.cart_invalid datetime=2026-09-20T15:44:41Z component=tickets outcome=failure action=checkout.cart_invalid error_code=cart_invalid event_id=12 request_id=req-7f3a9c
INFO 2026-09-20T16:10:00Z eventyay.talk: eventyay.submission.create datetime=2026-09-20T16:10:00Z component=talk outcome=success action=eventyay.submission.create event_id=12 object_id=99 request_id=req-7f3a9c
INFO 2026-09-20T16:11:00Z eventyay.talk: eventyay.schedule.release datetime=2026-09-20T16:11:00Z component=talk outcome=success action=eventyay.schedule.release event_id=12 request_id=req-7f3a9c
WARNING 2026-09-20T16:12:00Z eventyay.video: janus.connection datetime=2026-09-20T16:12:00Z component=video outcome=failure action=janus.connection error_code=janus_error request_id=req-7f3a9c
INFO 2026-09-20T16:12:05Z eventyay.video: connection.get datetime=2026-09-20T16:12:05Z component=video outcome=success action=connection.get backend=bbb status=200 duration_ms=88 event_id=12
WARNING 2026-09-20T16:12:08Z eventyay.video: iframe.error datetime=2026-09-20T16:12:08Z component=video outcome=failure action=iframe.error error_code=iframe_error backend=bbb event_id=12 user_id=9
INFO 2026-09-20T16:13:00Z eventyay.mail: mail.send datetime=2026-09-20T16:13:00Z component=mail outcome=success action=mail.send event_id=12 order_id=34 mail_type=order job_id=c0ffee12-task
WARNING 2026-09-20T16:13:02Z eventyay.mail: eventyay.event.order.email.error datetime=2026-09-20T16:13:02Z component=mail outcome=failure action=eventyay.event.order.email.error event_id=12 order_code=ABC12 job_id=c0ffee12-task
INFO 2026-09-20T16:14:00Z eventyay.plugins: webhook.outbound datetime=2026-09-20T16:14:00Z component=plugins outcome=success action=webhook.outbound webhook_id=8 status=200 duration_ms=41 retry_count=0 job_id=c0ffee12-task
WARNING 2026-09-20T16:15:00Z eventyay.core: permission.denied datetime=2026-09-20T16:15:00Z component=core outcome=failure action=permission.denied error_code=forbidden status=403 user_id=9 request_id=req-7f3a9c
INFO 2026-09-20T16:16:00Z eventyay.core: eventyay.team.created datetime=2026-09-20T16:16:00Z component=core outcome=success action=eventyay.team.created object_id=3 is_orga_action=True request_id=req-7f3a9c
INFO 2026-09-20T16:16:10Z eventyay.talk: eventyay.event.update datetime=2026-09-20T16:16:10Z component=talk outcome=success action=eventyay.event.update event_id=12 is_orga_action=True request_id=req-7f3a9c
WARNING 2026-09-20T16:17:00Z eventyay.talk: schedule.save datetime=2026-09-20T16:17:00Z component=talk outcome=failure action=schedule.save error_code=http_error backend=schedule_editor status=500
INFO 2026-09-20T16:18:00Z eventyay.video: auth.user.banned datetime=2026-09-20T16:18:00Z component=video outcome=success action=auth.user.banned event_id=12 user_id=9 object_id=u1 model=AuditLog request_id=req-7f3a9c
WARNING 2026-09-20T16:18:10Z eventyay.tickets: lock.timeout datetime=2026-09-20T16:18:10Z component=tickets outcome=failure action=lock.timeout error_code=lock_timeout request_id=req-7f3a9c
WARNING 2026-09-20T16:18:20Z eventyay.video: upload datetime=2026-09-20T16:18:20Z component=video outcome=failure action=upload error_code=file.type event_id=12 request_id=req-7f3a9c