boltZharp Docs
terminalLogs & Applications

Go Logs

Zharp Logs for Go sends your app's logs and panics to Zharp, on its own or as a log/slog handler. It has no dependencies and works on Go 1.21 and up.

Install

go get github.com/zharp-io/zharp-go-lib
import zharp "github.com/zharp-io/zharp-go-lib"

The package name is zharp; it uses only the standard library.

API key

  • In the dashboard, open Integrations & API and choose Main API keys.
  • Click Create API key, give it a name, and tick Ingest only. That key can send data but has no access to the REST API, so it's safe in a server's environment — and it's available on every plan.
  • Click Create key and copy the key. It's shown only once.
Create API key, with Ingest only tickedThe new key, shown once with a Copy button

Start it

Call zharp.Init first thing in main, and defer Close:

main.go
func main() {
	zharp.Init(zharp.Config{
		APIKey:      "YOUR_INGEST_ONLY_KEY",
		Service:     "checkout-api",
		Environment: "production",
		AppVersion:  "1.4.2",
	})
	defer zharp.Close()   // send what's left when main returns
	defer zharp.Recover() // send a panic before the program dies of it

	// …
}
warning
Defer Close

Go runs nothing at exit by itself, so logs still queued when main returns are lost unless defer zharp.Close() sends them. It waits at most 5 seconds.

From the environment

Leave the config empty and set the variables instead:

ZHARP_API_KEY=your-ingest-only-key
ZHARP_SERVICE=checkout-api      # optional — defaults to the last part of your module path
ZHARP_ENV=production            # optional — no default
zharp.Init(zharp.Config{})   // reads ZHARP_* from the environment
defer zharp.Close()

You can mix the two: a field set in Config is used, and anything left empty is read from the environment, setting by setting. Either way, one line goes to stderr on startup:

[Zharp] Logging active — service "checkout-api", environment "production"
SituationPrinted
No key in the config or ZHARP_API_KEY — nothing starts, every logging call does nothing[Zharp] No API key found — set Config.APIKey in zharp.Init(), or set ZHARP_API_KEY
No service name, and no module path to take one from — it starts anyway[Zharp] No service name — set Config.Service or ZHARP_SERVICE. Using 'unknown-service'.
ZHARP_DISABLED is set — nothing starts, whatever the code asks for[Zharp] Logging disabled by ZHARP_DISABLED — nothing will be sent

Init never returns an error or panics, so a test run or a preview environment without a key still starts. Calling it again replaces the running setup; what the old one had queued is still sent.

Options

Config fieldEnvironment variableDefault
APIKeyZHARP_API_KEY— (nothing starts without one)
ServiceZHARP_SERVICE, then OTEL_SERVICE_NAMEThe last part of your module path (github.com/acme/checkout-api → checkout-api), else unknown-service
EnvironmentZHARP_ENVNone — unset means no tag
AppVersionZHARP_VERSIONNone
SourceZHARP_SOURCENone
EndpointZHARP_ENDPOINThttps://api.zharp.io/api/v1
FlushInterval—5 seconds
MaxBatchSize—50
HTTPClient—A client with a 15-second timeout
—ZHARP_DISABLEDtrue, 1, yes or on: start nothing

Logging

zharp.Debug("Cache miss", zharp.Fields{"key": "user:42"})
zharp.Info("Request received", zharp.Fields{"path": "/checkout"})
zharp.Warn("Rate limit approaching", zharp.Fields{"remaining": 5})
zharp.Error("Stripe webhook failed", zharp.Fields{"event_id": "evt_123"})
zharp.Fatal("Database connection lost")   // logs at fatal; unlike log.Fatal it doesn't exit

// Any level, and optionally another service name for this one log:
zharp.Log(zharp.LevelError, "Charge declined", zharp.Fields{"amount": 5000})
zharp.LogService("payments", zharp.LevelError, "Charge declined", nil)

Fields are optional and are kept with the log, copied when it's made. Error and Fatal are sent straight away; the other levels go in batches from a background goroutine, so logging never waits on the network, and it never panics.

Errors

if err := charge(order); err != nil {
	zharp.Error("Payment failed", zharp.Fields{"order_id": order.ID, "err": err})
	zharp.CaptureError(err)                                   // the error alone
	zharp.CaptureError(err, zharp.Fields{"order_id": order.ID})
}

An error — as the message or among the fields — brings its type and a stack. Go errors don't carry a stack, so it's the goroutine's stack where the error was logged, starting at your code. An error that prints its own with %+v, as github.com/pkg/errors and similar packages make, keeps that one instead, even wrapped. The plain errors made by errors.New and fmt.Errorf are named error; other types by their Go type, such as *fs.PathError.

zharp.SetContext(zharp.Fields{"region": "eu-west"})   // added to every log's context
zharp.ResetSessionID()                                 // e.g. per job in a long-running worker

log/slog

Send your slog logs to Zharp:

slog.SetDefault(slog.New(zharp.NewSlogHandler(nil)))

slog.Warn("Stock low", "sku", "SKU-1")
slog.Error("Pricing failed", "err", err)

// Only WARN and up:
zharp.NewSlogHandler(&zharp.SlogOptions{Level: slog.LevelWarn})

Attributes become the log's fields, groups nested; an error attribute brings its stack; levels map as you'd expect (above slog.LevelError is fatal). The default sends INFO and up.

To keep printing to the console as well, pass each record to both handlers:

// Send each record to the console and to Zharp.
type both []slog.Handler

func (b both) Enabled(ctx context.Context, l slog.Level) bool {
	for _, h := range b {
		if h.Enabled(ctx, l) {
			return true
		}
	}
	return false
}

func (b both) Handle(ctx context.Context, r slog.Record) error {
	for _, h := range b {
		if h.Enabled(ctx, r.Level) {
			_ = h.Handle(ctx, r.Clone())
		}
	}
	return nil
}

func (b both) WithAttrs(a []slog.Attr) slog.Handler {
	out := make(both, len(b))
	for i, h := range b {
		out[i] = h.WithAttrs(a)
	}
	return out
}

func (b both) WithGroup(g string) slog.Handler {
	out := make(both, len(b))
	for i, h := range b {
		out[i] = h.WithGroup(g)
	}
	return out
}

slog.SetDefault(slog.New(both{slog.NewTextHandler(os.Stderr, nil), zharp.NewSlogHandler(nil)}))
warning
Not where the agent already collects your output

Where the Zharp agent already reads your app's output — Docker, journald, Kubernetes — send logs through one of them, not both, or every line arrives twice.

Panics

Go has no global hook for panics, so capturing them takes one line where they can happen. defer zharp.Recover() sends a panic as a fatal log with its stack, waits up to 2 seconds for it to go out, then panics again — Go prints it and the program ends as it would have. Put it in main, and at the top of every goroutine you start, since a panic in one ends the whole program:

go func() {
	defer zharp.Recover()
	processQueue()
}()

HTTP servers

zharp.Middleware sends a panic in a handler as an error log with its stack and the request's method and path, then lets net/http handle it as usual — it ends that request and keeps serving.

mux := http.NewServeMux()
mux.HandleFunc("/checkout", checkout)

http.ListenAndServe(":8080", zharp.Middleware(mux))

Gin, Echo, Chi and Gorilla routers are all http.Handlers, so wrap them the same way. Frameworks that recover panics themselves (Gin's Recovery, Echo's Recover) stop them before the middleware sees them — use it in their place, or log from their recovery handler.

See your logs

Open Logs in the dashboard sidebar and pick your service in the service dropdown. Click a line to see its fields and stack.

The Logs page, showing lines from one service

To be told when something shows up, add a log monitor.

What a log carries

One log entry
{
  "service": "checkout-api",
  "level": "error",
  "message": "Payment failed",
  "timestamp": "2026-10-26T09:14:32.041Z",
  "environment": "production",
  "version": "1.4.2",
  "metadata": {
    "name": "error",
    "stack": "charge order 99: card declined\n\ngoroutine stack (where it was logged):\nmain.checkout()\n\t/app/checkout.go:42\n…",
    "order_id": 99,
    "err": { "name": "error", "message": "charge order 99: card declined", "stack": "…" },
    "sessionId": "a3f8c2d1-…",
    "environment": "production",
    "context": {
      "hostname": "checkout-7d9f8b-x2k4q",
      "pid": "4182",
      "goVersion": "go1.23.2",
      "platform": "linux",
      "arch": "amd64",
      "appVersion": "1.4.2"
    }
  }
}
  • environment, version (from AppVersion) and source are top-level tags, sent when set. See Unified service tagging.
  • Your own fields sit at the top of metadata, next to an error's name and stack and the sessionId — one per run of the program.
  • Where it ran goes under context: hostname (the pod name in Kubernetes), pid, goVersion, platform and arch, plus whatever you set with SetContext.

Delivery

  • Logs wait in a queue of up to 500 entries; when it is full, the oldest is dropped. Every FlushInterval, up to MaxBatchSize of them are sent, and no request is larger than about 1 MB — a larger batch is split.
  • Requests go to /agent/logs with the key in an X-API-KEY header and X-Zharp-SDK: go-logs/0.1.0.
  • A request that fails on the network or with a 5xx is tried three times in all: once, then after 2 seconds, then after 4. A 4xx is not retried.

zharp.Flush(timeout) sends what's queued and waits for it, reporting whether all of it went — call it before a serverless function returns:

func handler(ctx context.Context, event Event) error {
	defer zharp.Flush(5 * time.Second) // send before the platform freezes the function
	return process(event)
}

The source is at github.com/zharp-io/zharp-go-lib, and the API reference on pkg.go.dev.