What to log
The hardest part of integrating Recalled is not the SDK call, it is deciding what is worth logging. Log too little and your audit trail is useless. Log too much and you blow your quota, slow your app and drown the signal in noise.
This page is the opinionated guide. Follow these rules and you will have a clean, useful, compliant audit log without thinking about it.
The 3 rules
1. Log state changes, not reads. A user reading their dashboard 50 times a day is not an audit event. A user changing their email address once is. If the action does not mutate something in your system or in the user's account, it does not belong in Recalled.
2. Log actions with consequences, not technical noise. A failed health check, a cache miss, a 304 response, a CDN purge: not audit events. They belong in your APM tool. Recalled is for actions a human or an auditor would care about in 6 months.
3. Log what tells a story. Imagine someone in 6 months asking "who deleted this invoice and why". The answer must come from a single event: the actor, the target, the reason if any, the IP, the time. If your log entry does not let you reconstruct the story, it is incomplete.
Decision flowchart
When you are about to add a client.events.create() call somewhere, ask:
- Does this change a piece of state in our system? No → do not log.
- Would the legal, support or security team ever ask "who did this and when"? No → do not log.
- Is the cardinality reasonable? (less than 10 events per active user per session) Yes → log it. No → consider sampling or aggregating client-side.
If you answer yes to 1 and 2 and the cardinality is reasonable, log it. Otherwise skip it.
Catalogue by category
The standard list of what most B2B and B2C SaaS should log. Pick the categories that apply to your product, copy the action names, adapt to your domain. Each tab carries the events to log, the ones to skip, and a metadata hint.
Authentication
Every action that changes who is logged in or how they authenticate.
- →
user.signed_up(new account creation) - →
user.logged_in(successful auth, every method) - →
user.logged_out(explicit logout, including session revocation) - →
user.login_failed(bad password, expired token, blocked IP). Critical for security. - →
user.password_changed - →
user.password_reset_requestedanduser.password_reset_completed(two distinct events) - →
user.email_changed - →
user.two_factor_enabledanduser.two_factor_disabled - →
user.session_revoked(admin force-logout, log out from all devices) - →
magic_link.sentandmagic_link.consumed - →
oauth.linkedandoauth.unlinked(Google, GitHub, etc.)
- ×Page views or user navigated to /dashboard
- ×Token refresh (every 5 minutes is noise, log only revocations)
- ×Auth health checks
- ×Successful CSRF token verifications
auth_provider (email, google, magic_link), ip, user_agent, mfa_used (true/false), success (for failed attempts include the reason: bad_password, account_locked, mfa_required).
Authorisation
Anything that changes who can do what.
- →
member.invited,member.joined,member.removed - →
member.role_changed(always include before/after in metadata) - →
team.created,team.deleted - →
permission.granted,permission.revoked - →
api_key.created,api_key.revoked. Last_used_at on the key is fine, do not log every single use - →
sharing.granted,sharing.revoked(when a user shares a resource with another user or externally) - →
ownership.transferred(especially for B2B)
- ×Permission checks (every API request runs them, that is APM territory)
- ×Read-only access grants if they auto-expire fast
- ×Internal RBAC computation steps
role_before, role_after, scope, granted_by, expires_at.
Data lifecycle
The heart of audit logging. Every business object your users create, update, delete, share or move.
- →<object>.created (
invoice.created,project.created,document.created, etc.) - →<object>.updated (with a
changed_fieldsarray in metadata, not the full diff if it is large) - →<object>.deleted (always, every soft-delete and hard-delete)
- →<object>.archived and <object>.restored
- →<object>.published, <object>.unpublished
- →<object>.duplicated (by whom, source id in targets)
- →<object>.moved (folder change, ownership change, etc.)
- ×Auto-saves and draft updates if your app saves every 10 seconds. Log the explicit save instead.
- ×Reads, opens, views (those go to product analytics, not audit)
- ×Internal denormalisation jobs that touch the same row
changed_fields (array of field names), reason (if user provided one), source (manual, api, import, automation).
Money
Every monetary state change. Be exhaustive here, accountants and your future self will thank you.
- →
subscription.created,subscription.updated,subscription.canceled - →
subscription.plan_changed(with from and to plan slugs in metadata) - →
invoice.created,invoice.paid,invoice.failed,invoice.refunded - →
payment.succeeded,payment.failed(include declined reason) - →
refund.issued,refund.completed - →
coupon.applied,coupon.expired - →
payment_method.added,payment_method.removed,payment_method.set_default
- ×Stripe webhook ping itself (log the event the webhook represents instead)
- ×Currency conversion checks
- ×Pre-flight payment validations that did not result in a charge attempt
amount_cents, currency, stripe_payment_intent_id, provider (stripe, paddle, etc.), reason for refunds.
Admin actions
Anything an internal team member does on behalf of a user. Always log these, no exception. This is what auditors look at first.
- →
admin.impersonation_started,admin.impersonation_ended - →
admin.user_unlocked,admin.user_locked - →
admin.feature_toggled(per user or globally) - →
admin.data_overridden(when support manually fixes a value) - →
admin.support_intervention(catch-all for one-off corrections) - →Anything done via your internal back-office tools that mutates customer data
admin_id, reason (always require one in your back-office UI), ticket_id if you link to your helpdesk.
Exports & imports
Data leaving or entering the system in bulk.
- →
export.started,export.completed,export.failed - →
import.started,import.completed,import.failed - →
bulk_delete.requested,bulk_delete.completed - →
gdpr.access_requestandgdpr.erasure_request
format (csv, json), row_count, destination for exports, source for imports, size_bytes.
Integrations
Events that involve third parties.
- →
integration.connected,integration.disconnected - →
webhook.created,webhook.updated,webhook.deleted - →
webhook.delivery_failed(after the retry budget is exhausted, not every retry) - →
oauth.token_refreshedonly if it represents a meaningful event (e.g. forced re-auth), otherwise skip
- ×Every successful webhook delivery
- ×Healthchecks against integrations
Security
Things your security team needs visibility on.
- →
security.brute_force_detected(after the threshold is hit) - →
security.suspicious_login(new country, new device, etc.) - →
security.rate_limit_exceeded(only if it persisted, not every hit) - →
security.csp_violation_reported - →
security.api_key_leaked(if you have a detection pipeline)
- ×Every rate-limited request individually
- ×Every CSP violation (sample or aggregate hourly)
System & jobs
Useful for operational debugging, but be careful with cardinality.
- →
cron.<name>.completedandcron.<name>.failed(one per run, not per item processed) - →
migration.appliedandmigration.rolled_back - →
backup.created,backup.restored
- ×Each iteration of a loop, each row processed by a batch job
- ×Heartbeats, liveness probes
- ×Internal queue moves
Every action that changes who is logged in or how they authenticate.
- →
user.signed_up(new account creation) - →
user.logged_in(successful auth, every method) - →
user.logged_out(explicit logout, including session revocation) - →
user.login_failed(bad password, expired token, blocked IP). Critical for security. - →
user.password_changed - →
user.password_reset_requestedanduser.password_reset_completed(two distinct events) - →
user.email_changed - →
user.two_factor_enabledanduser.two_factor_disabled - →
user.session_revoked(admin force-logout, log out from all devices) - →
magic_link.sentandmagic_link.consumed - →
oauth.linkedandoauth.unlinked(Google, GitHub, etc.)
- ×Page views or user navigated to /dashboard
- ×Token refresh (every 5 minutes is noise, log only revocations)
- ×Auth health checks
- ×Successful CSRF token verifications
auth_provider (email, google, magic_link), ip, user_agent, mfa_used (true/false), success (for failed attempts include the reason: bad_password, account_locked, mfa_required).
Naming conventions
Follow this schema for everything: <domain>.<subject>.<verb_past_tense> or <domain>.<verb_past_tense> when there is no separate subject.
Rules:
- All lowercase, dot-separated, snake_case if needed inside a segment.
- Past tense verbs:
.creatednot.create,.updatednot.update,.deletednot.delete. - Domain first, subject second, verb last:
invoice.line_item.addedis better thanadded.invoice.line_item. - Be consistent: pick a verb and stick with it. Do not mix
.deletedand.removedfor the same domain. Do not mix.failedand.errored. - Keep it stable: action names end up in your dashboards, retention rules and notification filters. Renaming them later breaks history.
Standard verbs to reach for, ranked by frequency:
| Verb | Use it for |
|---|---|
.created | New resource came into existence |
.updated | Existing resource changed |
.deleted | Resource removed (soft or hard) |
.archived / .restored | Soft state changes |
.published / .unpublished | Visibility toggles |
.shared / .unshared | Access grants and revocations |
.signed_up / .logged_in / .logged_out | Auth lifecycle |
.invited / .joined / .removed | Membership |
.started / .completed / .failed | Long-running async actions |
.requested / .granted / .revoked | Permission flows |
Metadata schema
metadata is a free-form JSON object. Keep it small (a few hundred bytes is plenty), structured, and useful in 6 months. The patterns below work across most action categories.
Always include when relevant
source: where the action came from. Values:web,mobile,api,admin_panel,automation,import,webhook.reason: free text reason if the user or admin provided one (metadata.reason: "duplicate").request_id: your own correlation id for tracing back to logs.result:successorfailure. Default tosuccessso you can filter failures later.
For updates
{
"metadata": {
"changed_fields": ["status", "due_date"],
"before": { "status": "draft" },
"after": { "status": "paid" }
}
}Only include the changed fields, not the full object. If the diff is huge, just keep changed_fields and skip before/after.
For money
Always serialise amounts in integer cents (or smallest currency unit), never floats:
{
"metadata": {
"amount_cents": 4200,
"currency": "EUR",
"stripe_payment_intent_id": "pi_..."
}
}For failures
{
"metadata": {
"result": "failure",
"reason": "card_declined",
"code": "insufficient_funds"
}
}Antipatterns
Do not put any of these in metadata:
- Plaintext secrets, passwords, full tokens, full credit card numbers. If it should not be in your logs, it should not be in Recalled.
- Full document bodies, file contents, blobs, images. Recalled is not a document store. Put the resource id in
targetsand let the consumer fetch the body if they need it. - PII you do not need. If you log an action on a user, you already have
actor.id. You do not also need their full address, phone number, etc. Less PII = less GDPR exposure. - Server-side debug data. Stack traces, SQL queries, internal IDs that mean nothing to a human auditor. Send those to your APM.
- Anything bigger than ~2 KB. Large
metadataslows down list queries and inflates your storage bill.
Volume guidance
Recalled bills by event count per month. The right ratio for most B2B SaaS:
- 1 to 5 events per active user per session (login, a couple of actions, logout)
- 0 events when a user is just reading
- A handful per day for system actions (cron, billing, backups)
If you find yourself logging more than 10 events per active user per day, you are probably over-logging reads or capturing technical noise. Re-read the 3 rules at the top.
Worked example
A fictional B2B invoicing SaaS. Here is the complete list of events its team would push to Recalled. Notice how it stays focused.
# Auth (5)
user.signed_up
user.logged_in
user.logged_out
user.login_failed
user.password_changed
# Team (4)
member.invited
member.joined
member.removed
member.role_changed
# Invoices, the core business object (6)
invoice.created
invoice.updated
invoice.sent
invoice.paid
invoice.refunded
invoice.deleted
# Customers (3)
customer.created
customer.updated
customer.deleted
# Billing for the SaaS itself (4)
subscription.created
subscription.plan_changed
subscription.canceled
payment_method.added
# Admin (2)
admin.impersonation_started
admin.impersonation_ended
# Exports (2)
export.started
export.completedThat is 26 distinct actions, more than enough for a clean audit trail. Your real list will be similar in size.
Next step
Once you have your list, drop it in the Events API and start sending. The dashboard's list_actions_summary tool helps you see in real time which actions you are logging the most, so you can spot over-logging or gaps.