Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions docs/enhancements/http-metering/http-traffic-metering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<project-uid>`), 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-<project-uid>`), 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/<project-name>`. 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
Expand All @@ -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/<project-name>` — 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.
Expand Down
34 changes: 32 additions & 2 deletions test/e2e/billing/chainsaw-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<project-name>; a subject built from the route_name
# namespace UID would never resolve. Assert subject ==
# projects/<project_name>, 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"
Expand Down Expand Up @@ -551,15 +572,16 @@ 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):
# Returns True once a single subject has the full, correct set
# of CloudEvents for our request. Records why each subject was
# rejected in 'diagnostics' for failure output.
diagnostics.clear()
# Group events by subject (projects/<uid>); a valid run has all
# four event types under one subject.
# Group events by subject (projects/<project-name>); a valid
# run has all four event types under one subject.
by_subject = {}
for e in events:
subj = e.get('subject', '')
Expand All @@ -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/<project-name> (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/<project-name>)\")
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}
Expand Down
Loading