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:
- stop claiming new jobs
- let active deliveries finish within a bounded grace period
- return unacknowledged messages to the queue
- 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
2xxsucceeds429and5xxare classified as retryable400moves 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.