Chris Tam · Last updated 22 October 2026
A Go backend can choose a report experience before returning its response. Amplitude feature flags control that choice, and experimentation lets you compare what users do after they encounter it.
In this tutorial, you'll extend the Go product analytics report app, choose a summary for signed-in users, request exposure tracking at that decision, and track an export as the conversion.
A preview endpoint that returns layout: control or layout: treatment, with a summary only for treatment. A separate export endpoint returns CSV and tracks Report Exported using the same identity. It never evaluates the flag again.
Prerequisites
- The Go 1.27 report app from the Go product analytics tutorial, with
analytics.go,auth.go,reports.go, andserver.goin the module root. Keep its analytics client and drain-before-close lifecycle. - Existing verified authentication that supplies a canonical user ID and account plan through
WithVerifiedUser. The standalone example stays anonymous until your real authentication middleware supplies a user. - An Amplitude project with permission to create a server deployment, flag, and experiment. Use the same project as the analytics API key.
Sign up for an Amplitude account and compare plans for your team's needs.
The files below add to the earlier app. Keep unrelated application routes, authorization, and request protections when merging them.
1. Install the Experiment SDK
From the existing module root, install the package named in the Experiment Go SDK reference.
Terminal
go get github.com/amplitude/experiment-go-server@v1.13.1
mkdir -p cmd/flagsKeep go.mod and go.sum with the application. This page adds a separate cmd/flags entry point so you can retain the earlier executable while reviewing the integration.
2. Create the report flag
In Amplitude, go to Experiments > Deployments, select Create Deployment, choose the analytics project, and create a Server deployment for this service. Keep its key in the server environment.
Go to Experiment > Feature Flags > Create Feature Flag. Set the key to report-summary, choose Remote evaluation, and attach the server deployment. Configure the variant values as control and treatment; only the exact value treatment enables the summary in this example. Save the variant changes and use Activate flag when the configuration is ready.
The following is a configuration checklist for those UI choices, not an API request:
Report flag settings
{
"flag_key": "report-summary",
"evaluation_mode": "remote",
"deployment_type": "Server",
"variant_values": ["control", "treatment"],
"targeting_property_available": "plan"
}Pass plan from verified account data if you target on it. For manual testing, configure your test users to receive each variant in the flag's targeting settings. Keep rollout and eligibility appropriate for your project.
3. Evaluate at the report decision
I'll reuse one remote client, build a fresh evaluation user per request, and ask only for the flag this response uses. A half-second timeout and zero retries keep the request from waiting through multiple evaluation attempts.
flags.go
package buildhub
import (
"context"
"errors"
"strings"
"time"
"github.com/amplitude/experiment-go-server/pkg/experiment"
"github.com/amplitude/experiment-go-server/pkg/experiment/remote"
)
const ReportFlag = "report-summary"
type ReportFlags struct{ client *remote.Client }
func NewReportFlags(key, zone string) (*ReportFlags, error) {
if strings.TrimSpace(key) == "" {
return nil, errors.New("set server-only AMPLITUDE_DEPLOYMENT_KEY")
}
config, err := reportFlagConfig(zone)
if err != nil {
return nil, err
}
return &ReportFlags{client: remote.Initialize(key, config)}, nil
}
func reportFlagConfig(zone string) (*remote.Config, error) {
config := &remote.Config{FetchTimeout: 500 * time.Millisecond,
RetryBackoff: &remote.RetryBackoff{FetchRetries: 0}}
switch strings.ToUpper(zone) {
case "", "US":
config.ServerUrl = "https://api.lab.amplitude.com"
case "EU":
config.ServerUrl = "https://api.lab.eu.amplitude.com"
default:
return nil, errors.New("AMPLITUDE_SERVER_ZONE must be US or EU")
}
return config, nil
}
func (f *ReportFlags) Layout(ctx context.Context, identity Identity) string {
// This experiment targets signed-in users; anonymous requests keep baseline.
if identity.UserID == "" {
return "control"
}
user := &experiment.User{UserId: identity.UserID, DeviceId: identity.DeviceID,
UserProperties: map[string]interface{}{"plan": identity.Plan}}
variants, err := f.client.FetchV2WithContextAndOptions(user, ctx, &remote.FetchOptions{
FlagKeys: []string{ReportFlag}, TracksAssignment: true, TracksExposure: true})
if err != nil {
return "control"
}
if variant, ok := variants[ReportFlag]; ok {
// V2 also returns default and undeployed variants; keep our baseline for them.
if variant.Metadata["default"] == true || variant.Metadata["deployed"] == false {
return "control"
}
if variant.Value == "treatment" {
return "treatment"
}
}
return "control"
}The user and device IDs come from the earlier app's verified request context and analytics metadata header. Explicit plan context makes the current account value available to targeting without waiting for a profile update.
Anonymous requests keep the baseline. Evaluation errors, missing or unknown variants, and default or undeployed variants also keep the baseline. A fallback is an application choice; it is not proof that Experiment assigned a real control variant.
Why. The SDK adds library information to the user passed to fetch. A fresh object inside Layout keeps concurrent requests from sharing that mutable state. The context-aware call also lets a canceled request stop waiting for evaluation. Regional hosts match the project's data residency; the deployment key stays server-side.
TracksExposure: true requests exposure tracking for this flag at the preview decision. Use it where the experience is selected, rather than in generic middleware or prefetch code. The server's decision does not prove the browser displayed the response. Keep the exposure event out of the export handler and avoid sending a second manual exposure for the same decision.
4. Track the export separately
I'll replace only the report's preview handler and add an export handler. Treatment adds a summary to the existing report response. Export queues Report Exported only after the CSV write succeeds.
flag_reports.go
package buildhub
import (
"encoding/json"
"net/http"
)
func FlagPreviewHandler(analytics *Analytics, flags *ReportFlags) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity := RequestIdentity(r)
layout := flags.Layout(r.Context(), identity)
report := map[string]interface{}{"report_type": "usage", "days": 7, "layout": layout}
if layout == "treatment" {
report["summary"] = "Your last seven days are ready to export."
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(report); err == nil {
analytics.Report("Report Previewed", identity)
}
})
}
func ExportHandler(analytics *Analytics) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", `attachment; filename="usage.csv"`)
if _, err := w.Write([]byte("report_type,days\nusage,7\n")); err == nil {
analytics.Report("Report Exported", RequestIdentity(r))
}
})
}analytics.Report keeps the earlier event properties, identity handling, and plan Identify operation. Both endpoints use the same canonical user ID. Keep your application's report authorization and data access checks around these handlers.
MCP. Use Amplitude's MCP server to inspect existing flags, metrics, and event names before creating another report conversion metric.
5. Connect the routes
I'll pass the flagged preview into the existing router and add export to that same mux. Keep the app's verified authentication middleware around it, together with any agent or application routes you've already added.
cmd/flags/main.go
// Keep verified auth, agent routes and other Go page additions in the app.
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)
}
flags, err := hub.NewReportFlags(os.Getenv("AMPLITUDE_DEPLOYMENT_KEY"), os.Getenv("AMPLITUDE_SERVER_ZONE"))
if err != nil {
analytics.Close()
log.Fatal(err)
}
mux := hub.NewRouter(analytics, hub.FlagPreviewHandler(analytics, flags))
mux.Handle("POST /api/reports/export", hub.ExportHandler(analytics))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := hub.Serve(ctx, "127.0.0.1:8080", mux, analytics); err != nil {
log.Print(err)
}
}This entry point uses the example module path from Go product analytics. Replace it with your module's path when integrating into an existing service. The shared server still drains active handlers before it closes analytics.
Stop the earlier local server before starting this entry point on the same port. Set both keys in the process environment:
Terminal
go mod tidy
export AMPLITUDE_API_KEY='your-project-api-key'
export AMPLITUDE_DEPLOYMENT_KEY='your-server-deployment-key'
export AMPLITUDE_SERVER_ZONE=US
go run ./cmd/flagsUse EU for an EU project. The analytics API key and deployment key have different roles: one sends product events, and the other authorizes evaluation for the deployment. Keep both tied to the same project. This executable reads process variables and does not load a .env file.
6. Set up the experiment metric
To compare exports between variants, convert the flag to an experiment and configure a primary success metric for Report Exported. In the Metrics section, select or create that metric and set the expected direction to increase. Review targeting and allocation before selecting Start Experiment.
Use [Experiment] Exposure as the exposure event for the explicit exposure tracking requested by this example. Report Previewed describes the product action, and [Experiment] Assignment is a distinct assignment event. The event-tracking reference explains the difference.
This is the expected journey to verify, not an event payload to send manually:
Experiment journey
Same user ID
POST /api/reports/preview
evaluate report-summary and request exposure
return control or treatment report
queue Report Previewed
POST /api/reports/export
return CSV
queue Report ExportedA rollout changes who sees the summary. Experiment analysis uses the processed exposure and conversion events to measure the outcome; the application should not infer a winner from its own treatment response count.
Verify it worked
Use your application's authenticated client for both variant cases. A raw request to the unmodified standalone server stays anonymous and deliberately receives the baseline.
Terminal
curl -i -X POST http://127.0.0.1:8080/api/reports/preview \
-H 'X-Amplitude-Device-Id: go-flag-anonymous-test'
curl -i -X POST http://127.0.0.1:8080/api/reports/export \
-H 'X-Amplitude-Device-Id: go-flag-anonymous-test'- Confirm the anonymous preview returns
layout: controlwithout a summary, and export returns a CSV attachment. These anonymous requests do not evaluate the flag. - Through real verified authentication, send a preview for a test user configured to get treatment. Confirm
layout: treatmentand the summary, then repeat for a control user and confirm no summary. Keep the same user ID for evaluation and product events. - Verify each authenticated preview requests only
report-summary, with assignment and exposure tracking enabled. Export must not trigger another evaluation request. A successful evaluation response proves the request succeeded, not that Amplitude finished processing exposure. - In Amplitude, inspect delivery counts in the Ingestion Debugger and search the user under Users > User Profiles. Check
Report Previewed,Report Exported, and processed[Experiment] Exposurefor the actual flag and variant. Then check the experiment's exposure and conversion analysis. - In development, test evaluation failure and a request timeout. The report should remain available with the baseline. Do not count these responses as successful control assignments. Stop the server gracefully and allow the analytics SDK to send its final batch.
Troubleshooting
Every response uses the baseline
Confirm that verified authentication supplied a user, the flag belongs to the configured deployment, and its exact values are control and treatment. Missing flags, unexpected values, default or undeployed variants, and evaluation failures all keep the baseline.
The flag works in one environment only
Match the deployment key, analytics project, region, and deployment membership. A flag in another project or deployment is not included just because its key matches.
A pro user misses a plan targeting rule
Pass the current plan from verified account data through the request identity. Keep the spelling and value consistent with the flag's targeting condition. User properties explicitly passed to evaluation take precedence over historical values.
Exports appear but the experiment has no exposures
Confirm TracksExposure: true, the report-summary scope, deployment membership, and a matching user ID. Inspect processed [Experiment] Exposure separately from assignment. The export handler deliberately does not evaluate the flag.
The report becomes slow when evaluation is unavailable
Keep the request timeout bounded and retry policy suitable for the HTTP path. This sample uses one attempt with a 500-millisecond timeout and falls back to the baseline. Keep reporting and authorization independent of evaluation availability.
Frequently asked questions
Does a flag replace authorization?
Keep authorization in your existing application controls. This flag changes the report summary after the request has passed your normal access checks.
Should I reuse an evaluation user across requests?
Reuse the SDK client and construct a new experiment.User inside each call. The SDK writes library context to that object, so sharing it across concurrent handlers can mix mutable state.
Should exporting fetch the flag again?
Export is the outcome being measured. It sends Report Exported with the same user identity and leaves evaluation at the preview decision, where the experience is selected.
Does a timeout count as a control assignment?
A timeout chooses the application's baseline for availability. Confirm processed exposure and conversion events in Amplitude before interpreting experiment results.
Full code reference
Unchanged from Go product analytics; keep your versions: analytics.go, auth.go, reports.go, and server.go. Add these three files, retaining all existing authentication, routes, and shutdown hooks.
// flags.go
package buildhub
import (
"context"
"errors"
"strings"
"time"
"github.com/amplitude/experiment-go-server/pkg/experiment"
"github.com/amplitude/experiment-go-server/pkg/experiment/remote"
)
const ReportFlag = "report-summary"
type ReportFlags struct{ client *remote.Client }
func NewReportFlags(key, zone string) (*ReportFlags, error) {
if strings.TrimSpace(key) == "" {
return nil, errors.New("set server-only AMPLITUDE_DEPLOYMENT_KEY")
}
config, err := reportFlagConfig(zone)
if err != nil {
return nil, err
}
return &ReportFlags{client: remote.Initialize(key, config)}, nil
}
func reportFlagConfig(zone string) (*remote.Config, error) {
config := &remote.Config{FetchTimeout: 500 * time.Millisecond,
RetryBackoff: &remote.RetryBackoff{FetchRetries: 0}}
switch strings.ToUpper(zone) {
case "", "US":
config.ServerUrl = "https://api.lab.amplitude.com"
case "EU":
config.ServerUrl = "https://api.lab.eu.amplitude.com"
default:
return nil, errors.New("AMPLITUDE_SERVER_ZONE must be US or EU")
}
return config, nil
}
func (f *ReportFlags) Layout(ctx context.Context, identity Identity) string {
// This experiment targets signed-in users; anonymous requests keep baseline.
if identity.UserID == "" {
return "control"
}
user := &experiment.User{UserId: identity.UserID, DeviceId: identity.DeviceID,
UserProperties: map[string]interface{}{"plan": identity.Plan}}
variants, err := f.client.FetchV2WithContextAndOptions(user, ctx, &remote.FetchOptions{
FlagKeys: []string{ReportFlag}, TracksAssignment: true, TracksExposure: true})
if err != nil {
return "control"
}
if variant, ok := variants[ReportFlag]; ok {
// V2 also returns default and undeployed variants; keep our baseline for them.
if variant.Metadata["default"] == true || variant.Metadata["deployed"] == false {
return "control"
}
if variant.Value == "treatment" {
return "treatment"
}
}
return "control"
}
// flag_reports.go
package buildhub
import (
"encoding/json"
"net/http"
)
func FlagPreviewHandler(analytics *Analytics, flags *ReportFlags) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity := RequestIdentity(r)
layout := flags.Layout(r.Context(), identity)
report := map[string]interface{}{"report_type": "usage", "days": 7, "layout": layout}
if layout == "treatment" {
report["summary"] = "Your last seven days are ready to export."
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(report); err == nil {
analytics.Report("Report Previewed", identity)
}
})
}
func ExportHandler(analytics *Analytics) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", `attachment; filename="usage.csv"`)
if _, err := w.Write([]byte("report_type,days\nusage,7\n")); err == nil {
analytics.Report("Report Exported", RequestIdentity(r))
}
})
}
// cmd/flags/main.go
// Keep verified auth, agent routes and other Go page additions in the app.
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)
}
flags, err := hub.NewReportFlags(os.Getenv("AMPLITUDE_DEPLOYMENT_KEY"), os.Getenv("AMPLITUDE_SERVER_ZONE"))
if err != nil {
analytics.Close()
log.Fatal(err)
}
mux := hub.NewRouter(analytics, hub.FlagPreviewHandler(analytics, flags))
mux.Handle("POST /api/reports/export", hub.ExportHandler(analytics))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := hub.Serve(ctx, "127.0.0.1:8080", mux, analytics); err != nil {
log.Print(err)
}
}Hand it to your agent
Use https://amplitude.com/build/go-feature-flags to extend this Go report service.
Preserve the existing analytics client, verified authentication and authorization,
per-request identity, application routes, and drain-before-close lifecycle.
Initialize one Experiment Go remote client with a server deployment key from the
analytics project. Configure US/EU correctly. Construct a fresh experiment.User
for each preview. Evaluate only report-summary with the request context and
request assignment plus exposure tracking where the preview chooses its layout.
Serve the baseline for anonymous users, errors, missing or unknown values, and
default or undeployed variants. Only treatment adds summary. Add an export route
that returns CSV and queues Report Exported after a successful write; never fetch
a flag or send another manual exposure on export. Keep keys in process environment.
Check both variants, scoped exposure requests, conversion identity, cancellation,
timeout, concurrency, and graceful final-event delivery. Verify remote exposure
processing and experiment analysis separately in Amplitude. Report changed files
and actual test results.What this unlocks
Feature flags control which eligible users receive the report summary.
Experimentation compares Report Exported after processed [Experiment] Exposure events.
Product analytics shows report previews and exports alongside the rest of the user's activity.
Next steps
Reference docs
Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.