blob: e4ad0513c0582e023dc795f214d82b449bf73b25 [file]
// Copyright 2026 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package otel exports spans and metrics to an OpenTelemetry
// collector using the OTLP HTTP/JSON protocol.
//
// Use [NewExporter] to create an exporter, then chain its
// [Exporter.ProcessEvent] method with other exporters:
//
// otelExporter := otel.NewExporter(ctx,
// otel.WithEndpoint("http://localhost:4318"),
// otel.WithServiceName("myservice"),
// )
// event.SetExporter(otelExporter.ProcessEvent)
//
// The exporter batches telemetry and flushes periodically in a
// background goroutine. Call [Exporter.Flush] to force an
// immediate export.
package otel
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"runtime"
"strconv"
"sync"
"time"
"golang.org/x/tools/internal/event"
"golang.org/x/tools/internal/event/core"
"golang.org/x/tools/internal/event/export"
"golang.org/x/tools/internal/event/export/metric"
"golang.org/x/tools/internal/event/label"
)
// Exporter exports spans and metrics to an OTLP HTTP endpoint.
type Exporter struct {
mu sync.Mutex
endpoint string
serviceName string
serviceVersion string
resource otlpResource
client *http.Client
flushPeriod time.Duration
spans []*export.Span
metrics map[string]metric.Data
}
// NewExporter creates an exporter that sends spans to an OTLP endpoint.
// Spans are collected and exported periodically in a background goroutine.
// When the context is done, remaining spans are flushed.
func NewExporter(ctx context.Context, opts ...Option) *Exporter {
e := &Exporter{
endpoint: DefaultEndpoint,
serviceName: DefaultServiceName,
client: &http.Client{Timeout: DefaultTimeout},
flushPeriod: DefaultFlushPeriod,
}
for _, opt := range opts {
opt(e)
}
e.resource = e.buildResource()
go func() {
ticker := time.NewTicker(e.flushPeriod)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
e.Flush() // final flush before exiting
return
case <-ticker.C:
e.Flush()
}
}
}()
return e
}
// ProcessEvent handles events and collects completed spans and metrics.
func (e *Exporter) ProcessEvent(ctx context.Context, ev core.Event, lm label.Map) context.Context {
e.mu.Lock()
defer e.mu.Unlock()
if event.IsEnd(ev) {
if span := export.GetSpan(ctx); span != nil {
e.spans = append(e.spans, span)
}
}
if event.IsMetric(ev) {
if e.metrics == nil {
e.metrics = make(map[string]metric.Data)
}
if entries := metric.Entries.Get(lm); entries != nil {
for _, data := range entries.([]metric.Data) {
e.metrics[data.Handle()] = data
}
}
}
return ctx
}
// Flush exports all collected spans and metrics to the OTLP endpoint.
func (e *Exporter) Flush() {
e.mu.Lock()
spans := e.spans
e.spans = nil
metrics := e.metrics
e.metrics = nil
e.mu.Unlock()
// Export spans
if len(spans) > 0 {
otlpSpans := make([]otlpSpan, 0, len(spans))
for _, span := range spans {
otlpSpans = append(otlpSpans, convertSpan(span))
}
if err := e.exportTraces(otlpSpans); err != nil {
log.Printf("post spans: %v", err)
return
}
}
// Export metrics
if len(metrics) > 0 {
otlpMetrics := make([]otlpMetric, 0, len(metrics))
for _, data := range metrics {
otlpMetrics = append(otlpMetrics, convertMetric(data))
}
if err := e.exportMetrics(otlpMetrics); err != nil {
log.Printf("post metrics: %v", err)
}
}
}
// post sends a JSON payload to the OTLP endpoint.
func (e *Exporter) post(path string, payload any) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
// The background context is appropriate here since post is called only
// from Flush, which is called only by the Exporter's background goroutine.
// Notably it may be called after cancellation of the client's context.
req, err := http.NewRequestWithContext(context.Background(), "POST", e.endpoint+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := e.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("OTLP export to %s failed with status %d", path, resp.StatusCode)
}
return nil
}
func (e *Exporter) buildResource() otlpResource {
attrs := []otlpAttribute{
{Key: "service.name", Value: otlpAttributeValue{StringValue: &e.serviceName}},
{Key: "process.runtime.version", Value: otlpAttributeValue{StringValue: varOf(runtime.Version())}},
{Key: "process.pid", Value: otlpAttributeValue{StringValue: varOf(strconv.Itoa(os.Getpid()))}},
{Key: "host.arch", Value: otlpAttributeValue{StringValue: varOf(runtime.GOARCH)}},
{Key: "os.type", Value: otlpAttributeValue{StringValue: varOf(runtime.GOOS)}},
}
if hostname, err := os.Hostname(); err == nil {
attrs = append(attrs, otlpAttribute{
Key: "host.name",
Value: otlpAttributeValue{StringValue: &hostname},
})
}
if e.serviceVersion != "" {
attrs = append(attrs, otlpAttribute{
Key: "service.version",
Value: otlpAttributeValue{StringValue: &e.serviceVersion},
})
}
return otlpResource{Attributes: attrs}
}