---
title: "How to add product analytics to a Go backend"
description: "Track report previews in Go with Amplitude. Keep identity per request, forward anonymous device IDs, and drain handlers before SDK shutdown."
canonical: "https://amplitude.com/en-us/build/go-product-analytics"
---

# How to add product analytics to a Go backend

Track report previews in Go with Amplitude. Keep identity per request, forward anonymous device IDs, and drain handlers before SDK shutdown.

Source: https://amplitude.com/en-us/build/go-product-analytics

---

# How to add product analytics to a Go backend

Track report previews with the Amplitude Go SDK and attach the right identity to each request. I'll add a reusable client and shut it down after active handlers finish. Allow about 20 minutes.

- Go 1.27
- net/http
- Go SDK 1.x
- 20 min

Chris Tam · Last updated 22 October 2026

Go's HTTP handlers are a useful place to track actions that reach your backend. [Amplitude product analytics](https://amplitude.com/amplitude-analytics) lets you inspect those actions by user and event property alongside the activity your frontend already sends.

In this tutorial, you'll track a report preview, attach verified user identity or an anonymous device ID, update a plan property, and give the SDK time to send its final batch during shutdown.

A `POST /api/reports/preview` endpoint that returns a seven-day usage report and queues `Report Previewed` with `report_type=usage`, `days=7`, and `framework=go`. Signed-in requests set `plan`; anonymous requests use their original device ID.

## Prerequisites

- Go 1.27, a terminal, and a Go HTTP application. The complete standalone example below uses `net/http` and module path `example.com/amplitude-build-go`.
- Your application's existing verified authentication for signed-in events. Keep the same stable user ID convention as your frontend.
- An Amplitude project API key from Settings > Projects > your project > General > Manage next to API Key.

[Sign up for free](https://app.amplitude.com/signup) to create an Amplitude account and get a project API key.

The standalone server also works anonymously. When integrating these files into an existing module, replace the example import path in the entry point with your module path and keep your existing authentication middleware.

## 1. Install the Go SDK

I'll start a small module and install the package from the [Go SDK reference](https://amplitude.com/docs/sdks/analytics/go/go-sdk). For an existing module, run only `go get` and keep your current `go.mod` and `go.sum`.

**Terminal**

Terminal

```sh
mkdir amplitude-go-report
cd amplitude-go-report
go mod init example.com/amplitude-build-go
go get github.com/amplitude/analytics-go@v1.3.1
mkdir -p cmd/analytics
```

Keep the generated module and checksum files in source control. I'll put the reusable files in the module root and the executable in `cmd/analytics`.

## 2. Configure one analytics client

I'll initialize the client once and reuse it across requests. Identity stays on each event, so two concurrent users share a transport without sharing user state.

**analytics.go**

analytics.go

```go
package buildhub

import (
	"errors"
	"log"
	"strings"
	"time"

	"github.com/amplitude/analytics-go/amplitude"
)

type Analytics struct{ client amplitude.Client }

// Payloads can contain identifiers. Keep SDK diagnostics out of application logs.
type sdkLogger struct{}

func (sdkLogger) Debugf(string, ...interface{}) {}
func (sdkLogger) Infof(string, ...interface{})  {}
func (sdkLogger) Warnf(string, ...interface{}) {
	log.Print("Amplitude SDK warning; inspect the project's Ingestion Debugger")
}
func (sdkLogger) Errorf(string, ...interface{}) {
	log.Print("Amplitude SDK error; inspect the project's Ingestion Debugger")
}

func NewAnalytics(apiKey, zone string) (*Analytics, error) {
	if strings.TrimSpace(apiKey) == "" {
		return nil, errors.New("set AMPLITUDE_API_KEY")
	}
	config := amplitude.NewConfig(apiKey)
	config.Logger = sdkLogger{}
	switch strings.ToUpper(zone) {
	case "", "US":
		config.ServerZone = amplitude.ServerZoneUS
	case "EU":
		config.ServerZone = amplitude.ServerZoneEU
	default:
		return nil, errors.New("AMPLITUDE_SERVER_ZONE must be US or EU")
	}
	config.ConnectionTimeout = 2 * time.Second
	config.FlushMaxRetries = 1
	return &Analytics{client: amplitude.NewClient(config)}, nil
}

func (a *Analytics) Report(eventType string, identity Identity) bool {
	if identity.UserID == "" && identity.DeviceID == "" {
		return false
	}
	options := amplitude.EventOptions{UserID: identity.UserID, DeviceID: identity.DeviceID, SessionID: -1}
	if identity.UserID != "" && identity.Plan != "" {
		properties := amplitude.Identify{}
		properties.Set("plan", identity.Plan)
		a.client.Identify(properties, options)
	}
	a.client.Track(amplitude.Event{EventType: eventType, EventOptions: options,
		EventProperties: map[string]interface{}{"report_type": "usage", "days": 7, "framework": "go"}})
	return true
}

// Call only after HTTP handlers have drained, not after every request.
func (a *Analytics) Close() { a.client.Shutdown() }
```

`Report` queues the Identify operation before the product event. The Identify operation updates `plan` for subsequent events. The returned boolean means the helper had enough identity to queue work; delivery still depends on the SDK and network.

**Why.** The logger keeps SDK payloads out of application logs and leaves a generic warning or error to investigate. Treat the project API key and user identifiers as values to keep out of logs. The two-second request timeout and one retry are choices for this small service; use the [configuration reference](https://amplitude.com/docs/sdks/analytics/go/go-sdk#configure-the-sdk) to size them for your traffic and shutdown window.

## 3. Read identity from verified authentication

I'll store the application's verified user in the request context. The optional device header comes from the frontend's current analytics device ID. It helps connect anonymous browser activity with the server event and carries no authentication authority.

**auth.go**

auth.go

```go
package buildhub

import (
	"context"
	"net/http"
	"strings"
	"unicode/utf8"
)

type User struct {
	ID   string
	Plan string
}
type userKey struct{}

// Existing authentication calls this only after it has verified the session.
// Never populate this value from an unverified user-ID header or request body.
func WithVerifiedUser(ctx context.Context, user User) context.Context {
	return context.WithValue(ctx, userKey{}, user)
}

type Identity struct {
	UserID   string
	DeviceID string
	Plan     string
}

func validID(value string) bool {
	if !utf8.ValidString(value) || utf8.RuneCountInString(value) < 5 || len(value) > 1024 {
		return false
	}
	switch strings.ToLower(value) {
	case "anonymous", "nil", "none", "null", "n/a", "na", "undefined", "unknown",
		`""""`, "00000000-0000-0000-0000-000000000000", "{}", "lmy47d", "0", "-1":
		return false
	}
	return true
}

func RequestIdentity(r *http.Request) Identity {
	identity := Identity{}
	if user, ok := r.Context().Value(userKey{}).(User); ok && validID(user.ID) {
		identity.UserID, identity.Plan = user.ID, user.Plan
	}
	if device := r.Header.Get("X-Amplitude-Device-Id"); validID(device) {
		identity.DeviceID = device
	}
	return identity
}
```

Call `WithVerifiedUser` from your existing session or token middleware after authentication succeeds, passing the canonical user ID and the plan read from your account data. The request body and an `X-User-ID` header cannot populate it. These helpers leave anonymous requests anonymous.

Use stable IDs at least five characters long. This example also limits ID length to 1,024 bytes and rejects the documented invalid placeholder IDs, matching them without regard to case. Keep your frontend's existing ID format; changing `12345` to `user-12345` only on the server creates a different identity.

**Why.** A process-wide user variable lets concurrent requests overwrite each other's identity. This handler reads a separate context value for each request and puts it directly into `EventOptions`. `SessionID: -1` leaves the server event outside an analytics visit; forwarding a device ID alone does not establish browser session continuity.

## 4. Track the report preview

I'll send one event after the response is successfully written. Replace the small report object with your application's existing report computation, keeping the event at the successful action boundary.

**reports.go**

reports.go

```go
package buildhub

import (
	"encoding/json"
	"net/http"
)

func PreviewHandler(analytics *Analytics) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		report := map[string]interface{}{"report_type": "usage", "days": 7}
		if err := json.NewEncoder(w).Encode(report); err == nil {
			analytics.Report("Report Previewed", RequestIdentity(r))
		}
	})
}

// Later Go pages replace only the preview handler and add routes to this mux.
func NewRouter(analytics *Analytics, preview http.Handler) *http.ServeMux {
	if preview == nil {
		preview = PreviewHandler(analytics)
	}
	mux := http.NewServeMux()
	mux.Handle("POST /api/reports/preview", preview)
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	return mux
}
```

Requests with no usable user or device ID still receive the report, and `Report` skips analytics for them. A failed response write also skips the event. A successful write means the server wrote the response; it does not prove a browser displayed it.

**MCP.** Ask your coding agent to inspect your project's existing event taxonomy through [Amplitude's MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp) before choosing event names. Keep an existing report event name if your project already uses one.

## 5. Drain requests before shutting down

I'll stop accepting requests, let active handlers finish, then close the SDK. That order gives the last handler a live client when it queues its event.

**server.go**

server.go

```go
package buildhub

import (
	"context"
	"errors"
	"net/http"
	"sync"
	"time"
)

func Serve(ctx context.Context, address string, handler http.Handler, analytics *Analytics) error {
	var active sync.WaitGroup
	server := &http.Server{Addr: address, ReadHeaderTimeout: 5 * time.Second,
		IdleTimeout: 60 * time.Second, WriteTimeout: 15 * time.Second,
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			active.Add(1)
			defer active.Done()
			handler.ServeHTTP(w, r)
		})}
	errorsFromServer := make(chan error, 1)
	go func() { errorsFromServer <- server.ListenAndServe() }()
	var serveErr error
	select {
	case <-ctx.Done():
	case serveErr = <-errorsFromServer:
	}
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := server.Shutdown(shutdownCtx); err != nil {
		_ = server.Close()
	}
	// Forced connection closure is not proof that a handler already returned.
	active.Wait()
	analytics.Close()
	if errors.Is(serveErr, http.ErrServerClosed) {
		return nil
	}
	return serveErr
}
```

The server allows ten seconds for graceful HTTP shutdown. If that deadline expires, it closes the connections and still waits for handlers to return before closing analytics. Keep your application's database and external API calls bounded and responsive to cancellation, and allow enough time in your deployment's termination grace period. A process kill can interrupt the in-memory queue.

**cmd/analytics/main.go**

cmd/analytics/main.go

```go
// Keep flag/agent routes and shutdown hooks added by later Go pages.
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"

	hub "example.com/amplitude-build-go"
)

func main() {
	analytics, err := hub.NewAnalytics(os.Getenv("AMPLITUDE_API_KEY"), os.Getenv("AMPLITUDE_SERVER_ZONE"))
	if err != nil {
		log.Fatal(err)
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	// Merge the app's verified authentication middleware around this router.
	if err := hub.Serve(ctx, "127.0.0.1:8080", hub.NewRouter(analytics, nil), analytics); err != nil {
		log.Print(err)
	}
```

This entry point listens on localhost for the walkthrough. When merging it into your service, wrap the router in your verified authentication middleware and keep the same drain-before-close order. Keep routes and shutdown hooks from other Go tutorials when changing this file.

**Terminal**

Terminal

```sh
go mod tidy
export AMPLITUDE_API_KEY='your-project-api-key'
export AMPLITUDE_SERVER_ZONE=US
go run ./cmd/analytics
```

`go mod tidy` records the imports after the files have been added. Use `EU` for an EU project. These variables must be in the process environment; this program does not load a `.env` file. Bind the address your hosting platform requires when deploying.

## Verify it worked

From a second terminal, send an anonymous report request with a recognizable device ID:

**Terminal**

Terminal

```sh
curl -i -X POST http://127.0.0.1:8080/api/reports/preview \
  -H 'X-Amplitude-Device-Id: go-report-test-20261021'
```

1. Confirm a `200` response with `report_type: usage` and `days: 7`. Wait for the SDK's batch interval or press Ctrl+C and let shutdown complete. A successful report response alone does not confirm event ingestion.
2. In Amplitude, open Data > Sources > Ingestion Debugger for the project. Check successful requests and errors there; it shows aggregate counts.
3. Open Users > User Profiles, search for device ID `go-report-test-20261021`, and find `Report Previewed` in the event stream. Allow ten seconds to one minute. Open Raw and check `report_type`, `days`, `framework`, the device ID, and `session_id: -1`.
4. In your authenticated application, repeat the request through the existing sign-in flow. Search the canonical user ID and check that `plan` matches the account. Run two signed-in users concurrently and confirm each has their own events and plan. An anonymous request sent after sign-out must use the frontend's new device ID.

Shutdown tries to flush the buffer. Network failure, retry exhaustion, queue capacity, or a forced termination can still lose events. Use a durable application event pipeline when the event must survive process loss.

## Troubleshooting

### The report succeeds but no event appears

The standalone app needs a usable `X-Amplitude-Device-Id` for anonymous requests. Confirm the API key, region, and Ingestion Debugger errors. Allow the batch to send before terminating the process.

### Signed-in requests are still anonymous

Your verified authentication middleware must call `WithVerifiedUser` and pass the returned context into the handler. The standalone example intentionally has no sign-in mechanism. Sending a user ID in JSON or a header does not authenticate a request.

### Frontend and backend actions show different users

Use the exact same canonical user ID and forward the frontend's existing device ID. Keep app logout responsible for resetting its anonymous identity. Never mint a new device ID on every backend request.

### Events disappear during a deploy

Wait for active handlers before `Close`, and give the process time to finish. Keep outbound calls bounded. SIGKILL, an expired deployment grace period, and exhausted SDK retries can interrupt delivery.

### Application logs show only a generic SDK warning

The custom logger suppresses SDK payload details. Inspect project delivery counts and errors in the Ingestion Debugger, then use a dedicated development environment for deeper investigation. Avoid enabling payload logs in a service handling customer data.

## Frequently asked questions

### Do I create a client for each request?

I reuse one client per running process. Each call supplies its own user and device IDs through `EventOptions`, which keeps concurrent requests separate.

### Should I call `Close` at the end of a handler?

Call it when the process is shutting down, after handlers finish. A closed client stops accepting new events, so closing it after one request breaks later tracking.

### Does the device header prove a user's identity?

The header is analytics metadata. Authenticate and authorize the request through your application's existing verified session or token before putting a user in the context.

### Does a report event capture the frontend screen?

This Go SDK sends server events. A frontend integration captures screen activity and provides the device ID if you want to connect that activity with the backend event.

## Full code reference

The module root uses package `buildhub`. The executable imports that root by the module path set in step 1. Keep these files together; if you already have a module, keep its path and update the executable import.

Full code reference

```go
// analytics.go
package buildhub

import (
	"errors"
	"log"
	"strings"
	"time"

	"github.com/amplitude/analytics-go/amplitude"
)

type Analytics struct{ client amplitude.Client }

// Payloads can contain identifiers. Keep SDK diagnostics out of application logs.
type sdkLogger struct{}

func (sdkLogger) Debugf(string, ...interface{}) {}
func (sdkLogger) Infof(string, ...interface{})  {}
func (sdkLogger) Warnf(string, ...interface{}) {
	log.Print("Amplitude SDK warning; inspect the project's Ingestion Debugger")
}
func (sdkLogger) Errorf(string, ...interface{}) {
	log.Print("Amplitude SDK error; inspect the project's Ingestion Debugger")
}

func NewAnalytics(apiKey, zone string) (*Analytics, error) {
	if strings.TrimSpace(apiKey) == "" {
		return nil, errors.New("set AMPLITUDE_API_KEY")
	}
	config := amplitude.NewConfig(apiKey)
	config.Logger = sdkLogger{}
	switch strings.ToUpper(zone) {
	case "", "US":
		config.ServerZone = amplitude.ServerZoneUS
	case "EU":
		config.ServerZone = amplitude.ServerZoneEU
	default:
		return nil, errors.New("AMPLITUDE_SERVER_ZONE must be US or EU")
	}
	config.ConnectionTimeout = 2 * time.Second
	config.FlushMaxRetries = 1
	return &Analytics{client: amplitude.NewClient(config)}, nil
}

func (a *Analytics) Report(eventType string, identity Identity) bool {
	if identity.UserID == "" && identity.DeviceID == "" {
		return false
	}
	options := amplitude.EventOptions{UserID: identity.UserID, DeviceID: identity.DeviceID, SessionID: -1}
	if identity.UserID != "" && identity.Plan != "" {
		properties := amplitude.Identify{}
		properties.Set("plan", identity.Plan)
		a.client.Identify(properties, options)
	}
	a.client.Track(amplitude.Event{EventType: eventType, EventOptions: options,
		EventProperties: map[string]interface{}{"report_type": "usage", "days": 7, "framework": "go"}})
	return true
}

// Call only after HTTP handlers have drained, not after every request.
func (a *Analytics) Close() { a.client.Shutdown() }

// auth.go
package buildhub

import (
	"context"
	"net/http"
	"strings"
	"unicode/utf8"
)

type User struct {
	ID   string
	Plan string
}
type userKey struct{}

// Existing authentication calls this only after it has verified the session.
// Never populate this value from an unverified user-ID header or request body.
func WithVerifiedUser(ctx context.Context, user User) context.Context {
	return context.WithValue(ctx, userKey{}, user)
}

type Identity struct {
	UserID   string
	DeviceID string
	Plan     string
}

func validID(value string) bool {
	if !utf8.ValidString(value) || utf8.RuneCountInString(value) < 5 || len(value) > 1024 {
		return false
	}
	switch strings.ToLower(value) {
	case "anonymous", "nil", "none", "null", "n/a", "na", "undefined", "unknown",
		`""""`, "00000000-0000-0000-0000-000000000000", "{}", "lmy47d", "0", "-1":
		return false
	}
	return true
}

func RequestIdentity(r *http.Request) Identity {
	identity := Identity{}
	if user, ok := r.Context().Value(userKey{}).(User); ok && validID(user.ID) {
		identity.UserID, identity.Plan = user.ID, user.Plan
	}
	if device := r.Header.Get("X-Amplitude-Device-Id"); validID(device) {
		identity.DeviceID = device
	}
	return identity
}

// reports.go
package buildhub

import (
	"encoding/json"
	"net/http"
)

func PreviewHandler(analytics *Analytics) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		report := map[string]interface{}{"report_type": "usage", "days": 7}
		if err := json.NewEncoder(w).Encode(report); err == nil {
			analytics.Report("Report Previewed", RequestIdentity(r))
		}
	})
}

// Later Go pages replace only the preview handler and add routes to this mux.
func NewRouter(analytics *Analytics, preview http.Handler) *http.ServeMux {
	if preview == nil {
		preview = PreviewHandler(analytics)
	}
	mux := http.NewServeMux()
	mux.Handle("POST /api/reports/preview", preview)
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	return mux
}

// server.go
package buildhub

import (
	"context"
	"errors"
	"net/http"
	"sync"
	"time"
)

func Serve(ctx context.Context, address string, handler http.Handler, analytics *Analytics) error {
	var active sync.WaitGroup
	server := &http.Server{Addr: address, ReadHeaderTimeout: 5 * time.Second,
		IdleTimeout: 60 * time.Second, WriteTimeout: 15 * time.Second,
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			active.Add(1)
			defer active.Done()
			handler.ServeHTTP(w, r)
		})}
	errorsFromServer := make(chan error, 1)
	go func() { errorsFromServer <- server.ListenAndServe() }()
	var serveErr error
	select {
	case <-ctx.Done():
	case serveErr = <-errorsFromServer:
	}
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := server.Shutdown(shutdownCtx); err != nil {
		_ = server.Close()
	}
	// Forced connection closure is not proof that a handler already returned.
	active.Wait()
	analytics.Close()
	if errors.Is(serveErr, http.ErrServerClosed) {
		return nil
	}
	return serveErr
}

// cmd/analytics/main.go
// Keep flag/agent routes and shutdown hooks added by later Go pages.
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"

	hub "example.com/amplitude-build-go"
)

func main() {
	analytics, err := hub.NewAnalytics(os.Getenv("AMPLITUDE_API_KEY"), os.Getenv("AMPLITUDE_SERVER_ZONE"))
	if err != nil {
		log.Fatal(err)
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	// Merge the app's verified authentication middleware around this router.
	if err := hub.Serve(ctx, "127.0.0.1:8080", hub.NewRouter(analytics, nil), analytics); err != nil {
		log.Print(err)
	}
```

## Hand it to your agent

Use this prompt with your application's codebase:

Using Cursor or Claude Code? Paste this prompt

```text
Use https://amplitude.com/build/go-product-analytics to add Amplitude to this Go
HTTP backend. Inspect the existing module path, router, verified authentication,
event taxonomy and shutdown lifecycle first. Reuse one Analytics Go SDK client
per process. Keep identity in request context and per-event EventOptions; use
the exact same canonical user ID as frontend analytics. Forward the frontend's
device ID only as analytics metadata. Track Report Previewed with report_type,
days and framework after the report response is written. Identify plan before
tracking for signed-in users. Keep anonymous requests anonymous. Keep API keys
in process environment and payloads out of logs. Drain handlers before SDK
shutdown. Preserve existing flag, agent and application routes. Check US/EU
configuration, missing/short IDs, concurrent users, failed writes and final
event delivery on graceful exit. Report which files changed and the test results.
```

## What this unlocks

[Product analytics](https://amplitude.com/amplitude-analytics) can segment `Report Previewed` by `plan` and compare report use across your frontend and backend.

[Feature flags](https://amplitude.com/feature-management) let you change the report summary for a chosen group, while `Report Exported` can measure the next product action.

[Agent analytics](https://amplitude.com/agent-analytics) uses the same canonical user ID to connect an assistant's work with the report actions it helps users complete.

## Next steps

[GoGo feature flagsEvaluate a report summary at the point the handler uses it and track exports as the conversion.](https://amplitude.com/build/go-feature-flags)[GoGo agent analyticsInstrument agent exchanges with the standard Go SDK and the managed agent event taxonomy.](https://amplitude.com/build/go-agent-analytics)[FrontendFlutter product analyticsTrack the screen and report action in a mobile client, using a shared identity convention.](https://amplitude.com/build/flutter-product-analytics)

Reference docs

[Go SDK →](https://amplitude.com/docs/sdks/analytics/go/go-sdk)[HTTP V2 event fields and identity requirements →](https://amplitude.com/docs/apis/analytics/http-v2)[Identify unique users →](https://amplitude.com/docs/data/sources/instrument-track-unique-users)[Find API keys →](https://amplitude.com/docs/apis/authentication)[Debug analytics →](https://amplitude.com/docs/analytics/debug-analytics)[Amplitude MCP →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

Amplitude is the leading [all-in-one analytics platform](https://amplitude.com/) for building better products. Product and engineering teams use Amplitude to connect [product analytics](https://amplitude.com/amplitude-analytics) and [web analytics](https://amplitude.com/web-analytics) with [session replay](https://amplitude.com/session-replay), [heatmaps](https://amplitude.com/heatmaps), [experimentation](https://amplitude.com/amplitude-experiment), [feature flags](https://amplitude.com/feature-management), and [guides and surveys](https://amplitude.com/guides-and-surveys) in one unified workflow, with [AI agents](https://amplitude.com/ai-agents) working across all of it. Learn how Amplitude is building [self-improving products](https://amplitude.com/self-improving-products) with [Wave](https://amplitude.com/wave), helping teams turn product data into PRs.

For agents

[View raw .md](/build/go-product-analytics.md)[llms.txt](/llms.txt)

## Ship the instrumentation, then read the data

Use the report events to see who reaches the preview and which users return to it.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
