-
Notifications
You must be signed in to change notification settings - Fork 539
feat: add object form support for observability.otlp.headers #28524
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
85a8ef6
5acc9eb
3695c43
c65a738
e302f87
a7a7888
9026f61
d26a881
60709bb
bf33af6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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 }}\"})", | ||
| )) | ||
|
||
| } | ||
| headers = normalized | ||
| } | ||
| return | ||
| } | ||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
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).