audit-gen is a CLI tool that reads your taxonomy YAML and generates
type-safe Go code: constants for event types, field names, and
categories, plus per-event builder structs with required-field
constructors.
Without code generation, emitting an audit event looks like this:
auditor.AuditEvent(audit.NewEvent("user_create", audit.Fields{
"actor_id": "alice",
"outcom": "success", // typo — runtime validation catches it, but only if tested
}))With code generation:
auditor.AuditEvent(NewUserCreateEvent("alice", "success"))
// "outcom" typo is impossible — required fields are constructor parameters
// Unknown field "NewUserCrateEvent" fails at compile timeRequired fields become constructor parameters — you cannot forget them. Optional fields are chainable setters. A typo in an event name or field name is a compile error, not a runtime surprise.
- Define your taxonomy in
taxonomy.yaml - Add a
go:generatedirective to your Go code - Run
go generate ./... - Commit the generated file to version control
Add this comment to any .go file in your package (typically
main.go or a dedicated generate.go):
//go:generate go run github.com/axonops/audit/cmd/audit-gen -input taxonomy.yaml -output audit_generated.go -package maingo generate ./...go run automatically downloads and caches audit-gen — no
separate install step needed. The generated file appears in the
same directory as the go:generate directive.
Makefile:
generate:
go generate ./...
# Run generation before build
build: generate
go build ./...CI pipeline (GitHub Actions):
- name: Generate audit code
run: go generate ./...
- name: Check generated code is committed
run: git diff --exit-code -- '**/audit_generated.go'The CI step ensures the generated file is always committed and up
to date. If someone changes taxonomy.yaml but forgets to
regenerate, the build fails.
IDE: Most Go IDEs (VS Code with gopls, GoLand) recognise
go:generate directives. In VS Code, run Go: Generate from the
command palette. In GoLand, right-click the file and select
"Run go generate."
For a taxonomy with user_create (required: actor_id, outcome)
and auth_failure events:
Constants:
const (
EventUserCreate = "user_create"
EventAuthFailure = "auth_failure"
CategoryWrite = "write"
CategorySecurity = "security"
FieldActorID = "actor_id"
FieldOutcome = "outcome"
FieldSourceIP = "source_ip"
)Typed Builder:
// Required fields are constructor parameters — compile-time safety
func NewUserCreateEvent(actorID string, outcome string) *UserCreateEvent
// Optional fields are chainable setters typed from the YAML `type:`
// annotation (default string)
func (e *UserCreateEvent) SetTargetID(v string) *UserCreateEvent
func (e *UserCreateEvent) SetReason(v string) *UserCreateEvent
func (e *UserCreateEvent) SetQuota(v int) *UserCreateEvent // type: int
func (e *UserCreateEvent) SetCreatedAt(v time.Time) *UserCreateEvent // type: time
func (e *UserCreateEvent) SetIdleTimeout(v time.Duration) *UserCreateEvent // type: duration
// Implements audit.Event — pass directly to auditor.AuditEvent()
func (e *UserCreateEvent) EventType() string // returns "user_create"
func (e *UserCreateEvent) Fields() audit.Fields // returns the constructed field map
// Metadata accessors for introspection
func (e *UserCreateEvent) FieldInfo() UserCreateFields // typed struct (compile-time field access)
func (e *UserCreateEvent) FieldInfoMap() map[string]audit.FieldInfo // flat map (audit.Event interface, dynamic lookup)
func (e *UserCreateEvent) Categories() []audit.CategoryInfo
func (e *UserCreateEvent) Description() string// Type-safe — typos fail at compile time, and wrong value types too
err := auditor.AuditEvent(
NewUserCreateEvent("alice", "success").
SetTargetID("user-42").
SetReason("admin request"),
)Every generated setter takes a typed Go parameter, never any. The
type comes from one of two places:
| Field origin | Setter type | Can type: change it? |
|---|---|---|
| Reserved standard field (see Reserved Field Names for the canonical list) | Library-authoritative Go type — string for most names; int for source_port, dest_port, file_size; time.Time for start_time, end_time |
No. type: MUST NOT be declared on a reserved standard field; the taxonomy parser rejects any such override with an error wrapping audit.ErrConfigInvalid. |
Consumer-declared field with type: annotation |
Annotated Go type (see Typed Custom Fields) | — the annotation is the source. |
Consumer-declared field with no type: annotation |
string (default) |
Yes — add type: to widen to int, bool, time.Time, etc. |
The any parameter type does not appear in generated code: there is
no path that produces an untyped setter. Reserved fields always use
the library type; consumer fields default to string and become
typed when annotated.
Compile-time checking therefore extends to value types as well as field names:
e.SetSourcePort(443) // OK — SetSourcePort takes int
e.SetSourcePort("443") // compile error: cannot use "443" (string) as intEvery custom (non-reserved) field in the taxonomy may carry a
type: annotation to produce a Go-typed setter. Accepted values:
YAML type: |
Generated Go setter param | Notes |
|---|---|---|
string (default when omitted) |
v string |
Fallback — no extra annotation needed |
int |
v int |
Most audit counters; JSON-numeric on the wire |
int64 |
v int64 |
Use when the value clearly exceeds 2³¹ |
float64 |
v float64 |
Scores, rates, latencies (if stored as seconds) |
bool |
v bool |
Flags, binary outcomes |
time |
v time.Time |
Timestamps (RFC 3339 on the wire) |
duration |
v time.Duration |
Elapsed times, TTLs |
Reserved standard fields (actor_id, source_ip, dest_port, …)
always use the library-authoritative Go type and reject any YAML
type: override — the generator's reserved-field table stays
canonical.
Example taxonomy:
events:
request_handled:
fields:
outcome: {required: true} # reserved → string
actor_id: {required: true} # reserved → string
endpoint: {type: string} # explicit string
status_code: {type: int} # typed int
response_ms: {type: int64} # typed int64
received_at: {type: time} # typed time.Time
idle_timeout: {type: duration} # typed time.Duration
privileged: {type: bool} # typed boolUnknown type values are rejected at taxonomy parse time with the
valid-set listed in the error message (e.g. unknown type "strng" (valid: string, int, int64, float64, bool, time, duration)).
Each tagged release publishes a multi-arch (amd64 + arm64) OCI
image at ghcr.io/axonops/audit-gen with three tags:
| Tag | Updates | When to use |
|---|---|---|
:vX.Y.Z (e.g. :v1.0.0) |
Pinned to the exact release | CI pipelines (recommended — reproducible builds) |
:vX.Y (e.g. :v1.0) |
Floats over patch releases | Adopters who want patch-level updates without minor surprises |
:latest |
Floats over every release | Local dev / quick experiments only |
The image runs audit-gen as a non-root user from a
distroless/static
base — no shell, no package manager, ~5 MB compressed. Mount your
source tree into /src and the binary's working directory matches:
docker run --rm \
-v "$PWD":/src \
ghcr.io/axonops/audit-gen:v1.0.0 \
-input /src/taxonomy.yaml \
-output /src/audit_generated.go \
-package mainFor CI that runs go generate ./..., prefer the binary release
tarball over the image — go generate invokes go run, not
docker run. The image is for pipelines that explicitly call out
to a containerised codegen step (e.g., language-agnostic CI
runners that don't have a Go toolchain).
Every image manifest is signed via Sigstore keyless OIDC against the same identity as the release tarball checksum (#516):
cosign verify \
--certificate-identity 'https://github.com/axonops/audit/.github/workflows/release.yml@refs/tags/v1.0.0' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
ghcr.io/axonops/audit-gen:v1.0.0The verification proves the image was produced by the audit project's published release workflow at the named tag, with a transparency-log entry recorded in Rekor. See docs/releasing.md for the full verification model.
| Flag | Default | Description |
|---|---|---|
-input |
(required) | Path to taxonomy YAML file |
-output |
(required) | Output Go file path; use - for stdout |
-package |
(required) | Go package name for the generated file |
-types |
true |
Generate event type constants |
-fields |
true |
Generate field name constants |
-categories |
true |
Generate category constants |
-labels |
true |
Generate sensitivity label constants |
-builders |
true |
Generate typed event builder structs |
-standard-setters |
all |
all = every builder gets a setter for every reserved standard field (IDE-autocomplete-friendly); explicit = only taxonomy-declared reserved fields produce setters (cuts generator output by ~80 % for small schemas) |
Generated builders satisfy the FieldsDonor extension interface via
the unexported donateFields() sentinel method. When an event reaches
Auditor.AuditEvent and is recognised as a donor, the auditor takes
ownership of the builder's Fields map — no defensive copy. Combined
with the W2 zero-copy drain pipeline (#497), this puts generated
builders on a path that achieves zero allocations per event on the
drain side after pool warm-up.
Single-use rule: generated builders are single-use per
AuditEvent call. Re-using the same builder for a second
AuditEvent is undefined behaviour — the auditor mutates the donated
Fields map (merging standard-field defaults) before serialisation.
Build a fresh builder per event.
For the full performance model, fast-path / slow-path comparison, and
benchmark methodology see docs/performance.md.
- Progressive Example: Code Generation — complete working example
- Taxonomy Validation — YAML schema reference
- Performance: Fast Path and Slow Path — drain pipeline allocation model
- ADR 0001: Fields Ownership Contract —
FieldsDonordesign rationale - Behavioural specification: typed_builders.feature — BDD scenarios that exercise audit-gen output end-to-end (constructor, setters for every reserved-field type, FieldsDonor donation, metadata accessors). Companion to dynamic_emission.feature for the runtime
audit.NewEventpath.