One metric name. Two rulers. Four different versions of reality.
This interactive lab shows how compatible but conflicting metric units corrupt an aggregate. The default incident has eight producers declaring duration unit s; one actually reports milliseconds. Only 12.5% of the batch drifts, yet a name-only merge inflates fleet p95 from 132 milliseconds to 121.5 seconds—roughly 920×.
The measurement contract is an executable Protocol Buffers schema. A dependency-free Node.js service generates text-format messages, compiles them to protobuf wire bytes with protoc, decodes the binary, applies four policies, renders the result, and exposes correlated OTLP-shaped metrics, logs, and traces.
In the OpenTelemetry metrics data model, unit is an identifying property of a metric stream. Conflicting units such as ms and s are a semantic error. A producer may convert compatible units; otherwise it should report the conflict and pass both streams through. See the OpenTelemetry metrics data model.
OpenTelemetry’s general metric conventions say duration instruments should use seconds. Prometheus likewise recommends base units and requires a metric to represent one unit rather than mixing seconds and milliseconds. See the OpenTelemetry metric semantic conventions and Prometheus metric naming practices.
The lab compares four policies:
| Policy | Default result | Failure mode |
|---|---|---|
| Name-only merge | 121.5 s |
The fleet appears to have a massive latency incident |
| Plausibility filter | 0.132 s |
The affected producer silently disappears; coverage falls to 87.5% |
| Split by resource | eight panels | The broken source is visible, but the fleet view fragments |
| Normalize + signal | 0.132 s + alert |
The latency is corrected and the unit conflict remains observable |
The lab emits the evidence needed to separate workload latency from measurement integrity:
lab.http.server.request.duration.p95shows each policy’s displayed result;telemetry.metric.unit_conflict.countidentifies conflicting samples;telemetry.input.coverageshows which policies remove data;telemetry.unit.scale_factordistinguishes a 1,000× error from a 1,000,000× error;- warning and error logs preserve declared unit, actual unit, producer, and scale;
- query spans record policy, coverage, conflict count, and dashboard state.
The operational pattern is:
- make the producer’s source unit explicit at a trusted boundary;
- normalize compatible values into the stream’s canonical unit;
- emit a conflict signal rather than silently repairing or discarding the evidence.
Requirements:
- Protocol Buffers compiler (
protoc) - GNU Make
- Node.js 20 or newer
npm ci
make check
make runOpen http://127.0.0.1:3000.
Useful endpoints:
GET /api/simulate
GET /api/telemetry
GET /healthz
Every UI control is also a query parameter:
/api/simulate?badUnit=MICROSECOND&producers=12&samples=30&driftProducer=9&driftRate=60&baselineMs=180&thresholdMs=500
docker compose up --buildThen open http://127.0.0.1:3000.
model/measurement.proto defines the complete wire contract:
message Sample {
string producer = 1;
string metric_name = 2;
double value = 3;
string declared_unit = 4;
DurationUnit actual_unit = 5;
uint32 sequence = 6;
uint64 timestamp_unix_nano = 7;
}The service creates text protobuf, then performs a real wire round trip:
protoc --proto_path=model --encode=unitlab.Batch model/measurement.proto
protoc --proto_path=model --decode=unitlab.Batch model/measurement.protoThe aggregation code operates only on the decoded records. The browser does not reimplement the protobuf contract or fabricate policy outputs.
browser controls
│
▼
Node.js service ── textproto ──▶ protoc encode ──▶ wire bytes
▲ │
└── policies + OTLP evidence ◀── decoded batch ◀┘
No database, framework, credentials, CDN, or third-party JavaScript dependency is required.
This is a deterministic educational simulation. It sends no external traffic and exports no telemetry.