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

activity

package
v1.0.2 Latest Latest
Warning

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

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

Documentation

Overview

Package activity is forge's typed activity stream: who did what to which object, where, and through what. "sean promoted release v1.7.44 in prod via cli" is one Event.

An activity event is DECLARED on the rpc, not written by hand at each call site:

rpc PromoteRelease(PromoteReleaseRequest) returns (PromoteReleaseResponse) {
  option (forge.v1.method) = {
    activity: {
      verb: "promote"
      target: { kind: "release", id: "release_id", label: "response.release.version" }
      env: "to_env"
    }
  };
}

One interceptor (Interceptor) reads the declaration from the method descriptor and writes the event when the handler succeeds. `forge lint` (forgeconv-activity-declared) fails a mutating rpc that declares nothing, so a new mutation cannot ship without saying what it records, or saying `activity: { none: "<why>" }`. Resolve is the one function that interprets a declaration; the interceptor and forge's lint both call it, so what the lint reports is what the runtime does.

Actors that are not RPCs (a converger, an alert transition, CI) call Record directly.

Transactions

By default the interceptor writes after the handler returns successfully, in its own statement, best-effort: a failed write is logged and counted and never fails the RPC, whose mutation has already committed. It cannot join the handler's transaction (that commits before the handler returns, and a created object's id exists only in the response), and wrapping the whole handler in one would re-run its non-database side effects on every serialization retry. The cost is a possible missing row after a crash between commit and insert; never a phantom row, because a failed call writes nothing.

Where the event must commit or roll back WITH the mutation, declare `writer: HANDLER` and call Record inside the handler's RunTx. The default Store writes through *orm.Client, so the insert joins the ctx-carried transaction.

Storage

Store persists events to the activity_events table, whose canonical DDL is SchemaSQL. The table is a migration the project owns, like every other table; the read rpc (ListActivity) is project code over Store.List, because who may read which scope is access control.

Index

Constants

View Source
const (
	DefaultPageSize = 50
	MaxPageSize     = 500
)

Page sizes for Store.List.

View Source
const DefaultWriteTimeout = 2 * time.Second

DefaultWriteTimeout bounds one interceptor write when Deps.WriteTimeout is zero.

View Source
const SchemaSQL = `` /* 1127-byte string literal not displayed */

SchemaSQL is the canonical activity_events table Store reads and writes, with the indexes its List filters use. A project owns the table as one of its migrations; `forge project new` and `forge scaffold activity` write this DDL into db/migrations. Keep a hand-written table's columns to this shape.

View Source
const ScopeSeparator = "/"

ScopeSeparator separates the segments of a hierarchical scope: an event scoped "org_1/billing" belongs to project billing of org org_1, and one scoped "org_1" to the org itself. See Filter.IncludeAncestors and Filter.IncludeDescendants.

View Source
const ViaAPI = "api"

ViaAPI is the via recorded when a request names no client.

View Source
const ViaHeader = "Forge-Via"

ViaHeader is the request header a client names itself in: "console", "cli", "ci". Browsers sending it need it in the CORS allow-list.

Variables

View Source
var ErrBadPageToken = errors.New("activity store: malformed page token")

ErrBadPageToken is returned for a PageToken List did not issue.

Functions

func DefaultVia

func DefaultVia(_ context.Context, req connect.AnyRequest) string

DefaultVia is the request's Forge-Via header, lowercased and limited to a short token, or "api" when absent or malformed.

func Interceptor

func Interceptor(deps Deps) (connect.Interceptor, error)

Interceptor returns the connect interceptor that writes each declared activity event. Put it after authentication, as close to the handler as the chain allows (serverkit.ChainDeps.Activity does both). It fails when Deps.Principal is nil: an interceptor that cannot name the caller would record every event as anonymous, so it is a boot error instead.

Per unary call it resolves the method's declaration once (Resolve, cached per procedure), runs the handler, and on success extracts the declared fields from the request and response and writes the event, best-effort: a write failure is logged and counted and the RPC still succeeds. A failed call writes nothing. An rpc declaring nothing, or `none`, passes straight through.

func IsMutating

func IsMutating(md protoreflect.MethodDescriptor) bool

IsMutating reports whether an rpc changes state and so must declare its activity (or `none`). It does unless its name starts with a read verb (Get, List, Watch, Search, Query, Count, Describe, BatchGet) or it declares protobuf's own read-only marker, `option idempotency_level = NO_SIDE_EFFECTS;`.

func Record

func Record(ctx context.Context, w Writer, ev Event) error

Record writes ev through w. It is the explicit write: for actors that are not RPCs (a converger, an alert transition, CI ingestion), and for a handler whose rpc declares `writer: ACTIVITY_WRITER_HANDLER`.

Inside a declared rpc, every field ev leaves zero is filled from the call: verb and target kind from the declaration, actor, via and scope from the caller, and target id, label, env and detail from the request paths (response paths cannot be read yet: the handler is still running). Outside one, ev must be complete.

Inside a RunTx, a Store backed by *orm.Client writes in the transaction, so the event commits or rolls back with the mutation; the call counts as recorded only once that transaction commits. Record returns the validation or write error; a handler decides whether its mutation should fail with it.

func Skip

func Skip(ctx context.Context) bool

Skip records that this call, although it succeeded, changed nothing worth an activity event, so the interceptor writes none for it: an idempotent "ensure" rpc that found its object already there, a dry run, a no-op update. It reports whether ctx is a declared rpc's context; outside one it does nothing.

A declaration states what an rpc records when it changes something; Skip is the handler's way to say this call did not. Inside a RunTx the skip takes effect only if the transaction commits, like Record.

Types

type Actor

type Actor struct {
	// ID is the stable identifier filters match on (a user id, "ci",
	// "converger").
	ID string
	// Name is how the actor is shown. Empty falls back to ID.
	Name string
	// Kind classifies the actor.
	Kind ActorKind
}

Actor is who performed an action.

func Machine

func Machine(id, name string) Actor

Machine returns a non-human principal actor.

func System

func System(name string) Actor

System returns an actor for the application itself, named name.

func User

func User(id, name string) Actor

User returns a person actor.

type ActorKind

type ActorKind string

ActorKind classifies who performed an action.

const (
	// ActorUser is a person, signed in.
	ActorUser ActorKind = "user"
	// ActorMachine is a non-human principal acting on someone's behalf: a
	// CI job, an API key, a service account.
	ActorMachine ActorKind = "machine"
	// ActorSystem is the application itself: a converger, a scheduler, an
	// alert evaluator.
	ActorSystem ActorKind = "system"
)

func (ActorKind) Valid

func (k ActorKind) Valid() bool

Valid reports whether k is one of the three kinds.

type ClaimsLookup

type ClaimsLookup = func(ctx context.Context) (*auth.Claims, bool)

ClaimsLookup reads the authenticated caller's forge claims from a request context: authn.ClaimsFromContext, or a project's own reader when its auth stashes claims under a key it owns. It is the same shape as middleware.ClaimsLookup and serverkit.CallerLookup.

type DB

type DB interface {
	Exec(ctx context.Context, query string, args ...any) (sql.Result, error)
	Query(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}

DB is the database handle a Store runs statements through. *orm.Client satisfies it, and a statement issued with a ctx carrying an orm RunTx joins that transaction. Wrap a bare *sql.DB with SQLDB.

func SQLDB

func SQLDB(db *sql.DB) DB

SQLDB adapts a *sql.DB to DB. Its statements never join an orm transaction; prefer the project's *orm.Client.

type Declaration

type Declaration struct {
	// None is the reason the rpc records no activity. When set, every other
	// field is zero.
	None string
	// Verb is the action token.
	Verb string
	// TargetKind is the target's type.
	TargetKind string
	// TargetID, TargetLabel, Env and Scope locate their values in the
	// request or response. A zero Path is absent (TargetID never is).
	TargetID, TargetLabel, Env, Scope Path
	// Detail lists the paths copied into Event.Detail.
	Detail []Path
	// Writer says who writes the event.
	Writer WriterKind
	// Implied reports a declaration derived from forge's CRUD shape rather
	// than written in an option.
	Implied bool
}

Declaration is an rpc's resolved activity declaration.

func Resolve

Resolve interprets md's activity declaration: the explicit `(forge.v1.method).activity` option, or, without one, the declaration forge's CRUD shape implies. It returns nil, nil when the rpc declares nothing, and an error naming every defect when the declaration is malformed. The interceptor and `forge lint` both call it, so they agree.

type Deps

type Deps struct {
	// Principal names each call's caller and scope. Required: see
	// [Principal] for why there is no default.
	Principal Principal
	// Writer persists events. nil is LogWriter(Logger): events are logged,
	// not stored.
	Writer Writer
	// ViaFrom names the channel a request came through. nil is
	// [DefaultVia].
	ViaFrom func(ctx context.Context, req connect.AnyRequest) string
	// Logger reports failed writes and malformed declarations. nil is
	// slog.Default().
	Logger *slog.Logger
	// Meter counts failed writes (forge_activity_write_failures_total). nil
	// is the global meter provider's.
	Meter metric.Meter
	// WriteTimeout bounds one write. Zero is DefaultWriteTimeout.
	WriteTimeout time.Duration
}

Deps configures Interceptor. Principal is required; every other field has a working default.

type Event

type Event struct {
	// ID is assigned by the store on write; set on events List returns.
	ID string
	// OccurredAt is when the action happened. Zero is "now" at write.
	OccurredAt time.Time
	// Scope is what reads filter by: an org, a project. Empty is
	// unscoped.
	Scope string
	// Actor performed the action.
	Actor Actor
	// Verb is the action, a lower_snake token: "promote", "rotate".
	Verb string
	// Target is the object acted on.
	Target Target
	// Env is the environment the action happened in. Empty is a
	// project-level event.
	Env string
	// Via is the channel the action came through: "console", "cli", "ci",
	// "api", or a system's own name.
	Via string
	// Procedure is the RPC that recorded the event, when one did.
	Procedure string
	// Detail carries small, declared extras ({"from_env": "e2e"}).
	Detail map[string]string
	// TraceID ties the event to the request's trace, when there was one.
	TraceID string
}

Event is one entry in the activity stream.

type Filter

type Filter struct {
	Scope string
	// IncludeAncestors also matches events scoped to each ancestor of Scope:
	// a project's feed ("org_1/billing") then includes the events of its org
	// ("org_1"), such as an alert receiver created org-wide.
	IncludeAncestors bool
	// IncludeDescendants also matches events scoped below Scope: an org's
	// feed ("org_1") then includes every project's ("org_1/billing", ...).
	IncludeDescendants bool
	ActorID            string
	ActorKind          ActorKind
	Verb               string
	TargetKind         string
	TargetID           string
	Env                string
	// Envs matches any of the listed environments (AND-combined with Env).
	// Empty does not filter.
	Envs []string
	Via  string
	// Since and Until bound OccurredAt (inclusive, exclusive). Zero is
	// unbounded.
	Since, Until time.Time
	// PageSize is the page length. Zero is DefaultPageSize; it is capped at
	// MaxPageSize.
	PageSize int
	// PageToken continues from a previous page's NextPageToken.
	PageToken string
}

Filter selects events for Store.List. Set fields are AND-combined; an empty field does not filter. Scope is matched exactly, including "": a read handler serving several orgs or projects must always set it.

type Page

type Page struct {
	Events []Event
	// NextPageToken continues the listing; empty on the last page.
	NextPageToken string
}

Page is one page of events, newest first.

type Path

type Path struct {
	// Spec is the path as written ("response.release.version").
	Spec string
	// Response reports a path resolved against the response message.
	Response bool
	// contains filtered or unexported fields
}

Path is a resolved field path: the fields to walk, from the request or the response.

func (Path) IsZero

func (p Path) IsZero() bool

IsZero reports an absent path.

func (Path) Key

func (p Path) Key() string

Key is the name a detail value is stored under: the path's last segment.

type Principal

type Principal interface {
	// Actor is the caller of the call on ctx.
	Actor(ctx context.Context) Actor
	// Scope is the scope (an org, a project) of an event whose declaration
	// names no scope path.
	Scope(ctx context.Context) string
}

Principal is who is calling and the scope the call acts in: the one thing the interceptor cannot know, because the app's auth decides where it keeps the caller. It is declared here, where it is consumed, and the app implements it over its own auth context — control-plane's activitystream, for one, resolves a machine credential acting for a person to that person.

It is REQUIRED (Interceptor refuses a nil one). There is no default because a guessed one fails silently: a project whose auth does not stash forge's claims would have every event recorded as system:anonymous with an empty scope, and nothing would say so. A project on forge/pkg/authn names that choice explicitly with ClaimsPrincipal(authn.ClaimsFromContext).

func ClaimsPrincipal

func ClaimsPrincipal(lookup ClaimsLookup) Principal

ClaimsPrincipal is the Principal of an app whose auth puts forge claims on the context. The actor is a user named by email (or id); a call with no claims — one the auth policy let through anonymously — is the system ("anonymous"). The scope is the claims' OrgID. A project whose principals include machines (CI tokens, API keys) implements Principal itself, since only it knows which callers are machines.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the Postgres Writer and reader for the activity_events table.

func NewStore

func NewStore(db DB, opts ...StoreOption) *Store

NewStore returns a Store over db. Pass the same Store to the interceptor (Deps.Writer), to Record, and to the ListActivity handler.

func (*Store) List

func (s *Store) List(ctx context.Context, f Filter) (Page, error)

List returns the events matching f, newest first. Paging is keyset on (occurred_at, id), so a page never repeats or skips a row as new events arrive.

func (*Store) Write

func (s *Store) Write(ctx context.Context, ev Event) error

Write inserts ev. The store assigns the id; a zero OccurredAt is now.

type StoreOption

type StoreOption func(*Store)

StoreOption configures a Store.

func WithSchema

func WithSchema(schema string) StoreOption

WithSchema names the schema activity_events lives in, so every statement says "<schema>".activity_events rather than resolving the bare name through the connection's search_path, which a transaction-pooling proxy can leave pointing anywhere (see audit.WithSchema). Empty leaves it unqualified.

type Target

type Target struct {
	// Kind is the object's type, a lower_snake token: "release", "secret".
	Kind string
	// ID identifies the object within its kind.
	ID string
	// Label is a human-readable name. Empty falls back to ID.
	Label string
}

Target is the object an action was done to.

type Writer

type Writer interface {
	Write(ctx context.Context, ev Event) error
}

Writer persists events. Store is the database writer; LogWriter is the slog-only one the interceptor uses when no store is wired.

func LogWriter

func LogWriter(logger *slog.Logger) Writer

LogWriter returns a Writer that records each event as one slog line (message "activity.event", log_type=activity) and persists nothing. It is the interceptor's writer until a Store is wired, so a declaration is visible in the logs from the first deploy. A nil logger is slog.Default().

type WriterFunc

type WriterFunc func(ctx context.Context, ev Event) error

WriterFunc adapts a function to Writer.

func (WriterFunc) Write

func (f WriterFunc) Write(ctx context.Context, ev Event) error

Write calls f.

type WriterKind

type WriterKind int

WriterKind says who writes a declared event.

const (
	// WriterInterceptor: the interceptor writes the event after the handler
	// succeeds. The default.
	WriterInterceptor WriterKind = iota
	// WriterHandler: the handler calls [Record], normally inside its RunTx.
	WriterHandler
)

func (WriterKind) String

func (w WriterKind) String() string

String names the writer as the proto option spells it.

Jump to

Keyboard shortcuts

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