Skip to content

Commit b1c5040

Browse files
committed
Harden DI and STJ release compatibility
1 parent 8b3e364 commit b1c5040

20 files changed

Lines changed: 699 additions & 136 deletions

File tree

.github/workflows/build-linux.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,13 @@ 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: Pack Core for NativeAOT Test
31+
run: dotnet pack --configuration Release --no-build --output artifacts/aot-package -p:SolutionDir="$GITHUB_WORKSPACE/" src/Exceptionless/Exceptionless.csproj
3032
- name: NativeAOT Smoke Test
3133
run: |
3234
for framework in net8.0 net10.0; do
33-
dotnet publish --configuration Release --framework "$framework" --output "artifacts/aot-smoke/$framework" test/Exceptionless.AotSmoke/Exceptionless.AotSmoke.csproj
34-
"./artifacts/aot-smoke/$framework/Exceptionless.AotSmoke"
35+
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
36+
"./artifacts/aot-smoke/$framework/Exceptionless.PackageAotSmoke"
37+
dotnet publish --configuration Release --framework "$framework" --output "artifacts/hosting-aot-smoke/$framework" test/Exceptionless.HostingAotSmoke/Exceptionless.HostingAotSmoke.csproj
38+
"./artifacts/hosting-aot-smoke/$framework/Exceptionless.HostingAotSmoke"
3539
done

README.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,30 @@ using Exceptionless;
2222
using Exceptionless.Serializer;
2323
using Microsoft.Extensions.DependencyInjection;
2424

25-
[JsonSourceGenerationOptions(
26-
GenerationMode = JsonSourceGenerationMode.Metadata,
27-
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
28-
UseStringEnumConverter = true)]
29-
[JsonSerializable(typeof(MyPayload))]
30-
internal partial class AppJsonSerializerContext : JsonSerializerContext { }
31-
3225
var serializer = new DefaultJsonSerializer(AppJsonSerializerContext.Default);
3326
var services = new ServiceCollection();
3427
services.AddSingleton<IJsonSerializer>(serializer);
3528
services.AddSingleton<IStorageSerializer>(serializer);
3629

3730
using var client = new ExceptionlessClient(services, configuration => {
3831
configuration.ApiKey = "YOUR_API_KEY";
32+
configuration.IncludePrivateInformation = false;
3933
});
34+
35+
[JsonSourceGenerationOptions(
36+
GenerationMode = JsonSourceGenerationMode.Metadata,
37+
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
38+
IncludeFields = true,
39+
UseStringEnumConverter = true)]
40+
[JsonSerializable(typeof(MyPayload))]
41+
internal partial class AppJsonSerializerContext : JsonSerializerContext { }
42+
43+
internal sealed class MyPayload {
44+
public int Id { get; set; }
45+
}
4046
```
4147

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.
48+
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`.
4349

4450
## Getting Started (Development)
4551

build/common.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<Copyright>Copyright © $([System.DateTime]::Now.ToString(yyyy)) Exceptionless. All rights reserved.</Copyright>
1212
<Authors>Exceptionless</Authors>
1313
<NoWarn>$(NoWarn);CS1591;NU1701</NoWarn>
14-
<WarningsAsErrors>true</WarningsAsErrors>
14+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
1515
<LangVersion>9.0</LangVersion>
1616
<GenerateDocumentationFile>true</GenerateDocumentationFile>
1717
<PackageOutputPath>$(SolutionDir)artifacts</PackageOutputPath>

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,29 @@
4040
## Execution results
4141

4242
- Baseline: 300 core tests and 10 MessagePack tests passed; 18 existing tests were skipped.
43-
- Final non-Windows suite: 313 core tests and 10 MessagePack tests passed; 0 failed; the same 18 tests were skipped.
43+
- Final non-Windows suite: 324 core tests and 11 MessagePack tests passed; 0 failed; 18 tests were skipped.
4444
- 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.
45-
- 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.
45+
- 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.
4646
- `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.
4747
- 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. Real NativeAOT executables now cover default Microsoft DI/default serialization, source-generated custom payloads, storage round-trips, queued submission, and nested exception capture on both target frameworks; Linux CI publishes and runs them with linker warnings treated as errors.
48+
- `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.
49+
- 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.
50+
- Release builds now actually treat warnings as errors; the previous `WarningsAsErrors=true` property was a warning-code list rather than the boolean build gate.
51+
52+
## Release impact
53+
54+
- 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%).
55+
- 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.
56+
- 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.
57+
- 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.
58+
- 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.
59+
- 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.
60+
61+
## Remaining release gates
62+
63+
- 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.
64+
- 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.
65+
- Push/open the branch only with awareness that non-PR Windows pushes publish prerelease packages to GitHub Packages and Feedz.
4966

5067
## Historical NativeAOT comparison
5168

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using System;
2+
3+
namespace Exceptionless.Json {
4+
/// <summary>
5+
/// Instructs the Exceptionless serializer not to serialize a public field or property.
6+
/// </summary>
7+
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
8+
public sealed class ExceptionlessIgnoreAttribute : Attribute { }
9+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
using System.Collections.Generic;
2+
3+
namespace Exceptionless.Json.Serialization {
4+
public delegate IEnumerable<KeyValuePair<object, object>> ExtensionDataGetter(object o);
5+
6+
public delegate object ObjectConstructor<T>(params object[] args);
7+
}

src/Exceptionless/Extensions/ToErrorModelExtensions.cs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,11 @@ private static void PopulateStackTrace(this Error error, Error root, Exception e
216216
return;
217217
}
218218

219-
foreach (StackFrame frame in frames) {
219+
string[] rawStackFrames = (exception.StackTrace ?? String.Empty)
220+
.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries);
221+
222+
for (int frameIndex = 0; frameIndex < frames.Length; frameIndex++) {
223+
StackFrame frame = frames[frameIndex];
220224
var stackFrame = new Models.Data.StackFrame {
221225
LineNumber = frame.GetFileLineNumber(),
222226
Column = frame.GetFileColumnNumber(),
@@ -234,17 +238,71 @@ private static void PopulateStackTrace(this Error error, Error root, Exception e
234238

235239
try {
236240
#if NET8_0_OR_GREATER
241+
MethodBase method = frame.GetMethod();
237242
if (RuntimeFeature.IsDynamicCodeSupported)
238-
#endif
243+
stackFrame.PopulateMethod(root, method);
244+
else
245+
stackFrame.PopulateMethodIdentity(method);
246+
#else
239247
stackFrame.PopulateMethod(root, frame.GetMethod());
248+
#endif
240249
} catch (Exception ex) {
241250
log.Error(typeof(ExceptionlessClient), ex, "Error populating StackFrame method info: " + ex.Message);
242251
}
243252

253+
if (String.IsNullOrEmpty(stackFrame.Name) && frameIndex < rawStackFrames.Length)
254+
stackFrame.PopulateMethodIdentity(rawStackFrames[frameIndex]);
255+
244256
error.StackTrace.Add(stackFrame);
245257
}
246258
}
247259

260+
private static void PopulateMethodIdentity(this Method method, string stackTraceLine) {
261+
string identity = stackTraceLine?.Trim();
262+
if (String.IsNullOrEmpty(identity))
263+
return;
264+
265+
if (identity.StartsWith("at ", StringComparison.Ordinal))
266+
identity = identity.Substring(3);
267+
268+
int argumentsStart = identity.IndexOf('(');
269+
if (argumentsStart >= 0)
270+
identity = identity.Substring(0, argumentsStart);
271+
272+
int locationStart = identity.IndexOf(" in ", StringComparison.Ordinal);
273+
if (locationStart >= 0)
274+
identity = identity.Substring(0, locationStart);
275+
276+
int methodSeparator = identity.LastIndexOf('.');
277+
if (methodSeparator < 0) {
278+
method.Name = identity;
279+
return;
280+
}
281+
282+
method.Name = identity.Substring(methodSeparator + 1);
283+
string declaringType = identity.Substring(0, methodSeparator);
284+
int namespaceSeparator = declaringType.LastIndexOf('.');
285+
if (namespaceSeparator < 0) {
286+
method.DeclaringType = declaringType;
287+
return;
288+
}
289+
290+
method.DeclaringNamespace = declaringType.Substring(0, namespaceSeparator);
291+
method.DeclaringType = declaringType.Substring(namespaceSeparator + 1);
292+
}
293+
294+
private static void PopulateMethodIdentity(this Method method, MethodBase methodBase) {
295+
if (methodBase == null)
296+
return;
297+
298+
method.Name = methodBase.Name;
299+
if (methodBase.DeclaringType == null)
300+
return;
301+
302+
method.DeclaringNamespace = methodBase.DeclaringType.Namespace;
303+
method.DeclaringType = methodBase.DeclaringType.Name;
304+
}
305+
248306
private static void PopulateMethod(this Method method, Error root, MethodBase methodBase) {
249307
if (methodBase == null)
250308
return;

src/Exceptionless/Models/Collections/DataDictionary.cs

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
using System;
2+
using System.Collections;
23
using System.Collections.Generic;
34

45
namespace Exceptionless.Models {
5-
public class DataDictionary : Dictionary<string, object> {
6-
private readonly HashSet<string> _rawJsonKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
6+
public class DataDictionary : Dictionary<string, object>, IDictionary<string, object>, IDictionary {
7+
private readonly Dictionary<string, string> _rawJsonValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
78

89
public DataDictionary() : base(StringComparer.OrdinalIgnoreCase) {}
910

@@ -31,7 +32,7 @@ public DataDictionary(IEnumerable<KeyValuePair<string, object>> values) : base(S
3132
}
3233

3334
public new void Clear() {
34-
_rawJsonKeys.Clear();
35+
_rawJsonValues.Clear();
3536
base.Clear();
3637
}
3738

@@ -61,20 +62,109 @@ public string GetString(string name, string @default) {
6162
return String.Empty;
6263
}
6364

64-
internal bool IsRawJson(string key) {
65-
return !String.IsNullOrEmpty(key) && _rawJsonKeys.Contains(key);
65+
internal bool IsRawJson(string key, object value) {
66+
return !String.IsNullOrEmpty(key)
67+
&& value is string stringValue
68+
&& _rawJsonValues.TryGetValue(key, out string rawJson)
69+
&& ReferenceEquals(stringValue, rawJson);
6670
}
6771

6872
internal void SetRawJson(string key, string value) {
6973
base[key] = value;
7074

7175
if (!String.IsNullOrEmpty(key))
72-
_rawJsonKeys.Add(key);
76+
_rawJsonValues[key] = value;
7377
}
7478

7579
private void ClearRawJson(string key) {
7680
if (!String.IsNullOrEmpty(key))
77-
_rawJsonKeys.Remove(key);
81+
_rawJsonValues.Remove(key);
82+
}
83+
84+
object IDictionary<string, object>.this[string key] {
85+
get => this[key];
86+
set => this[key] = value;
87+
}
88+
89+
ICollection<string> IDictionary<string, object>.Keys => base.Keys;
90+
ICollection<object> IDictionary<string, object>.Values => base.Values;
91+
int ICollection<KeyValuePair<string, object>>.Count => base.Count;
92+
bool ICollection<KeyValuePair<string, object>>.IsReadOnly => false;
93+
94+
void IDictionary<string, object>.Add(string key, object value) => Add(key, value);
95+
bool IDictionary<string, object>.ContainsKey(string key) => base.ContainsKey(key);
96+
bool IDictionary<string, object>.Remove(string key) => Remove(key);
97+
bool IDictionary<string, object>.TryGetValue(string key, out object value) => base.TryGetValue(key, out value);
98+
99+
void ICollection<KeyValuePair<string, object>>.Add(KeyValuePair<string, object> item) => Add(item.Key, item.Value);
100+
void ICollection<KeyValuePair<string, object>>.Clear() => Clear();
101+
bool ICollection<KeyValuePair<string, object>>.Contains(KeyValuePair<string, object> item) {
102+
return base.TryGetValue(item.Key, out object value)
103+
&& EqualityComparer<object>.Default.Equals(value, item.Value);
104+
}
105+
void ICollection<KeyValuePair<string, object>>.CopyTo(KeyValuePair<string, object>[] array, int arrayIndex) {
106+
foreach (var item in this)
107+
array[arrayIndex++] = item;
108+
}
109+
bool ICollection<KeyValuePair<string, object>>.Remove(KeyValuePair<string, object> item) {
110+
if (!((ICollection<KeyValuePair<string, object>>)this).Contains(item))
111+
return false;
112+
113+
return Remove(item.Key);
114+
}
115+
116+
IEnumerator<KeyValuePair<string, object>> IEnumerable<KeyValuePair<string, object>>.GetEnumerator() => base.GetEnumerator();
117+
IEnumerator IEnumerable.GetEnumerator() => base.GetEnumerator();
118+
119+
object IDictionary.this[object key] {
120+
get => key is string stringKey && base.TryGetValue(stringKey, out object value) ? value : null;
121+
set {
122+
if (!(key is string stringKey))
123+
throw new ArgumentException("DataDictionary keys must be strings.", nameof(key));
124+
125+
this[stringKey] = value;
126+
}
127+
}
128+
129+
ICollection IDictionary.Keys => base.Keys;
130+
ICollection IDictionary.Values => base.Values;
131+
bool IDictionary.IsReadOnly => false;
132+
bool IDictionary.IsFixedSize => false;
133+
int ICollection.Count => base.Count;
134+
bool ICollection.IsSynchronized => false;
135+
object ICollection.SyncRoot => this;
136+
137+
void IDictionary.Add(object key, object value) {
138+
if (!(key is string stringKey))
139+
throw new ArgumentException("DataDictionary keys must be strings.", nameof(key));
140+
141+
Add(stringKey, value);
142+
}
143+
void IDictionary.Clear() => Clear();
144+
bool IDictionary.Contains(object key) => key is string stringKey && base.ContainsKey(stringKey);
145+
IDictionaryEnumerator IDictionary.GetEnumerator() => new DataDictionaryEnumerator(base.GetEnumerator());
146+
void IDictionary.Remove(object key) {
147+
if (key is string stringKey)
148+
Remove(stringKey);
149+
}
150+
void ICollection.CopyTo(Array array, int index) {
151+
foreach (var item in this)
152+
array.SetValue(new DictionaryEntry(item.Key, item.Value), index++);
153+
}
154+
155+
private sealed class DataDictionaryEnumerator : IDictionaryEnumerator {
156+
private readonly IEnumerator<KeyValuePair<string, object>> _inner;
157+
158+
public DataDictionaryEnumerator(IEnumerator<KeyValuePair<string, object>> inner) {
159+
_inner = inner;
160+
}
161+
162+
public DictionaryEntry Entry => new DictionaryEntry(Key, Value);
163+
public object Key => _inner.Current.Key;
164+
public object Value => _inner.Current.Value;
165+
public object Current => Entry;
166+
public bool MoveNext() => _inner.MoveNext();
167+
public void Reset() => _inner.Reset();
78168
}
79169
}
80-
}
170+
}

0 commit comments

Comments
 (0)