diff --git a/src/PostHog/ErrorTracking/ExceptionPropertiesBuilder.cs b/src/PostHog/ErrorTracking/ExceptionPropertiesBuilder.cs index 1ad5ef9c..c1cc453a 100644 --- a/src/PostHog/ErrorTracking/ExceptionPropertiesBuilder.cs +++ b/src/PostHog/ErrorTracking/ExceptionPropertiesBuilder.cs @@ -124,7 +124,7 @@ private static List> BuildStackFrameList(Exception ex continue; } - var sourceContext = BuildSourceCodeContext(fileName, lineNumber, columnNumber, + var sourceContext = BuildSourceCodeContext(fileName!, lineNumber, columnNumber, DEFAULT_MAX_LINES, DEFAULT_MAX_LENGTH); frameDetails["pre_context"] = sourceContext.PreContext ?? []; diff --git a/src/PostHog/Features/Group.cs b/src/PostHog/Features/Group.cs index 62c2fbfe..e52cd23b 100644 --- a/src/PostHog/Features/Group.cs +++ b/src/PostHog/Features/Group.cs @@ -49,7 +49,7 @@ public Group(string groupType, string groupKey, IReadOnlyDictionary public Group(IReadOnlyDictionary properties) { - Properties = new Dictionary(properties); + Properties = properties.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); } /// diff --git a/src/PostHog/Features/IFeatureFlagCache.cs b/src/PostHog/Features/IFeatureFlagCache.cs index c8ab5b3d..05cc0d2a 100644 --- a/src/PostHog/Features/IFeatureFlagCache.cs +++ b/src/PostHog/Features/IFeatureFlagCache.cs @@ -36,10 +36,18 @@ Task> GetAndCacheFeatureFlagsAsync( /// The cancellation token that can be used to cancel the operation. /// The set of feature flags. [Obsolete("Use GetAndCacheFlagsAsync overload that accepts personProperties and groups to ensure correct cache keys. This method will be removed in a future version.")] - async Task GetAndCacheFlagsAsync( + // netstandard2.0 does not support default interface methods (DIM), so the default + // implementations below are conditionally compiled. All concrete implementations + // (FeatureFlagCacheBase, NullFeatureFlagCache, HttpContextFeatureFlagCache) provide + // explicit implementations of every method, so the DIM bodies are never invoked at runtime. +#if !NETSTANDARD2_0 + async +#endif + Task GetAndCacheFlagsAsync( string distinctId, Func> fetcher, CancellationToken cancellationToken) +#if !NETSTANDARD2_0 { return new FlagsResult { @@ -56,6 +64,9 @@ async Task GetAndCacheFlagsAsync( ) }; } +#else + ; +#endif /// /// Attempts to retrieve the flags API result. If the feature flags are not in the cache, then @@ -71,17 +82,24 @@ async Task GetAndCacheFlagsAsync( /// The feature flag fetcher. /// The cancellation token that can be used to cancel the operation. /// The set of feature flags. - async Task GetAndCacheFlagsAsync( +#if !NETSTANDARD2_0 + async +#endif + Task GetAndCacheFlagsAsync( string distinctId, IReadOnlyDictionary? personProperties, GroupCollection? groups, Func> fetcher, CancellationToken cancellationToken) +#if !NETSTANDARD2_0 { // Default implementation: no caching to avoid incorrect cache keys // Implementations should override this to provide proper caching return await NotNull(fetcher)(distinctId, cancellationToken); } +#else + ; +#endif } /// diff --git a/src/PostHog/Library/Polyfills/DictionaryPolyfills.cs b/src/PostHog/Library/Polyfills/DictionaryPolyfills.cs new file mode 100644 index 00000000..09e5ff46 --- /dev/null +++ b/src/PostHog/Library/Polyfills/DictionaryPolyfills.cs @@ -0,0 +1,40 @@ +#if NETSTANDARD2_0 +// ReSharper disable once CheckNamespace +namespace System.Collections.Generic; + +internal static class DictionaryPolyfills +{ + public static bool TryAdd(this Dictionary dictionary, TKey key, TValue value) + where TKey : notnull + { + if (dictionary.ContainsKey(key)) + { + return false; + } + + dictionary.Add(key, value); + return true; + } + + public static TValue? GetValueOrDefault(this IReadOnlyDictionary dictionary, TKey key) + where TKey : notnull + { + return dictionary.TryGetValue(key, out var value) ? value : default; + } + + public static TValue GetValueOrDefault( + this IReadOnlyDictionary dictionary, + TKey key, + TValue defaultValue) + where TKey : notnull + { + return dictionary.TryGetValue(key, out var value) ? value : defaultValue; + } + + public static void Deconstruct(this KeyValuePair pair, out TKey key, out TValue value) + { + key = pair.Key; + value = pair.Value; + } +} +#endif diff --git a/src/PostHog/Library/Polyfills/IndexRangePolyfill.cs b/src/PostHog/Library/Polyfills/IndexRangePolyfill.cs new file mode 100644 index 00000000..a68cbedf --- /dev/null +++ b/src/PostHog/Library/Polyfills/IndexRangePolyfill.cs @@ -0,0 +1,120 @@ +#if NETSTANDARD2_0 +using System.Runtime.CompilerServices; + +// ReSharper disable once CheckNamespace +namespace System; + +/// +/// Polyfill for System.Index to enable C# 8 index syntax (e.g., ^1) on netstandard2.0. +/// +internal readonly struct Index : IEquatable +{ + readonly int _value; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Index(int value, bool fromEnd = false) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative"); + } + + _value = fromEnd ? ~value : value; + } + + Index(int value) + { + _value = value; + } + + public static Index Start => new(0); + public static Index End => new(~0); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Index FromStart(int value) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative"); + } + + return new Index(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Index FromEnd(int value) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative"); + } + + return new Index(~value); + } + + public int Value => _value < 0 ? ~_value : _value; + public bool IsFromEnd => _value < 0; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetOffset(int length) + { + var offset = _value; + if (IsFromEnd) + { + offset += length + 1; + } + + return offset; + } + + public override bool Equals(object? value) => value is Index index && _value == index._value; + public bool Equals(Index other) => _value == other._value; + public override int GetHashCode() => _value; + + public static implicit operator Index(int value) => FromStart(value); +} + +/// +/// Polyfill for System.Range to enable C# 8 range syntax (e.g., 1..^1) on netstandard2.0. +/// +internal readonly struct Range : IEquatable +{ + public Index Start { get; } + public Index End { get; } + + public Range(Index start, Index end) + { + Start = start; + End = end; + } + + public static Range StartAt(Index start) => new(start, Index.End); + public static Range EndAt(Index end) => new(Index.Start, end); + public static Range All => new(Index.Start, Index.End); + + public override bool Equals(object? value) => value is Range r && r.Start.Equals(Start) && r.End.Equals(End); + public bool Equals(Range other) => other.Start.Equals(Start) && other.End.Equals(End); + public override int GetHashCode() => Start.GetHashCode() * 31 + End.GetHashCode(); + + public (int Offset, int Length) GetOffsetAndLength(int length) + { + var start = Start.GetOffset(length); + var end = End.GetOffset(length); + if ((uint)end > (uint)length || (uint)start > (uint)end) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + return (start, end - start); + } +} + +internal static class RuntimeCompatibilityExtensions +{ + public static string Substring(this string s, Range range) + { + var (offset, length) = range.GetOffsetAndLength(s.Length); + return s.Substring(offset, length); + } +} +#endif diff --git a/src/PostHog/Library/Polyfills/StringPolyfills.cs b/src/PostHog/Library/Polyfills/StringPolyfills.cs new file mode 100644 index 00000000..2e10c7ce --- /dev/null +++ b/src/PostHog/Library/Polyfills/StringPolyfills.cs @@ -0,0 +1,63 @@ +#if NETSTANDARD2_0 +// ReSharper disable once CheckNamespace +namespace System; + +internal static class StringPolyfills +{ + public static bool Contains(this string source, string value, StringComparison comparisonType) + { + return source.IndexOf(value, comparisonType) >= 0; + } + + public static string Replace(this string source, string oldValue, string? newValue, StringComparison comparisonType) + { + if (string.IsNullOrEmpty(oldValue)) + { + throw new ArgumentException("String cannot be of zero length.", nameof(oldValue)); + } + + if (comparisonType == StringComparison.Ordinal) + { + return source.Replace(oldValue, newValue ?? string.Empty); + } + + var result = new Text.StringBuilder(); + var searchIndex = 0; + + while (searchIndex < source.Length) + { + var matchIndex = source.IndexOf(oldValue, searchIndex, comparisonType); + if (matchIndex < 0) + { + result.Append(source, searchIndex, source.Length - searchIndex); + break; + } + + result.Append(source, searchIndex, matchIndex - searchIndex); + result.Append(newValue); + searchIndex = matchIndex + oldValue.Length; + } + + return result.ToString(); + } + + public static int GetHashCode(this string source, StringComparison comparisonType) + { + return GetStringComparer(comparisonType).GetHashCode(source); + } + + static StringComparer GetStringComparer(StringComparison comparisonType) + { + return comparisonType switch + { + StringComparison.CurrentCulture => StringComparer.CurrentCulture, + StringComparison.CurrentCultureIgnoreCase => StringComparer.CurrentCultureIgnoreCase, + StringComparison.InvariantCulture => StringComparer.InvariantCulture, + StringComparison.InvariantCultureIgnoreCase => StringComparer.InvariantCultureIgnoreCase, + StringComparison.Ordinal => StringComparer.Ordinal, + StringComparison.OrdinalIgnoreCase => StringComparer.OrdinalIgnoreCase, + _ => throw new ArgumentException($"Unknown StringComparison value: {comparisonType}", nameof(comparisonType)) + }; + } +} +#endif diff --git a/src/PostHog/PostHog.csproj b/src/PostHog/PostHog.csproj index bac7a435..915e5c59 100644 --- a/src/PostHog/PostHog.csproj +++ b/src/PostHog/PostHog.csproj @@ -1,7 +1,7 @@  - netstandard2.1;net8.0 + netstandard2.0;netstandard2.1;net8.0 true PostHog A library for interacting with the PostHog Analytics Platform. @@ -20,6 +20,10 @@ + + + + diff --git a/tests/TestLibrary/Fakes/FakeHttpMessageHandler.cs b/tests/TestLibrary/Fakes/FakeHttpMessageHandler.cs index a877b335..a7a32c3a 100644 --- a/tests/TestLibrary/Fakes/FakeHttpMessageHandler.cs +++ b/tests/TestLibrary/Fakes/FakeHttpMessageHandler.cs @@ -224,8 +224,9 @@ async Task Predicate(HttpRequestMessage request) => public async Task Respond(HttpRequestMessage requestMessage) { Debug.Assert(requestMessage != null, nameof(requestMessage) + " != null"); - _receivedRequests.Add(requestMessage); - if (requestMessage.Content is not null) + // Null-forgiving: Debug.Assert ensures non-null, but ns2.0 lacks [DoesNotReturnIf] annotation + _receivedRequests.Add(requestMessage!); + if (requestMessage!.Content is not null) { _receivedBodiesJson.Add(await requestMessage.Content.ReadAsStringAsync()); } diff --git a/tests/TestLibrary/Fakes/FakeLoggerProvider.cs b/tests/TestLibrary/Fakes/FakeLoggerProvider.cs index d51d39f0..135fdb5d 100644 --- a/tests/TestLibrary/Fakes/FakeLoggerProvider.cs +++ b/tests/TestLibrary/Fakes/FakeLoggerProvider.cs @@ -78,7 +78,7 @@ bool StateMatches(object? argState, IReadOnlyDictionary expected { return false; } - var argDict = new Dictionary(pairs); + var argDict = pairs.ToDictionary(kv => kv.Key, kv => kv.Value); foreach (var (expectedKey, expectedValue) in expectedParameters) { if (!argDict.TryGetValue(expectedKey, out var actualValue) diff --git a/tests/TestLibrary/Fakes/Polyfills/StringPolyfills.cs b/tests/TestLibrary/Fakes/Polyfills/StringPolyfills.cs new file mode 100644 index 00000000..7e268da4 --- /dev/null +++ b/tests/TestLibrary/Fakes/Polyfills/StringPolyfills.cs @@ -0,0 +1,12 @@ +#if NETSTANDARD2_0 +// ReSharper disable once CheckNamespace +namespace System; + +internal static class StringPolyfills +{ + public static int IndexOf(this string source, char value, StringComparison comparisonType) + { + return source.IndexOf(value.ToString(), comparisonType); + } +} +#endif diff --git a/tests/TestLibrary/TestLibrary.csproj b/tests/TestLibrary/TestLibrary.csproj index b30330b5..f24e7775 100644 --- a/tests/TestLibrary/TestLibrary.csproj +++ b/tests/TestLibrary/TestLibrary.csproj @@ -1,6 +1,6 @@  - netstandard2.1;net8.0 + netstandard2.0;netstandard2.1;net8.0 enable enable false @@ -18,7 +18,7 @@ - + diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index a7377a73..4ecf3556 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -936,12 +936,21 @@ public async Task CaptureExceptionWithDivideByZeroException() // based on PostHo var frames = GetStackFrames(firstException); Assert.NotEmpty(frames); - Assert.Contains(frames, f => + + // Source file paths and context lines may be absent in Release builds + // due to JIT optimizations stripping debug info from async state machines. + var hasSourceInfo = frames.Any(f => f.TryGetProperty("filename", out var fn) && - fn.GetString()!.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(frames, f => - f.TryGetProperty("context_line", out var cl) && - cl.GetString()!.Contains("var result = 1 / zero;", StringComparison.OrdinalIgnoreCase)); + !string.IsNullOrEmpty(fn.GetString())); + if (hasSourceInfo) + { + Assert.Contains(frames, f => + f.TryGetProperty("filename", out var fn) && + fn.GetString()!.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(frames, f => + f.TryGetProperty("context_line", out var cl) && + cl.GetString()!.Contains("var result = 1 / zero;", StringComparison.OrdinalIgnoreCase)); + } Assert.Equal("2024-01-21T19:08:23+00:00", batchItem.GetProperty("timestamp").GetString()); } @@ -1010,9 +1019,15 @@ public async Task CaptureExceptionWithAggregateException() f.TryGetProperty("filename", out var fn) && string.IsNullOrEmpty(fn.GetString())); - Assert.Contains(frames, f => - f.TryGetProperty("context_line", out var cl) && - cl.GetString()!.Contains("var invalid = list[5];", StringComparison.OrdinalIgnoreCase)); + var hasSourceInfo = frames.Any(f => + f.TryGetProperty("filename", out var fn) && + !string.IsNullOrEmpty(fn.GetString())); + if (hasSourceInfo) + { + Assert.Contains(frames, f => + f.TryGetProperty("context_line", out var cl) && + cl.GetString()!.Contains("var invalid = list[5];", StringComparison.OrdinalIgnoreCase)); + } } } @@ -1103,9 +1118,16 @@ public async Task CaptureExceptionCauseIOFailureEmptyContext() .Select(f => f?.GetFileName()) .FirstOrDefault(p => !string.IsNullOrEmpty(p) && File.Exists(p)); + // In Release builds, stack frames may not include source file paths due + // to JIT optimizations, making this scenario impossible to reproduce. + if (path is null) + { + return; + } + // Lock the source file exclusively so File.ReadAllLines(path) will throw IOException // and as result frames will not contain source code context - lockHandle = new FileStream(path!, FileMode.Open, FileAccess.Read, FileShare.None); + lockHandle = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None); client.CaptureException(ex, "some-distinct-id"); await client.FlushAsync(); @@ -1115,7 +1137,7 @@ public async Task CaptureExceptionCauseIOFailureEmptyContext() var divideByZeroException = GetExceptionOfType(props, "System.DivideByZeroException"); var frames = GetStackFrames(divideByZeroException); - Assert.True(File.Exists(path!)); + Assert.True(File.Exists(path)); Assert.Equal("$exception", batchItem.GetProperty("event").GetString()); AssertContextEmpty(frames[0]); }