Ingestion
Go
otlploghttp pointed straight at Bilis, or a dependency-free slog handler that batches into the simple JSON endpoint.
Fill this page in with a real API key
The samples below carry a placeholder key and an example host. Log in or create an account to issue one and have them rewritten in place.
Or hand it to a coding agent
Copy this prompt into Claude Code — or any agent that can read a URL — and let it do the setup against the current version of this page.
Set up Bilis — self-hosted log and trace storage — in this project, following its "Go" guide: https://bilis.app/docs/ingestion/go.md Bilis endpoint: https://bilis.app API key: bilis_YOUR_API_KEY Read the guide first, then make the changes it describes. Keep the API key in the environment rather than in a file that gets committed, and finish by sending one record and confirming it arrives.
Two routes, both good — pick by whether the service already speaks OpenTelemetry.
otlploghttp→/api/v1/logs. The standard exporter, no Collector in between. Bilis decodes the protobuf encoding the Go SDK sends.log/slog→/api/v1/ingest. No dependencies at all, about a hundred lines, works with whatever logging you already do.
1. OpenTelemetry: otlploghttp
The Go OTLP exporters are protobuf-only — there is no WithProtocol, and
OTEL_EXPORTER_OTLP_PROTOCOL=http/json is not a value they accept. Bilis
decodes protobuf as well as JSON, so that is no longer a reason to run a
Collector:
OTEL_SERVICE_NAME=checkout
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://bilis.example.com/api/v1/logs
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer bilis_YOUR_API_KEY"
Or in code, which is what the environment variables end up doing anyway:
exporter, err := otlploghttp.New(ctx,
otlploghttp.WithEndpoint("bilis.example.com"),
otlploghttp.WithURLPath("/api/v1/logs"),
otlploghttp.WithHeaders(map[string]string{
"Authorization": "Bearer " + os.Getenv("BILIS_API_KEY"),
}),
otlploghttp.WithCompression(otlploghttp.GzipCompression),
)
if err != nil {
return err
}
provider := sdklog.NewLoggerProvider(
sdklog.WithResource(resource.NewSchemaless(
semconv.ServiceName("checkout"),
)),
sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)),
)
defer provider.Shutdown(ctx)
Things worth knowing about this path:
OTEL_EXPORTER_OTLP_LOGS_ENDPOINTis used verbatim, which is why it names the full path. The signal-agnosticOTEL_EXPORTER_OTLP_ENDPOINThas the exporter append/v1/logs, so give that one the base only:https://bilis.example.com/api.- gzip is understood, and worth turning on: log batches compress well.
- gRPC is not.
otlploggrpcon port 4317 has nothing to talk to; use a Collector if you need gRPC on the application side. Shutdownis what flushes the batch. Without it the last export dies with the process, exactly as with the handler below.- To bridge
slogonto this exporter rather than writing OTel calls, use otelslog; the handler in the next section is the alternative that costs no dependencies.
2. slog handler
Create a project and an API key in the app, then put them in the environment:
BILIS_ENDPOINT=https://bilis.example.com
BILIS_API_KEY=bilis_YOUR_API_KEY
Drop this in internal/bilislog/bilislog.go. It implements slog.Handler, so
every slog.Info / slog.With / slog.Group you already wrote keeps working.
package bilislog
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"sync"
"time"
)
// Options configures the handler. Only Endpoint, APIKey and Service matter.
type Options struct {
Endpoint string // Bilis origin, e.g. https://bilis.example.com
APIKey string // bilis_… project key
Service string // becomes the service filter in the viewer
Level slog.Leveler // default slog.LevelInfo
BatchSize int // default 100
FlushInterval time.Duration // default 2s
Client *http.Client
}
// entry is the simple-JSON shape of /api/v1/ingest.
type entry struct {
Message string `json:"message"`
Level string `json:"level"`
Timestamp string `json:"timestamp"`
Service string `json:"service,omitempty"`
Context map[string]any `json:"context,omitempty"`
}
type Handler struct {
opts Options
ship *shipper
attrs map[string]any
prefix string
}
var _ slog.Handler = (*Handler)(nil)
func New(opts Options) *Handler {
if opts.Level == nil {
opts.Level = slog.LevelInfo
}
if opts.BatchSize <= 0 {
opts.BatchSize = 100
}
if opts.FlushInterval <= 0 {
opts.FlushInterval = 2 * time.Second
}
if opts.Client == nil {
opts.Client = &http.Client{Timeout: 10 * time.Second}
}
return &Handler{opts: opts, ship: newShipper(opts), attrs: map[string]any{}}
}
func (h *Handler) Enabled(_ context.Context, l slog.Level) bool {
return l >= h.opts.Level.Level()
}
func (h *Handler) Handle(_ context.Context, r slog.Record) error {
attrs := make(map[string]any, len(h.attrs)+r.NumAttrs())
for k, v := range h.attrs {
attrs[k] = v
}
r.Attrs(func(a slog.Attr) bool {
flatten(attrs, h.prefix, a)
return true
})
e := entry{
Message: r.Message,
Level: levelName(r.Level),
Timestamp: r.Time.UTC().Format(time.RFC3339Nano),
Service: h.opts.Service,
Context: attrs,
}
h.ship.enqueue(e)
return nil
}
func (h *Handler) WithAttrs(as []slog.Attr) slog.Handler {
c := h.clone()
for _, a := range as {
flatten(c.attrs, c.prefix, a)
}
return c
}
func (h *Handler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
c := h.clone()
if c.prefix == "" {
c.prefix = name
} else {
c.prefix += "." + name
}
return c
}
// Close flushes what is still buffered. Call it exactly once, at shutdown.
func (h *Handler) Close() { h.ship.close() }
func (h *Handler) clone() *Handler {
attrs := make(map[string]any, len(h.attrs))
for k, v := range h.attrs {
attrs[k] = v
}
return &Handler{opts: h.opts, ship: h.ship, attrs: attrs, prefix: h.prefix}
}
// flatten turns nested slog groups into dotted keys: user.id, http.status.
func flatten(dst map[string]any, prefix string, a slog.Attr) {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return
}
key := a.Key
if key == "" {
key = prefix // a group with an empty key is inlined
} else if prefix != "" {
key = prefix + "." + a.Key
}
switch a.Value.Kind() {
case slog.KindGroup:
for _, g := range a.Value.Group() {
flatten(dst, key, g)
}
case slog.KindTime:
dst[key] = a.Value.Time().UTC().Format(time.RFC3339Nano)
case slog.KindDuration:
dst[key] = a.Value.Duration().String()
case slog.KindAny:
// error marshals to {} otherwise — the classic structured-logging bug.
if err, ok := a.Value.Any().(error); ok {
dst[key] = err.Error()
} else {
dst[key] = a.Value.Any()
}
default:
dst[key] = a.Value.Any()
}
}
// levelName maps slog levels onto names Bilis understands. Custom levels land
// in the band below them, which is what slog itself does when it prints them.
func levelName(l slog.Level) string {
switch {
case l < slog.LevelDebug:
return "trace"
case l < slog.LevelInfo:
return "debug"
case l < slog.LevelWarn:
return "info"
case l < slog.LevelError:
return "warn"
default:
return "error"
}
}
type shipper struct {
opts Options
ch chan entry
done chan struct{}
once sync.Once
}
func newShipper(opts Options) *shipper {
s := &shipper{opts: opts, ch: make(chan entry, 4096), done: make(chan struct{})}
go s.run()
return s
}
func (s *shipper) enqueue(e entry) {
select {
case s.ch <- e:
default: // buffer full: drop the line rather than block the caller
}
}
func (s *shipper) run() {
defer close(s.done)
ticker := time.NewTicker(s.opts.FlushInterval)
defer ticker.Stop()
batch := make([]entry, 0, s.opts.BatchSize)
flush := func() {
if len(batch) == 0 {
return
}
s.post(batch)
batch = batch[:0]
}
for {
select {
case e, ok := <-s.ch:
if !ok {
flush()
return
}
batch = append(batch, e)
if len(batch) >= s.opts.BatchSize {
flush()
}
case <-ticker.C:
flush()
}
}
}
func (s *shipper) post(batch []entry) {
body, err := json.Marshal(batch)
if err != nil {
return
}
req, err := http.NewRequest(http.MethodPost, s.opts.Endpoint+"/api/v1/ingest", bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.opts.APIKey)
resp, err := s.opts.Client.Do(req)
if err != nil {
return // a dead log backend never breaks the program
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
}
func (s *shipper) close() {
s.once.Do(func() { close(s.ch) })
<-s.done
}
Wiring it up
func main() {
bilis := bilislog.New(bilislog.Options{
Endpoint: os.Getenv("BILIS_ENDPOINT"),
APIKey: os.Getenv("BILIS_API_KEY"),
Service: "checkout",
Level: slog.LevelInfo,
})
defer bilis.Close()
slog.SetDefault(slog.New(bilis))
slog.Error("Card declined for order 41902",
"order.id", "41902",
"attempt", 3,
"err", errors.New("issuer declined"),
)
}
That is one 202 Accepted with a batch of one, at most two seconds later.
Keep a local copy. A remote log target should not be the only place your
logs exist, so fan out to stderr as well — with
slog-multi that is one line:
slog.SetDefault(slog.New(slogmulti.Fanout(
slog.NewTextHandler(os.Stderr, nil),
bilis,
)))
What to know about this handler
Handlenever blocks on the network. Records go into a buffered channel and a single goroutine batches them, by size (100) or by time (2s).- A full buffer drops lines instead of stalling the request. 4096 pending records means Bilis is unreachable or far slower than your log rate; blocking the application would be the worse failure.
Close()is not optional. Without it, whatever is in the current batch dies with the process.deferit inmain, and call it after your HTTP server's graceful shutdown returns — not before, or you lose the shutdown logs. Call it once: enqueueing after close panics on a closed channel.- Errors are stringified explicitly.
slog.Any("err", err)would otherwise marshal to{}, because most error types have no exported fields. - Ship failures are swallowed. Retrying is safe if you want it — see
Limits — but never
loga shipping failure through this handler. - Timestamps are
time.RFC3339Nano, which carries an offset, so the line lands at event time and not at flush time. See Timestamps.
Trace correlation
If you already run OpenTelemetry tracing, Handle receives the record's
context — lift the ids out of it and Bilis will store them:
type entry struct {
// …
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
}
func (h *Handler) Handle(ctx context.Context, r slog.Record) error {
// …
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
e.TraceID = sc.TraceID().String()
e.SpanID = sc.SpanID().String()
}
h.ship.enqueue(e)
}
With the ids on the line, the log viewer links it to the span's waterfall and the waterfall links back. Sending the spans themselves is covered in Traces.
3. With a Collector in front
Bilis no longer needs a Collector to translate — but a Collector is still worth running for what it does either side of the wire: a persistent queue that survives a restart, retries, and a place to add or drop attributes without redeploying the service.
# the Go application
OTEL_SERVICE_NAME=checkout
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# the Collector
receivers:
otlp:
protocols:
# Your services may still speak gRPC to the Collector; only the
# hop to Bilis has to be HTTP.
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
extensions:
file_storage:
directory: /var/lib/otelcol/storage
exporters:
otlphttp/bilis:
logs_endpoint: https://bilis.example.com/api/v1/logs
traces_endpoint: https://bilis.example.com/api/v1/traces
headers:
Authorization: Bearer bilis_YOUR_API_KEY
# gzip is the exporter's default; Bilis inflates gzip and deflate.
compression: gzip
# Protobuf is the default encoding and is decoded in-process;
# `encoding: json` works too.
sending_queue:
enabled: true
storage: file_storage # survives a Collector restart
retry_on_failure:
enabled: true
service:
extensions: [file_storage]
pipelines:
logs:
receivers: [otlp]
processors: [] # deliberately no batch processor
exporters: [otlphttp/bilis]
traces:
receivers: [otlp]
processors: []
exporters: [otlphttp/bilis]
This is the same file the Shippers
and Traces pages use; the
settings that decide whether you lose data under load are explained there. The
traces pipeline carries the spans an otlptracehttp exporter sends to the
same Collector — see Traces for that side. If your Go service runs in
Docker and simply writes to stdout, you can skip the SDK entirely and have the
Collector tail the container log files instead — see
Linux host.
Which one
- Already on the OpenTelemetry SDK →
otlploghttpstraight at Bilis. It is the standard exporter and now needs nothing in between. - Plain
slog, no OTel anywhere → the handler above. It costs no dependencies and no container. - Need a queue that survives a restart, or per-host enrichment → a Collector, with either of the above in front of it.
Note: the protobuf encoding is decoded by Bilis itself, in PHP, and can be switched off per instance with
BILIS_OTLP_PROTOBUF=false— after which anotlploghttpexporter gets415again. If a self-hosted instance you do not operate answers415, that is the setting to ask about. See Endpoints.