Observability
DotNet Query emits distributed traces, metrics, and structured log messages out of the box. It uses only BCL APIs — System.Diagnostics.ActivitySource, System.Diagnostics.Metrics.Meter, and Microsoft.Extensions.Logging.ILogger — so no OpenTelemetry package is required in the library itself. Consumers wire up collection on their side and the standard hooks are picked up automatically.
How It Works
All telemetry flows through a single public entry point:
// DotNetQuery.Core.Observability
public static class QueryTelemetry
{
public const string SourceName = "DotNetQuery";
public static readonly ActivitySource ActivitySource = new(SourceName);
public static readonly Meter Meter = new(SourceName);
}
QueryTelemetry.SourceName ("DotNetQuery") is the name you use when subscribing to traces or metrics in your app.
Enabling Logging
Pass a logger when creating the client. With DI the ILoggerFactory is resolved automatically:
// DI (recommended) — no extra configuration needed
builder.Services.AddDotNetQuery();
Without DI, pass a logger to the factory:
ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddConsole());
ILogger logger = loggerFactory.CreateLogger(QueryTelemetry.SourceName);
IQueryClient client = QueryClientFactory.Create(new QueryClientOptions(), logger: logger);
Enabling OpenTelemetry
Add the OpenTelemetry packages to your app project (not to the library):
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.Console # or any other exporter
Then subscribe to the "DotNetQuery" source in Program.cs:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddSource(QueryTelemetry.SourceName)
.AddConsoleExporter())
.WithMetrics(metrics => metrics
.AddMeter(QueryTelemetry.SourceName)
.AddConsoleExporter());
Any OpenTelemetry-compatible exporter works — Jaeger, Zipkin, OTLP, Prometheus, Azure Monitor, etc.
Naming Queries and Mutations
Set Name on QueryOptions<TArgs, TData> or MutationOptions<TArgs, TData> to control the low-cardinality
identifier used to tag metrics:
var options = new QueryOptions<int, User>
{
Name = "users", // shows up as the query.name tag on metrics
KeyFactory = id => QueryKey.From("users", id),
Fetcher = (id, ct) => userApi.GetAsync(id, ct),
};
When Name is not set, DotNet Query falls back to the first part of the derived QueryKey
(e.g. "users" for a key built as QueryKey.From("users", id)). Mutations without a Name
fall back to typeof(TArgs).Name.
Traces
DotNet Query creates one activity span per operation:
| Span name | When |
|---|---|
query.fetch |
Every time a query fetches data |
mutation.execute |
Every time a mutation runs |
Query fetch span
The query.fetch span carries the full identity of the fetch — traces are not subject to the
cardinality limits that apply to metrics, so the complete QueryKey is always included:
| Tag | Value |
|---|---|
query.key |
The string representation of the QueryKey (e.g. users:42) |
query.name |
The low-cardinality metric name — the same value metrics are tagged with, for trace↔metric correlation |
trigger |
What caused the fetch: manual, invalidate, interval, stale, or prefetch |
direction |
Infinite queries only: refetch_all, next, or previous |
query.pages |
Infinite queries only: number of pages fetched (1 for next/previous, page count for refetch_all) |
attempts |
Number of attempts made by the configured IRetryHandler (1 when no retry occurred) |
otel.status_code |
Ok on success, Error on failure or cancellation |
error.type |
Exception type name (only on failure) |
A retry activity event is added for each attempt beyond the first.
Infinite queries emit the same query.fetch span as regular queries. A refetch_all re-fetches every
currently loaded page under a single span, so its attempts counts retries across all of those page
fetches — it is 1 when every page succeeded first try.
Mutation execute span
| Tag | Value |
|---|---|
mutation.name |
The mutation's Name, or typeof(TArgs).Name when unset |
attempts |
Number of attempts made by the configured IRetryHandler |
otel.status_code |
Ok on success, Error on failure or cancellation |
error.type |
Exception type name (only on failure) |
Metrics
All metrics use the "DotNetQuery" meter name. Attach a tag filter in your metrics pipeline if needed.
| Instrument | Type | Unit | Description |
|---|---|---|---|
dotnetquery.query.duration |
Histogram | s | Duration of each query fetch operation |
dotnetquery.query.active |
UpDownCounter | — | Number of query fetch operations currently in flight |
dotnetquery.query.retries |
Counter | — | Retry attempts made by query fetches beyond the first |
dotnetquery.cache.hits |
Counter | — | Cache lookups that found an existing entry |
dotnetquery.cache.misses |
Counter | — | Cache lookups that created a new entry |
dotnetquery.cache.entries |
UpDownCounter | — | Entries currently held in the query cache |
dotnetquery.cache.evictions |
Counter | — | Entries automatically evicted after CacheTime elapsed |
dotnetquery.mutation.duration |
Histogram | s | Duration of each mutation operation |
Duration histograms record seconds, following current OTel semantic conventions for
*.duration instruments. Log messages report milliseconds for readability.
dotnetquery.cache.entries is also decremented when a client (and its cache) is disposed, so
scoped SSR clients do not leak their live-entry count into the process-wide gauge.
| dotnetquery.mutation.retries | Counter | — | Retry attempts made by mutations beyond the first |
Tags on metrics
| Metric | Tags |
|---|---|
dotnetquery.query.duration |
query.name, status (success / failure / cancelled), error.type (on failure), trigger |
dotnetquery.query.active |
query.name |
dotnetquery.query.retries |
query.name |
dotnetquery.cache.hits |
query.name |
dotnetquery.cache.misses |
query.name |
dotnetquery.cache.entries |
query.name |
dotnetquery.cache.evictions |
query.name |
dotnetquery.mutation.duration |
mutation.name, status (success / failure / cancelled), error.type (on failure) |
dotnetquery.mutation.retries |
mutation.name |
Cardinality
Metrics are tagged with query.name (or mutation.name), never with the full QueryKey. A QueryKey
typically embeds per-entity arguments — QueryKey.From("users", id) — and tagging metrics with it
would create one time series per distinct id. Most metrics backends enforce a cardinality limit per
stream (OpenTelemetry defaults to 2000); exceeding it silently collapses new series into an overflow
bucket. Traces and log messages are not affected — they always carry the full key.
If your keys are drawn from a bounded, known-small set and you want the full key on metrics anyway,
set QueryClientOptions.IncludeQueryKeyInMetrics = true. This adds a query.key tag alongside
query.name on every metric. Leave it false (the default) for any key space that includes
per-entity identifiers.
Log Messages
All log messages use the category "DotNetQuery" (the same string as QueryTelemetry.SourceName).
| Level | Message |
|---|---|
| Debug | Fetch started for key '{QueryKey}' |
| Debug | Fetch succeeded for key '{QueryKey}' in {Duration}ms |
| Warning | Fetch failed for key '{QueryKey}' after {Duration}ms (+ exception) |
| Debug | Fetch cancelled for key '{QueryKey}' |
| Debug | Cache hit for key '{QueryKey}' |
| Debug | Cache miss for key '{QueryKey}' |
| Debug | Cache entry for key '{QueryKey}' evicted after CacheTime elapsed |
| Debug | Cache entry for key '{QueryKey}' released on cache dispose |
| Debug | Mutation '{MutationName}' started |
| Debug | Mutation '{MutationName}' succeeded in {Duration}ms |
| Warning | Mutation '{MutationName}' failed after {Duration}ms (+ exception) |
| Debug | Mutation '{MutationName}' cancelled |
Log messages are source-generated via [LoggerMessage], so Debug-level calls cost nothing beyond an
IsEnabled check when the category is filtered out.
Filtering Log Output
Because all messages share the "DotNetQuery" category, you can control verbosity with a single filter:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"DotNetQuery": "Warning"
}
}
}
This suppresses the Debug-level fetch/cache messages and keeps only warnings (failures and retries).