Tracking with Google Analytics in Go: A Practical Guide

Tracking with Google Analytics in Go: A Practical Guide

I've built a few CLI tools that have picked up some decent usage. CC Switch helps developers manage multiple Claude Code sessions, and it's being used by quite a few people now. Every so often, someone asks: "Do you track usage? Would be interesting to know how people use it."

The answer is no, I don't track it. Privacy first. But it's a fair question, and one that kept coming up as I've built more tools. So I spent some time digging into how you'd actually integrate Google Analytics with Go if you wanted to. Turns out there are two completely different directions people care about: sending events to Google Analytics from your Go backend, and pulling analytics data out to build custom dashboards.

Most people searching for "golang google analytics" want to send data. That's what we'll focus on here.

The Two Directions

When people talk about Google Analytics integration with Go, they're usually asking about one of two things:

Sending events to Google Analytics - This is server-side tracking. Your Go application sends events to GA4 using the Measurement Protocol. Use cases include tracking CLI tool usage, API calls, backend conversions, or any server-side events you want to measure. No JavaScript required, no browser needed.

Pulling data from Google Analytics - This is about fetching your analytics data programmatically to build custom dashboards or automate reports. You use the Reporting API to query GA4 and get data back. More complex to set up, requires OAuth, but useful when you want to do something with your analytics data beyond what the GA4 interface offers.

The key difference: sending is straightforward, pulling is complicated. If you're tracking a CLI tool or backend service, you only need the first one.

Why Track a CLI Tool or Backend Service

Before we get into implementation, it's worth thinking about whether you should track at all. For CC Switch, I decided against it. Privacy matters more than knowing which commands people use most.

But there are legitimate reasons to track:

Understanding feature usage - Which commands get used? Which flags are popular? This helps prioritise development effort on features people actually use rather than ones you think are interesting.

Error and crash reporting - Knowing when and where things break helps fix issues faster. If a specific command fails frequently on a particular OS, that's useful signal.

Version adoption - Are people updating? If everyone's stuck on an old version, you might have a release process problem.

Geographic distribution - If you're making decisions about CDN locations or regional support, knowing where your users are helps.

The key is tracking events, not people. Anonymous client IDs, no personal information, easy opt-out. When I built GitPilotAI, I considered adding tracking to understand how the tool was being used in different workflows. Didn't implement it, but the technical approach would have been identical to what we're covering here.

The Go Library Landscape

There are a few libraries for working with Google Analytics in Go. Here's what's available:

LibraryStarsLast UpdateGA4 SupportPurpose
jpillora/go-ogle-analytics60+2019No (UA only)Sending events
olebedev/go-gamp10+2021No (UA only)Sending events
ozgur-yalcin/google-analytics5+2023No (UA only)Sending events
Raw HTTPN/ACurrentYesSending events
google.golang.org/api/analyticsOfficialActivev3 onlyPulling data
google.golang.org/api/analyticsdataOfficialActiveYes (GA4)Pulling data

The problem: most third-party libraries were built for Universal Analytics, which Google sunset in July 2024. They don't work with GA4, which is what you need now.

The practical reality: You're better off using raw HTTP requests to GA4's Measurement Protocol. It's simpler than you'd think, gives you full control, and you're not depending on abandoned libraries. For pulling data, use the official Google client libraries.

Sending Events: The Measurement Protocol

The Measurement Protocol is Google's API for sending events to Analytics from anywhere. No JavaScript needed, just HTTP POST requests. It's designed for server-side tracking.

Here's what you need:

  1. Measurement ID - Format: G-XXXXXXXXXX, found in GA4 under Admin > Data Streams
  2. API Secret - Generated in GA4 under Admin > Data Streams > Measurement Protocol
  3. Client ID - A UUID you generate and persist for each unique user/instance

The endpoint is straightforward:

https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=YOUR_SECRET

Notice there's no OAuth, no service accounts, no complex authentication. Just include your measurement ID and API secret in the URL. This is intentionally simple because Google wants you to send them data.

Implementation: Tracking CLI Commands

Let's build a basic tracker that you could drop into any Go CLI tool. This is production-ready code, not a toy example.

package analytics

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "time"

    "github.com/google/uuid"
)

type Tracker struct {
    MeasurementID string
    APISecret     string
    ClientID      string
    Enabled       bool
    client        *http.Client
}

type Event struct {
    Name   string                 `json:"name"`
    Params map[string]interface{} `json:"params"`
}

type Payload struct {
    ClientID string  `json:"client_id"`
    Events   []Event `json:"events"`
}

func NewTracker(measurementID, apiSecret, clientID string) *Tracker {
    return &Tracker{
        MeasurementID: measurementID,
        APISecret:     apiSecret,
        ClientID:      clientID,
        Enabled:       true,
        client: &http.Client{
            Timeout: 2 * time.Second,
        },
    }
}

func (t *Tracker) TrackEvent(ctx context.Context, eventName string, params map[string]interface{}) error {
    if !t.Enabled {
        return nil
    }

    // Run async so we don't block the CLI
    go func() {
        if err := t.sendEvent(context.Background(), eventName, params); err != nil {
            // Log error but don't crash
            // In production, you'd use proper logging
            fmt.Printf("Analytics error: %v\n", err)
        }
    }()

    return nil
}

func (t *Tracker) sendEvent(ctx context.Context, eventName string, params map[string]interface{}) error {
    payload := Payload{
        ClientID: t.ClientID,
        Events: []Event{
            {
                Name:   eventName,
                Params: params,
            },
        },
    }

    jsonData, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("failed to marshal payload: %w", err)
    }

    url := fmt.Sprintf(
        "https://www.google-analytics.com/mp/collect?measurement_id=%s&api_secret=%s",
        t.MeasurementID,
        t.APISecret,
    )

    req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
    if err != nil {
        return fmt.Errorf("failed to create request: %w", err)
    }

    req.Header.Set("Content-Type", "application/json")

    resp, err := t.client.Do(req)
    if err != nil {
        return fmt.Errorf("failed to send request: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
        return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
    }

    return nil
}

// GenerateClientID creates a new anonymous client ID
func GenerateClientID() string {
    return uuid.New().String()
}

This tracker does a few important things:

Non-blocking execution - Tracking happens in a goroutine so it never slows down your CLI. If tracking fails, your tool still works.

Timeout handling - 2-second timeout means you won't wait forever if GA4 is down. The request fails fast and your tool continues.

Error handling - Errors are logged but don't crash the application. Tracking is secondary to functionality.

Context support - Proper context handling for cancellation and timeouts.

Using the Tracker

Here's how you'd integrate this into a CLI tool:

package main

import (
    "context"
    "flag"
    "fmt"
    "os"
    
    "your-project/analytics"
)

var (
    measurementID = "G-XXXXXXXXXX"
    apiSecret     = "your_api_secret_here"
    version       = "0.1.0"
)

func main() {
    // Check for opt-out
    if os.Getenv("ANALYTICS_DISABLE") == "1" {
        // Skip tracker initialisation
        runCommand(nil)
        return
    }

    // Get or create client ID
    clientID, err := getClientID()
    if err != nil {
        // If we can't get a client ID, just run without tracking
        runCommand(nil)
        return
    }

    tracker := analytics.NewTracker(measurementID, apiSecret, clientID)
    
    runCommand(tracker)
}

func runCommand(tracker *analytics.Tracker) {
    command := flag.String("command", "", "Command to run")
    flag.Parse()

    // Track command execution
    if tracker != nil {
        ctx := context.Background()
        tracker.TrackEvent(ctx, "command_executed", map[string]interface{}{
            "command": *command,
            "version": version,
        })
    }

    // Actually run your command
    switch *command {
    case "hello":
        fmt.Println("Hello, World!")
    default:
        fmt.Println("Unknown command")
    }
}

func getClientID() (string, error) {
    // Check if client ID exists in config file
    homeDir, err := os.UserHomeDir()
    if err != nil {
        return "", err
    }

    configDir := filepath.Join(homeDir, ".your-cli")
    clientIDFile := filepath.Join(configDir, "client-id")

    // Try to read existing ID
    data, err := os.ReadFile(clientIDFile)
    if err == nil {
        return string(data), nil
    }

    // Generate new ID
    clientID := analytics.GenerateClientID()

    // Create config directory
    if err := os.MkdirAll(configDir, 0755); err != nil {
        return clientID, nil // Return ID but don't persist
    }

    // Save client ID
    if err := os.WriteFile(clientIDFile, []byte(clientID), 0644); err != nil {
        return clientID, nil // Return ID but don't persist
    }

    return clientID, nil
}

Key points here:

Environment variable opt-out - ANALYTICS_DISABLE=1 completely disables tracking. Simple and discoverable.

Graceful degradation - If anything fails (can't get client ID, can't write config), the tool still works. Tracking is optional.

Persistent client ID - Stored in ~/.your-cli/client-id so the same instance gets the same ID across runs. This is anonymous but consistent.

Testing Without Polluting Production

GA4 has a debug endpoint that validates your events without recording them:

func (t *Tracker) sendEventDebug(ctx context.Context, eventName string, params map[string]interface{}) error {
    // Same payload construction as before
    payload := Payload{
        ClientID: t.ClientID,
        Events: []Event{
            {
                Name:   eventName,
                Params: params,
            },
        },
    }

    jsonData, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("failed to marshal payload: %w", err)
    }

    // Use debug endpoint instead
    url := fmt.Sprintf(
        "https://www.google-analytics.com/debug/mp/collect?measurement_id=%s&api_secret=%s",
        t.MeasurementID,
        t.APISecret,
    )

    req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
    if err != nil {
        return fmt.Errorf("failed to create request: %w", err)
    }

    req.Header.Set("Content-Type", "application/json")

    resp, err := t.client.Do(req)
    if err != nil {
        return fmt.Errorf("failed to send request: %w", err)
    }
    defer resp.Body.Close()

    // Read and print validation response
    var result map[string]interface{}
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return fmt.Errorf("failed to decode response: %w", err)
    }

    fmt.Printf("Validation result: %+v\n", result)
    return nil
}

The debug endpoint returns validation messages telling you if your event structure is correct. Use this during development, then switch to the production endpoint when you're ready.

You can also verify events in GA4's real-time reports. They show up within seconds, so you can test and see results immediately.

Privacy Considerations

When I decided not to track CC Switch, it came down to a few principles:

Anonymous by default - Client IDs should be random UUIDs, not tied to any user identity. No names, no emails, no IP addresses beyond what GA4 automatically collects.

Easy opt-out - ANALYTICS_DISABLE=1 is simple and discoverable. Document it clearly in your README.

No sensitive data - Track command names and flags, but not the values. If someone runs your-cli connect --server prod-db-01, track that they used the connect command with the --server flag, but not which server.

Be transparent - Tell people you're tracking. In your README, explain what you collect and why. Give them the opt-out mechanism upfront.

Respect context - Open source tools? Privacy matters more. Internal corporate tools? Different expectations. SaaS products? You're probably already collecting data anyway.

For context on security considerations when building production tools, I've written about avoiding critical mistakes in AI-generated code, which includes thinking through these trade-offs carefully.

What You Actually Track

Here's what a typical event payload looks like:

{
  "client_id": "550e8400-e29b-41d4-a716-446655440000",
  "events": [
    {
      "name": "command_executed",
      "params": {
        "command": "hello",
        "version": "0.1.0",
        "flags_used": "name,output",
        "engagement_time_msec": 100
      }
    }
  ]
}

The engagement_time_msec parameter helps GA4 calculate active users. Set it to something small like 100ms for CLI tools.

You can add custom parameters for anything you want to track:

params := map[string]interface{}{
    "command": "build",
    "version": version,
    "flags_used": strings.Join(flagsUsed, ","),
    "execution_time_ms": executionTime,
    "success": success,
    "error_type": errorType, // Only if error occurred
}

GA4 allows up to 25 parameters per event and 25 events per request. For CLI tools, you'll rarely hit these limits.

Authentication Deep Dive

The Measurement Protocol is deliberately simple. You need three things:

  1. Measurement ID (G-XXXXXXXXXX)
  2. API Secret
  3. Client ID (you generate this)

No OAuth, no service accounts, no JWT tokens. Just include the measurement ID and API secret in the URL, and you're authenticated.

This works because you're sending data TO Google, not requesting data FROM Google. They want to make sending events as frictionless as possible.

Getting your credentials:

  1. Go to GA4 property
  2. Admin > Data Streams > Select your stream
  3. Note your Measurement ID
  4. Scroll down to "Measurement Protocol API secrets"
  5. Create new secret
  6. Copy the secret value

Store these as environment variables:

export GA_MEASUREMENT_ID="G-XXXXXXXXXX"
export GA_API_SECRET="your_secret_here"

Never commit these to version control. Use environment variables or a config file that's in your .gitignore.

Pulling Data: The Reporting API

If you want to pull data out of GA4 - build dashboards, automate reports, analyse trends - you need the Reporting API. This is more complex than sending events.

Authentication complexity:

You need a service account with the right permissions. This involves:

  1. Create a service account in Google Cloud Console
  2. Download the JSON key file
  3. Grant the service account access to your GA4 property
  4. Use the JSON key to authenticate your requests

Using the official Go library:

import (
    "context"
    "google.golang.org/api/analyticsdata/v1beta"
    "google.golang.org/api/option"
)

func getAnalyticsData() error {
    ctx := context.Background()

    // Authenticate using service account JSON key
    client, err := analyticsdata.NewService(ctx, 
        option.WithCredentialsFile("service-account-key.json"))
    if err != nil {
        return err
    }

    // Build your report request
    request := &analyticsdata.RunReportRequest{
        DateRanges: []*analyticsdata.DateRange{
            {
                StartDate: "7daysAgo",
                EndDate:   "today",
            },
        },
        Metrics: []*analyticsdata.Metric{
            {Name: "activeUsers"},
        },
        Dimensions: []*analyticsdata.Dimension{
            {Name: "city"},
        },
    }

    // Execute the request
    response, err := client.Properties.RunReport(
        "properties/YOUR_PROPERTY_ID", 
        request,
    ).Do()
    if err != nil {
        return err
    }

    // Process response
    for _, row := range response.Rows {
        // Handle your data
    }

    return nil
}

This is significantly more involved than sending events. You're dealing with OAuth flows, service account permissions, and complex API responses. For most CLI tool use cases, you don't need this.

If you're building internal dashboards or need to automate reporting, the Reporting API makes sense. For simple usage tracking, stick with sending events.

When Tracking Makes Sense

After building several tools and thinking through when to implement tracking, here's my framework:

Track when:

  • You're building internal tools for your company (different privacy expectations)
  • You're running a SaaS product (you're already collecting data)
  • Understanding usage helps prioritise development (limited time, want to focus on used features)
  • Error tracking helps users (knowing what breaks helps fix it faster)
  • You can be transparent about it (clear documentation, easy opt-out)

Don't track when:

  • Privacy matters more than the data (open source tools, security-focused applications)
  • You're collecting for vanity metrics (number of users doesn't help you build better software)
  • You can't be transparent (if you wouldn't want to document what you're tracking, don't track it)
  • The data won't change your decisions (tracking for the sake of tracking adds complexity)
  • Your users expect privacy (tools for security researchers, privacy advocates, etc.)

For CC Switch, privacy won out. The tool helps developers work more efficiently, and I don't need to know how they use it to keep improving it. User feedback and GitHub issues tell me what matters.

For a SaaS product? Different calculation. You're already running authentication, storing user data, and providing a service. Tracking feature usage helps build a better product.

Alternatives to Google Analytics

If you want tracking but don't want Google involved:

Self-hosted options:

  • Plausible - Privacy-focused, simple, clean interface. Open source with paid hosted option.
  • Umami - Minimalist analytics, easy to self-host, PostgreSQL or MySQL backend.
  • Matomo - Full-featured, open source, been around forever. More complex but powerful.

Simple approaches:

  • Log to your own backend - POST to an endpoint you control, store in PostgreSQL, query as needed.
  • Telemetry libraries - Segment, PostHog, or similar services designed for product analytics.
  • Simple ping - HTTP GET to your server with query parameters, log to file, analyse later.

The implementation for any of these is similar to what we've covered. You're still sending HTTP requests with event data, just to different endpoints with different authentication.

The Reality

Most people searching for "golang google analytics" want to send events from their backend to GA4. The implementation is simpler than you'd expect - no complex authentication, just HTTP POST requests to the Measurement Protocol endpoint.

The hard part isn't the code, it's the decision. Should you track? What should you track? How do you respect privacy while getting useful data?

I still don't track CC Switch. The privacy trade-off doesn't make sense for that tool. But if I were building a SaaS product or internal tooling where usage data helps prioritise development, this is exactly how I'd implement it.

The code is straightforward: generate a client ID, build your event payload, POST to GA4's endpoint. Run it async so it never blocks your application. Handle errors gracefully so tracking failures don't crash your tool. Provide an easy opt-out so users control their privacy.

That's the entire implementation. No fancy libraries needed, no complex authentication flows, just practical Go code that solves the problem.

If you're building similar tools and want to see how I approach these problems, I've written about automating Git workflows with GitPilotAI and managing multiple development sessions with CC Switch. The pattern is the same: identify the problem, build the simplest solution that works, respect user privacy.


Need help with your business?

Enjoyed this post? I help companies navigate AI implementation, fintech architecture, and technical strategy. Whether you're scaling engineering teams or building AI-powered products, I'd love to discuss your challenges.

Learn more about how I can support you.

Get practical insights weekly

Real solutions to real problems. No fluff, just production-ready code and workflows that work.
You've successfully subscribed to Kyle Redelinghuys
Great! Next, complete checkout to get full access to all premium content.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.