activitylog

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 11 Imported by: 0

README

go-activitylog

tests Go Reference

Model change history, audit trails and actor tracking for Go, written in the same database transaction as the change they describe. Tag the struct fields you care about, put the actor, tenant and request details in the context once, and every create, update and delete is recorded with who did it, from where, and exactly which fields changed (secrets marked as changed without their values). Read it back as a tenant activity feed, a record's history or an actor's trail, with stable keyset pagination.

Install

go get github.com/byteminus/go-activitylog

Requires Go 1.26 or later. The PostgreSQL store uses pgx v5.

Quick start

package main

import (
	"context"
	"log"
	"os"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/byteminus/go-activitylog"
	"github.com/byteminus/go-activitylog/pgxstore"
)

// Project opts fields into change tracking with the activity tag.
type Project struct {
	ID     string `activity:"-"`
	Name   string `activity:"name"`
	Status string `activity:"status"`
	Token  string `activity:"token,redact"` // record that it changed, never the value
}

// ActivityRef names the subject of the project's activities.
func (p *Project) ActivityRef() activitylog.Ref {
	return activitylog.Ref{Type: "project", ID: p.ID}
}

type txKey struct{}

func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	store, err := pgxstore.New(pool, pgxstore.WithConn(func(ctx context.Context) pgxstore.Querier {
		if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
			return tx // join the caller's transaction
		}
		return nil // use the pool
	}))
	if err != nil {
		log.Fatal(err)
	}
	if err := store.CreateSchema(ctx); err != nil {
		log.Fatal(err)
	}
	logger, err := activitylog.New(store, activitylog.Options{})
	if err != nil {
		log.Fatal(err)
	}

	// Usually set once by middleware.
	ctx = activitylog.WithCauser(ctx, activitylog.Ref{Type: "user", ID: "42"})
	ctx = activitylog.WithTenant(ctx, "acme")
	ctx = activitylog.WithProperties(ctx, map[string]any{
		"source": "admin", "ip": "203.0.113.9", "request_id": "req-7",
	})

	before := &Project{ID: "p1", Name: "Apollo", Status: "draft", Token: "t1"}
	after := *before
	after.Status, after.Token = "active", "t2"

	err = pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
		ctx := context.WithValue(ctx, txKey{}, tx)
		// ... UPDATE projects ... through tx ...
		return logger.Updated(ctx, activitylog.Entry{}, before, &after)
	})
	if err != nil {
		log.Fatal(err)
	}

	history, err := store.List(ctx, activitylog.Query{Subject: activitylog.Ref{Type: "project", ID: "p1"}})
	if err != nil {
		log.Fatal(err)
	}
	for _, a := range history {
		log.Printf("%s %s by %s: %d field(s) changed", a.Event, a.Subject.ID, a.Causer.ID, len(a.Changes))
	}
}

Concepts

  • Activity: one row of the log. It has a log name (to separate, say, auth from billing), an Event and/or a Description, a Subject (what it happened to), a Causer (who did it), a TenantID, a BatchID, free-form Properties (a JSON object) and field-level Changes.

  • Ref: a {Type, ID} pair naming a subject or causer. Models implement Subject (ActivityRef() Ref) so LogChange can fill the subject itself. The zero causer stands for the system.

  • Context: WithCauser, WithTenant and WithProperties are set once by the adapter that authenticated the request (admin session, API token, CLI, job), so domain code only says what happened. WithProperties merges copy-on-write; entry properties win over context ones. WithBatch gives the activities of one operation a shared UUID v7; nested operations join the outer batch. WithoutLogging turns the logger into a no-op for seeding and imports.

  • Change tracking: Diff and LogChange track only tagged fields:

    Tag Effect
    activity:"email" tracked as email
    activity:"" tracked under the Go field name
    activity:"password,redact" records that it changed, never the values
    activity:"-" or no tag not tracked

    Untagged embedded structs held by value are walked; embedded pointers are not. Values are compared with the type's Equal(T) bool method when it has one (so time.Time in different zones compares by instant), otherwise with reflect.DeepEqual, and are JSON-encoded at the time of the call. Updates in which no tracked field changed are skipped unless Options.LogEmptyUpdates is set.

  • Freezing: properties and changes are encoded when they are logged, so later mutation of the values never rewrites history, and encoding errors are returned to the caller rather than surfacing in the store.

  • Integrity: Activity.Validate and Query.Normalize define what every store accepts; the PostgreSQL schema enforces the same rules with constraints.

PostgreSQL

pgxstore.New(db, opts...) takes a *pgxpool.Pool, *pgx.Conn or pgx.Tx. store.CreateSchema(ctx) creates the table and indexes; if you use a migration tool, put store.Schema() in a migration instead. With the default table name it is:

CREATE TABLE IF NOT EXISTS "activity_log" (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    log_name     text NOT NULL CHECK (log_name <> ''),
    event        text NOT NULL DEFAULT '',
    description  text NOT NULL DEFAULT '',
    subject_type text NOT NULL DEFAULT '',
    subject_id   text NOT NULL DEFAULT '',
    causer_type  text NOT NULL DEFAULT '',
    causer_id    text NOT NULL DEFAULT '',
    tenant_id    text NOT NULL DEFAULT '',
    batch_id     text NOT NULL DEFAULT '',
    properties   jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(properties) = 'object'),
    changes      jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(changes) = 'object'),
    created_at   timestamptz NOT NULL,
    CHECK (event <> '' OR description <> ''),
    CHECK (subject_id = '' OR subject_type <> ''),
    CHECK (causer_id = '' OR causer_type <> '')
);
CREATE INDEX IF NOT EXISTS "activity_log_subject_idx" ON "activity_log" (subject_type, subject_id, id DESC);
CREATE INDEX IF NOT EXISTS "activity_log_causer_idx" ON "activity_log" (causer_type, causer_id, id DESC);
CREATE INDEX IF NOT EXISTS "activity_log_tenant_idx" ON "activity_log" (tenant_id, id DESC);
CREATE INDEX IF NOT EXISTS "activity_log_log_name_idx" ON "activity_log" (log_name, id DESC);
CREATE INDEX IF NOT EXISTS "activity_log_batch_idx" ON "activity_log" (batch_id) WHERE batch_id <> '';
CREATE INDEX IF NOT EXISTS "activity_log_created_at_idx" ON "activity_log" (created_at);

Options:

  • WithTable("audit.activity_log") sets the table, optionally schema-qualified. Names are quoted, so they are used exactly as written.
  • WithConn(func(ctx) pgxstore.Querier) resolves the connection per call. Return the transaction your code carries in the context and the activity commits or rolls back with the change it describes; return nil to use the db given to New. The quick start shows the pattern.
  • WithPruneBatch(n) sets how many rows each Prune statement deletes (default 5000), so retention jobs never hold long locks.
  • WithNow(fn) sets the clock used for activities inserted without a time.

Queries use the indexes: subject history, causer trail, tenant feed and log name are all (..., id DESC) so a page is an index range scan, and batches use a partial index that skips the rows outside any batch.

API overview

Package Identifiers
activitylog Logger, New, Options; Log, LogChange, Created, Updated, Deleted
Entry, Activity (Validate, Clone), Ref, Subject, Change, Changes
Diff
WithCauser/CauserFrom, WithTenant/TenantFrom, WithBatch/BatchFrom, WithProperties/PropertiesFrom, WithoutLogging/LoggingDisabled
Store, Reader, Pruner, Query (Normalize, Matches), DefaultLimit, MaxLimit
EventCreated, EventUpdated, EventDeleted, DefaultLogName
ErrEmptyEntry, ErrNoSubject, ErrInvalidDiff, ErrInvalidActivity, ErrInvalidQuery, ErrNilStore
pgxstore New, Store (Insert, List, Prune, Schema, CreateSchema), Querier, WithTable, WithConn, WithPruneBatch, WithNow, ErrInvalidOption
memstore New, Store, WithNow (not transactional)
storetest Run, Store

Errors are prefixed with the package name and wrap their causes, so errors.Is works through every layer.

Testing

go test -race ./...
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable \
  go test -race -count=1 ./...

Integration tests create and drop their own schema, so they run in parallel against a shared database. They skip without TEST_DATABASE_URL, and fail instead when CI is set.

To check your own store, run the conformance suite against it:

func TestConformance(t *testing.T) {
	storetest.Run(t, func(t *testing.T) storetest.Store { return newMyStore(t) })
}

Roadmap

  • Filtering on properties (for example by request ID) in Query.
  • A cursor type for paginating by time as well as by ID.
  • Tracking of nested struct fields under dotted names, and of fields reached through embedded pointers.
  • Optional time-based partitioning of the PostgreSQL table for very large logs.
  • Stores for other databases (SQLite, MySQL).

License

MIT

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

Examples

Constants

View Source
const (
	EventCreated = "created"
	EventUpdated = "updated"
	EventDeleted = "deleted"
)

Events written by LogChange and its shorthands when the entry does not name one.

View Source
const (
	DefaultLimit = 50
	MaxLimit     = 1000
)

Limits on the number of activities one List call returns.

View Source
const DefaultLogName = "default"

DefaultLogName is the log an activity is written to when neither the entry nor the Logger names one.

Variables

View Source
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 BatchFrom

func BatchFrom(ctx context.Context) string

BatchFrom returns the batch ID carried by ctx, or "" when there is none.

func LoggingDisabled

func LoggingDisabled(ctx context.Context) bool

LoggingDisabled reports whether ctx was derived from WithoutLogging.

func PropertiesFrom

func PropertiesFrom(ctx context.Context) map[string]any

PropertiesFrom returns a copy of the properties carried by ctx, or nil when there are none.

func TenantFrom

func TenantFrom(ctx context.Context) string

TenantFrom returns the tenant carried by ctx, or "" when there is none.

func WithBatch

func WithBatch(ctx context.Context) context.Context

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

func WithCauser(ctx context.Context, causer Ref) context.Context

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

func WithProperties(ctx context.Context, props map[string]any) context.Context

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

func WithTenant(ctx context.Context, tenantID string) context.Context

WithTenant returns a context whose activities belong to tenantID.

func WithoutLogging

func WithoutLogging(ctx context.Context) context.Context

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

func (a Activity) Clone() Activity

Clone returns a deep copy of a; stores use it so activities they hold share no memory with their callers.

func (Activity) Validate

func (a Activity) Validate() error

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

type Changes map[string]Change

Changes maps tracked field names to their change.

func Diff

func Diff(before, after any) (Changes, error)

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

func (Changes) Clone

func (c Changes) Clone() Changes

Clone returns a deep copy of c, so a caller can keep it while the original is modified.

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 New

func New(store Store, opts Options) (*Logger, error)

New returns a Logger writing to store. It fails with ErrNilStore when store is nil.

func (*Logger) Created

func (l *Logger) Created(ctx context.Context, e Entry, model any) error

Created records the creation of model; see LogChange.

func (*Logger) Deleted

func (l *Logger) Deleted(ctx context.Context, e Entry, model any) error

Deleted records the deletion of model; see LogChange.

func (*Logger) Log

func (l *Logger) Log(ctx context.Context, e Entry) error

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

func (l *Logger) LogChange(ctx context.Context, e Entry, before, after any) error

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.

func (*Logger) Updated

func (l *Logger) Updated(ctx context.Context, e Entry, before, after any) error

Updated records the update of a model from before to after; see LogChange.

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

func (Query) Matches

func (q Query) Matches(a Activity) bool

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

func (q Query) Normalize() (Query, error)

Normalize checks q and returns it with defaults applied: the default limit, and Since and Until in UTC. Every store calls it, so all stores accept and reject the same queries. Errors wrap ErrInvalidQuery.

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

type Ref struct {
	Type string `json:"type"`
	ID   string `json:"id"`
}

Ref identifies a record by type and ID, such as a subject that was changed or the causer that changed it.

func CauserFrom

func CauserFrom(ctx context.Context) Ref

CauserFrom returns the causer carried by ctx, or the zero Ref when there is none, which stands for the system.

func (Ref) IsZero

func (r Ref) IsZero() bool

IsZero reports whether r refers to nothing.

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.

type Subject

type Subject interface {
	ActivityRef() Ref
}

Subject is implemented by models that know their own Ref, so LogChange can fill the subject without the caller spelling it out.

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.

Jump to

Keyboard shortcuts

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