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 ¶
- Variables
- type AsyncConfig
- type AsyncRecorder
- type AuditLog
- type CompactBatchOptions
- type CompactOptions
- type CompactResult
- type Core
- func (c *Core) Compact(ctx context.Context, modelType, modelID string, opts CompactOptions) (int, error)
- func (c *Core) CompactBatch(ctx context.Context, opts CompactBatchOptions) (CompactResult, error)
- func (c *Core) Create(ctx context.Context, in NewAuditLog) (AuditLog, error)
- func (c *Core) NormalizeDiff(diff string) string
- func (c *Core) QueryDiffAllVersionByModelID(ctx context.Context, modelType, modelID string, filter QueryFilter) ([]DiffRecord, error)
- func (c *Core) QueryDiffByModelID(ctx context.Context, modelType, modelID string, filter QueryFilter) (string, error)
- func (c *Core) QueryHistoryByModelID(ctx context.Context, modelType, modelID string) ([]AuditLog, error)
- func (c *Core) QueryLastByModelID(ctx context.Context, modelType, modelID string) (AuditLog, error)
- func (c *Core) Record(ctx context.Context, entry NewAuditLog)
- type DiffRecord
- type Field
- type ModelRef
- type NewAuditLog
- type Option
- type QueryFilter
- type Recorder
- type Store
Constants ¶
This section is empty.
Variables ¶
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).
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.
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 (*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 ¶
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 ¶
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 ¶
QueryLastByModelID returns the most recent entry for a model, or ErrNotFound.
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 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.
Source Files
¶
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. |