Documentation
¶
Overview ¶
Package activitylog records model change history, audit trails and actor tracking, written in the same database transaction as the change it describes.
An Activity says that something happened (an Event, a Description, or both) to a Subject, caused by a Causer, inside a tenant, optionally as part of a batch of related activities, with free-form Properties and the field-level Changes of the subject.
Writing ¶
A Logger fills an Entry from the context and hands the resulting Activity to a Store:
- WithCauser, WithTenant and WithProperties attach the actor, the tenant and request-scoped detail (source, client IP, request ID) once, in middleware, so domain code only names what happened.
- WithBatch groups the activities of one operation; nested operations join the outer batch.
- WithoutLogging silences the logger for seeding and imports.
LogChange (and its shorthands Created, Updated and Deleted) computes the field changes with Diff, which tracks only struct fields tagged `activity:"..."`, and can record that a secret changed without recording its values (`activity:"password,redact"`).
Storing ¶
The root package defines the Store, Reader and Pruner interfaces and imports no database driver. The pgxstore subpackage stores activities in PostgreSQL and joins the caller's transaction, so an activity commits or rolls back with the change it describes. The memstore subpackage keeps them in memory for tests and examples, and storetest is the conformance suite both pass.
Reading ¶
A Query selects activities for a tenant feed (TenantID), a record's history (Subject), an actor's trail (Causer) or a batch (BatchID), newest first, paginated with a keyset cursor (BeforeID).
Example ¶
package main
import (
"context"
"fmt"
"time"
activitylog "github.com/byteminus/go-activitylog"
"github.com/byteminus/go-activitylog/memstore"
)
// Project is a model whose changes are tracked. Untagged fields are not.
type Project struct {
ID string `activity:"-"`
Name string `activity:"name"`
Status string `activity:"status"`
APIToken string `activity:"api_token,redact"`
Views int
}
// ActivityRef lets LogChange fill the subject.
func (p *Project) ActivityRef() activitylog.Ref {
return activitylog.Ref{Type: "project", ID: p.ID}
}
func main() {
store := memstore.New()
logger, err := activitylog.New(store, activitylog.Options{
Now: func() time.Time { return time.Date(2026, 10, 23, 9, 30, 0, 0, time.UTC) },
})
if err != nil {
panic(err)
}
// Middleware sets these once per request.
ctx := activitylog.WithCauser(context.Background(), activitylog.Ref{Type: "user", ID: "42"})
ctx = activitylog.WithTenant(ctx, "acme")
ctx = activitylog.WithProperties(ctx, map[string]any{"source": "admin", "request_id": "req-7"})
before := &Project{ID: "p1", Name: "Apollo", Status: "draft", APIToken: "t1"}
after := *before
after.Status = "active"
after.APIToken = "t2"
after.Views = 10
if err := logger.Updated(ctx, activitylog.Entry{}, before, &after); err != nil {
panic(err)
}
history, err := store.List(ctx, activitylog.Query{Subject: activitylog.Ref{Type: "project", ID: "p1"}})
if err != nil {
panic(err)
}
a := history[0]
fmt.Println(a.Event, a.Subject.Type, a.Subject.ID, "by", a.Causer.Type, a.Causer.ID, "in", a.TenantID)
fmt.Println("status:", string(a.Changes["status"].Old), "->", string(a.Changes["status"].New))
fmt.Println("api_token redacted:", a.Changes["api_token"].Redacted)
fmt.Println("properties:", string(a.Properties))
fmt.Println("at:", a.CreatedAt.Format(time.RFC3339))
}
Output: updated project p1 by user 42 in acme status: "draft" -> "active" api_token redacted: true properties: {"request_id":"req-7","source":"admin"} at: 2026-10-23T09:30:00Z
Index ¶
- Constants
- Variables
- func BatchFrom(ctx context.Context) string
- func LoggingDisabled(ctx context.Context) bool
- func PropertiesFrom(ctx context.Context) map[string]any
- func TenantFrom(ctx context.Context) string
- func WithBatch(ctx context.Context) context.Context
- func WithCauser(ctx context.Context, causer Ref) context.Context
- func WithProperties(ctx context.Context, props map[string]any) context.Context
- func WithTenant(ctx context.Context, tenantID string) context.Context
- func WithoutLogging(ctx context.Context) context.Context
- type Activity
- type Change
- type Changes
- type Entry
- type Logger
- func (l *Logger) Created(ctx context.Context, e Entry, model any) error
- func (l *Logger) Deleted(ctx context.Context, e Entry, model any) error
- func (l *Logger) Log(ctx context.Context, e Entry) error
- func (l *Logger) LogChange(ctx context.Context, e Entry, before, after any) error
- func (l *Logger) Updated(ctx context.Context, e Entry, before, after any) error
- type Options
- type Pruner
- type Query
- type Reader
- type Ref
- type Store
- type Subject
Examples ¶
Constants ¶
const ( EventCreated = "created" EventUpdated = "updated" EventDeleted = "deleted" )
Events written by LogChange and its shorthands when the entry does not name one.
const ( DefaultLimit = 50 MaxLimit = 1000 )
Limits on the number of activities one List call returns.
const DefaultLogName = "default"
DefaultLogName is the log an activity is written to when neither the entry nor the Logger names one.
Variables ¶
var ( // ErrEmptyEntry is returned by Logger.Log when an entry has neither an // Event nor a Description. ErrEmptyEntry = errors.New("activitylog: entry needs an event or a description") // ErrNoSubject is returned by LogChange when the entry names no subject // and the model does not implement Subject. ErrNoSubject = errors.New("activitylog: cannot determine the subject") // ErrInvalidDiff is returned by Diff for models it cannot compare: bad // activity tags, mismatched types, non-struct values, or two nil sides. ErrInvalidDiff = errors.New("activitylog: invalid diff") // ErrInvalidActivity is returned by Activity.Validate, and by stores // that call it, for an activity that would break the log's integrity. ErrInvalidActivity = errors.New("activitylog: invalid activity") // ErrInvalidQuery is returned by Query.Normalize, and by stores that // call it, for a query no store accepts. ErrInvalidQuery = errors.New("activitylog: invalid query") // ErrNilStore is returned by New when it is given no store. ErrNilStore = errors.New("activitylog: store is nil") )
Functions ¶
func LoggingDisabled ¶
LoggingDisabled reports whether ctx was derived from WithoutLogging.
func PropertiesFrom ¶
PropertiesFrom returns a copy of the properties carried by ctx, or nil when there are none.
func TenantFrom ¶
TenantFrom returns the tenant carried by ctx, or "" when there is none.
func WithBatch ¶
WithBatch returns a context whose activities share a new batch ID (a UUID v7), so the activities of one operation can be read back together. A context that is already in a batch is returned unchanged: nested operations join the outer batch instead of splitting it.
Example ¶
package main
import (
"context"
"fmt"
activitylog "github.com/byteminus/go-activitylog"
"github.com/byteminus/go-activitylog/memstore"
)
func main() {
store := memstore.New()
logger, err := activitylog.New(store, activitylog.Options{})
if err != nil {
panic(err)
}
ctx := activitylog.WithBatch(context.Background())
// A nested operation joins the outer batch.
inner := activitylog.WithBatch(ctx)
for _, c := range []context.Context{ctx, inner} {
if err := logger.Log(c, activitylog.Entry{Event: "imported"}); err != nil {
panic(err)
}
}
batch, err := store.List(ctx, activitylog.Query{BatchID: activitylog.BatchFrom(ctx)})
if err != nil {
panic(err)
}
fmt.Println(len(batch), "activities in the batch")
}
Output: 2 activities in the batch
func WithCauser ¶
WithCauser returns a context whose activities are attributed to causer, typically the authenticated user, set once by the adapter that authenticated the request.
func WithProperties ¶
WithProperties returns a context that adds props to every activity logged with it, such as the request ID, source and client IP. Properties already in ctx are kept unless props redefines them; the caller's map is copied, so later changes to it have no effect. Entry properties win over context ones.
func WithTenant ¶
WithTenant returns a context whose activities belong to tenantID.
func WithoutLogging ¶
WithoutLogging returns a context in which every Logger call succeeds without writing anything, for seeding, imports and migrations whose rows should not appear as user activity.
Example ¶
package main
import (
"context"
"fmt"
activitylog "github.com/byteminus/go-activitylog"
"github.com/byteminus/go-activitylog/memstore"
)
// Project is a model whose changes are tracked. Untagged fields are not.
type Project struct {
ID string `activity:"-"`
Name string `activity:"name"`
Status string `activity:"status"`
APIToken string `activity:"api_token,redact"`
Views int
}
// ActivityRef lets LogChange fill the subject.
func (p *Project) ActivityRef() activitylog.Ref {
return activitylog.Ref{Type: "project", ID: p.ID}
}
func main() {
store := memstore.New()
logger, err := activitylog.New(store, activitylog.Options{})
if err != nil {
panic(err)
}
ctx := activitylog.WithoutLogging(context.Background())
if err := logger.Created(ctx, activitylog.Entry{}, &Project{ID: "seed"}); err != nil {
panic(err)
}
all, err := store.List(context.Background(), activitylog.Query{})
if err != nil {
panic(err)
}
fmt.Println(len(all), "activities")
}
Output: 0 activities
Types ¶
type Activity ¶
type Activity struct {
ID int64 `json:"id"`
LogName string `json:"log_name"`
Event string `json:"event"`
Description string `json:"description"`
Subject Ref `json:"subject"`
Causer Ref `json:"causer"`
TenantID string `json:"tenant_id"`
BatchID string `json:"batch_id"`
// Properties is a JSON object; stores return "{}" when there are none.
Properties json.RawMessage `json:"properties"`
// Changes is nil when the activity records no field changes.
Changes Changes `json:"changes,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
Activity is one stored entry of the log.
func (Activity) Clone ¶
Clone returns a deep copy of a; stores use it so activities they hold share no memory with their callers.
func (Activity) Validate ¶
Validate reports whether a can be stored. Every store calls it from Insert, so all stores accept and reject the same activities; the PostgreSQL schema enforces the same rules with constraints.
It requires a log name, an event or a description, a type on any Ref that has an ID, Properties that are empty or a JSON object, and changes whose values are valid JSON. A redacted change must carry no values.
type Change ¶
type Change struct {
Old json.RawMessage `json:"old,omitempty"`
New json.RawMessage `json:"new,omitempty"`
Redacted bool `json:"redacted,omitempty"`
}
Change is the before and after value of one tracked field, each as JSON. Old is absent for a creation and New for a deletion. A redacted change records that the field changed and carries no values.
type Changes ¶
Changes maps tracked field names to their change.
func Diff ¶
Diff compares two values of the same struct type, or pointers to it, and returns the tracked fields that differ, with their values JSON-encoded at the time of the call. It returns nil when nothing changed.
A nil before (or nil pointer) describes a creation and a nil after a deletion; in both cases every tracked field is reported, with only New or only Old set.
Tracking is opt-in through the activity struct tag:
Email string `activity:"email"` // tracked as "email" Name string `activity:""` // tracked under its Go name, "Name" Password string `activity:"password,redact"` // change recorded, values never Cache []byte `activity:"-"` // never tracked
Untagged fields are ignored, except untagged embedded structs held by value, whose fields are walked as if declared in the outer struct. Embedded pointers are not followed, so a shared or cyclic value never leaks into the log.
Field values are compared with their Equal method when the type has one with the signature Equal(T) bool (as time.Time does), through one level of pointer or interface, and with reflect.DeepEqual otherwise.
Diff returns an error wrapping ErrInvalidDiff for a tagged unexported field, an unknown tag option, two fields with the same tracked name, values of different types, non-struct values, and two nil sides.
Example ¶
package main
import (
"fmt"
activitylog "github.com/byteminus/go-activitylog"
)
func main() {
type Account struct {
Email string `activity:"email"`
Password string `activity:"password,redact"`
Plan string `activity:""`
}
changes, err := activitylog.Diff(
Account{Email: "ann@example.com", Password: "a", Plan: "free"},
Account{Email: "ann@example.org", Password: "b", Plan: "free"},
)
if err != nil {
panic(err)
}
fmt.Println(string(changes["email"].Old), "->", string(changes["email"].New))
fmt.Println("password redacted:", changes["password"].Redacted)
_, planChanged := changes["Plan"]
fmt.Println("plan changed:", planChanged)
}
Output: "ann@example.com" -> "ann@example.org" password redacted: true plan changed: false
type Entry ¶
type Entry struct {
LogName string
Event string
Description string
Subject Ref
Causer Ref
TenantID string
// Properties must be JSON-encodable. Its keys override properties of
// the same name carried by the context.
Properties map[string]any
Changes Changes
}
Entry describes an activity to log. Zero fields are filled from the Logger options and the context; see Logger.Log.
type Logger ¶
type Logger struct {
// contains filtered or unexported fields
}
Logger writes activities to a Store. It is safe for concurrent use when its store is.
func (*Logger) Log ¶
Log records e. It is a no-op in a context derived from WithoutLogging.
Zero fields of e are filled in: the log name from the Logger options, the causer and tenant from the context; the batch ID always comes from the context. Context properties are merged under the entry's, and the result is JSON-encoded now, so later changes to the values are not recorded and encoding errors surface here rather than in the store.
Log returns ErrEmptyEntry when e has neither an Event nor a Description, and an error wrapping ErrInvalidActivity when the result would be invalid (see Activity.Validate). Changes marked Redacted are stored without values even if the entry supplied some.
func (*Logger) LogChange ¶
LogChange records the change of a model from before to after, with the field changes computed by Diff. A nil before (or nil pointer) is a creation and a nil after a deletion.
When e names no Event it is set from the transition (EventCreated, EventUpdated or EventDeleted), and when it names no Subject it is taken from the model's ActivityRef method (after's, or before's for a deletion); ErrNoSubject is returned when that is not possible or the ref has no ID. Changes in e are kept for fields Diff did not report, for values that are not struct fields.
An update in which no tracked field changed and e carries no changes is skipped, unless Options.LogEmptyUpdates is set. Creations and deletions are always logged.
type Options ¶
type Options struct {
// LogName is used for entries that name no log; it defaults to
// DefaultLogName.
LogName string
// LogEmptyUpdates makes LogChange record updates in which no tracked
// field changed. By default they are skipped, so saving an unchanged
// form does not fill the log with noise.
LogEmptyUpdates bool
// Enrich, when set, is called with every activity just before it is
// stored, to add or rewrite details. It must leave the activity valid.
Enrich func(ctx context.Context, a *Activity)
// Now is the clock; it defaults to time.Now.
Now func() time.Time
}
Options configure a Logger. The zero value is ready to use.
type Pruner ¶
type Pruner interface {
// Prune deletes every activity created strictly before the given time
// and returns how many it deleted.
Prune(ctx context.Context, before time.Time) (int64, error)
}
Pruner deletes old activities, for retention policies.
type Query ¶
type Query struct {
LogName string
Event string
// Subject with only a Type selects every subject of that type, which
// is how a feed of, say, all invoices is read.
Subject Ref
Causer Ref
TenantID string
BatchID string
// Since is inclusive and Until exclusive, on CreatedAt.
Since time.Time
Until time.Time
// BeforeID is a keyset cursor: pass the ID of the last activity of a
// page to read the next, older, page. Unlike an offset it stays stable
// while new activities are written.
BeforeID int64
// Limit caps the result; 0 means DefaultLimit.
Limit int
}
Query selects activities. Zero fields match everything; set fields are combined with AND.
Example (Pagination) ¶
package main
import (
"context"
"fmt"
"strings"
activitylog "github.com/byteminus/go-activitylog"
"github.com/byteminus/go-activitylog/memstore"
)
func main() {
store := memstore.New()
logger, err := activitylog.New(store, activitylog.Options{})
if err != nil {
panic(err)
}
ctx := activitylog.WithTenant(context.Background(), "acme")
for i := range 5 {
if err := logger.Log(ctx, activitylog.Entry{Event: fmt.Sprint("event-", i)}); err != nil {
panic(err)
}
}
q := activitylog.Query{TenantID: "acme", Limit: 2}
for {
page, err := store.List(ctx, q)
if err != nil {
panic(err)
}
events := make([]string, len(page))
for i, a := range page {
events[i] = a.Event
}
fmt.Println(strings.Join(events, " "))
if len(page) < q.Limit {
break
}
q.BeforeID = page[len(page)-1].ID
}
}
Output: event-4 event-3 event-2 event-1 event-0
type Reader ¶
type Reader interface {
// List returns the activities matching q, newest (highest ID) first.
// It rejects queries that Query.Normalize rejects.
List(ctx context.Context, q Query) ([]Activity, error)
}
Reader reads activities back.
type Ref ¶
Ref identifies a record by type and ID, such as a subject that was changed or the causer that changed it.
func CauserFrom ¶
CauserFrom returns the causer carried by ctx, or the zero Ref when there is none, which stands for the system.
type Store ¶
type Store interface {
// Insert stores a after checking it with Activity.Validate. It joins
// the transaction carried by ctx when the implementation supports one,
// so the activity commits or rolls back with the change it describes.
// The store assigns the ID (a.ID is ignored), treats nil Properties as
// "{}", stamps a zero CreatedAt with the current time, and keeps
// CreatedAt at microsecond precision in UTC.
Insert(ctx context.Context, a Activity) error
}
Store persists activities. Implementations must be safe for concurrent use.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package memstore is an in-memory activitylog store for tests and examples.
|
Package memstore is an in-memory activitylog store for tests and examples. |
|
Package pgxstore stores activitylog activities in PostgreSQL through pgx.
|
Package pgxstore stores activitylog activities in PostgreSQL through pgx. |
|
Package storetest is a conformance suite for activitylog stores.
|
Package storetest is a conformance suite for activitylog stores. |