Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ public void Subscribe()
if (!QylAutoInstrumentationOptions.Current.IsInstrumentationEnabled(Signal, InstrumentationId))
return;

// Claim the DiagnosticListener lane for this signal. If a higher-priority lane (interceptor /
// generated middleware) also covers it, this subscriber defers in OnNext so the operation is
// instrumented exactly once. See QylSignalOwnership.
QylSignalOwnership.Register(InstrumentationId, QylSignalOwnership.DiagnosticListener);
_allListenersSubscription ??= DiagnosticListener.AllListeners.Subscribe(new AllListenersObserver(this));
Comment on lines +42 to 46
}

Expand All @@ -47,7 +51,8 @@ public void Subscribe()

void IObserver<KeyValuePair<string, object?>>.OnNext(KeyValuePair<string, object?> value)
{
if (QylAutoInstrumentationOptions.Current.IsInstrumentationEnabled(Signal, InstrumentationId))
if (QylAutoInstrumentationOptions.Current.IsInstrumentationEnabled(Signal, InstrumentationId)
&& QylSignalOwnership.ShouldEmit(InstrumentationId, QylSignalOwnership.DiagnosticListener))
OnEvent(value.Key, value.Value);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public static class QylAspNetCoreInstrumentationServiceCollectionExtensions
public static IServiceCollection AddQylAspNetCoreInstrumentation(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Claim the ASP.NET Core signal for the middleware lane so the DiagnosticListener lane (if the
// Hosting package is also referenced) defers and the server span is emitted exactly once.
QylSignalOwnership.Register(QylAutoInstrumentationIds.AspNetCore, QylSignalOwnership.GeneratedMiddleware);
Comment on lines +29 to +31
services.TryAddEnumerable(ServiceDescriptor.Singleton<IStartupFilter, QylAspNetCoreStartupFilter>());
return services;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ internal sealed class QylAspNetCoreStartupFilter : IStartupFilter
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) =>
app =>
{
app.Use(static (context, requestDelegate) => QylInterceptedAspNetCore.InvokeAsync(requestDelegate, context));
// Middleware lane (priority 90). Defers to the endpoint interceptor lane (95) when it owns the
// ASP.NET Core signal, so a consumer that has both intercepted endpoints and this middleware
// still emits exactly one server span. Wins over the DiagnosticListener lane (70).
app.Use(static (context, requestDelegate) =>
QylSignalOwnership.ShouldEmit(QylAutoInstrumentationIds.AspNetCore, QylSignalOwnership.GeneratedMiddleware)
? QylInterceptedAspNetCore.InvokeAsync(requestDelegate, context)
: requestDelegate(context));
next(app);
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,14 @@ private static void RecordResponse(Activity? activity, HttpContext context)
}

private static RequestDelegate Observe(RequestDelegate requestDelegate)
=> requestDelegate is null ? null! : context => InvokeAsync(requestDelegate, context);
{
// The endpoint interceptor lane owns the ASP.NET Core server signal (priority 95). Registered here
// at endpoint-mapping time (before requests) so the middleware (90) and DiagnosticListener (70)
// lanes defer — exactly one server span per request. Registration is NOT done from InvokeAsync,
// which the middleware also calls; only actually-intercepted endpoints claim the interceptor lane.
QylSignalOwnership.Register(QylAutoInstrumentationIds.AspNetCore, QylSignalOwnership.Interceptor);
return requestDelegate is null ? null! : context => InvokeAsync(requestDelegate, context);
Comment on lines +126 to +131
}

private static string? GetRoute(HttpContext context)
=> context.GetEndpoint() is RouteEndpoint routeEndpoint
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ namespace Qyl.OpenTelemetry.AutoInstrumentation;
/// <example><code>var apiType = typeof(QylInterceptedGrpcNetClient);</code></example>
public static class QylInterceptedGrpcNetClient
{
// Registered on first use (inside an intercepted gRPC call, before the underlying call raises the
// Grpc.Net.Client DiagnosticListener event) so the listener lane defers — no double-count.
static QylInterceptedGrpcNetClient()
=> QylSignalOwnership.Register(QylAutoInstrumentationIds.GrpcNetClient, QylSignalOwnership.Interceptor);
Comment on lines +12 to +15

/// <summary>Runs the Start Activity runtime helper used by source-generated qyl interceptors.</summary>
public static Activity? StartActivity(string clientTypeName, string methodName, Metadata? requestMetadata)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ namespace Qyl.OpenTelemetry.AutoInstrumentation;
/// </summary>
public static class QylInterceptedHttpClient
{
// Registered on first use — which is inside an intercepted HttpClient call, before that call reaches
// the BCL that raises the HttpClient DiagnosticListener event — so the listener lane defers (no double).
static QylInterceptedHttpClient()
=> QylSignalOwnership.Register(QylAutoInstrumentationIds.HttpClient, QylSignalOwnership.Interceptor);
Comment on lines +14 to +17

/// <summary>Runs the Send runtime helper used by source-generated qyl interceptors.</summary>
public static HttpResponseMessage Send(HttpClient client, HttpRequestMessage request)
Expand Down
60 changes: 60 additions & 0 deletions src/Qyl.OpenTelemetry.AutoInstrumentation/QylSignalOwnership.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Collections.Concurrent;

namespace Qyl.OpenTelemetry.AutoInstrumentation;

/// <summary>
/// Single-owner-per-signal registry. More than one lane can be able to produce a span for the same
/// instrumentation signal — a source-generated call-site interceptor, a generated middleware/wrapper,
/// the library's own native <c>ActivitySource</c>, or a <c>DiagnosticListener</c> subscription. Without
/// coordination a consumer that opts into two lanes emits the operation twice (double-counting).
///
/// <para>
/// Each lane <see cref="Register"/>s a priority for a signal; only the highest-priority registered lane
/// <see cref="ShouldEmit"/>s, every other lane defers — so exactly one span is produced per operation.
/// Priorities follow the qyl instrumentation-mechanism ranking (higher = more precise, lower overhead,
/// more AOT-native): compiler-generated &gt; interceptor &gt; generated middleware &gt; native
/// ActivitySource &gt; DiagnosticListener.
/// </para>
///
/// <para>
/// Registration is race-free by construction: the interceptor helper's static initializer registers on
/// first use — which happens inside the intercepted call, before that call reaches the underlying
/// framework code that raises the DiagnosticListener event — and the middleware registers at DI time,
/// before the first request. So by the time a lower lane observes an event, the higher lane is already
/// the recorded owner.
/// </para>
/// </summary>
internal static class QylSignalOwnership
{
/// <summary>Compiler-generated AOP (reserved for a future lane).</summary>
public const int CompilerGenerated = 100;

/// <summary>Source-generated call-site interceptor — the qyl-preferred lane.</summary>
public const int Interceptor = 95;

/// <summary>Generated middleware / wrapper (e.g. the ASP.NET Core server-span <c>IStartupFilter</c>).</summary>
public const int GeneratedMiddleware = 90;

/// <summary>A span emitted natively by the instrumented library's own <c>ActivitySource</c>.</summary>
public const int NativeActivitySource = 85;

/// <summary>A <c>DiagnosticListener</c> subscription — broad compatibility, coarser payload.</summary>
public const int DiagnosticListener = 70;

private static readonly ConcurrentDictionary<string, int> Owners = new(StringComparer.Ordinal);

/// <summary>
/// Records that a lane of the given <paramref name="priority"/> can produce
/// <paramref name="instrumentationId"/>. The highest priority ever registered wins.
/// </summary>
public static void Register(string instrumentationId, int priority)
=> Owners.AddOrUpdate(instrumentationId, priority, (_, existing) => existing >= priority ? existing : priority);

/// <summary>
/// True when a lane of <paramref name="priority"/> is the highest-priority producer registered for
/// <paramref name="instrumentationId"/> and should therefore emit; false when a higher lane owns the
/// signal, so this lane defers. When nothing is registered the caller emits (it is the only lane).
/// </summary>
public static bool ShouldEmit(string instrumentationId, int priority)
=> !Owners.TryGetValue(instrumentationId, out var max) || priority >= max;
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,11 @@ public static WebApiAotReport Create(CapturedActivity[] activities)
var failures = new List<string>();
var signals = new List<MatchedSignal>();

// Server span is owned by the generated-middleware lane (priority 90) which wins over the
// DiagnosticListener lane (70) via QylSignalOwnership, so exactly one server span is emitted and
// it carries the aspnetcore.server domain (with the route backfilled after routing).
Comment on lines +151 to +153
AddRequired(signals, failures, "aspnetcore.server", activities.FirstOrDefault(static activity =>
HasTag(activity, "qyl.instrumentation.domain", "http.server") &&
HasTag(activity, "qyl.instrumentation.domain", "aspnetcore.server") &&
HasTag(activity, "http.route", "/probe/{id:int}")));

AddRequired(signals, failures, "httpclient.self", activities.FirstOrDefault(static activity =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,6 @@
"server.port": "<port>"
}
},
{
"Kind": "Client",
"Name": "GET",
"Signal": "activity",
"Status": "Unset",
"Tags": {
"http.request.method": "GET",
"http.response.status_code": "204",
"qyl.instrumentation.domain": "http.client",
"server.address": "127.0.0.1",
"server.port": "<port>"
}
},
{
"Kind": "Client",
"Name": "GET",
Expand All @@ -87,18 +74,6 @@
"qyl.instrumentation.domain": "aspnetcore.server"
}
},
{
"Kind": "Server",
"Name": "GET /probe/{id:int}",
"Signal": "activity",
"Status": "Unset",
"Tags": {
"http.request.method": "GET",
"http.response.status_code": "204",
"http.route": "/probe/{id:int}",
"qyl.instrumentation.domain": "http.server"
}
},
{
"Kind": "Client",
"Name": "SQL SELECT",
Expand Down Expand Up @@ -126,7 +101,7 @@
"http.request.method": "GET",
"http.response.status_code": "204",
"http.route": "/probe/{id:int}",
"qyl.instrumentation.domain": "http.server"
"qyl.instrumentation.domain": "aspnetcore.server"
}
},
{
Expand Down
Loading