Skip to content

Commit ef0088b

Browse files
committed
Harden core client for NativeAOT
1 parent 118123b commit ef0088b

25 files changed

Lines changed: 443 additions & 55 deletions

.github/workflows/build-linux.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,7 @@ jobs:
2727
run: dotnet build --configuration Release Exceptionless.Net.NonWindows.slnx
2828
- name: Run Tests
2929
run: dotnet test --configuration Release --no-build Exceptionless.Net.NonWindows.slnx
30+
- name: NativeAOT Smoke Test
31+
run: |
32+
dotnet publish --configuration Release --output artifacts/aot-smoke test/Exceptionless.AotSmoke/Exceptionless.AotSmoke.csproj
33+
./artifacts/aot-smoke/Exceptionless.AotSmoke

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,35 @@ The definition of the word exceptionless is: to be without exception. [Exception
1212

1313
Refer to the Exceptionless documentation here: [Exceptionless Docs](https://exceptionless.com/docs/).
1414

15+
### NativeAOT
16+
17+
The core `Exceptionless` package supports NativeAOT on `net8.0` and later. Exceptionless provides source-generated metadata for its wire models. Applications must provide a `JsonSerializerContext` for custom objects stored in event data and register the resulting serializer before creating the client:
18+
19+
```csharp
20+
using System.Text.Json.Serialization;
21+
using Exceptionless;
22+
using Exceptionless.Serializer;
23+
using Microsoft.Extensions.DependencyInjection;
24+
25+
[JsonSourceGenerationOptions(
26+
GenerationMode = JsonSourceGenerationMode.Metadata,
27+
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
28+
UseStringEnumConverter = true)]
29+
[JsonSerializable(typeof(MyPayload))]
30+
internal partial class AppJsonSerializerContext : JsonSerializerContext { }
31+
32+
var serializer = new DefaultJsonSerializer(AppJsonSerializerContext.Default);
33+
var services = new ServiceCollection();
34+
services.AddSingleton<IJsonSerializer>(serializer);
35+
services.AddSingleton<IStorageSerializer>(serializer);
36+
37+
using var client = new ExceptionlessClient(services, configuration => {
38+
configuration.ApiKey = "YOUR_API_KEY";
39+
});
40+
```
41+
42+
NativeAOT applications must also register custom services and plugins explicitly. Runtime type names from configuration and unregistered concrete-type activation are intentionally unsupported because trimming cannot preserve those types reliably. Error events still contain normal runtime stack traces, but the optional IL/PDB demystification used by legacy targets is disabled.
43+
1544
## Getting Started (Development)
1645

1746
All of our [.NET clients can be installed](https://www.nuget.org/profiles/exceptionless?showAllPackages=True) via the [NuGet package manager](https://docs.nuget.org/consume/Package-Manager-Dialog).

docs/brainstorms/2026-07-12-modern-di-stj-brainstorm.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ The STJ migration already exists as four focused commits on `niemyjski/drop-json
2323
- Allow explicit `IServiceCollection` customization before the resolver is used.
2424
- Keep Exceptionless' internally owned provider isolated from an application's root provider; hosted apps continue resolving `ExceptionlessClient` from application DI.
2525
- Treat serialized JSON as a compatibility contract, not merely valid JSON.
26-
- Keep STJ converters reflection-based for `netstandard2.0` and `net462` compatibility; source generation can be a later optimization.
26+
- Keep reflection fallback for `netstandard2.0` and `net462`, but use built-in source-generated metadata for the modern `net8.0` and `net10.0` assets. NativeAOT consumers register an additional `JsonSerializerContext` for their own payload types.
2727

2828
## Open Questions
2929

3030
- Removing `IDependencyResolver` and making every internal service directly application-DI-owned is deferred to a future major version.
31-
- A separate performance benchmark can decide whether STJ source generation is worth the additional model metadata surface.
31+
- NativeAOT deliberately uses ordinary runtime stack traces instead of the bundled IL/PDB demystifier because the latter depends on metadata that trimming removes.
3232

3333
## Next Steps
3434

docs/plans/2026-07-12-modern-di-stj-plan.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@
2929
4. Inspect the final diff for accidental API breaks and remaining Newtonsoft/TinyIoC references.
3030
5. Document Windows-only validation that still needs CI when it cannot be run on macOS.
3131

32+
## NativeAOT hardening
33+
34+
1. Multi-target the core package for `net8.0` and `net10.0` and enable the built-in AOT, trimming, and single-file analyzers.
35+
2. Use an Exceptionless-owned `JsonSerializerContext` for the wire model and accept a consumer resolver/context for arbitrary event data.
36+
3. Require NativeAOT consumers to register services and custom payload metadata explicitly; retain reflection and unregistered concrete activation only for dynamic-code runtimes.
37+
4. Use regular runtime stack traces on modern targets instead of the IL/PDB demystifier, while keeping exception capture and serialization functional with reduced metadata.
38+
5. Publish and execute a warning-as-error NativeAOT smoke application covering Microsoft DI, custom STJ metadata, storage, queues, submission, and nested exception capture in Linux CI.
39+
3240
## Execution results
3341

3442
- Baseline: 300 core tests and 10 MessagePack tests passed; 18 existing tests were skipped.
@@ -37,3 +45,4 @@
3745
- Serializer coverage retains exact wire-format assertions and adds regressions for exclusions, depth limits, `DataDictionary` structured/raw values, settings coercion, and MessagePack marker round-trips.
3846
- `net462` core and `net472` test assemblies build with 0 warnings and 0 errors when Windows targeting is forced on macOS. Runtime execution of the .NET Framework tests still belongs in Windows CI.
3947
- The non-Windows solution packs successfully. A Windows-shaped core package containing both `net462` and `netstandard2.0` assets also packs successfully with explicit `SolutionDir`.
48+
- `net8.0` and `net10.0` core builds pass with 0 AOT/trim/single-file warnings. A real NativeAOT executable now covers Microsoft DI, source-generated custom payloads, storage round-trips, queued submission, and nested exception capture; Linux CI publishes and runs that executable with linker warnings treated as errors.

src/Exceptionless/Configuration/ExceptionlessConfiguration.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections.Concurrent;
33
using System.Collections.Generic;
44
using System.Diagnostics;
5+
using System.Diagnostics.CodeAnalysis;
56
using System.Linq;
67
using System.Net;
78
using System.Reflection;
@@ -412,7 +413,7 @@ public void AddPlugin<T>(T plugin) where T : IEventPlugin {
412413
/// Register an plugin to be used in this configuration.
413414
/// </summary>
414415
/// <typeparam name="T">The plugin type to be added.</typeparam>
415-
public void AddPlugin<T>() where T : IEventPlugin {
416+
public void AddPlugin<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() where T : IEventPlugin {
416417
AddPlugin(typeof(T).FullName, typeof(T));
417418
}
418419

@@ -421,7 +422,7 @@ public void AddPlugin<T>() where T : IEventPlugin {
421422
/// </summary>
422423
/// <param name="key">The key used to identify the plugin.</param>
423424
/// <param name="pluginType">The plugin type to be added.</param>
424-
public void AddPlugin(string key, Type pluginType) {
425+
public void AddPlugin(string key, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type pluginType) {
425426
RemovePlugin(key);
426427

427428
var plugin = new PluginRegistration(key, GetPriority(pluginType), new Lazy<IEventPlugin>(() => Resolver.Resolve(pluginType) as IEventPlugin));

src/Exceptionless/Demystifier/TypeNameHelper.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ private static void ProcessType(StringBuilder builder, Type type, DisplayNameOpt
101101
{
102102
builder.Append(type.Name);
103103
}
104-
else if (type.Assembly.ManifestModule.Name == "FSharp.Core.dll"
104+
else if (type.Assembly.GetName().Name == "FSharp.Core"
105105
&& FSharpTypeNames.TryGetValue(type.Name, out builtInName))
106106
{
107107
builder.Append(builtInName);
@@ -174,7 +174,7 @@ private static void ProcessGenericType(StringBuilder builder, Type type, Type[]
174174
return;
175175
}
176176

177-
if (type.Assembly.ManifestModule.Name == "FSharp.Core.dll"
177+
if (type.Assembly.GetName().Name == "FSharp.Core"
178178
&& FSharpTypeNames.TryGetValue(type.Name, out var builtInName))
179179
{
180180
builder.Append(builtInName);

src/Exceptionless/Dependency/DefaultDependencyResolver.cs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Diagnostics.CodeAnalysis;
34
using System.Linq;
5+
using System.Runtime.CompilerServices;
46
using Microsoft.Extensions.DependencyInjection;
57

68
namespace Exceptionless.Dependency {
@@ -28,7 +30,7 @@ public DefaultDependencyResolver(IServiceCollection services) {
2830
AddServices(services);
2931
}
3032

31-
public object Resolve(Type serviceType) {
33+
public object Resolve([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type serviceType) {
3234
if (serviceType == null)
3335
throw new ArgumentNullException(nameof(serviceType));
3436

@@ -44,7 +46,7 @@ public object Resolve(Type serviceType) {
4446
}
4547
}
4648

47-
public void Register(Type serviceType, Type concreteType) {
49+
public void Register(Type serviceType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type concreteType) {
4850
if (serviceType == null)
4951
throw new ArgumentNullException(nameof(serviceType));
5052
if (concreteType == null)
@@ -151,7 +153,7 @@ private ServiceProvider GetProvider() {
151153
return _provider;
152154
}
153155

154-
private object CreateInstance(IServiceProvider provider, Type concreteType) {
156+
private object CreateInstance(IServiceProvider provider, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type concreteType) {
155157
return ActivatorUtilities.CreateInstance(new FallbackServiceProvider(this, provider), concreteType);
156158
}
157159

@@ -175,7 +177,7 @@ private static bool CanActivate(Type type) {
175177
return !type.IsAbstract && !type.IsInterface && !type.ContainsGenericParameters;
176178
}
177179

178-
private static bool CanAssign(Type serviceType, Type concreteType) {
180+
private static bool CanAssign(Type serviceType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type concreteType) {
179181
if (!serviceType.IsGenericTypeDefinition)
180182
return serviceType.IsAssignableFrom(concreteType);
181183

@@ -202,6 +204,7 @@ public FallbackServiceProvider(DefaultDependencyResolver resolver, IServiceProvi
202204
_provider = provider;
203205
}
204206

207+
[UnconditionalSuppressMessage("Trimming", "IL2067", Justification = "The unannotated IServiceProvider contract cannot express constructor requirements. NativeAOT rejects this dynamic fallback before activation; AOT callers must register the service.")]
205208
public object GetService(Type serviceType) {
206209
if (serviceType == typeof(IServiceProvider))
207210
return this;
@@ -210,7 +213,15 @@ public object GetService(Type serviceType) {
210213
if (service != null)
211214
return service;
212215

213-
return CanActivate(serviceType) ? _resolver.CreateInstance(_provider, serviceType) : null;
216+
if (!CanActivate(serviceType))
217+
return null;
218+
219+
#if NET8_0_OR_GREATER
220+
if (!RuntimeFeature.IsDynamicCodeSupported)
221+
throw new NotSupportedException($"Type '{serviceType.FullName}' must be registered before it can be resolved in a NativeAOT application.");
222+
#endif
223+
224+
return _resolver.CreateInstance(_provider, serviceType);
214225
}
215226
}
216227
}

src/Exceptionless/Dependency/DependencyResolverExtensions.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Diagnostics.CodeAnalysis;
23
using Exceptionless.Logging;
34
using Exceptionless.Queue;
45
using Exceptionless.Serializer;
@@ -8,22 +9,22 @@
89

910
namespace Exceptionless.Dependency {
1011
public static class DependencyResolverExtensions {
11-
public static bool HasRegistration<TService>(this IDependencyResolver resolver) where TService : class {
12+
public static bool HasRegistration<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService>(this IDependencyResolver resolver) where TService : class {
1213
if (resolver == null)
1314
return false;
1415

1516
return resolver.Resolve(typeof(TService)) != null;
1617
}
1718

18-
public static bool HasDefaultRegistration<TService, TDefaultImplementation>(this IDependencyResolver resolver) where TService : class where TDefaultImplementation : TService {
19+
public static bool HasDefaultRegistration<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService, TDefaultImplementation>(this IDependencyResolver resolver) where TService : class where TDefaultImplementation : TService {
1920
if (resolver == null)
2021
return false;
2122

2223
var instance = resolver.Resolve(typeof(TService));
2324
return instance is TDefaultImplementation;
2425
}
2526

26-
public static object Resolve(this IDependencyResolver resolver, Type type) {
27+
public static object Resolve(this IDependencyResolver resolver, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) {
2728
if (resolver == null)
2829
throw new ArgumentNullException(nameof(resolver));
2930

@@ -33,7 +34,7 @@ public static object Resolve(this IDependencyResolver resolver, Type type) {
3334
return resolver.Resolve(type);
3435
}
3536

36-
public static TService Resolve<TService>(this IDependencyResolver resolver, TService defaultImplementation = null) where TService : class {
37+
public static TService Resolve<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService>(this IDependencyResolver resolver, TService defaultImplementation = null) where TService : class {
3738
if (resolver == null)
3839
throw new ArgumentNullException(nameof(resolver));
3940

@@ -53,14 +54,14 @@ public static void Register<TService>(this IDependencyResolver resolver, TServic
5354
resolver.Register(typeof(TService), () => implementation);
5455
}
5556

56-
public static void Register<TService>(this IDependencyResolver resolver) {
57+
public static void Register<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] TService>(this IDependencyResolver resolver) {
5758
if (resolver == null)
5859
throw new ArgumentNullException(nameof(resolver));
5960

6061
resolver.Register(typeof(TService), typeof(TService));
6162
}
6263

63-
public static void Register<TService, TImplementation>(this IDependencyResolver resolver) where TImplementation : TService {
64+
public static void Register<TService, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] TImplementation>(this IDependencyResolver resolver) where TImplementation : TService {
6465
if (resolver == null)
6566
throw new ArgumentNullException(nameof(resolver));
6667

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
using System;
2+
using System.Diagnostics.CodeAnalysis;
23

34
namespace Exceptionless.Dependency {
45
public interface IDependencyResolver : IDisposable {
5-
object Resolve(Type serviceType);
6-
void Register(Type serviceType, Type concreteType);
6+
object Resolve([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type serviceType);
7+
void Register(Type serviceType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type concreteType);
78
void Register(Type serviceType, Func<object> activator);
89
}
910
}

src/Exceptionless/Exceptionless.csproj

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
<Import Project="..\..\build\common.props" />
33

44
<PropertyGroup Condition=" '$(OS)' != 'Windows_NT' ">
5-
<TargetFramework>netstandard2.0</TargetFramework>
5+
<TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>
66
</PropertyGroup>
77
<PropertyGroup Condition=" '$(OS)' == 'Windows_NT' ">
8-
<TargetFrameworks>netstandard2.0;net462</TargetFrameworks>
8+
<TargetFrameworks>netstandard2.0;net462;net8.0;net10.0</TargetFrameworks>
9+
</PropertyGroup>
10+
11+
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))" Label="AOT">
12+
<IsAotCompatible>true</IsAotCompatible>
913
</PropertyGroup>
1014

1115
<PropertyGroup Label="Package">
@@ -23,11 +27,19 @@
2327
<None Include="readme.txt" pack="true" PackagePath="." />
2428
</ItemGroup>
2529

26-
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'" Label="Package References">
30+
<ItemGroup Condition="'$(TargetFramework)' != 'net462'" Label="Package References">
2731
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
32+
</ItemGroup>
33+
34+
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'" Label="Package References">
2835
<PackageReference Include="System.Reflection.Emit.Lightweight" Version="4.7.0" />
2936
</ItemGroup>
3037

38+
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))" Label="AOT">
39+
<Compile Remove="Demystifier\**\*.cs" />
40+
<Compile Include="Demystifier\TypeNameHelper.cs" />
41+
</ItemGroup>
42+
3143
<ItemGroup>
3244
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
3345
<PackageReference Include="System.Reflection.Metadata" Version="8.0.1" />
@@ -52,4 +64,8 @@
5264
<PropertyGroup Condition="'$(TargetFramework)' == 'net462'" Label="Build">
5365
<DefineConstants>$(DefineConstants);NET45</DefineConstants>
5466
</PropertyGroup>
67+
68+
<PropertyGroup Condition="'$(TargetFramework)' != 'net462'" Label="Build">
69+
<DefineConstants>$(DefineConstants);NETSTANDARD</DefineConstants>
70+
</PropertyGroup>
5571
</Project>

0 commit comments

Comments
 (0)