diff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go index e2ff2fd52f1..91d93f5050a 100644 --- a/pkg/workflow/observability_otlp.go +++ b/pkg/workflow/observability_otlp.go @@ -17,6 +17,7 @@ var otlpLog = logger.New("workflow:observability_otlp") var sentryEndpointExpressionPattern = regexp.MustCompile(`(?i)^\$\{\{\s*secrets\.` + regexp.QuoteMeta(constants.OTELSentryEndpointSecretName) + `\s*\}\}$`) var otlpResourceAttributeSecretRefPattern = regexp.MustCompile(`\$\{\{\s*(secrets|vars)\.`) +var otelServiceNameKeyPattern = regexp.MustCompile(`(?m)^\s*OTEL_SERVICE_NAME:`) func normalizeOTLPHeadersForEndpoint(raw any, endpoint string) string { if raw == nil { @@ -732,7 +733,14 @@ func (c *Compiler) injectOTLPConfig(workflowData *WorkflowData) { // OTEL_EXPORTER_OTLP_ENDPOINT is set to the first endpoint for backward // compatibility (MCP gateway, legacy scripts). OTEL_SERVICE_NAME is // workflow-specific when WorkflowID is available. - otlpEnvLines := fmt.Sprintf(" OTEL_EXPORTER_OTLP_ENDPOINT: %s\n OTEL_SERVICE_NAME: %s", firstEndpoint, serviceName) + // If the user has already defined OTEL_SERVICE_NAME in their env block, + // we respect their value and skip injection to avoid duplicate key errors. + otlpEnvLines := " OTEL_EXPORTER_OTLP_ENDPOINT: " + firstEndpoint + if otelServiceNameKeyPattern.MatchString(workflowData.Env) { + otlpLog.Printf("Skipping OTEL_SERVICE_NAME injection: already defined by user") + } else { + otlpEnvLines += "\n OTEL_SERVICE_NAME: " + serviceName + } otlpEnvLines += "\n OTEL_RESOURCE_ATTRIBUTES: '" + escapeYAMLSingleQuoted(otelResourceAttributes(workflowData)) + "'" // 3. Inject per-endpoint headers env vars. diff --git a/pkg/workflow/observability_otlp_test.go b/pkg/workflow/observability_otlp_test.go index 3b91aa08c24..40cf59b0c67 100644 --- a/pkg/workflow/observability_otlp_test.go +++ b/pkg/workflow/observability_otlp_test.go @@ -585,6 +585,23 @@ func TestInjectOTLPConfig(t *testing.T) { assert.Equal(t, 1, strings.Count(wd.Env, "env:"), "should have exactly one env: key") }) + t.Run("preserves user-defined OTEL_SERVICE_NAME and does not inject duplicate", func(t *testing.T) { + c := newCompiler() + wd := &WorkflowData{ + ParsedFrontmatter: &FrontmatterConfig{ + Observability: &ObservabilityConfig{ + OTLP: &OTLPConfig{Endpoint: "https://traces.example.com"}, + }, + }, + Env: "env:\n OTEL_SERVICE_NAME: my-service", + } + c.injectOTLPConfig(wd) + + assert.Contains(t, wd.Env, "OTEL_SERVICE_NAME: my-service", "user-defined service name should be preserved") + assert.Equal(t, 1, strings.Count(wd.Env, "OTEL_SERVICE_NAME:"), "OTEL_SERVICE_NAME should appear exactly once") + assert.Contains(t, wd.Env, "OTEL_EXPORTER_OTLP_ENDPOINT: https://traces.example.com", "endpoint should still be injected") + }) + t.Run("OTEL_SERVICE_NAME includes sanitized workflow ID when available", func(t *testing.T) { c := newCompiler() wd := &WorkflowData{