DocsRecipes

Recipes

Go reference implementation

4 min readReviewed July 2026

This reference uses only the Go standard library. One reusable client owns the GraphJSON envelope and HTTP behavior; application code owns event meaning, authorization, and durability.

For business-critical events, call the client from a durable queue consumer or transactional outbox worker. A goroutine started by an HTTP handler can disappear during a deploy or process crash.

Load and validate configuration#

Keep the API key in the server environment or secret manager:

package analytics

import (
	"errors"
	"os"
)

type Config struct {
	APIKey     string
	Collection string
}

func ConfigFromEnv() (Config, error) {
	cfg := Config{
		APIKey:     os.Getenv("GRAPHJSON_API_KEY"),
		Collection: os.Getenv("GRAPHJSON_COLLECTION"),
	}
	if cfg.APIKey == "" {
		return Config{}, errors.New("GRAPHJSON_API_KEY is required")
	}
	if cfg.Collection == "" {
		cfg.Collection = "product_events"
	}
	return cfg, nil
}

Fail startup when production configuration is missing. Do not pass the key to templates, browser JavaScript, or client-readable runtime configuration.

Use typed domain events#

A typed structure catches misspelled fields and accidental type changes before the event reaches GraphJSON:

type ReportExported struct {
	Event         string `json:"event"`
	EventID       string `json:"event_id"`
	SchemaVersion int    `json:"schema_version"`
	ReportID      string `json:"report_id"`
	UserID        string `json:"user_id"`
	AccountID     string `json:"account_id"`
	Format        string `json:"format"`
}

Construct this value from authenticated application state and persisted records. Generate EventID when the business fact occurs, and store it if the event can be retried.

Centralize GraphJSON delivery#

Reuse a single http.Client so Go can reuse connections:

package analytics

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

const logEndpoint = "https://api.graphjson.com/api/log"

type Client struct {
	apiKey     string
	collection string
	http       *http.Client
}

type logRequest struct {
	APIKey     string `json:"api_key"`
	Collection string `json:"collection"`
	Timestamp  int64  `json:"timestamp"`
	JSON       string `json:"json"`
}

type ResponseError struct {
	Status int
	Body   string
}

func (e *ResponseError) Error() string {
	return fmt.Sprintf("GraphJSON returned HTTP %d", e.Status)
}

func (e *ResponseError) Retryable() bool {
	return e.Status == http.StatusTooManyRequests || e.Status >= 500
}

func NewClient(cfg Config) *Client {
	return &Client{
		apiKey:     cfg.APIKey,
		collection: cfg.Collection,
		http:       &http.Client{Timeout: 8 * time.Second},
	}
}

func (c *Client) Log(
	ctx context.Context,
	event any,
	occurredAt time.Time,
) error {
	eventJSON, err := json.Marshal(event)
	if err != nil {
		return fmt.Errorf("encode event: %w", err)
	}

	body, err := json.Marshal(logRequest{
		APIKey:     c.apiKey,
		Collection: c.collection,
		Timestamp:  occurredAt.Unix(),
		JSON:       string(eventJSON),
	})
	if err != nil {
		return fmt.Errorf("encode envelope: %w", err)
	}

	req, err := http.NewRequestWithContext(
		ctx,
		http.MethodPost,
		logEndpoint,
		bytes.NewReader(body),
	)
	if err != nil {
		return fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	response, err := c.http.Do(req)
	if err != nil {
		return fmt.Errorf("send event: %w", err)
	}
	defer response.Body.Close()

	if response.StatusCode >= 200 && response.StatusCode < 300 {
		io.Copy(io.Discard, response.Body)
		return nil
	}

	errorBody, _ := io.ReadAll(io.LimitReader(response.Body, 301))
	return &ResponseError{
		Status: response.StatusCode,
		Body:   string(errorBody),
	}
}

The error deliberately omits the request body and API key. Before logging the response body, redact it or record only a bounded, reviewed subset.

Put a narrow endpoint in front of browser events#

If a browser must report a UI action, accept a small command—not a free-form GraphJSON envelope:

type clientEvent struct {
	Event    string `json:"event"`
	ReportID string `json:"report_id"`
	Format   string `json:"format"`
}

func (s *Server) collectEvent(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
	decoder := json.NewDecoder(r.Body)
	decoder.DisallowUnknownFields()

	var input clientEvent
	if err := decoder.Decode(&input); err != nil {
		http.Error(w, "invalid event", http.StatusBadRequest)
		return
	}
	if input.Event != "report_exported" ||
		(input.Format != "csv" && input.Format != "json") {
		http.Error(w, "unsupported event", http.StatusBadRequest)
		return
	}

	session, err := s.sessions.Require(r.Context(), r)
	if err != nil {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}

	event := ReportExported{
		Event:         "report_exported",
		EventID:       s.ids.New(),
		SchemaVersion: 1,
		ReportID:      input.ReportID,
		UserID:        session.UserID,
		AccountID:     session.AccountID,
		Format:        input.Format,
	}

	if err := s.queue.Publish(r.Context(), event, time.Now().UTC()); err != nil {
		http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusAccepted)
}

The endpoint needs your normal CSRF, origin, authentication, account-membership, rate-limit, and abuse controls. Resolve the report record and prove that it belongs to the authorized account before enqueueing.

Deliver from a durable worker#

Define a message that preserves both occurrence time and event ID. A queue retry must not generate a new ID.

func (w *Worker) Handle(ctx context.Context, message Message) error {
	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	err := w.graphjson.Log(ctx, message.Event, message.OccurredAt)
	if err == nil {
		return nil
	}

	var responseErr *analytics.ResponseError
	if errors.As(err, &responseErr) && !responseErr.Retryable() {
		return w.deadLetters.Store(ctx, message, responseErr.Status)
	}
	return err
}

Configure bounded exponential backoff with jitter for network failures, 429, and 5xx. Move a stable validation failure to a dead-letter path for review. The GraphJSON endpoint does not provide exactly-once delivery; if a timeout makes acceptance uncertain, retry the stable event ID and deduplicate it in queries.

When your queue is not transactional with the application database, use an outbox table:

id | event_id | occurred_at | payload | state | attempts | next_attempt_at

Insert the outbox row in the same transaction as the business change. A dispatcher claims rows in small batches, and the worker marks them delivered only after a 2xx response.

Support bulk workers#

When a worker already owns several events for one collection, send up to 50 at a time with /api/bulk-log. Keep timestamps and serialized payloads paired by index, retain the batch until acknowledgment, and split a rejected batch to isolate an invalid item.

Do not wait to fill a batch indefinitely. Flush on either a size threshold or a short time threshold so low-volume queues remain fresh.

Shut down deliberately#

On SIGTERM:

  1. stop claiming new jobs
  2. let active deliveries finish within a bounded grace period
  3. return unacknowledged messages to the queue
  4. close the worker

An in-memory channel can be useful for disposable telemetry, but it is not a durable queue. If you use one, bound its size, define overflow behavior, and expose dropped-event counts.

Test the boundaries#

Use httptest.Server and inject its URL into the client in tests. Verify:

  • the request contains a whole Unix-second timestamp
  • the inner event is serialized exactly once
  • request cancellation and the client timeout stop delivery
  • 2xx succeeds
  • 429 and 5xx are classified as retryable
  • 400 moves to the dead-letter path
  • browser-provided user or account IDs cannot override session identity
  • an unauthorized report cannot be enqueued
  • retry attempts preserve the event ID
  • logs contain neither API key nor full payload

Keep one smoke test against a staging collection outside the unit-test suite.

Production checklist#

  • Startup validates the server-only API key and collection.
  • Typed events define stable names and field types.
  • Identity and tenant scope come from authenticated server state.
  • One reusable client owns serialization, timeouts, and redaction.
  • Important events cross a durable queue or outbox boundary.
  • Retries are bounded, status-aware, and reuse event_id.
  • Shutdown returns incomplete work safely.
  • Queue age, drops, retries, rejections, and latency are monitored.
  • Contract and failure-mode tests run in CI.

See High-volume ingestion and backfills when a worker must drain large batches, and Executable event contracts for shared validation across producers.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support