|
| 1 | +# Content-Encoding Mismatch Lab |
| 2 | + |
| 3 | +The bytes say gzip. The header says nothing. |
| 4 | + |
| 5 | +This interactive lab models an OTLP/HTTP proxy that mutates a compressed body |
| 6 | +and its `Content-Encoding` metadata independently. The edge forwards every |
| 7 | +request, but the collector chooses its decoder from a contract that no longer |
| 8 | +describes the bytes. |
| 9 | + |
| 10 | +The default scenario sends 240 trace batches per minute with 200 spans in each. |
| 11 | +A header-stripping proxy is rolled out to 55% of traffic: |
| 12 | + |
| 13 | +| Signal | Edge view | Collector view | |
| 14 | +| --- | ---: | ---: | |
| 15 | +| Batches forwarded / minute | 240 | — | |
| 16 | +| Batches decoded / minute | — | 108 | |
| 17 | +| Apparent delivery | 100% | — | |
| 18 | +| Effective delivery | — | 45% | |
| 19 | +| Spans rejected / minute | 0 | 26,400 | |
| 20 | + |
| 21 | +Every result is computed by the compiled Vala model in |
| 22 | +[`model/encoding_contract.vala`](model/encoding_contract.vala). The browser |
| 23 | +contains no duplicate simulation math. |
| 24 | + |
| 25 | +## Why this happens |
| 26 | + |
| 27 | +OTLP/HTTP permits clients to gzip the request body. When they do, the stable |
| 28 | +OTLP specification requires the request to include |
| 29 | +`Content-Encoding: gzip`. A collector uses that metadata to select the |
| 30 | +decompression step before decoding the OTLP Protobuf payload. |
| 31 | + |
| 32 | +Two proxy transformations break the contract: |
| 33 | + |
| 34 | +```text |
| 35 | +header stripped |
| 36 | +Content-Encoding: (absent) bytes: 1f 8b 08 00… |
| 37 | +collector selects Protobuf result: invalid wire type |
| 38 | +
|
| 39 | +header stale |
| 40 | +Content-Encoding: gzip bytes: 0a 8f 9c 06… |
| 41 | +collector selects gzip result: gzip magic missing |
| 42 | +``` |
| 43 | + |
| 44 | +Neither case is a transient outage. The OTLP specification classifies |
| 45 | +permanently undecodable input as bad data, for which the server returns |
| 46 | +`HTTP 400 Bad Request` and the client must not retry. |
| 47 | + |
| 48 | +Primary references: |
| 49 | + |
| 50 | +- [OTLP specification](https://opentelemetry.io/docs/specs/otlp/) |
| 51 | +- [OTLP exporter compression configuration](https://opentelemetry.io/docs/specs/otel/protocol/exporter/) |
| 52 | +- [Vala compiler documentation](https://docs.vala.dev/tutorials/programming-language/main/07-00-tools/07-01-valac.html) |
| 53 | + |
| 54 | +## The repairs |
| 55 | + |
| 56 | +The controls demonstrate two sound proxy contracts: |
| 57 | + |
| 58 | +1. **Pass through** — forward `Content-Encoding: gzip` and the gzipped bytes |
| 59 | + without changing either. |
| 60 | +2. **Normalize** — decompress the body and remove `Content-Encoding`, leaving |
| 61 | + ordinary Protobuf bytes for the collector. |
| 62 | + |
| 63 | +The invariant is simple: the header and body must be transformed together. |
| 64 | + |
| 65 | +## What telemetry.sh reveals |
| 66 | + |
| 67 | +A proxy request counter reports perfect delivery in both broken modes. The lab |
| 68 | +emits the signals needed to reconcile transport hops: |
| 69 | + |
| 70 | +- `lab.otlp.apparent_delivery{hop="proxy"}` counts successfully forwarded |
| 71 | + requests. |
| 72 | +- `lab.otlp.effective_delivery{hop="collector"}` counts decoded requests. |
| 73 | +- `lab.otlp.decode_failures{error.type=...}` separates missing-header and |
| 74 | + stale-header failures. |
| 75 | +- `lab.otlp.rejected_spans{error.type=...}` quantifies permanent loss. |
| 76 | +- `lab.otlp.wire_bytes{representation=...}` shows when proxy decompression |
| 77 | + silently expands traffic. |
| 78 | +- a shared trace ID connects the forwarding proxy span to the collector decode |
| 79 | + log, including `Content-Encoding`, first bytes, and selected decoder. |
| 80 | + |
| 81 | +Try the investigation queries in the UI: |
| 82 | + |
| 83 | +```text |
| 84 | +metrics | where name in ("lab.otlp.apparent_delivery", "lab.otlp.effective_delivery") | chart value by hop |
| 85 | +logs | where error.type in ("gzip_header_missing", "gzip_magic_missing") | summarize count() by proxy.version |
| 86 | +traces | where http.route == "/v1/traces" | project trace_id, proxy.content_encoding, collector.decoder, otel.status_code |
| 87 | +``` |
| 88 | + |
| 89 | +This is the power of correlated telemetry: two individually plausible views |
| 90 | +become an obvious contradiction when joined. |
| 91 | + |
| 92 | +## Run locally |
| 93 | + |
| 94 | +Install Vala 0.56, a C compiler, Python 3, and Node.js, then: |
| 95 | + |
| 96 | +```bash |
| 97 | +make check |
| 98 | +make run |
| 99 | +``` |
| 100 | + |
| 101 | +Open <http://localhost:8080>. |
| 102 | + |
| 103 | +The live endpoints are: |
| 104 | + |
| 105 | +```text |
| 106 | +GET /healthz |
| 107 | +GET /api/simulate |
| 108 | +GET /api/telemetry |
| 109 | +``` |
| 110 | + |
| 111 | +Example repairs: |
| 112 | + |
| 113 | +```bash |
| 114 | +curl 'http://localhost:8080/api/simulate?mode=passthrough' |
| 115 | +curl 'http://localhost:8080/api/simulate?mode=normalize' |
| 116 | +``` |
| 117 | + |
| 118 | +## Run with Docker |
| 119 | + |
| 120 | +```bash |
| 121 | +docker compose up --build |
| 122 | +``` |
| 123 | + |
| 124 | +The multi-stage image compiles the Vala model and runs the full test suite, |
| 125 | +then copies only the model, Python service, and static UI into an unprivileged |
| 126 | +runtime image. |
| 127 | + |
| 128 | +## Repository layout |
| 129 | + |
| 130 | +```text |
| 131 | +. |
| 132 | +├── model/encoding_contract.vala # header/body contract model |
| 133 | +├── service/server.py # static site, API, correlated evidence |
| 134 | +├── tests/ # model, service, and UI contracts |
| 135 | +├── public/ # dependency-free packet workbench |
| 136 | +├── telemetry/signals.md # evidence catalog |
| 137 | +├── Dockerfile |
| 138 | +└── compose.yaml |
| 139 | +``` |
| 140 | + |
| 141 | +## License |
| 142 | + |
| 143 | +MIT |
0 commit comments