Diagnostics and Metrics
DataLinq now exposes runtime metrics through a hierarchical snapshot API:
- runtime totals at the top
- one metrics node per loaded provider instance
- one metrics node per table within each provider
That shape is deliberate. A flat runtime snapshot would be easy to read but too easy to misread. It would blur together values that are really owned by different providers and tables, and it would make it harder to reason about production behavior when several providers were loaded at once.
Entry Points
Use the static DataLinqMetrics API:
using DataLinq.Diagnostics;
var snapshot = DataLinqMetrics.Snapshot();
The snapshot type is DataLinqMetricsSnapshot.
For benchmarks or controlled test runs, you can also reset the collected metrics:
DataLinqMetrics.Reset();
Do not blindly call Reset() in a live multi-consumer production process. That is fine for tests and benchmarks, but it is a blunt tool for shared diagnostics.
Hierarchy
---
config:
theme: neo
look: classic
---
flowchart TD
A["DataLinqMetricsSnapshot<br/>Runtime totals"] --> B["Provider 1<br/>DataLinqProviderMetricsSnapshot"]
A --> C["Provider 2<br/>DataLinqProviderMetricsSnapshot"]
B --> D["Table: employees<br/>DataLinqTableMetricsSnapshot"]
B --> E["Table: dept-emp<br/>DataLinqTableMetricsSnapshot"]
C --> F["Table: users<br/>DataLinqTableMetricsSnapshot"]
Each provider node is keyed by a stable provider-instance id for the current process lifetime. That matters because several loaded providers may share the same logical database name or the same metadata model and still need to be tracked independently.
Ownership Rules
The ownership model is what keeps the sums honest.
QueryMetricsSnapshotis provider-owned.CommandMetricsSnapshotis provider-owned.TransactionMetricsSnapshotis provider-owned.MutationMetricsSnapshotis table-owned, then summed upward at provider and runtime level.CacheOccupancyMetricsSnapshotis table-owned, then summed upward.CacheCleanupMetricsSnapshotis table-owned, then summed upward.RelationMetricsSnapshotis table-owned.RowCacheMetricsSnapshotis table-owned.CacheNotificationMetricsSnapshotis table-owned.
So:
- runtime
Queriesis the sum of providerQueries - runtime
CommandsandTransactionsare sums of provider-owned values - provider
Mutations,Occupancy,Cleanup,Relations,RowCache, andCacheNotificationsare sums of that provider's tables - runtime
Mutations,Occupancy,Cleanup,Relations,RowCache, andCacheNotificationsare sums of all providers
Query metrics are intentionally not forced into tables. A single query can touch several tables, and fake table attribution would make the totals look cleaner while making them less true.
Snapshot Shapes
Runtime
DataLinqMetricsSnapshot
{
QueryMetricsSnapshot Queries;
CommandMetricsSnapshot Commands;
TransactionMetricsSnapshot Transactions;
MutationMetricsSnapshot Mutations;
CacheOccupancyMetricsSnapshot Occupancy;
CacheCleanupMetricsSnapshot Cleanup;
RelationMetricsSnapshot Relations;
RowCacheMetricsSnapshot RowCache;
CacheNotificationMetricsSnapshot CacheNotifications;
DataLinqProviderMetricsSnapshot[] Providers;
}
Provider
DataLinqProviderMetricsSnapshot
{
string ProviderInstanceId;
string ProviderTypeName;
string DatabaseName;
DatabaseType DatabaseType;
QueryMetricsSnapshot Queries;
CommandMetricsSnapshot Commands;
TransactionMetricsSnapshot Transactions;
MutationMetricsSnapshot Mutations;
CacheOccupancyMetricsSnapshot Occupancy;
CacheCleanupMetricsSnapshot Cleanup;
RelationMetricsSnapshot Relations;
RowCacheMetricsSnapshot RowCache;
CacheNotificationMetricsSnapshot CacheNotifications;
DataLinqTableMetricsSnapshot[] Tables;
}
Table
DataLinqTableMetricsSnapshot
{
string TableName;
MutationMetricsSnapshot Mutations;
CacheOccupancyMetricsSnapshot Occupancy;
CacheCleanupMetricsSnapshot Cleanup;
RelationMetricsSnapshot Relations;
RowCacheMetricsSnapshot RowCache;
CacheNotificationMetricsSnapshot CacheNotifications;
}
Reading the Metrics Correctly
This is where people most often fool themselves.
Query metrics
EntityExecutionsandScalarExecutionsare counters.- They are provider-owned and summed upward.
Command metrics
ReaderExecutions,ScalarExecutions,NonQueryExecutions, andFailuresare counters.TotalDurationMicrosecondsis cumulative duration, not the duration of the most recent command.- They are provider-owned and summed upward.
Transaction metrics
Starts,Commits,Rollbacks, andFailuresare counters.TotalDurationMicrosecondsis cumulative duration across completed transactions.- They are provider-owned and summed upward.
Mutation metrics
Inserts,Updates,Deletes,Failures, andAffectedRowsare counters.TotalDurationMicrosecondsis cumulative duration across executed mutations.- They are table-owned and summed upward.
Cache occupancy metrics
Rows,TransactionRows,Bytes, andIndexEntriesare gauges.- They describe current state, not cumulative history.
- They are table-owned and summed upward.
Cache cleanup metrics
OperationsandRowsRemovedare counters.TotalDurationMicrosecondsis cumulative cleanup duration.- They are table-owned and summed upward.
Row cache metrics
Hits,Misses,DatabaseRowsLoaded,Materializations, andStoresare counters.- They are table-owned and summed upward.
Relation metrics
ReferenceCacheHits,ReferenceLoads,CollectionCacheHits, andCollectionLoadsare counters.- They are table-owned and summed upward.
Cache notification metrics
Some values are counters. Some are gauges. Some are “last seen per child, then summed”.
Subscriptionsis a cumulative counter ofSubscribe()calls. It is not the current number of live subscribers.ApproximateCurrentQueueDepthis a gauge. At runtime level it is the sum of the current per-table queue depths.NotifySweeps,NotifySnapshotEntries,NotifyLiveSubscribers,CleanSweeps,CleanSnapshotEntries,CleanRequeuedSubscribers,CleanDroppedSubscribers, andCleanBusySkipsare cumulative counters.LastNotifySnapshotEntries,LastNotifyLiveSubscribers,LastCleanSnapshotEntries,LastCleanRequeuedSubscribers, andLastCleanDroppedSubscribersare the latest values recorded on each child, then summed upward.ApproximatePeakQueueDepthis a max, not a sum.
That last point is important. A runtime peak queue depth of 5000 means some underlying table peaked around 5000. It does not mean the system once had a global atomic queue depth of exactly 5000.
Example
var snapshot = DataLinqMetrics.Snapshot();
// Runtime totals
var totalEntityQueries = snapshot.Queries.EntityExecutions;
var totalCommandCount = snapshot.Commands.TotalExecutions;
var totalTransactionStarts = snapshot.Transactions.Starts;
var totalMutationRows = snapshot.Mutations.AffectedRows;
var totalCachedRows = snapshot.Occupancy.Rows;
var totalRowCacheHits = snapshot.RowCache.Hits;
var totalNotificationDepth = snapshot.CacheNotifications.ApproximateCurrentQueueDepth;
// Provider-level drilldown
foreach (var provider in snapshot.Providers)
{
Console.WriteLine($"{provider.ProviderTypeName} ({provider.DatabaseName})");
Console.WriteLine($" Entity queries: {provider.Queries.EntityExecutions}");
Console.WriteLine($" Commands: {provider.Commands.TotalExecutions}");
Console.WriteLine($" Transactions: {provider.Transactions.Starts}");
Console.WriteLine($" Cached rows: {provider.Occupancy.Rows}");
Console.WriteLine($" Row cache hits: {provider.RowCache.Hits}");
Console.WriteLine($" Notification depth: {provider.CacheNotifications.ApproximateCurrentQueueDepth}");
foreach (var table in provider.Tables)
{
Console.WriteLine($" {table.TableName}:");
Console.WriteLine($" Mutations: {table.Mutations.TotalExecutions}");
Console.WriteLine($" Cached rows: {table.Occupancy.Rows}");
Console.WriteLine($" Row cache hits: {table.RowCache.Hits}");
Console.WriteLine($" Notification depth: {table.CacheNotifications.ApproximateCurrentQueueDepth}");
}
}
Practical Recommendation for Application Integrations
If you are integrating this into an admin page or periodic telemetry log:
- keep a flat adapter DTO if your UI already expects one
- expose the provider/table tree as a second, richer view when you need drilldown
- log both runtime totals and the hottest provider/table contributors
If you only log the runtime totals, you will eventually end up asking “which table actually caused this?” and have no answer.
Standard .NET Telemetry
DataLinqMetrics is the in-process snapshot view. It is not the whole telemetry story.
DataLinq also emits standard .NET telemetry with:
Meter:DataLinqActivitySource:DataLinq
That is the right library boundary. DataLinq produces telemetry; your application decides whether to inspect it locally, export it with OpenTelemetry, or ignore it.
What DataLinq emits
The exported surface now covers the main runtime paths:
- query count and end-to-end query duration
- DB command count and duration
- transaction start/completion count and duration
- mutation count, affected rows, and duration
- row-cache hit/miss/store counters
- relation cache hit/load counters
- cache occupancy gauges for rows, transaction rows, bytes, and index entries
- cache-notification queue depth gauges
- cache maintenance counters and duration
SQL text is still a logging concern, not a metric tag. That is deliberate. Putting SQL text into metric tags would be a cardinality bug.
Local Inspection with dotnet-counters
For quick local inspection, dotnet-counters is the simplest path.
- Start your application.
- Find the process id.
- Monitor the DataLinq meter:
dotnet-counters monitor --process-id <pid> --counters DataLinq
That is useful for questions like:
- are commands actually being issued?
- are mutations increasing?
- is the cache growing or being cleaned up?
- are transaction rates changing under load?
If you need table-by-table drilldown, use DataLinqMetrics.Snapshot() in-process. dotnet-counters is for live aggregate observation, not rich per-table analysis.
OpenTelemetry Integration
DataLinq does not require an OpenTelemetry dependency in the core package. The application should opt into collection and exporting.
A normal application-side setup looks like this:
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics
.AddMeter("DataLinq")
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing
.AddSource("DataLinq");
});
Then add whatever exporter your app actually uses. That might be OTLP, Azure Monitor, or just console/exporter wiring during development.
The important part is not the exporter. The important part is that the app listens to:
Meter("DataLinq")ActivitySource("DataLinq")
If you want a fuller example instead of a short setup snippet, see Telemetry Integration Example.
Choosing Between Snapshot and Exported Telemetry
Use DataLinqMetrics.Snapshot() when you need:
- provider/table drilldown
- deterministic before/after deltas in tests or benchmarks
- a local admin/debug endpoint
Use Meter and ActivitySource when you need:
- live process observation
- app-wide telemetry collection
- traces correlated with the rest of your service
- backend/export integration through standard .NET tooling
These are complementary. If you force one to do the other's job, you will get worse results.