Orleans.Lattice.Api.Telemetry
This page documents Orleans.Lattice.Api.Telemetry 9.9.0, in the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at README.md, and llms.txt lists every page.A backend-neutral telemetry facade for a Lattice cluster. It answers a small, curated set of named queries over a Prometheus-compatible metrics backend, scopes every answer to the caller's tenant on the server, and refuses anything outside the curated set.
It exists because a client head - the Explorer among them - cannot be trusted to enforce tenant scoping itself - a head that derived its own tenant filter would be asking the client to police its own access. So the facade derives the scope, the head renders whatever the server pinned, and the backend is never reachable directly.
What it is not
It is not a query proxy. A caller names a query id from a catalogue; it never supplies PromQL. There is no wire field through which query text could arrive, so the discovery surface and the execution surface cannot disagree about what is runnable.
Registration
using System;
using Microsoft.Extensions.DependencyInjection;
using Orleans.Lattice.Api.Telemetry;
var services = new ServiceCollection();
services.Configure<LatticeTelemetryOptions>(options =>
{
options.BackendAddress = new Uri("https://metrics.internal:9090");
options.AuthMode = LatticeTelemetryBackendAuthMode.Bearer;
options.Credential = new LatticeTelemetryBackendCredential { BearerToken = "backend-token" };
// Fail closed: serve only the metrics named here.
options.MetricAccess = LatticeTelemetryMetricAccessMode.DenyAllExceptAllowed;
options.AllowedMetrics.Add("orleans_lattice_shard_*");
// Refuse a window the backend would answer at ruinous cost.
options.MaxRange = TimeSpan.FromHours(24);
options.MaxStep = TimeSpan.FromMinutes(5);
});
services.AddLatticeTelemetryApi();
AddLatticeTelemetryApi() is idempotent and wires the backend client itself, so a
transport binding layered on top neither repeats nor reconfigures it.
Options
| Property | Type | Default | Meaning |
|---|---|---|---|
BackendAddress |
Uri? |
null |
The Prometheus-compatible endpoint. Must be absolute. Unset means no backend is configured, and every query reports as unoffered. |
AuthMode |
LatticeTelemetryBackendAuthMode |
None |
None, Bearer, Basic, MutualTls, or DynamicBearer (a token resolved per request through ITelemetryBackendTokenProvider). |
Credential |
LatticeTelemetryBackendCredential? |
null |
The static credential for Bearer (BearerToken), Basic (BasicUsername / BasicPassword), or MutualTls (ClientCertificate). Required for those three modes; not consulted under None or DynamicBearer. |
RequestTimeout |
TimeSpan |
30 seconds | Per-request timeout against the backend. Must be strictly positive and no longer than int.MaxValue milliseconds (about 24.8 days), the longest finite timeout HttpClient accepts. |
MaxRange |
TimeSpan |
24 hours | The widest window a range query may evaluate. |
MaxStep |
TimeSpan |
1 hour | The coarsest step a range query may request. |
MetricAccess |
LatticeTelemetryMetricAccessMode |
ReadAll |
ReadAll, or DenyAllExceptAllowed to serve only AllowedMetrics. |
AllowedMetrics |
IList<string> |
empty | The allow-list consulted under DenyAllExceptAllowed. Each entry is an exact metric name or a * wildcard pattern (for example orleans_lattice_wal_*). Matching is whole-name and case-sensitive: a wildcard entry is anchored at both ends, its * never matches a newline (so a trailing newline cannot ride past the check), and it is matched without backtracking, in time linear in the name's length. Ignored under ReadAll. |
The proxy stamps the configured backend credential on every backend request and never forwards the caller's Lattice credential to it: the caller-side grant and the backend-side credential are two independent halves of the trust boundary.
AddLatticeTelemetryApi() registers no options validation - the host owns binding
and validating the options. Register LatticeTelemetryOptionsValidator as an
IValidateOptions<LatticeTelemetryOptions> to enforce the rules above (a supplied,
absolute backend address, defined AuthMode and MetricAccess values, the
credential member each static auth mode needs, a strictly positive timeout no
longer than int.MaxValue milliseconds, strictly positive guardrails, and a
non-empty allow-list with no blank entry under DenyAllExceptAllowed) when the
options are first resolved. Under the validator an
unset BackendAddress fails that resolution rather than degrading to the empty
catalogue.
The allow-list is enforced on extracted names, not on the raw string
Under DenyAllExceptAllowed, every metric name a query will actually evaluate is
extracted from its PromQL by PromQlMetricExtractor and checked against
AllowedMetrics (each entry an exact name or a * wildcard pattern such as
orleans_lattice_wal_*).
The extractor is deliberately conservative rather than a full PromQL parser:
it recognises an identifier as a metric name only where one may legally appear -
not when followed by (, not inside a string or a numeric/duration literal, not
inside the parenthesised label list of a grouping modifier (by, without, on,
ignoring, group_left, group_right), and not inside a {...} label matcher
unless it is the reserved __name__ label. A
keyword is skipped only where Prometheus reads it as one: Prometheus also accepts
the aggregation operators, and / or / unless, by, without, offset,
start, and end as a bare metric name wherever an operand is expected, so
up or min evaluates the metric min, and the extractor reports it as a
referenced name. Erring towards extracting more,
rather than fewer, names is what keeps it fail-closed: a name it cannot resolve
is refused, not admitted.
These rules are load-bearing, because the extractor and the backend must agree about what will be evaluated:
- a
#comment is discarded as whitespace exactly as Prometheus's own lexer discards it, before any string or brace state is entered. An earlier version had no rule for#, so a quote opened inside a comment was scanned as a string opener and swallowed the rest of the query - hiding a metric name from the allow-list that the backend then evaluated anyway; - a grouping modifier's label list is skipped with the same comment and string
awareness, so an unmatched
(inside a comment (sum by (job # () or inside a quoted label (sum by ("(")) cannot stretch the skip over the rest of the query and hide the aggregated metric selector from the allow-list; - an exact
__name__="up"matcher contributes its value as a referenced name, while a regex (__name__=~) or negative (__name__!=,__name__!~) matcher cannot be reduced to a fixed set, so it setsPromQlMetricReferences.HasUnresolvableNameMatcherand the gate fails closed. That shuts the bypass where a caller named a denied series only through__name__; - a top-level
{...}label selector - terminated or not - that is neither anchored to a metric name nor pinned by an exact__name__matcher, such as the right-hand side ofup or {job="api"}, selects series across every metric name, so it setsPromQlMetricReferences.HasUnconstrainedSelectorand the gate fails closed even when the expression also names an admitted metric. A query from which no metric name can be extracted at all is refused too.
Facade surface
ILatticeTelemetry (implemented by the public LatticeTelemetry, which AddLatticeTelemetryApi() registers) has two methods:
| Method | Signature |
|---|---|
GetCatalogAsync |
Task<TelemetryQueryCatalog> GetCatalogAsync(CancellationToken cancellationToken = default) |
QueryAsync |
Task<TelemetryQueryResponse> QueryAsync(TelemetryQueryRequest request, CancellationToken cancellationToken = default) |
The backend proxy beneath the facade
AddLatticeTelemetryApi() builds on the public AddLatticeTelemetryBackend(), which a binding that needs the
backend without the curated facade - the MCP telemetry tool group - calls directly. It is idempotent and
registers:
TelemetryMetricAccessPolicy- theMetricAccess/AllowedMetricsposture, compiled once from the bound options (IsReadAll, andIsAdmitted(metric)for one exposition name).IPrometheusQueryClient- the read-only backend client: instant query, range query, metric-name listing, and metric metadata. The defaultPrometheusQueryClientruns over anHttpClientbound toBackendAddressandRequestTimeoutand stamps the configured backend credential; it is registered only when the host has not registered its ownIPrometheusQueryClientfirst.
Public helpers let such a binding enforce the same rules the facade applies:
TelemetryAccessAuthorizer.AuthorizeClusterTelemetryAsync runs the cluster-wide Telemetry capability check
the facade opens every query with, throwing LatticeAuthorizationDeniedException when it is not granted;
TelemetryQueryAuthorizer.TryAuthorizeQuery(policy, query, out denialMessage) gates a PromQL expression against
the allow-list with the extractor rules above (admitting without scanning under ReadAll); and
TelemetryRangeGuardrails.TryValidateRange(options, start, end, step, out violationMessage) applies the
deployment-wide MaxRange / MaxStep guardrails. AddLatticeTelemetryBackend() does not register the
capability check, so a binding that evaluates caller-supplied PromQL registers TelemetryAccessAuthorizer
itself and runs it before any backend call, as the MCP tool group does. The allow-list only narrows what an
authorized caller may read and is no substitute for the capability: its ReadAll default admits every metric.
The curated catalogue
LatticeTelemetryQueries.Definitions is the complete built-in catalogue: fifteen
server-authored entries at catalogue revision LatticeTelemetryQueries.Version
(1), in ascending query-id order. Each entry's TelemetryQueryDescriptor names
the OpenTelemetry instruments it reads (for example orleans.lattice.shard.reads);
its PromQL template reads the Prometheus exposition names the backend holds when
the host publishes through the OpenTelemetry Prometheus exporter - orleans_lattice_*,
with the _total suffix on counters and the unit word on histogram buckets
(_milliseconds_bucket).
| Query id | Kind | Unit | Prometheus series read |
|---|---|---|---|
tenant.quota.byte_utilization |
Instant | 1 |
orleans_lattice_tenancy_usage_bytes, orleans_lattice_tenancy_quota_bytes |
tenant.usage.bytes |
Instant | By |
orleans_lattice_tenancy_usage_bytes |
tree.admission.utilization |
Instant | 1 |
orleans_lattice_admission_utilization |
tree.atomic_write.outcome_rate |
Range | {saga}/s |
orleans_lattice_atomic_write_completed_total |
tree.cache.hit_ratio |
Range | 1 |
orleans_lattice_cache_hits_total, orleans_lattice_cache_misses_total |
tree.read.operation_rate |
Range | {op}/s |
orleans_lattice_shard_reads_total |
tree.scan.latency_p95 |
Range | ms |
orleans_lattice_leaf_scan_duration_milliseconds_bucket |
tree.storage.bytes |
Instant | By |
orleans_lattice_storage_total_bytes |
tree.storage.bytes_trend |
Range | By |
orleans_lattice_storage_total_bytes |
tree.tombstones.created_rate |
Range | {tombstone}/s |
orleans_lattice_leaf_tombstones_created_total |
tree.tombstones.reaped_rate |
Range | {tombstone}/s |
orleans_lattice_leaf_tombstones_reaped_total |
tree.wal.saturation_state |
Instant | 1 |
orleans_lattice_wal_saturation_state |
tree.write.latency_p95 |
Range | ms |
orleans_lattice_leaf_write_duration_milliseconds_bucket |
tree.write.operation_rate |
Range | {op}/s |
orleans_lattice_shard_writes_total |
tree.write.record_rate |
Range | {record}/s |
orleans_lattice_shard_records_written_total |
The two tenant.* entries read the tenancy add-on's orleans.lattice.tenancy
meter; on a cluster without it they evaluate cleanly and return no series. Every
Range entry accepts a time range, a step, and a tree filter;
tree.storage.bytes, tree.admission.utilization, and
tree.wal.saturation_state accept a tree filter; the tenant.* entries take no
parameters. The tree filter matches the metrics' tree dimension. For every
series these entries read that is the logical tree id, which stays the same
across a resize, a shadow-cutover restore or a schema remediation (see The tree
dimension across aliasing),
so a query filtered on a tree keeps returning that tree's series after its data
moves to a new physical copy. That includes the storage-usage and admission gauges
behind tree.storage.bytes, tree.storage.bytes_trend and
tree.admission.utilization: the background poller and the cluster storage
roll-up still walk the physical copy's registered id, but the copy's aggregator
publishes under the logical id too, so an unfiltered answer never lists the copy
as a further tree. A tree's WAL-only and deep storage-usage aggregators are placed
independently, so more than one silo can export the same tree's storage series;
tree.storage.bytes and tree.storage.bytes_trend therefore take max by (tree)
across silos rather than summing, which would double-count that tree. The filter is matched verbatim: the facade
does not compose an unqualified name into the caller's tenant namespace, so on a tenancy cluster a
tenant's tree is filtered by its full t/{tenant}/{name} id, the value its tree
label carries. Each entry also declares TelemetryQueryBounds: a requested step is
clamped into the entry's step budget, but a window outside the entry's bounds -
or outside the deployment-wide MaxRange / MaxStep guardrails - is refused
with TelemetryQueryBoundsException rather than silently narrowed.
Under DenyAllExceptAllowed, each entry's footprint - the exposition names its
template reads - is checked against AllowedMetrics once, when the catalogue is
built. An entry whose series are not all admitted is left out of the catalogue and
is unreachable by id, indistinguishable from an unknown id, so allow-list the
exposition names above (for example orleans_lattice_shard_*), not the dotted
instrument names. AddLatticeTelemetryApi() registers the built-in
LatticeTelemetryQueryCatalog with TryAdd, so a host that curates its own
queries registers a catalogue built from its own TelemetryQueryDefinition set
first.
GetCatalogAsync degrades; it does not fail
Discovery never surfaces a backend fault. An unconfigured backend, and a caller entitled to no query, both receive TelemetryQueryCatalog.Empty rather than an exception, so a client renders no panels instead of erroring - and the two cases stay indistinguishable, so a refusal leaks nothing about the deployment.
This is load-bearing for callers, and changing it would break them silently. A client may therefore treat a transport-level Unavailable from GetCatalog as the surface is unreachable, because a mere metrics-store outage cannot produce one. The Explorer relies on exactly that: its Telemetry area hides itself whenever the catalogue read fails - a refused caller, a cluster that does not serve telemetry, or one it cannot reach - while a metrics-backend outage reaches it only through a query, as a retryable error on the chart that asked.
If this method were ever changed so that a backend fault could escape it, the Explorer would begin hiding its Telemetry area during ordinary metrics outages. No test in the client would catch it, because the client's tests exercise its own classification rather than this contract. Treat the degradation as part of the published behaviour of ILatticeTelemetry, not as an implementation detail of the current backend.
Tenant scope is derived, never accepted
TelemetryTenantScopeResolver decides the effective scope from the caller's own
identity. A request may state a preference; the resolver may refuse it. The
response's Scope (a TelemetryTenantScope) reports what actually happened:
| Field | Meaning |
|---|---|
RequestedVisibility |
What the caller asked for. |
EffectiveVisibility |
What the server granted. |
TenantId |
The tenant the answer is pinned to. |
WasDowngraded |
The request was narrowed. A UI must say so rather than silently showing less. |
IsCrossTenant |
The answer spans tenants, which only a platform operator can obtain. |
A caller-supplied tenant id is never trusted. A non-operator asking for a different tenant is pinned to its own, and the answer is marked downgraded.
Platform-operator validation asks the registered access gate for Admin on the
reserved authorization policy tree. Without the authorization add-on, the core
no-op gate that AddLattice registers allows that for every caller, so a widening
request is honoured; a host with no access gate registered at all never validates
one, so the request degrades.
Failure surface
Three exceptions, all declared in Orleans.Lattice.Api.Abstractions so every
transport binding can name them without referencing this package:
| Exception | Means |
|---|---|
TelemetryQueryNotFoundException |
The query id is unknown or not offered by this deployment. The two are deliberately indistinguishable, so a caller learns nothing about the deployment from a refusal. |
TelemetryQueryBoundsException |
A well-formed request whose window the entry's declared bounds or the deployment-wide MaxRange / MaxStep guardrails refuse - descending, too long, starting too far back, yielding too many points, or with a step above MaxStep (on an entry that declares no step ceiling, a step too large for the window arithmetic saturates and is refused by the MaxStep guardrail rather than overflowing). Its Violation carries the typed TelemetryBoundsViolation reason. |
TelemetryBackendException |
The backend was unreachable, timed out, or answered unusably. Not the caller's fault. |
QueryAsync can also refuse the caller. Before anything else it throws
LatticeAuthorizationDeniedException when a real access gate is registered and
the caller lacks the cluster-wide Telemetry capability, checked by the public
TelemetryAccessAuthorizer (discovery instead degrades to the empty catalogue);
once the query id and the entry's bounds pass, it throws LatticeTenantAccessDeniedException
when the caller cannot be attributed to any tenant, and ArgumentException when
the tree filter of an entry that accepts one contains a control character. The two Lattice refusals
(LatticeAuthorizationDeniedException and LatticeTenantAccessDeniedException) are core Orleans.Lattice
types, so a binding can name them too.
A binding must not forward TelemetryBackendException.Message to a remote
caller. It embeds the underlying transport fault, which routinely carries the
backend host and port. Log it, and answer with a fixed reason naming only what
the caller already supplied.
See also
Orleans.Lattice.Api.Telemetry.Grpc- the gRPC binding that exposes this facade to a remote head.