Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/adr/28524-support-object-form-for-otlp-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# ADR-28524: Support Object Form for `observability.otlp.headers`

**Date**: 2026-04-26
**Status**: Draft
**Deciders**: pelikhan

---

## Part 1 — Narrative (Human-Friendly)

### Context

Workflow frontmatter supports an `observability.otlp.headers` field for passing HTTP headers to an OTLP collector. The original form accepted only a comma-separated `key=value` string (e.g., `"Authorization=Bearer ${{ secrets.TOKEN }},X-Tenant=acme"`). This forced authors to concatenate multiple secrets into a single expression, which is cumbersome, error-prone, and inconsistent with how the `env` field already accepts maps of `name: value` pairs. As observability adoption grew, users increasingly needed to specify multiple headers with individual secret references.

### Decision

We will extend `OTLPConfig.Headers` from a `string` field to a polymorphic `any` field that accepts either a map of string key-to-value pairs (preferred) or a comma-separated string (deprecated). A new `normalizeOTLPHeaders` helper converts either form into the `key=value,...` format required by the `OTEL_EXPORTER_OTLP_HEADERS` environment variable. The string form is retained for backwards compatibility but emits a deprecation warning to `stderr` on use.

### Alternatives Considered

#### Alternative 1: Keep String Form, Add Multi-Secret Concatenation Helper

A new expression helper (e.g., `${{ otlp.headers(key1=val1, key2=val2) }}`) could be introduced to construct the header string. This would avoid a type change on the Go struct but would require a new expression-language feature, adding significant implementation surface and coupling the feature to the expression evaluator. It was rejected because it adds more complexity than switching to a map.

#### Alternative 2: Introduce a Structured List/Array Form

Headers could be expressed as a list of `{name, value}` objects: `headers: [{name: Authorization, value: "Bearer ${{ secrets.TOKEN }}"}]`. This is more explicit and mirrors patterns in other YAML-based CI systems. However, it is more verbose than a map for the common case and would require a separate deprecation/migration path from the current string form. The map form was preferred as it mirrors the established `env` pattern already familiar to workflow authors.

### Consequences

#### Positive
- Individual header values can reference separate GitHub Actions secrets, improving security hygiene.
- The map syntax is consistent with the `env` field pattern, reducing cognitive overhead for authors.
- Comprehensive test coverage for both forms reduces regression risk during the deprecation window.

#### Negative
- `OTLPConfig.Headers` is now typed as `any` in Go, requiring runtime type assertions wherever the field is read; any code that directly accessed `Headers` as a `string` must be updated.
- Two valid input forms must be supported and tested throughout the deprecation window, increasing maintenance burden.

#### Neutral
- JSON Schema is updated to `oneOf: [object, string]`, which may affect tooling that provides schema-based autocompletion.
- Non-string values inside the map are silently skipped with a debug log rather than producing a validation error; stricter validation may be desirable in future.

---

## Part 2 — Normative Specification (RFC 2119)

> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).

### Header Field Schema

1. The `observability.otlp.headers` field **MUST** accept both a string value and an object (map of string keys to string values).
2. The JSON schema for this field **MUST** express the two accepted types using `oneOf` with separate sub-schemas for the object form and the string form.
3. The object form **MUST** be listed first in the `oneOf` array and **MUST** be the documented preferred form.

### Normalization

1. Implementations **MUST** convert the headers value (regardless of input form) into the `key=value,...` format before injecting it as `OTEL_EXPORTER_OTLP_HEADERS`.
2. When the headers value is a map, implementations **MUST** produce a deterministic output by sorting keys lexicographically.
3. When a map entry's value is not a string, implementations **MUST NOT** include that entry in the normalized output and **SHOULD** emit a debug-level log message identifying the skipped key.
4. When the headers value is a `nil`, empty string, or empty map, implementations **MUST** produce an empty string and **MUST NOT** inject the `OTEL_EXPORTER_OTLP_HEADERS` variable.

### Deprecation

1. When the string form is used, implementations **MUST** emit a deprecation warning to `stderr` directing authors to use the map form.
2. Implementations **MUST NOT** reject or fail compilation when the string form is provided; the string value **MUST** be passed through unchanged to `OTEL_EXPORTER_OTLP_HEADERS`.
3. Implementations **SHOULD NOT** remove the string form without a documented removal timeline and a major version bump.

### Go Type Constraint

1. The `OTLPConfig.Headers` struct field **MUST** be typed as `any` (Go `interface{}`).
2. All read sites of `OTLPConfig.Headers` **MUST** route through the `normalizeOTLPHeaders` helper rather than performing inline type assertions.

### Conformance

An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/24943747500) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
17 changes: 13 additions & 4 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -5777,11 +5777,20 @@ observability:
# (optional)
endpoint: "example-value"

# Comma-separated list of key=value HTTP headers to include with every OTLP export
# request (e.g. 'Authorization=Bearer <token>'). Supports GitHub Actions
# expressions such as ${{ secrets.OTLP_HEADERS }}. Injected as the
# OTEL_EXPORTER_OTLP_HEADERS environment variable.
# (optional)
# This field supports multiple formats (oneOf):

# Option 1: Map of HTTP header names to values to include with every OTLP export
# request. Values support GitHub Actions expressions such as ${{ secrets.TOKEN }}.
# Injected as the OTEL_EXPORTER_OTLP_HEADERS environment variable.
headers:
{}

# Option 2: Deprecated: use the map form instead. Comma-separated list of
# key=value HTTP headers to include with every OTLP export request (e.g.
# 'Authorization=Bearer <token>'). Supports GitHub Actions expressions such as ${{
# secrets.OTLP_HEADERS }}. Injected as the OTEL_EXPORTER_OTLP_HEADERS environment
# variable.
headers: "example-value"

# Allow list of bot identifiers that can trigger the workflow even if they don't
Expand Down
15 changes: 13 additions & 2 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8675,8 +8675,19 @@
"description": "OTLP collector endpoint URL (e.g. 'https://traces.example.com:4317'). Supports GitHub Actions expressions such as ${{ secrets.OTLP_ENDPOINT }}. When a static URL is provided, its hostname is automatically added to the network firewall allowlist."
},
"headers": {
"type": "string",
"description": "Comma-separated list of key=value HTTP headers to include with every OTLP export request (e.g. 'Authorization=Bearer <token>'). Supports GitHub Actions expressions such as ${{ secrets.OTLP_HEADERS }}. Injected as the OTEL_EXPORTER_OTLP_HEADERS environment variable."
"oneOf": [
{
"type": "object",
"description": "Map of HTTP header names to values to include with every OTLP export request. Values support GitHub Actions expressions such as ${{ secrets.TOKEN }}. Injected as the OTEL_EXPORTER_OTLP_HEADERS environment variable.",
"additionalProperties": {
"type": "string"
}
},
{
"type": "string",
"description": "Deprecated: use the map form instead. Comma-separated list of key=value HTTP headers to include with every OTLP export request (e.g. 'Authorization=Bearer <token>'). Supports GitHub Actions expressions such as ${{ secrets.OTLP_HEADERS }}. Injected as the OTEL_EXPORTER_OTLP_HEADERS environment variable."
}
]
}
},
"additionalProperties": false
Expand Down
11 changes: 6 additions & 5 deletions pkg/workflow/frontmatter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,12 @@ type OTLPConfig struct {
// network firewall allowlist.
Endpoint string `json:"endpoint,omitempty"`

// Headers is a comma-separated list of key=value HTTP headers to include with
// every OTLP export request (e.g. "Authorization=Bearer <token>").
// Supports GitHub Actions expressions such as ${{ secrets.OTLP_HEADERS }}.
// Injected as the standard OTEL_EXPORTER_OTLP_HEADERS environment variable.
Headers string `json:"headers,omitempty"`
// Headers holds HTTP headers to include with every OTLP export request.
// Preferred form: a map of header name to value (e.g. {"Authorization": "Bearer ${{ secrets.TOKEN }}"}).
// Deprecated string form: a comma-separated list of key=value pairs
// (e.g. "Authorization=Bearer <token>"). Use the map form instead.
// Both forms are injected as the standard OTEL_EXPORTER_OTLP_HEADERS environment variable.
Headers any `json:"headers,omitempty"`
}

// ObservabilityConfig represents workflow observability options.
Expand Down
3 changes: 2 additions & 1 deletion pkg/workflow/mcp_gateway_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ func buildMCPGatewayConfig(workflowData *WorkflowData) *MCPGatewayRuntimeConfig
if otlpHeaders == "" && workflowData.ParsedFrontmatter != nil &&
workflowData.ParsedFrontmatter.Observability != nil &&
workflowData.ParsedFrontmatter.Observability.OTLP != nil {
otlpHeaders = workflowData.ParsedFrontmatter.Observability.OTLP.Headers
normalized, _ := normalizeOTLPHeaders(workflowData.ParsedFrontmatter.Observability.OTLP.Headers)

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildMCPGatewayConfig re-reads OTLP headers via extractOTLPConfigFromRaw, which now prints the deprecation warning. Since injectOTLPConfig also calls extractOTLPConfigFromRaw during compilation, this can cause duplicate warnings for a single workflow. Additionally, the ParsedFrontmatter fallback ignores the returned deprecated flag, so if headers only come from ParsedFrontmatter (e.g., via imports) this path won’t emit the deprecation warning unless another stage already did. Consider sourcing the normalized headers from a single place (e.g., store normalized headers on WorkflowData alongside OTLPEndpoint) and emitting the warning once (warn-once).

Copilot uses AI. Check for mistakes.
otlpHeaders = normalized
}
}
return &MCPGatewayRuntimeConfig{
Expand Down
65 changes: 62 additions & 3 deletions pkg/workflow/observability_otlp.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,60 @@ package workflow
import (
"fmt"
"net/url"
"os"
"sort"
"strings"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/logger"
)

var otlpLog = logger.New("workflow:observability_otlp")

// normalizeOTLPHeaders converts the headers field value (which may be a string or a map)
// into the comma-separated key=value format required by OTEL_EXPORTER_OTLP_HEADERS.
//
// The second return value is true when the deprecated string form was used, so callers
// can emit a deprecation warning.
//
// String form (deprecated): "Authorization=Bearer tok,X-Tenant=acme"
// Map form (preferred): map[string]any{"Authorization": "Bearer tok", "X-Tenant": "acme"}
func normalizeOTLPHeaders(raw any) (string, bool) {
if raw == nil {
return "", false
}
switch v := raw.(type) {
case string:
if v == "" {
return "", false
}
return v, true // string form is deprecated
case map[string]any:
if len(v) == 0 {
return "", false
}
// Sort keys for deterministic output
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
var parts []string
for _, k := range keys {
val, ok := v[k].(string)
if !ok {
otlpLog.Printf("OTLP headers map: value for key %q is not a string (got %T), skipping", k, v[k])
continue
}
parts = append(parts, k+"="+val)
}
return strings.Join(parts, ","), false
default:
otlpLog.Printf("Unexpected type for OTLP headers: %T", raw)
return "", false
}
}

// extractOTLPEndpointDomain parses an OTLP endpoint URL and returns its hostname.
// Returns an empty string when the endpoint is a GitHub Actions expression (which
// cannot be resolved at compile time) or when the URL is otherwise invalid.
Expand Down Expand Up @@ -103,8 +150,14 @@ func extractOTLPConfigFromRaw(frontmatter map[string]any) (endpoint, headers str
if ep, ok := otlpMap["endpoint"].(string); ok {
endpoint = ep
}
if h, ok := otlpMap["headers"].(string); ok {
headers = h
if raw, ok := otlpMap["headers"]; ok {
normalized, deprecated := normalizeOTLPHeaders(raw)
if deprecated {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
"observability.otlp.headers: string form is deprecated. Use the map form instead (e.g. headers: {Authorization: \"Bearer ${{ secrets.TOKEN }}\"})",
))

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractOTLPConfigFromRaw now emits a deprecation warning to stderr when the string form is encountered. This function is called from multiple places (e.g., injectOTLPConfig and buildMCPGatewayConfig), so the same workflow can end up printing the warning multiple times. It also adds an unconditional stderr side effect to what was previously a pure extraction helper (which will make unit test runs noisy). Consider returning the deprecation flag (or an enum) from extractOTLPConfigFromRaw and emitting the warning once at a higher level (or adding an option to suppress/warn-once).

Copilot uses AI. Check for mistakes.
}
headers = normalized
}
return
}
Expand Down Expand Up @@ -153,7 +206,13 @@ func (c *Compiler) injectOTLPConfig(workflowData *WorkflowData) {
if headers == "" && workflowData.ParsedFrontmatter != nil &&
workflowData.ParsedFrontmatter.Observability != nil &&
workflowData.ParsedFrontmatter.Observability.OTLP != nil {
headers = workflowData.ParsedFrontmatter.Observability.OTLP.Headers
normalized, deprecated := normalizeOTLPHeaders(workflowData.ParsedFrontmatter.Observability.OTLP.Headers)
if deprecated {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
"observability.otlp.headers: string form is deprecated. Use the map form instead (e.g. headers: {Authorization: \"Bearer ${{ secrets.TOKEN }}\"})",
))
}
headers = normalized
}
if headers != "" {
otlpEnvLines += "\n OTEL_EXPORTER_OTLP_HEADERS: " + headers
Expand Down
Loading