Blog Sending logs and traces

Centralized Go logging with slog and ZipLogger

Go services usually log to stdout and leave collection to whatever runs them. That works until you need every failed reservation for one SKU across all your pods. The ZipLogger Go SDK is a log/slog handler that sends your slog calls to one searchable place. It uses the standard library only.

Install

go get github.com/ziploggerhq/ZipLogger_Client/sdk_go

It needs Go 1.21 or newer, the first version with log/slog.

A note on versions, because the module moved. Today that command gets v0.4.0, the latest tag and the first one published under the ziploggerhq path. The older v0.3.3 declares the previous github.com/ahaliav/ZipLogger_Client/sdk_go path. If you have that in your go.mod, change the import path when you move to v0.4.0; Go will not accept v0.4.0 under the old name. The code on main has only comment changes since v0.4.0.

The package name is ziplogger, while the path ends in sdk_go. An import alias makes that obvious to readers:

import ziplogger "github.com/ziploggerhq/ZipLogger_Client/sdk_go"

For an API key, go to Settings → API keys in the app and click Create key. The key starts with zk_ and is shown once.

Set up the handler

package main

import (
	"errors"
	"fmt"
	"log/slog"
	"os"
	"time"

	ziplogger "github.com/ziploggerhq/ZipLogger_Client/sdk_go"
)

var ErrOutOfStock = errors.New("out of stock")

func main() {
	client, err := ziplogger.New(ziplogger.Options{
		Endpoint: "https://app.ziplogger.ai",
		APIKey:   os.Getenv("ZIPLOGGER_API_KEY"), // YOUR_API_KEY
		Source:   "inventory-api",
	})
	if err != nil {
		panic(err)
	}
	defer client.Close(5 * time.Second)

	logger := slog.New(ziplogger.NewSlogHandler(client, slog.LevelInfo))
	slog.SetDefault(logger)

	reqLog := logger.With("requestId", "req_7Hq2").WithGroup("http")
	reqLog.Info("reserved stock", "method", "POST", "status", 200)

	err = fmt.Errorf("reserve SKU-1042: %w", ErrOutOfStock)
	slog.Error("reservation failed", "err", err, "sku", "SKU-1042")
}

New returns an error only when Endpoint or APIKey is empty. It does not check the key against the server.

We ran this against a local HTTP catcher, with GIT_COMMIT and ZIPLOGGER_RELEASE set. These are the two lines it received (host name changed):

{
  "commitSha": "4f2a9c1",
  "fields": {
    "environment": "production",
    "http.method": "POST",
    "http.status": 200,
    "machineName": "web-01",
    "requestId": "req_7Hq2"
  },
  "message": "reserved stock",
  "release": "1.8.0",
  "severity": "info",
  "source": "inventory-api",
  "timestamp": "2026-09-29T14:26:05.9230663+03:00"
}
{
  "commitSha": "4f2a9c1",
  "fields": {
    "environment": "production",
    "exceptionMessage": "reserve SKU-1042: out of stock",
    "exceptionType": "*fmt.wrapError",
    "machineName": "web-01",
    "sku": "SKU-1042"
  },
  "message": "reservation failed",
  "release": "1.8.0",
  "severity": "error",
  "source": "inventory-api",
  "stackTrace": "reserve SKU-1042: out of stock",
  "timestamp": "2026-09-29T14:26:06.0174223+03:00"
}

Because of slog.SetDefault, calls to the old log package go through the handler too. A log.Printf("legacy line %d", 7) arrived as an info line with the message legacy line 7.

How attributes become fields

  • Each key-value pair and each With(...) attribute becomes a field.
  • WithGroup("http") prefixes the keys that follow with a dot: http.method, http.status.
  • Levels map to debug, info, warn and error. slog.LevelError+4 and above is sent as fatal. Lines below the level you pass to NewSlogHandler are skipped.
  • An attribute named err or error that holds an error fills the exception fields. Under any other key, an error arrives as an empty object. "cause", err sends {}.

A few things we found by running it, which you should know before you rely on them:

  • slog.Group(...) values are not expanded. A slog.Group("warehouse", slog.String("id", "eu-1")) attribute arrived as [{"Key":"id","Value":{}}]. Use WithGroup or flat keys like "warehouse.id".
  • LogValuer is not called. If you hide fields with a LogValue() method, the handler ignores it and sends the struct as JSON. A user type whose LogValue returned only the id arrived with its Email field too. Pass the id yourself ("userId", u.ID) until this changes.
  • A time.Duration is sent as nanoseconds: 250ms arrives as 250000000.
  • The context.Context passed to InfoContext and friends is not read. Trace ids are not added for you.

Errors do not carry a stack

A Go error has no stack trace, so stackTrace holds the error text and exceptionType is the Go type (*fmt.wrapError, *errors.errorString). You can still search on both.

ZipLogger matches new errors to the commit that likely caused them by reading file and line numbers from the stack trace. A line with only error text gives it nothing to read. If you want frames on important errors, use the client directly and attach the current goroutine's stack:

client.Log(ziplogger.Entry{
	Severity:   "error",
	Message:    "reservation failed",
	Err:        err,
	StackTrace: string(debug.Stack()), // import "runtime/debug"
	Fields:     map[string]any{"sku": "SKU-1042"},
})

That is the stack where you log, not where the error was created, but it has main.go:24-style lines the matcher can use. More in finding the commit that broke production.

Close, and when it does not run

Log puts the line on a buffered channel and returns. A goroutine sends batches in the background. Close(timeout) stops accepting lines, sends what is buffered, and waits up to timeout for that to finish.

defer client.Close(...) in main covers a normal return. It does not cover os.Exit, log.Fatal (which calls os.Exit) or a signal that kills the process. For a server, stop on SIGTERM and let main return:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// start your server, then:
<-ctx.Done()
server.Shutdown(context.Background())
// main returns, the deferred client.Close runs

Delivery

  • Log never blocks and never panics. The channel holds 10,000 lines (QueueCapacity). When it is full, new lines are dropped and counted in client.Dropped().
  • Batches of up to 100 lines (BatchSize) are sent as NDJSON to /ingest/v1/logs with an X-Api-Key header. A partial batch waits at most 2 seconds (FlushInterval).
  • Network errors, 408, 429 and 5xx are retried up to 5 times (MaxRetries), starting at 500ms and capped at 30 seconds. Retry-After is respected. Other errors, like 401 for a wrong key, drop the batch and count it.
  • When your plan's ingest volume is used up, the API answers 429, and after the retries the batch is dropped. See the pricing page.
  • A retry resends the whole batch without an Idempotency-Key, so a request that timed out after it was stored can show up twice.
  • HTTPClient lets you pass your own *http.Client, for a proxy for example. There is no disk buffer.

What the SDK adds to every line: source (the Source option, ZIPLOGGER_SOURCE, then the executable name), release (ZIPLOGGER_RELEASE), commitSha (ZIPLOGGER_COMMIT_SHA, GIT_COMMIT or COMMIT_SHA), fields.environment (ZIPLOGGER_ENVIRONMENT, default production) and fields.machineName. Go has no package version to read, so set the release yourself, for example with -ldflags into a variable you pass as Release.

This is a field on each line. Which ZipLogger environment the line is stored in is decided by the API key, not by this value. See Separate staging and production logs.

Finding the lines

In Search, the box searches the message unless you name a field:

fields.sku:SKU-1042 AND severity:error
fields.http.status:500
source:inventory-api AND "reservation failed"

The Patterns view groups lines by message shape. slog messages are usually fixed strings with the values in attributes, which groups well. In our demo workspace the Go service's Inventory synced line is one row:

ZipLogger Search page in the Patterns view, with log lines grouped into rows by message shape and a count for each, including an Inventory synced row

The Go SDK does not send a declared message template, so these groups are built from a sample at read time. See log pattern clustering.

What it does not do

It sends logs only: no events API and no tracing. For traces, use OpenTelemetry and send them over OTLP. If you add the trace id as a string attribute named traceId, the log detail shows a View trace link.

The setup is also in the Go docs. To send your first lines, create an account and make a key under Settings → API keys. Running Node too? See the Node.js guide.

Parse log lines into fields and metrics

Add fields to plain-text logs as they arrive with pattern, JSON or key=value rules, then turn matching logs into a metric you can chart and alert on.

Alert on error logs and quiet services

Set up log-based alert rules that fire on error spikes or when a service goes quiet, send them to email or Slack, and keep them from paging for nothing.

Centralized .NET logging with Serilog and ILogger

Send ASP.NET Core logs to ZipLogger with the Serilog sink or the ILogger provider, with structured properties you can search by user or order id.

Try it on your own logs

There is a free plan and no sales call. Install an SDK, send a few log lines, and search them.