Skip to content

ss-keel-otel

ss-keel-otel is the official observability addon for Keel. It initializes the OpenTelemetry Go SDK, creates a root span for every HTTP request, and exports traces and metrics through OTLP to any compatible backend — Grafana, Jaeger, Datadog, New Relic, AWS X-Ray, Honeycomb, or your own OTel Collector.

Implements: contracts.Tracer Current stable release: v0.1.0

Terminal window
keel add otel

Or manually:

Terminal window
go get github.com/slice-soft/ss-keel-otel

When you run keel add otel, the CLI creates cmd/setup_otel.go and adds one line to cmd/main.go:

// cmd/setup_otel.go — created by keel add otel
package main
import (
"github.com/slice-soft/ss-keel-core/config"
"github.com/slice-soft/ss-keel-core/core"
"github.com/slice-soft/ss-keel-core/logger"
ssotel "github.com/slice-soft/ss-keel-otel/otel"
)
// setupOtel initialises the OpenTelemetry SDK and registers the Fiber HTTP middleware.
// All telemetry is skipped when OTEL_ENABLED=false.
func setupOtel(app *core.App, log *logger.Logger) *ssotel.Provider {
otelConfig := config.MustLoadConfig[ssotel.Config]()
otelConfig.Logger = log
provider, err := ssotel.New(otelConfig)
if err != nil {
log.Error("failed to initialise otel: %v", err)
return provider
}
app.SetTracer(provider)
app.Fiber().Use(provider.Middleware())
app.OnShutdown(provider.Shutdown)
return provider
}

The following is injected into cmd/main.go:

_ = setupOtel(app, appLogger)

keel add otel appends these entries to application.properties and .env:

application.propertiesenv varDefaultPurpose
otel.enabledOTEL_ENABLEDfalseMaster on/off switch
otel.service-nameOTEL_SERVICE_NAMEmy-appLogical service name
otel.service-versionOTEL_SERVICE_VERSION0.0.0Version resource attribute
otel.environmentOTEL_ENVIRONMENTdevelopmentDeployment environment
otel.exporter-otlp-endpointOTEL_EXPORTER_OTLP_ENDPOINThttp://localhost:4318Collector endpoint
otel.exporter-otlp-protocolOTEL_EXPORTER_OTLP_PROTOCOLhttp/protobufTransport protocol
otel.traces-samplerOTEL_TRACES_SAMPLERparentbased_always_onSampler strategy
otel.traces-sampler-argOTEL_TRACES_SAMPLER_ARGRatio for ratio-based samplers

The OTLP exporters also read OTEL_EXPORTER_OTLP_HEADERS directly from the environment — use it to pass API keys or auth tokens without going through application.properties.

provider, err := ssotel.New(ssotel.Config{
Enabled: true,
ServiceName: "my-api", // required when enabled
ServiceVersion: "1.4.2",
Environment: "production",
ExporterProtocol: ssotel.ProtocolHTTP, // or ssotel.ProtocolGRPC
SamplerType: ssotel.SamplerParentBasedAlwaysOn,
SamplerArg: "", // ratio: "0.1" = 10%
Logger: log,
})

When Enabled is false, New returns immediately with a no-op provider — no SDK components are initialized and no network connections are attempted.

A trace is the end-to-end record of a single operation — typically one API request that may fan out across multiple services. A trace is composed of spans: individual timed units of work.

ss-keel-otel creates a root server span for each incoming HTTP request and makes it available to all downstream code via c.UserContext(). Your services can create child spans to track internal operations:

GET /users/123
└── HTTP GET /users/:id ← root span (created by middleware)
├── UserService.GetByID ← child span (created manually)
│ └── UserRepository.Find ← grandchild span
└── CacheService.Get ← child span

ss-keel-otel also initializes a MeterProvider with a periodic OTLP reader. Any instrumentation library that calls otel.Meter(...) will export metrics automatically through the same exporter.

Every signal is tagged with a set of resource attributes that identify the source:

AttributeSource
service.nameConfig.ServiceName
service.versionConfig.ServiceVersion
deployment.environmentConfig.Environment
host.namedetected at runtime
process.piddetected at runtime

The middleware creates a server span for every Fiber request and propagates incoming trace context from W3C traceparent and baggage headers:

// Applied automatically by setupOtel
app.Fiber().Use(provider.Middleware())

Each span records:

AttributeExample
http.request.methodGET
url.path/users/123
http.route/users/:id
server.addressapi.myapp.com
net.peer.ip203.0.113.1
http.response.status_code200

5xx responses mark the span as ERROR. Errors returned by the handler are recorded with span.RecordError.

Use app.Tracer() (returns contracts.Tracer) to create child spans anywhere in your application:

// In a service — pass ctx from the caller so the span becomes a child
func (s *UserService) GetByID(ctx context.Context, id string) (*User, error) {
ctx, span := app.Tracer().Start(ctx, "UserService.GetByID")
defer span.End()
span.SetAttribute("user.id", id)
user, err := s.repo.FindByID(ctx, id)
if err != nil {
span.RecordError(err)
return nil, err
}
return user, nil
}

In Fiber handlers, use c.UserContext() as the parent context:

func (h *Handler) GetUser(c *httpx.Ctx) error {
ctx, span := app.Tracer().Start(c.UserContext(), "GetUser")
defer span.End()
span.SetAttribute("user.id", c.Params("id"))
// ...
}
ValueDescription
always_onSample every trace — use in development only
always_offDrop all traces
parentbased_always_onFollow parent; sample root spans always (default)
traceidratioSample root spans at the given ratio
parentbased_traceidratioFollow parent; sample root spans at the given ratio

Production recommendation: parentbased_traceidratio with OTEL_TRACES_SAMPLER_ARG=0.1 (10%).

OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-prod-eu-west-0.grafana.net/otlp
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <base64-encoded-instance-id:api-key>

Traces land in Grafana Tempo, metrics in Grafana Mimir. The Grafana Cloud OTLP endpoint accepts both http/protobuf and grpc.

Start Jaeger with the OTLP HTTP receiver enabled (available since Jaeger 1.35):

# docker-compose.yml — local development
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "4318:4318" # OTLP HTTP
- "16686:16686" # Jaeger UI
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318

Enable the OTLP receiver in the Datadog Agent (datadog.yaml):

otlp_config:
receiver:
protocols:
http:
endpoint: 0.0.0.0:4318
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.nr-data.net
OTEL_EXPORTER_OTLP_HEADERS=api-key=<your-ingest-license-key>
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io
OTEL_EXPORTER_OTLP_HEADERS=x-honeycomb-team=<your-api-key>

Deploy the AWS Distro for OpenTelemetry (ADOT) collector alongside your service:

OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318

The ADOT collector receives OTLP and forwards to X-Ray, CloudWatch, and Prometheus.

Use an OTel Collector to fan out to multiple backends simultaneously:

otel-collector.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
jaeger:
endpoint: jaeger:14250
tls: { insecure: true }
prometheusremotewrite:
endpoint: http://prometheus:9090/api/v1/write
service:
pipelines:
traces:
receivers: [otlp]
exporters: [jaeger]
metrics:
receivers: [otlp]
exporters: [prometheusremotewrite]

A minimal docker-compose.yml for local tracing with Jaeger:

services:
app:
build: .
environment:
OTEL_ENABLED: "true"
OTEL_SERVICE_NAME: "my-api"
OTEL_ENVIRONMENT: "local"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318"
depends_on:
- jaeger
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "4318:4318" # OTLP HTTP receiver
- "16686:16686" # Jaeger UI → open http://localhost:16686

To use gRPC instead of HTTP/protobuf:

OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

gRPC endpoints typically listen on port 4317; HTTP on port 4318.

In unit tests, keep OTEL_ENABLED=false (the default). app.Tracer() returns a no-op tracer when the provider is disabled — no SDK init, no goroutines, no network calls:

// In tests — no setup needed, tracer is already a noop
ctx, span := app.Tracer().Start(context.Background(), "test-op")
span.SetAttribute("user.id", "123")
span.End() // no-op

To test code that creates spans, pass a contracts.Tracer interface and inject a stub:

type noopTracer struct{}
func (noopTracer) Start(ctx context.Context, _ string) (context.Context, contracts.Span) {
return ctx, noopSpan{}
}