Ollygarden otel go setup
Ollygarden's recommended pattern for setting up the OpenTelemetry SDK in Go services using otelconf. Covers project structure, the Providers struct, no-op fallback, runtime attribute injection, and the zap log bridge. Use when adding OTel to a Go project, structuring telemetry code, or reviewing an existing setup — including when DB spans show up as trace roots or GORM/database spans are disconnected from HTTP spans. Triggers on "go otel setup", "go telemetry pattern", "Providers struct go otel", "gorm WithContext", "root client span go".From its SKILL.md
npx -y skills add ollygarden/skills --skill ollygarden-otel-go-setupAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
13.4 KB, ~3.0k tokens by cl100k_base, as published. Nobody here has run it
Go SDK Setup Conventions
Setup Checklist — verify every item before you finish
Setup is not done when the SDK boots. Each unchecked item below produces a specific telemetry-quality finding in production; work through all of them.
-
Thread the request context into the data layer. Every database, HTTP-client, and queue call on a request path must receive the request's
ctx— nevercontext.Background()and never a bare global handle. With GORM this meansdb.WithContext(ctx)at every query site, with the context passed handler → service → model:// handler: take ctx from the framework request articles, err := svc.ListArticles(c.Request.Context(), filter) // service: every method takes ctx as its first parameter func (s *Service) ListArticles(ctx context.Context, f Filter) ([]Article, error) { return s.repo.List(ctx, f) } // repo/model: the only layer touching *gorm.DB func (r *Repo) List(ctx context.Context, f Filter) ([]Article, error) { var out []Article return out, r.db.WithContext(ctx).Where("tag = ?", f.Tag).Find(&out).Error }Verify by auditing every request-path DB call: each must receive the request context — via
db.WithContext(ctx), a per-requestgorm.Session{Context: ctx}, ordatabase/sql'sQueryContext/ExecContext. A quick spot-check isgrep -rn "WithContext" --include='*.go' .(zero hits on a GORM codebase is a strong signal the context is not threaded), but the grep alone is not proof — wrappers and reused*gorm.DBhandles hide call sites, so walk the request paths. Without the request context, DB spans become detached CLIENT-kind trace roots instead of children of the HTTP span (Root Client Span finding). Refactoring existing ctx-less signatures across layers is part of setup, not optional follow-up. -
Never record SQL parameter values — on any signal. Bound values must not appear in
db.query.text, SQL logs,database/sqlinstrumentation attributes, or custom spans; only?placeholders are acceptable. With the GORM OTel plugin specifically, passtracing.WithoutQueryVariables(). Raw values in any of these leak PII (Critical PII Leakage finding). -
Configure the SDK declaratively, not in code. Exporters, processors, sampling, and signal wiring live in an external YAML file (
configs/otel.yaml) parsed withotelconf— use the Setup Pattern below, not hand-constructed exporter/provider code. Operators must be able to change the telemetry setup without recompiling, and the app must fall back to no-op providers when the file is absent. -
Inject
service.instance.id(a per-process UUID) alongsideservice.version, as the setup pattern below does programmatically (Missing service.instance.id finding). -
Keep the resource lean.
service.name,service.version,service.instance.id, anddeployment.environment.name— that is the full set. Do not addresource.WithOS(),resource.WithProcess(),resource.WithHost(), or equivalent detectors:os.*andprocess.*resource attributes are discouraged (Discouraged Resource Attribute finding). -
Honor the standard
OTEL_*environment variables end-to-end.OTEL_EXPORTER_OTLP_*,OTEL_SERVICE_NAME, andOTEL_RESOURCE_ATTRIBUTESmust all take effect at runtime. Do not invent custom environment variables (DEPLOYMENT_ENVIRONMENT,SERVICE_VERSION, ...) for values the standard variables already express, and never overwrite an attribute supplied viaOTEL_RESOURCE_ATTRIBUTESwith a code-level default — a hardcoded fallback likedeployment.environment.name = "development"silently clobbers the deployment's real environment and misfiles every signal the service emits.
Required: Verification Report
Setup is not complete until you produce this report. It is a table with one row per checklist item above. Fill each row with artifacts from THIS run — the marker value you sent, an excerpt of the exported span dump, a trace id, the config value you changed. Never a restatement of the requirement, never a bare "done".
The table below is an illustrative example, not a report you can submit: every value in
it is a placeholder showing the expected shape of evidence. Replace every cell with your
own run's artifacts. If you did not run a check, write GAP — not run in that row and
leave it visible — a missing or hand-waved row is itself a finding.
Example (illustrative values — replace every cell with your own run's evidence):
| Item | Check performed | Observed evidence |
|---|---|---|
| Context threaded to data layer | traced a request end-to-end and inspected the exported spans | DB span is a CHILD of the HTTP server span (same trace id 4bf9…), no parentless CLIENT-kind roots |
| No SQL parameter values on any signal | ran a query with marker value MARKER_7f3a, inspected the exported DB span, SQL logs, and DB metrics | db.query.text shows only ? placeholders; MARKER_7f3a appears in no span, log, or metric |
| SDK configured declaratively | changed a value in configs/otel.yaml (e.g. the sampler ratio) and restarted without recompiling; renamed the file to confirm fallback | new sampling behavior took effect from the file alone; with the file absent the app logged the no-op warning and still ran |
service.instance.id injected | dumped the exported resource across two process starts | service.instance.id present as a UUID, and it differs between the two boots |
| Resource kept lean | dumped the exported resource attributes | exactly service.name, service.version, service.instance.id, deployment.environment.name; no os.* or process.* keys |
Standard OTEL_* honored | booted with OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES set to non-defaults, and OTEL_EXPORTER_OTLP_ENDPOINT pointed at a marker collector | service.name/deployment.environment.name carry the supplied values on exported telemetry, with no code-level default overwriting them; the marker collector's log / receipt confirms telemetry arrived at the overridden endpoint (the endpoint is a destination, evidenced there, not on the spans) |
A row you cannot fill with observed evidence is a visible gap — that item is not done. Do not delete the row, copy these example values, or write "N/A" to hide it; go run the check and record what you actually saw.
Recommended import path
For new code, use the root otelconf package (go.opentelemetry.io/contrib/otelconf) — it
tracks the current schema and includes the propagator-from-YAML fix. The schema-pinned
otelconf/v0.3.0 subpackage is for keeping existing configs unchanged.
Project Structure
internal/telemetry/
├── const.go # Service scope and telemetry constants
├── setup.go # SDK initialization (code below)
├── providers.go # Provider management utilities
└── carriers.go # Custom propagation carriers (if needed)
configs/
└── otel.yaml # Declarative configuration
Setup Pattern
The core setup reads a YAML config file, injects runtime attributes, and creates an SDK instance that provides all three providers (tracer, meter, logger) plus a propagator.
package telemetry
import (
"context"
"errors"
"fmt"
"os"
"github.com/google/uuid"
"go.opentelemetry.io/contrib/bridges/otelzap"
otelconf "go.opentelemetry.io/contrib/otelconf"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/log"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
type Providers struct {
TracerProvider trace.TracerProvider
MeterProvider metric.MeterProvider
LoggerProvider log.LoggerProvider
Logger *zap.Logger
Closer func(ctx context.Context) error
}
func SetupTelemetry(ctx context.Context, serviceName, version, configFile string) (*Providers, error) {
providers, sdk, err := providersFromConfig(ctx, serviceName, version, configFile)
if err != nil {
return nil, err
}
otel.SetTracerProvider(providers.TracerProvider)
otel.SetMeterProvider(providers.MeterProvider)
global.SetLoggerProvider(providers.LoggerProvider)
if sdk != nil {
otel.SetTextMapPropagator(sdk.Propagator())
} else {
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
}
return providers, nil
}
func providersFromConfig(ctx context.Context, scope, version, cfgFile string) (*Providers, *otelconf.SDK, error) {
b, err := os.ReadFile(cfgFile)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
logger := zap.Must(zap.NewProduction())
logger.Warn("OpenTelemetry config file not found, using no-op providers",
zap.String("config_file", cfgFile))
return &Providers{
TracerProvider: trace.NewNoOpTracerProvider(),
MeterProvider: metric.NewNoOpMeterProvider(),
LoggerProvider: log.NewNoOpLoggerProvider(),
Logger: logger,
Closer: func(ctx context.Context) error { return nil },
}, nil, nil
}
return nil, nil, fmt.Errorf("failed to read config file %s: %w", cfgFile, err)
}
b = []byte(os.ExpandEnv(string(b)))
conf, err := otelconf.ParseYAML(b)
if err != nil {
return nil, nil, err
}
if conf.Resource == nil {
conf.Resource = &otelconf.Resource{}
}
if conf.Resource.Attributes == nil {
conf.Resource.Attributes = []otelconf.AttributeNameValue{}
}
conf.Resource.Attributes = insertAttribute(conf.Resource.Attributes,
string(semconv.ServiceVersionKey), version)
conf.Resource.Attributes = insertAttribute(conf.Resource.Attributes,
string(semconv.ServiceInstanceIDKey), uuid.New().String())
sdk, err := otelconf.NewSDK(
otelconf.WithContext(ctx),
otelconf.WithOpenTelemetryConfiguration(*conf),
)
if err != nil {
return nil, nil, err
}
core := zapcore.NewTee(
zapcore.NewCore(
zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
zapcore.AddSync(os.Stdout),
zapcore.InfoLevel,
),
otelzap.NewCore(scope, otelzap.WithLoggerProvider(global.GetLoggerProvider())),
)
return &Providers{
TracerProvider: sdk.TracerProvider(),
MeterProvider: sdk.MeterProvider(),
LoggerProvider: sdk.LoggerProvider(),
Logger: zap.New(core),
Closer: sdk.Shutdown,
}, &sdk, nil
}
func insertAttribute(attrs []otelconf.AttributeNameValue, name, value string) []otelconf.AttributeNameValue {
for _, attr := range attrs {
if attr.Name == name {
return attrs
}
}
return append(attrs, otelconf.AttributeNameValue{Name: name, Value: value})
}
Main Integration
func main() {
ctx := context.Background()
providers, err := telemetry.SetupTelemetry(ctx,
telemetry.ServiceName,
telemetry.ServiceVersion,
"configs/otel.yaml")
if err != nil {
log.Fatalf("Failed to setup telemetry: %v", err)
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := providers.Closer(shutdownCtx); err != nil {
providers.Logger.Error("Failed to shutdown telemetry", zap.Error(err))
}
}()
tracer := otel.Tracer(telemetry.Scope)
meter := otel.Meter(telemetry.Scope)
// Application logic...
}
Key Details
- No-op fallback: If the config file doesn't exist, the setup returns no-op providers instead of failing. The application runs without telemetry.
- Runtime attributes:
service.versionandservice.instance.idare injected programmatically because they vary per deployment, not per environment. - Zap bridge: The
otelzapbridge sends structured logs to the OTel LoggerProvider, enabling log correlation with traces. Stdout JSON output is preserved via a tee. - 10-second shutdown timeout: Bounds shutdown so a hung exporter cannot block process exit.
Cross-References
- Reference:
otel-goskill —references/declarative-setup.mdforotelconffetch table, import path facts, schema version mapping;references/breaking-changes.mdfor SDK/contrib upgrade audits;references/instrumentation-libraries.mdfor wiring DB/HTTP/gRPC libraries, threadingcontext.Contextinto the data layer (avoid detached CLIENT-root DB spans), and keeping PII out ofdb.query.text. - General conventions:
ollygarden-otel-declarative-config— anti-patterns and common YAML patterns.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.