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

> Documentation Index
> Fetch the complete documentation index at: https://docs.pug.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Go SDK

`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](/sdks) with one exception: a server has no ambient
user or session to reset, so you name the `distinctID` on each call instead.

## Install

```bash
go get github.com/pug-sh/sdk-go
```

[View `github.com/pug-sh/sdk-go` on pkg.go.dev](https://pkg.go.dev/github.com/pug-sh/sdk-go)

The module needs **Go 1.26+**. Its package is named `pug`, so import it under
that name:

```go
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](/get-started/ai-setup).

```text
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](/get-started/authentication) -
if you don't have yours, issue a new one.

```go
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`](#errors)
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:

```go
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](/reference/well-known-events) add typed
properties and richer dashboards.

```go
// 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:

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

```go
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](#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](/reference/well-known-events)'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`:

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

```go
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](/reference/auto-properties#geo-enrichment-cloudflare):
`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.

```go
// 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](/get-started/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](#visitor-location) yourself. Server
events are also never tagged `$bot`, so [reads](#server-side-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`](#errors)**; 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.

```go
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](/api#error-model)). `Code` is zero when
the failure didn't come from the RPC layer, and then it matches none:

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

```go
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](/api) for field-by-field schemas. From a backend without an SDK,
call those services directly with your private key (see
[Authentication](/get-started/authentication)).

## Upgrading

**v0.0.1** is the first release of `github.com/pug-sh/sdk-go`, so there is
nothing to upgrade from yet.

Source: https://docs.pug.sh/sdks/go/index.mdx
