From 11d14c3f756f1feab45072d6f7bcab6e728e3694 Mon Sep 17 00:00:00 2001 From: Evan Vetere Date: Thu, 16 Jul 2026 16:13:59 -0400 Subject: [PATCH] test(billing): pin usage subject to project name + fix metering doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The billing consumer (milo-os/billing) attributes usage by PROJECT NAME: it strips "projects/" from the CloudEvent subject and matches the remainder against BillingAccountBinding.spec.projectRef.name. The subject must therefore be projects/; a subject built from the route_name namespace UID never resolves. The billing e2e previously accepted any single subject carrying the four CloudEvents, so a subject built from the wrong identifier passed undetected. Pin the assertion to projects/ and guard that project_name differs from the namespace UID, so a UID-based subject regression fails the test. Also correct the HTTP metering enhancement doc, which described project_name as merely a "dimension" — it is the attribution subject. Document the consumer contract (subject -> projectRef.name) so future changes don't repeat the confusion. No VRL behavior change. Refs #286, datum-cloud/infra#3444. --- .../http-metering/http-traffic-metering.md | 23 ++++++++----- test/e2e/billing/chainsaw-test.yaml | 34 +++++++++++++++++-- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/docs/enhancements/http-metering/http-traffic-metering.md b/docs/enhancements/http-metering/http-traffic-metering.md index 80c86b24..33b21c64 100644 --- a/docs/enhancements/http-metering/http-traffic-metering.md +++ b/docs/enhancements/http-metering/http-traffic-metering.md @@ -144,9 +144,13 @@ central Billing System. The raw access log already carries everything the meters need *except* one thing: the `route_name` field identifies the owning project only by its -control-plane namespace UID (e.g. `ns-`), not by the -human-readable project name. To populate the `project_name` dimension, three -components collaborate at the edge: +control-plane namespace UID (e.g. `ns-`), not by the project name. +This matters because the billing consumer attributes usage **by project name**: +it strips `projects/` from the CloudEvent subject and matches the remainder +against `BillingAccountBinding.spec.projectRef.name` (see `milo-os/billing`, +`internal/controller/consumer`). The namespace UID would never resolve, so the +CloudEvent subject **must** be `projects/`. To surface the project +name at the edge, three components collaborate: 1. **Extension Server (xDS mutation).** The NSO extension server implements `ApplyTPPRouteConfig` in `internal/extensionserver/mutate/tpp.go`. During @@ -171,11 +175,14 @@ components collaborate at the edge: a header.) 3. **Vector billing collector.** The `billing-usage-collector-vector` VRL - transform reads the `project_name` field from each parsed access log line - and adds it as a dimension on all four emitted CloudEvents (requests, - ingress-bytes, egress-bytes, connection-seconds). An absent or Envoy-default - `"-"` value is normalized to an empty string so unmatched routes do not - pollute the dimension. + transform reads the `project_name` field from each parsed access log line and + uses it as the CloudEvent **subject** — `projects/` — on all + four emitted events (requests, ingress-bytes, egress-bytes, + connection-seconds); this is the value the consumer attributes on. The same + name is also carried as a `project_name` dimension for reporting. The subject + falls back to the `route_name` namespace UID only when `project_name` is + absent (those events cannot be attributed and are quarantined); an absent or + Envoy-default `"-"` value is normalized to an empty string. This keeps the entire signal path — xDS route enrichment, log emission, parsing, and CloudEvent forwarding — co-located on the edge cluster. diff --git a/test/e2e/billing/chainsaw-test.yaml b/test/e2e/billing/chainsaw-test.yaml index c03d9f13..76134088 100644 --- a/test/e2e/billing/chainsaw-test.yaml +++ b/test/e2e/billing/chainsaw-test.yaml @@ -522,6 +522,27 @@ spec: exit 1 fi + # The billing consumer (milo-os/billing) resolves attribution by + # PROJECT NAME: it strips "projects/" from the CloudEvent subject + # and matches the remainder against + # BillingAccountBinding.spec.projectRef.name. So the subject MUST be + # projects/; a subject built from the route_name + # namespace UID would never resolve. Assert subject == + # projects/, and guard that project_name differs from + # the namespace UID so a UID-based subject regression is actually + # distinguishable (infra#3444 / #286). + ROUTE_NAME=$(python3 -c "import sys, json; print(json.loads(sys.argv[1]).get('route_name', ''))" "$ENVOY_LOG") + NS_UID=$(python3 -c "import sys, re; m = re.match(r'^httproute/ns-([^/]+)/', sys.argv[1]); print(m.group(1) if m else '')" "$ROUTE_NAME") + if [ -z "$NS_UID" ]; then + echo "ERROR: could not derive namespace uid from route_name '$ROUTE_NAME'" >&2 + exit 1 + fi + EXPECTED_SUBJECT="projects/$PROJECT_NAME" + if [ "$PROJECT_NAME" = "$NS_UID" ]; then + echo "ERROR: fixture project_name equals the namespace uid; the test cannot distinguish a name-based subject from a uid-based one. Use a project whose name differs from its uid." >&2 + exit 1 + fi + echo "Parsed Envoy Log metrics:" echo " - Ingress bytes (expected): 11" echo " - Egress bytes (expected): $BYTES_SENT" @@ -551,6 +572,7 @@ spec: expected_bytes_sent = float($BYTES_SENT) expected_duration_sec = float(int(float($DURATION_MS) / 1000.0)) expected_project_name = \"$PROJECT_NAME\" + expected_subject = \"$EXPECTED_SUBJECT\" logs = sys.stdin.read() diagnostics = [] def check_events(events): @@ -558,8 +580,8 @@ spec: # of CloudEvents for our request. Records why each subject was # rejected in 'diagnostics' for failure output. diagnostics.clear() - # Group events by subject (projects/); a valid run has all - # four event types under one subject. + # Group events by subject (projects/); a valid + # run has all four event types under one subject. by_subject = {} for e in events: subj = e.get('subject', '') @@ -568,6 +590,14 @@ spec: diagnostics.append(\"No events found in this log chunk.\") return False for subj, subj_events in by_subject.items(): + # The billing subject must be projects/ (the + # attribution key the consumer matches against + # BillingAccountBinding.spec.projectRef.name); a subject + # built from the route_name namespace UID must NOT satisfy + # this check. + if subj != expected_subject: + diagnostics.append(f\"Ignoring subject '{subj}': expected '{expected_subject}' (projects/)\") + continue # Index this subject's events by type so we can assert each # of the four expected usage events exists exactly once. types = {e.get('type'): e for e in subj_events}