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
26 changes: 26 additions & 0 deletions .github/workflows/otlp-collector-fixtures.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: qyl-otlp-collector-fixtures

on:
push:
pull_request:

jobs:
otlp-collector-fixtures:
name: otlp collector fixtures (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

- name: Verify collector-backed OTLP transport fixtures
run: python3 tools/verify-otlp-collector-fixtures.py
26 changes: 26 additions & 0 deletions .github/workflows/smoketest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: qyl-smoketest

on:
push:
pull_request:

jobs:
smoke:
name: smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

- name: Run PackageReference and ProjectReference smoke tests
run: bash tools/smoketest.sh
29 changes: 29 additions & 0 deletions .github/workflows/webapi-aot-demo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: qyl-webapi-aot-demo

on:
pull_request:
push:
branches:
- main
- claude/**

jobs:
webapi-aot-demo:
name: webapi-aot-demo (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

- name: Publish and run NativeAOT web API demo
run: python3 tools/verify-webapi-aot-demo.py
11 changes: 9 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<!--
Root-of-repo MSBuild defaults shared by every project under src/.

Rationale (substrate-swap, post v0.2.0-pre.1):
Rationale (substrate-swap, post v0.3.0-pre.1):
The runtime used to be the OpenTelemetry .NET auto-instrumentation native CLR profiler;
we now ship a pure-managed runtime that runs under NativeAOT-published apps. Runtime
projects MUST stay AOT-clean: trim analyzer, AOT analyzer, and single-file analyzer are
Expand Down Expand Up @@ -34,7 +34,7 @@
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>

<!-- One version source of truth across all packed projects. Bumped on every milestone-bumping commit. -->
<Version>0.2.0-pre.1</Version>
<Version>0.3.0-pre.1</Version>
<Authors>qyl</Authors>
<RepositoryUrl>https://github.com/ANcpLua/qyl-dotnet-autoinstrumentation</RepositoryUrl>
<RepositoryType>git</RepositoryType>
Expand All @@ -51,6 +51,8 @@
<ErrorProneNETStructsVersion>0.6.1-beta.1</ErrorProneNETStructsVersion>
<RoslynatorAnalyzersVersion>4.15.0</RoslynatorAnalyzersVersion>
<JonSkeetRoslynAnalyzersVersion>1.0.0-beta.6</JonSkeetRoslynAnalyzersVersion>
<MicrosoftCodeAnalysisPublicApiAnalyzersVersion>3.3.4</MicrosoftCodeAnalysisPublicApiAnalyzersVersion>
<QylEnablePublicApiAnalyzers Condition="'$(MSBuildProjectName)' == 'Qyl.AutoInstrumentation' or '$(MSBuildProjectName)' == 'Qyl.AutoInstrumentation.DiagnosticListeners' or '$(MSBuildProjectName)' == 'Qyl.AutoInstrumentation.Hosting' or '$(MSBuildProjectName)' == 'Qyl.AutoInstrumentation.EntityFrameworkCore' or '$(MSBuildProjectName)' == 'Qyl.AutoInstrumentation.SqlClient'">true</QylEnablePublicApiAnalyzers>
</PropertyGroup>

<ItemGroup>
Expand Down Expand Up @@ -82,6 +84,11 @@
Version="$(JonSkeetRoslynAnalyzersVersion)"
PrivateAssets="all"
IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" />
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers"
Condition="'$(QylEnablePublicApiAnalyzers)' == 'true'"
Version="$(MicrosoftCodeAnalysisPublicApiAnalyzersVersion)"
PrivateAssets="all"
IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" />
</ItemGroup>

</Project>
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ current compile-time lane classifies the contract as 33 source-generated signal
unsupported NativeAOT parity/dynamic signal promises, seven global controls, and 16 instrumentation
options. Nothing is silently dropped; unsupported parity requires an explicit reason.

## Substrate-swap note (v0.2.0-pre.1)
## Substrate-swap note (v0.3.0-pre.1)

Pre-v0.2.0 the runtime was the OpenTelemetry .NET auto-instrumentation native CLR profiler,
attached via `CORECLR_PROFILER` / `OTEL_DOTNET_AUTO_PLUGINS` / a `dotnet tool` (`qyl install`)
Expand Down Expand Up @@ -55,6 +55,21 @@ Runtime projects inherit `IsAotCompatible`, trim, AOT, and single-file analyzers
`PublishAot=true` excludes the generator project so NativeAOT publish never tries to publish a
build-time Roslyn assembly.

Zero-code interception is a package build-asset contract. A normal consumer uses
`PackageReference` to `Qyl.AutoInstrumentation`, which supplies the analyzer plus `build/` and
`buildTransitive/` targets for `InterceptsLocationAttribute` and the interceptor namespace.
Source-tree dogfooding through `ProjectReference` must make the same build-time pieces explicit:
reference `Qyl.AutoInstrumentation.csproj` as runtime, reference
`Qyl.AutoInstrumentation.SourceGenerators.csproj` with
`OutputItemType="Analyzer" ReferenceOutputAssembly="false"`, and import
`src/Qyl.AutoInstrumentation/buildTransitive/Qyl.AutoInstrumentation.targets`. For NativeAOT
publish in that source-tree path, prebuild the generator and include its output DLL as an
`Analyzer`; do not let `PublishAot=true` traverse the netstandard2.0 generator project. A bare
runtime `ProjectReference` is not a supported zero-code path because MSBuild resolves it as a
reference assembly, not as compiler analyzer/build assets.
`tools/verify-projectreference-behavior.py` proves the supported ProjectReference dogfooding path
under managed execution and NativeAOT.

Current evidence proves the qyl runtime closure is NativeAOT-clean and emits spans under
NativeAOT. Library-specific packages call out upstream app-side warning boundaries when the
instrumented library itself is not warning-clean. The active generator direction is compile-time
Expand Down Expand Up @@ -119,6 +134,11 @@ The agent emits runtime values only when the instrumented library supplied them
`DiagnosticSource` payloads or the current `Activity`. It does not invent fallback URLs, database
names, SQL statements, routes, methods, status codes, or RPC methods.

The semconv conformance processor is a development gate, not a production hot-path cost. Its
`qyl.semconv.attribute.checks` counter is default-off and runs only when
`QYL_CONFORMANCE_ENABLED=1` is set or the host calls
`AddQylAutoInstrumentation(o => o.EnableConformanceProcessor = true)`.

Semantic rules live in the diagnostic listener layer:

- Stable OpenTelemetry keys are emitted; deprecated aliases such as `http.url`, `http.status_code`,
Expand Down
210 changes: 210 additions & 0 deletions benchmarks/Qyl.AutoInstrumentation.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
using System.Data;
using System.Data.Common;
using System.Collections;
using System.Net;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using Qyl.AutoInstrumentation;

if (args.Contains("--smoke", StringComparer.Ordinal))
{
await BenchmarkSmoke.RunAsync();
return;
}

BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);

internal static class BenchmarkSmoke
{
public static async Task RunAsync()
{
using var httpClient = new HttpClientHotPathBenchmarks();
await httpClient.InterceptedGetAsync();

var dbCommand = new DbCommandHotPathBenchmarks();
dbCommand.InterceptedSqlClientCommand();

var efCore = new EntityFrameworkCoreHotPathBenchmarks();
efCore.InterceptedExecuteSqlRaw();
}
}

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net10_0, launchCount: 1, warmupCount: 3, iterationCount: 5)]
[SimpleJob(RuntimeMoniker.NativeAot10_0, launchCount: 1, warmupCount: 3, iterationCount: 5)]
public class HttpClientHotPathBenchmarks : IDisposable
{
private readonly HttpClient httpClient = new(new StaticHttpMessageHandler())
{
BaseAddress = new Uri("https://example.invalid"),
};

[Benchmark(Baseline = true)]
public async Task<int> DirectGetAsync()
{
using var response = await httpClient.GetAsync("/", HttpCompletionOption.ResponseHeadersRead);
return (int)response.StatusCode;
}

[Benchmark]
public async Task<int> InterceptedGetAsync()
{
using var response = await QylInterceptedHttpClient.GetAsync(httpClient, "/", HttpCompletionOption.ResponseHeadersRead);
return (int)response.StatusCode;
}

public void Dispose() => httpClient.Dispose();
}

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net10_0, launchCount: 1, warmupCount: 3, iterationCount: 5)]
[SimpleJob(RuntimeMoniker.NativeAot10_0, launchCount: 1, warmupCount: 3, iterationCount: 5)]
public class DbCommandHotPathBenchmarks
{
private readonly BenchmarkDbCommand command = new()
{
CommandText = "SELECT 1",
CommandType = CommandType.Text,
};

[Benchmark(Baseline = true)]
public int DirectSqlClientCommand() => command.CommandText!.Length;

[Benchmark]
public int InterceptedSqlClientCommand()
{
using var activity = QylInterceptedDbCommand.StartActivity(
command,
QylAutoInstrumentationIds.SqlClient,
"ExecuteScalar");

return activity is null ? 0 : 1;
}
}

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net10_0, launchCount: 1, warmupCount: 3, iterationCount: 5)]
[SimpleJob(RuntimeMoniker.NativeAot10_0, launchCount: 1, warmupCount: 3, iterationCount: 5)]
public class EntityFrameworkCoreHotPathBenchmarks
{
private const string Statement = "INSERT INTO qyl_benchmark(value) VALUES (1)";

[Benchmark(Baseline = true)]
public int DirectExecuteSqlRaw() => Statement.Length;

[Benchmark]
public int InterceptedExecuteSqlRaw()
{
using var activity = QylInterceptedEntityFrameworkCore.StartActivity(Statement);

return activity is null ? 0 : 1;
}
}

internal sealed class StaticHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)
{
RequestMessage = request,
});
}
}

internal sealed class BenchmarkDbCommand : DbCommand
{
#pragma warning disable CS8765
public override string CommandText { get; set; } = string.Empty;
#pragma warning restore CS8765

public override int CommandTimeout { get; set; }

public override CommandType CommandType { get; set; }

public override bool DesignTimeVisible { get; set; }

public override UpdateRowSource UpdatedRowSource { get; set; }

protected override DbConnection? DbConnection { get; set; }

protected override DbParameterCollection DbParameterCollection => EmptyDbParameterCollection.Instance;

protected override DbTransaction? DbTransaction { get; set; }

public override void Cancel()
{
}

public override int ExecuteNonQuery() => 0;

public override object ExecuteScalar() => 1;

public override void Prepare()
{
}

protected override DbParameter CreateDbParameter() => throw new NotSupportedException();

protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw new NotSupportedException();
}

internal sealed class EmptyDbParameterCollection : DbParameterCollection
{
public static readonly EmptyDbParameterCollection Instance = new();

private EmptyDbParameterCollection()
{
}

public override int Count => 0;

public override object SyncRoot => this;

public override int Add(object value) => throw new NotSupportedException();

public override void AddRange(Array values) => throw new NotSupportedException();

public override void Clear()
{
}

public override bool Contains(object value) => false;

public override bool Contains(string value) => false;

public override void CopyTo(Array array, int index)
{
}

public override IEnumerator GetEnumerator() => Array.Empty<DbParameter>().GetEnumerator();

public override int IndexOf(object value) => -1;

public override int IndexOf(string parameterName) => -1;

public override void Insert(int index, object value) => throw new NotSupportedException();

public override void Remove(object value)
{
}

public override void RemoveAt(int index)
{
}

public override void RemoveAt(string parameterName)
{
}

protected override DbParameter GetParameter(int index) => throw new ArgumentOutOfRangeException(nameof(index));

protected override DbParameter GetParameter(string parameterName) => throw new ArgumentOutOfRangeException(nameof(parameterName));

protected override void SetParameter(int index, DbParameter value) => throw new NotSupportedException();

protected override void SetParameter(string parameterName, DbParameter value) => throw new NotSupportedException();
}
Loading
Loading