diff --git a/README.md b/README.md index f4c764e..f4007c7 100644 --- a/README.md +++ b/README.md @@ -329,8 +329,8 @@ Controls the OpenTelemetry **Logs** Exporter. Tracing and Metrics inherit `Endpo | `Endpoint` | `string` | `""` | `host:port` or URL. URL schemes override `Insecure` setting. | | `Protocol` | `string` | `"grpc"` | `"grpc"` (recommended) or `"http"`. | | `Insecure` | `bool` | `false` | Disables TLS (dev only). Ignored if Endpoint starts with `https://`. | -| `Username` | `string` | `""` | Basic Auth username. | -| `Password` | `string` | `""` | Basic Auth password. | +| `Username` | `string` | `""` | Basic Auth username. (Fallback for VPC) | +| `Password` | `string` | `""` | Basic Auth password. (Fallback for VPC) | | `BatchSize` | `int` | `512` | Max logs per export batch. | | `ExportInterval` | `Duration` | `5s` | Flush interval. | | `Level` | `string` | `""` | Optional override for OTEL log level. | @@ -420,6 +420,19 @@ cfg.Console.Format = "systemd" cfg.Console.ErrorsToStderr = true ``` +### Full Configuration with Token Auth + +```go +cfg := ion.Default() +cfg.OTEL.Enabled = true +cfg.OTEL.Endpoint = "otel.jmdt.io:443" +cfg.OTEL.Headers = map[string]string{ + "Authorization": "Bearer 89658fc8a43d1a39cde4c59d1e2772194a8e2b533d53f93ca29659f40972980f", +} +cfg.Tracing.Enabled = true +cfg.Metrics.Enabled = true +``` + ### Full Stack (Kubernetes) ```go diff --git a/internal/config/config.go b/internal/config/config.go index 80c6049..2578515 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,7 +50,8 @@ type ConsoleConfig struct { // Default: true Enabled bool `yaml:"enabled" json:"enabled"` - // Format: "json" for structured JSON, "pretty" for human-readable. + // Format: "json" for structured JSON, "pretty" for human-readable, + // or "systemd" for Journald-optimized output. // Default: "json" (production), "pretty" (development) Format string `yaml:"format" json:"format"` @@ -116,13 +117,17 @@ type OTELConfig struct { // Default: false Insecure bool `yaml:"insecure" json:"insecure"` - // Username for Basic Authentication (optional). + // Username is used for HTTP Basic Authentication if required by the OTLP endpoint. Username string `yaml:"username" json:"username" env:"OTEL_USERNAME"` - // Password for Basic Authentication (optional). + // Password is used for HTTP Basic Authentication if required by the OTLP endpoint. Password string `yaml:"password" json:"password" env:"OTEL_PASSWORD"` //nolint:gosec // Required for configuration binding - // Headers are additional headers to send (e.g., auth tokens). + // Headers are custom headers to send to the exporter. + // This map is the standard mechanism for injecting Bearer tokens, API keys, or custom + // routing headers (e.g., {"Authorization": "Bearer "}). + // Note: If an Authorization header is explicitly provided here, it will automatically + // supersede the Username and Password configuration. Headers map[string]string `yaml:"headers" json:"headers"` // Timeout is the export timeout. @@ -161,13 +166,13 @@ type TracingConfig struct { // Insecure disables TLS. Insecure bool `yaml:"insecure" json:"insecure"` - // Username for Basic Authentication (optional). + // Username is used for HTTP Basic Authentication if required by the OTLP endpoint. Username string `yaml:"username" json:"username" env:"TRACING_USERNAME"` - // Password for Basic Authentication (optional). + // Password is used for HTTP Basic Authentication if required by the OTLP endpoint. Password string `yaml:"password" json:"password" env:"TRACING_PASSWORD"` //nolint:gosec // Required for configuration binding - // Headers for authentication. + // Headers are custom headers for authentication (supersedes Username/Password if Authorization is set). Headers map[string]string `yaml:"headers" json:"headers"` // Timeout for export. @@ -204,13 +209,13 @@ type MetricsConfig struct { // Insecure disables TLS. Insecure bool `yaml:"insecure" json:"insecure"` - // Username for Basic Authentication (optional). + // Username is used for HTTP Basic Authentication if required by the OTLP endpoint. Username string `yaml:"username" json:"username" env:"METRICS_USERNAME"` - // Password for Basic Authentication (optional). + // Password is used for HTTP Basic Authentication if required by the OTLP endpoint. Password string `yaml:"password" json:"password" env:"METRICS_PASSWORD"` //nolint:gosec // Required for configuration binding - // Headers for authentication. + // Headers are custom headers for authentication (supersedes Username/Password if Authorization is set). Headers map[string]string `yaml:"headers" json:"headers"` // Timeout for export. diff --git a/internal/core/logger_factory.go b/internal/core/logger_factory.go index 6406b7b..11243cd 100644 --- a/internal/core/logger_factory.go +++ b/internal/core/logger_factory.go @@ -67,8 +67,8 @@ func NewZapLogger(cfg config.Config) (*ZapFactoryResult, error) { // 1. Setup OTEL if enabled if cfg.OTEL.Enabled && cfg.OTEL.Endpoint != "" { - // Inject Basic Auth header if credentials provided - cfg.OTEL.Headers = injectBasicAuth(cfg.OTEL.Headers, cfg.OTEL.Username, cfg.OTEL.Password, cfg.OTEL.Protocol) + // Inject Auth header logic: Preferred Headers > Basic Auth fallback + cfg.OTEL.Headers = injectAuth(cfg.OTEL.Headers, cfg.OTEL.Username, cfg.OTEL.Password, cfg.OTEL.Protocol) otelProvider, err = SetupLogProvider(cfg.OTEL, cfg.ServiceName, cfg.Version) if err != nil { diff --git a/internal/core/meter.go b/internal/core/meter.go index 46321ba..ebbe21b 100644 --- a/internal/core/meter.go +++ b/internal/core/meter.go @@ -59,8 +59,8 @@ func SetupMeterProvider(cfg config.MetricsConfig, serviceName, version string) ( return nil, fmt.Errorf("failed to create OTEL resource: %w", err) } - // Inject Basic Auth header if credentials provided - headers := injectBasicAuth(cfg.Headers, cfg.Username, cfg.Password, cfg.Protocol) + // Inject Auth header logic: Preferred Headers > Basic Auth fallback + headers := injectAuth(cfg.Headers, cfg.Username, cfg.Password, cfg.Protocol) // Parse/Sanitize endpoint endpoint, insecure, err := processEndpoint(cfg.Endpoint, cfg.Insecure) diff --git a/internal/core/otel.go b/internal/core/otel.go index 750b21b..67b3a0d 100644 --- a/internal/core/otel.go +++ b/internal/core/otel.go @@ -83,6 +83,7 @@ func SetupLogProvider(cfg config.OTELConfig, serviceName, version string) (*LogP resource.WithHost(), resource.WithOS(), resource.WithProcess(), + resource.WithTelemetrySDK(), resource.WithAttributes(attrs...), ) if err != nil { @@ -95,12 +96,10 @@ func SetupLogProvider(cfg config.OTELConfig, serviceName, version string) (*LogP if err != nil { return nil, fmt.Errorf("invalid OTEL endpoint: %w", err) } + // Inject Auth header logic: Preferred Headers > Basic Auth fallback + cfg.Headers = injectAuth(cfg.Headers, cfg.Username, cfg.Password, cfg.Protocol) var exporter sdklog.Exporter - - // Inject Basic Auth header if credentials provided - cfg.Headers = injectBasicAuth(cfg.Headers, cfg.Username, cfg.Password, cfg.Protocol) - switch cfg.Protocol { case "http": exporter, err = createHTTPLogExporter(ctx, endpoint, insecure, cfg) @@ -116,6 +115,13 @@ func SetupLogProvider(cfg config.OTELConfig, serviceName, version string) (*LogP if batchSize <= 0 { batchSize = 512 } + // A robust queue size buffers telemetry against momentary network latency or throughput spikes. + // We use 4x the batch size or the OTEL standard 2048, whichever is larger, to prevent premature drops. + queueSize := batchSize * 4 + if queueSize < 2048 { + queueSize = 2048 + } + exportInterval := cfg.ExportInterval if exportInterval <= 0 { exportInterval = 5 * time.Second @@ -123,7 +129,7 @@ func SetupLogProvider(cfg config.OTELConfig, serviceName, version string) (*LogP processor := sdklog.NewBatchProcessor( exporter, - sdklog.WithMaxQueueSize(batchSize*2), + sdklog.WithMaxQueueSize(queueSize), sdklog.WithExportMaxBatchSize(batchSize), sdklog.WithExportInterval(exportInterval), ) @@ -144,15 +150,18 @@ func SetupTracerProvider(cfg config.TracingConfig, serviceName, version string) if !cfg.Enabled { return nil, nil } - - // Inject Basic Auth header if credentials provided - cfg.Headers = injectBasicAuth(cfg.Headers, cfg.Username, cfg.Password, cfg.Protocol) + // Inject Auth header logic: Preferred Headers > Basic Auth fallback + cfg.Headers = injectAuth(cfg.Headers, cfg.Username, cfg.Password, cfg.Protocol) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Resource res, err := resource.New(ctx, + resource.WithHost(), + resource.WithOS(), + resource.WithProcess(), + resource.WithTelemetrySDK(), resource.WithAttributes( semconv.ServiceName(serviceName), semconv.ServiceVersion(version), @@ -188,6 +197,12 @@ func SetupTracerProvider(cfg config.TracingConfig, serviceName, version string) if batchSize <= 0 { batchSize = 512 } + // A robust queue size buffers telemetry against momentary network latency or throughput spikes. + queueSize := batchSize * 4 + if queueSize < 2048 { + queueSize = 2048 + } + exportInterval := cfg.ExportInterval if exportInterval <= 0 { exportInterval = 5 * time.Second @@ -196,6 +211,7 @@ func SetupTracerProvider(cfg config.TracingConfig, serviceName, version string) tp := sdktrace.NewTracerProvider( sdktrace.WithResource(res), sdktrace.WithBatcher(exporter, + sdktrace.WithMaxQueueSize(queueSize), sdktrace.WithMaxExportBatchSize(batchSize), sdktrace.WithBatchTimeout(exportInterval), ), @@ -346,23 +362,45 @@ func processEndpoint(endpoint string, configInsecure bool) (string, bool, error) return host, insecure, nil } -// injectBasicAuth adds a Basic Authorization header to the provided headers map -// if username and password are provided. Returns the updated headers map. -// Protocol should be "http" or "grpc" - gRPC requires lowercase "authorization" key. -func injectBasicAuth(headers map[string]string, username, password, protocol string) map[string]string { - if headers == nil { - headers = make(map[string]string) +// injectAuth ensures the provided headers map contains appropriate authentication credentials. +// It returns a newly allocated map to guarantee the original configuration remains immutable. +// +// Hierarchy: +// 1. Header-First: If the provided headers map already contains an "Authorization" (or "authorization") key, it is preserved. +// 2. Basic Auth Fallback: If no authorization header is present, it constructs a Basic Auth credential using the provided username and password. +// +// Protocol should be "http" or "grpc". gRPC requires the lowercase "authorization" key to comply with HTTP/2 and gRPC metadata semantics. +func injectAuth(headers map[string]string, username, password, protocol string) map[string]string { + // Deep copy to ensure we do not mutate shared configuration maps across components + out := make(map[string]string, len(headers)+1) + for k, v := range headers { + out[k] = v } + + key := "Authorization" + altKey := "authorization" + if protocol != "http" { + key = "authorization" + altKey = "Authorization" + } + + // 1. Header-First: Check if authentication is already explicitly provided in the headers + if _, hasPrimary := out[key]; hasPrimary { + return out + } + if val, hasAlt := out[altKey]; hasAlt { + // Normalize to the protocol's required case + out[key] = val + delete(out, altKey) + return out + } + + // 2. Fallback: Generate Basic Auth if credentials are provided if username != "" && password != "" { auth := fmt.Sprintf("%s:%s", username, password) encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) - - // Use lowercase "authorization" for gRPC to comply with HTTP/2 and gRPC metadata specs. - key := "Authorization" - if protocol != "http" { - key = "authorization" - } - headers[key] = "Basic " + encodedAuth + out[key] = "Basic " + encodedAuth } - return headers + + return out } diff --git a/internal/core/otel_test.go b/internal/core/otel_test.go index eea2c04..817f964 100644 --- a/internal/core/otel_test.go +++ b/internal/core/otel_test.go @@ -103,3 +103,151 @@ func TestProcessEndpoint(t *testing.T) { }) } } +func TestInjectAuth(t *testing.T) { + tests := []struct { + name string + headers map[string]string + username string + password string + protocol string + wantKey string + wantValue string + wantLength int + }{ + { + name: "Header-First priority over Basic Auth (capitalized)", + headers: map[string]string{"Authorization": "Bearer pre-existing-token"}, + username: "user", + password: "pass", + protocol: "http", + wantKey: "Authorization", + wantValue: "Bearer pre-existing-token", + wantLength: 1, + }, + { + name: "Header-First priority over Basic Auth (lowercase)", + headers: map[string]string{"authorization": "Bearer lower-token"}, + username: "user", + password: "pass", + protocol: "grpc", + wantKey: "authorization", + wantValue: "Bearer lower-token", + wantLength: 1, + }, + { + name: "Basic Auth fallback when no token is in headers", + headers: nil, + username: "user", + password: "pass", + protocol: "http", + wantKey: "Authorization", + wantValue: "Basic dXNlcjpwYXNz", + wantLength: 1, + }, + { + name: "gRPC uses lowercase authorization for Basic Auth and custom tokens", + headers: map[string]string{"Authorization": "Bearer test-grpc-token"}, + username: "", + password: "", + protocol: "grpc", + wantKey: "authorization", // normalized to lower case by the function + wantValue: "Bearer test-grpc-token", + wantLength: 1, + }, + { + name: "No auth info", + headers: nil, + protocol: "http", + wantLength: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Copy the original input to ensure immutability is respected + originalInput := make(map[string]string) + for k, v := range tt.headers { + originalInput[k] = v + } + + got := injectAuth(tt.headers, tt.username, tt.password, tt.protocol) + + // 1. Verify Immutability + if tt.headers != nil { + if len(originalInput) != len(tt.headers) { + t.Errorf("injectAuth() mutated original input length! Expected %v, got %v", len(originalInput), len(tt.headers)) + } + for k, v := range originalInput { + if tt.headers[k] != v { + t.Errorf("injectAuth() mutated original input value for key %q", k) + } + } + } + + // 2. Verify Output + if len(got) != tt.wantLength { + t.Errorf("injectAuth() length = %v, want %v", len(got), tt.wantLength) + return + } + if tt.wantLength > 0 { + if val, ok := got[tt.wantKey]; !ok || val != tt.wantValue { + t.Errorf("injectAuth() %s = %v, want %v. out map: %+v", tt.wantKey, val, tt.wantValue, got) + } + } + }) + } +} + +func TestParseSampler(t *testing.T) { + tests := []struct { + name string + sampler string + // Since sdktrace.Sampler is an interface, we can verify its description + wantDesc string + }{ + { + name: "Empty defaults to AlwaysOn", + sampler: "", + wantDesc: "AlwaysOnSampler", + }, + { + name: "Explicit always", + sampler: "always", + wantDesc: "AlwaysOnSampler", + }, + { + name: "Explicit never", + sampler: "never", + wantDesc: "AlwaysOffSampler", + }, + { + name: "Valid ratio", + sampler: "ratio:0.15", + wantDesc: "TraceIDRatioBased{0.15}", + }, + { + name: "Invalid ratio format falls back to AlwaysOn", + sampler: "ratio:not-a-number", + wantDesc: "AlwaysOnSampler", + }, + { + name: "Unknown string falls back to AlwaysOn", + sampler: "random_string", + wantDesc: "AlwaysOnSampler", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseSampler(tt.sampler) + if got == nil { + t.Fatal("parseSampler() returned nil") + } + + desc := got.Description() + if desc != tt.wantDesc { + t.Errorf("parseSampler() description = %v, want %v", desc, tt.wantDesc) + } + }) + } +}