Skip to content

feat: VCL trace-context rewrite (tracing P2) - #108

Merged
jensens merged 15 commits into
mainfrom
feat/otel-tracing-p2
Sep 30, 2026
Merged

jensens merged 15 commits into
mainfrom
feat/otel-tracing-p2

Conversation

@jensens

@jensens jensens commented Sep 30, 2026

Copy link
Copy Markdown
Member

Summary

P2 of OpenTelemetry tracing, per the (herein corrected) design spec: the backend becomes a proper child of the Varnish fetch span.

  • Generator (HasTracing flag, the xkey/esi pattern; user snippet fields untouched): import uuid;, a traceparent-validation block early in vcl_recv (W3C shape, all-zero ids, and the W3C-forbidden ff version all unset; placed before the PURGE/cluster-routing return(pass) paths), and a minting block in vcl_backend_fetch that rewrites the span id on bereq.http.traceparent (trace id and flags preserved; full self-root mint when the client sent nothing; every hex through std.tolower).
  • Tracer adoption: spans.Build adopts the VCL-minted fetch span id from the VSL BereqHeader record — and, for self-rooted traffic, the minted trace id — with a guard so an unrewritten P1-style forward is never adopted. vsl.Header() now returns the last matching record (the VSL logs the forwarded header, an unset, and the re-add; last is what the backend received), pinned by a dedicated test against the recorded fixture.
  • Fixtures: new P2 recordings from real varnish:8.0.2 with the tracing VCL active (rewrite_miss, rewrite_selfroot) — this recording was also the first live varnishd validation of the VCL, which loaded first try.
  • vinylprobe: -body-capture (print a regex capture from a response body) and -trace-id/-span-id filters on -assert-spans, plus transport errors surfaced in the FAIL line.
  • envtest: the webhook suite's first real admission assertions (deny tracing-without-endpoint, admit host:port) via the API-server round trip.
  • E2E: a rewrite-proof step — read the minted traceparent back out of the echo backend's reflected body, then require the sink to hold a varnish fetch span with exactly that trace and span id, and a varnish request span in the same trace (also proving self-root trace adoption).
  • Spec: four corrections aligning the design doc with shipped reality (image name, injection mechanism, response-header claim, controller-side defaults).

Verification

make lint, make test, make test-int (incl. the new admission specs), helm unittest (59/59), both hack checks, zero generated-file drift — all green at HEAD. Every task passed spec+quality review; the final whole-branch review's one functional finding (the ff-version gate divergence) is fixed in the last commits and re-reviewed.

Note for reviewers

The rewrite-proof E2E step has never executed before this PR's CI run. Please do not merge before the fast suite is green. Its budget arithmetic is documented in the step (worst case 120s under a 180s ceiling).

Follow-ups (P3 scope)

Coalescing/grace/ESI truth semantics; retry/ESI bereqs are nested VSL grandchildren the builder does not yet visit (pre-existing P1 gap); multi-child self-root and duplicate-distinct-traceparent hardening.

Assisted-by: Claude Fable 5

8 TDD tasks: generator HasTracing flag + uuid import + recv validation
+ backend_fetch minting, P2 fixtures recorded with the tracing VCL
active, tracer adoption of VCL-minted ids (incl. self-root trace
adoption and the unrewritten-forward guard), vinylprobe -body-capture
and span-id filters, first real envtest admission assertions, E2E
rewrite proof via the echo backend's reflected traceparent, spec
corrections, verification battery.

Assisted-by: Claude Fable 5
P1 added spec.tracing to the CRD and the tracer sidecar; this makes
generated VCL participate. Gated by a new HasTracing flag
(Spec.Tracing.Enabled):

- main.vcl.tmpl imports vmod uuid when tracing is enabled.
- vcl_recv validates an inbound traceparent against the W3C
  trace-context shape and drops all-zero trace/parent ids, before the
  PURGE/BAN/cluster-routing blocks — those return(pass)/return(synth)
  early and would skip validation placed any later.
- vcl_backend_fetch mints a fresh span id into bereq.http.traceparent
  (rewriting the parent-id segment on an existing header, or minting a
  full self-rooted header when none arrived) so the tracer can adopt
  it as the "varnish fetch" span's id from the VSL BereqHeader record.

TDD: five new tests in generator_test.go cover disabled (no tracing
content emitted), uuid import, recv validation + its required
ordering before PURGE, backend_fetch minting, and coexistence with
operator-authored VCL snippets. Full ./internal/generator/ suite,
whole-repo build/test, and make lint all green.

Assisted-by: Claude Fable 5
Adds record-fixtures-tracing.sh, which runs varnish:8.0.2 with an
embedded VCL mirroring the tracing blocks of
internal/generator/templates/{vcl_recv,vcl_backend_fetch}.vcl.tmpl
(rendered by hand for a static backend instead of the k8s backend
list the generator needs). This is the first time that VCL meets a
real varnishd; it loaded and ran without incident.

rewrite_miss.txt: a request carrying a valid traceparent gets its
trace id preserved and its span id rewritten by vcl_backend_fetch.
rewrite_selfroot.txt: a request with no traceparent gets a full
VCL-minted one. Both confirmed lowercase by eye per testdata/README.md.

Assisted-by: Claude Fable 5
Build now adopts the fetch span id (and trace id, when the client sent
none) that the P2 VCL snippet mints onto bereq's traceparent, so the
backend's own spans land under the fetch span instead of as its
sibling. A BereqHeader span id equal to the incoming parent id is an
unrewritten P1-style forward, not a minted one, and is ignored.

Also fix vsl.Tx.Header to return the last matching record instead of
the first: VCL logs an unset-then-reset pair when it rewrites a
header, so the first match is the pre-rewrite value. This only
affects headers VCL rewrites (BereqHeader traceparent); the existing
single-occurrence ReqHeader lookups are unaffected.

Assisted-by: Claude Fable 5
The previous commit's fix to vsl.Tx.Header (return the last matching
record so a VCL unset+re-add resolves to the rewritten value) was only
proven indirectly, through spans_test.go's adoption tests. Add
TestHeader_LastMatchWinsOnRewrittenHeader against the real
rewrite_miss.txt fixture, whose BeReq child genuinely carries the
forwarded "Traceparent", a BereqUnset, and the VCL-minted lowercase
"traceparent" re-add. Verified this goes red on a first-match revert
(confirmed by hand before committing) and green on the current
implementation.

Assisted-by: Claude Fable 5
Adds probe.BodyCapture (internal/probe/cache.go) for GET-once regex
capture against a response body, used by vinylprobe -body-capture to
print the first capture group (exit 0), FAIL on no match (exit 1), or
error on bad regex/transport (exit 2) -- the same stdout contract as
-seed's token, for E2E steps that need to read back backend-reflected
state such as a VCL-minted traceparent.

decideSpans gains traceID/spanID equality filters, exposed as
-assert-spans's new -trace-id/-span-id flags, and -assert-spans's FAIL
message now appends the last poll iteration's transport error when
present, instead of hiding it behind a silent retry.

Assisted-by: Claude Fable 5
Adds the first two envtest It blocks in the v1alpha1 webhook suite,
proving the API-server round trip (webhook wired, failurePolicy
effective) rather than just the pure ValidateVinylCache function
(already unit-tested in internal/webhook/vinylcache_validator_test.go).

minimalAdmissibleVC mirrors the unit suite's minimalValidVC fixture
verbatim, since test helpers cannot be imported across packages.

Assisted-by: Claude Fable 5
Adds the rewrite-proof step between assert-spans and cleanup: a
fresh-path request captures the backend-reflected traceparent the VCL
minted onto bereq (vinylprobe -body-capture), then asserts a "varnish
fetch" span with exactly that trace/span id and a "varnish request"
span in the same trace with no client traceparent sent, proving
self-root trace adoption. Updates the suite description's NOT COVERED
line now that P2 trace-context rewrite is covered.

Assisted-by: Claude Fable 5
The step's worst case (30s -body-capture default timeout + 60s + 30s
-assert-spans polls = 120s) exactly equalled the script timeout,
leaving zero margin for the three kubectl exec spawns' overhead --
unlike the sibling assert-spans step, which documents and budgets a
deliberate margin over its own worst case. Raise the script timeout
to 180s and document the arithmetic in the step description, mirroring
assert-spans.

Assisted-by: Claude Fable 5
- Image name: vinyl-tracer → cloud-vinyl-tracer (shipped name)
- VCL injection: clarify that snippet is generated via HasTracing flag
- X-Cache headers: describe deployment concern for cache-status capture
- Webhook responsibility: defaults applied controller-side, webhook validates
- Testing section: remove reference to webhook defaulting (validation only)
Assisted-by: Claude Fable 5
vcl_recv's tracing gate unset malformed and all-zero traceparents but
let version "ff" through (the shape regex allows any [0-9a-f]{2}
version byte). parseTraceparent rejects ff per W3C trace-context, so
an ff header passed the VCL gate, got a bereq id minted under it, and
the tracer adopted nothing: request and fetch spans landed in a
random self-rooted trace with the backend left disconnected from it.

Add an unset condition for "^ff-" alongside the existing all-zero
checks, and mirror it into record-fixtures-tracing.sh's embedded VCL
so the two stay logic-identical per the keep-in-sync comment there.
Recorded fixtures are unaffected (inputs don't touch this path).

TDD: extended TestGenerate_TracingEnabled_RecvValidatesTraceparent to
assert the rendered VCL contains the ^ff- unset condition; confirmed
RED before the template fix, GREEN after.

Assisted-by: Claude Fable 5
The committed plan doc's global constraints told agentic workers to
end commits with the outdated attribution form,
which is outdated: repo policy is Assisted-by: Claude Fable 5, never
outdated-attribution, never a no-reply address.

Assisted-by: Claude Fable 5
Two comment-only clarifications from the whole-branch review:

- vsl.Tx.Header: note that *Unset records are not processed, so a
  header that ends unset in the real request still returns its last
  set value here. Callers relying on this for "what the backend saw"
  are only safe while their own parser is stricter than the VCL gate
  that would have unset it.

- spans.Build's adoption comment: note that with multiple direct
  BeReq children under a self-root, only the first valid child's
  trace id is adopted (mintedTraceID is set once); later children's
  backends would land in orphaned traces of their own. Practically
  unreachable today (one BeReq per top-level Request); P3 revisits
  this with retry/ESI fixtures.

Assisted-by: Claude Fable 5
CI's Unit Tests job runs bare `go test -race ./...` with no
KUBEBUILDER_ASSETS. Before this branch, internal/webhook/v1alpha1's
Ginkgo suite had zero It blocks, so BeforeSuite never actually ran
there; the new admission specs (d0b0a58) start envtest on every plain
`go test`, which fails in CI with "fork/exec
/usr/local/kubebuilder/bin/etcd: no such file or directory".

Tag webhook_suite_test.go and vinylcache_webhook_test.go with
`//go:build integration`, matching internal/controller's existing
suite_test.go/vinylcache_controller_test.go convention. The suite now
only runs via `make test-int` (which passes -tags=integration); plain
`go test ./...` and `make test` report "no test files" for the
package, same as the controller suite already does.

Assisted-by: Claude Fable 5
In CI, e2e/tests/tracing's traffic-miss-then-hit step got "connect:
connection refused" (vinylprobe exit 2) a few seconds after the
StatefulSet's readyReplicas assert had already gone green — a
readiness flap right at startup, not a caching bug; the parallel
untraced copy of this test passed a minute later.

vinylprobe separates transport failure (exit 2) from a wrong verdict
(exit 1) for exactly this: wrap the single vinylprobe call in a loop
that retries only on exit 2 (up to 5 times, 5s apart) and lets exit 0
or exit 1 through immediately, so a real assertion failure still fails
fast instead of being retried into a false pass. Documented in the
step description why the added sleep is acceptable here (bounded
transport-error backoff, not an assertion poll — vinylprobe still owns
every assertion) and why the ~30s worst case still fits the step's
existing spec-level exec:120s budget without raising it.

Verified: python3 yaml.safe_load, hack/check-suite-labels.sh, and
hack/check-e2e-boundary.sh all pass on the updated file.

Assisted-by: Claude Fable 5
@jensens
jensens merged commit 49fa585 into main Sep 30, 2026
8 checks passed
@jensens
jensens deleted the feat/otel-tracing-p2 branch September 30, 2026 12:19
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