Repository: gofr-dev/gofr

Issue:
Support for Custom Headers in OTLP Exporter
**Is your feature request related to a problem? Please describe.**

GoFr's OpenTelemetry integration only supports basic configuration (`TRACE_EXPORTER`, `TRACER_URL`, `TRACER_RATIO`) without the ability to specify custom authentication headers. Many observability platforms (Grafana Cloud, New Relic, Datadog, Honeycomb, etc.) require custom headers for authentication (e.g., `X-API-Key`, `Api-Key`, `X-Honeycomb-Team`). Currently, users must either deploy an OpenTelemetry Collector as a proxy or implement OTEL manually, adding unnecessary complexity.

**Describe the solution you'd like**

Add support for custom headers in the OTLP exporter configuration via environment variables:

```dotenv
TRACE_EXPORTER=otlp
TRACER_URL=otel.example.com:4317
TRACER_HEADERS=X-Api-Key=secret,X-Custom-Header=value
```

Alternatively, support the standard OpenTelemetry environment variable `OTEL_EXPORTER_OTLP_HEADERS` for ecosystem consistency.

**Describe alternatives you've considered**

1. **OpenTelemetry Collector sidecar**: Deploy a collector to handle authentication - adds infrastructure overhead
2. **Manual OTEL implementation**: Implement OpenTelemetry manually using the Go SDK - loses GoFr's built-in convenience
3. **Reverse proxy**: Use an authentication proxy in front of the OTLP endpoint - increases latency and complexity

**Additional context**

- [OpenTelemetry OTLP Exporter Configuration](https://opentelemetry.io/docs/concepts/sdk-configuration/otlp-exporter-configuration/)
- [GoFr Observability Documentation](https://gofr.dev/docs/quick-start/observability)
- The Go OTEL SDK already supports custom headers via `otlptracegrpc.WithHeaders()` - this enhancement would expose that capability through GoFr's configuration layer


Discussion:

Solution patch:
diff --git a/docs/quick-start/observability/page.md b/docs/quick-start/observability/page.md
index 7ac6182f..3bb25af0 100644
--- a/docs/quick-start/observability/page.md
+++ b/docs/quick-start/observability/page.md
@@ -144,7 +144,7 @@ and visualized in dashboards using tools like {% new-tab-link title="Grafana" hr
 
 Here's a sample Grafana dashboard utilizing GoFr's metrics:
 
-{% figure src="/metrics-dashboard.png" alt="Grafana Dashboard showing GoFr metrics including HTTP request rates, 
+{% figure src="/metrics-dashboard.png" alt="Grafana Dashboard showing GoFr metrics including HTTP request rates,
 response times, etc." caption="Example monitoring dashboard using GoFr's built-in metrics" /%}
 
 
@@ -175,7 +175,7 @@ a request as it travels through your distributed system by simply looking at the
 ### Configuration & Usage:
 
 GoFr has support for following trace-exporters:
-#### 1. [Zipkin](https://zipkin.io/): 
+#### 1. [Zipkin](https://zipkin.io/):
 
 To see the traces install zipkin image using the following Docker command:
 
@@ -272,3 +272,41 @@ TRACER_RATIO=0.1
 > `TRACER_RATIO` refers to the proportion of traces that are exported through sampling. It ranges between 0 and 1. By default, this ratio is set to 1, meaning all traces are exported.
 >
 > Open {% new-tab-link title="gofr-tracer" href="https://tracer.gofr.dev/" /%} and search by TraceID (correlationID) to see the trace.
+
+
+### Custom Authentication Headers
+
+Many observability platforms require custom headers for authentication. GoFr supports this through the `TRACER_HEADERS` configuration, which accepts comma-separated `key=value` pairs following the OpenTelemetry standard format.
+
+#### Usage Examples
+
+**Single Header:**
+```dotenv
+# Honeycomb
+TRACER_HEADERS="X-Honeycomb-Team=your_api_key"
+```
+
+**Multiple Headers:**
+```dotenv
+# Grafana Cloud with multiple headers
+TRACER_HEADERS="Authorization=Basic base64encodedcreds,X-Scope-OrgID=tenant-1"
+```
+
+```dotenv
+# API key with special characters
+TRACER_HEADERS="X-Api-Key=secret123,Authorization=Bearer token"
+```
+
+####  Configuration Example
+
+Here's an example for sending traces to Grafana Cloud with authentication:
+
+```dotenv
+APP_NAME=my-service
+
+# Grafana Cloud OTLP endpoint with authentication
+TRACE_EXPORTER=otlp
+TRACER_URL=otlp-gateway-prod-us-east-0.grafana.net:443
+TRACER_HEADERS="Authorization=Basic dXNlcm5hbWU6cGFzc3dvcmQ=,X-Scope-OrgID=123456"
+TRACER_RATIO=1.0
+```
diff --git a/docs/references/configs/page.md b/docs/references/configs/page.md
index 0da1dc7e..a4131419 100644
--- a/docs/references/configs/page.md
+++ b/docs/references/configs/page.md
@@ -93,8 +93,12 @@ This document lists all the configuration options supported by the GoFr framewor
 ---
 
 -  TRACER_AUTH_KEY
--  Authorization header for trace exporter requests.
--  Supported for zipkin, jaeger.
+-  Authorization header for trace exporter requests. Supported for zipkin, jaeger, otlp.
+
+---
+
+-  TRACER_HEADERS
+-  Custom authentication headers for trace exporter requests in comma-separated key=value format (e.g., "X-Api-Key=secret,Authorization=Bearer token"). Supported for zipkin, jaeger, otlp. Takes priority over TRACER_AUTH_KEY.
 
 ---
 
@@ -208,11 +212,29 @@ This document lists all the configuration options supported by the GoFr framewor
 ---
 
 -  DB_SSL_MODE
--  Currently supported only for PostgreSQL, with Default certificate file.
+-  TLS/SSL mode for database connections. Supported modes: **disable** (no TLS), **preferred** (attempts TLS, falls back to plain), **require** (enforces TLS, skips validation), **skip-verify** (enforces TLS, no certificate validation), **verify-ca** (enforces TLS, validates certificate against CA), **verify-full** (enforces TLS with full validation including hostname). Currently supported for MySQL/MariaDB and PostgreSQL.
 -  disable
 
 ---
 
+- DB_TLS_CA_CERT
+- Path to CA certificate file for TLS connections. Required for **verify-ca** and **verify-full** SSL modes.
+- None
+
+---
+
+- DB_TLS_CLIENT_CERT
+- Path to client certificate file for mutual TLS authentication.
+- None
+
+---
+
+- DB_TLS_CLIENT_KEY
+- Path to client private key file for mutual TLS authentication.
+- None
+
+---
+
 - DB_REPLICA_HOSTS
 - Comma-separated list of replica database hosts. Used for read replicas.
 - None
diff --git a/pkg/gofr/otel.go b/pkg/gofr/otel.go
index 5b9465ce..a0fd7a63 100644
--- a/pkg/gofr/otel.go
+++ b/pkg/gofr/otel.go
@@ -78,19 +78,67 @@ func isValidConfig(logger logging.Logger, name, url, host, port string) bool {
 	return true
 }
 
+// parseHeaders converts comma-separated key=value pairs to headers map.
+// Format follows OTEL standard: "Key1=Value1,Key2=Value2".
+// Splits only on first '=' to allow '=' in values.
+func parseHeaders(headerStr string) map[string]string {
+	headers := make(map[string]string)
+
+	if headerStr == "" {
+		return headers
+	}
+
+	const keyValueParts = 2
+
+	// Split by comma
+	pairs := strings.Split(headerStr, ",")
+
+	for _, pair := range pairs {
+		pair = strings.TrimSpace(pair)
+
+		// Split only on first '=' to allow '=' in values
+		kv := strings.SplitN(pair, "=", keyValueParts)
+
+		if len(kv) == keyValueParts {
+			key := strings.TrimSpace(kv[0])
+			value := strings.TrimSpace(kv[1])
+
+			if key != "" && value != "" {
+				headers[key] = value
+			}
+		}
+	}
+
+	return headers
+}
+
+// getTracerHeaders returns headers map from TRACER_HEADERS or TRACER_AUTH_KEY config.
+func (a *App) getTracerHeaders() map[string]string {
+	headers := make(map[string]string)
+
+	// Check for TRACER_HEADERS first (supports multiple custom headers)
+	if headerStr := a.Config.Get("TRACER_HEADERS"); headerStr != "" {
+		headers = parseHeaders(headerStr)
+	} else if authKey := a.Config.Get("TRACER_AUTH_KEY"); authKey != "" {
+		headers["Authorization"] = authKey
+	}
+
+	return headers
+}
+
 func (a *App) getExporter(name, host, port, url string) (sdktrace.SpanExporter, error) {
 	var (
 		exporter sdktrace.SpanExporter
 		err      error
 	)
 
-	authHeader := a.Config.Get("TRACER_AUTH_KEY")
+	headers := a.getTracerHeaders()
 
 	switch strings.ToLower(name) {
 	case "otlp", "jaeger":
-		exporter, err = buildOtlpExporter(a.Logger(), name, url, host, port, authHeader)
+		exporter, err = buildOtlpExporter(a.Logger(), name, url, host, port, headers)
 	case "zipkin":
-		exporter, err = buildZipkinExporter(a.Logger(), url, host, port, authHeader)
+		exporter, err = buildZipkinExporter(a.Logger(), url, host, port, headers)
 	case gofrTraceExporter:
 		exporter = buildGoFrExporter(a.Logger(), url)
 	default:
@@ -102,7 +150,7 @@ func (a *App) getExporter(name, host, port, url string) (sdktrace.SpanExporter,
 
 // buildOpenTelemetryProtocol using OpenTelemetryProtocol as the trace exporter
 // jaeger accept OpenTelemetry Protocol (OTLP) over gRPC to upload trace data.
-func buildOtlpExporter(logger logging.Logger, name, url, host, port, authHeader string) (sdktrace.SpanExporter, error) {
+func buildOtlpExporter(logger logging.Logger, name, url, host, port string, headers map[string]string) (sdktrace.SpanExporter, error) {
 	if url == "" {
 		url = fmt.Sprintf("%s:%s", host, port)
 	}
@@ -111,14 +159,14 @@ func buildOtlpExporter(logger logging.Logger, name, url, host, port, authHeader
 
 	opts := []otlptracegrpc.Option{otlptracegrpc.WithInsecure(), otlptracegrpc.WithEndpoint(url)}
 
-	if authHeader != "" {
-		opts = append(opts, otlptracegrpc.WithHeaders(map[string]string{"Authorization": authHeader}))
+	if len(headers) > 0 {
+		opts = append(opts, otlptracegrpc.WithHeaders(headers))
 	}
 
 	return otlptracegrpc.New(context.Background(), opts...)
 }
 
-func buildZipkinExporter(logger logging.Logger, url, host, port, authHeader string) (sdktrace.SpanExporter, error) {
+func buildZipkinExporter(logger logging.Logger, url, host, port string, headers map[string]string) (sdktrace.SpanExporter, error) {
 	if url == "" {
 		url = fmt.Sprintf("http://%s:%s/api/v2/spans", host, port)
 	}
@@ -126,8 +174,8 @@ func buildZipkinExporter(logger logging.Logger, url, host, port, authHeader stri
 	logger.Infof("Exporting traces to zipkin at %s", url)
 
 	var opts []zipkin.Option
-	if authHeader != "" {
-		opts = append(opts, zipkin.WithHeaders(map[string]string{"Authorization": authHeader}))
+	if len(headers) > 0 {
+		opts = append(opts, zipkin.WithHeaders(headers))
 	}
 
 	return zipkin.New(url, opts...)
@@ -138,7 +186,7 @@ func buildGoFrExporter(logger logging.Logger, url string) sdktrace.SpanExporter
 		url = "https://tracer-api.gofr.dev/api/spans"
 	}
 
-	logger.Infof("Exporting traces to GoFr at %s", gofrTracerURL)
+	logger.Infof("Exporting traces to GoFr at %s", url)
 
 	return NewExporter(url, logging.NewLogger(logging.INFO))
 }



Test patch:
diff --git a/pkg/gofr/gofr_test.go b/pkg/gofr/gofr_test.go
index 6f048ff7..72084bf8 100644
--- a/pkg/gofr/gofr_test.go
+++ b/pkg/gofr/gofr_test.go
@@ -624,7 +624,7 @@ func Test_initTracer(t *testing.T) {
 		{"jaeger exporter with auth", mockConfig4, "Exporting traces to jaeger at localhost:4317"},
 		{"otlp exporter", mockConfig5, "Exporting traces to otlp at localhost:4317"},
 		{"otlp exporter with authKey", mockConfig6, "Exporting traces to otlp at localhost:4317"},
-		{"gofr exporter with default url", mockConfig7, "Exporting traces to GoFr at https://tracer.gofr.dev"},
+		{"gofr exporter with default url", mockConfig7, "Exporting traces to GoFr at https://tracer-api.gofr.dev/api/spans"},
 	}
 
 	for i, tc := range tests {
diff --git a/pkg/gofr/otel_test.go b/pkg/gofr/otel_test.go
new file mode 100644
index 00000000..26dc1bcd
--- /dev/null
+++ b/pkg/gofr/otel_test.go
@@ -0,0 +1,196 @@
+package gofr
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"gofr.dev/pkg/gofr/config"
+)
+
+func TestParseHeaders(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected map[string]string
+	}{
+		{
+			name:     "empty string",
+			input:    "",
+			expected: map[string]string{},
+		},
+		{
+			name:  "single header",
+			input: "Key=Value",
+			expected: map[string]string{
+				"Key": "Value",
+			},
+		},
+		{
+			name:  "multiple headers",
+			input: "K1=V1,K2=V2",
+			expected: map[string]string{
+				"K1": "V1",
+				"K2": "V2",
+			},
+		},
+		{
+			name:  "value with equals sign",
+			input: "Hash=sha256=abc123,Key=value",
+			expected: map[string]string{
+				"Hash": "sha256=abc123",
+				"Key":  "value",
+			},
+		},
+		{
+			name:  "skip invalid entries",
+			input: "NoEquals,Valid=value,=EmptyKey",
+			expected: map[string]string{
+				"Valid": "value",
+			},
+		},
+		{
+			name:  "trim whitespace",
+			input: " Key1 = Value1 , Key2 = Value2 ",
+			expected: map[string]string{
+				"Key1": "Value1",
+				"Key2": "Value2",
+			},
+		},
+		{
+			name:  "empty key",
+			input: "=Value,Valid=value",
+			expected: map[string]string{
+				"Valid": "value",
+			},
+		},
+		{
+			name:  "empty value",
+			input: "Key=,Valid=value",
+			expected: map[string]string{
+				"Valid": "value",
+			},
+		},
+		{
+			name:  "base64 authorization header",
+			input: "Authorization=Basic dXNlcjpwYXNz",
+			expected: map[string]string{
+				"Authorization": "Basic dXNlcjpwYXNz",
+			},
+		},
+		{
+			name:  "multiple headers with special characters",
+			input: "X-Api-Key=abc123xyz,Authorization=Bearer token123,X-Scope-OrgID=tenant-1",
+			expected: map[string]string{
+				"X-Api-Key":     "abc123xyz",
+				"Authorization": "Bearer token123",
+				"X-Scope-OrgID": "tenant-1",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := parseHeaders(tt.input)
+
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestApp_getTracerHeaders_WithTracerHeaders(t *testing.T) {
+	tests := []struct {
+		name              string
+		tracerHeaders     string
+		expectedHeaders   map[string]string
+		expectedHeaderLen int
+	}{
+		{
+			name:              "multiple headers",
+			tracerHeaders:     "X-Api-Key=secret123,Authorization=Bearer token",
+			expectedHeaderLen: 2,
+			expectedHeaders: map[string]string{
+				"X-Api-Key":     "secret123",
+				"Authorization": "Bearer token",
+			},
+		},
+		{
+			name:              "single header",
+			tracerHeaders:     "X-Honeycomb-Team=abc123",
+			expectedHeaderLen: 1,
+			expectedHeaders: map[string]string{
+				"X-Honeycomb-Team": "abc123",
+			},
+		},
+		{
+			name:              "priority over TRACER_AUTH_KEY",
+			tracerHeaders:     "X-Custom-Header=value",
+			expectedHeaderLen: 1,
+			expectedHeaders: map[string]string{
+				"X-Custom-Header": "value",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			configData := map[string]string{
+				"TRACER_HEADERS": tt.tracerHeaders,
+			}
+
+			app := &App{
+				Config: config.NewMockConfig(configData),
+			}
+
+			headers := app.getTracerHeaders()
+
+			require.Equal(t, tt.expectedHeaders, headers)
+			require.Len(t, headers, tt.expectedHeaderLen)
+		})
+	}
+}
+
+func TestApp_getTracerHeaders_WithAuthKey(t *testing.T) {
+	tests := []struct {
+		name              string
+		tracerAuthKey     string
+		expectedHeaders   map[string]string
+		expectedHeaderLen int
+	}{
+		{
+			name:              "backward compatibility",
+			tracerAuthKey:     "Bearer legacy-token",
+			expectedHeaderLen: 1,
+			expectedHeaders: map[string]string{
+				"Authorization": "Bearer legacy-token",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			configData := map[string]string{
+				"TRACER_AUTH_KEY": tt.tracerAuthKey,
+			}
+
+			app := &App{
+				Config: config.NewMockConfig(configData),
+			}
+
+			headers := app.getTracerHeaders()
+
+			require.Equal(t, tt.expectedHeaders, headers)
+			require.Len(t, headers, tt.expectedHeaderLen)
+		})
+	}
+}
+
+func TestApp_getTracerHeaders_NoConfig(t *testing.T) {
+	app := &App{
+		Config: config.NewMockConfig(map[string]string{}),
+	}
+
+	headers := app.getTracerHeaders()
+
+	require.Empty(t, headers)
+}