Skip to content

Commit 8b96dc4

Browse files
authored
Merge pull request #177 from datum-cloud/feat/wasm-metering-alternative
docs: add WASM filter emitter as the recommended approach for telemetry and billing attribution
2 parents 543830e + 48b76df commit 8b96dc4

1 file changed

Lines changed: 117 additions & 14 deletions

File tree

docs/enhancements/service-catalog-registration.md

Lines changed: 117 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -387,22 +387,36 @@ confirmed architectural choice before implementation begins.
387387

388388
### How Envoy Gateway exposes telemetry
389389

390-
Envoy Gateway exposes traffic telemetry through two primary surfaces:
390+
Envoy Gateway exposes traffic telemetry through three primary surfaces:
391391

392392
1. **Prometheus scrape endpoint** — the Envoy data plane exposes a `/stats`
393393
endpoint with counters including `envoy_http_downstream_rq_total` (request
394394
count), `envoy_http_downstream_cx_rx_bytes_total` (ingress bytes),
395395
`envoy_http_downstream_cx_tx_bytes_total` (egress bytes), and
396396
`envoy_http_downstream_cx_active` (active connections, from which
397397
connection-seconds can be derived). These are gauge/counter deltas — they are
398-
not pre-attributed to a project or billing account.
398+
not pre-attributed to a project or billing account. Importantly, the
399+
cluster-level variants (`envoy_cluster_upstream_*`) carry `httproute_name`,
400+
`httproute_namespace`, and `httproute_rule_ordinal` labels already, providing
401+
per-route attribution with no configuration changes. Counter values are held
402+
in-memory and lost when a pod restarts; the data loss window equals the scrape
403+
interval (typically 15–30 s).
399404

400405
2. **Access logs** — Envoy can be configured via the `EnvoyProxy` CR to emit
401406
structured JSON access logs (one line per completed request). Each line carries
402407
request bytes, response bytes, duration, upstream cluster, and extensible
403408
metadata. Access logs are the most natural source for per-request billing
404409
because each log line maps directly to one billable unit.
405410

411+
3. **WASM filter hooks** — Envoy embeds a WASM runtime (V8 or Wasmtime) and
412+
executes a `.wasm` binary inside the Envoy process. The proxy-wasm ABI
413+
exposes lifecycle hooks (`on_request_headers`, `on_response_headers`,
414+
`on_log`, `on_done`) that fire at well-defined points in the request
415+
lifecycle. The filter reads request metadata via `get_property()` host
416+
function calls and dispatches async HTTP calls via `dispatch_http_call()` —
417+
both are synchronous from the filter's perspective but non-blocking to Envoy.
418+
Because the filter runs in-process, no sidecar is needed and no data is lost on pod restart.
419+
406420
### Candidate approaches
407421

408422
#### Option A: Access log scraping via Vector Agent (recommended)
@@ -467,23 +481,112 @@ forwards to the Ingestion Gateway.
467481
- Attribution of OTLP metric streams to billing accounts is not defined in the
468482
current pipeline design.
469483

484+
#### Option D: WASM filter emitter
485+
486+
Build a small WASM binary (Rust, using `proxy-wasm-rust-sdk`) that runs inside
487+
the Envoy process and hooks into the request lifecycle via the proxy-wasm ABI.
488+
On `on_log()` — which fires after each request completes and on connection close
489+
for WebSocket / long-lived connections — the filter extracts billing signals and
490+
accumulates them in shared memory. Rather than emitting one event per request,
491+
the filter flushes a single batched payload every N seconds (configurable,
492+
default 60 s) via `dispatch_http_call()`. At 10 k req/s this reduces outbound
493+
billing calls from 10,000/s down to 1/min per proxy pod — a reduction of ~600,000×
494+
in emission volume, with no loss of per-route accuracy since signals are
495+
aggregated by `httproute_name` + `httproute_namespace` before flushing.
496+
497+
The filter reads signal data through `get_property()` host calls:
498+
499+
```
500+
request.size → ingress bytes for this request
501+
response.size → egress bytes for this request
502+
request.duration → connection duration (ms) on close
503+
upstream.cluster_name → encodes httproute_name + httproute_namespace
504+
response.code → HTTP status
505+
```
506+
507+
Envoy's upstream cluster name for a route follows the pattern:
508+
`httproute/<namespace>/<name>/rule/<ordinal>` — the filter parses this string
509+
to extract route identity with no Kubernetes API calls.
510+
511+
The binary is packaged as an OCI image and wired in via `EnvoyExtensionPolicy`:
512+
513+
```yaml
514+
apiVersion: gateway.envoyproxy.io/v1alpha1
515+
kind: EnvoyExtensionPolicy
516+
metadata:
517+
name: billing-wasm
518+
spec:
519+
targetRef:
520+
group: gateway.networking.k8s.io
521+
kind: Gateway
522+
name: <gateway>
523+
wasm:
524+
- name: billing-emitter
525+
rootID: billing
526+
code:
527+
type: Image
528+
image:
529+
url: oci://ghcr.io/datum-cloud/billing-wasm:latest
530+
config: |
531+
{ "endpoint": "http://billing-usage-collector-vector.billing-system.svc.cluster.local:9880/cloudevents", "flush_interval_seconds": 60 }
532+
```
533+
534+
**Pros:**
535+
- No data loss on pod restarts — each request emits independently; there are no
536+
accumulated counters to lose.
537+
- Connection-seconds for WebSocket / long-lived connections is captured natively
538+
— `on_log()` fires on connection close with the full duration available.
539+
- Runs inside the Envoy process — no sidecar required.
540+
- No changes to the network-services-operator Go binary.
541+
- Route identity (`httproute_name`, `httproute_namespace`) is available directly
542+
from the upstream cluster name without Kubernetes API calls.
543+
544+
**Cons:**
545+
- Requires a separate Rust build pipeline and OCI image; adds a new artifact to
546+
maintain.
547+
- The filter cannot make synchronous network calls — billing dispatch goes
548+
through Envoy's async `dispatch_http_call()` to a local agent. If that agent
549+
is unavailable, events must be dropped or buffered in WASM shared memory
550+
(limited).
551+
- WASM sandbox has no filesystem access; durable buffering must be delegated to
552+
Vector or another on-node agent.
553+
- Adds a small per-request overhead (<1 ms) for the `on_log()` hook.
554+
555+
**Comparison across options:**
556+
557+
| | Option A (access logs) | Option B (Prometheus) | Option D (WASM) |
558+
|---|---|---|---|
559+
| Data loss on pod restart | near-zero | ~1 scrape interval | none |
560+
| Per-request granularity | ✓ | ✗ | ✓ |
561+
| Connection-seconds | requires separate mechanism | via `cx_length_ms_sum` | ✓ native on close |
562+
| NSO Go changes | none | none | none |
563+
| New build artifact | none | none | Rust WASM binary |
564+
| Infrastructure changes | `EnvoyProxy` CR + Vector config | none | `EnvoyExtensionPolicy` |
565+
470566
### Connection-seconds handling
471567

472-
For all options, the connection-seconds signal for WebSocket and other
473-
long-lived connections is not captured naturally by per-request telemetry. The
474-
recommended approach is to emit a connection-open and connection-close event from
475-
the network-services-operator's gateway controller (which already watches
476-
`Gateway` objects and manages their lifecycle). The duration between open and
477-
close events, reported as a sum of connection-seconds, gives the meter its
478-
signal. This is a small, localized Go change to the gateway controller.
568+
For Option A and Option B, the connection-seconds signal for WebSocket and other
569+
long-lived connections requires a separate mechanism — per-request access logs
570+
do not emit while a connection is held open, and Prometheus counters
571+
(`envoy_cluster_upstream_cx_length_ms_sum`) only update when a connection
572+
closes.
573+
574+
For **Option D (WASM)**, connection-seconds is handled natively: `on_log()`
575+
fires when a connection closes, at which point `request.duration` contains the
576+
full connection lifetime in milliseconds. No additional Go controller changes
577+
are needed.
479578

480579
### Recommendation
481580

482-
**Option A** (access log scraping via Vector) is the most consistent with the
483-
billing pipeline architecture and is recommended as the primary collection
484-
mechanism for request count, egress bytes, and ingress bytes. Connection-seconds
485-
for persistent connections is handled by a lightweight controller-side emitter
486-
(see above). This approach requires:
581+
**Option A** (access log scraping via Vector) is the recommended approach for
582+
the initial implementation. At current scale, per-request events without
583+
aggregation are sufficient — one `UsageEvent` per completed request, forwarded
584+
directly by Vector to the Ingestion Gateway. Aggregation is a pipeline-level
585+
concern; if it becomes necessary at scale it will be added as a platform
586+
capability in the billing pipeline, not as Envoy-specific logic.
587+
588+
Connection-seconds for persistent connections is handled by a lightweight
589+
controller-side emitter (see above). This approach requires:
487590

488591
1. An `EnvoyProxy` CR patch configuring structured JSON access logs.
489592
2. A Vector configuration to parse the log format and construct `UsageEvent`s.

0 commit comments

Comments
 (0)