Help us shape the future of Go! Take the 2026 Go Developer Survey

activitylog

package module
v0.2.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: 15 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, at any depth). 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.WithActor(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)
	}

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

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), an Actor (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 actor, so "user 42" and "api_token 42" never collide. A stored Ref is zero or complete. Models implement Subject (ActivityRef() Ref) so LogChange can fill the subject itself. The zero actor stands for the system. Ref.String() is for display only; compare the struct.

  • Context: WithActor, 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. Each ...From function returns (value, ok).

  • 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 recursively: with the type's Equal(T) bool method where it has one (so a time.Time anywhere in the value compares by instant, ignoring location and monotonic clock), json.RawMessage by JSON meaning, nil and empty slices and maps as equal, and NaN as equal to NaN. A field counts as changed only when its encoding changes too. Updates in which no tracked field changed are skipped unless Options.LogEmptyUpdates is set.

  • Redaction at any depth: a tracked field whose value reaches a field tagged redact (through nested or embedded structs, pointers, slices, arrays, maps or interfaces, exported or not) is recorded as redacted as a whole. Redacted values are never encoded, formatted or put in an error. A property holding such a value is refused rather than logged.

  • 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.

  • Append-only: no store offers a way to change or delete one entry; Prune, which deletes by age for retention, is the only deletion.

  • Integrity: Activity.Validate and Query.Normalize define what every store accepts, including the text and JSON PostgreSQL can store; the PostgreSQL schema enforces the structural rules with constraints.

Wiring the actor and tenant once

Every package in the Byte Minus family reads the actor and tenant through hooks with the same names and signatures, so an application states them once. Options has one hook per context value the Logger reads (ActorFrom, TenantFrom, BatchFrom, PropertiesFrom, LoggingDisabled); nil means the package function of the same name.

package myapp

import (
	"context"

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

type User struct{ ID, OrgID string }

type userKey struct{}

// UserFrom is the application's own lookup, set by its auth middleware.
func UserFrom(ctx context.Context) (User, bool) {
	u, ok := ctx.Value(userKey{}).(User)
	return u, ok
}

// TenantIDFrom has the shape every family package expects.
func TenantIDFrom(ctx context.Context) (string, bool) {
	u, ok := UserFrom(ctx)
	return u.OrgID, ok
}

func NewLogger(store activitylog.Store) (*activitylog.Logger, error) {
	return activitylog.New(store, activitylog.Options{
		TenantFrom: TenantIDFrom, // same signature, no adapter
		ActorFrom: func(ctx context.Context) (activitylog.Ref, bool) {
			u, ok := UserFrom(ctx)
			return activitylog.Ref{Type: "user", ID: u.ID}, ok
		},
	})
}

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 '',
    actor_type   text NOT NULL DEFAULT '',
    actor_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 <> ''),
    CONSTRAINT "activity_log_subject_check" CHECK ((subject_type = '') = (subject_id = '')),
    CONSTRAINT "activity_log_actor_check" CHECK ((actor_type = '') = (actor_id = ''))
);
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_actor_idx" ON "activity_log" (actor_type, actor_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_event_idx" ON "activity_log" (event, 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);

Upgrading from 0.1.0 changes this schema; CHANGELOG.md has the SQL.

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).
  • WithNow(fn) sets the clock used for activities inserted without a time.

Queries use the indexes: subject history, actor trail, tenant feed, event and log name are all (..., id DESC), so a page is an index range scan that stops after Limit rows, and batches use a partial index that skips the rows outside any batch. A filter on a subject or actor type alone uses the leading column of the Ref index.

Prune deletes in batches along the created_at index, each batch its own statement on the db given to New (never on a transaction from WithConn), so a retention job never becomes one long transaction. Rows another transaction has locked are skipped, not waited for, and the next run deletes them.

API overview

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

Errors are prefixed with the package name and wrap their causes, so errors.Is works through every layer. Every validation error wraps activitylog.ErrInvalid.

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:

package mystore_test

import (
	"testing"

	"github.com/byteminus/go-activitylog/memstore"
	"github.com/byteminus/go-activitylog/storetest"
)

func TestConformance(t *testing.T) {
	storetest.Run(t, func(t *testing.T) storetest.Store {
		return memstore.New() // your store here
	})
}

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).

Stability

This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.

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, 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

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 (
	// 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.

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 = 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.

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

func BatchFrom(ctx context.Context) (string, bool)

BatchFrom returns the batch ID stored by WithBatch. It returns false when ctx is in no batch.

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, bool)

PropertiesFrom returns a copy of the properties stored by WithProperties. It returns false when ctx carries none.

func TenantFrom

func TenantFrom(ctx context.Context) (string, bool)

TenantFrom returns the tenant ID stored by WithTenant. It returns false when ctx carries none.

func WithActor added in v0.2.0

func WithActor(ctx context.Context, actor Ref) context.Context

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

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)
		}
	}
	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

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. An empty props returns ctx unchanged.

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), "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

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 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

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. 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

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. Clone of nil is nil.

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

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

New returns a Logger writing to store, with nil options set to their defaults. 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 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

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. 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"

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. 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

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 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

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

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

func ActorFrom(ctx context.Context) (Ref, bool)

ActorFrom returns the actor stored by WithActor. It returns false when ctx carries none.

func (Ref) IsZero

func (r Ref) IsZero() bool

IsZero reports whether both fields are empty, which for an actor stands for the system.

func (Ref) String added in v0.2.0

func (r Ref) String() string

String returns "type:id", for logs and display only. It is not a key: a type containing ":" makes two Refs print alike, so compare the struct.

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.

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