-
Notifications
You must be signed in to change notification settings - Fork 432
[formal-spec] Add OTEL observability formal compatibility test suite (v0.4.0 L1/L2) #41906
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
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,44 @@ | ||
| # ADR-41906: Formal OTEL Observability Compatibility Test Suite (v0.4.0) | ||
|
|
||
| **Date**: 2026-06-27 | ||
| **Status**: Draft | ||
| **Deciders**: Unknown (automated draft from PR #41906 — review and finalize) | ||
|
|
||
| --- | ||
|
|
||
| ### Context | ||
|
|
||
| The repository has a formal specification `specs/otel-observability-spec.md` (v0.4.0) that defines 15 predicates for Level 1/2 `observability.otlp` behavior: endpoint form normalization, deterministic header serialization, Sentry `Authorization → x-sentry-auth` rewrite, `if-missing` policy validation, OTEL service-name construction, static domain extraction, expression exclusion from allowlisting, top-level header scoping, fan-out declaration-order preservation, mirror path constants, empty URL entry discard, and nil/empty header normalization. Without explicit test coverage mapped to these predicates, the implementation can silently diverge from the spec as either evolves. The existing `observability_otlp.go` helpers implement these behaviors, but predicate-level regression protection was absent. | ||
|
|
||
| ### Decision | ||
|
|
||
| We will add a dedicated formal test file `pkg/workflow/otel_observability_formal_test.go` that maps each of the 15 predicates in `specs/otel-observability-spec.md` v0.4.0 to an explicit `TestFormal_*` test. Tests assert against the public compiler/runtime behavior of `observability_otlp.go` helpers (e.g., `collectAllOTLPEndpoints`, `normalizeOTLPHeadersForEndpoint`, `otelServiceName`) rather than implementation internals, making the spec a continuously-enforced compatibility contract verified on every CI run. | ||
|
|
||
| ### Alternatives Considered | ||
|
|
||
| #### Alternative 1: Integration/End-to-End Tests | ||
|
|
||
| Test the full OTEL pipeline end-to-end (workflow compilation → OTLP emission → collector receipt) rather than testing each predicate in isolation. This would be more realistic but far slower to run and harder to isolate failures to specific predicates. It would also require a running OTEL collector in CI, adding infrastructure complexity. Unit-level predicate tests provide faster feedback and pinpoint exactly which invariant broke. | ||
|
|
||
| #### Alternative 2: Property-Based / Fuzz Testing | ||
|
|
||
| Use Go's `testing/quick` or fuzz infrastructure to cover the input space for endpoint normalization and header serialization rather than explicit hand-written cases. This would catch edge cases the spec authors didn't enumerate, but would not verify that the specific named invariants from the formal spec are met. Explicit named tests serve as a living checklist against the spec document, which fuzz tests cannot replicate. | ||
|
|
||
| ### Consequences | ||
|
|
||
| #### Positive | ||
| - All 15 v0.4.0 predicates from the formal spec are continuously enforced; any regression breaks CI immediately. | ||
| - `TestFormal_*` tests serve as executable documentation — future contributors can cross-reference the spec and know exactly which test covers which predicate. | ||
| - Tests run at unit speed with no external dependencies, giving fast CI feedback. | ||
|
|
||
| #### Negative | ||
| - Tests directly call package-internal functions (`collectAllOTLPEndpoints`, `normalizeOTLPHeadersForEndpoint`, etc.), so internal API refactors require corresponding test updates even if observable behavior is unchanged. | ||
| - When `specs/otel-observability-spec.md` is updated (v0.5.0, etc.), the test file must be manually updated to reflect new or changed predicates — there is no automated linkage between the spec document and the test file. | ||
|
|
||
| #### Neutral | ||
| - The formal test file lives in `package workflow` (not `package workflow_test`), giving it access to unexported symbols — this is intentional to allow contract-level assertions without adding a testing-only public API. | ||
| - The `determinismTestIterations = 10` constant for `TestFormal_HeaderMapDeterminism` is a pragmatic choice; it is not derived from the formal spec and may need tuning if map iteration order changes are suspected. | ||
|
|
||
| --- | ||
|
|
||
| *ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,245 @@ | ||
| package workflow | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/github/gh-aw/pkg/constants" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| const determinismTestIterations = 10 | ||
| const authPlaceholder = "******" | ||
|
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] 💡 SuggestionUse a realistic, clearly-fake token value that cannot be confused with a masked secret: const authPlaceholder = "test-auth-token-placeholder"This also makes Sentry rewrite assertions easier to read: @copilot please address this. |
||
|
|
||
| func TestFormal_EndpointFormNormalization(t *testing.T) { | ||
| t.Run("string object and array normalize to ordered entries", func(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. [/tdd] All three endpoint forms (string, object, array) are bundled inside one 💡 SuggestionGive each form its own subtest for isolated, self-describing failures: func TestFormal_EndpointFormNormalization(t *testing.T) {
t.Run("string form", func(t *testing.T) { ... })
t.Run("object form", func(t *testing.T) { ... })
t.Run("array form", func(t *testing.T) { ... })
t.Run("empty and absent", func(t *testing.T) { ... })
}Each subtest name then appears directly in the failure output, matching the spec predicate language. @copilot please address this. |
||
| stringForm := map[string]any{ | ||
| "observability": map[string]any{ | ||
| "otlp": map[string]any{ | ||
| "endpoint": "https://string.example.com:4317", | ||
| }, | ||
| }, | ||
| } | ||
| assert.Equal(t, | ||
| []otlpEndpointEntry{{URL: "https://string.example.com:4317"}}, | ||
| collectAllOTLPEndpoints(stringForm), | ||
| ) | ||
|
Comment on lines
+23
to
+26
|
||
|
|
||
| objectForm := map[string]any{ | ||
| "observability": map[string]any{ | ||
| "otlp": map[string]any{ | ||
| "endpoint": map[string]any{"url": "https://object.example.com:4317"}, | ||
| }, | ||
| }, | ||
| } | ||
| assert.Equal(t, | ||
| []otlpEndpointEntry{{URL: "https://object.example.com:4317"}}, | ||
| collectAllOTLPEndpoints(objectForm), | ||
| ) | ||
|
|
||
| arrayForm := map[string]any{ | ||
| "observability": map[string]any{ | ||
| "otlp": map[string]any{ | ||
| "endpoint": []any{ | ||
| map[string]any{"url": "https://first.example.com:4317"}, | ||
| map[string]any{"url": "https://second.example.com:4317"}, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| assert.Equal(t, | ||
| []otlpEndpointEntry{ | ||
| {URL: "https://first.example.com:4317"}, | ||
| {URL: "https://second.example.com:4317"}, | ||
| }, | ||
| collectAllOTLPEndpoints(arrayForm), | ||
| ) | ||
| }) | ||
|
|
||
| t.Run("empty and absent normalize to empty", func(t *testing.T) { | ||
| assert.Empty(t, collectAllOTLPEndpoints(nil)) | ||
| assert.Empty(t, collectAllOTLPEndpoints(map[string]any{})) | ||
| assert.Empty(t, collectAllOTLPEndpoints(map[string]any{"observability": map[string]any{}})) | ||
| }) | ||
| } | ||
|
|
||
| func TestFormal_HeaderMapDeterminism(t *testing.T) { | ||
| headers := map[string]any{"z": "3", "a": "1", "m": "2"} | ||
| want := "a=1,m=2,z=3" | ||
|
|
||
| for range determinismTestIterations { | ||
|
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] The determinism loop calls the same map object 10 times, but Go randomises map iteration order at map creation, not per iteration. So all 10 calls see the same internal order and the loop adds no additional coverage — it's the same assertion repeated. 💡 SuggestionThe actual invariant being tested is that for range determinismTestIterations {
h := map[string]any{"z": "3", "a": "1", "m": "2"}
assert.Equal(t, "a=1,m=2,z=3", normalizeOTLPHeadersForEndpoint(h, "(example.com/redacted)"))
}Alternatively, rename the constant to @copilot please address this. |
||
| assert.Equal(t, want, normalizeOTLPHeadersForEndpoint(headers, "https://example.com:4317")) | ||
| } | ||
| } | ||
|
|
||
| func TestFormal_SentryAuthHeaderRewrite(t *testing.T) { | ||
| normalizedSentryHeaders := normalizeOTLPHeadersForEndpoint("Authorization="+authPlaceholder, "https://o0.ingest.sentry.io/api/0/envelope/") | ||
| normalizedNonSentryHeaders := normalizeOTLPHeadersForEndpoint("Authorization="+authPlaceholder, "https://otlp.example.com:4317") | ||
| normalizedSentryMixedHeaders := normalizeOTLPHeadersForEndpoint( | ||
| map[string]any{"Authorization": authPlaceholder, "X-Tenant": "acme"}, | ||
| "https://o0.ingest.sentry.io/api/0/envelope/", | ||
| ) | ||
|
|
||
| assert.Equal(t, "x-sentry-auth="+authPlaceholder, normalizedSentryHeaders) | ||
| assert.Equal(t, "Authorization="+authPlaceholder, normalizedNonSentryHeaders) | ||
| assert.Equal(t, "x-sentry-auth="+authPlaceholder+",X-Tenant=acme", normalizedSentryMixedHeaders) | ||
| } | ||
|
|
||
| func TestFormal_IfMissingPolicyValidation(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. [/tdd] 💡 SuggestionConsolidate all func TestFormal_IfMissingPolicy(t *testing.T) {
cases := []struct{ input, want string }{
{"error", "error"}, {"WARN", "warn"}, {" Ignore ", "ignore"},
{"fail", ""}, {"silent", ""}, {"skip", ""}, {"abort", ""},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
assert.Equal(t, tc.want, normalizeOTLPIfMissingMode(tc.input))
})
}
}@copilot please address this. |
||
| assert.Equal(t, "error", normalizeOTLPIfMissingMode("error")) | ||
| assert.Equal(t, "warn", normalizeOTLPIfMissingMode("WARN")) | ||
| assert.Equal(t, "ignore", normalizeOTLPIfMissingMode(" Ignore ")) | ||
| } | ||
|
|
||
| func TestFormal_ServiceNameFormation(t *testing.T) { | ||
| assert.Equal(t, "gh-aw", otelServiceName(nil)) | ||
| assert.Equal(t, "gh-aw.repo-triage-weekly", otelServiceName(&WorkflowData{WorkflowID: "repo-triage-weekly", Name: "Sample Name"})) | ||
| assert.Equal(t, "gh-aw.repo-triage-weekly", otelServiceName(&WorkflowData{WorkflowID: "Repo Triage/Weekly", Name: "Sample Name"})) | ||
| assert.Equal(t, "gh-aw.workflow-name", otelServiceName(&WorkflowData{Name: "Workflow Name"})) | ||
| } | ||
|
|
||
| func TestFormal_StaticDomainExtraction(t *testing.T) { | ||
| assert.Equal(t, "traces.example.com", extractOTLPEndpointDomain("https://traces.example.com:4317")) | ||
| assert.Empty(t, extractOTLPEndpointDomain("")) | ||
| assert.Empty(t, extractOTLPEndpointDomain("${{ secrets.OTLP_ENDPOINT }}")) | ||
| } | ||
|
|
||
| func TestFormal_ExpressionProducesNoAllowlistEntry(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. [/tdd] 💡 SuggestionEither remove @copilot please address this. |
||
| assert.Empty(t, extractOTLPEndpointDomain("${{ secrets.OTLP_ENDPOINT }}")) | ||
|
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. Exact duplicate of 💡 Suggested fixEither delete this function or replace with a genuinely distinct input not yet covered (e.g. a Sentry-named secret expression, a bare hostname without scheme, or a URL with user-info that should still extract the domain): func TestFormal_ExpressionProducesNoAllowlistEntry(t *testing.T) {
// Sentry-style secret expression also produces no domain
assert.Empty(t, extractOTLPEndpointDomain(
"${{ secrets."+constants.OTELSentryEndpointSecretName+" }}",
))
}As-is, if someone deletes the shared assertion from |
||
| } | ||
|
Comment on lines
+107
to
+109
|
||
|
|
||
| func TestFormal_TopLevelHeadersApplyToStringFormOnly(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. [/tdd] 💡 SuggestionAdd a third case for array form: arrayForm := map[string]any{
"observability": map[string]any{
"otlp": map[string]any{
"endpoint": []any{
map[string]any{"url": "(array.example.com/redacted)"},
},
"headers": "Authorization=" + authPlaceholder,
},
},
}
arrayEntries := collectAllOTLPEndpoints(arrayForm)
require.Len(t, arrayEntries, 1)
// Assert: top-level headers do NOT propagate to array-form entries
assert.Empty(t, arrayEntries[0].Headers)(Adjust the assertion to match actual spec behaviour if the intent differs.) @copilot please address this. |
||
| stringForm := map[string]any{ | ||
| "observability": map[string]any{ | ||
| "otlp": map[string]any{ | ||
| "endpoint": "https://string.example.com:4317", | ||
| "headers": "Authorization=" + authPlaceholder, | ||
| }, | ||
| }, | ||
| } | ||
| entries := collectAllOTLPEndpoints(stringForm) | ||
| require.Len(t, entries, 1) | ||
| assert.Equal(t, "Authorization="+authPlaceholder, entries[0].Headers) | ||
|
|
||
| objectForm := map[string]any{ | ||
| "observability": map[string]any{ | ||
| "otlp": map[string]any{ | ||
| "endpoint": map[string]any{ | ||
| "url": "https://object.example.com:4317", | ||
| "headers": "X-Per-Entry=v", | ||
| }, | ||
| "headers": "Authorization=" + authPlaceholder, | ||
| }, | ||
| }, | ||
| } | ||
| objectEntries := collectAllOTLPEndpoints(objectForm) | ||
| require.Len(t, objectEntries, 1) | ||
| assert.Equal(t, "X-Per-Entry=v", objectEntries[0].Headers) | ||
|
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. Array form is not tested — the function name claims "string form only" but only verifies string and object forms; a regression where array-form entries incorrectly inherit the top-level 💡 Suggested fixAdd an array-form assertion to complete the spec contract: // Array form: per-entry headers take effect; top-level headers are ignored
arrayForm := map[string]any{
"observability": map[string]any{
"otlp": map[string]any{
"endpoint": []any{
map[string]any{
"url": "(first.example.com/redacted)",
"headers": "X-Per-Entry=v1",
},
},
"headers": "Authorization=" + authPlaceholder, // top-level — must NOT apply
},
},
}
arrayEntries := collectAllOTLPEndpoints(arrayForm)
require.Len(t, arrayEntries, 1)
assert.Equal(t, "X-Per-Entry=v1", arrayEntries[0].Headers)Without this, the invariant that top-level |
||
| } | ||
|
|
||
| func TestFormal_FanOutPreservesDeclarationOrder(t *testing.T) { | ||
| frontmatter := map[string]any{ | ||
| "observability": map[string]any{ | ||
| "otlp": map[string]any{ | ||
| "endpoint": []any{ | ||
| map[string]any{"url": "https://one.example.com:4317"}, | ||
| map[string]any{"url": "https://two.example.com:4317"}, | ||
| map[string]any{"url": "https://three.example.com:4317"}, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| entries := collectAllOTLPEndpoints(frontmatter) | ||
| require.Len(t, entries, 3) | ||
| assert.Equal(t, "https://one.example.com:4317", entries[0].URL) | ||
| assert.Equal(t, "https://two.example.com:4317", entries[1].URL) | ||
| assert.Equal(t, "https://three.example.com:4317", entries[2].URL) | ||
| } | ||
|
|
||
| func TestFormal_MirrorPathConstant(t *testing.T) { | ||
| assert.Equal(t, "/tmp/gh-aw/otel.jsonl", constants.TmpGhAwDirSlash+constants.OtelJsonlFilename) | ||
| } | ||
|
|
||
| func TestFormal_EmptyURLEntriesDiscarded(t *testing.T) { | ||
| frontmatter := map[string]any{ | ||
| "observability": map[string]any{ | ||
| "otlp": map[string]any{ | ||
| "endpoint": []any{ | ||
| map[string]any{"url": ""}, | ||
| map[string]any{"url": "https://valid.example.com:4317"}, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| assert.Equal(t, []otlpEndpointEntry{{URL: "https://valid.example.com:4317"}}, collectAllOTLPEndpoints(frontmatter)) | ||
| } | ||
|
|
||
| func TestFormal_StringHeaderFormPreservedForNonSentry(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. [/tdd] 💡 SuggestionRemove this function. The non-Sentry preservation invariant is already a named assertion in @copilot please address this. |
||
| assert.Equal(t, | ||
| "Authorization="+authPlaceholder, | ||
| normalizeOTLPHeadersForEndpoint("Authorization="+authPlaceholder, "https://otlp.example.com:4317"), | ||
| ) | ||
| } | ||
|
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. Duplicate of 💡 Suggested fixRemove |
||
|
|
||
| func TestFormal_NilAndEmptyHeadersYieldEmptyString(t *testing.T) { | ||
| assert.Empty(t, normalizeOTLPHeadersForEndpoint(nil, "https://example.com:4317")) | ||
| assert.Empty(t, normalizeOTLPHeadersForEndpoint("", "https://example.com:4317")) | ||
| assert.Empty(t, normalizeOTLPHeadersForEndpoint(map[string]any{}, "https://example.com:4317")) | ||
| } | ||
|
|
||
| func TestFormal_InvalidIfMissingFallsBackToDefault(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. [/tdd] 💡 SuggestionSplit into two focused tests: func TestFormal_InvalidIfMissingModesReturnEmpty(t *testing.T) {
for _, mode := range []string{"fail", "silent", "skip", "abort"} {
t.Run(mode, func(t *testing.T) {
assert.Empty(t, normalizeOTLPIfMissingMode(mode))
})
}
}
func TestFormal_InvalidIfMissingNotInjected(t *testing.T) {
// ...injectOTLPConfig assertions...
}Using @copilot please address this. |
||
| for _, mode := range []string{"fail", "silent", "skip", "abort"} { | ||
| assert.Empty(t, normalizeOTLPIfMissingMode(mode)) | ||
| } | ||
|
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. Loop without subtests loses per-iteration failure context — when 💡 Suggested fixfor _, mode := range []string{"fail", "silent", "skip", "abort"} {
t.Run(mode, func(t *testing.T) {
assert.Empty(t, normalizeOTLPIfMissingMode(mode))
})
}This is also a good place to add |
||
|
|
||
| workflowData := &WorkflowData{ | ||
| RawFrontmatter: map[string]any{ | ||
| "observability": map[string]any{ | ||
| "otlp": map[string]any{ | ||
| "endpoint": "https://traces.example.com:4317", | ||
| "if-missing": "fail", | ||
| }, | ||
| }, | ||
| }, | ||
| ParsedFrontmatter: &FrontmatterConfig{ | ||
| Observability: &ObservabilityConfig{ | ||
| OTLP: &OTLPConfig{ | ||
| Endpoint: "https://traces.example.com:4317", | ||
| IfMissing: "fail", | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| (&Compiler{}).injectOTLPConfig(workflowData) | ||
| assert.NotContains(t, workflowData.Env, "GH_AW_OTLP_IF_MISSING") | ||
|
|
||
| validWorkflowData := &WorkflowData{ | ||
| RawFrontmatter: map[string]any{ | ||
| "observability": map[string]any{ | ||
| "otlp": map[string]any{ | ||
| "endpoint": "https://traces.example.com:4317", | ||
| "if-missing": "warn", | ||
| }, | ||
| }, | ||
| }, | ||
| ParsedFrontmatter: &FrontmatterConfig{ | ||
| Observability: &ObservabilityConfig{ | ||
| OTLP: &OTLPConfig{ | ||
| Endpoint: "https://traces.example.com:4317", | ||
| IfMissing: "warn", | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| (&Compiler{}).injectOTLPConfig(validWorkflowData) | ||
| assert.Contains(t, validWorkflowData.Env, "GH_AW_OTLP_IF_MISSING") | ||
| assert.Contains(t, validWorkflowData.Env, "warn") | ||
|
Comment on lines
+237
to
+238
|
||
| } | ||
|
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. Substring 💡 Suggested fixPin both the key and the value together so a future rename or value-format change doesn't silently pass this check: assert.Contains(t, validWorkflowData.Env, "GH_AW_OTLP_IF_MISSING: warn")For example, if |
||
|
|
||
| func TestFormal_AbsentObservabilityProducesNoEndpoints(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. [/tdd] 💡 SuggestionMove the unique case ( @copilot please address this. |
||
| assert.Empty(t, collectAllOTLPEndpoints(nil)) | ||
| assert.Empty(t, collectAllOTLPEndpoints(map[string]any{})) | ||
| assert.Empty(t, collectAllOTLPEndpoints(map[string]any{"observability": nil})) | ||
| } | ||
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.
[/grill-with-docs] No spec predicate references appear in the file. The PR description explicitly maps each test to a named predicate in
specs/otel-observability-spec.md(v0.4.0), but none of those references appear inside the test file itself. Without them, the traceability that makes a "formal spec" test suite valuable is lost as soon as someone reads the file without the PR context.💡 Suggestion
Add a package-level or per-test doc comment linking to the spec section. For example:
Or per-test:
// Spec: endpoint-form-normalization (v0.4.0 §3.1)@copilot please address this.