Repository navigation
Add formal test predicates P16–P21 for otel-observability-spec #49809
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 all commits
78fbb3d
07866e0
18f4d8b
6a3a5ba
88b70f9
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,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).* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
| "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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] P16 does not cover 💡 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] P18 asserts 💡 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{})) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] P19 only tests nil/empty → nil, but does not assert what 💡 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested fix: |
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] P20's 💡 RationaleA stub that only confirms its own hardcoded data is correct cannot catch a real regression. Adding @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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
As written, this is a spec/contract placeholder disguised as a |
||
| t.Skip("pending: no production instrumentationScopeResolver implementation in pkg/workflow; " + | ||
| "replace t.Skip with assertions against the real resolver once it lands") | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 @copilot please address this. |
||
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.
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(usesotlpResourceAttributeSecretRefPattern.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.