Table of Contents

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).