Skip to main content

Auditing

ant gives you four places to record what happened. Which one fits depends on what you need to know.

You needUse
Field-level history of SaveChangesSaveInterceptor
A record that can't disagree with the dataA row in the same Tx
A reaction after the commitObserver
Delivery that survives a crashAn outbox row and a worker
Bulk writes and raw SQL tooInterceptor, or a database trigger

What changed, field by field​

A SaveInterceptor sees every SaveChanges before it writes. An error cancels the save.

type AuditTrail struct {
log *slog.Logger
}

func (a *AuditTrail) Before(ctx context.Context, _ *ant.Statement) (context.Context, error) {
return ctx, nil
}

func (a *AuditTrail) After(context.Context, *ant.Statement, ant.Outcome) {}

func (a *AuditTrail) SavingChanges(ctx context.Context, changes []ant.Change) error {
for _, c := range changes {
a.log.Info("saving", "model", c.Model, "state", c.State.String(), "fields", c.Changed)
}

return nil
}

To record old values too, read them with ant.Original(ant.Entry(db, user), &U.Age).

A row in the same transaction​

For a record that can never disagree with the data, write it in the same transaction as the change. Either both commit, or neither does.

err := db.Tx(ctx, func(tx *AppDB) error {
if err := tx.Orders.Insert(ctx, order); err != nil {
return err
}

return tx.AuditLog.Insert(ctx, &AuditEntry{Action: "order.created", Subject: order.ID})
})

A hook runs inside the write too, but it only gets a context.Context, with no way to reach the transaction. Use hooks to check or refuse, not to write rows.

After it happened​

An observer runs after the commit, and never hears about a rollback. It runs in the process, and is gone if it crashes. For anything that must not be lost, write an outbox row in the transaction, as the ledger example does, and let a worker send it.

Every statement​

An Interceptor sees every statement, including bulk writes and raw SQL, in SQL rather than entities.

Bulk Update and Delete, InsertMany, and Raw don't run hooks or a SaveInterceptor, because they never load an entity. Only a database trigger sees everything, including someone typing into psql.

For a whole audit service, see the audit example.