A community based topic aggregation platform built on atproto
1package observability
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "go.opentelemetry.io/otel"
9 "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
10 "go.opentelemetry.io/otel/sdk/resource"
11 "go.opentelemetry.io/otel/sdk/trace"
12 semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
13)
14
15// Provider manages the OpenTelemetry TracerProvider lifecycle.
16type Provider struct {
17 enabled bool
18 tracerProvider *trace.TracerProvider
19}
20
21// NewProvider creates a new OpenTelemetry provider based on the given configuration.
22// If tracing is not enabled, it returns a Provider with enabled=false and no active tracer.
23// When enabled, it creates an OTLP HTTP exporter and configures the global tracer provider.
24func NewProvider(ctx context.Context, cfg Config) (*Provider, error) {
25 if err := cfg.Validate(); err != nil {
26 return nil, fmt.Errorf("invalid observability config: %w", err)
27 }
28
29 if !cfg.Enabled {
30 return &Provider{enabled: false}, nil
31 }
32
33 // Build exporter options for HTTP
34 opts := []otlptracehttp.Option{
35 otlptracehttp.WithEndpoint(stripScheme(cfg.Endpoint)),
36 }
37
38 if cfg.Insecure {
39 opts = append(opts, otlptracehttp.WithInsecure())
40 }
41
42 // Parse and add headers if provided
43 if cfg.Headers != "" {
44 headers := parseHeaders(cfg.Headers)
45 if len(headers) > 0 {
46 opts = append(opts, otlptracehttp.WithHeaders(headers))
47 }
48 }
49
50 // Create the OTLP HTTP exporter
51 exporter, err := otlptracehttp.New(ctx, opts...)
52 if err != nil {
53 return nil, fmt.Errorf("failed to create OTLP trace exporter: %w", err)
54 }
55
56 // Create the resource with service information
57 res, err := resource.Merge(
58 resource.Default(),
59 resource.NewWithAttributes(
60 semconv.SchemaURL,
61 semconv.ServiceName(cfg.ServiceName),
62 ),
63 )
64 if err != nil {
65 return nil, fmt.Errorf("failed to create resource: %w", err)
66 }
67
68 // Create the sampler based on the sample ratio
69 var sampler trace.Sampler
70 if cfg.SampleRatio >= 1.0 {
71 sampler = trace.AlwaysSample()
72 } else if cfg.SampleRatio <= 0.0 {
73 sampler = trace.NeverSample()
74 } else {
75 sampler = trace.TraceIDRatioBased(cfg.SampleRatio)
76 }
77
78 // Create the TracerProvider with the exporter and sampler
79 tp := trace.NewTracerProvider(
80 trace.WithBatcher(exporter),
81 trace.WithResource(res),
82 trace.WithSampler(sampler),
83 )
84
85 // Register the global tracer provider
86 otel.SetTracerProvider(tp)
87
88 return &Provider{
89 enabled: true,
90 tracerProvider: tp,
91 }, nil
92}
93
94// Shutdown flushes any remaining spans and shuts down the tracer provider.
95// It should be called when the application is shutting down to ensure
96// all traces are exported before the process exits.
97func (p *Provider) Shutdown(ctx context.Context) error {
98 if p == nil || !p.enabled || p.tracerProvider == nil {
99 return nil
100 }
101
102 if err := p.tracerProvider.Shutdown(ctx); err != nil {
103 return fmt.Errorf("failed to shutdown tracer provider: %w", err)
104 }
105
106 return nil
107}
108
109// Enabled returns whether tracing is enabled for this provider.
110func (p *Provider) Enabled() bool {
111 if p == nil {
112 return false
113 }
114 return p.enabled
115}
116
117// stripScheme removes the http:// or https:// prefix from an endpoint URL.
118// The HTTP exporter expects just host:port, not a full URL with scheme.
119func stripScheme(endpoint string) string {
120 endpoint = strings.TrimPrefix(endpoint, "http://")
121 endpoint = strings.TrimPrefix(endpoint, "https://")
122 return endpoint
123}
124
125// parseHeaders parses a comma-separated list of key=value pairs into a map.
126// Example: "key1=value1,key2=value2" -> map[string]string{"key1": "value1", "key2": "value2"}
127func parseHeaders(headers string) map[string]string {
128 result := make(map[string]string)
129 pairs := strings.Split(headers, ",")
130 for _, pair := range pairs {
131 pair = strings.TrimSpace(pair)
132 if pair == "" {
133 continue
134 }
135 parts := strings.SplitN(pair, "=", 2)
136 if len(parts) == 2 {
137 key := strings.TrimSpace(parts[0])
138 value := strings.TrimSpace(parts[1])
139 if key != "" {
140 result[key] = value
141 }
142 }
143 }
144 return result
145}