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, done by an Actor, inside a tenant, optionally as part of a batch of related activities, with free-form Properties and the field-level Changes of the subject. Subjects and actors are Refs: a type and an ID, so "user 42" and "api_token 42" stay apart.
Writing ¶
A Logger fills an Entry from the context and hands the resulting Activity to a Store:
- WithActor, 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.
- Options.ActorFrom, TenantFrom, BatchFrom, PropertiesFrom and LoggingDisabled replace those lookups, so an application that already keeps its user and tenant in the context wires them in once.
- 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 records that a secret changed without recording its values (`activity:"password,redact"`). Redaction holds at any depth: a tracked value that contains a redacted field anywhere is redacted whole, and a property holding one is refused.
Storing ¶
The root package defines the Store, Reader and Pruner interfaces and imports no database driver. The log is append-only: no store offers a way to change or delete one entry, and Prune, which deletes by age, is the only deletion. 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 (Actor), an event or a batch, newest first. A Reader returns a Page whose Next is the keyset cursor (Query.BeforeID) of the following page, so pages stay stable while activities are written.
Errors ¶
Every validation error wraps ErrInvalid, with a more specific error (ErrInvalidActivity, ErrInvalidQuery, ErrInvalidDiff, ...) in between, so an application maps them all to one response with errors.Is.
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.WithActor(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.Activities[0]
fmt.Println(a.Event, a.Subject, "by", a.Actor, "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, bool)
- func LoggingDisabled(ctx context.Context) bool
- func PropertiesFrom(ctx context.Context) (map[string]any, bool)
- func TenantFrom(ctx context.Context) (string, bool)
- func WithActor(ctx context.Context, actor Ref) context.Context
- func WithBatch(ctx context.Context) 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 Page
- 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 ( // DefaultPageSize is the page size of a query with no Limit. DefaultPageSize = 50 // MaxPageSize caps Query.Limit; larger limits are lowered to it. MaxPageSize = 1000 )
Page sizes of Reader.List.
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 = invalidError("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, or its Ref has no ID. ErrNoSubject = invalidError("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 = invalidError("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 = invalidError("activitylog: invalid activity") // ErrInvalidQuery is returned by Query.Normalize, and by stores that // call it, for a query no store accepts. ErrInvalidQuery = invalidError("activitylog: invalid query") // ErrNilStore is returned by New when it is given no store. ErrNilStore = invalidError("activitylog: store is nil") )
The errors below are more specific; each wraps ErrInvalid.
var ErrInvalid = errors.New("activitylog: invalid input")
ErrInvalid is the family-wide sentinel for input that failed validation. Every validation error of this module wraps it, so an application can map all of them to one response (HTTP 400 or 422) with errors.Is.
Functions ¶
func BatchFrom ¶
BatchFrom returns the batch ID stored by WithBatch. It returns false when ctx is in no batch.
func LoggingDisabled ¶
LoggingDisabled reports whether ctx was derived from WithoutLogging.
func PropertiesFrom ¶
PropertiesFrom returns a copy of the properties stored by WithProperties. It returns false when ctx carries none.
func TenantFrom ¶
TenantFrom returns the tenant ID stored by WithTenant. It returns false when ctx carries none.
func WithActor ¶ added in v0.2.0
WithActor returns a context whose activities are attributed to actor, typically the authenticated user, set once by the adapter that authenticated the request. A zero actor stands for the system.
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)
}
}
id, _ := activitylog.BatchFrom(ctx)
batch, err := store.List(ctx, activitylog.Query{BatchID: id})
if err != nil {
panic(err)
}
fmt.Println(len(batch.Activities), "activities in the batch")
}
Output: 2 activities in the batch
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. An empty props returns ctx unchanged.
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), "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"`
Actor Ref `json:"actor"`
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 structural rules with constraints.
It requires a log name; an event or a description; Refs that are zero or complete; Properties that are empty or a JSON object; and changes with non-empty names whose values are valid JSON, with no values on a redacted change; and a CreatedAt that is zero or within years 1 to 9999. Text must be valid UTF-8 without NUL bytes, and JSON must be storable in PostgreSQL jsonb (no \u0000 or unpaired surrogate escapes, and numbers within the numeric range), so a value accepted by one store is accepted by all. Errors wrap ErrInvalidActivity and name the field.
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. Diff is safe for concurrent use.
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 of the model are ignored, except untagged embedded structs held by value, whose fields are walked as if declared in the model. Embedded pointers of the model are not walked, so a shared or cyclic value never leaks into the log.
A tracked field is recorded whole, as its JSON encoding: inside it, activity tags do not select fields, and json.Marshal decides what is written, untagged fields and embedded pointers included. Tags still protect at every depth, failing closed: a tracked field whose value can reach a field tagged activity:"-" or activity:",redact" (through nested or embedded structs, pointers, slices, arrays, map keys or values, or interfaces, exported or not) is recorded as Redacted as a whole, because leaving out one nested field would depend on how the value encodes itself. Nested tags follow the same syntax rules as the model's, so a malformed one is an error rather than a silent no-op. The values of a redacted field are never encoded, formatted or included in an error.
A field counts as changed when its old and new values differ and so do their encodings. Values are compared recursively: with the type's Equal method when it has one with the signature Equal(T) bool (so time.Time compares by instant, ignoring location and monotonic clock); json.RawMessage by its JSON meaning; nil and empty slices and maps as equal; NaN as equal to NaN; and pointers and interfaces by what they hold. Strings are compared byte for byte: encoding/json writes invalid UTF-8 as U+FFFD, so a change between two invalid strings is recorded even when both sides then read the same. A tracked float that is NaN or infinite is encoded as the string "NaN", "+Inf" or "-Inf", and an empty json.RawMessage as null, since JSON has no such values.
Diff returns an error wrapping ErrInvalidDiff for a tagged unexported field, an unknown tag option at any depth, two fields with the same tracked name, values of different types, non-struct values, and two nil sides; and an encoding error for a changed value json.Marshal rejects.
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
Example (NestedRedaction) ¶
A tracked field whose value holds a redacted field anywhere inside it is redacted as a whole, so a nested secret cannot leak through its parent.
package main
import (
"fmt"
activitylog "github.com/byteminus/go-activitylog"
)
func main() {
type Credentials struct {
User string
Secret string `activity:"secret,redact"`
}
type Integration struct {
Name string `activity:"name"`
Creds *Credentials `activity:"credentials"`
}
changes, err := activitylog.Diff(
Integration{Name: "crm", Creds: &Credentials{User: "svc", Secret: "s1"}},
Integration{Name: "crm", Creds: &Credentials{User: "svc", Secret: "s2"}},
)
if err != nil {
panic(err)
}
c := changes["credentials"]
fmt.Println("redacted:", c.Redacted, "values:", len(c.Old)+len(c.New))
}
Output: redacted: true values: 0
type Entry ¶
type Entry struct {
LogName string
Event string
Description string
Subject Ref
Actor Ref
TenantID string
// Properties must be JSON-encodable and must not hold values with
// fields tagged activity:"-" or activity:",redact". 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 New ¶
New returns a Logger writing to store, with nil options set to their defaults. It fails with ErrNilStore when store is nil.
func (*Logger) Log ¶
Log records e. It is a no-op when Options.LoggingDisabled reports true, as in a context derived from WithoutLogging.
Zero fields of e are filled in: the log name from the Logger options, and the actor and tenant from Options.ActorFrom and Options.TenantFrom; the batch ID always comes from Options.BatchFrom. 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; an error wrapping ErrInvalidActivity when the result would be invalid (see Activity.Validate), a property holds a value with a field tagged activity:"-" or activity:",redact", or a property's type has a malformed activity tag; and the store's error, wrapped. 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. It is a no-op when logging is disabled, as for Log.
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; one named like a redacted field of the model is stored redacted.
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. Diff's errors are returned as is; see Log for the rest.
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 := context.Background()
p := &Project{ID: "p2", Name: "Gemini", Status: "draft"}
if err := logger.LogChange(ctx, activitylog.Entry{}, nil, p); err != nil { // created
panic(err)
}
if err := logger.LogChange(ctx, activitylog.Entry{Description: "Archived"}, p, nil); err != nil { // deleted
panic(err)
}
page, err := store.List(ctx, activitylog.Query{Subject: activitylog.Ref{Type: "project", ID: "p2"}})
if err != nil {
panic(err)
}
for _, a := range page.Activities {
fmt.Println(a.Event, a.Description, "name:", string(a.Changes["name"].Old)+string(a.Changes["name"].New))
}
}
Output: deleted Archived name: "Gemini" created name: "Gemini"
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. The activity is validated after
// Enrich returns, so an Enrich that leaves it invalid makes Log fail
// with ErrInvalidActivity and nothing is stored. Enrich runs after
// redaction and must not put secret values back.
Enrich func(ctx context.Context, a *Activity)
// Now is the clock; it defaults to time.Now.
Now func() time.Time
// ActorFrom returns the party performing the operation. Nil means the
// package-level ActorFrom, which reads the value stored by WithActor.
ActorFrom func(ctx context.Context) (Ref, bool)
// TenantFrom returns the tenant the operation belongs to. Nil means
// the package-level TenantFrom.
TenantFrom func(ctx context.Context) (string, bool)
// BatchFrom returns the batch the operation belongs to. Nil means the
// package-level BatchFrom.
BatchFrom func(ctx context.Context) (string, bool)
// PropertiesFrom returns the request-scoped properties of the
// operation. The Logger does not modify the map. Nil means the
// package-level PropertiesFrom.
PropertiesFrom func(ctx context.Context) (map[string]any, bool)
// LoggingDisabled reports whether the operation must not be logged.
// Nil means the package-level LoggingDisabled.
LoggingDisabled func(ctx context.Context) bool
}
Options configure a Logger. The zero value is ready to use.
The context hooks let an application state its actor and tenant once for every package it uses: each has the name and signature of the package function it replaces, and nil means that function.
Example (Resolvers) ¶
The resolver hooks take the actor and tenant from wherever the application already keeps them, so they are stated once for every package.
package main
import (
"context"
"fmt"
activitylog "github.com/byteminus/go-activitylog"
"github.com/byteminus/go-activitylog/memstore"
)
func main() {
type userKey struct{}
type account struct{ ID, Org string }
store := memstore.New()
logger, err := activitylog.New(store, activitylog.Options{
ActorFrom: func(ctx context.Context) (activitylog.Ref, bool) {
u, ok := ctx.Value(userKey{}).(account)
return activitylog.Ref{Type: "user", ID: u.ID}, ok
},
TenantFrom: func(ctx context.Context) (string, bool) {
u, ok := ctx.Value(userKey{}).(account)
return u.Org, ok
},
})
if err != nil {
panic(err)
}
ctx := context.WithValue(context.Background(), userKey{}, account{ID: "7", Org: "acme"})
if err := logger.Log(ctx, activitylog.Entry{Event: "exported"}); err != nil {
panic(err)
}
page, err := store.List(ctx, activitylog.Query{TenantID: "acme"})
if err != nil {
panic(err)
}
fmt.Println(page.Activities[0].Event, "by", page.Activities[0].Actor)
}
Output: exported by user:7
type Page ¶ added in v0.2.0
type Page struct {
// Activities holds the page, newest first.
Activities []Activity
// Next is the Query.BeforeID that reads the following, older page. It
// is 0 when no further activity matches.
Next int64
}
Page is one page of Reader.List.
type Pruner ¶
type Pruner interface {
// Prune deletes every activity created strictly before the given time
// and returns how many it deleted. Stores keep microseconds, so a
// cut-off between two microseconds behaves like the next microsecond.
// A zero time, or one before year 1, deletes nothing; one after year
// 9999 deletes everything. When it fails part way it returns the
// number deleted so far with the error, including ctx's error once ctx
// is done.
Prune(ctx context.Context, before time.Time) (int64, error)
}
Pruner deletes old activities, for retention policies. It is implemented by storage packages. Implementations must be safe for concurrent use.
Example ¶
package main
import (
"context"
"fmt"
"time"
activitylog "github.com/byteminus/go-activitylog"
"github.com/byteminus/go-activitylog/memstore"
)
func main() {
now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.UTC)
store := memstore.New()
for _, age := range []time.Duration{100 * 24 * time.Hour, 10 * 24 * time.Hour} {
a := activitylog.Activity{LogName: activitylog.DefaultLogName, Event: "login", CreatedAt: now.Add(-age)}
if err := store.Insert(context.Background(), a); err != nil {
panic(err)
}
}
// A retention job keeps 90 days.
var pruner activitylog.Pruner = store
n, err := pruner.Prune(context.Background(), now.Add(-90*24*time.Hour))
if err != nil {
panic(err)
}
fmt.Println(n, "pruned")
}
Output: 1 pruned
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. Likewise for Actor.
Subject Ref
Actor Ref
// TenantID selects one tenant's activities. Empty matches every
// tenant, so a reader serving a tenant must always set it.
TenantID string
BatchID string
// Since is inclusive and Until exclusive, on CreatedAt.
Since time.Time
Until time.Time
// BeforeID is the keyset cursor: pass the Next of the previous page to
// read the following, older page. Unlike an offset it stays stable
// while new activities are written. Zero starts at the newest.
BeforeID int64
// Limit is the page size: 0 means DefaultPageSize, and values above
// MaxPageSize are lowered to it.
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.Activities))
for i, a := range page.Activities {
events[i] = a.Event
}
fmt.Println(strings.Join(events, " "))
if page.Next == 0 {
break
}
q.BeforeID = page.Next
}
}
Output: event-4 event-3 event-2 event-1 event-0
func (Query) Matches ¶
Matches reports whether a satisfies the filters of q, ignoring Limit. It is the reference semantics of a query, for stores that filter in Go.
func (Query) Normalize ¶
Normalize checks q and returns it with defaults applied: the page size defaulted and capped, and Since and Until in UTC, rounded up to the microsecond (the precision stores keep, so the same activities match). It rejects a negative Limit or BeforeID, a Ref filter with an ID but no type, text that is not valid UTF-8 or holds a NUL byte, a Since or Until outside years 1 to 9999, and an Until not after Since. Every store calls it, so all stores accept and reject the same queries. Errors wrap ErrInvalidQuery and name the field.
type Reader ¶
type Reader interface {
// List returns the page of activities matching q, newest (highest ID)
// first. It must reject the queries Query.Normalize rejects with that
// error, apply q.Normalize's defaults, filter with the semantics of
// Query.Matches, return at most q.Limit activities, and set Page.Next
// exactly when more matching activities exist. It returns ctx's error
// once ctx is done.
List(ctx context.Context, q Query) (Page, error)
}
Reader reads activities back. It is implemented by storage packages. Implementations must be safe for concurrent use.
type Ref ¶
Ref identifies a record or party by type and ID, such as the subject that was changed or the actor that changed it. Typing the ID keeps "user 42" and "api_token 42" apart.
A stored Ref is either zero or has both fields set; a half-empty Ref is rejected with ErrInvalidActivity.
func ActorFrom ¶ added in v0.2.0
ActorFrom returns the actor stored by WithActor. It returns false when ctx carries none.
type Store ¶
type Store interface {
// Insert stores a. It must:
// - reject a with the error of Activity.Validate when that fails;
// - assign a new, strictly increasing ID, ignoring a.ID;
// - treat nil Properties as "{}" and empty Changes as none;
// - stamp a zero CreatedAt with the current time, and keep CreatedAt
// at microsecond precision (truncated) in UTC;
// - join the transaction carried by ctx when it supports one, so the
// activity commits or rolls back with the change it describes;
// - return ctx's error once ctx is done.
Insert(ctx context.Context, a Activity) error
}
Store persists activities. It is implemented by storage packages (pgxstore, memstore, or your own) and called by Logger.
The log is append-only: a Store offers no way to change or delete one entry. Pruner is the only deletion, and it removes by age.
Implementations must be safe for concurrent use and pass the storetest conformance suite.
Source Files
¶
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. |