Skip to content

[golang] Add apim weblog variant for azure/apim-callout - #7516

Open
eliottness wants to merge 9 commits into
mainfrom
eliottness/azure-apim
Open

[golang] Add apim weblog variant for azure/apim-callout#7516
eliottness wants to merge 9 commits into
mainfrom
eliottness/azure-apim

Conversation

@eliottness

Copy link
Copy Markdown
Contributor

Motivation

dd-trace-go ships an Azure API Management integration (contrib/azure/apim-callout) that had no system-tests coverage. The other two Go proxy integrations, Envoy and HAProxy, each have a weblog variant; APIM did not, so the callout's behaviour was only covered by that repo's own unit tests.

The obstacle is that Azure APIM has no runnable container — it is a managed Azure service. There is nothing to docker run as the gateway.

Changes

A new Go weblog variant apim, at activation parity with envoy.

Since APIM itself cannot run locally, the gateway role is filled by a small system-tests-owned Go shim that reimplements APIM send-request behaviour. It deliberately does not parse policy XML — it reproduces what the policy does:

test runner --> apim-gateway (shim, :80 -> host 127.0.0.1:7777)
                  |-> apim-callout (POST :8080, health :8081)   <- library under test
                  `-> http-app (:8080)

The shim is stdlib-only, has no require block, and adds no configuration surface beyond a single control header.

Body delivery modes. The callout supports two, and testing only one would leave half its body-handling code dark. Default is the deferred 4-call flow. X-Datadog-Apim-Body-Mode: inline switches both sides to inline delivery, which suppresses allowed-body-size and collapses the exchange to 2 calls. The header is stripped before the callout payload and before the upstream forward, so it never reaches the WAF or the app, and the shared suite never sends it.

Fail-closed, deliberately unlike APIM. The real policy uses ignore-error="true". The shim instead returns 502 plus a stderr diagnostic on detectable failure, so system-tests can assert on it. One case is genuinely undetectable and documented as such: the callout returns HTTP 200 {} both for processor errors and as a legitimate phase-2/4 success.

Manifest. All 89 envoy: rows are widened to envoy, apim:, transcribing enabled rows as enabled. Widening all 89 rather than only the non-redundant ones is necessary, not merely tidy: 29 rows have a "*" sibling and in every one the "*" value differs from envoy's, so for 13 of them the fallback would have handed apim an outright wrong declaration.

One row is declared missing_feature for apim rather than enabled — test_span_links_from_conflicting_contexts. This is a real library gap, not a shim defect: apim-callout defaults DD_TRACE_PROPAGATION_STYLE to datadog, so a conflicting W3C traceparent is never extracted and no terminated-context span link is created. haproxy-spoa ships the identical default and is already missing_feature on that exact row; envoy's processor leaves the variable unset and gets the tracer default, which is why it passes. Recording the gap was preferred over overriding the variable in the harness, which would test a configuration no default deployment runs.

containers.py:998 also gains apim. Without it get_image_list tries to open a nonexistent apim.Dockerfile and breaks get-image-list.py while every test still passes — a silent failure.

CI

apim is picked up automatically by compute-workflow-parameters.py (verified: it appears in endtoend_defs_parallel_jobs with weblog_build_required: false and scenarios [APPSEC_BLOCKING, DEFAULT]), so no workflow changes were needed. Like envoy and haproxy, it uses the :latest callout image in normal PR CI and :dev in _system_tests_dev_mode, where load-binary.sh writes the pointer.

Validation

Both scenarios were run locally against apim-callout:dev (golang@2.11.0-dev.1): DEFAULT 128 passed and APPSEC_BLOCKING 50 passed, with zero enabled-row failures. Also verified by hand: a live 403 WAF block through the shim, inline yielding exactly 2 callout calls and deferred exactly 4 (each sharing one request-id), and the fail-closed 502 path.

Note a bodiless GET correctly produces 3 calls, not 4 — the callout only returns allowed-body-size when it actually wants a request body.

One caveat: DummyServerContainer hardcodes an amd64-only image, so local validation on arm64 required substituting a native http-app. This affects envoy and haproxy identically and is pre-existing; amd64 CI exercises the stock topology.

Workflow

  1. ⚠️ Create your PR as draft ⚠️
  2. Work on you PR until the CI passes
  3. Mark it as ready for review
    • Test logic is modified? -> Get a review from RFC owner.
    • Framework is modified, or non obvious usage of it -> get a review from R&P team

🚀 Once your PR is reviewed and the CI green, you can merge it!

🛟 #apm-shared-testing 🛟

Reviewer checklist

  • Anything but tests/ or manifests/ is modified ? I have the approval from R&P team
    • Framework changes are involved and need R&P review: a new weblog variant and two new container classes in utils/_context/containers.py, the GoProxyWeblogs union and dispatch in utils/_context/weblog_infrastructure.py, the containers.py:998 image-list guard, plus utils/scripts/load-binary.sh and the new shim under utils/build/docker/golang/apim/.
  • A docker base image is modified?
    • the relevant build-XXX-image label is present
    • No base image is modified. The variant is build_mode: none and runs golang:1.25-alpine (already mirrored) with the shim bind-mounted read-only.
  • A scenario is added, removed or renamed?
    • No scenario is added, removed or renamed. apim is restricted to the existing DEFAULT and APPSEC_BLOCKING.

Add a Go weblog variant `apim` exercising
dd-trace-go/contrib/azure/apim-callout, at activation parity with `envoy`.

Azure APIM has no runnable container, so the gateway role is filled by a
system-tests-owned, stdlib-only Go shim that reimplements APIM `send-request`
behavior. It never parses policy XML.

    test runner --> apim-gateway (shim, :80 -> host 127.0.0.1:7777)
                      |-> apim-callout (POST :8080, health :8081)
                      `-> http-app (:8080)

The shim defaults to the 4-call flow (deferred bodies). A control header
`X-Datadog-Apim-Body-Mode: inline` switches both sides to inline delivery,
which suppresses `allowed-body-size` and collapses the exchange to 2 calls.
The header is stripped before building the callout payload and before
forwarding upstream. The shared suite never sends it.

Unlike the APIM policy's ignore-error="true", the shim fails closed (502 +
stderr) on detectable transport/protocol failure, so system-tests can assert
on it. The callout's HTTP 200 `{}` for processor errors is indistinguishable
from a legitimate phase-2/4 success, so the one available check is requiring
a non-empty request-id in the phase-1 response.

All 89 `envoy:` manifest rows are widened to `envoy, apim:`, transcribing
enabled rows as enabled. Widening all 89 rather than only the non-redundant
ones is required, not just harmless: 29 rows have a `"*"` sibling and in every
one the `"*"` value differs from envoy's, so for 13 of them the fallback would
have given apim an outright wrong declaration.

`containers.py:998` also gains `apim`, otherwise get_image_list opens a
nonexistent apim.Dockerfile and breaks get-image-list.py while every
acceptance criterion still passes.
Cover the apim shim's two body modes with system-tests:

- default (deferred) drives 4 callout calls sharing one request-id
- inline drives exactly 2
- span structure and component are identical in both modes
- inline mode closes cached state, so no orphaned-span warning appears
  in apim-callout stderr after the 30s TTL

The gateway now logs the request path alongside the phase and request-id.
Without it these tests cannot isolate their own traffic: the gateway
healthcheck is a bodiless GET, which is deferred mode, so it drives a
4-call flow of its own and the container only reports healthy once such a
request succeeds. Asserting merely that "some request-id somewhere made 4
calls" therefore could not fail. Each test now uses a unique probe path
and asserts exactly one correlated request-id group for it.

The path is logged to stderr rather than returned as a response header so
it stays invisible to the WAF, the upstream and the client.
AC1 measured ~11.93s from container start to the first successful health
probe. Replace the provisional 153s ceiling with ~21s (15s start_period +
6 * 1s), keeping a cushion because `go run .` still compiles the shim from
an empty build cache on every container start.
…p, cuts

Fail-closed paths now emit a diagnostic. D3 requires "502 + stderr", but
failClosed discarded the error, so 11 paths returned a bare 502 with nothing
in the log: request/upstream body reads, inline and deferred body encodes,
the upstream call, and block writes at all four phases. Each now logs
`apim-gateway fail-closed stage=<stage> error=...`. Callout-error sites use a
separate non-logging variant, since callout() already logged them and a single
logging helper would double-log. Adds tests for the upstream-closed-port and
malformed-block paths, and tightens the existing closed-port test to assert an
exact line count rather than a substring, which previously tolerated the very
double-logging this splits apart.

Record the span-links gap for apim. apim-callout defaults
DD_TRACE_PROPAGATION_STYLE to datadog, so a conflicting W3C traceparent is
never extracted and no terminated-context span link is created. Envoy's
processor leaves the variable unset and gets the tracer default, which is why
it passes. haproxy-spoa ships the same datadog-only default and is already
missing_feature on this row, so apim is marked beside it rather than having
the harness override a shipped default to force a pass.

Drop three pieces of dead or gold-plated code:
- calloutMessage.Gateway was never assigned. With omitempty and no writer it
  could not serialize, so removal is wire-neutral, and it was the footgun for
  the rule against sending a gateway at all.
- decodeJSON's trailing-JSON rejection was untested and guarded nothing: the
  first Decode had already produced the correct result. It only manufactured a
  502 the real callout cannot trigger, adding a third candidate cause to a
  shim whose main risk is undiagnosable failures. The two sibling guards stay,
  because each converts a handler panic into a clean 502.
- responseStatus() reflected over fixture JSON and panicked to recover values
  already present as literals; replaced by a wantStatus field.

Also drops the four manifest entries for the new tests. The @Irrelevant
decorator already produces the "*" outcome, and the sibling test_apm.py has no
manifest rows at all, so the entries duplicated activation across two
mechanisms and the version floor was unreachable.
allowed-body-size is a pointer, and a returned 0 is a limit rather than an
absence, so both body phases must still fire with a fully truncated body.
Every existing fixture returned a positive size, so narrowing the check from
"is set" to "is positive" left the whole suite green -- the shim's single most
important semantic rule was unguarded.

Adds a four-phase fixture returning 0 on both header phases, asserting the
callout receives an empty body while upstream and the client still receive the
untruncated payloads. Under a mutation to a positive-value check, every
pre-existing test still passes and only this one fails, which is what makes it
worth having.

Also covers the negative-size guard. Deleting it panics with slice bounds out
of range [:-1] at the body[:*limit] site, so the guard's rationale is now
verified rather than asserted; the shim returns 502 with a fail-closed
diagnostic instead.
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

CODEOWNERS have been resolved as:

tests/external_processing/test_apim_callout.py                          @DataDog/asm-libraries @DataDog/system-tests-core
tests/test_the_test/test_get_image_list.py                              @DataDog/system-tests-core
utils/build/docker/golang/apim/go.mod                                   @DataDog/dd-trace-go-guild @DataDog/system-tests-core
utils/build/docker/golang/apim/main.go                                  @DataDog/dd-trace-go-guild @DataDog/system-tests-core
utils/build/docker/golang/apim/main_test.go                             @DataDog/dd-trace-go-guild @DataDog/system-tests-core
docs/understand/scenarios/README.md                                     @DataDog/system-tests-core
manifests/golang.yml                                                    @DataDog/dd-trace-go-guild
tests/external_processing/test_apm.py                                   @DataDog/asm-libraries @DataDog/system-tests-core
tests/test_semantic_conventions.py                                      @DataDog/system-tests-core
tests/test_the_test/test_ci_orchestrator.py                             @DataDog/system-tests-core
utils/_context/containers.py                                            @DataDog/system-tests-core
utils/_context/weblog_infrastructure.py                                 @DataDog/system-tests-core
utils/build/docker/golang/go.work                                       @DataDog/dd-trace-go-guild @DataDog/system-tests-core
utils/build/docker/golang/weblog_metadata.yml                           @DataDog/dd-trace-go-guild @DataDog/system-tests-core
utils/scripts/load-binary.sh                                            @DataDog/system-tests-core

@datadog-prod-us1-6

datadog-prod-us1-6 Bot commented Aug 14, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: ec42fca | Docs | Datadog PR Page | Give us feedback!

The apim end-to-end jobs hung at "Pull images" while every sibling golang
variant passed. .github/actions/pull_images redirects get-image-list.py's
stdout into compose.yaml and feeds that to `docker compose`, and container
objects are constructed during that call -- so ApimCalloutContainer's
logger.stdout() lines landed inside the compose document as a bogus top-level
key:

    {'WARNING': 'binaries/golang-apim-callout-image not found...',
     'services': {}}

Both branches logged, which is why both the prod job (fallback) and the dev
job (pointer found) hung. The sibling processor containers are unaffected only
because they swallow the same branch silently.

Switch to logger.info/logger.warning. The resolved tag still reaches stdout on
a real run via GoProcessorContainer.post_start, which prints
"Processor image: <name>", so the released-vs-commit-under-test distinction
stays visible where it matters.

Adds tests/test_the_test/test_get_image_list.py asserting that the script's
stdout parses as a compose document whose only top-level key is `services`,
parametrised over all three go-proxy weblogs and over both pointer states.
Reintroducing either logger.stdout call fails it. It deliberately does not
pin services == {}, since a pointer to an image absent from the local cache
legitimately adds an entry.
The deferred probes asserted four callout phases and failed in CI with three:
<ResponseBody> never fired. The gateway only makes that call when the
<ResponseHeaders> callout answers with allowed-body-size, and the callout only
asks for a response body it can parse.

The stock http-app (jasonrm/dummy-server) has a single 34-line handler that
answers every request with the status code as text/plain, so there is no
request that can make it return a parseable body -- pointing the probes
elsewhere is not an option. Across both CI jobs, 1164 requests produced zero
<ResponseBody> phases under two different callout versions, while the 20
<RequestBody> phases line up exactly with the JSON-body POSTs, so the request
side works and the response side is upstream-driven.

Local validation missed this because the amd64-only http-app cannot run on
arm64 and was substituted with a JSON-returning stand-in, which makes the
fourth phase fire. The comment on UPSTREAM_DEPENDENT_PHASE says so, to stop
someone re-requiring it after validating the same way.

So the deferred assertion now requires the three guaranteed phases and
tolerates a single trailing <ResponseBody>. What matters is preserved: the
deferred flow must contain <RequestBody> and the inline flow must not, which
is the distinction these tests exist to prove. Renames the test and probe path
that claimed four calls.
@eliottness
eliottness marked this pull request as ready for review August 14, 2026 14:05
@eliottness
eliottness requested review from a team as code owners August 14, 2026 14:05

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe965689a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/external_processing/test_apim_callout.py
Comment thread tests/external_processing/test_apim_callout.py Outdated
Comment thread tests/external_processing/test_apim_callout.py
Comment thread tests/external_processing/test_apim_callout.py
The health budget was tightened to a ~21s ceiling off a single 11.93s
measurement, which was too aggressive. `go run .` rebuilds net/http and
crypto/tls from source into an empty GOCACHE on every container start --
~12s wall but ~13s of CPU locally -- so a contended 2-vCPU runner can
exceed 21s, and the failure mode is a dead scenario rather than a slow one.

execute_command stops at the first success, so a high retry count costs
nothing when the container is already up. It also treats start_period as an
unconditional sleep rather than a Docker grace period, so dropping it makes
a fast start detected sooner than the previous blind 15s wait. Every other
container in the file uses retries 10-60 with no start_period; 5 was the
lowest value present.

Also route block headers through applyHeaders. It already canonicalizes,
and writeBlock was the one place writing callout-supplied header names
verbatim. Not currently reachable, since dd-trace-go builds those headers
via Header().Set and they arrive canonical, but the failure mode is quiet:
a lowercase content-type would be missed by Go's internal Header.get, so
the server would content-sniff and emit a second Content-Type.
pr-review.mdc forbids time.sleep() inside setup_ methods in end-to-end
scenarios, and the 31s sleep also added that much unconditional wall time to
every DEFAULT run.

The wait was unnecessary. Every setup_ runs before any test_, and the
containers are stopped and their logs collected in between, so far more than
the callout's 30s state TTL has already elapsed by the time the assertion
reads the log.

Also scope the assertion to the probe's own request-id, which
_assert_inline_probe_phases already computed and discarded. Matching the bare
warning text would fail on any unrelated request that legitimately orphaned
state and blame inline mode for it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant