Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
47 changes: 47 additions & 0 deletions docs/adr/49809-formalize-otel-observability-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# ADR-49809: Formalize OTLP Observability Spec with Executable Predicates P16–P21

**Date**: 2026-08-02
**Status**: Accepted

---

### Context

The `gh-aw` workflow runtime has an OTLP observability integration (`pkg/workflow/observability_otlp.go`) that enforces several security-critical and semantic invariants: resource-attribute values must not reference `secrets.*` or `vars.*` expressions, span attributes and resource attributes are parsed from disjoint frontmatter keys, and the `mergeOTLPStringMaps` utility follows base-wins precedence with a nil-as-sentinel contract for empty inputs. Additionally, the system is expected to enforce metric cardinality bounds and distinct instrumentation scope names in future work. These invariants were previously verified only through informal tests or not at all, making it difficult to audit, communicate, or regress-test the spec. An existing formal predicate convention (P1–P15) in `otel_observability_formal_test.go` already establishes a numbered, property-named test style that serves as a machine-verifiable living specification.

### Decision

We will extend the formal OTLP observability test suite by adding predicates P16–P21 to `pkg/workflow/otel_observability_formal_test.go`. P16–P19 test concrete behaviors already present in `observability_otlp.go` (secret-ref rejection, attribute independence, merge precedence, nil-as-sentinel). P20 and P21 are forward-looking pending predicates: each test function immediately calls `t.Skip("pending: ...")` with an explicit message explaining what production implementation is required before they can exercise real behavior. The stub interface types (`metricAttributeRegistry`, `instrumentationScopeResolver`) that were previously defined alongside test-only implementations have been removed; the expected contract is instead described in the pending-test comment. This avoids the tautological anti-pattern of a test that constructs its own oracle and validates against that same oracle. When production implementations of the cardinality filter and instrumentation-scope resolver land in `pkg/workflow`, the `t.Skip` calls will be replaced with assertions against those implementations.

### Alternatives Considered

#### Alternative 1: Informal Go unit tests without predicate naming

Add standard `TestXxx` functions without the `P16`-style numbering convention. This was rejected because the numbered predicate convention ties directly to the prose specification (`specs/otel-observability-spec.md`) and makes it easy to cross-reference which behaviors are covered. Informal tests provide the same runtime coverage but lose the self-documenting, spec-linked quality.

#### Alternative 2: Documentation-only approach in `specs/otel-observability-spec.md`

Write the invariants as prose in the specification document without adding executable tests. This was rejected because documentation drifts from implementation over time; executable predicates remain accurate by construction — if the implementation drifts, the test fails. A prose-only spec cannot prevent regressions.

#### Alternative 3: Use Go's built-in fuzzing (`go test -fuzz`) for attribute validation

Apply fuzz testing to `validateOTLPResourceAttributes` and `mergeOTLPStringMaps` to explore the input space rather than using fixed test cases. This was not chosen because the existing P1–P15 suite uses deterministic scenario-based tests, and consistency with the established testing pattern was preferred. Fuzzing could complement the formal predicates in a future iteration.

### Consequences

#### Positive
- P16–P19 are backed by concrete implementations; regressions in security-critical behaviors (secret rejection, attribute isolation, merge semantics) will be caught immediately by CI.
- P20 and P21 establish interface contracts (`metricAttributeRegistry`, `instrumentationScopeResolver`) before the production implementations exist, enabling interface-first design and reducing future coupling.
- The numbered predicate style makes it straightforward to audit which invariants from `specs/otel-observability-spec.md` are machine-verified vs. not yet covered.

#### Negative
- P20 and P21 are currently skipped (`t.Skip("pending: ...")`); they do not exercise real production code until the cardinality filter and instrumentation-scope resolver are implemented.
- When those implementations land, contributors must remove the `t.Skip` calls and supply real assertions; this is enforced only by code-review discipline, not by tooling.

#### Neutral
- The formal test suite now spans P1–P21 across a single file; continued growth will require either continued sequential numbering or a decision to split the file by concern.
- Future contributors must follow the P-numbered predicate naming convention when adding invariants; this convention is not enforced by tooling, only by code-review discipline.

---

*ADR created by [adr-writer agent], finalized in [PR #49809](https://github.com/github/gh-aw/pull/49809).*
166 changes: 166 additions & 0 deletions pkg/workflow/otel_observability_formal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,169 @@ func TestFormal_AbsentObservabilityProducesNoEndpoints(t *testing.T) {
assert.Empty(t, collectAllOTLPEndpoints(map[string]any{}))
assert.Empty(t, collectAllOTLPEndpoints(map[string]any{"observability": nil}))
}

// P16 — SecretRefResourceAttributeRejected
// validateOTLPResourceAttributes must reject any resource-attribute value that
// references secrets.* or vars.* and must accept literal string values and nil
// workflow data without error.
func TestFormal_SecretRefResourceAttributeRejected(t *testing.T) {
// Nil workflow data is safe — no attributes to reject.
assert.NoError(t, validateOTLPResourceAttributes(nil))

// Literal string values are accepted.
literalData := &WorkflowData{
RawFrontmatter: map[string]any{
"observability": map[string]any{
"otlp": map[string]any{
"resource-attributes": map[string]any{
"deployment.environment": "production",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P16 verifies exact ${{ secrets.X }}/${{ vars.X }} patterns but has no negative case proving benign values containing those substrings are accepted, nor coverage for case variants.

💡 Details

Looking at validateOTLPResourceAttributes (uses otlpResourceAttributeSecretRefPattern.MatchString(value)), the test only exercises: nil input, clean literals, and the two canonical secret/var expression forms. It never asserts what happens for values that legitimately mention the words "secrets" or "vars" without being an actual ${{ }} expression (e.g. team.name: "secrets-rotation-squad"), which would validate whether the regex is anchored/scoped correctly and not simply doing a substring match that could produce false-positive rejections on innocuous attribute values. Given this validator gates user configuration and its regex behavior isn't inspected here, add at least one accept-case with a benign string containing "secrets"/"vars" as a substring to pin down the intended matching semantics.

"team.name": "platform",
},
},
},
},
}
assert.NoError(t, validateOTLPResourceAttributes(literalData))

// References to secrets.* must be rejected.
secretsData := &WorkflowData{
RawFrontmatter: map[string]any{
"observability": map[string]any{
"otlp": map[string]any{
"resource-attributes": map[string]any{
"auth.token": "${{ secrets.MY_TOKEN }}",
},
},
},
},
}
require.Error(t, validateOTLPResourceAttributes(secretsData))

// References to vars.* must also be rejected.
varsData := &WorkflowData{
RawFrontmatter: map[string]any{
"observability": map[string]any{
"otlp": map[string]any{
"resource-attributes": map[string]any{
"config.value": "${{ vars.SOME_VAR }}",
},
},
},
},
}
require.Error(t, validateOTLPResourceAttributes(varsData))
}

// P17 — CustomAttributesResourceAttributesIndependent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] P16 does not cover ${{ env.* }} expressions — the production regex only rejects secrets.* and vars.*, so env.* is silently accepted. Add a case that explicitly documents whether that is intentional or a gap.

💡 Suggested addition
// ${{ env.* }} — document the intent: accepted or should be rejected?
envData := &WorkflowData{
    RawFrontmatter: map[string]any{
        "observability": map[string]any{
            "otlp": map[string]any{
                "resource-attributes": map[string]any{
                    "some.key": "${{ env.SOME_VAR }}",
                },
            },
        },
    },
}
assert.NoError(t, validateOTLPResourceAttributes(envData), "env.* is intentionally accepted")

@copilot please address this.

// collectOTLPCustomAttributes and collectOTLPResourceAttributes must read from
// their respective `otlp.attributes` and `otlp.resource-attributes` keys
// independently; writing to one must not affect the other.
func TestFormal_CustomAttributesResourceAttributesIndependent(t *testing.T) {
frontmatter := map[string]any{
"observability": map[string]any{
"otlp": map[string]any{
"attributes": map[string]any{
"span.key": "span-value",
},
"resource-attributes": map[string]any{
"resource.key": "resource-value",
},
},
},
}

customAttrs := collectOTLPCustomAttributes(frontmatter)
resourceAttrs := collectOTLPResourceAttributes(frontmatter)

// Each collector returns only its own entries.
require.Len(t, customAttrs, 1)
assert.Equal(t, "span-value", customAttrs["span.key"])
assert.NotContains(t, customAttrs, "resource.key")

require.Len(t, resourceAttrs, 1)
assert.Equal(t, "resource-value", resourceAttrs["resource.key"])
assert.NotContains(t, resourceAttrs, "span.key")

// Absent sibling does not bleed into the other field.
onlyCustom := map[string]any{
"observability": map[string]any{
"otlp": map[string]any{
"attributes": map[string]any{"k": "v"},
},
},
}
assert.Nil(t, collectOTLPResourceAttributes(onlyCustom))

onlyResource := map[string]any{
"observability": map[string]any{
"otlp": map[string]any{
"resource-attributes": map[string]any{"k": "v"},
},
},
}
assert.Nil(t, collectOTLPCustomAttributes(onlyResource))
}

// P18 — MergePrecedenceBaseWinsOverOverride
// mergeOTLPStringMaps must give base values priority over override values when
// the same key exists in both maps. Disjoint keys from both maps must appear in
// the result.
func TestFormal_MergePrecedenceBaseWinsOverOverride(t *testing.T) {
base := map[string]string{
"shared.key": "base-value",
"base-only.key": "base-only-value",
}
override := map[string]string{
"shared.key": "override-value",
"override-only.key": "override-only-value",
}

merged := mergeOTLPStringMaps(base, override)

// Base wins on collision.
assert.Equal(t, "base-value", merged["shared.key"])
// Disjoint keys from both sides are present.
assert.Equal(t, "base-only-value", merged["base-only.key"])
assert.Equal(t, "override-only-value", merged["override-only.key"])
assert.Len(t, merged, 3)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] P18 asserts assert.Len(t, merged, 3) but does not test the case where override-only keys are present and base is nil/empty. Add a case asserting that a nil base returns all override keys (or vice-versa) to fully specify the boundary behaviour.

💡 Suggested addition
// Base-nil: all override keys should appear
result := mergeOTLPStringMaps(nil, map[string]string{"a": "1", "b": "2"})
assert.Equal(t, map[string]string{"a": "1", "b": "2"}, result)

@copilot please address this.

}

// P19 — MergeOfEmptyMapsYieldsNil
// mergeOTLPStringMaps must return nil (not an allocated empty map) when both
// inputs are nil or empty, so callers can rely on nil as a sentinel for
// "no attributes configured".
func TestFormal_MergeOfEmptyMapsYieldsNil(t *testing.T) {
assert.Nil(t, mergeOTLPStringMaps(nil, nil))
assert.Nil(t, mergeOTLPStringMaps(map[string]string{}, nil))
assert.Nil(t, mergeOTLPStringMaps(nil, map[string]string{}))
assert.Nil(t, mergeOTLPStringMaps(map[string]string{}, map[string]string{}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] P19 only tests nil/empty → nil, but does not assert what mergeOTLPStringMaps returns when one side is nil and the other is non-empty. Add the asymmetric cases to fully nail the nil-as-sentinel contract.

💡 Missing cases
// One-sided non-empty — result should equal the non-empty map (not nil)
result := mergeOTLPStringMaps(map[string]string{"k": "v"}, nil)
assert.NotNil(t, result)
assert.Equal(t, "v", result["k"])

result2 := mergeOTLPStringMaps(nil, map[string]string{"k": "v"})
assert.NotNil(t, result2)
assert.Equal(t, "v", result2["k"])

Without these, a regression where a non-empty map is accidentally dropped would not be caught here.

@copilot please address this.

}

// P20 — MetricResourceCardinalityBound
// High-cardinality per-run/per-user identifiers (gh-aw.run.id, gh-aw.run.uuid,
// user.id, session.id, trace.id, job.id, span.id, git.commit.sha, pr.number,
// issue.number, actor.id, url, conversation.id) must be excluded from default
// metric dimensions to prevent unbounded label growth. Stable, bounded
// attributes such as service.name and gh-aw.workflow.name must be allowed.
//
// Pending: no production metric-cardinality filter exists in pkg/workflow yet.
// Wire this predicate to the real filter once
// https://github.com/github/gh-aw/issues is addressed.
// See also specs/otel-observability-spec.md §metric-cardinality and ADR-49809.
func TestFormal_MetricResourceCardinalityBound(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test only verifies a test-local hardcoded stub returns its own hardcoded values — it provides zero regression protection for actual gh-aw metric-cardinality behavior.

💡 Details

staticMetricAttributeRegistry.highCardinalityKeys is populated with the exact same keys the assertions then check. There is no production implementation of metricAttributeRegistry in pkg/workflow — the interface exists solely for this test. So when a real cardinality filter is eventually implemented (with possibly different/buggy classification logic), this test keeps passing forever because it never touches that code. The TestFormal_* naming matches the rest of the suite (P1-P19), which do exercise real production functions (validateOTLPResourceAttributes, mergeOTLPStringMaps), creating false confidence that P20 has equivalent coverage. There is also no tracking-issue reference committing to replace the stub, so it risks becoming permanent dead scaffolding.

Suggested fix: t.Skip("pending #issue — no production cardinality-filter implementation yet"), or hold off adding a TestFormal_*-named test until a real metricAttributeRegistry implementation exists to exercise.

t.Skip("pending: no production metricAttributeRegistry implementation in pkg/workflow; " +
"replace t.Skip with assertions against the real filter once it lands")
}

// P21 — InstrumentationScopeNaming
// The core instrumentation scope must be "gh-aw" and the MCP gateway scope
// must be "gh-aw-mcpg". The two scopes must be distinct so traces from each

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] P20's staticMetricAttributeRegistry is a tautological stub: the test asserts that the keys it was constructed with are classified as high-cardinality — which is true by construction. The test provides no value until it exercises a real implementation that decides cardinality from the key name/pattern. Consider adding a // TODO(P20): replace with real pkg/workflow impl comment and a t.Skip("stub — no real implementation yet") guard so CI doesn't present this as evidence of a working feature.

💡 Rationale

A stub that only confirms its own hardcoded data is correct cannot catch a real regression. Adding t.Skip makes it clear this predicate is aspirational, and removes the risk of misleading green CI.

@copilot please address this.

// component can be filtered independently.
//
// Pending: no production instrumentation-scope resolver exists in pkg/workflow yet.
// Wire this predicate to the real resolver once it is implemented.
// See specs/otel-observability-spec.md §instrumentation-scope and ADR-49809.
func TestFormal_InstrumentationScopeNaming(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same problem as P20: this asserts a locally-defined stub's hardcoded return values against themselves, exercising no production code at all.

💡 Details

staticInstrumentationScopeResolver.CoreScope()/GatewayScope() are literal constants declared a few lines above in this same file. resolver.CoreScope() returning "gh-aw" is guaranteed by construction — the assertion cannot fail unless someone edits the stub itself. If gh-aw's actual OTel setup ever names its instrumentation scope something other than "gh-aw" (e.g. a typo, a rename, or divergence between core and gateway), this test will not catch it, because instrumentationScopeResolver has no production implementation anywhere in pkg/workflow.

As written, this is a spec/contract placeholder disguised as a TestFormal_* regression test. Recommend t.Skip with a tracking-issue reference, or move the interface/stub out of the test file into a design-doc comment until the real resolver exists.

t.Skip("pending: no production instrumentationScopeResolver implementation in pkg/workflow; " +
"replace t.Skip with assertions against the real resolver once it lands")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Same tautology concern as P20: P21 tests that a hand-written struct returns the strings it was coded to return — there is no production behaviour being verified. The same t.Skip guard pattern recommended for P20 applies here.

@copilot please address this.

Loading