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"` // Export Gordon, proxy access, and app container 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 // WorkloadLogs exports proxy access and app container logs, each
39 // under its own service identity. Nil when log export is disabled.
40 WorkloadLogs *LogExporter
41}
42
43// endpointConfig holds parsed endpoint details shared across exporter setup.
44type endpointConfig struct {
45 host string
46 basePath string
47 insecure bool
48 headers map[string]string
49}
50
51// NewProvider creates and configures OTel providers based on the given config.
52// Returns a noop provider if telemetry is disabled.
53// The returned shutdown function must be called on application exit.
54func NewProvider(ctx context.Context, cfg Config, serviceName, version string) (*Provider, func(context.Context), error) {
55 noop := func(context.Context) {}
56
57 if !cfg.Enabled || cfg.Endpoint == "" {
58 return &Provider{}, noop, nil
59 }
60
61 res, err := resource.New(ctx,
62 resource.WithAttributes(
63 semconv.ServiceName(serviceName),
64 semconv.ServiceVersion(version),
65 ),
66 resource.WithOS(),
67 resource.WithHost(),
68 )
69 if err != nil {
70 return nil, noop, fmt.Errorf("create resource: %w", err)
71 }
72
73 ep, err := parseEndpoint(cfg)
74 if err != nil {
75 return nil, noop, err
76 }
77
78 var shutdowns []func(context.Context) error
79 p := &Provider{}
80
81 if cfg.Traces {
82 if err := p.setupTracing(ctx, ep, cfg, res, &shutdowns); err != nil {
83 return nil, noop, err
84 }
85 }
86
87 if cfg.Metrics {
88 if err := p.setupMetrics(ctx, ep, res, &shutdowns); err != nil {
89 return nil, noop, err
90 }
91 }
92
93 if cfg.Logs {
94 if err := p.setupLogging(ctx, ep, res, &shutdowns); err != nil {
95 return nil, noop, err
96 }
97 }
98
99 shutdown := func(ctx context.Context) {
100 for _, fn := range shutdowns {
101 _ = fn(ctx)
102 }
103 }
104
105 return p, shutdown, nil
106}
107
108// parseEndpoint extracts host, path, and scheme from the configured endpoint URL.
109func parseEndpoint(cfg Config) (*endpointConfig, error) {
110 parsedURL, err := url.Parse(cfg.Endpoint)
111 if err != nil {
112 return nil, fmt.Errorf("parse endpoint URL: %w", err)
113 }
114
115 headers := make(map[string]string)
116 if cfg.AuthToken != "" {
117 headers["Authorization"] = "Basic " + cfg.AuthToken
118 }
119
120 return &endpointConfig{
121 host: parsedURL.Host,
122 basePath: strings.TrimSuffix(parsedURL.Path, "/"),
123 insecure: parsedURL.Scheme == "http",
124 headers: headers,
125 }, nil
126}
127
128// setupTracing initializes the trace provider with an OTLP/HTTP exporter.
129func (p *Provider) setupTracing(ctx context.Context, ep *endpointConfig, cfg Config, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
130 traceOpts := []otlptracehttp.Option{
131 otlptracehttp.WithEndpoint(ep.host),
132 otlptracehttp.WithHeaders(ep.headers),
133 }
134 if ep.basePath != "" {
135 traceOpts = append(traceOpts, otlptracehttp.WithURLPath(ep.basePath+"/v1/traces"))
136 }
137 if ep.insecure {
138 traceOpts = append(traceOpts, otlptracehttp.WithInsecure())
139 }
140
141 traceExp, err := otlptracehttp.New(ctx, traceOpts...)
142 if err != nil {
143 return fmt.Errorf("create trace exporter: %w", err)
144 }
145
146 // 0 = never sample (disabled), (0,1) = ratio-based, >= 1 = always sample.
147 // Clamp to [0, 1] so out-of-range values don't produce surprising behavior.
148 rate := cfg.TraceSampleRate
149 if rate < 0 {
150 rate = 0
151 } else if rate > 1 {
152 rate = 1
153 }
154 sampler := trace.AlwaysSample()
155 if rate == 0 {
156 sampler = trace.NeverSample()
157 } else if rate < 1.0 {
158 sampler = trace.TraceIDRatioBased(rate)
159 }
160
161 tp := trace.NewTracerProvider(
162 trace.WithBatcher(traceExp),
163 trace.WithResource(res),
164 trace.WithSampler(sampler),
165 )
166 otel.SetTracerProvider(tp)
167 p.TracerProvider = tp
168 *shutdowns = append(*shutdowns, tp.Shutdown)
169 return nil
170}
171
172// setupMetrics initializes the meter provider with an OTLP/HTTP exporter.
173func (p *Provider) setupMetrics(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
174 metricOpts := []otlpmetrichttp.Option{
175 otlpmetrichttp.WithEndpoint(ep.host),
176 otlpmetrichttp.WithHeaders(ep.headers),
177 }
178 if ep.basePath != "" {
179 metricOpts = append(metricOpts, otlpmetrichttp.WithURLPath(ep.basePath+"/v1/metrics"))
180 }
181 if ep.insecure {
182 metricOpts = append(metricOpts, otlpmetrichttp.WithInsecure())
183 }
184
185 metricExp, err := otlpmetrichttp.New(ctx, metricOpts...)
186 if err != nil {
187 return fmt.Errorf("create metric exporter: %w", err)
188 }
189
190 mp := metric.NewMeterProvider(
191 metric.WithReader(metric.NewPeriodicReader(metricExp)),
192 metric.WithResource(res),
193 )
194 otel.SetMeterProvider(mp)
195 p.MeterProvider = mp
196 *shutdowns = append(*shutdowns, mp.Shutdown)
197 return nil
198}
199
200// setupLogging initializes the log provider with an OTLP/HTTP exporter.
201func (p *Provider) setupLogging(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
202 logOpts := []otlploghttp.Option{
203 otlploghttp.WithEndpoint(ep.host),
204 otlploghttp.WithHeaders(ep.headers),
205 }
206 if ep.basePath != "" {
207 logOpts = append(logOpts, otlploghttp.WithURLPath(ep.basePath+"/v1/logs"))
208 }
209 if ep.insecure {
210 logOpts = append(logOpts, otlploghttp.WithInsecure())
211 }
212
213 logExp, err := otlploghttp.New(ctx, logOpts...)
214 if err != nil {
215 return fmt.Errorf("create log exporter: %w", err)
216 }
217
218 lp := otellog.NewLoggerProvider(
219 otellog.WithProcessor(otellog.NewBatchProcessor(logExp)),
220 otellog.WithResource(res),
221 )
222 p.LogProvider = lp
223 *shutdowns = append(*shutdowns, lp.Shutdown)
224
225 // Workload logs use a dedicated exporter: the SDK forbids concurrent
226 // Export calls, and Gordon's own provider already owns logExp.
227 workloadExp, err := otlploghttp.New(ctx, logOpts...)
228 if err != nil {
229 return fmt.Errorf("create workload log exporter: %w", err)
230 }
231 // Host and OS only: app records must not inherit Gordon's
232 // service.name or service.version.
233 base, err := resource.New(ctx, resource.WithOS(), resource.WithHost())
234 if err != nil {
235 return fmt.Errorf("create workload log resource: %w", err)
236 }
237 p.WorkloadLogs = NewLogExporter(workloadExp, base)
238 *shutdowns = append(*shutdowns, p.WorkloadLogs.Shutdown)
239 return nil
240}