Go logging to UptimeEye (slog, zap)

Go's log/slog (and zap) already emit the right shape: one JSON object per line with time, level, msg and your attributes. In containers, write to stdout and let the agent ship it. Without an agent, wrap the handler in a small batching writer.

Endpoint: https://logs.uptimeeye.com/v1/ingest/jsonline

1.Containers: slog JSON to stdout

main.go
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).With(
    "service", "checkout",
    "env", os.Getenv("ENV"),
)
slog.SetDefault(logger)

slog.Info("payment authorized", "order_id", "4711", "duration_ms", 812)
// {"time":"2026-09-04T11:42:08.877Z","level":"INFO","msg":"payment authorized","service":"checkout","env":"prod","order_id":"4711","duration_ms":812}
Note: With the Vector examples set _msg_field: msg and _time_field: time in the sink query.

2.No agent: a batching writer for slog or zap

uptimeeye.go
package uptimeeye

import (
	"bytes"
	"net/http"
	"os"
	"sync"
	"time"
)

const endpoint = "https://logs.uptimeeye.com/v1/ingest/jsonline?_msg_field=msg&_time_field=time&_stream_fields=service,env"

// Writer buffers JSON lines and posts them every 2s or at 256 KiB.
type Writer struct {
	mu  sync.Mutex
	buf bytes.Buffer
	key string
}

func New() *Writer {
	w := &Writer{key: os.Getenv("UPTIMEEYE_INGEST_KEY")}
	go func() {
		for range time.Tick(2 * time.Second) {
			w.Flush()
		}
	}()
	return w
}

func (w *Writer) Write(p []byte) (int, error) {
	w.mu.Lock()
	w.buf.Write(p)
	full := w.buf.Len() >= 256<<10
	w.mu.Unlock()
	if full {
		w.Flush()
	}
	return len(p), nil
}

func (w *Writer) Flush() {
	w.mu.Lock()
	if w.buf.Len() == 0 {
		w.mu.Unlock()
		return
	}
	body := make([]byte, w.buf.Len())
	copy(body, w.buf.Bytes())
	w.buf.Reset()
	w.mu.Unlock()

	req, _ := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+w.key)
	req.Header.Set("Content-Type", "application/x-ndjson")
	if res, err := http.DefaultClient.Do(req); err == nil {
		res.Body.Close()
	}
}

// usage:
//   w := uptimeeye.New(); defer w.Flush()
//   slog.SetDefault(slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, w), nil)).With("service", "checkout", "env", "prod"))
//   zap: zapcore.NewCore(zapcore.NewJSONEncoder(cfg), zapcore.AddSync(w), zap.InfoLevel)

Fields you get

These show up in the fields panel and can be used in every filter:

  • service, env
  • level
  • msg as the message
  • every slog attribute / zap field

Tips

  • Use io.MultiWriter(os.Stdout, w) so you keep local output while shipping.
  • zap: zapcore.EncoderConfig{TimeKey: "time", LevelKey: "level", MessageKey: "msg", EncodeTime: zapcore.RFC3339NanoTimeEncoder} matches the field names used above.
  • OpenTelemetry Go: go.opentelemetry.io/contrib/bridges/otelslog bridges slog to the OTLP exporter; endpoint and headers as on the OpenTelemetry page.

FAQ

What happens to lines while UptimeEye is unreachable?
The writer above drops them after one failed attempt — acceptable for a sidecar-less demo, not for production. An agent with a disk buffer (Vector, Fluent Bit) retries for you.
Where do I get the ingest key?
In the app under API Keys → New API Key → type “Log ingest”. The key starts with ue_ingest_ and is shown once. Management keys (ue_live_) are refused by the ingest endpoint.
How do I check that logs arrive?
Open Logs in the app, pick the 15m range and search for service:=<your service>. New lines are searchable within about a second; Live tail shows them with a ~6 s delay.