Go instrumentation
Instrument a Go application with OpenTelemetry and send traces, logs, and metrics to Maple.
This guide sets up the OpenTelemetry Go SDK so your application sends traces, logs and metrics to Maple, and adds instrumentation for net/http, gRPC and database/sql.
To have a coding agent do this setup, use the maple-onboard skill, and maple-audit to check an existing setup.
Prerequisites
- Go 1.23+
- An ingest key from Settings → Ingestion in Maple. Use the private key (
maple_sk_…) for server applications.
Install
go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/sdk \
go.opentelemetry.io/otel/sdk/metric \
go.opentelemetry.io/otel/sdk/log \
go.opentelemetry.io/otel/log \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp \
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp
Configure
Set up the three providers at startup and shut them down on exit:
package main
import (
"context"
"errors"
"log"
"os"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
sdklog "go.opentelemetry.io/otel/sdk/log"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
const mapleEndpoint = "https://ingest.maple.dev" // EU: https://ingest.eu.maple.dev
const mapleKey = "YOUR_INGEST_KEY"
func initTelemetry(ctx context.Context) (func(context.Context) error, error) {
headers := map[string]string{"Authorization": "Bearer " + mapleKey}
res, err := resource.New(ctx, resource.WithAttributes(
attribute.String("service.name", "my-go-app"),
attribute.String("deployment.environment.name", os.Getenv("DEPLOYMENT_ENV")),
attribute.String("vcs.repository.url.full", "https://github.com/acme/my-go-app"),
))
if err != nil {
return nil, err
}
traceExporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpointURL(mapleEndpoint+"/v1/traces"),
otlptracehttp.WithHeaders(headers),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(traceExporter), sdktrace.WithResource(res))
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{},
))
metricExporter, err := otlpmetrichttp.New(ctx,
otlpmetrichttp.WithEndpointURL(mapleEndpoint+"/v1/metrics"),
otlpmetrichttp.WithHeaders(headers),
)
if err != nil {
return nil, err
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter)),
sdkmetric.WithResource(res),
)
otel.SetMeterProvider(mp)
logExporter, err := otlploghttp.New(ctx,
otlploghttp.WithEndpointURL(mapleEndpoint+"/v1/logs"),
otlploghttp.WithHeaders(headers),
)
if err != nil {
return nil, err
}
lp := sdklog.NewLoggerProvider(
sdklog.WithProcessor(sdklog.NewBatchProcessor(logExporter)),
sdklog.WithResource(res),
)
global.SetLoggerProvider(lp)
shutdown := func(ctx context.Context) error {
return errors.Join(tp.Shutdown(ctx), mp.Shutdown(ctx), lp.Shutdown(ctx))
}
return shutdown, nil
}
func main() {
ctx := context.Background()
// A setup error only disables telemetry; it never stops the app.
if shutdown, err := initTelemetry(ctx); err != nil {
log.Printf("telemetry disabled: %v", err)
} else {
defer shutdown(ctx)
}
// Your application code here
}
The example puts the endpoint and key in source. An ingest key can only write telemetry to your organization. It cannot read data or call the Maple API. Keeping it in source means the SDK always starts with a complete configuration, so a deploy that is missing an environment variable cannot silently turn telemetry off. To keep the key out of source, use environment variables instead.
Environment variables
The Go exporters read the standard OpenTelemetry variables when you create them without endpoint or header options:
export OTEL_SERVICE_NAME="my-go-app"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.maple.dev"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=production,vcs.repository.url.full=https://github.com/acme/my-go-app"
In initTelemetry, call otlptracehttp.New(ctx), otlpmetrichttp.New(ctx) and otlploghttp.New(ctx) with no options, and build the resource with resource.New(ctx, resource.WithFromEnv()) so it reads OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES. The *http exporters always use OTLP over HTTP; setting the protocol variable keeps the configuration explicit for any other tool that reads it.
Auto-instrumentation
Go has no automatic library discovery. You add an instrumentation package for each library you use.
HTTP server
go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
mux := http.NewServeMux()
mux.HandleFunc("GET /api/orders", handleOrders)
// Every request becomes a server span
handler := otelhttp.NewHandler(mux, "server")
http.ListenAndServe(":8080", handler)
HTTP client
Wrap the transport so outgoing requests get client spans and carry the trace context:
client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
resp, err := client.Do(req)
gRPC
go get go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc
import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
// Server
server := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
// Client
conn, err := grpc.NewClient(addr, grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
Database
go get github.com/XSAM/otelsql
import "github.com/XSAM/otelsql"
db, err := otelsql.Open("postgres", dsn)
Pass the request ctx to db.QueryContext and friends so query spans nest under the request span.
Custom spans
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
var tracer = otel.Tracer("my-app")
func processOrder(ctx context.Context, orderID string) error {
ctx, span := tracer.Start(ctx, "process-order")
defer span.End()
span.SetAttributes(
attribute.String("order.id", orderID),
attribute.String("payment.method", "card"),
)
if err := chargePayment(ctx, orderID); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
return nil
}
Service map edges come from instrumented client spans that propagate traceparent to an instrumented callee, not from attributes such as peer.service. See Service map. Always pass ctx down the call chain so child spans link to their parent.
Log correlation
The otelslog bridge sends log/slog records to the logger provider configured above. Records logged with a context that holds an active span carry its trace and span IDs:
go get go.opentelemetry.io/contrib/bridges/otelslog
import "go.opentelemetry.io/contrib/bridges/otelslog"
logger := otelslog.NewLogger("my-app")
logger.InfoContext(ctx, "Order processed", "order_id", orderID)
Use the ...Context methods. Without the context, the bridge cannot find the active span.
Verify
- Start your application and send it a few requests.
- In Maple, open Explore → Traces. The SDK sends spans in batches every 5 seconds by default, and metrics every 60 seconds.
- Each request should show up as one trace with a single root server span, and child spans for the queries and outgoing calls it made.
otelhttpnames the server span with the operation name you pass toNewHandler, and records the route inhttp.route.
Your service also appears on the Services page once its first spans arrive.
Troubleshooting
401responses. The key is wrong, was copied from the other region, or the header is malformed. The header must beAuthorization: Bearer YOUR_INGEST_KEY. InOTEL_EXPORTER_OTLP_HEADERSit is writtenAuthorization=Bearer YOUR_INGEST_KEY. See Ingest API status codes.- Wrong protocol or path. Maple accepts OTLP over HTTP. Use the
otlp*httpexporters, nototlp*grpc.WithEndpointURLtakes the full URL including/v1/traces;OTEL_EXPORTER_OTLP_ENDPOINTtakes only the base URL. - Network. From the machine running the app, run
curl -i https://ingest.maple.dev/v1/traces -X POST. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS. - Nothing exported. Call the shutdown function before
mainreturns so the batchers flush. Code that callsos.Exitorlog.Fatalskips deferred calls. Register an error handler withotel.SetErrorHandlerto print export failures.