CLAUDE.md - Plugin Test Guide

This file guides AI assistants writing and changing plugin tests under test/plugin/scenarios/. For the full test-framework mechanics (scenario layout, configuration.yml, expectedData.yaml, containers, running locally) see the “Plugin Test Framework” section of apm-sniffer/apm-sdk-plugin/CLAUDE.md, and docs/.../Plugin-test.md.

Testing philosophy: plugin scenarios are the key test — not unit tests

For an agent plugin, the plugin test scenario is the primary and sufficient test. Do not over-engineer by adding many unit tests for a plugin.

A plugin's real risk is two things, and a scenario proves both at once by running the real framework:

  1. Cast / fetch-data logic — the interceptor casting the intercepted arguments to the framework types and reading request/response fields off them (headers, method, URI, status, params). Mocks can't validate that the real framework class actually exposes those methods the way you assumed.
  2. Version compatibility — the same interceptor must work across the supported version range.

So: when adding or changing a plugin, add or extend its scenario, not a pile of mock-based unit tests. Reserve unit tests for genuinely logic/namespace-critical shared code where a deterministic in-JVM assertion earns its keep (e.g. servlet-commons wrap() resolving javax vs jakarta with both APIs on the test classpath) — never for per-plugin interceptor mocking.

Validation path: assert the full span shape your plugin produces

A scenario proves a plugin by asserting the exact spans the real framework makes the agent produce. Pick the shape by what the framework actually does — and don't settle for a single entry span when the plugin also does propagation, or you silently skip the inject/extract path.

  • RPC / web frameworks that have both a server and a client side (Struts, Spring MVC, gRPC, Dubbo, HTTP servers …): drive the full round-trip — curl → server → its own client call → another service's endpoint. The trace then contains 2 entry spans + 1 exit span:

    1. the server entry span for the request curl hit (proves the server side: extract + entry),
    2. the client exit span for the outbound call (proves inject + exit),
    3. a second entry span on the other endpoint carrying a cross-process ref back to the first (proves propagation across the wire — the receiving/extract side).

    The ref itself must be asserted — for RPC the cross-process refs: block is the whole point, so expectedData.yaml must include it on that second entry span with refType: CrossProcess, parentEndpoint, parentSpanId (pointing at the exit span), parentTraceSegmentId: not null, parentService, and traceId: not null. A scenario that asserts spans but not the ref does not prove propagation.

    The struts2.7 scenario is exactly this: case.action (entry) → HttpClient (exit) → case1.action (entry + ref); jetty-12 uses the same shape via a JDK-HttpURLConnection self-call. Keep that nested self-call — it is the only thing that proves inject / extract / propagation; a lone entry span does not.

  • Client-only plugins (JDBC/PostgreSQL, Redis, MongoDB, an HTTP client, a message producer): the peer is a database/broker, not another instrumented SkyWalking service, so there is no second entry span. Assert just the client exit span (component, peer, and the db.* / http.* tags). You do NOT need to stand up another service endpoint.

Version coverage: one version per minor (latest patch)

support-version.list must cover the framework's supported range, but keep one version per minor version — the latest patch, not every patch. E.g. Jetty 12.0.36 + 12.1.10; Struts 7.0.3, 7.1.1, 7.2.1; Spring 6.0.4, 6.1.1, 6.2.19. Verify each version resolves on Maven Central. This is the compatibility proof; CI runs the scenario against each listed version.

How the test containers work (and where the JDK/Tomcat version really comes from)

A scenario runs inside one of two prebuilt container images, selected by type: in configuration.yml:

  • type: jvm → the agent-test-jvm container. Your app is a fat-jar started by bin/startup.sh with ${agent_opts} (the -javaagent: line — you MUST include it in startup.sh). Built FROM ${base_image_java}.
  • type: tomcat → the agent-test-tomcat container. Your app is a *.war dropped into /usr/local/tomcat/webapps/; the agent is wired in via the container's patched catalina.sh. Built FROM ${base_image_tomcat}.

The version pin is in the CI lane, not configuration.yml. configuration.yml only says type: tomcat — it does not choose a Tomcat version. The concrete JDK and Tomcat versions come from the base_image_java / base_image_tomcat inputs passed to ./.github/actions/build in the lane's Build job, and every scenario in that lane shares that one image. So:

  • type: tomcat on the plugins-jdk8-* lane → tomcat:8.5-jdk8 (javax); on plugins-jdk11-*tomcat:9.0-jdk11 (javax); on plugins-jdk17-*tomcat:10.1-jdk17 (jakarta).
  • That is why a jakarta/Servlet-6 framework (Struts 7, Jetty 12, Spring 6) MUST be registered on a JDK-17 lane — it needs the Tomcat-10.1 base — and a javax one on an older lane. Putting a jakarta WAR on a Tomcat-8.5 lane just won't deploy.

To run locally you pass the same two knobs explicitly (the defaults are the javax/JDK-8 pair): bash test/plugin/run.sh --base_image_java eclipse-temurin:17-jdk --base_image_tomcat tomcat:10.1-jdk17-temurin <scenario>. Some newer bases lack curl (the container health check needs it) — the JDK-25 lane bakes it in via .github/workflows/Dockerfile-tomcat-jdk25-withCurl.

How the GitHub Actions lanes work

Scenarios are load-balanced across .github/workflows/plugins-<jdk>-test.<group>.yaml files. Each file is one lane: its Build job pins base_image_java + base_image_tomcat, and its test job‘s matrix.case: list names the scenarios that run on that image. Lanes exist per JDK (jdk8/jdk11/jdk17/jdk21/jdk25); the trailing .0/.1/… is just a bucket to spread load. Register a new scenario by adding its directory name to the matrix.case: list of the lane whose base image matches the framework’s needs — use python3 tools/select-group.py to pick the least-loaded bucket in that JDK lane.

Cold-start and the 3s entry-service timeout (a real gotcha)

The container‘s run.sh runs under set -e and hits the entry service with curl -s --max-time 3 ${SCENARIO_ENTRY_SERVICE}no warm-up first. So the very first (cold) entry request must finish within 3 seconds or curl exits 28 and the whole scenario fails. That first request pays a lot of one-time cost at once: the agent’s ByteBuddy enhancement of every class it touches on that path, JIT, and — for WARs — a cold Jasper JSP compile, all under the container's tight -Xmx256m heap and whatever CPU the runner gives, with jacoco simultaneously instrumenting the org.apache.skywalking.* classes for coverage.

Keep the entry path's cold cost low:

  • Don't render a JSP from the entry action if a lightweight result works (e.g. Struts httpheader, a plain-text/stream result). The view is irrelevant to what a plugin scenario verifies (entry/exit spans, tags, propagation), and a cold Jasper compile is expensive.
  • Be extra careful when the entry action makes a nested internal call (for cross-process propagation coverage): if that nested request also does cold work (a JSP compile) while the outer request is still cold-enhancing Struts/HttpClient, the two cold-start storms contend for the limited CPU and heap and blow up super-linearly (observed ~0.5s each in isolation but ~3–20s nested under throttle). This is a slow first request, not a plugin bug — verify with a CPU-throttled container (docker run --cpus=0.5 …) before assuming a hang.

Practical notes

  • One scenario per plugin (or per javax/jakarta era). Mirror an existing sibling scenario as a template and change only what differs (framework version, namespace, deployment model).
  • Match type: to the deployment: jvm (fat-jar started by bin/startup.sh, ${agent_opts} required) or tomcat (WAR on a container). Jakarta/newer frameworks go in a JDK-17 CI group.
  • Assert the entry span shape that exercises the plugin's data logic: componentId, spanType: Entry, spanLayer: Http, and the url / http.method / http.status_code (and http.params / http.headers where the plugin collects them) tags, plus cross-process refs.
  • Register the scenario in the right .github/workflows/plugins-*.yaml matrix (use python3 tools/select-group.py to pick the least-loaded group for its JDK lane).