Skip to content
Draft
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 10 additions & 0 deletions .github/workflows/build-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,13 @@ jobs:
run: dotnet build --configuration Release Exceptionless.Net.NonWindows.slnx
- name: Run Tests
run: dotnet test --configuration Release --no-build Exceptionless.Net.NonWindows.slnx
- name: Pack Core for NativeAOT Test
run: dotnet pack --configuration Release --no-build --output artifacts/aot-package -p:SolutionDir="$GITHUB_WORKSPACE/" src/Exceptionless/Exceptionless.csproj
- name: NativeAOT Smoke Test
run: |
for framework in net8.0 net10.0; do
dotnet publish --configuration Release --framework "$framework" --output "artifacts/aot-smoke/$framework" -p:ExceptionlessPackageVersion="$MINVERVERSIONOVERRIDE" -p:ExceptionlessPackageSource="$GITHUB_WORKSPACE/artifacts/aot-package" test/Exceptionless.PackageAotSmoke/Exceptionless.PackageAotSmoke.csproj
"./artifacts/aot-smoke/$framework/Exceptionless.PackageAotSmoke"
dotnet publish --configuration Release --framework "$framework" --output "artifacts/hosting-aot-smoke/$framework" test/Exceptionless.HostingAotSmoke/Exceptionless.HostingAotSmoke.csproj
"./artifacts/hosting-aot-smoke/$framework/Exceptionless.HostingAotSmoke"
done
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,41 @@ The definition of the word exceptionless is: to be without exception. [Exception

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

### NativeAOT

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:

```csharp
using System.Text.Json.Serialization;
using Exceptionless;
using Exceptionless.Serializer;
using Microsoft.Extensions.DependencyInjection;

var serializer = new DefaultJsonSerializer(AppJsonSerializerContext.Default);
var services = new ServiceCollection();
services.AddSingleton<IJsonSerializer>(serializer);
services.AddSingleton<IStorageSerializer>(serializer);

using var client = new ExceptionlessClient(services, configuration => {
configuration.ApiKey = "YOUR_API_KEY";
configuration.IncludePrivateInformation = false;
});

[JsonSourceGenerationOptions(
GenerationMode = JsonSourceGenerationMode.Metadata,
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
IncludeFields = true,
UseStringEnumConverter = true)]
[JsonSerializable(typeof(MyPayload))]
internal partial class AppJsonSerializerContext : JsonSerializerContext { }

internal sealed class MyPayload {
public int Id { get; set; }
}
```

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. The `net8.0` and later assets do not include the optional IL/PDB demystification used by the legacy target. CI publishes and executes NativeAOT smoke applications on `net8.0` and `net10.0`.

## Getting Started (Development)

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).
Expand Down
2 changes: 1 addition & 1 deletion build/common.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<Copyright>Copyright © $([System.DateTime]::Now.ToString(yyyy)) Exceptionless. All rights reserved.</Copyright>
<Authors>Exceptionless</Authors>
<NoWarn>$(NoWarn);CS1591;NU1701</NoWarn>
<WarningsAsErrors>true</WarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>9.0</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageOutputPath>$(SolutionDir)artifacts</PackageOutputPath>
Expand Down
35 changes: 35 additions & 0 deletions docs/brainstorms/2026-07-12-modern-di-stj-brainstorm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
date: 2026-07-12
topic: modern-di-stj
---

# Modern DI and System.Text.Json

## What We're Building

Modernize the Exceptionless .NET client without requiring consumers to rewrite their existing setup. Replace the vendored internal Newtonsoft.Json fork with System.Text.Json while preserving the collector wire format, persisted queue compatibility, exclusions, depth limits, and arbitrary `DataDictionary` values. Replace TinyIoC with Microsoft.Extensions.DependencyInjection behind the existing `IDependencyResolver`, and add an `IServiceCollection` customization seam for new integrations.

## Why This Approach

A direct removal of `IDependencyResolver` would break configuration extensions, plugins, legacy platforms, isolated clients, and applications that do not use the Generic Host. Keeping TinyIoC indefinitely avoids that break but leaves the client with a private container implementation and prevents normal Microsoft DI customization. The staged adapter approach modernizes the implementation now, makes Microsoft DI the extensibility path, and leaves removal of the compatibility facade for a future major release.

The STJ migration already exists as four focused commits on `niemyjski/drop-json-net-use-stj`. Reusing and rebasing that work is lower-risk than independently recreating its converters and regression fixes.

## Key Decisions

- Preserve `IDependencyResolver` and all existing `ExceptionlessClient` constructors in this release.
- Back `DefaultDependencyResolver` with `Microsoft.Extensions.DependencyInjection` singleton registrations.
- Preserve unregistered concrete-type activation and replacement registration behavior.
- Allow explicit `IServiceCollection` customization before the resolver is used.
- Keep Exceptionless' internally owned provider isolated from an application's root provider; hosted apps continue resolving `ExceptionlessClient` from application DI.
- Treat serialized JSON as a compatibility contract, not merely valid JSON.
- 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.

## Open Questions

- Removing `IDependencyResolver` and making every internal service directly application-DI-owned is deferred to a future major version.
- NativeAOT deliberately uses ordinary runtime stack traces instead of the bundled IL/PDB demystifier because the latter depends on metadata that trimming removes.

## Next Steps

Implement the plan in `docs/plans/2026-07-12-modern-di-stj-plan.md` and verify focused serializer, resolver, storage, hosting, and full non-Windows test coverage.
78 changes: 78 additions & 0 deletions docs/plans/2026-07-12-modern-di-stj-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Modern DI and System.Text.Json implementation plan

## Compatibility baseline

1. Run the current non-Windows solution tests before editing.
2. Inventory exact JSON assertions, storage round-trips, resolver semantics, hosting registrations, and package target frameworks.

## System.Text.Json

1. Rebase the existing `niemyjski/drop-json-net-use-stj` commits onto current `main`.
2. Resolve package-version and current-main conflicts without weakening serializer tests.
3. Retain exact collector property names, enum strings, null/default output, `DataDictionary` raw-value markers, settings coercion, POST data behavior, exclusions, maximum depth, and stream ownership.
4. Run focused serializer, storage, configuration exclusion, submission, and MessagePack tests.

## Dependency injection

1. Add Microsoft.Extensions.DependencyInjection to the core package for all supported targets.
2. Replace TinyIoC in `DefaultDependencyResolver` with an `IServiceCollection`-backed provider.
3. Preserve the existing registration lifetimes: interface/abstract mappings are singletons, concrete mappings and factories are transient, and explicit instances remain externally owned singletons.
4. Preserve isolated resolvers, constructor injection, singleton resolution, unregistered concrete activation, null argument behavior, disposal, and registrations made after an initial resolution.
5. Add a constructor/customization seam that accepts an `IServiceCollection` so new code can use normal Microsoft DI registrations without depending on the legacy methods.
6. Add focused tests for service collection customization, replacement registration, late registration, and disposable singleton ownership.

## Validation and readiness

1. Build the non-Windows solution with warnings as errors.
2. Run the complete non-Windows test suite.
3. Pack the core and modern platform projects to catch dependency/package metadata issues.
4. Inspect the final diff for accidental API breaks and remaining Newtonsoft/TinyIoC references.
5. Document Windows-only validation that still needs CI when it cannot be run on macOS.

## NativeAOT hardening

1. Multi-target the core package for `net8.0` and `net10.0` and enable the built-in AOT, trimming, and single-file analyzers.
2. Use an Exceptionless-owned `JsonSerializerContext` for the wire model and accept a consumer resolver/context for arbitrary event data.
3. Require NativeAOT consumers to register services and custom payload metadata explicitly; retain reflection and unregistered concrete activation only for dynamic-code runtimes.
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.
5. Publish and execute a warning-as-error NativeAOT smoke application for both `net8.0` and `net10.0`, covering default Microsoft DI/default serialization, custom STJ metadata, storage, queues, submission, and nested exception capture in Linux CI.

## Execution results

- Baseline: 300 core tests and 10 MessagePack tests passed; 18 existing tests were skipped.
- Final non-Windows suite: 324 core tests and 11 MessagePack tests passed; 0 failed; 18 tests were skipped.
- Focused DI coverage now includes singleton and transient compatibility, constructor injection, isolated containers, late replacement, `IServiceCollection` overrides, open generics, container disposal, external instance ownership, and host-provider disposal of the default client.
- Serializer coverage retains exact wire-format assertions and adds regressions for exclusions inside collections and settings, depth limits, reference loops in string and stream paths, public fields, the legacy ignore attribute, `DataDictionary` interface mutation, structured/raw values, settings coercion, and MessagePack round-trips.
- `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.
- The non-Windows solution packs successfully. A Windows-shaped core package containing both `net462` and `netstandard2.0` assets also packs successfully with explicit `SolutionDir`.
- `net8.0` and `net10.0` core builds pass with 0 AOT/trim/single-file warnings. Real NativeAOT executables now cover the packed NuGet assets, default Microsoft DI/default serialization, source-generated custom properties and fields, storage round-trips, queued submission, identifiable exception frames, and Generic Host startup/shutdown on both target frameworks. Linux CI publishes and executes the package and hosting smoke applications with linker warnings treated as errors.
- SDK ApiCompat is clean from the previous `netstandard2.0` asset to both the new `netstandard2.0` and `net8.0` assets. Compatibility shims retain the legacy ignore attribute, serializer delegates, and `AssemblyHelper.GetTypes` API.
- Release builds now actually treat warnings as errors; the previous `WarningsAsErrors=true` property was a warning-code list rather than the boolean build gate.

## Release impact

- The non-Windows core package grows from 596,508 bytes to 686,321 bytes (about 15%) because it contains `netstandard2.0`, `net8.0`, and `net10.0` assets instead of one asset. Its uncompressed contents shrink from 2,261,763 bytes to 1,792,690 bytes (about 21%), and the `netstandard2.0` assembly shrinks from 998,912 bytes to 348,160 bytes (about 65%).
- Every package inherits `Microsoft.Extensions.DependencyInjection` 8 through the core package. The `netstandard2.0`, `net462`, and `net8.0` dependency groups also carry System.Text.Json 10, which is the largest deployment and binding-risk change for .NET Framework applications.
- The existing `IDependencyResolver` and public setup APIs remain compatible. Microsoft DI backs the client-owned resolver and exposes an `IServiceCollection` customization seam; registrations from an application's root Generic Host provider are not automatically imported into the client's isolated provider.
- Custom payloads now follow System.Text.Json contracts. Public fields and `ExceptionlessIgnoreAttribute` remain supported, but consumers relying on Newtonsoft-specific contracts such as `DataContract`/`DataMember` behavior should migrate to STJ attributes and source-generated metadata.
- The `net8.0` and later core assets use normal runtime stack traces instead of the legacy IL/PDB demystifier. NativeAOT frames retain parsed method identity, but modern JIT consumers also lose enhanced demystification.
- Although ApiCompat is clean, the serializer behavior, transitive dependency graph, target assets, and modern-stack-trace change are substantial enough to recommend a 7.0 release rather than shipping the work as 6.2.1.

## Remaining release gates

- Run the net462/net472 tests and package-load checks on a real Windows runner; macOS cross-compilation passes but cannot prove .NET Framework runtime binding behavior.
- Require the Linux, macOS, and Windows build jobs in branch protection. The repository currently requires only `license/cla`, so the new regression gates are not merge-enforced yet.
- Push/open the branch only with awareness that non-PR Windows pushes publish prerelease packages to GitHub Packages and Feedz.

## Historical NativeAOT comparison

The same strict NativeAOT client/serialization/storage probe was published and executed against four repository states:

| Commit | State | NativeAOT result |
| --- | --- | --- |
| `5eb567a` | Vendored Newtonsoft + TinyIoC | Published with 10 TinyIoC AOT/trim warnings, then exited 134 because TinyIoC could not construct `DefaultJsonSerializer`. |
| `2dd1d8e` | System.Text.Json + TinyIoC | Same TinyIoC warnings and runtime construction failure. |
| `118123b` | System.Text.Json + Microsoft DI before AOT hardening | Published with 4 DI trim warnings, then exited 134 because the serializer constructor was trimmed. |
| `ef0088b` | Hardened System.Text.Json + Microsoft DI | Published with no product AOT/trim warnings and exited 0 with `AOT_HISTORY_PROBE_OK`. |

The legacy Newtonsoft serializer was also instantiated directly to remove TinyIoC as a confound. Under NativeAOT it serialized a normal POCO as `{}` and then failed built-in `Event` storage serialization because `SnakeCaseNamingStrategy` construction metadata had been trimmed. The pre-modernization client therefore had two independent NativeAOT blockers: TinyIoC and reflection-driven Newtonsoft serialization.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System;

namespace Exceptionless.Json {
/// <summary>
/// Instructs the Exceptionless serializer not to serialize a public field or property.
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
public sealed class ExceptionlessIgnoreAttribute : Attribute { }
}
7 changes: 7 additions & 0 deletions src/Exceptionless/Compatibility/JsonSerializationDelegates.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using System.Collections.Generic;

namespace Exceptionless.Json.Serialization {
public delegate IEnumerable<KeyValuePair<object, object>> ExtensionDataGetter(object o);

public delegate object ObjectConstructor<T>(params object[] args);
}
5 changes: 3 additions & 2 deletions src/Exceptionless/Configuration/ExceptionlessConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net;
using System.Reflection;
Expand Down Expand Up @@ -412,7 +413,7 @@ public void AddPlugin<T>(T plugin) where T : IEventPlugin {
/// Register an plugin to be used in this configuration.
/// </summary>
/// <typeparam name="T">The plugin type to be added.</typeparam>
public void AddPlugin<T>() where T : IEventPlugin {
public void AddPlugin<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() where T : IEventPlugin {
AddPlugin(typeof(T).FullName, typeof(T));
}

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

var plugin = new PluginRegistration(key, GetPriority(pluginType), new Lazy<IEventPlugin>(() => Resolver.Resolve(pluginType) as IEventPlugin));
Expand Down
4 changes: 2 additions & 2 deletions src/Exceptionless/Demystifier/TypeNameHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ private static void ProcessType(StringBuilder builder, Type type, DisplayNameOpt
{
builder.Append(type.Name);
}
else if (type.Assembly.ManifestModule.Name == "FSharp.Core.dll"
else if (type.Assembly.GetName().Name == "FSharp.Core"
&& FSharpTypeNames.TryGetValue(type.Name, out builtInName))
{
builder.Append(builtInName);
Expand Down Expand Up @@ -174,7 +174,7 @@ private static void ProcessGenericType(StringBuilder builder, Type type, Type[]
return;
}

if (type.Assembly.ManifestModule.Name == "FSharp.Core.dll"
if (type.Assembly.GetName().Name == "FSharp.Core"
&& FSharpTypeNames.TryGetValue(type.Name, out var builtInName))
{
builder.Append(builtInName);
Expand Down
Loading
Loading