Skip to content

Commit e9860ad

Browse files
authored
Merge pull request #57 from EFNext/feat/telemetry
Add telemetry support for diagnostics
2 parents 95c57ef + 5e51d87 commit e9860ad

9 files changed

Lines changed: 457 additions & 18 deletions

File tree

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const sidebar: DefaultTheme.Sidebar = {
6464
{ text: 'Switch Expressions', link: '/reference/switch-expressions' },
6565
{ text: 'Expression Transformers', link: '/reference/expression-transformers' },
6666
{ text: 'Diagnostics & Code Fixes', link: '/reference/diagnostics' },
67+
{ text: 'Telemetry', link: '/reference/telemetry' },
6768
{ text: 'Troubleshooting', link: '/reference/troubleshooting' },
6869
]
6970
}

docs/reference/telemetry.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Telemetry
2+
3+
ExpressiveSharp emits runtime telemetry through the standard .NET diagnostics primitives — `ActivitySource` for distributed traces, `Meter` for metrics, and `EventSource` for previously-silent failure paths. All three share the source name `ExpressiveSharp` (also exposed as the constant `ExpressiveDiagnostics.SourceName`).
4+
5+
Every instrument is **zero-cost when no listener is attached**: counters and activities short-circuit on the no-listener path, and the more expensive observations (node-counting, `MemberInfo.ToString()` for tags) are guarded explicitly.
6+
7+
## Activity (Distributed Tracing)
8+
9+
A single span is emitted from the `ExpressiveSharp` `ActivitySource` (the source's name is also exposed as the constant `ExpressiveDiagnostics.SourceName`):
10+
11+
| Span | Emitted from | Tags |
12+
|------|--------------|------|
13+
| `Expressive.Expand` | `expression.ExpandExpressives()` (and the EF Core / MongoDB query compiler decorators that call into it) | `transformer.count`, `expansion.node_count`, `expansion.duration_ms` |
14+
15+
Because the span runs synchronously inside the EF Core query pipeline, it nests cleanly under EF Core's `Microsoft.EntityFrameworkCore` activity. Trace viewers will show:
16+
17+
```
18+
EF query → Expressive.Expand → SQL execution
19+
```
20+
21+
### OpenTelemetry
22+
23+
```csharp
24+
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
25+
.AddSource("ExpressiveSharp")
26+
.AddSource("Microsoft.EntityFrameworkCore") // optional — nests Expressive.Expand under EF activities
27+
.AddOtlpExporter()
28+
.Build();
29+
```
30+
31+
::: tip
32+
The `expansion.duration_ms` tag is recorded redundantly with the activity's intrinsic duration so consumers that only run a metrics pipeline (no tracer) still get the timing.
33+
:::
34+
35+
## Meter (Metrics)
36+
37+
Five instruments on `Meter("ExpressiveSharp")`:
38+
39+
| Instrument | Type | Unit | Tags | What it tells you |
40+
|------------|------|------|------|-------------------|
41+
| `expressive.cache.hits` | Counter<long> ||| The runtime resolver cache served the lookup without computing it again. |
42+
| `expressive.cache.misses` | Counter<long> ||| The resolver had to compute the lambda for a member it hadn't seen before. |
43+
| `expressive.reflection_fallback.count` | Counter<long> || `member` | The slow reflection-based fallback ran. Used for open-generic class members and generic methods not in the static registry. **Sustained activity here is a perf bug.** |
44+
| `expressive.expansion.node_count` | Histogram<int> | nodes || Total expression-tree node count after expansion + transformers. Surfaces members that explode into massive trees and bloat SQL. |
45+
| `expressive.expansion.duration_ms` | Histogram<double> | ms || Wall-clock cost of one `ExpandExpressives()` call. |
46+
47+
### Live monitoring with `dotnet-counters`
48+
49+
```sh
50+
dotnet-counters monitor -n MyApp ExpressiveSharp
51+
```
52+
53+
### OpenTelemetry
54+
55+
```csharp
56+
using var meterProvider = Sdk.CreateMeterProviderBuilder()
57+
.AddMeter("ExpressiveSharp")
58+
.AddOtlpExporter()
59+
.Build();
60+
```
61+
62+
::: info
63+
A healthy steady-state has **`cache.hits``cache.misses`** after warmup, and **`reflection_fallback.count` should be flat** unless your code uses open-generic `[Expressive]` members. A non-trivial slope on `reflection_fallback.count` means the runtime is paying reflection costs that the static registry could have served — please file an issue with a reproducer.
64+
:::
65+
66+
## EventSource (Failure Diagnostics)
67+
68+
Three events on `EventSource("ExpressiveSharp")`. They surface failure paths that ExpressiveSharp deliberately recovers from but that historically left users debugging by rubber duck.
69+
70+
| Event ID | Name | Level | Payload | When it fires |
71+
|----------|------|-------|---------|---------------|
72+
| `1` | `RegistryInitializationFailed` | Error | `assemblyName`, `exceptionType`, `message` | An assembly's generated `ExpressionRegistry` static constructor threw. The registry is marked inert (no `[ExpressiveFor]` lookups will succeed against it) but the process keeps running. |
73+
| `2` | `HotReloadResetFailed` | Warning | `assemblyName`, `exceptionType`, `message` | A hot-reload edit-and-continue update arrived but invalidating the assembly's expression registry threw. The stale registry stays cached, so the next lookup may return pre-edit lambdas. |
74+
| `3` | `MultipleExpressiveForMappings` | Error | `memberInfoString`, `firstAssembly`, `secondAssembly` | Two different assemblies both registered an `[ExpressiveFor]` mapping for the same member. An `InvalidOperationException` is thrown immediately after; the event fires first so it survives even if the exception is later caught. |
75+
76+
### Collecting with `dotnet-trace`
77+
78+
The most common path. No code changes, no app restart:
79+
80+
```sh
81+
dotnet-trace collect -n MyApp --providers ExpressiveSharp::Verbose
82+
```
83+
84+
Open the resulting `.nettrace` file in [PerfView](https://github.com/microsoft/perfview) or Visual Studio's diagnostic tools to inspect the events.
85+
86+
## Stability
87+
88+
The source name `ExpressiveSharp`, the instrument names, and the EventSource event IDs are part of the public API surface and follow the same stability rules as the rest of ExpressiveSharp. Tags carried on activities and counters may be extended (new tags added) but existing tags will not be renamed within a major version.

docs/reference/troubleshooting.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,23 @@ public double Total => Price * Quantity;
322322
public double TotalWithTax => Total * (1 + TaxRate); // Total is inlined
323323
```
324324

325+
### How do I see what ExpressiveSharp is doing at runtime?
326+
327+
ExpressiveSharp emits three independent signals on the `ExpressiveSharp` source name — each requires a different out-of-process tool:
328+
329+
```sh
330+
# Failure events (registry static-ctor failures, hot-reload reset failures, [ExpressiveFor] collisions)
331+
dotnet-trace collect -n MyApp --providers ExpressiveSharp::Verbose
332+
333+
# Metrics (cache hit/miss ratios, expansion timings, reflection-fallback rate)
334+
dotnet-counters monitor -n MyApp ExpressiveSharp
335+
336+
# Distributed tracing (the Expressive.Expand activity span) — wire via OpenTelemetry,
337+
# see the Telemetry reference for the AddSource("ExpressiveSharp") snippet.
338+
```
339+
340+
See [Telemetry](./telemetry) for the full instrument and event reference.
341+
325342
### Is ExpressiveSharp EF Core specific?
326343

327344
No. The core `ExpressiveSharp` package works with any LINQ provider or standalone expression tree use case. See [ExpressionPolyfill.Create](../guide/expression-polyfill) and [IExpressiveQueryable](../guide/expressive-queryable) for non-EF-Core usage.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.Diagnostics;
2+
using System.Diagnostics.Metrics;
3+
4+
namespace ExpressiveSharp.Diagnostics;
5+
6+
public static class ExpressiveDiagnostics
7+
{
8+
public const string SourceName = "ExpressiveSharp";
9+
10+
internal static readonly ActivitySource ActivitySource = new(SourceName);
11+
internal static readonly Meter Meter = new(SourceName);
12+
13+
internal static readonly Counter<long> CacheHits =
14+
Meter.CreateCounter<long>("expressive.cache.hits");
15+
16+
internal static readonly Counter<long> CacheMisses =
17+
Meter.CreateCounter<long>("expressive.cache.misses");
18+
19+
internal static readonly Counter<long> ReflectionFallback =
20+
Meter.CreateCounter<long>("expressive.reflection_fallback.count");
21+
22+
internal static readonly Histogram<int> ExpansionNodeCount =
23+
Meter.CreateHistogram<int>("expressive.expansion.node_count");
24+
25+
internal static readonly Histogram<double> ExpansionDurationMs =
26+
Meter.CreateHistogram<double>("expressive.expansion.duration_ms", unit: "ms");
27+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using System;
2+
using System.Diagnostics.Tracing;
3+
using System.Reflection;
4+
5+
namespace ExpressiveSharp.Diagnostics;
6+
7+
[EventSource(Name = ExpressiveDiagnostics.SourceName)]
8+
internal sealed class ExpressiveEventSource : EventSource
9+
{
10+
public static readonly ExpressiveEventSource Log = new();
11+
12+
private ExpressiveEventSource() { }
13+
14+
[Event(1, Level = EventLevel.Error,
15+
Message = "ExpressiveSharp registry initialization failed for assembly '{0}': {1}: {2}")]
16+
public void RegistryInitializationFailed(string assemblyName, string exceptionType, string message)
17+
=> WriteEvent(1, assemblyName, exceptionType, message);
18+
19+
[NonEvent]
20+
public void RegistryInitializationFailed(Assembly assembly, Exception exception)
21+
{
22+
if (!IsEnabled()) return;
23+
// The caller catches TypeInitializationException, but the actionable cause is the
24+
// static-ctor exception nested inside it — unwrap so consumers see the real failure.
25+
var actual = exception is TypeInitializationException && exception.InnerException is { } inner
26+
? inner : exception;
27+
RegistryInitializationFailed(
28+
assembly.GetName().Name ?? "<unknown>",
29+
actual.GetType().FullName ?? actual.GetType().Name,
30+
actual.Message);
31+
}
32+
33+
[Event(2, Level = EventLevel.Warning,
34+
Message = "ExpressiveSharp hot-reload registry reset failed for assembly '{0}': {1}: {2}")]
35+
public void HotReloadResetFailed(string assemblyName, string exceptionType, string message)
36+
=> WriteEvent(2, assemblyName, exceptionType, message);
37+
38+
[NonEvent]
39+
public void HotReloadResetFailed(Assembly assembly, Exception exception)
40+
{
41+
if (!IsEnabled()) return;
42+
HotReloadResetFailed(
43+
assembly.GetName().Name ?? "<unknown>",
44+
exception.GetType().FullName ?? exception.GetType().Name,
45+
exception.Message);
46+
}
47+
48+
[Event(3, Level = EventLevel.Error,
49+
Message = "Multiple [ExpressiveFor] mappings found for '{0}' in assemblies '{1}' and '{2}'")]
50+
public void MultipleExpressiveForMappings(string memberInfoString, string firstAssembly, string secondAssembly)
51+
=> WriteEvent(3, memberInfoString, firstAssembly, secondAssembly);
52+
53+
[NonEvent]
54+
public void MultipleExpressiveForMappings(MemberInfo memberInfo, Assembly first, Assembly second)
55+
{
56+
if (!IsEnabled()) return;
57+
MultipleExpressiveForMappings(
58+
memberInfo.ToString() ?? memberInfo.Name,
59+
first.GetName().Name ?? "<unknown>",
60+
second.GetName().Name ?? "<unknown>");
61+
}
62+
}
Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Diagnostics;
12
using System.Linq.Expressions;
3+
using ExpressiveSharp.Diagnostics;
24
using ExpressiveSharp.Services;
35

46
namespace ExpressiveSharp;
@@ -16,28 +18,70 @@ public static Expression ExpandExpressives(this Expression expression)
1618
/// Like <see cref="ExpandExpressives(Expression)"/> but uses transformers from the given options.
1719
/// </summary>
1820
public static Expression ExpandExpressives(this Expression expression, ExpressiveOptions options)
19-
{
20-
var expanded = new ExpressiveReplacer(new ExpressiveResolver()).Replace(expression);
21-
var transformers = options.GetTransformers();
22-
foreach (var transformer in transformers)
23-
{
24-
expanded = transformer.Transform(expanded);
25-
}
26-
return expanded;
27-
}
21+
=> ExpandExpressivesCore(expression, options.GetTransformers());
2822

2923
/// <summary>
3024
/// Like <see cref="ExpandExpressives(Expression)"/> but uses the explicitly supplied transformers.
3125
/// </summary>
3226
public static Expression ExpandExpressives(
3327
this Expression expression,
3428
params IExpressionTreeTransformer[] transformers)
29+
=> ExpandExpressivesCore(expression, transformers);
30+
31+
private static Expression ExpandExpressivesCore(
32+
Expression expression,
33+
IReadOnlyList<IExpressionTreeTransformer> transformers)
3534
{
35+
using var activity = ExpressiveDiagnostics.ActivitySource.StartActivity("Expressive.Expand");
36+
37+
var measureDuration = activity is not null || ExpressiveDiagnostics.ExpansionDurationMs.Enabled;
38+
var startTimestamp = measureDuration ? Stopwatch.GetTimestamp() : 0L;
39+
3640
var expanded = new ExpressiveReplacer(new ExpressiveResolver()).Replace(expression);
37-
foreach (var transformer in transformers)
41+
for (var i = 0; i < transformers.Count; i++)
42+
{
43+
expanded = transformers[i].Transform(expanded);
44+
}
45+
46+
if (activity is not null)
47+
{
48+
activity.SetTag("transformer.count", transformers.Count);
49+
}
50+
51+
if (activity is not null || ExpressiveDiagnostics.ExpansionNodeCount.Enabled)
52+
{
53+
var nodeCount = ExpansionNodeCounter.Count(expanded);
54+
ExpressiveDiagnostics.ExpansionNodeCount.Record(nodeCount);
55+
activity?.SetTag("expansion.node_count", nodeCount);
56+
}
57+
58+
if (measureDuration)
3859
{
39-
expanded = transformer.Transform(expanded);
60+
var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds;
61+
ExpressiveDiagnostics.ExpansionDurationMs.Record(elapsedMs);
62+
activity?.SetTag("expansion.duration_ms", elapsedMs);
4063
}
64+
4165
return expanded;
4266
}
67+
68+
private sealed class ExpansionNodeCounter : ExpressionVisitor
69+
{
70+
private int _count;
71+
72+
public static int Count(Expression? expression)
73+
{
74+
if (expression is null) return 0;
75+
var counter = new ExpansionNodeCounter();
76+
counter.Visit(expression);
77+
return counter._count;
78+
}
79+
80+
public override Expression? Visit(Expression? node)
81+
{
82+
if (node is null) return null;
83+
_count++;
84+
return base.Visit(node);
85+
}
86+
}
4387
}

src/ExpressiveSharp/Services/ExpressiveHotReloadHandler.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections.Generic;
33
using System.Reflection;
44
using System.Reflection.Metadata;
5+
using ExpressiveSharp.Diagnostics;
56

67
[assembly: MetadataUpdateHandler(typeof(ExpressiveSharp.Services.ExpressiveHotReloadHandler))]
78

@@ -56,8 +57,17 @@ private static void ResetGeneratedRegistries(IEnumerable<Assembly> assemblies)
5657
var reset = registryType?.GetMethod("ResetMap", BindingFlags.Static | BindingFlags.NonPublic);
5758
if (reset is null) continue;
5859

59-
try { reset.Invoke(null, null); }
60-
catch { /* best-effort; stale registry stays stale */ }
60+
try
61+
{
62+
reset.Invoke(null, null);
63+
}
64+
catch (Exception ex)
65+
{
66+
// Best-effort; stale registry stays stale. Surface via EventSource so
67+
// a hot-reload edit-and-continue failure is no longer silent.
68+
var inner = (ex as TargetInvocationException)?.InnerException ?? ex;
69+
ExpressiveEventSource.Log.HotReloadResetFailed(assembly, inner);
70+
}
6171
}
6272
}
6373
}

0 commit comments

Comments
 (0)