github.com/pug-sh/sdk-go sends events from your Go backend and reads
analytics back - profiles, activity and insights. It authenticates with your
private key (prv_...), so it belongs on the server only. It follows the
same model as every Pug SDK with one exception: a server has no ambient
user or session to reset, so you name the distinctID on each call instead.
Install
go get github.com/pug-sh/sdk-goView github.com/pug-sh/sdk-go on pkg.go.dev
The module needs Go 1.26+. Its package is named pug, so import it under
that name:
import pug "github.com/pug-sh/sdk-go"Unlike the client SDKs it authenticates with your private key (prv_...), so
keep it on the server - in an environment variable or a secret store, never in
code shipped to a browser or app. Construct one client at startup and share it:
a *pug.Client is safe for concurrent use.
Working with a coding agent? Paste this instead - it points the agent at the markdown copy of this page, so it works from the reference below rather than from memory. More on the approach in Install with AI.
Add Pug product analytics to this Go backend.
Read these first - they are the current source of truth, and more recent than your training data:
- https://docs.pug.sh/sdks/go/index.md - SDK reference
- https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog
Then read this codebase before you write anything, and work out what the service actually does: its routes or handlers, its main models, and the state changes that matter to the business. On a server the events worth tracking are the ones a browser cannot observe - a payment settling, a webhook arriving, a job finishing, a subscription lapsing - not request logging. If you cannot state what this service is for in one sentence, keep reading.
Then:
1. Run go get github.com/pug-sh/sdk-go. It needs Go 1.26+, and its package is named pug, so import it as pug "github.com/pug-sh/sdk-go".
2. Construct one client with pug.New at startup and share it - do not build one per request. It is safe for concurrent use, so hand it to handlers the way this codebase passes its other dependencies. Its APIKey is the private key (prv_...), read from an environment variable, never committed. pug.New returns an error for a missing or public key - handle it at startup.
3. Remember the argument order: client.Track(distinctID, kind, fields...). The distinctID comes first because a server has no ambient user - use our own user ID, or the anon-... ID forwarded from the client. Properties are typed fields such as pug.String("plan", "pro") and pug.Float("amount", 49), in the same list as options such as pug.WithTimestamp, and a kind held in a variable needs pug.Kind(name).
4. Add track calls for the three or four state changes you identified.
5. Call client.Identify(externalID, traits...) when a user record is created or changes materially, not on every request. Use the named traits - pug.Name, pug.Email and the rest - for the keys the dashboard reads.
6. Call client.Close(ctx) in the graceful-shutdown path, after the HTTP server has shut down, so queued events and identifies flush, and set an OnError handler so dropped events and identifies are logged rather than lost.
Name events from the catalog before you invent one. The Go SDK knows 114 of the 127 well-known events, and client.TrackWellKnown takes each one's typed message - &commoneventsv1.PurchaseProperties{...} from github.com/pug-sh/sdk-go/gen/common/events/v1 - so the compiler checks the properties, where Track with a well-known kind checks them at runtime and drops the event on a wrong type. The ones a backend is placed to see are exactly the ones whose schemas are worth having - purchase, subscription_started, invoice_paid, payment_failed, order_refunded. Search the catalog for every action you are about to track, use TrackWellKnown for the ones that fit, and fall back to a custom snake_case name only when nothing fits. When you do, tell me which event you considered and why it did not.
There is no Reset on the server SDK, and no immediate option on Track - use Flush. Track and Identify never return errors; only the reads do. Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this service does, then list the files you changed, the event names you chose, and the environment variables I need to set.Initialize
Construct a client with pug.New once at startup with your private key -
every other call needs it first. Private keys live under Settings > API
Keys, but one is only shown when you create it -
if you don’t have yours, issue a new one.
import (
"log"
"os"
pug "github.com/pug-sh/sdk-go"
)
// Construct once at startup, share it across goroutines and requests.
client, err := pug.New(pug.Options{
APIKey: os.Getenv("PUG_PRIVATE_KEY"), // prv_... - server-side only, never ship to a client
OnError: func(err error, lost pug.Undelivered) {
log.Printf("pug dropped %d event(s) and %d identify(s): %v", len(lost.Events), len(lost.Identifies), err)
},
})
if err != nil {
log.Fatal(err)
}pug.New returns the client, or an error if APIKey is missing or doesn’t
start with prv_ - the server SDK refuses a public key up front rather than
failing later on the first read - or holds anything but printable ASCII, such
as the trailing newline a secrets file leaves. An Endpoint that isn’t an
absolute http(s) URL is refused too, so configuration mistakes surface at
startup; after that, ingestion never fails.
| Option | Type | Default | Description |
|---|---|---|---|
APIKey |
string |
required | Private key (prv_...), sent as x-api-key. Server-side only. |
Endpoint |
string |
Pug Cloud | Pug server origin. Defaults to https://api.pugs.dev - set it only when self-hosting. |
Batch |
pug.BatchConfig |
MaxSize: 100, MaxWait: 5 * time.Second, MaxQueueSize: 10_000 |
Ingestion buffers, one each for events and identifies: items per request (capped at 1000), the longest an item waits before a send, and how many to hold before the oldest are dropped. Zero fields take the defaults. |
OnError |
pug.OnError |
none | Dead-letter hook, called as (err, lost) with what couldn’t be delivered - permanent send failure, queue overflow, or still undelivered at Close. lost.Events or lost.Identifies holds the items; the other is always empty. |
Logger |
*slog.Logger |
slog.Default() |
Gets the SDK’s warnings and errors, each prefixed pug: . |
HTTPClient |
*http.Client |
a client on http.DefaultTransport |
Sends every request - set it for a proxy, custom TLS or instrumentation. |
Timeout |
time.Duration |
10 * time.Second |
Bounds each request whose context has no deadline: every background send, and any read you call without one. |
Two goroutines, one for events and one for identifies, send in batches, when
MaxSize are queued or MaxWait has passed. A transient failure, such as a
network error or a timeout, keeps them queued and retries MaxWait after the
failure; a permanent one - a client error such as Unauthenticated or
InvalidArgument - hands them to OnError as a *pug.Error
carrying that code. Overflow arrives as pug.ErrQueueOverflow, and items
Close couldn’t send as pug.ErrClosed or the error that failed their last
attempt - match them with errors.Is. OnError may run on the goroutine that
called Track or Identify, on a background sender, or on the one calling
Close, so it must be safe for concurrent use, return promptly, and not call
Flush or Close. Events the server refuses from a batch it otherwise accepts
are logged as a warning.
Call client.Close(ctx) on graceful shutdown so queued events and identifies
flush - anything still undeliverable, including what’s left when ctx ends, is
reported through OnError. Later Track and Identify calls are dropped with
a warning. The Go SDK has no Reset or Rotate: a server has no ambient user
or session to reset.
In an HTTP server
Construct the client in main, use it from your handlers, and close it after
the server has shut down, so the events of requests still in flight are queued
before the drain:
func main() {
client, err := pug.New(pug.Options{APIKey: os.Getenv("PUG_PRIVATE_KEY")})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
order := placeOrder(r) // your handler's own work
client.Track(order.UserID, "order.completed", pug.Float("amount", order.Total))
})
srv := &http.Server{Addr: ":8080", Handler: mux}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdown) // in-flight requests finish and queue their events
client.Close(shutdown) // then the queues drain
}Track events
Events are the foundation of every analytic in Pug. After the distinctID, the
next argument is the event name (the kind): custom names work without a
schema, while well-known events add typed
properties and richer dashboards.
// distinctID (who the event is for) comes first - a server has no ambient user.
client.Track("user-123", "order.completed", pug.Float("amount", 49), pug.String("currency", "USD"))
// Well-known events get typed, validated properties.
client.Track("user-123", "purchase",
pug.String("productId", "sku_123"), pug.Float("amount", 29.99), pug.String("currency", "USD"))client.Track(distinctID, kind, opts...) puts the distinctID first - a
server has no ambient user, so you name who each event is for: your external
ID, or an anon-... ID forwarded from the client. Track never fails and
returns at once: events are validated, batched, and sent in the background, and
invalid input is logged and dropped rather than returned.
Properties are typed fields, passed in the same list as the options below and in any order:
| Field | Sent as |
|---|---|
pug.String(key, v) |
string |
pug.Int(key, v) |
int, from any integer type |
pug.Float(key, v) |
double, even for a whole number |
pug.Bool(key, v) |
bool |
pug.Time(key, v) |
timestamp |
pug.Duration(key, v) |
duration string, such as "1.5s" |
pug.Any(key, v) |
mapped by its Go type (table below) |
When keys are only known at run time, pass a pug.Properties map; a
map[string]any needs converting, as pug.Properties(m). Fields and maps mix
freely, and a key set twice in one call takes the later value.
The kind is a pug.Kind. A string literal converts on its own, and a kind held
in a variable needs pug.Kind(name), so a distinct ID passed where the kind
belongs fails to compile:
kind := os.Getenv("CHECKOUT_EVENT")
client.Track("user-123", pug.Kind(kind), pug.Properties{"plan": "pro", "seats": 5})There is no immediate option on the server - flush explicitly instead:
err := client.Flush(ctx) // send what's queued now, and wait for that attempt
err = client.Close(ctx) // drain on graceful shutdown so nothing queued is lostBoth return ctx’s error if it ended first. A nil doesn’t mean every event
was delivered: from Flush it means the attempt finished, and a transient
failure re-queues for a later send; from Close it means the drain finished,
and every event it couldn’t send has gone to OnError and the log by the time
it returns - as it has when ctx ended first, too.
Track options:
| Option | Description |
|---|---|
pug.WithTimestamp(t) |
Override the event time with a time.Time; it defaults to now. A time before 1970 or after 9999 is ignored with a warning and the event is sent at the current time. |
pug.WithSessionID(id) |
A UUID to use as this event’s session ID - otherwise the client uses one for its lifetime. Anything but a UUID is ignored with a warning, and the event keeps the client’s session. |
pug.WithLocation(loc) |
Where the visitor is. Without it a server event carries no geo at all. See Visitor location. |
Property values in pug.Any and pug.Properties map by Go type:
| Go value | Sent as |
|---|---|
string, bool |
string, bool |
| integers | int - a uint64 past the int64 range is dropped |
float32, float64 |
double - NaN and ±Inf are dropped |
json.Number |
int when it has no fraction, else double |
time.Time, *timestamppb.Timestamp |
timestamp |
time.Duration, *durationpb.Duration |
duration string, such as "1.5s" |
encoding.TextMarshaler |
its text |
| other maps, slices and structs | JSON string |
nil |
omitted |
Pointers are followed, and a nil one is omitted. A value with no mapping, such
as a func, is dropped with a warning - the property, not the event. Strings are
capped at 1024 bytes and truncated with a warning. Decode JSON request bodies
with json.Decoder.UseNumber so whole numbers stay ints.
Property keys are sent as written, so order_total stays order_total.
A key starting with $ drops the event: that prefix is reserved for
auto-properties. So does a kind with anything but letters, digits, _, .
and - in it.
A well-known event’s properties are checked
against its schema before the event is queued, keyed by the fields’ JSON names
(productId, not product_id). A field’s type comes from the schema rather
than the Go value, so pug.Int("amount", 10) is sent as a double. A value of
the wrong type, or one failing a rule such as currency needing three
uppercase letters, drops the whole event, and the log names the field
(quantity: expected int32, got string). A key that is a field’s proto name
(product_id) doesn’t set the field: it is sent as a custom property with a
warning naming the JSON name, and if the field is required the event is
dropped. Keys outside the schema pass through as custom properties. Duration
fields such as position take a time.Duration and are sent as a string like
"92.5s".
client.TrackWellKnown(distinctID, event, opts...) takes the typed schema
instead, so the compiler checks field names and types. The kind comes from the
message, whose types live in github.com/pug-sh/sdk-go/gen/common/events/v1:
import (
commoneventsv1 "github.com/pug-sh/sdk-go/gen/common/events/v1"
"google.golang.org/protobuf/proto"
)
client.TrackWellKnown("user-123", &commoneventsv1.PurchaseProperties{
ProductId: proto.String("sku_123"),
Amount: proto.Float64(29.99),
Currency: proto.String("USD"),
}, pug.String("coupon", "FALL25"))Rules such as the currency pattern are still checked when it’s called, and a
violation drops the event with a log. Fields and Properties among opts are
sent as custom properties alongside the typed ones, except one keyed by a typed
field’s JSON name, which is skipped with a warning. A message that isn’t a
well-known server event, such as a web-only event’s, is refused with a log.
The distinctID must not start with cookieless-. The server reserves that
prefix and rejects a whole batch over one such event, so Track logs and drops
it before it can take other users’ events down with it. An empty distinctID,
or one that isn’t valid UTF-8, is dropped the same way.
Visitor location
Pug resolves geo from CDN headers, which describe whoever opened the
connection - your backend, on every request this SDK makes. So the server skips
geo enrichment on a private key entirely: an event carries the location you give
it, or none at all. pug.WithLocation is how you give it one.
client.Track("user-123", "purchase",
pug.String("productId", "sku_123"), pug.Float("amount", 29.99), pug.String("currency", "USD"),
pug.WithLocation(pug.Location{Country: "DE", City: "Berlin"}),
)| Field | Type | Notes |
|---|---|---|
Country |
string |
ISO 3166-1 alpha-2 (DE). Case and surrounding spaces are normalized; a code Pug doesn’t recognize is dropped. |
Continent, Region, City, PostalCode, MetroCode |
string |
Sent as given, trimmed. |
Timezone |
string |
IANA name (Europe/Berlin). |
Coordinates |
*pug.Coordinates |
Latitude and Longitude in decimal degrees, -90 to 90 and -180 to 180. Sent only as a pair - one out of range drops both, since a lone coordinate reads as a real position with the other axis at 0. |
Each field becomes the matching $-prefixed
auto-property:
PostalCode lands as $postalCode. That makes WithLocation the only way to
set an auto-property from this SDK - properties become custom properties,
and one whose key starts with $ drops the event.
An empty field is skipped silently. One that looks like a mistake - a country code Pug doesn’t know, a coordinate out of range - is dropped with a warning and the rest of the event still sends. Nothing is merged at ingestion, so a dropped field is a permanent hole rather than one the edge fills in later.
Identify and sessions
Identity turns anonymous events into actionable profiles. Use a stable, unique external ID - your database user ID, not an email (which can change). Only the first identify after an anonymous session merges the prior activity; the same external ID always resolves to the same profile, and traits are merged (never removed) server-side.
// On the first identify, pass the client's anonymous ID to merge its prior anonymous activity.
client.Identify("user-123",
pug.Email("user@example.com"), pug.String("plan", "pro"),
pug.WithAnonymousID("anon-abc123"),
)client.Identify(externalID, opts...) never fails and returns at once:
like Track, it queues the identify, and identifies go out in batches through
BatchIdentify - or one Identify call each on a server that predates it.
Delivery failures go to OnError in lost.Identifies.
Traits are the same typed fields as properties, or a pug.Traits map for keys
known only at run time. Named traits write the keys Pug’s dashboard reads; they
only compile in Identify, and an empty value is omitted, so a blank column
can’t erase a stored trait:
| Named trait | Key |
|---|---|
pug.Name(v) |
name |
pug.FirstName(v) |
firstName - with lastName, the dashboard composes a display name when name is unset |
pug.LastName(v) |
lastName |
pug.Email(v) |
email |
pug.Avatar(v) |
avatar - the dashboard shows only an absolute https URL |
Traits must be JSON-serializable. They travel as a protobuf Struct, so a
pug.Time trait arrives as an RFC 3339 string, a pug.Duration as "1.5s",
and every number as a double - an integer past 2^53 arrives rounded. A NaN or
±Inf trait is skipped with a warning and the rest still goes out. Traits that
won’t serialize, an externalID that is empty or starts with the reserved
cookieless- prefix, and an identify over 900 KiB are logged and nothing is
sent. Pass pug.WithAnonymousID(id) (must start with anon-) on the first
identify to merge a client’s prior anonymous activity into the profile, and
pug.WithDeviceID(id) to attach a device. There is no Reset or Rotate:
identity isn’t ambient on the server - you name the distinctID on every
Track call instead.
Sessions
The client uses a single session ID for its lifetime - override it per call
with pug.WithSessionID(id), since sessions are a client concept. For the
anonymous->identified model, see Core concepts.
Auto-tracking
There is no client-side auto-tracking on the server - you send every event
explicitly with client.Track(distinctID, ...). The server doesn’t fill in geo
or user agent either: both read the connection, which on a private key is your
backend, so pass the visitor’s location yourself. Server
events are also never tagged $bot, so reads never leave
them out as bot traffic.
Consent and privacy
The Go SDK has no consent gate and no redaction hooks, so both are yours:
respect the visitor’s consent choice before you call Track, and keep PII out
of the properties you pass.
Server-side reads
The private key authorizes ingestion and reads - the same insights,
profiles, and activity data the dashboard shows. Reads are request/response and
return a *pug.Error; they run in your control flow and are
never retried, so you own retry. Your ctx deadline bounds each one; without
one, Timeout does. Ingestion (Track / Identify) still never fails.
import (
commonv1 "github.com/pug-sh/sdk-go/gen/common/v1"
insightsv1 "github.com/pug-sh/sdk-go/gen/shared/insights/v1"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Look up a profile by your external ID.
profile, err := client.Profiles.GetByExternalID(ctx, "user-123")
// Auto-paginating: ranges over every matching profile; an error ends the loop.
for p, err := range client.Profiles.List(ctx, nil) {
if err != nil {
return err
}
fmt.Println(p.GetExternalId())
}
// Run an insights query - the same spec the dashboard uses.
now := time.Now()
result, err := client.Insights.Query(ctx, &insightsv1.QueryRequest{
Spec: &insightsv1.InsightQuerySpec{
InsightType: insightsv1.InsightType_INSIGHT_TYPE_TRENDS.Enum(),
Events: []*insightsv1.EventQuery{{Event: &commonv1.EventFilter{Kind: proto.String("order.completed")}}},
},
TimeRange: &commonv1.TimeRange{From: timestamppb.New(now.AddDate(0, 0, -30)), To: timestamppb.New(now)},
Granularity: insightsv1.Granularity_GRANULARITY_DAY.Enum(),
})| Namespace | Methods |
|---|---|
client.Profiles |
Get(ctx, id, opts...), GetByExternalID(ctx, externalID, opts...), Delete(ctx, id), List(ctx, req): an auto-paginating iter.Seq2 |
client.Activity |
Feed, EventExplorer, Heatmap, ProfileSessions, ProfileStats, FilterSchema, PropertyValues |
client.Insights |
Query, SegmentUsers, FilterSchema, PropertyValues |
Requests and responses are the generated protobuf types under
github.com/pug-sh/sdk-go/gen - shared/insights/v1, shared/activity/v1,
shared/profiles/v1 and common/v1 - whose optional fields take pointers
(proto.String, proto.Bool) and whose getters, such as p.GetExternalId(),
are nil-safe. Profiles.Get and GetByExternalID return the profile itself.
List takes nil to list every profile, and the whole iteration is one
streamed request, so without a deadline on ctx it is bounded by Timeout -
give a long listing a ctx with more time. Property names in reads are the
stored ones, as you tracked them.
Errors
A *pug.Error carries the Connect Code and Message and wraps the
underlying error. errors.Is matches it to the sentinel for its code -
pug.ErrNotFound, pug.ErrUnauthenticated, pug.ErrUnavailable and so on, one
per Connect code (see the error model). Code is zero when
the failure didn’t come from the RPC layer, and then it matches none:
profile, err := client.Profiles.GetByExternalID(ctx, "user-123")
if errors.Is(err, pug.ErrNotFound) {
// no profile with that external ID
}
var perr *pug.Error
if errors.As(err, &perr) {
log.Println(perr.Code, perr.Message)
}OnError gets a failed send as the same *pug.Error, so one errors.Is check
works for reads and dead letters alike.
Reads leave out traffic tagged as a bot at ingest: web-SDK events from crawler
user agents and datacenter networks. Events from this SDK are never tagged. To
count bot traffic, pass pug.IncludeBots() to Profiles.Get and
GetByExternalID, or set IncludeBots in the request for Profiles.List,
Insights.Query (on its Spec) and the activity Feed, EventExplorer,
Heatmap, ProfileSessions and ProfileStats:
profile, err := client.Profiles.GetByExternalID(ctx, "user-123", pug.IncludeBots())
sessions, err := client.Activity.ProfileSessions(ctx, &activityv1.GetProfileSessionsRequest{
DistinctId: proto.String("user-123"),
IncludeBots: proto.Bool(true),
})The request/response shapes match the shared.* Connect services - see the
API reference for field-by-field schemas. From a backend without an SDK,
call those services directly with your private key (see
Authentication).
Upgrading
v0.0.1 is the first release of github.com/pug-sh/sdk-go, so there is
nothing to upgrade from yet.