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 @@ -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;
Expand Down Expand Up @@ -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 <QylAutoInstrumentationInterceptWebApplicationBuilderBuild>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)
Expand Down Expand Up @@ -198,14 +219,18 @@ private static void EnsureContractDeclaredByMatcher(

private static void EmitInterceptors(
SourceProductionContext context,
ImmutableArray<InterceptedInvocation?> nullableInvocations)
ImmutableArray<InterceptedInvocation?> nullableInvocations,
bool interceptWebApplicationBuilderBuild)
{
if (nullableInvocations.IsDefaultOrEmpty)
return;

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,11 @@
<PropertyGroup Condition="'$(_QylAutoInstrumentationCoreBuildAssetsAlreadyImported)' != 'true'">
<InterceptorsNamespaces>$(InterceptorsNamespaces);Qyl.OpenTelemetry.AutoInstrumentation.Generated</InterceptorsNamespaces>
</PropertyGroup>

<!-- Lets a consumer hand WebApplicationBuilder.Build() interception to a cooperating generator by
setting <QylAutoInstrumentationInterceptWebApplicationBuilderBuild>false</...>; the qyl source
generator reads this as build_property.* to avoid a CS9153 interceptor collision. -->
<ItemGroup Condition="'$(_QylAutoInstrumentationCoreBuildAssetsAlreadyImported)' != 'true'">
<CompilerVisibleProperty Include="QylAutoInstrumentationInterceptWebApplicationBuilderBuild" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,11 @@
<PropertyGroup Condition="'$(_QylAutoInstrumentationCoreBuildAssetsAlreadyImported)' != 'true'">
<InterceptorsNamespaces>$(InterceptorsNamespaces);Qyl.OpenTelemetry.AutoInstrumentation.Generated</InterceptorsNamespaces>
</PropertyGroup>

<!-- Lets a consumer hand WebApplicationBuilder.Build() interception to a cooperating generator by
setting <QylAutoInstrumentationInterceptWebApplicationBuilderBuild>false</...>; the qyl source
generator reads this as build_property.* to avoid a CS9153 interceptor collision. -->
<ItemGroup Condition="'$(_QylAutoInstrumentationCoreBuildAssetsAlreadyImported)' != 'true'">
<CompilerVisibleProperty Include="QylAutoInstrumentationInterceptWebApplicationBuilderBuild" />
</ItemGroup>
</Project>
1 change: 1 addition & 0 deletions tools/verify-aot-autoinstrumentation-goal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down
125 changes: 125 additions & 0 deletions tools/verify-build-interceptor-optout.py
Original file line number Diff line number Diff line change
@@ -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 <QylAutoInstrumentationInterceptWebApplicationBuilderBuild>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"""<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>{TARGET_FRAMEWORK}</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="{CORE_PROJECT}" />
<ProjectReference Include="{GENERATOR_PROJECT}"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<Compile Remove="Generated/**/*.cs" />
</ItemGroup>

<Import Project="{TARGETS}" />
</Project>
""",
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()