1// Package telemetry provides OpenTelemetry initialization for Gordon.
2// It configures trace, metric, and log providers that export via OTLP/HTTP.
3package telemetry
4
5import (
6 "context"
7 "fmt"
8 "net/url"
9 "strings"
10
11 "go.opentelemetry.io/otel"
12 "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
13 "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
14 "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
15 otellog "go.opentelemetry.io/otel/sdk/log"
16 "go.opentelemetry.io/otel/sdk/metric"
17 "go.opentelemetry.io/otel/sdk/resource"
18 "go.opentelemetry.io/otel/sdk/trace"
19 semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
20)
21
22// Config holds telemetry configuration.
23type Config struct {
24 Enabled bool `mapstructure:"enabled"`
25 Endpoint string `mapstructure:"endpoint"` // OTLP HTTP endpoint, e.g. "http://localhost:4318"
26 AuthToken string `mapstructure:"auth_token"` // Basic auth token (base64 encoded user:pass)
27 Traces bool `mapstructure:"traces"` // Enable trace export
28 Metrics bool `mapstructure:"metrics"` // Enable metric export
29 Logs bool `mapstructure:"logs"` // Bridge zerowrap logs to OTLP
30 TraceSampleRate float64 `mapstructure:"trace_sample_rate"` // 0=disabled, (0,1)=ratio-based, >=1=always sample
31}
32
33// Provider holds the initialized OTel providers.
34type Provider struct {
35 TracerProvider *trace.TracerProvider
36 MeterProvider *metric.MeterProvider
37 LogProvider *otellog.LoggerProvider
38}
39
40// endpointConfig holds parsed endpoint details shared across exporter setup.
41type endpointConfig struct {
42 host string
43 basePath string
44 insecure bool
45 headers map[string]string
46}
47
48// NewProvider creates and configures OTel providers based on the given config.
49// Returns a noop provider if telemetry is disabled.
50// The returned shutdown function must be called on application exit.
51func NewProvider(ctx context.Context, cfg Config, serviceName, version string) (*Provider, func(context.Context), error) {
52 noop := func(context.Context) {}
53
54 if !cfg.Enabled || cfg.Endpoint == "" {
55 return &Provider{}, noop, nil
56 }
57
58 res, err := resource.New(ctx,
59 resource.WithAttributes(
60 semconv.ServiceName(serviceName),
61 semconv.ServiceVersion(version),
62 ),
63 resource.WithOS(),
64 resource.WithHost(),
65 )
66 if err != nil {
67 return nil, noop, fmt.Errorf("create resource: %w", err)
68 }
69
70 ep, err := parseEndpoint(cfg)
71 if err != nil {
72 return nil, noop, err
73 }
74
75 var shutdowns []func(context.Context) error
76 p := &Provider{}
77
78 if cfg.Traces {
79 if err := p.setupTracing(ctx, ep, cfg, res, &shutdowns); err != nil {
80 return nil, noop, err
81 }
82 }
83
84 if cfg.Metrics {
85 if err := p.setupMetrics(ctx, ep, res, &shutdowns); err != nil {
86 return nil, noop, err
87 }
88 }
89
90 if cfg.Logs {
91 if err := p.setupLogging(ctx, ep, res, &shutdowns); err != nil {
92 return nil, noop, err
93 }
94 }
95
96 shutdown := func(ctx context.Context) {
97 for _, fn := range shutdowns {
98 _ = fn(ctx)
99 }
100 }
101
102 return p, shutdown, nil
103}
104
105// parseEndpoint extracts host, path, and scheme from the configured endpoint URL.
106func parseEndpoint(cfg Config) (*endpointConfig, error) {
107 parsedURL, err := url.Parse(cfg.Endpoint)
108 if err != nil {
109 return nil, fmt.Errorf("parse endpoint URL: %w", err)
110 }
111
112 headers := make(map[string]string)
113 if cfg.AuthToken != "" {
114 headers["Authorization"] = "Basic " + cfg.AuthToken
115 }
116
117 return &endpointConfig{
118 host: parsedURL.Host,
119 basePath: strings.TrimSuffix(parsedURL.Path, "/"),
120 insecure: parsedURL.Scheme == "http",
121 headers: headers,
122 }, nil
123}
124
125// setupTracing initializes the trace provider with an OTLP/HTTP exporter.
126func (p *Provider) setupTracing(ctx context.Context, ep *endpointConfig, cfg Config, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
127 traceOpts := []otlptracehttp.Option{
128 otlptracehttp.WithEndpoint(ep.host),
129 otlptracehttp.WithHeaders(ep.headers),
130 }
131 if ep.basePath != "" {
132 traceOpts = append(traceOpts, otlptracehttp.WithURLPath(ep.basePath+"/v1/traces"))
133 }
134 if ep.insecure {
135 traceOpts = append(traceOpts, otlptracehttp.WithInsecure())
136 }
137
138 traceExp, err := otlptracehttp.New(ctx, traceOpts...)
139 if err != nil {
140 return fmt.Errorf("create trace exporter: %w", err)
141 }
142
143 // 0 = never sample (disabled), (0,1) = ratio-based, >= 1 = always sample.
144 // Clamp to [0, 1] so out-of-range values don't produce surprising behavior.
145 rate := cfg.TraceSampleRate
146 if rate < 0 {
147 rate = 0
148 } else if rate > 1 {
149 rate = 1
150 }
151 sampler := trace.AlwaysSample()
152 if rate == 0 {
153 sampler = trace.NeverSample()
154 } else if rate < 1.0 {
155 sampler = trace.TraceIDRatioBased(rate)
156 }
157
158 tp := trace.NewTracerProvider(
159 trace.WithBatcher(traceExp),
160 trace.WithResource(res),
161 trace.WithSampler(sampler),
162 )
163 otel.SetTracerProvider(tp)
164 p.TracerProvider = tp
165 *shutdowns = append(*shutdowns, tp.Shutdown)
166 return nil
167}
168
169// setupMetrics initializes the meter provider with an OTLP/HTTP exporter.
170func (p *Provider) setupMetrics(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
171 metricOpts := []otlpmetrichttp.Option{
172 otlpmetrichttp.WithEndpoint(ep.host),
173 otlpmetrichttp.WithHeaders(ep.headers),
174 }
175 if ep.basePath != "" {
176 metricOpts = append(metricOpts, otlpmetrichttp.WithURLPath(ep.basePath+"/v1/metrics"))
177 }
178 if ep.insecure {
179 metricOpts = append(metricOpts, otlpmetrichttp.WithInsecure())
180 }
181
182 metricExp, err := otlpmetrichttp.New(ctx, metricOpts...)
183 if err != nil {
184 return fmt.Errorf("create metric exporter: %w", err)
185 }
186
187 mp := metric.NewMeterProvider(
188 metric.WithReader(metric.NewPeriodicReader(metricExp)),
189 metric.WithResource(res),
190 )
191 otel.SetMeterProvider(mp)
192 p.MeterProvider = mp
193 *shutdowns = append(*shutdowns, mp.Shutdown)
194 return nil
195}
196
197// setupLogging initializes the log provider with an OTLP/HTTP exporter.
198func (p *Provider) setupLogging(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
199 logOpts := []otlploghttp.Option{
200 otlploghttp.WithEndpoint(ep.host),
201 otlploghttp.WithHeaders(ep.headers),
202 }
203 if ep.basePath != "" {
204 logOpts = append(logOpts, otlploghttp.WithURLPath(ep.basePath+"/v1/logs"))
205 }
206 if ep.insecure {
207 logOpts = append(logOpts, otlploghttp.WithInsecure())
208 }
209
210 logExp, err := otlploghttp.New(ctx, logOpts...)
211 if err != nil {
212 return fmt.Errorf("create log exporter: %w", err)
213 }
214
215 lp := otellog.NewLoggerProvider(
216 otellog.WithProcessor(otellog.NewBatchProcessor(logExp)),
217 otellog.WithResource(res),
218 )
219 p.LogProvider = lp
220 *shutdowns = append(*shutdowns, lp.Shutdown)
221 return nil
222}