Skip to content

Go SDK

Send events and read profiles, activity and insights from a Go backend with github.com/pug-sh/sdk-go and a private key.

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

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

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

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

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.

Navigation

Type to search...

↑↓ navigate↵ selectEsc close