Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .spelling
Original file line number Diff line number Diff line change
Expand Up @@ -631,3 +631,8 @@ ASTs
DAGs
representable
rc
rescale
rescaled
rescaling
rescales
configurator
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ packaged contents.

Pull request titles must follow [Conventional Commits](https://www.conventionalcommits.org/) naming, e.g. `feat(bytesbuf): add new metric` or `fix(cachet): correct eviction logic`.

Pull request descriptions must be high-level and describe only the content the PR delivers and the problem it fixes. Do not narrate the history of how the PR came to be, or how it was created, designed, reviewed, or tested.

## Feature-gated Doctests

Doctests that reference items behind a Cargo feature must compile both with and without that feature; wrap their bodies in hidden `#[cfg(...)]` shims. See [AGENTS-feature-gated-doctests.md](AGENTS-feature-gated-doctests.md).
Expand Down
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions crates/opentelemetry_rescaled/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

[package]
name = "opentelemetry_rescaled"
description = "Wraps an OpenTelemetry meter provider to emit rescaled side-by-side copies of selected instruments."
version = "0.1.0"
readme = "README.md"
keywords = ["opentelemetry", "metrics", "telemetry", "rescale"]
categories = ["development-tools::debugging"]
edition = { workspace = true }
rust-version = { workspace = true }
authors = { workspace = true }
license = { workspace = true }
homepage = { workspace = true }
include = { workspace = true }
repository = "https://github.com/microsoft/oxidizer/tree/main/crates/opentelemetry_rescaled"

[package.metadata.cargo_check_external_types]
allowed_external_types = [
"opentelemetry::metrics::meter::Meter",
"opentelemetry::metrics::meter::MeterProvider",
]

[package.metadata.docs.rs]
all-features = true

[features]
default = []

[lints]
workspace = true

[dependencies]
foldhash = { workspace = true }
opentelemetry = { workspace = true, features = ["metrics"] }

[dev-dependencies]
opentelemetry_sdk = { workspace = true, features = ["metrics", "testing"] }
64 changes: 64 additions & 0 deletions crates/opentelemetry_rescaled/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<div align="center">
<img src="./logo.png" alt="OpenTelemetry Rescaled Logo" width="96">

# OpenTelemetry Rescaled

[![crate.io](https://img.shields.io/crates/v/opentelemetry_rescaled.svg)](https://crates.io/crates/opentelemetry_rescaled)
[![docs.rs](https://docs.rs/opentelemetry_rescaled/badge.svg)](https://docs.rs/opentelemetry_rescaled)
[![MSRV](https://img.shields.io/crates/msrv/opentelemetry_rescaled)](https://crates.io/crates/opentelemetry_rescaled)
[![CI](https://github.com/microsoft/oxidizer/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/microsoft/oxidizer/actions/workflows/main.yml)
[![Coverage](https://codecov.io/gh/microsoft/oxidizer/graph/badge.svg?token=FCUG0EL5TI)](https://codecov.io/gh/microsoft/oxidizer)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE)
<a href="../.."><img src="../../logo.svg" alt="This crate was developed as part of the Oxidizer project" width="20"></a>

</div>

Wraps an inner OpenTelemetry meter provider to transparently emit *rescaled* side-by-side copies of selected instruments.

For a chosen instrument in a chosen instrumentation scope, this layer creates
a second instrument whose measurements are the original values multiplied by a
fixed factor. For example, a `http.client.request.duration` instrument that
records seconds can gain a `http.client.request.duration.millis` sidecar that
records the same measurements multiplied by `1000.0`.

The rescaling is invisible to instrument users — they interact only with their
original instrument — and the inner provider simply sees two independently
registered instruments.

## Quick start

```rust
use opentelemetry::metrics::MeterProvider;
use opentelemetry_rescaled::RescaledMetrics;

// Any `MeterProvider` works as the inner provider.
let inner = opentelemetry::metrics::noop::NoopMeterProvider::new();

let outer = RescaledMetrics::builder(inner)
.scope("my_scope_name", |scope| {
// source name, target name, target unit (mandatory), factor
scope.rescale(
"http.client.request.duration",
"http.client.request.duration.millis",
"ms",
1000.0,
);
})
.build();

// `outer` is itself a `MeterProvider`.
let meter = outer.meter("my_scope_name");
let histogram = meter.f64_histogram("http.client.request.duration").build();
histogram.record(1.5, &[]); // recorded as 1.5 s and, on the sidecar, 1500 ms
```

See [`docs/DESIGN.md`][__link0]
for the architecture and the resolved design decisions.


<hr/>
<sub>
This crate was developed as part of <a href="../..">The Oxidizer Project</a>. Browse this crate's <a href="https://github.com/microsoft/oxidizer/tree/main/crates/opentelemetry_rescaled">source code</a>.
</sub>

[__link0]: https://github.com/microsoft/oxidizer/blob/main/crates/opentelemetry_rescaled/docs/DESIGN.md
178 changes: 178 additions & 0 deletions crates/opentelemetry_rescaled/docs/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# OpenTelemetry Rescaled — Architecture & Design

This document describes the intended design of the crate so it can be reviewed
before implementation. For the user-facing summary see the crate-level rustdoc
(`src/lib.rs`).

## Goal

Provide a wrapping layer around an existing OpenTelemetry meter provider that,
for specific instruments in specific instrumentation scopes, transparently emits
a **rescaled sidecar** of each such instrument: a second instrument carrying the
same measurements multiplied by a fixed factor.

The canonical motivating case: an instrument records a duration in seconds
(`http.client.request.duration`) and a downstream system expects milliseconds.
Rather than change the instrumented code, the operator configures a sidecar
`http.client.request.duration.millis` with factor `1000.0`. Both instruments are
exported independently by the underlying SDK.

Scope: **metrics only**, all instrument kinds (synchronous and observable, for
every value type the API supports).

## Tenets

- **Transparent to the measurer.** Code recording measurements sees exactly one
instrument — its original. It has no knowledge that a sidecar exists, and its
hot path is unchanged apart from the fan-out described below.
- **Transparent to the inner provider.** The wrapped SDK sees two ordinary,
independently registered instruments. No SDK internals are touched; the layer
composes purely through the public OpenTelemetry API surface.
- **Configured once, at build time.** The set of scopes, source instruments,
targets, units, and factors is fixed when the provider is built and never
changes for the life of the provider.
- **Zero cost where unused.** Scopes and instruments that are not configured for
rescaling incur no wrapping and delegate directly to the inner provider.
- **Fail fast on nonsense.** A configuration that cannot produce a meaningful
sidecar (see [Configuration model](#configuration-model)) panics at build time
rather than silently emitting garbage.

## Usage shape

```rust,ignore
let inner = build_inner_meter_provider();

let outer = RescaledMetrics::builder(inner)
.scope("my_scope_name", |scope| {
// source name, target name, target unit (mandatory), factor
scope.rescale("http.client.request.duration",
"http.client.request.duration.millis",
"ms",
1000.0);
})
.build();

// `outer` is itself a `MeterProvider`; hand it wherever the inner one went
// (e.g. to instrumented libraries, or `global::set_meter_provider`).
```

## How interception works

OpenTelemetry's Rust metrics API is layered as
`MeterProvider` → `Meter` → typed instrument builders → concrete instruments, and
a `Meter` is nothing more than a handle to an `InstrumentProvider`. This layering
is the seam the crate exploits: it substitutes its own `MeterProvider` and
`InstrumentProvider` while delegating all real work to the inner ones.

### The provider wrapper

`RescaledMetrics` implements `MeterProvider`. When a scoped meter is requested it
resolves the inner scoped meter and then decides, by matching the scope against
the configuration:

- **Unconfigured scope** → return the inner meter unchanged (no wrapping, no
overhead).
- **Configured scope** → return a meter backed by a *rescaling instrument
provider* that holds the resolved inner meter plus that scope's rescale rules.

### Synchronous instruments — fan-out

A synchronous instrument (`Counter`, `UpDownCounter`, `Gauge`, `Histogram`)
delegates every measurement to an inner `SyncInstrument`. The rescaling provider
constructs, for a configured source instrument, **two** inner instruments — the
original and the sidecar — and returns to the caller a single instrument whose
backing `SyncInstrument` is a small **fan-out**:

```text
caller.add(v, attrs)
fan-out.measure(v, attrs)
├─────────────► original.measure(v, attrs)
└─────────────► sidecar .measure(scale(v), attrs)
```

The caller holds one handle; each recorded measurement reaches both inner
instruments. Because the fan-out records through the ordinary instrument handles,
no SDK internals are involved.

### Observable instruments — dual registration

Observable instruments (`ObservableCounter`, `ObservableUpDownCounter`,
`ObservableGauge`) carry user callbacks that the SDK invokes at collection time,
passing an observer bound to one specific instrument. There is no public
multi-instrument callback, so the layer registers the source instrument's
callbacks **twice** on the inner meter:

- once on the original instrument, invoking the callbacks with the observer as-is;
- once on the sidecar instrument, invoking the same callbacks through a **scaling
observer** that multiplies every observed value before forwarding it.

The user's callbacks are shared (behind an `Arc`) between the two registrations.
A consequence is that the callbacks run **twice per collection**. This is an
accepted cost: callbacks are expected to be cheap and idempotent, and a
replay-once cache would introduce its own staleness and correctness hazards for
no meaningful benefit.

## Value rescaling

A rescale factor is always a plain multiplicative `f64` — the only transform the
crate needs. Applying it depends on the instrument's value type:

- **`f64` instruments** multiply directly.
- **`u64`/`i64` instruments** multiply in `f64`, **round** to the nearest
integer, and **saturate** at the type's bounds. Saturation rather than
wrap/overflow keeps a runaway sidecar bounded and obvious instead of silently
corrupt.

Histograms need one extra step: the sidecar's **bucket boundaries** are scaled by
the same factor as the values, so the buckets stay meaningful. When the source
instrument supplies explicit boundaries the layer scales them; when it relies on
the SDK's default boundaries there is nothing to scale (the defaults are not
visible through the API), so the sidecar simply keeps the defaults. That yields
an obviously wrong bucketing that prompts the operator to configure real
boundaries — acceptable because default boundaries are not expected in real
production use.

## Configuration model

Configuration is a map from scope to a set of rescale rules. Each rule maps a
source instrument name to one or more targets; a target carries its **name**, its
**unit** (mandatory), and its **factor**. A single source may therefore feed
several sidecars. The sidecar inherits the source's description but **must** be
given a new unit at configuration time — rescaling almost always changes the unit
(`s` → `ms`), and inheriting the stale one would be misleading.

Matching a source instrument is by name within its scope, and the same rule
applies to whichever value type the caller builds under that name.

Scopes are matched **by name only** for now. If several instrumentation scopes
share a name, the rules apply to all of them. The configuration type is shaped so
that stricter matching (e.g. a future `scope_exact(...)` keyed on the full
instrumentation scope — name, version, schema URL, attributes) can be added later
without reworking the model.

Duplicate target names across the process are **not** the crate's concern:
duplicate instrument registration is always possible in OpenTelemetry, and it is
the user's job to avoid collisions and the SDK's job to cope with them.

Validation happens at build time, and a configuration that cannot yield a
meaningful sidecar **panics** — for example a factor that is `0.0`, `NaN`,
infinite, or negative, a rule whose source equals its target, or duplicate
targets within a scope.

## Relationship to the wider system

The crate depends only on the `opentelemetry` API crate — not on
`opentelemetry_sdk` — so it composes with any conforming provider, including the
SDK provider, the no-op provider, and other wrappers. It is itself a
`MeterProvider`, so wrappers may be stacked.

The inner provider is taken **by value** but immediately **type-erased** behind a
trait object, so `RescaledMetrics` carries no generic parameter for it and does
not leak the inner provider's concrete type into callers' signatures.

The public API surface exposes only the `opentelemetry` provider types
(`MeterProvider` and `Meter`, the latter through the `MeterProvider` trait
impl); these are enumerated in the crate's `allowed_external_types` allowlist, as
sibling crates do.
Loading
Loading