From 9337efeaba327b383fe8e9385f6968e1d6022e17 Mon Sep 17 00:00:00 2001 From: ancplua Date: Tue, 30 Jun 2026 03:21:04 +0200 Subject: [PATCH] feat(generator): opt out of WebApplicationBuilder.Build() interception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a consumer-settable build property so a cooperating generator can own the WebApplicationBuilder.Build() call site: false C# allows only one interceptor per call site (CS9153). When a consumer also runs another generator that intercepts builder.Build() — e.g. qyl's ServiceDefaults generator, which composes our QylInterceptedAspNetCore.Build — the two collide. This opt-out lets the consumer hand Build() to that generator while keeping every other qyl auto-instrumentation interceptor. - Generator reads build_property.QylAutoInstrumentationInterceptWebApplicationBuilderBuild via AnalyzerConfigOptions; only an explicit "false" yields the call site. Absent or any other value keeps full instrumentation, so default output is byte-identical. - Core package build/buildTransitive targets expose it as a CompilerVisibleProperty. - New tools/verify-build-interceptor-optout.py proves it is surgical (default emits the Build interceptor + a control HttpClient interceptor; opt-out drops only Build, keeps HttpClient, still builds) and is wired into the verify goal suite. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../QylAutoInstrumentationGenerator.cs | 29 +++- ....OpenTelemetry.AutoInstrumentation.targets | 7 + ....OpenTelemetry.AutoInstrumentation.targets | 7 + tools/verify-aot-autoinstrumentation-goal.py | 1 + tools/verify-build-interceptor-optout.py | 125 ++++++++++++++++++ 5 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tools/verify-build-interceptor-optout.py diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs index bdcd8fe0..72b5a98b 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs @@ -5,6 +5,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Text; namespace Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators; @@ -80,9 +81,29 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(static invocation => invocation is not null) .Collect(); - context.RegisterSourceOutput(interceptedInvocations, EmitInterceptors); + // A consumer can hand WebApplicationBuilder.Build() interception to another generator + // (e.g. qyl's ServiceDefaults generator, which composes our QylInterceptedAspNetCore.Build) + // by setting false. C# + // forbids two interceptors on one call site, so this opt-out is how the package boundary + // stays clean instead of colliding (CS9153). Default keeps full auto-instrumentation. + var interceptWebApplicationBuilderBuild = context.AnalyzerConfigOptionsProvider + .Select(static (provider, _) => ReadInterceptWebApplicationBuilderBuild(provider)); + + context.RegisterSourceOutput( + interceptedInvocations.Combine(interceptWebApplicationBuilderBuild), + static (productionContext, input) => + EmitInterceptors(productionContext, input.Left, input.Right)); } + private const string InterceptWebApplicationBuilderBuildProperty = + "build_property.QylAutoInstrumentationInterceptWebApplicationBuilderBuild"; + + // Default (property absent or any value other than "false") keeps intercepting Build(). Only an + // explicit "false" yields the call site so a cooperating generator can own it. + private static bool ReadInterceptWebApplicationBuilderBuild(AnalyzerConfigOptionsProvider provider) => + !provider.GlobalOptions.TryGetValue(InterceptWebApplicationBuilderBuildProperty, out var value) + || !string.Equals(value, "false", System.StringComparison.OrdinalIgnoreCase); + private static InterceptedInvocation? TryCreateInterceptedInvocation( GeneratorSyntaxContext context, CancellationToken cancellationToken) @@ -198,7 +219,8 @@ private static void EnsureContractDeclaredByMatcher( private static void EmitInterceptors( SourceProductionContext context, - ImmutableArray nullableInvocations) + ImmutableArray nullableInvocations, + bool interceptWebApplicationBuilderBuild) { if (nullableInvocations.IsDefaultOrEmpty) return; @@ -206,6 +228,9 @@ private static void EmitInterceptors( var invocations = nullableInvocations .Where(static invocation => invocation is not null) .Select(static invocation => invocation!.Value) + .Where(invocation => + interceptWebApplicationBuilderBuild || + invocation.Target.Kind != InterceptorKind.AspNetCoreWebApplicationBuilderBuild) .Distinct() // Stable, content-based ordering so the emission order and the _N interceptor-name indices // are a pure function of the matched call sites — independent of Roslyn's cross-tree syntax diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation/build/Qyl.OpenTelemetry.AutoInstrumentation.targets b/src/Qyl.OpenTelemetry.AutoInstrumentation/build/Qyl.OpenTelemetry.AutoInstrumentation.targets index 1f70cca1..be91d70b 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation/build/Qyl.OpenTelemetry.AutoInstrumentation.targets +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation/build/Qyl.OpenTelemetry.AutoInstrumentation.targets @@ -7,4 +7,11 @@ $(InterceptorsNamespaces);Qyl.OpenTelemetry.AutoInstrumentation.Generated + + + + + diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation/buildTransitive/Qyl.OpenTelemetry.AutoInstrumentation.targets b/src/Qyl.OpenTelemetry.AutoInstrumentation/buildTransitive/Qyl.OpenTelemetry.AutoInstrumentation.targets index 1f70cca1..be91d70b 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation/buildTransitive/Qyl.OpenTelemetry.AutoInstrumentation.targets +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation/buildTransitive/Qyl.OpenTelemetry.AutoInstrumentation.targets @@ -7,4 +7,11 @@ $(InterceptorsNamespaces);Qyl.OpenTelemetry.AutoInstrumentation.Generated + + + + + diff --git a/tools/verify-aot-autoinstrumentation-goal.py b/tools/verify-aot-autoinstrumentation-goal.py index 4925a41e..81b803d7 100644 --- a/tools/verify-aot-autoinstrumentation-goal.py +++ b/tools/verify-aot-autoinstrumentation-goal.py @@ -23,6 +23,7 @@ ("instrumentation disabled behavior", [sys.executable, "tools/verify-instrumentation-disabled-behavior.py"]), ("conformance opt-in", [sys.executable, "tools/verify-conformance-opt-in.py"]), ("generator snapshots", [sys.executable, "tools/verify-generator-snapshots.py"]), + ("build interceptor opt-out", [sys.executable, "tools/verify-build-interceptor-optout.py"]), ("source interceptor consumer", [sys.executable, "tools/verify-source-interceptor-consumer.py"]), ("real adonet demo", [sys.executable, "tools/verify-real-adonet-demo.py"]), ("real aspnetcore demo", [sys.executable, "tools/verify-real-aspnetcore-demo.py"]), diff --git a/tools/verify-build-interceptor-optout.py b/tools/verify-build-interceptor-optout.py new file mode 100644 index 00000000..5c099bdf --- /dev/null +++ b/tools/verify-build-interceptor-optout.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Verifies the WebApplicationBuilder.Build() interceptor opt-out. + +A consumer can hand WebApplicationBuilder.Build() interception to a cooperating generator +(for example qyl's ServiceDefaults generator, which composes QylInterceptedAspNetCore.Build) +by setting false. C# forbids +two interceptors on one call site, so without this opt-out the two generators collide (CS9153). + + default build -> the Build() interceptor IS emitted (full auto-instrumentation). + opt-out build -> the Build() interceptor is NOT emitted, and the build still succeeds. + +The opt-out is surgical: only the WebApplicationBuilder.Build() interceptor is withheld; every +other interceptor in the same consumer is unaffected. +""" +from __future__ import annotations + +import tempfile +from pathlib import Path + +from verify_helpers import clean_env, run_checked + + +ROOT = Path(__file__).resolve().parents[1] +CORE_PROJECT = ROOT / "src" / "Qyl.OpenTelemetry.AutoInstrumentation" / "Qyl.OpenTelemetry.AutoInstrumentation.csproj" +GENERATOR_PROJECT = ( + ROOT + / "src" + / "Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators" + / "Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.csproj" +) +TARGETS = ROOT / "src" / "Qyl.OpenTelemetry.AutoInstrumentation" / "buildTransitive" / "Qyl.OpenTelemetry.AutoInstrumentation.targets" +TARGET_FRAMEWORK = "net10.0" +OPT_OUT_PROPERTY = "QylAutoInstrumentationInterceptWebApplicationBuilderBuild" +BUILD_INTERCEPTOR_TOKEN = "global::Qyl.OpenTelemetry.AutoInstrumentation.QylInterceptedAspNetCore.Build(" +OTHER_INTERCEPTOR_TOKEN = "global::Qyl.OpenTelemetry.AutoInstrumentation.QylInterceptedHttpClient.GetStringAsync(" + +# A WebApplicationBuilder.Build() call (intercepted by AspNetCoreWebApplicationBuilderBuild) plus a +# never-executed HttpClient call (intercepted by HttpClient) so the opt-out build still emits at +# least one interceptor — proving the opt-out drops only Build(), not every interceptor. +PROGRAM = """using System.Net.Http; + +var builder = WebApplication.CreateSlimBuilder(args); +var app = builder.Build(); +app.MapGet("/", () => "ok"); +app.Run(); + +static async System.Threading.Tasks.Task Probe(HttpClient client) + => _ = await client.GetStringAsync("https://qyl-build-optout.invalid"); +""" + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def write_project(directory: Path) -> Path: + directory.mkdir(parents=True) + project = directory / "BuildOptOutConsumer.csproj" + project.write_text( + f""" + + {TARGET_FRAMEWORK} + enable + enable + true + Generated + + + + + + + + + + +""", + encoding="utf-8", + ) + (directory / "Program.cs").write_text(PROGRAM, encoding="utf-8") + return project + + +def generated_interceptor_text(directory: Path) -> str: + files = sorted((directory / "Generated").rglob("QylAutoInstrumentation.Interceptors.g.cs")) + if not files: + return "" + if len(files) != 1: + fail(f"expected at most one generated interceptor source file, found {len(files)}") + return files[0].read_text(encoding="utf-8") + + +def build(project: Path, env: dict[str, str], opt_out: bool) -> None: + command = ["dotnet", "build", str(project), "-c", "Release", "-v", "quiet"] + if opt_out: + command.append(f"-p:{OPT_OUT_PROPERTY}=false") + run_checked(command, project.parent, env) + + +def main() -> None: + env = clean_env() + with tempfile.TemporaryDirectory(prefix="qyl-build-optout-") as temp: + default_dir = Path(temp) / "default" + build(write_project(default_dir), env, opt_out=False) + default_text = generated_interceptor_text(default_dir) + if BUILD_INTERCEPTOR_TOKEN not in default_text: + fail("default build did not emit the WebApplicationBuilder.Build() interceptor") + if OTHER_INTERCEPTOR_TOKEN not in default_text: + fail("default build did not emit the control HttpClient interceptor") + + optout_dir = Path(temp) / "optout" + build(write_project(optout_dir), env, opt_out=True) + optout_text = generated_interceptor_text(optout_dir) + if BUILD_INTERCEPTOR_TOKEN in optout_text: + fail("opt-out build still emitted the WebApplicationBuilder.Build() interceptor") + if OTHER_INTERCEPTOR_TOKEN not in optout_text: + fail("opt-out build dropped more than Build() — the control HttpClient interceptor is gone") + + print("build-interceptor-optout-ok") + + +if __name__ == "__main__": + main()