auditlog

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 2, 2026 License: MIT Imports: 17 Imported by: 0

README

auditlog

Versioned audit trail of model changes, with history and diff queries.

Each change is stored as a JSON snapshot keyed by (model_type, model_id). Snapshots for the same model form an ordered history with a monotonically increasing version. Create is idempotent against no-op writes: if a new snapshot is identical to the latest one (ignoring the volatile updated_at / updated_by fields) no new version is stored.

auditlog/             core: Core, Store, models, QueryFilter, Recorder, Compact, AsyncRecorder
├── db/               Postgres implementation of Store (+ Schema/EnsureSchema)
├── auditbus/         eventbus adapter — record over the in-process (or external) bus
├── auditqueue/       durable, queue-backed Recorder + Worker
├── auditrest/        read-side handler group (history / diff / changes)
└── mocks/            generated Store mock (moq)

Design: audit at the domain layer

Audit a business state change, not "an HTTP request happened". Emitting the change in the domain — not in transport middleware — means every path (REST, gRPC, background workers, queue consumers, other services) is covered by one wiring, and the audit can commit atomically with the business write. There is intentionally no HTTP middleware / gRPC interceptor.

Setup

store := auditdb.NewStore(log, db)      // db is *sqlx.DB
core  := auditlog.NewCore(log, store)

// Tests / simple boot: EnsureSchema runs the DDL under a transaction-level
// advisory lock, so concurrent replica startups don't race. In production prefer
// a migration — goose already serializes migrations with its own advisory lock.
_ = store.EnsureSchema(ctx)

The schema declares UNIQUE (model_type, model_id, version). Because Create is a read-modify-write, this makes concurrent writers safe: a racing insert at the same version fails, and Create re-reads and retries (bounded) so concurrent distinct changes are not lost.

Recording a change

All paths converge on Core.Create. Pick the entry point per call site:

Way How Delivery
Direct core.Create(ctx, auditlog.NewAuditLog{...}) caller-controlled; atomic via store.WithTx(tx)
Internal bus (decoupled) auditbus.Register(bus, rec) + auditbus.Publish(ctx, bus, ev) best-effort
External bus decode a broker delivery → auditbus.Record(ctx, core, ev) caller-controlled (ack/nack)
// Direct, atomic with the business change:
err := dbx.WithinTran(ctx, log, db, func(tx *sqlx.Tx) error {
    if err := widgetStore.WithTx(tx).Update(ctx, w); err != nil { return err }
    _, err := auditlog.NewCore(log, store.WithTx(tx)).Create(ctx, auditlog.NewAuditLog{
        ModelType: "widget", ModelID: w.ID, CreatedBy: actor(ctx), Payload: w,
    })
    return err
})

// Decoupled over the in-process bus — the producer never imports auditlog:
auditbus.Register(bus, core)
auditbus.Publish(ctx, bus, auditbus.Event{ModelType: "widget", ModelID: id, CreatedBy: actor, Payload: raw})

Recorders (sync vs async)

Producers depend on the Recorder interface (Record(ctx, NewAuditLog)), so the write can be moved off the request path without changing call sites:

Recorder Latency Durability Ordering
*Core (sync) DB write inline as durable as the tx (atomic possible) strict
auditlog.AsyncRecorder non-blocking lost on crash per-model (sharded)
auditqueue.Recorder ~1 INSERT durable (survives crash) not guaranteed across consumers
// In-process, non-blocking, per-model order preserved:
rec := auditlog.NewAsyncRecorder(core, auditlog.AsyncConfig{Workers: 4, Buffer: 512})
group.Add(rec)                  // worker.Runnable
auditbus.Register(bus, rec)     // bus consumer now records asynchronously

// Durable queue + worker:
qrec := auditqueue.NewRecorder(q, log)
group.Add(auditqueue.NewWorker(q, core, log, auditqueue.WorkerConfig{Interval: time.Second}))

Read API (handler group)

Mount the query endpoints on any skit router in one call:

auditrest.NewHandlers(core).Routes(r)              // or Routes(r, adminOnly)
Method & path Returns
GET /auditlog/{model_type}/{model_id} full version history
GET /auditlog/{model_type}/{model_id}/diff?current=&target= diff between two versions (base64=true to encode)
GET /auditlog/{model_type}/{model_id}/changes each version with its changed fields

Compaction

Thin history while always keeping the first (baseline) and last (current) version. Two API entry points — scheduling is the caller's job via the worker package (auditlog ships no built-in loop):

// 1. Opportunistic, inline on write — compacts the model just written once every
//    N versions (best-effort, gated so most writes pay nothing). Often enough on
//    its own.
core := auditlog.NewCore(log, store, auditlog.WithAutoCompact(100, opts))

// 2. On demand, in portions — from an API handler, a CLI command, or a worker.
//    Loop until res.Models == 0 to drain a large backlog gently.
res, err := core.CompactBatch(ctx, auditlog.CompactBatchOptions{
    Threshold: 100, Limit: 50, Compact: opts,
})

// single model:
deleted, err := core.Compact(ctx, "widget", id, opts)

Run CompactBatch on a schedule with the worker package:

tick := func(ctx context.Context) error {
    _, err := core.CompactBatch(ctx, auditlog.CompactBatchOptions{
        Threshold: 100, Limit: 50, Compact: opts,
    })
    return err
}
group.Add(worker.NewLoop(log.Slog(), worker.LoopConfig{
    Name: "auditlog-compaction", Interval: time.Hour,
}, tick))

CompactOptions: Factor keeps every N-th middle version; MaxVersions caps the total (even downsample); KeepRecent always retains the newest N; MinVersions skips small histories. First and last are never removed.

Limitations

  • The field-level changes diff compares scalar fields only; array changes and removed keys are not reported. The textual diff (go-cmp) covers everything.
  • History is returned unpaginated.
  • auditqueue does not guarantee per-model ordering across consumers — use a single consumer or AsyncRecorder when strict version ordering matters.

Documentation

Overview

Package auditlog records a versioned audit trail of model changes and answers history and diff queries over it.

Each change is stored as a snapshot keyed by (ModelType, ModelID); snapshots for the same model form an ordered history with a monotonically increasing Version. Create is idempotent against no-op writes: if the new snapshot is identical to the latest one (ignoring updated_at/updated_by) no new version is stored. The Core depends only on the Store interface declared here; the Postgres implementation lives in the db subpackage.

Recording changes

Audit at the domain layer, not the transport: emit a change where it happens, so every path (REST, gRPC, workers, consumers) is covered by one wiring. Two equivalent entry points, both converging on Core.Create:

// Direct (atomic with the business write via store.WithTx, or best-effort):
_, err := core.Create(ctx, auditlog.NewAuditLog{
    ModelType: "widget", ModelID: id, CreatedBy: actor, Payload: snapshot,
})

// Decoupled, over the in-process eventbus (producer doesn't import auditlog):
auditbus.Register(bus, core)                 // once at startup
auditbus.Publish(ctx, bus, auditbus.Event{ModelType: "widget", ModelID: id, ...})

Recorders

Transports/producers depend on the Recorder interface (best-effort Record), so recording can be synchronous or asynchronous without changing call sites:

  • *Core — synchronous; the only option that can be atomic (WithTx).
  • AsyncRecorder — in-process workers, sharded by model (per-model order), non-blocking; lost on crash.
  • auditqueue.Recorder — durable queue + Worker; survives crashes, smooths spikes.

Querying

last, err := core.QueryLastByModelID(ctx, "widget", id)
hist, err := core.QueryHistoryByModelID(ctx, "widget", id)
diff, err := core.QueryDiffByModelID(ctx, "widget", id, filter) // between two versions
recs, err := core.QueryDiffAllVersionByModelID(ctx, "widget", id, filter)

The auditrest subpackage exposes a ready-made read-side handler group (history / diff / changes) mountable on any skit router in one call:

auditrest.NewHandlers(core).Routes(r.HandleApp)

Compaction

Compact thins a model's history while always keeping the first and last versions. Two API entry points; scheduling is left to the caller's worker.

  • Opportunistic, inline on write: WithAutoCompact makes Create compact the model it just wrote, once every N versions (best-effort, gated so most writes pay nothing) — often enough to keep history bounded on its own.

  • On demand, in portions: CompactBatch compacts up to Limit models over a version threshold. Call it from an API handler, a CLI command, or a worker; loop until Result.Models is 0 to drain a backlog gently.

    core := auditlog.NewCore(log, store, auditlog.WithAutoCompact(100, opts)) // inline res, err := core.CompactBatch(ctx, auditlog.CompactBatchOptions{Threshold: 100, Limit: 50, Compact: opts})

Scheduled compaction is wired with the worker package — auditlog ships no built-in loop:

tick := func(ctx context.Context) error {
    _, err := core.CompactBatch(ctx, auditlog.CompactBatchOptions{Threshold: 100, Limit: 50, Compact: opts})
    return err
}
group.Add(worker.NewLoop(log.Slog(), worker.LoopConfig{Name: "auditlog-compaction", Interval: time.Hour}, tick))

Concurrency

Create is a read-modify-write (read latest version, insert version+1). The db schema declares UNIQUE (model_type, model_id, version): a racing insert at the same version fails with a duplicate-key error, and Create re-reads and retries (bounded) so concurrent distinct changes are not lost.

Index

Constants

This section is empty.

Variables

View Source
var ErrInvalidFilter = errors.New("invalid audit log query filter")

ErrInvalidFilter is returned when a query filter is missing required fields (e.g. a diff request without both version numbers).

View Source
var ErrNotFound = errors.New("audit log not found")

ErrNotFound is returned when no audit log entry matches the query.

Functions

This section is empty.

Types

type AsyncConfig

type AsyncConfig struct {
	// Workers is the number of background workers (and shards). Entries for the
	// same model always go to the same shard, so per-model order is preserved.
	// Defaults to 4.
	Workers int
	// Buffer is the capacity of each shard's queue. Defaults to 256.
	Buffer int
	// BlockOnFull makes Record block until there is room instead of dropping.
	// Default (false) drops the entry and calls OnDrop, so the request path is
	// never slowed by a saturated audit pipeline.
	BlockOnFull bool
	// OnDrop is invoked with an entry dropped because its shard was full (when
	// BlockOnFull is false). Use it for a metric/log; may be nil.
	OnDrop func(NewAuditLog)
}

AsyncConfig configures an AsyncRecorder.

type AsyncRecorder

type AsyncRecorder struct {
	// contains filtered or unexported fields
}

AsyncRecorder records audit entries on background workers so the request path is not slowed by the database write. It implements Recorder (non-blocking Record) and worker.Runnable (Start/Stop/Name): supervise it in a worker.Group.

Entries are sharded by (ModelType, ModelID) onto a fixed worker, so writes for the same model stay ordered — important because Core.Create derives the next version from the latest stored one. On shutdown, Start drains the buffered entries before returning.

func NewAsyncRecorder

func NewAsyncRecorder(core *Core, cfg AsyncConfig) *AsyncRecorder

NewAsyncRecorder builds an AsyncRecorder over core. It does not write anything until Start is called; Record before Start (or after Stop) drops.

func (*AsyncRecorder) Name

func (a *AsyncRecorder) Name() string

Name identifies the recorder in the supervisor and logs.

func (*AsyncRecorder) Record

func (a *AsyncRecorder) Record(ctx context.Context, entry NewAuditLog)

Record enqueues entry onto its model's shard and returns immediately. The request context is detached (WithoutCancel) so the background write is not canceled when the request ends, while trace/values are preserved for logging. When the shard is full it blocks or drops per AsyncConfig.

func (*AsyncRecorder) Start

func (a *AsyncRecorder) Start(ctx context.Context) error

Start runs the workers until ctx is canceled, then drains the buffered entries and returns. It implements worker.Runnable.

func (*AsyncRecorder) Stop

Stop is a no-op: Start exits and drains on ctx cancellation.

type AuditLog

type AuditLog struct {
	ID        int
	Version   int
	ModelType string
	ModelID   string
	Method    string
	Path      string
	Payload   []byte
	CreatedAt time.Time
	CreatedBy string
}

AuditLog is a single, versioned snapshot of a model recorded in the audit trail. Entries for the same (ModelType, ModelID) form an ordered history; Version increments by one per stored change. Payload is the JSON-encoded snapshot of the model at that version. CreatedBy is the actor (e.g. the JWT subject) that made the change.

type CompactBatchOptions

type CompactBatchOptions struct {
	// Threshold: only models with more than this many stored versions are
	// candidates.
	Threshold int
	// Limit caps how many models this call processes — the "portion". Default 100.
	Limit int
	// Compact options applied to each model.
	Compact CompactOptions
}

CompactBatchOptions configures one batch of compaction across many models.

type CompactOptions

type CompactOptions struct {
	// Factor keeps roughly every Factor-th middle version (Factor >= 2). When
	// MaxVersions is also set, the cap takes precedence. Factor < 2 disables
	// factor thinning (only MaxVersions, if set, thins).
	Factor int
	// KeepRecent always retains this many most-recent versions untouched.
	KeepRecent int
	// MaxVersions, when > 0, caps the total kept versions: the middle is
	// downsampled evenly so no more than MaxVersions remain.
	MaxVersions int
	// MinVersions skips compaction entirely when a model has this many versions or
	// fewer. Defaults to 2 (nothing below first+last is ever thinned).
	MinVersions int
}

CompactOptions controls how a model's version history is thinned. The first (baseline) and last (current) versions are always kept, so diffs and the current snapshot remain available.

type CompactResult

type CompactResult struct {
	Models  int // models that had versions removed
	Deleted int // total versions removed
}

CompactResult reports what a CompactBatch did.

type Core

type Core struct {
	// contains filtered or unexported fields
}

Core is the audit-log business logic: it records versioned model snapshots and answers history/diff queries. It depends only on the Store interface declared here; the Postgres implementation lives in the db subpackage.

func NewCore

func NewCore(log *logger.Logger, store Store, opts ...Option) *Core

NewCore constructs a Core over the given store. log may be nil.

func (*Core) Compact

func (c *Core) Compact(ctx context.Context, modelType, modelID string, opts CompactOptions) (int, error)

Compact thins the stored version history of one model per opts and returns how many versions were deleted. The first and last versions are never removed.

func (*Core) CompactBatch

func (c *Core) CompactBatch(ctx context.Context, opts CompactBatchOptions) (CompactResult, error)

CompactBatch compacts one portion of the models whose version count exceeds the threshold (most-versioned first), up to Limit models. It is the single reusable entry point for scheduled (NewSweeper), command-line, or API-triggered runs — call it repeatedly until Result.Models is 0 to drain a large backlog gently.

func (*Core) Create

func (c *Core) Create(ctx context.Context, in NewAuditLog) (AuditLog, error)

Create records a new version of a model. When the new snapshot is identical to the latest stored one (ignoring the normalized-away updated_at/updated_by fields) no new version is written and a zero AuditLog is returned. The returned AuditLog has Version == 0 exactly when nothing was stored.

Create is a read-modify-write (read latest version, insert version+1). If a concurrent writer inserts the same version first, the UNIQUE constraint rejects this insert; Create then re-reads the latest version and retries, so concurrent distinct changes are not lost (up to maxCreateAttempts).

func (*Core) NormalizeDiff

func (c *Core) NormalizeDiff(diff string) string

NormalizeDiff save line only contains the + sing and minus sign in the begiiing of the lines all other lines are removed

func (*Core) QueryDiffAllVersionByModelID

func (c *Core) QueryDiffAllVersionByModelID(ctx context.Context, modelType, modelID string, filter QueryFilter) ([]DiffRecord, error)

QueryDiffAllVersionByModelID finds the diffs between all versions of the model

func (*Core) QueryDiffByModelID

func (c *Core) QueryDiffByModelID(ctx context.Context, modelType, modelID string, filter QueryFilter) (string, error)

QueryDiffByModelID returns a textual diff between two versions of a model. Both filter.CurrentVersion and filter.TargetVersion are required.

func (*Core) QueryHistoryByModelID

func (c *Core) QueryHistoryByModelID(ctx context.Context, modelType, modelID string) ([]AuditLog, error)

QueryHistoryByModelID returns every version of a model in ascending order.

func (*Core) QueryLastByModelID

func (c *Core) QueryLastByModelID(ctx context.Context, modelType, modelID string) (AuditLog, error)

QueryLastByModelID returns the most recent entry for a model, or ErrNotFound.

func (*Core) Record

func (c *Core) Record(ctx context.Context, entry NewAuditLog)

Record calls Create and logs any error instead of returning it, satisfying Recorder. The transport adapters use it so an audit failure never affects the response the user already received.

type DiffRecord

type DiffRecord struct {
	ID            int
	ModelID       string
	ModelType     string
	Method        string
	Path          string
	Version       int
	UpdatedBy     *string
	UpdatedAt     *time.Time
	ChangedFields []Field
}

DiffRecord is one version in a model's history together with the fields that changed relative to the previous version. It is returned by Core.QueryDiffAllVersionByModelID.

type Field

type Field struct {
	Key      string
	OldValue any
	NewValue any
}

Field is a single changed attribute between two versions.

type ModelRef

type ModelRef struct {
	ModelType string
	ModelID   string
	Versions  int
}

ModelRef identifies a model and how many versions it has stored.

type NewAuditLog

type NewAuditLog struct {
	ModelID   string
	ModelType string
	Method    string
	Path      string
	Payload   any
	CreatedBy string
}

NewAuditLog is the input to Core.Create. Payload is any value that JSON-encodes to the model snapshot; the version and CreatedAt are assigned by Create (last version + 1, or skipped when unchanged). Method/Path label the transport action (HTTP verb + path, or the gRPC full method).

type Option

type Option func(*Core)

Option customizes a Core.

func WithAutoCompact

func WithAutoCompact(every int, opts CompactOptions) Option

WithAutoCompact enables opportunistic compaction: after Create stores a new version, once every `every` versions it compacts that model with opts (best-effort — a failure is logged, never returned). Set every to roughly the threshold at which history should be thinned. The separate Compact/CompactBatch remain available for on-demand or scheduled runs.

type QueryFilter

type QueryFilter struct {
	CurrentVersion *int `validate:"omitempty,numeric,min=1"`
	TargetVersion  *int `validate:"omitempty,numeric,min=1"`
}

QueryFilter selects which versions a diff query compares. Both versions are required for QueryDiffByModelID.

func (*QueryFilter) Validate

func (qf *QueryFilter) Validate() error

Validate reports whether the filter is well-formed, returning an errs.InvalidArgument error with per-field detail when not.

func (*QueryFilter) WithCurrentVersion

func (qf *QueryFilter) WithCurrentVersion(ver int)

WithCurrentVersion sets the current-version field.

func (*QueryFilter) WithTargetVersion

func (qf *QueryFilter) WithTargetVersion(ver int)

WithTargetVersion sets the target-version field.

type Recorder

type Recorder interface {
	Record(ctx context.Context, entry NewAuditLog)
}

Recorder records an audit entry best-effort (errors are handled internally, not returned). Transports (auditrest, auditgrpc, auditbus) depend on this interface so the recording can be synchronous (*Core) or asynchronous (AsyncRecorder, or a queue-backed recorder) without changing the call sites.

type Store

type Store interface {
	WithTx(tx sqlx.ExtContext) Store
	Save(ctx context.Context, al AuditLog) error
	QueryLastByModelID(ctx context.Context, modelType, modelID string) (AuditLog, error)
	QueryHistoryByModelID(ctx context.Context, modelType, modelID string) ([]AuditLog, error)
	QueryModelByVersion(ctx context.Context, modelType, modelID string, ver int) (AuditLog, error)

	// Versions returns the stored version numbers for a model, ascending. Used by
	// Compact to decide what to thin.
	Versions(ctx context.Context, modelType, modelID string) ([]int, error)
	// DeleteVersions removes the given versions of a model and returns the count
	// deleted. Used by Compact.
	DeleteVersions(ctx context.Context, modelType, modelID string, versions []int) (int, error)
	// OverThreshold lists models whose stored-version count exceeds threshold, up
	// to limit. Used by the background Sweeper to find compaction candidates.
	OverThreshold(ctx context.Context, threshold, limit int) ([]ModelRef, error)
}

Store is the persistence contract for audit log entries. WithTx returns a sibling bound to a transaction, so callers that need the audit write to commit atomically with their business change can run Create on a tx-bound Core.

Directories

Path Synopsis
Package auditbus records audit entries from events on the in-process eventbus, decoupling producers from the audit core: a producer publishes an Event on the bus and Register subscribes the audit core to consume it, so a domain package can request an audit write without importing auditlog.
Package auditbus records audit entries from events on the in-process eventbus, decoupling producers from the audit core: a producer publishes an Event on the bus and Register subscribes the audit core to consume it, so a domain package can request an audit write without importing auditlog.
Package auditqueue is a durable, queue-backed auditlog.Recorder: Record enqueues the audit entry onto a skit queue (one cheap INSERT on the request path) and a background Worker drains it into the audit core.
Package auditqueue is a durable, queue-backed auditlog.Recorder: Record enqueues the audit entry onto a skit queue (one cheap INSERT on the request path) and a background Worker drains it into the audit core.
Package auditrest is the REST integration for auditlog: a read-side handler group (history / diff / changes) mountable on any skit router in one call.
Package auditrest is the REST integration for auditlog: a read-side handler group (history / diff / changes) mountable on any skit router in one call.
Package db is the Postgres implementation of auditlog.Store.
Package db is the Postgres implementation of auditlog.Store.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL