telemetry
packageAPI reference for the telemetry
package.
Imports
(20)context
STD
fmt
STD
sync
STD
time
INT
github.com/mirkobrombin/go-foundation/v2/core/options
STD
bytes
STD
encoding/json
STD
errors
STD
net/http
STD
net/http/httptest
STD
strings
STD
sync/atomic
STD
testing
INT
github.com/mirkobrombin/go-foundation/v2/core/contracts
STD
bufio
STD
io
STD
net
STD
net/url
STD
path
STD
strconv
Tracer
Tracer creates and manages spans.
type Tracer interface
Methods
Span
Span represents an active span in a trace.
type Span interface
Methods
func End(...)
Meter
Meter creates and records metrics.
type Meter interface
Counter
Counter records monotonically increasing values.
type Counter interface
Methods
Histogram
Histogram records distribution of values.
type Histogram interface
Methods
Gauge
Gauge records current values.
type Gauge interface
Methods
Attribute
Attribute is a key-value pair for spans and metrics.
type Attribute struct
Fields
| Name | Type | Description |
|---|---|---|
| Key | string | |
| Value | any |
Provider
Provider is the central telemetry hub.
type Provider struct
Methods
Shutdown cleans up all providers.
func (*Provider) Shutdown()
{
for _, f := range p.shutdown {
f()
}
}
Option
Option configures a Provider.
type Option options.Option[Provider]
NewProvider
NewProvider creates a telemetry provider with noop defaults.
Parameters
Returns
func NewProvider(opts ...Option) *Provider
{
p := &Provider{
Tracer: noopTracerInst,
Meter: noopMeterInst,
}
for _, opt := range opts {
opt(p)
}
return p
}
WithTracer
WithTracer sets the tracer implementation.
func WithTracer(t Tracer) Option
{
return func(p *Provider) { p.Tracer = t }
}
WithMeter
WithMeter sets the meter implementation.
func WithMeter(m Meter) Option
{
return func(p *Provider) { p.Meter = m }
}
Timed
Timed measures and records the duration of fn as a metric.
Parameters
Returns
func Timed(ctx context.Context, h Histogram, fn func()) time.Duration
{
start := time.Now()
fn()
dur := time.Since(start)
h.Record(ctx, dur.Seconds())
return dur
}
noopTracer
type noopTracer struct
Methods
Parameters
Returns
func (*noopTracer) Start(ctx context.Context, name string, attrs ...Attribute) (Span, context.Context)
{
return &noopSpan{}, ctx
}
noopSpan
type noopSpan struct
Methods
Parameters
func (*noopSpan) SetAttributes(attrs ...Attribute)
{}
func (*noopSpan) End()
{}
noopMeter
type noopMeter struct
Methods
Parameters
Returns
func (*noopMeter) Counter(name string, attrs ...Attribute) Counter
{ return &noopCounter{} }
Parameters
Returns
func (*noopMeter) Histogram(name string, attrs ...Attribute) Histogram
{ return &noopHistogram{} }
noopCounter
type noopCounter struct
Methods
Parameters
func (*noopCounter) Add(ctx context.Context, delta int64)
{}
noopHistogram
type noopHistogram struct
Methods
Parameters
func (*noopHistogram) Record(ctx context.Context, value float64)
{}
noopGauge
type noopGauge struct
Methods
Parameters
func (*noopGauge) Set(ctx context.Context, value float64)
{}
SimpleTracer
SimpleTracer is a basic tracer that logs span starts and ends to stderr.
type SimpleTracer struct
Methods
Parameters
Returns
func (*SimpleTracer) Start(ctx context.Context, name string, attrs ...Attribute) (Span, context.Context)
{
span := &simpleSpan{name: name, start: time.Now(), attrs: attrs}
return span, ctx
}
Fields
| Name | Type | Description |
|---|---|---|
| mu | sync.Mutex |
NewSimpleTracer
NewSimpleTracer creates a SimpleTracer.
Returns
func NewSimpleTracer() *SimpleTracer
{ return &SimpleTracer{} }
simpleSpan
type simpleSpan struct
Methods
Parameters
func (*simpleSpan) SetAttributes(attrs ...Attribute)
{
s.attrs = append(s.attrs, attrs...)
}
func (*simpleSpan) End()
{
fmt.Printf("[TRACE] %s duration=%v attrs=%v\n", s.name, time.Since(s.start), s.attrs)
}
SimpleMeter
SimpleMeter is a basic meter that stores counters in memory.
type SimpleMeter struct
Methods
Parameters
Returns
func (*SimpleMeter) Counter(name string, attrs ...Attribute) Counter
{
return &simpleCounter{meter: m, name: name}
}
Parameters
Returns
func (*SimpleMeter) Histogram(name string, attrs ...Attribute) Histogram
{
return &simpleHistogram{meter: m, name: name}
}
Parameters
Returns
func (*SimpleMeter) Gauge(name string, attrs ...Attribute) Gauge
{
return &simpleGauge{meter: m, name: name}
}
GetCounter returns the current counter value by name.
Parameters
Returns
func (*SimpleMeter) GetCounter(name string) int64
{
m.mu.Lock()
defer m.mu.Unlock()
return m.counters[name]
}
GetGauge returns the current gauge value by name.
Parameters
Returns
func (*SimpleMeter) GetGauge(name string) float64
{
m.mu.Lock()
defer m.mu.Unlock()
return m.gauges[name]
}
GetHistogram returns the aggregate histogram values by name.
Parameters
Returns
func (*SimpleMeter) GetHistogram(name string) HistogramSnapshot
{
m.mu.Lock()
defer m.mu.Unlock()
return m.histogram[name]
}
Fields
| Name | Type | Description |
|---|---|---|
| mu | sync.Mutex | |
| counters | map[string]int64 | |
| histogram | map[string]HistogramSnapshot | |
| gauges | map[string]float64 |
HistogramSnapshot
HistogramSnapshot is the bounded aggregate for a recorded histogram.
type HistogramSnapshot struct
Fields
| Name | Type | Description |
|---|---|---|
| Count | int64 | |
| Sum | float64 | |
| Min | float64 | |
| Max | float64 |
NewSimpleMeter
NewSimpleMeter creates a SimpleMeter.
Returns
func NewSimpleMeter() *SimpleMeter
{
return &SimpleMeter{
counters: make(map[string]int64),
histogram: make(map[string]HistogramSnapshot),
gauges: make(map[string]float64),
}
}
simpleCounter
type simpleCounter struct
Methods
Parameters
func (*simpleCounter) Add(ctx context.Context, delta int64)
{
c.meter.mu.Lock()
c.meter.counters[c.name] += delta
c.meter.mu.Unlock()
}
Fields
| Name | Type | Description |
|---|---|---|
| meter | *SimpleMeter | |
| name | string |
simpleHistogram
type simpleHistogram struct
Methods
Parameters
func (*simpleHistogram) Record(ctx context.Context, value float64)
{
h.meter.mu.Lock()
snapshot := h.meter.histogram[h.name]
if snapshot.Count == 0 || value < snapshot.Min {
snapshot.Min = value
}
if snapshot.Count == 0 || value > snapshot.Max {
snapshot.Max = value
}
snapshot.Count++
snapshot.Sum += value
h.meter.histogram[h.name] = snapshot
h.meter.mu.Unlock()
}
Fields
| Name | Type | Description |
|---|---|---|
| meter | *SimpleMeter | |
| name | string |
simpleGauge
type simpleGauge struct
Methods
Parameters
func (*simpleGauge) Set(ctx context.Context, value float64)
{
g.meter.mu.Lock()
g.meter.gauges[g.name] = value
g.meter.mu.Unlock()
}
Fields
| Name | Type | Description |
|---|---|---|
| meter | *SimpleMeter | |
| name | string |
TestProvider_NoopDefault
Parameters
func TestProvider_NoopDefault(t *testing.T)
{
p := NewProvider()
if p.Tracer == nil {
t.Error("Tracer should not be nil (noop)")
}
if p.Meter == nil {
t.Error("Meter should not be nil (noop)")
}
span, ctx := p.Tracer.Start(context.Background(), "test")
span.SetAttributes(Attribute{Key: "k", Value: "v"})
span.End()
p.Meter.Counter("c").Add(ctx, 1)
p.Meter.Histogram("h").Record(ctx, 3.14)
p.Meter.Gauge("g").Set(ctx, 42)
}
TestSimpleTracer
Parameters
func TestSimpleTracer(t *testing.T)
{
tracer := NewSimpleTracer()
span, _ := tracer.Start(context.Background(), "operation")
span.End()
}
TestSimpleMeter
Parameters
func TestSimpleMeter(t *testing.T)
{
meter := NewSimpleMeter()
ctx := context.Background()
counter := meter.Counter("requests")
counter.Add(ctx, 5)
counter.Add(ctx, 3)
if v := meter.GetCounter("requests"); v != 8 {
t.Errorf("counter: got %d, want 8", v)
}
gauge := meter.Gauge("cpu")
gauge.Set(ctx, 75.5)
if v := meter.GetGauge("cpu"); v != 75.5 {
t.Errorf("gauge: got %f, want 75.5", v)
}
histogram := meter.Histogram("latency")
histogram.Record(ctx, 2)
histogram.Record(ctx, 6)
snapshot := meter.GetHistogram("latency")
if snapshot.Count != 2 || snapshot.Sum != 8 || snapshot.Min != 2 || snapshot.Max != 6 {
t.Fatalf("histogram = %+v", snapshot)
}
}
TestTimed
Parameters
func TestTimed(t *testing.T)
{
meter := NewSimpleMeter()
h := meter.Histogram("duration")
ctx := context.Background()
called := false
dur := Timed(ctx, h, func() {
called = true
})
if !called {
t.Error("Timed should call fn")
}
if dur <= 0 {
t.Error("Timed should return positive duration")
}
}
TestProvider_WithTracer
Parameters
func TestProvider_WithTracer(t *testing.T)
{
custom := NewSimpleTracer()
p := NewProvider(WithTracer(custom))
if p.Tracer != custom {
t.Error("WithTracer should set custom tracer")
}
}
TestProvider_Shutdown
Parameters
func TestProvider_Shutdown(t *testing.T)
{
called := false
p := NewProvider()
p.shutdown = append(p.shutdown, func() { called = true })
p.Shutdown()
if !called {
t.Error("Shutdown should call cleanup functions")
}
}
TestOTLPExporter_ExportFlush
Parameters
func TestOTLPExporter_ExportFlush(t *testing.T)
{
type request struct {
path string
body map[string]any
}
requests := make(chan request, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode OTLP request: %v", err)
}
requests <- request{path: r.URL.Path, body: body}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
e := NewOTLPExporter(WithOTLPEndpoint(server.URL), WithOTLPBatchSize(100))
defer e.Close()
start := time.Now().Add(-time.Second)
if err := e.ExportSpan(OTLPSpan{
TraceID: "0af7651916cd43dd8448eb211c80319c",
SpanID: "b7ad6b7169203331",
Name: "test-span",
StartTime: start,
EndTime: time.Now(),
}); err != nil {
t.Fatal(err)
}
if err := e.ExportMetric(OTLPMetric{
Name: "http_requests",
Kind: OTLPMetricCounter,
Value: 42,
}); err != nil {
t.Fatal(err)
}
err := e.Flush(context.Background())
if err != nil {
t.Errorf("Flush: %v", err)
}
seen := make(map[string]map[string]any)
for range 2 {
req := <-requests
seen[req.path] = req.body
}
if seen["/v1/traces"]["resourceSpans"] == nil {
t.Fatal("trace request did not contain resourceSpans")
}
if seen["/v1/metrics"]["resourceMetrics"] == nil {
t.Fatal("metric request did not contain resourceMetrics")
}
}
TestOTLPExporterCloseIsIdempotentAndFlushes
Parameters
func TestOTLPExporterCloseIsIdempotentAndFlushes(t *testing.T)
{
requests := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests <- struct{}{}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
exporter := NewOTLPExporter(WithOTLPEndpoint(server.URL))
if err := exporter.ExportMetric(OTLPMetric{Name: "requests", Kind: OTLPMetricCounter, Value: 1}); err != nil {
t.Fatal(err)
}
if err := exporter.Close(); err != nil {
t.Fatal(err)
}
if err := exporter.Close(); err != nil {
t.Fatal(err)
}
select {
case <-requests:
case <-time.After(time.Second):
t.Fatal("Close() did not flush pending telemetry")
}
}
TestOTLPExporterRestoresFailedBatch
Parameters
func TestOTLPExporterRestoresFailedBatch(t *testing.T)
{
exporter := NewOTLPExporter(WithOTLPEndpoint("://invalid"))
start := time.Now().Add(-time.Second)
if err := exporter.ExportSpan(OTLPSpan{
TraceID: "0af7651916cd43dd8448eb211c80319c",
SpanID: "b7ad6b7169203331",
Name: "span",
StartTime: start,
EndTime: time.Now(),
}); err != nil {
t.Fatal(err)
}
if err := exporter.Flush(context.Background()); err == nil {
t.Fatal("Flush() succeeded with invalid endpoint")
}
exporter.batchMu.Lock()
count := len(exporter.spans)
exporter.batchMu.Unlock()
if count != 1 {
t.Fatalf("retained spans = %d, want 1", count)
}
_ = exporter.Close()
}
TestOTLPExporterBoundsQueueAndRejectsUseAfterClose
Parameters
func TestOTLPExporterBoundsQueueAndRejectsUseAfterClose(t *testing.T)
{
exporter := NewOTLPExporter(
WithOTLPBatchSize(10),
WithOTLPQueueSize(1),
)
if err := exporter.ExportMetric(OTLPMetric{
Name: "load",
Kind: OTLPMetricGauge,
}); err != nil {
t.Fatal(err)
}
if err := exporter.ExportMetric(OTLPMetric{
Name: "second",
Kind: OTLPMetricGauge,
}); !errors.Is(err, ErrOTLPQueueFull) {
t.Fatalf("ExportMetric() error = %v, want ErrOTLPQueueFull", err)
}
exporter.endpoint = "://invalid"
if err := exporter.Close(); err == nil {
t.Fatal("Close() succeeded with an invalid endpoint")
}
if err := exporter.ExportMetric(OTLPMetric{
Name: "closed",
Kind: OTLPMetricGauge,
}); !errors.Is(err, ErrOTLPExporterClosed) {
t.Fatalf("ExportMetric() after Close() = %v", err)
}
}
TestOTLPExporterValidatesPublicInputs
Parameters
func TestOTLPExporterValidatesPublicInputs(t *testing.T)
{
exporter := NewOTLPExporter()
defer exporter.Close()
if err := exporter.ExportSpan(OTLPSpan{Name: "invalid"}); err == nil {
t.Fatal("ExportSpan() accepted missing IDs and timestamps")
}
if err := exporter.ExportMetric(OTLPMetric{Name: "invalid"}); err == nil {
t.Fatal("ExportMetric() accepted an invalid kind")
}
}
TestOTLPExporterDoesNotRetryPartialSuccessBatch
Parameters
func TestOTLPExporterDoesNotRetryPartialSuccessBatch(t *testing.T)
{
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"partialSuccess":{"rejectedDataPoints":"1","errorMessage":"invalid metric"}}`))
}))
defer server.Close()
exporter := NewOTLPExporter(WithOTLPEndpoint(server.URL))
defer exporter.Close()
if err := exporter.ExportMetric(OTLPMetric{
Name: "invalid",
Kind: OTLPMetricGauge,
}); err != nil {
t.Fatal(err)
}
if err := exporter.Flush(context.Background()); err == nil {
t.Fatal("Flush() ignored an OTLP partial success")
}
exporter.batchMu.Lock()
queued := len(exporter.metrics)
exporter.batchMu.Unlock()
if queued != 0 {
t.Fatalf("partial-success batch was queued for retry: %d metrics", queued)
}
}
TestOTLPExporterCoalescesAutomaticFlushDuringOutage
Parameters
func TestOTLPExporterCoalescesAutomaticFlushDuringOutage(t *testing.T)
{
started := make(chan struct{}, 1)
release := make(chan struct{})
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
select {
case started <- struct{}{}:
default:
}
<-release
w.WriteHeader(http.StatusServiceUnavailable)
}))
exporter := NewOTLPExporter(
WithOTLPEndpoint(server.URL),
WithOTLPBatchSize(1),
)
var wait sync.WaitGroup
wait.Add(10)
for index := 0; index < 10; index++ {
go func(value float64) {
defer wait.Done()
_ = exporter.ExportMetric(OTLPMetric{
Name: "load",
Kind: OTLPMetricGauge,
Value: value,
})
}(float64(index))
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("automatic flush did not start")
}
wait.Wait()
close(release)
deadline := time.Now().Add(time.Second)
for {
exporter.batchMu.Lock()
inFlight := exporter.inFlight
exporter.batchMu.Unlock()
if inFlight == 0 || time.Now().After(deadline) {
break
}
time.Sleep(time.Millisecond)
}
if got := requests.Load(); got != 1 {
t.Fatalf("automatic outage flush requests = %d, want 1", got)
}
exporter.batchMu.Lock()
exporter.spans = nil
exporter.metrics = nil
exporter.batchMu.Unlock()
if err := exporter.Close(); err != nil {
t.Fatal(err)
}
server.Close()
}
TestPrometheusExporter_CounterGauge
Parameters
func TestPrometheusExporter_CounterGauge(t *testing.T)
{
p := NewPrometheusExporter()
p.IncCounter("http_requests_total", 5)
p.IncCounter("http_requests_total", 3)
p.SetGauge("cpu_usage", 75.5)
var buf bytes.Buffer
p.WriteText(&buf)
output := buf.String()
if !strings.Contains(output, "http_requests_total 8") {
t.Errorf("expected counter in output, got: %s", output)
}
if !strings.Contains(output, "cpu_usage 75.5") {
t.Errorf("expected gauge in output, got: %s", output)
}
}
TestPrometheusExporter_Histogram
Parameters
func TestPrometheusExporter_Histogram(t *testing.T)
{
p := NewPrometheusExporter()
buckets := []float64{0.1, 0.5, 1.0}
p.ObserveHistogram("request_duration", 0.05, buckets)
p.ObserveHistogram("request_duration", 0.25, buckets)
p.ObserveHistogram("request_duration", 0.75, buckets)
p.ObserveHistogram("request_duration", 1.5, buckets)
var buf bytes.Buffer
p.WriteText(&buf)
output := buf.String()
if !strings.Contains(output, "request_duration_bucket") {
t.Errorf("expected histogram buckets in output, got: %s", output)
}
if !strings.Contains(output, "request_duration_sum") {
t.Errorf("expected histogram sum in output, got: %s", output)
}
}
TestTraceparent
Parameters
func TestTraceparent(t *testing.T)
{
tc, err := ParseTraceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
if err != nil {
t.Fatalf("ParseTraceparent: %v", err)
}
if tc.TraceID != "0af7651916cd43dd8448eb211c80319c" {
t.Errorf("TraceID = %q, want full trace ID", tc.TraceID)
}
if tc.ParentID != "b7ad6b7169203331" {
t.Errorf("ParentID = %q", tc.ParentID)
}
if tc.TraceFlags != "01" {
t.Errorf("TraceFlags = %q", tc.TraceFlags)
}
encoded := tc.Encode()
if encoded != "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" {
t.Errorf("Encode = %q", encoded)
}
}
TestTraceparent_Invalid
Parameters
func TestTraceparent_Invalid(t *testing.T)
{
_, err := ParseTraceparent("invalid")
if err == nil {
t.Error("expected error for invalid traceparent")
}
_, err = ParseTraceparent("01-abc-def-01")
if err == nil {
t.Error("expected error for unsupported version")
}
invalid := []string{
"00-00000000000000000000000000000000-b7ad6b7169203331-01",
"00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01",
"00-0AF7651916CD43DD8448EB211C80319C-b7ad6b7169203331-01",
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-zz",
}
for _, header := range invalid {
if _, err := ParseTraceparent(header); err == nil {
t.Fatalf("ParseTraceparent() accepted %q", header)
}
}
}
TestTelemetryMiddleware_WrapHTTP
Parameters
func TestTelemetryMiddleware_WrapHTTP(t *testing.T)
{
meter := NewSimpleMeter()
provider := NewProvider(WithMeter(meter))
mw := NewTelemetryMiddleware(provider)
handler := mw.WrapHTTP(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("status = %d, want 200", w.Code)
}
if v := meter.GetCounter("http_requests_total"); v == 0 {
t.Error("expected counter to be incremented")
}
if got := w.Header().Get("traceparent"); got == "" {
t.Fatal("traceparent response header was not written")
}
}
TestTelemetryURLRedactsSecrets
Parameters
func TestTelemetryURLRedactsSecrets(t *testing.T)
{
request := httptest.NewRequest(
http.MethodGet,
"https://user:[email protected]/path?token=secret#fragment",
nil,
)
got := telemetryURL(request)
if got != "https://example.com/path" {
t.Fatalf("telemetryURL() = %q, want safe URL", got)
}
}
TestTelemetryResponseWriterPreservesFirstStatusAndUnwraps
Parameters
func TestTelemetryResponseWriterPreservesFirstStatusAndUnwraps(t *testing.T)
{
recorder := httptest.NewRecorder()
writer := &telemetryResponseWriter{
ResponseWriter: recorder,
statusCode: http.StatusOK,
}
writer.WriteHeader(http.StatusCreated)
writer.WriteHeader(http.StatusInternalServerError)
if writer.statusCode != http.StatusCreated {
t.Fatalf("recorded status = %d, want 201", writer.statusCode)
}
if recorder.Code != http.StatusCreated {
t.Fatalf("response status = %d, want 201", recorder.Code)
}
if writer.Unwrap() != recorder {
t.Fatal("Unwrap() did not return the underlying writer")
}
if err := http.NewResponseController(writer).Flush(); err != nil {
t.Fatalf("ResponseController.Flush() error = %v", err)
}
}
OTLPExporter
OTLPExporter sends spans and metrics to an OTLP-compatible endpoint over HTTP/JSON.
type OTLPExporter struct
Methods
ExportSpan queues a span and flushes when the batch threshold is reached.
Parameters
Returns
func (*OTLPExporter) ExportSpan(span OTLPSpan) error
{
if err := validateOTLPSpan(span); err != nil {
return err
}
e.batchMu.Lock()
if e.closed {
e.batchMu.Unlock()
return ErrOTLPExporterClosed
}
if len(e.spans)+len(e.metrics)+e.inFlight >= e.maxQueue {
e.batchMu.Unlock()
return ErrOTLPQueueFull
}
e.exportWG.Add(1)
defer e.exportWG.Done()
e.spans = append(e.spans, span)
shouldFlush := len(e.spans) >= e.maxBatch
e.batchMu.Unlock()
if shouldFlush {
e.requestFlush()
}
return nil
}
ExportMetric queues a metric and flushes when the batch threshold is reached.
Parameters
Returns
func (*OTLPExporter) ExportMetric(metric OTLPMetric) error
{
if err := validateOTLPMetric(metric); err != nil {
return err
}
e.batchMu.Lock()
if e.closed {
e.batchMu.Unlock()
return ErrOTLPExporterClosed
}
if len(e.spans)+len(e.metrics)+e.inFlight >= e.maxQueue {
e.batchMu.Unlock()
return ErrOTLPQueueFull
}
e.exportWG.Add(1)
defer e.exportWG.Done()
e.metrics = append(e.metrics, metric)
shouldFlush := len(e.metrics) >= e.maxBatch
e.batchMu.Unlock()
if shouldFlush {
e.requestFlush()
}
return nil
}
func (*OTLPExporter) requestFlush()
{
select {
case e.flushCh <- struct{}{}:
default:
}
}
Flush sends all currently queued telemetry.
Parameters
Returns
func (*OTLPExporter) Flush(ctx context.Context) error
{
if ctx == nil {
ctx = context.Background()
}
e.flushMu.Lock()
defer e.flushMu.Unlock()
e.batchMu.Lock()
spans := e.spans
metrics := e.metrics
e.spans = nil
e.metrics = nil
batchSize := len(spans) + len(metrics)
e.inFlight += batchSize
e.batchMu.Unlock()
if len(spans) == 0 && len(metrics) == 0 {
return nil
}
spansSent := len(spans) == 0
metricsSent := len(metrics) == 0
defer func() {
e.batchMu.Lock()
if !spansSent {
e.spans = append(spans, e.spans...)
}
if !metricsSent {
e.metrics = append(metrics, e.metrics...)
}
e.inFlight -= batchSize
e.batchMu.Unlock()
}()
if len(spans) > 0 {
if err := e.send(ctx, "traces", otlpTracePayload(spans)); err != nil {
var partial *otlpPartialSuccessError
if errors.As(err, &partial) {
spansSent = true
}
return err
}
spansSent = true
}
if len(metrics) > 0 {
if err := e.send(ctx, "metrics", otlpMetricPayload(metrics)); err != nil {
var partial *otlpPartialSuccessError
if errors.As(err, &partial) {
metricsSent = true
}
return err
}
metricsSent = true
}
return nil
}
Parameters
Returns
func (*OTLPExporter) send(ctx context.Context, signal string, payload any) error
{
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("otlp marshal %s: %w", signal, err)
}
endpoint, err := otlpSignalEndpoint(e.endpoint, signal)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("otlp request %s: %w", signal, err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := e.client.Do(req)
if err != nil {
return fmt.Errorf("otlp send %s: %w", signal, err)
}
defer resp.Body.Close()
const maxOTLPResponseSize = 4 << 20
body, err := io.ReadAll(io.LimitReader(resp.Body, maxOTLPResponseSize+1))
if err != nil {
return fmt.Errorf("otlp read %s response: %w", signal, err)
}
if len(body) > maxOTLPResponseSize {
return fmt.Errorf("otlp %s response exceeds size limit", signal)
}
if resp.StatusCode >= 300 {
return fmt.Errorf("otlp %s status %d: %s", signal, resp.StatusCode, string(body))
}
if len(bytes.TrimSpace(body)) > 0 {
var response struct {
PartialSuccess struct {
RejectedSpans string `json:"rejectedSpans"`
RejectedDataPoints string `json:"rejectedDataPoints"`
ErrorMessage string `json:"errorMessage"`
} `json:"partialSuccess"`
}
if err := json.Unmarshal(body, &response); err != nil {
return fmt.Errorf("otlp decode %s response: %w", signal, err)
}
rejected := response.PartialSuccess.RejectedSpans
if signal == "metrics" {
rejected = response.PartialSuccess.RejectedDataPoints
}
if rejected != "" && rejected != "0" {
return &otlpPartialSuccessError{
signal: signal,
rejected: rejected,
message: response.PartialSuccess.ErrorMessage,
}
}
}
return nil
}
func (*OTLPExporter) loop()
{
ticker := time.NewTicker(e.flushMs)
defer ticker.Stop()
for {
select {
case <-ticker.C:
_ = e.Flush(context.Background())
case <-e.flushCh:
if err := e.Flush(context.Background()); err != nil {
select {
case <-e.flushCh:
default:
}
}
case <-e.stopCh:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := e.Flush(ctx)
cancel()
e.closeMu.Lock()
e.closeErr = err
e.closeMu.Unlock()
close(e.done)
return
}
}
}
Returns
func (*OTLPExporter) Close() error
{
e.stopOnce.Do(func() {
e.batchMu.Lock()
e.closed = true
e.batchMu.Unlock()
e.exportWG.Wait()
close(e.stopCh)
})
<-e.done
e.closeMu.Lock()
defer e.closeMu.Unlock()
return e.closeErr
}
Fields
| Name | Type | Description |
|---|---|---|
| endpoint | string | |
| client | *http.Client | |
| batchMu | sync.Mutex | |
| exportWG | sync.WaitGroup | |
| flushMu | sync.Mutex | |
| spans | []OTLPSpan | |
| metrics | []OTLPMetric | |
| inFlight | int | |
| maxBatch | int | |
| maxQueue | int | |
| flushMs | time.Duration | |
| flushCh | chan struct{} | |
| stopCh | chan struct{} | |
| stopOnce | sync.Once | |
| done | chan struct{} | |
| closeMu | sync.Mutex | |
| closeErr | error | |
| closed | bool |
OTLPMetricKind
OTLPMetricKind identifies the OTLP data-point representation.
type OTLPMetricKind string
OTLPMetric
OTLPMetric is a metric data point accepted by OTLPExporter.
type OTLPMetric struct
Fields
| Name | Type | Description |
|---|---|---|
| Name | string | |
| Kind | OTLPMetricKind | |
| Value | float64 | |
| Time | time.Time | |
| Attributes | map[string]any |
OTLPOption
OTLPOption configures an OTLPExporter.
type OTLPOption func(*OTLPExporter)
WithOTLPEndpoint
WithOTLPEndpoint sets the OTLP receiver base URL.
Parameters
Returns
func WithOTLPEndpoint(url string) OTLPOption
{
return func(e *OTLPExporter) { e.endpoint = url }
}
WithOTLPBatchSize
WithOTLPBatchSize sets the maximum batch size before flushing.
Parameters
Returns
func WithOTLPBatchSize(n int) OTLPOption
{
return func(e *OTLPExporter) { e.maxBatch = n }
}
WithOTLPQueueSize
WithOTLPQueueSize sets the maximum number of pending spans and metrics.
Parameters
Returns
func WithOTLPQueueSize(n int) OTLPOption
{
return func(e *OTLPExporter) { e.maxQueue = n }
}
NewOTLPExporter
NewOTLPExporter creates a new OTLPExporter with the given options.
Parameters
Returns
func NewOTLPExporter(opts ...OTLPOption) *OTLPExporter
{
e := &OTLPExporter{
endpoint: "http://localhost:4318",
client: &http.Client{Timeout: 5 * time.Second},
maxBatch: 100,
maxQueue: 10_000,
flushMs: 5000 * time.Millisecond,
flushCh: make(chan struct{}, 1),
stopCh: make(chan struct{}),
done: make(chan struct{}),
}
for _, opt := range opts {
opt(e)
}
if e.maxBatch <= 0 {
panic("telemetry: OTLP batch size must be positive")
}
if e.maxQueue <= 0 {
panic("telemetry: OTLP queue size must be positive")
}
go e.loop()
return e
}
otlpPartialSuccessError
type otlpPartialSuccessError struct
Methods
Returns
func (*otlpPartialSuccessError) Error() string
{
return fmt.Sprintf("otlp %s partial success rejected %s: %s", e.signal, e.rejected, e.message)
}
Fields
| Name | Type | Description |
|---|---|---|
| signal | string | |
| rejected | string | |
| message | string |
otlpSignalEndpoint
Parameters
Returns
func otlpSignalEndpoint(base, signal string) (string, error)
{
endpoint, err := url.Parse(base)
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return "", fmt.Errorf("otlp endpoint %q is invalid", base)
}
cleanPath := strings.TrimSuffix(endpoint.Path, "/")
for _, suffix := range []string{"/v1/traces", "/v1/metrics"} {
cleanPath = strings.TrimSuffix(cleanPath, suffix)
}
endpoint.Path = path.Join("/", cleanPath, "v1", signal)
return endpoint.String(), nil
}
validateOTLPSpan
Parameters
Returns
func validateOTLPSpan(span OTLPSpan) error
{
if len(span.TraceID) != 32 || !isLowerHex(span.TraceID) || allZero(span.TraceID) {
return errors.New("telemetry: OTLP span requires a non-zero 32-character lowercase hexadecimal trace ID")
}
if len(span.SpanID) != 16 || !isLowerHex(span.SpanID) || allZero(span.SpanID) {
return errors.New("telemetry: OTLP span requires a non-zero 16-character lowercase hexadecimal span ID")
}
if span.Name == "" {
return errors.New("telemetry: OTLP span name cannot be empty")
}
if span.StartTime.IsZero() || span.EndTime.IsZero() || span.EndTime.Before(span.StartTime) {
return errors.New("telemetry: OTLP span requires a valid start and end time")
}
return nil
}
Uses
validateOTLPMetric
Parameters
Returns
func validateOTLPMetric(metric OTLPMetric) error
{
if metric.Name == "" {
return errors.New("telemetry: OTLP metric name cannot be empty")
}
switch metric.Kind {
case OTLPMetricCounter, OTLPMetricGauge, OTLPMetricHistogram:
default:
return fmt.Errorf("telemetry: unsupported OTLP metric kind %q", metric.Kind)
}
return nil
}
otlpTracePayload
Parameters
Returns
func otlpTracePayload(spans []OTLPSpan) any
{
items := make([]map[string]any, 0, len(spans))
for _, span := range spans {
items = append(items, map[string]any{
"traceId": span.TraceID,
"spanId": span.SpanID,
"name": span.Name,
"kind": 1,
"startTimeUnixNano": strconv.FormatInt(span.StartTime.UnixNano(), 10),
"endTimeUnixNano": strconv.FormatInt(span.EndTime.UnixNano(), 10),
"attributes": otlpAttributes(span.Attributes),
})
}
return map[string]any{
"resourceSpans": []any{
map[string]any{
"scopeSpans": []any{
map[string]any{"spans": items},
},
},
},
}
}
otlpMetricPayload
Parameters
Returns
func otlpMetricPayload(metrics []OTLPMetric) any
{
items := make([]map[string]any, 0, len(metrics))
for _, metric := range metrics {
timestamp := metric.Time
if timestamp.IsZero() {
timestamp = time.Now()
}
point := map[string]any{
"timeUnixNano": strconv.FormatInt(timestamp.UnixNano(), 10),
"asDouble": metric.Value,
"attributes": otlpAttributes(metric.Attributes),
}
item := map[string]any{"name": metric.Name}
switch metric.Kind {
case OTLPMetricCounter:
item["sum"] = map[string]any{
"aggregationTemporality": 2,
"isMonotonic": true,
"dataPoints": []any{point},
}
case OTLPMetricGauge:
item["gauge"] = map[string]any{"dataPoints": []any{point}}
case OTLPMetricHistogram:
delete(point, "asDouble")
point["count"] = "1"
point["sum"] = metric.Value
point["min"] = metric.Value
point["max"] = metric.Value
point["bucketCounts"] = []string{"1"}
point["explicitBounds"] = []float64{}
item["histogram"] = map[string]any{
"aggregationTemporality": 2,
"dataPoints": []any{point},
}
}
items = append(items, item)
}
return map[string]any{
"resourceMetrics": []any{
map[string]any{
"scopeMetrics": []any{
map[string]any{"metrics": items},
},
},
},
}
}
otlpAttributes
Parameters
Returns
func otlpAttributes(attributes map[string]any) []map[string]any
{
result := make([]map[string]any, 0, len(attributes))
for key, value := range attributes {
result = append(result, map[string]any{
"key": key,
"value": otlpAnyValue(value),
})
}
return result
}
otlpAnyValue
Parameters
Returns
func otlpAnyValue(value any) map[string]any
{
switch typed := value.(type) {
case bool:
return map[string]any{"boolValue": typed}
case int:
return map[string]any{"intValue": strconv.FormatInt(int64(typed), 10)}
case int8:
return map[string]any{"intValue": strconv.FormatInt(int64(typed), 10)}
case int16:
return map[string]any{"intValue": strconv.FormatInt(int64(typed), 10)}
case int32:
return map[string]any{"intValue": strconv.FormatInt(int64(typed), 10)}
case int64:
return map[string]any{"intValue": strconv.FormatInt(typed, 10)}
case uint:
return map[string]any{"intValue": strconv.FormatUint(uint64(typed), 10)}
case uint8:
return map[string]any{"intValue": strconv.FormatUint(uint64(typed), 10)}
case uint16:
return map[string]any{"intValue": strconv.FormatUint(uint64(typed), 10)}
case uint32:
return map[string]any{"intValue": strconv.FormatUint(uint64(typed), 10)}
case uint64:
return map[string]any{"intValue": strconv.FormatUint(typed, 10)}
case float32:
return map[string]any{"doubleValue": float64(typed)}
case float64:
return map[string]any{"doubleValue": typed}
case string:
return map[string]any{"stringValue": typed}
default:
return map[string]any{"stringValue": fmt.Sprint(typed)}
}
}
PrometheusExporter
type PrometheusExporter struct
Methods
Parameters
func (*PrometheusExporter) IncCounter(name string, delta int64)
{
p.mu.Lock()
defer p.mu.Unlock()
p.counters[name] += delta
}
Parameters
func (*PrometheusExporter) SetGauge(name string, value float64)
{
p.mu.Lock()
defer p.mu.Unlock()
p.gauges[name] = value
}
Parameters
func (*PrometheusExporter) ObserveHistogram(name string, value float64, buckets []float64)
{
p.mu.Lock()
defer p.mu.Unlock()
h, ok := p.histos[name]
if !ok {
h = &promHistogram{buckets: make(map[float64]int64), sum: 0, count: 0}
for _, b := range buckets {
h.buckets[b] = 0
}
p.histos[name] = h
}
h.count++
h.sum += value
for _, b := range buckets {
if value <= b {
h.buckets[b]++
}
}
}
Parameters
func (*PrometheusExporter) WriteText(w io.Writer)
{
p.mu.RLock()
defer p.mu.RUnlock()
for name, val := range p.counters {
fmt.Fprintf(w, "# TYPE %s counter\n%s %d\n", name, name, val)
}
for name, val := range p.gauges {
fmt.Fprintf(w, "# TYPE %s gauge\n%s %g\n", name, name, val)
}
for name, h := range p.histos {
fmt.Fprintf(w, "# TYPE %s histogram\n", name)
for _, b := range sortedKeys(h.buckets) {
fmt.Fprintf(w, "%s_bucket{le=\"%g\"} %d\n", name, b, h.buckets[b])
}
fmt.Fprintf(w, "%s_bucket{le=\"+Inf\"} %d\n", name, h.count)
fmt.Fprintf(w, "%s_sum %g\n", name, h.sum)
fmt.Fprintf(w, "%s_count %d\n", name, h.count)
}
}
Fields
| Name | Type | Description |
|---|---|---|
| mu | sync.RWMutex | |
| counters | map[string]int64 | |
| gauges | map[string]float64 | |
| histos | map[string]*promHistogram |
promHistogram
type promHistogram struct
Fields
| Name | Type | Description |
|---|---|---|
| buckets | map[float64]int64 | |
| sum | float64 | |
| count | int64 |
NewPrometheusExporter
NewPrometheusExporter creates a new PrometheusExporter.
Returns
func NewPrometheusExporter() *PrometheusExporter
{
return &PrometheusExporter{
counters: make(map[string]int64),
gauges: make(map[string]float64),
histos: make(map[string]*promHistogram),
}
}
sortedKeys
Parameters
Returns
func sortedKeys(m map[float64]int64) []float64
{
keys := make([]float64, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sortFloat64s(keys)
return keys
}
sortFloat64s
Parameters
func sortFloat64s(a []float64)
{
for i := 1; i < len(a); i++ {
for j := i; j > 0 && a[j] < a[j-1]; j-- {
a[j], a[j-1] = a[j-1], a[j]
}
}
}
TraceContext
type TraceContext struct
Methods
Returns
func (*TraceContext) Encode() string
{
return fmt.Sprintf("00-%s-%s-%s", tc.TraceID, tc.ParentID, tc.TraceFlags)
}
Fields
| Name | Type | Description |
|---|---|---|
| TraceID | string | |
| ParentID | string | |
| TraceFlags | string |
ParseTraceparent
ParseTraceparent parses a W3C traceparent header.
Parameters
Returns
func ParseTraceparent(header string) (*TraceContext, error)
{
parts := strings.Split(header, "-")
if len(parts) != 4 {
return nil, fmt.Errorf("invalid traceparent: expected 4 parts, got %d", len(parts))
}
if parts[0] != "00" {
return nil, fmt.Errorf("unsupported traceparent version: %s", parts[0])
}
tc := &TraceContext{
TraceID: parts[1],
ParentID: parts[2],
TraceFlags: parts[3],
}
if len(tc.TraceID) != 32 {
return nil, fmt.Errorf("invalid trace ID length: %d", len(tc.TraceID))
}
if len(tc.ParentID) != 16 {
return nil, fmt.Errorf("invalid parent ID length: %d", len(tc.ParentID))
}
if len(tc.TraceFlags) != 2 {
return nil, fmt.Errorf("invalid trace flags length: %d", len(tc.TraceFlags))
}
if !isLowerHex(tc.TraceID) || !isLowerHex(tc.ParentID) || !isLowerHex(tc.TraceFlags) {
return nil, fmt.Errorf("traceparent IDs and flags must use lowercase hexadecimal")
}
if allZero(tc.TraceID) || allZero(tc.ParentID) {
return nil, fmt.Errorf("traceparent trace and parent IDs cannot be all zero")
}
return tc, nil
}
isLowerHex
Parameters
Returns
func isLowerHex(value string) bool
{
for _, character := range value {
if character < '0' || character > '9' {
if character < 'a' || character > 'f' {
return false
}
}
}
return true
}
allZero
Parameters
Returns
func allZero(value string) bool
{
return strings.Trim(value, "0") == ""
}
TelemetryMiddleware
type TelemetryMiddleware struct
Methods
SrvMiddleware returns a middleware function compatible with the srv package. It accepts a HandlerFunc type to avoid circular import - use via adapter.
Parameters
Returns
func (*TelemetryMiddleware) WrapHTTP(next http.Handler) http.Handler
{
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
traceCtx, _ := ParseTraceparent(r.Header.Get("traceparent"))
var span Span
ctx := r.Context()
if tm.Provider != nil && tm.Provider.Tracer != nil {
attrs := []Attribute{
{Key: "http.method", Value: r.Method},
{Key: "http.url", Value: telemetryURL(r)},
}
if traceCtx != nil {
attrs = append(attrs,
Attribute{Key: "trace.parent_id", Value: traceCtx.ParentID},
)
}
span, ctx = tm.Provider.Tracer.Start(ctx, r.Method+" "+r.URL.Path, attrs...)
}
rw := &telemetryResponseWriter{ResponseWriter: w, statusCode: 200}
if traceCtx != nil {
w.Header().Set("traceparent", traceCtx.Encode())
}
next.ServeHTTP(rw, r.WithContext(ctx))
if span != nil {
span.SetAttributes(
Attribute{Key: "http.status_code", Value: rw.statusCode},
Attribute{Key: "http.duration_ms", Value: time.Since(start).Milliseconds()},
)
span.End()
}
if tm.Provider != nil && tm.Provider.Meter != nil {
counter := tm.Provider.Meter.Counter("http_requests_total",
Attribute{Key: "method", Value: r.Method},
Attribute{Key: "path", Value: r.URL.Path},
)
counter.Add(ctx, 1)
hist := tm.Provider.Meter.Histogram("http_request_duration_ms",
Attribute{Key: "method", Value: r.Method},
)
hist.Record(ctx, float64(time.Since(start).Milliseconds()))
}
})
}
Fields
| Name | Type | Description |
|---|---|---|
| Provider | *Provider |
NewTelemetryMiddleware
NewTelemetryMiddleware creates a new TelemetryMiddleware wrapping the given provider.
Parameters
Returns
func NewTelemetryMiddleware(provider *Provider) *TelemetryMiddleware
{
return &TelemetryMiddleware{Provider: provider}
}
telemetryURL
Parameters
Returns
func telemetryURL(r *http.Request) string
{
if r == nil || r.URL == nil {
return ""
}
safe := *r.URL
safe.User = nil
safe.RawQuery = ""
safe.ForceQuery = false
safe.Fragment = ""
return safe.String()
}
telemetryResponseWriter
type telemetryResponseWriter struct
Methods
Parameters
func (*telemetryResponseWriter) WriteHeader(code int)
{
if w.wroteHeader {
return
}
w.wroteHeader = true
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
Parameters
Returns
func (*telemetryResponseWriter) Write(data []byte) (int, error)
{
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(data)
}
Returns
func (*telemetryResponseWriter) Unwrap() http.ResponseWriter
{
return w.ResponseWriter
}
func (*telemetryResponseWriter) Flush()
{
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
Returns
func (*telemetryResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error)
{
hijacker, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, errors.New("telemetry: response writer does not support hijacking")
}
return hijacker.Hijack()
}
Parameters
Returns
func (*telemetryResponseWriter) Push(target string, opts *http.PushOptions) error
{
pusher, ok := w.ResponseWriter.(http.Pusher)
if !ok {
return http.ErrNotSupported
}
return pusher.Push(target, opts)
}
Parameters
Returns
func (*telemetryResponseWriter) ReadFrom(reader io.Reader) (int64, error)
{
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
if readerFrom, ok := w.ResponseWriter.(io.ReaderFrom); ok {
return readerFrom.ReadFrom(reader)
}
return io.Copy(w.ResponseWriter, reader)
}
Fields
| Name | Type | Description |
|---|---|---|
| statusCode | int | |
| wroteHeader | bool |