diff --git a/src/Exceptionless.Core/Models/Data/Error.cs b/src/Exceptionless.Core/Models/Data/Error.cs index 07a6c1ce8d..ff7299199a 100644 --- a/src/Exceptionless.Core/Models/Data/Error.cs +++ b/src/Exceptionless.Core/Models/Data/Error.cs @@ -12,6 +12,7 @@ public class Error : InnerError public static class KnownDataKeys { public const string ExtraProperties = "@ext"; + public const string SourceMap = "@source_map"; public const string TargetInfo = "@target"; } diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/15_SourceMapPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/15_SourceMapPlugin.cs index a92c8bc692..7f227a7f11 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/15_SourceMapPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/15_SourceMapPlugin.cs @@ -38,7 +38,8 @@ public override async Task EventProcessingAsync(EventContext context) context.Project.Id, context.EventPostInfo?.ClientKeyHash, String.Equals(context.Organization.PlanId, _billingPlans.FreePlan.Id, StringComparison.OrdinalIgnoreCase)); - if (await _sourceMapService.SymbolicateAsync(request, error)) + var result = await _sourceMapService.ProcessAsync(request, error); + if (result.Modified) context.Event.SetError(error); } } diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs index 0705417883..cb8394df32 100644 --- a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs @@ -19,8 +19,9 @@ public sealed class SourceMapService : IDisposable private static readonly TimeSpan FailureCacheLifetime = TimeSpan.FromMinutes(15); private static readonly TimeSpan DeletedProjectCacheLifetime = TimeSpan.FromDays(1); private const int MaximumLocalUsageEntries = 100_000; + private const int MaximumFailureDetails = 5; private readonly SemaphoreSlim _downloadSemaphore; - private readonly ConcurrentDictionary>> _inflightSourceMaps = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary>> _inflightSourceMaps = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _parsedSourceMapEntries = new(StringComparer.Ordinal); private readonly MemoryCache _parsedSourceMaps; private readonly MemoryCache _recentlyTrackedUsages; @@ -30,6 +31,7 @@ public sealed class SourceMapService : IDisposable private readonly ICacheClient _cache; private readonly ILockProvider _lockProvider; private readonly SourceMapOptions _options; + private readonly JsonSerializerOptions _serializerOptions; private readonly TimeSpan _usageCacheLifetime; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; @@ -51,6 +53,7 @@ public SourceMapService( _cache = cache; _lockProvider = lockProvider; _options = options.SourceMapOptions; + _serializerOptions = serializerOptions; _usageCacheLifetime = TimeSpan.FromDays(Math.Max(_options.FreeArtifactRetentionDays, _options.ArtifactRetentionDays) + 1L); _downloadSemaphore = new SemaphoreSlim(_options.MaximumConcurrentDownloads); _parsedSourceMaps = new MemoryCache(new MemoryCacheOptions { SizeLimit = _options.MaximumParsedSourceMapCacheSize }); @@ -161,28 +164,61 @@ public async Task DeleteProjectArtifactsAsync(string projectId, CancellationToke _inflightSourceMaps.TryRemove(key, out _); } - public Task SymbolicateAsync(string projectId, InnerError? error, CancellationToken cancellationToken = default) - => SymbolicateAsync(new SourceMapRequest(projectId, projectId, null, false), error, cancellationToken); + public async Task SymbolicateAsync(string projectId, InnerError? error, CancellationToken cancellationToken = default) + => (await ProcessAsync(new SourceMapRequest(projectId, projectId, null, false), error, cancellationToken)).Symbolicated; internal async Task SymbolicateAsync(SourceMapRequest request, InnerError? error, CancellationToken cancellationToken = default) + => (await ProcessAsync(request, error, cancellationToken)).Symbolicated; + + internal async Task ProcessAsync(SourceMapRequest request, InnerError? error, CancellationToken cancellationToken = default) { - bool changed = false; + if (error is null) + return default; + + bool symbolicated = false; + bool hasSymbolicatedFrames = false; int framesProcessed = 0; + bool failureDetailsTruncated = false; + bool processingTruncated = false; + bool processingDeferred = false; + var deferredGeneratedFileUrls = new HashSet(StringComparer.Ordinal); + string? activeGeneratedFileUrl = null; + var failures = new List(); + InnerError rootError = error; using var processingCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); processingCancellationTokenSource.CancelAfter(_options.MaximumProcessingTime); try { - while (error is not null) + while (error is not null && !processingTruncated) { if (error.StackTrace is not null) { foreach (var frame in error.StackTrace) { if (++framesProcessed > _options.MaximumFramesPerError) - return changed; - - if (await SymbolicateFrameAsync(request, frame, processingCancellationTokenSource.Token)) - changed = true; + { + processingTruncated = true; + break; + } + + if (frame.Data?.ContainsKey(StackFrame.KnownDataKeys.SourceMap) == true) + hasSymbolicatedFrames = true; + activeGeneratedFileUrl = frame.FileName; + var result = await SymbolicateFrameAsync(request, frame, processingCancellationTokenSource.Token); + if (result.Symbolicated) + { + symbolicated = true; + hasSymbolicatedFrames = true; + } + if (result.Failure is not null) + AddFailure(failures, result.Failure, ref failureDetailsTruncated); + if (result.IsDeferred) + { + processingDeferred = true; + if (TryNormalizeGeneratedFileUrl(activeGeneratedFileUrl, requireHttps: false, out var deferredGeneratedFileUri)) + deferredGeneratedFileUrls.Add(deferredGeneratedFileUri.AbsoluteUri); + } + activeGeneratedFileUrl = null; } } @@ -192,9 +228,45 @@ internal async Task SymbolicateAsync(SourceMapRequest request, InnerError? catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { _logger.LogDebug("Source map processing exceeded its time budget for project {ProjectId}.", request.ProjectId); + if (TryNormalizeGeneratedFileUrl(activeGeneratedFileUrl, requireHttps: false, out var generatedFileUri)) + AddFailure(failures, new SourceMapFailure(generatedFileUri.AbsoluteUri, SourceMapFailureReasons.Timeout), ref failureDetailsTruncated); + } + + bool existingSourceMapStatusMerged = false; + if (processingDeferred + && rootError.Data is not null + && rootError.Data.ContainsKey(Error.KnownDataKeys.SourceMap)) + { + var existingStatus = rootError.Data.GetValue(Error.KnownDataKeys.SourceMap, _serializerOptions); + if (existingStatus is not null) + { + MergeDeferredFailures(existingStatus, deferredGeneratedFileUrls, failures, ref failureDetailsTruncated); + existingSourceMapStatusMerged = true; + } } - return changed; + bool sourceMapStatusModified; + if (failures.Count > 0 || processingTruncated) + { + rootError.Data ??= new DataDictionary(); + rootError.Data[Error.KnownDataKeys.SourceMap] = new DataDictionary + { + ["status"] = hasSymbolicatedFrames ? "partial" : "failed", + ["failures"] = failures.Select(f => new DataDictionary + { + ["generated_file_name"] = f.GeneratedFileUrl, + ["reason"] = f.Reason + }).ToArray(), + ["processing_truncated"] = processingTruncated, + ["truncated"] = failureDetailsTruncated + }; + sourceMapStatusModified = true; + } + else + sourceMapStatusModified = (!processingDeferred || existingSourceMapStatusMerged) + && rootError.Data?.Remove(Error.KnownDataKeys.SourceMap) == true; + + return new SourceMapProcessingResult(symbolicated, symbolicated || sourceMapStatusModified); } public void Dispose() @@ -204,25 +276,29 @@ public void Dispose() _downloadSemaphore.Dispose(); } - private async Task SymbolicateFrameAsync(SourceMapRequest request, StackFrame frame, CancellationToken cancellationToken) + private async Task SymbolicateFrameAsync(SourceMapRequest request, StackFrame frame, CancellationToken cancellationToken) { if (frame.Data?.ContainsKey(StackFrame.KnownDataKeys.SourceMap) == true || frame.LineNumber is null || frame.LineNumber < 1 || frame.Column is null || frame.Column < 1 || String.IsNullOrWhiteSpace(frame.FileName)) - return false; + return default; if (!TryNormalizeGeneratedFileUrl(frame.FileName, requireHttps: false, out var generatedFileUri)) - return false; + return default; - var resolved = await GetSourceMapAsync(request, generatedFileUri, cancellationToken); - if (resolved is null) - return false; + var lookup = await GetSourceMapAsync(request, generatedFileUri, cancellationToken); + if (lookup.SourceMap is null) + { + if (lookup.FailureReason is not null) + return new SourceMapFrameResult(false, new SourceMapFailure(generatedFileUri.AbsoluteUri, lookup.FailureReason)); + return lookup.IsDeferred ? new SourceMapFrameResult(false, null, true) : default; + } int generatedColumn = frame.Column.Value - 1; - var original = resolved.Document.FindOriginalLocation(frame.LineNumber.Value - 1, generatedColumn); + var original = lookup.SourceMap.Document.FindOriginalLocation(frame.LineNumber.Value - 1, generatedColumn); if (original is null) - return false; - if (!await TrackUsageAsync(request.ProjectId, resolved.Artifact.Id, cancellationToken)) - return false; + return new SourceMapFrameResult(false, new SourceMapFailure(generatedFileUri.AbsoluteUri, SourceMapFailureReasons.NoMatchingMapping)); + if (!await TrackUsageAsync(request.ProjectId, lookup.SourceMap.Artifact.Id, cancellationToken)) + return new SourceMapFrameResult(false, null, true); frame.Data ??= new DataDictionary(); frame.Data[StackFrame.KnownDataKeys.SourceMap] = new DataDictionary @@ -231,31 +307,71 @@ private async Task SymbolicateFrameAsync(SourceMapRequest request, StackFr ["generated_line_number"] = frame.LineNumber, ["generated_column"] = frame.Column, ["generated_name"] = frame.Name, - ["source_map_id"] = resolved.Artifact.Id + ["source_map_id"] = lookup.SourceMap.Artifact.Id }; frame.FileName = original.Source; frame.LineNumber = original.Line + 1; frame.Column = original.Column + 1; frame.Name = String.IsNullOrWhiteSpace(original.Name) ? null : original.Name; - return true; + return new SourceMapFrameResult(true, null); + } + + private static void AddFailure(List failures, SourceMapFailure failure, ref bool failureDetailsTruncated) + { + if (failures.Any(f => String.Equals(f.GeneratedFileUrl, failure.GeneratedFileUrl, StringComparison.Ordinal))) + return; + + if (failures.Count < MaximumFailureDetails) + failures.Add(failure); + else + failureDetailsTruncated = true; + } + + private void MergeDeferredFailures( + DataDictionary existingStatus, + HashSet deferredGeneratedFileUrls, + List failures, + ref bool failureDetailsTruncated) + { + if (!existingStatus.ContainsKey("failures")) + return; + + var existingFailures = existingStatus.GetValue("failures", _serializerOptions); + if (existingFailures is null) + return; + + foreach (var existingFailure in existingFailures) + { + string? generatedFileUrl = existingFailure.GetString("generated_file_name"); + string? reason = existingFailure.GetString("reason"); + if (generatedFileUrl is null || reason is null || !deferredGeneratedFileUrls.Contains(generatedFileUrl)) + continue; + + AddFailure(failures, new SourceMapFailure(generatedFileUrl, reason), ref failureDetailsTruncated); + } + + if (deferredGeneratedFileUrls.Count > 0 + && existingStatus.ContainsKey("truncated") + && existingStatus.GetValue("truncated", _serializerOptions)) + failureDetailsTruncated = true; } - private async Task GetSourceMapAsync(SourceMapRequest request, Uri generatedFileUri, CancellationToken cancellationToken) + private async Task GetSourceMapAsync(SourceMapRequest request, Uri generatedFileUri, CancellationToken cancellationToken) { string cacheKey = GetMemoryCacheKey(request.ProjectId, generatedFileUri.AbsoluteUri); if (_parsedSourceMaps.TryGetValue(cacheKey, out ResolvedSourceMap? cached) && cached is not null) { long cacheVersion = await GetProjectCacheVersionAsync(request.ProjectId); if (cached.CacheVersion == cacheVersion && !ShouldRefresh(cached.Artifact, generatedFileUri)) - return cached; + return SourceMapLookupResult.Resolved(cached); _parsedSourceMaps.Remove(cacheKey); } - var lazy = _inflightSourceMaps.GetOrAdd(cacheKey, _ => new Lazy>( + var lazy = _inflightSourceMaps.GetOrAdd(cacheKey, _ => new Lazy>( () => LoadAndCacheSourceMapAsync(request, generatedFileUri, cacheKey), LazyThreadSafetyMode.ExecutionAndPublication)); - Task loadTask = lazy.Value; + Task loadTask = lazy.Value; try { @@ -270,32 +386,32 @@ private async Task SymbolicateFrameAsync(SourceMapRequest request, StackFr } } - private async Task LoadAndCacheSourceMapAsync(SourceMapRequest request, Uri generatedFileUri, string cacheKey) + private async Task LoadAndCacheSourceMapAsync(SourceMapRequest request, Uri generatedFileUri, string cacheKey) { long cacheVersion = await GetProjectCacheVersionAsync(request.ProjectId); var resolved = await LoadSourceMapAsync(request, generatedFileUri, cacheVersion); long currentCacheVersion = await GetProjectCacheVersionAsync(request.ProjectId); - if ((resolved is null && cacheVersion != currentCacheVersion) - || (resolved is not null && resolved.CacheVersion != currentCacheVersion)) + if ((resolved.SourceMap is null && cacheVersion != currentCacheVersion) + || (resolved.SourceMap is not null && resolved.SourceMap.CacheVersion != currentCacheVersion)) { resolved = await LoadSourceMapAsync(request, generatedFileUri, currentCacheVersion); } - if (resolved is not null && resolved.Document.EstimatedMemorySize <= _options.MaximumParsedSourceMapCacheSize) + if (resolved.SourceMap is not null && resolved.SourceMap.Document.EstimatedMemorySize <= _options.MaximumParsedSourceMapCacheSize) { - _parsedSourceMapEntries[cacheKey] = resolved; + _parsedSourceMapEntries[cacheKey] = resolved.SourceMap; var cacheOptions = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = _options.ParsedSourceMapCacheLifetime, - Size = Math.Max(1, resolved.Document.EstimatedMemorySize) + Size = Math.Max(1, resolved.SourceMap.Document.EstimatedMemorySize) }.RegisterPostEvictionCallback( static (key, _, _, state) => { if (key is string evictedCacheKey && state is ParsedSourceMapCacheRegistration registration) registration.Service.RemoveParsedSourceMapEntry(evictedCacheKey, registration.SourceMap); }, - new ParsedSourceMapCacheRegistration(this, resolved)); - _parsedSourceMaps.Set(cacheKey, resolved, cacheOptions); + new ParsedSourceMapCacheRegistration(this, resolved.SourceMap)); + _parsedSourceMaps.Set(cacheKey, resolved.SourceMap, cacheOptions); } return resolved; @@ -303,8 +419,8 @@ private async Task SymbolicateFrameAsync(SourceMapRequest request, StackFr private async Task RemoveInflightSourceMapWhenCompleteAsync( string cacheKey, - Lazy> lazy, - Task loadTask) + Lazy> lazy, + Task loadTask) { try { @@ -318,10 +434,10 @@ private async Task RemoveInflightSourceMapWhenCompleteAsync( RemoveInflightSourceMap(cacheKey, lazy); } - private void RemoveInflightSourceMap(string cacheKey, Lazy> lazy) + private void RemoveInflightSourceMap(string cacheKey, Lazy> lazy) { - ICollection>>> entries = _inflightSourceMaps; - entries.Remove(new KeyValuePair>>(cacheKey, lazy)); + ICollection>>> entries = _inflightSourceMaps; + entries.Remove(new KeyValuePair>>(cacheKey, lazy)); } private void RemoveParsedSourceMapEntry(string cacheKey, ResolvedSourceMap sourceMap) @@ -349,7 +465,7 @@ private async Task WaitForInflightArtifactAsync(string projectId, string artifac await WaitForInflightSourceMapAsync(sourceMap, cancellationToken); } - private static async Task WaitForInflightSourceMapAsync(Lazy> sourceMap, CancellationToken cancellationToken) + private static async Task WaitForInflightSourceMapAsync(Lazy> sourceMap, CancellationToken cancellationToken) { try { @@ -365,7 +481,7 @@ private static async Task WaitForInflightSourceMapAsync(Lazy LoadSourceMapAsync(SourceMapRequest request, Uri generatedFileUri, long cacheVersion) + private async Task LoadSourceMapAsync(SourceMapRequest request, Uri generatedFileUri, long cacheVersion) { string projectId = request.ProjectId; string generatedFileUrl = generatedFileUri.AbsoluteUri; @@ -376,11 +492,12 @@ private static async Task WaitForInflightSourceMapAsync(Lazy(failureCacheKey)).HasValue) - return null; + var cachedFailure = await _cache.GetAsync(failureCacheKey); + if (cachedFailure.HasValue) + return SourceMapLookupResult.Failed(cachedFailure.Value); try { @@ -390,27 +507,34 @@ private static async Task WaitForInflightSourceMapAsync(Lazy new(artifact, SourceMapDocument.Parse(content, _options.MaximumMappingSegments), cacheVersion); + private SourceMapLookupResult Resolve(SourceMapArtifact artifact, byte[] content, long cacheVersion) + => SourceMapLookupResult.Resolved(new ResolvedSourceMap(artifact, SourceMapDocument.Parse(content, _options.MaximumMappingSegments), cacheVersion)); + + private static string GetFailureReason(Exception exception) + => exception is JsonException or FormatException + ? SourceMapFailureReasons.Invalid + : SourceMapFailureReasons.Unavailable; + + private Task CacheExpectedFailureAsync(string failureCacheKey, string generatedFileUrl, Exception exception, string reason) + { + _logger.LogDebug( + "Unable to download a source map for {GeneratedFileUrl}: {FailureType}: {FailureMessage}", + generatedFileUrl, + exception.GetType().Name, + exception.Message); + return CacheFailureAsync(failureCacheKey, reason); + } private bool ShouldRefresh(SourceMapArtifact artifact, Uri generatedFileUri) => artifact.IsAutoDownloaded @@ -646,10 +793,10 @@ private Task RemoveUsageTrackingAsync(string projectId, string artifactId) _cache.RemoveAsync(GetLastUsedCacheKey(projectId, artifactId))); } - private async Task CacheFailureAsync(string failureCacheKey) + private async Task CacheFailureAsync(string failureCacheKey, string reason) { - await _cache.SetAsync(failureCacheKey, true, FailureCacheLifetime); - return null; + await _cache.SetAsync(failureCacheKey, reason, FailureCacheLifetime); + return SourceMapLookupResult.Failed(reason); } private async Task TryAcquireGlobalDownloadSlotAsync(string artifactId, CancellationToken cancellationToken) @@ -692,7 +839,7 @@ public static bool TryNormalizeGeneratedFileUrl(string? value, bool requireHttps private static string GetProjectCacheVersionKey(string projectId) => $"source-maps:cache-version:{projectId}"; private static string GetDeletedProjectCacheKey(string projectId) => $"source-maps:project-deleted:{projectId}"; private static string GetMemoryCacheKey(string projectId, string generatedFileUrl) => $"{projectId}:{generatedFileUrl}"; - private static string GetFailureCacheKey(string projectId, string generatedFileUrl) => $"source-maps:failure:{projectId}:{generatedFileUrl.ToSHA256()}"; + private static string GetFailureCacheKey(string projectId, string generatedFileUrl) => $"source-maps:failure:v2:{projectId}:{generatedFileUrl.ToSHA256()}"; private static string GetUsagePendingCacheKey() => "source-maps:usage:pending"; private static string GetLastUsedCacheKey(string projectId, string artifactId) => $"source-maps:usage:last:{projectId}:{artifactId}"; @@ -704,9 +851,28 @@ public static bool TryNormalizeGeneratedFileUrl(string? value, bool requireHttps } private sealed record ResolvedSourceMap(SourceMapArtifact Artifact, SourceMapDocument Document, long CacheVersion); + private sealed record SourceMapFailure(string GeneratedFileUrl, string Reason); + private readonly record struct SourceMapFrameResult(bool Symbolicated, SourceMapFailure? Failure, bool IsDeferred = false); + private readonly record struct SourceMapLookupResult(ResolvedSourceMap? SourceMap, string? FailureReason, bool IsDeferred = false) + { + public static SourceMapLookupResult Deferred => new(null, null, true); + public static SourceMapLookupResult Failed(string reason) => new(null, reason); + public static SourceMapLookupResult Resolved(ResolvedSourceMap sourceMap) => new(sourceMap, null); + } private sealed record ParsedSourceMapCacheRegistration(SourceMapService Service, ResolvedSourceMap SourceMap); } +internal readonly record struct SourceMapProcessingResult(bool Symbolicated, bool Modified); + +internal static class SourceMapFailureReasons +{ + public const string Invalid = "invalid"; + public const string NoMatchingMapping = "no_matching_mapping"; + public const string NotFound = "not_found"; + public const string Timeout = "timeout"; + public const string Unavailable = "unavailable"; +} + internal sealed record SourceMapUsageKey(string ProjectId, string ArtifactId); public sealed class SourceMapStorageLimitException(string message) : InvalidOperationException(message); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/source-map-status.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/source-map-status.svelte new file mode 100644 index 0000000000..b39b28be0e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/source-map-status.svelte @@ -0,0 +1,84 @@ + + +{#if failures.length > 0 || processingTruncated} + + + {#snippet child({ props })} + + {/snippet} + + + + {title} + + {#if failures.length > 0} +

+ Exceptionless couldn't map {failures.length === 1 ? 'a JavaScript file' : `${failures.length} JavaScript files`} to original source. The + stack trace may be minified. Uploading a source map will improve new events. +

+
    + {#each failures as failure (failure.generated_file_name)} +
  • + {failure.generated_file_name} + — {getFailureDescription(failure)} +
  • + {/each} + {#if sourceMapStatus?.truncated}
  • Additional generated files were omitted.
  • {/if} +
+ {/if} + {#if processingTruncated} +

0}> + Exceptionless stopped checking source maps after reaching the stack-frame processing limit. Some frames may remain minified. +

+ {/if} +
+
+
+ +
+
+
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/source-map-status.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/source-map-status.svelte.test.ts new file mode 100644 index 0000000000..69a4d199a2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/source-map-status.svelte.test.ts @@ -0,0 +1,62 @@ +import type { ErrorInfo } from '$features/events/models/event-data'; + +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import SourceMapStatus from './source-map-status.svelte'; + +const projectId = '507f1f77bcf86cd799439011'; + +describe('SourceMapStatus', () => { + it('shows source map failure details on demand', async () => { + const error: ErrorInfo = { + data: { + '@source_map': { + failures: [ + { + generated_file_name: 'https://cdn.example.com/assets/app.min.js', + reason: 'invalid' + } + ], + status: 'failed' + } + } + }; + + render(SourceMapStatus, { error, projectId }); + + const trigger = screen.getByRole('button', { name: 'Source map unavailable' }); + expect(trigger).toBeTruthy(); + expect(screen.queryByText('https://cdn.example.com/assets/app.min.js')).toBeNull(); + + await fireEvent.click(trigger); + + expect(screen.getByText('https://cdn.example.com/assets/app.min.js')).toBeTruthy(); + expect(screen.getByText(/downloaded source map is invalid or unsupported/i)).toBeTruthy(); + expect(screen.getByRole('link', { name: 'Manage source maps' }).getAttribute('href')).toBe(`/next/project/${projectId}/source-maps`); + }); + + it('does not render without failure metadata', () => { + render(SourceMapStatus, { error: {}, projectId }); + + expect(screen.queryByRole('button', { name: /source map/i })).toBeNull(); + }); + + it('shows processing limit details on demand', async () => { + const error: ErrorInfo = { + data: { + '@source_map': { + failures: [], + processing_truncated: true, + status: 'failed' + } + } + }; + + render(SourceMapStatus, { error, projectId }); + + await fireEvent.click(screen.getByRole('button', { name: 'Source map unavailable' })); + + expect(screen.getByText(/stack-frame processing limit/i)).toBeTruthy(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/error.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/error.svelte index b8b444fe20..281039ed82 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/error.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/error.svelte @@ -11,6 +11,7 @@ import ExtendedDataItem from '../extended-data-item.svelte'; import SimpleStackTrace from '../simple-stack-trace/simple-stack-trace.svelte'; + import SourceMapStatus from '../stack-trace/source-map-status.svelte'; import StackTrace from '../stack-trace/stack-trace.svelte'; interface Props { @@ -63,7 +64,10 @@

Stack Trace

-
+
+ {#if event.data?.['@error']} + + {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte index 93476d176d..a46ec9baac 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte @@ -25,6 +25,7 @@ import LogLevel from '../log-level.svelte'; import SessionEventDuration from '../session-event-duration.svelte'; import SimpleStackTrace from '../simple-stack-trace/simple-stack-trace.svelte'; + import SourceMapStatus from '../stack-trace/source-map-status.svelte'; import StackTrace from '../stack-trace/stack-trace.svelte'; interface Props { @@ -237,7 +238,10 @@ {#if hasError}

Stack Trace

-
+
+ {#if event.data?.['@error']} + + {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/models/event-data.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/models/event-data.ts index fc7df6290f..f048a6768a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/models/event-data.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/models/event-data.ts @@ -23,6 +23,7 @@ export interface ErrorInfo extends InnerErrorInfo { export interface IErrorData extends Record { '@ext'?: Record | string; + '@source_map'?: SourceMapStatusInfo; '@target'?: ITargetErrorData; } @@ -116,6 +117,18 @@ export interface SimpleErrorInfo { type?: string; } +export interface SourceMapFailureInfo { + generated_file_name: string; + reason: string; +} + +export interface SourceMapStatusInfo { + failures: SourceMapFailureInfo[]; + processing_truncated?: boolean; + status: 'failed' | 'partial' | string; + truncated?: boolean; +} + export interface StackFrameInfo extends MethodInfo { column?: number; file_name?: string; diff --git a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs index 138370e38d..18b588fe01 100644 --- a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs @@ -466,6 +466,7 @@ public async Task SymbolicateAsync_WhenExpiredSourceMapCannotRefresh_DoesNotUseS public async Task SymbolicateAsync_WhenDownloadedSourceMapIsInvalid_DoesNotPersistArtifact() { int requestCount = 0; + var logger = new CollectingLogger(); var handler = new DelegateHandler(request => { requestCount++; @@ -479,12 +480,424 @@ public async Task SymbolicateAsync_WhenDownloadedSourceMapIsInvalid_DoesNotPersi return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("not a source map") }; }); using var httpClient = new HttpClient(handler); - using var service = CreateService(httpClient); + using var service = CreateService(httpClient, logger: logger); + var error = CreateError(); - Assert.False(await service.SymbolicateAsync(ProjectId, CreateError(), TestContext.Current.CancellationToken)); + Assert.False(await service.SymbolicateAsync(ProjectId, error, TestContext.Current.CancellationToken)); Assert.Empty(await service.GetArtifactsAsync(ProjectId, TestContext.Current.CancellationToken)); - Assert.False(await service.SymbolicateAsync(ProjectId, CreateError(), TestContext.Current.CancellationToken)); + var sourceMapStatus = Assert.IsType(error.Data![Error.KnownDataKeys.SourceMap]); + Assert.Equal("failed", sourceMapStatus["status"]); + var failure = Assert.Single(Assert.IsType(sourceMapStatus["failures"])); + Assert.Equal(GeneratedFileUrl, failure["generated_file_name"]); + Assert.Equal(SourceMapFailureReasons.Invalid, failure["reason"]); + + var cachedFailureError = CreateError(); + Assert.False(await service.SymbolicateAsync(ProjectId, cachedFailureError, TestContext.Current.CancellationToken)); + var cachedSourceMapStatus = Assert.IsType(cachedFailureError.Data![Error.KnownDataKeys.SourceMap]); + Assert.Equal(SourceMapFailureReasons.Invalid, Assert.Single(Assert.IsType(cachedSourceMapStatus["failures"]))["reason"]); Assert.Equal(2, requestCount); + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Debug, entry.Level); + Assert.Null(entry.Exception); + Assert.Contains("Unable to download a source map", entry.Message); + } + + [Fact] + public async Task SymbolicateAsync_WhenSourceMapBecomesAvailable_ClearsFailureDiagnostics() + { + var handler = new DelegateHandler(request => + { + if (request.RequestUri == new Uri(GeneratedFileUrl)) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("minified") }; + response.Headers.TryAddWithoutValidation("SourceMap", "app.min.js.map"); + return response; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("not a source map") }; + }); + using var httpClient = new HttpClient(handler); + using var service = CreateService(httpClient); + var error = CreateError(); + + Assert.False(await service.SymbolicateAsync(ProjectId, error, TestContext.Current.CancellationToken)); + Assert.True(error.Data?.ContainsKey(Error.KnownDataKeys.SourceMap)); + + await using (var sourceMapStream = new MemoryStream(SourceMap)) + await service.SaveUploadedAsync(ProjectId, GeneratedFileUrl, "app.min.js.map", sourceMapStream, TestContext.Current.CancellationToken); + + Assert.True(await service.SymbolicateAsync(ProjectId, error, TestContext.Current.CancellationToken)); + Assert.False(error.Data?.ContainsKey(Error.KnownDataKeys.SourceMap)); + } + + [Fact] + public async Task SymbolicateAsync_WithExistingMappedFrameAndFailure_PreservesPartialStatus() + { + using var httpClient = new HttpClient(new DelegateHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))); + using var service = CreateService(httpClient); + string suffix = Guid.NewGuid().ToString("N"); + string missingFileUrl = $"https://cdn.example.com/{suffix}/missing.js"; + var error = CreateError(); + var mappedFrame = Assert.Single(error.StackTrace!); + mappedFrame.FileName = "src/app.ts"; + mappedFrame.Data = new DataDictionary + { + [StackFrame.KnownDataKeys.SourceMap] = new DataDictionary() + }; + error.StackTrace!.Add(new StackFrame + { + FileName = missingFileUrl, + LineNumber = 1, + Column = 1, + Name = "b" + }); + error.Data = new DataDictionary + { + [Error.KnownDataKeys.SourceMap] = new DataDictionary { ["status"] = "partial" } + }; + + Assert.False(await service.SymbolicateAsync($"project-{suffix}", error, TestContext.Current.CancellationToken)); + + var sourceMapStatus = Assert.IsType(error.Data[Error.KnownDataKeys.SourceMap]); + Assert.Equal("partial", sourceMapStatus["status"]); + Assert.Equal(missingFileUrl, Assert.Single(Assert.IsType(sourceMapStatus["failures"]))["generated_file_name"]); + } + + [Fact] + public async Task SymbolicateAsync_WhenProcessingBudgetExpires_RecordsTimeoutFailure() + { + var options = GetService(); + options.SourceMapOptions.MaximumProcessingTimeMilliseconds = 1000; + var requestStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseRequest = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new AsyncDelegateHandler(async _ => + { + requestStarted.TrySetResult(); + await releaseRequest.Task; + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + using var httpClient = new HttpClient(handler); + using var service = CreateService(httpClient); + string suffix = Guid.NewGuid().ToString("N"); + string generatedFileUrl = $"https://cdn.example.com/{suffix}/app.min.js"; + var error = CreateError(generatedFileUrl); + + Task symbolication = service.SymbolicateAsync($"project-{suffix}", error, TestContext.Current.CancellationToken); + await requestStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + + try + { + Assert.False(await symbolication); + var sourceMapStatus = Assert.IsType(error.Data![Error.KnownDataKeys.SourceMap]); + var failure = Assert.Single(Assert.IsType(sourceMapStatus["failures"])); + Assert.Equal(generatedFileUrl, failure["generated_file_name"]); + Assert.Equal(SourceMapFailureReasons.Timeout, failure["reason"]); + } + finally + { + releaseRequest.TrySetResult(); + await service.DeleteProjectArtifactsAsync($"project-{suffix}", TestContext.Current.CancellationToken); + } + } + + [Fact] + public async Task SymbolicateAsync_WhenFrameLimitIsReached_RecordsProcessingTruncation() + { + var options = GetService(); + options.SourceMapOptions.MaximumFramesPerError = 1; + var service = GetService(); + var error = CreateError("native"); + error.StackTrace!.Add(new StackFrame + { + FileName = GeneratedFileUrl, + LineNumber = 1, + Column = 1, + Name = "b" + }); + + Assert.False(await service.SymbolicateAsync(ProjectId, error, TestContext.Current.CancellationToken)); + + var sourceMapStatus = Assert.IsType(error.Data![Error.KnownDataKeys.SourceMap]); + Assert.True(Assert.IsType(sourceMapStatus["processing_truncated"])); + Assert.Empty(Assert.IsType(sourceMapStatus["failures"])); + } + + [Fact] + public async Task SymbolicateAsync_WhenAutoDownloadIsDisabled_RecordsMissingSourceMap() + { + var options = GetService(); + options.SourceMapOptions.EnableAutoDownload = false; + int requestCount = 0; + using var httpClient = new HttpClient(new DelegateHandler(_ => + { + requestCount++; + return new HttpResponseMessage(HttpStatusCode.NotFound); + })); + using var service = CreateService(httpClient); + string suffix = Guid.NewGuid().ToString("N"); + var error = CreateError($"https://cdn.example.com/{suffix}/app.min.js"); + + Assert.False(await service.SymbolicateAsync($"project-{suffix}", error, TestContext.Current.CancellationToken)); + + var sourceMapStatus = Assert.IsType(error.Data![Error.KnownDataKeys.SourceMap]); + var failure = Assert.Single(Assert.IsType(sourceMapStatus["failures"])); + Assert.Equal(SourceMapFailureReasons.NotFound, failure["reason"]); + Assert.Equal(0, requestCount); + } + + [Fact] + public async Task SymbolicateAsync_WhenDownloadedMapCannotBePersisted_LogsOperatorWarning() + { + var logger = new CollectingLogger(); + var handler = new DelegateHandler(request => + { + if (request.RequestUri == new Uri(GeneratedFileUrl)) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("minified") }; + response.Headers.TryAddWithoutValidation("SourceMap", "app.min.js.map"); + return response; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(SourceMap) }; + }); + using var httpClient = new HttpClient(handler); + using var service = CreateService(httpClient, new SaveFailingFileStorage(GetService()), logger); + var error = CreateError(); + + Assert.False(await service.SymbolicateAsync(ProjectId, error, TestContext.Current.CancellationToken)); + + var sourceMapStatus = Assert.IsType(error.Data![Error.KnownDataKeys.SourceMap]); + Assert.Equal(SourceMapFailureReasons.Unavailable, Assert.Single(Assert.IsType(sourceMapStatus["failures"]))["reason"]); + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.IsType(entry.Exception); + Assert.Contains("Unable to persist a source map", entry.Message); + } + + [Fact] + public async Task SymbolicateAsync_WhenDiscoveryIsDeferred_PreservesExistingDiagnostics() + { + var options = GetService(); + options.SourceMapOptions.MaximumAutoDiscoveriesPerProject = 0; + int requestCount = 0; + using var httpClient = new HttpClient(new DelegateHandler(_ => + { + requestCount++; + return new HttpResponseMessage(HttpStatusCode.NotFound); + })); + using var service = CreateService(httpClient); + string suffix = Guid.NewGuid().ToString("N"); + string generatedFileUrl = $"https://cdn.example.com/{suffix}/app.min.js"; + var existingStatus = new DataDictionary + { + ["failures"] = new[] + { + new DataDictionary + { + ["generated_file_name"] = generatedFileUrl, + ["reason"] = SourceMapFailureReasons.NotFound + } + }, + ["status"] = "failed" + }; + var error = CreateError(generatedFileUrl); + error.Data = new DataDictionary { [Error.KnownDataKeys.SourceMap] = existingStatus }; + + Assert.False(await service.SymbolicateAsync($"project-{suffix}", error, TestContext.Current.CancellationToken)); + + var sourceMapStatus = Assert.IsType(error.Data[Error.KnownDataKeys.SourceMap]); + Assert.Equal("failed", sourceMapStatus["status"]); + var failure = Assert.Single(Assert.IsType(sourceMapStatus["failures"])); + Assert.Equal(generatedFileUrl, failure["generated_file_name"]); + Assert.Equal(SourceMapFailureReasons.NotFound, failure["reason"]); + Assert.Equal(0, requestCount); + } + + [Fact] + public async Task SymbolicateAsync_WithMixedRetryOutcomes_MergesDeferredAndDefinitiveDiagnostics() + { + var options = GetService(); + options.SourceMapOptions.MaximumAutoDiscoveriesPerProject = 1; + string suffix = Guid.NewGuid().ToString("N"); + string mappedFileUrl = $"https://cdn.example.com/{suffix}/mapped.js"; + string invalidFileUrl = $"https://cdn.example.com/{suffix}/invalid.js"; + string deferredFileUrl = $"https://cdn.example.com/{suffix}/deferred.js"; + var handler = new DelegateHandler(request => + { + if (request.RequestUri == new Uri(invalidFileUrl)) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("minified") }; + response.Headers.TryAddWithoutValidation("SourceMap", "invalid.js.map"); + return response; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("not a source map") }; + }); + using var httpClient = new HttpClient(handler); + using var service = CreateService(httpClient); + string projectId = $"project-{suffix}"; + await using (var sourceMapStream = new MemoryStream(SourceMap)) + await service.SaveUploadedAsync(projectId, mappedFileUrl, "mapped.js.map", sourceMapStream, TestContext.Current.CancellationToken); + + var error = CreateError(mappedFileUrl); + error.StackTrace!.Add(new StackFrame { FileName = invalidFileUrl, LineNumber = 1, Column = 1, Name = "b" }); + error.StackTrace.Add(new StackFrame { FileName = deferredFileUrl, LineNumber = 1, Column = 1, Name = "c" }); + error.Data = new DataDictionary + { + [Error.KnownDataKeys.SourceMap] = new DataDictionary + { + ["status"] = "failed", + ["failures"] = new[] + { + new DataDictionary + { + ["generated_file_name"] = mappedFileUrl, + ["reason"] = SourceMapFailureReasons.NotFound + }, + new DataDictionary + { + ["generated_file_name"] = deferredFileUrl, + ["reason"] = SourceMapFailureReasons.Unavailable + } + } + } + }; + var serializer = GetService(); + error = serializer.Deserialize(serializer.SerializeToString(error))!; + + Assert.True(await service.SymbolicateAsync(projectId, error, TestContext.Current.CancellationToken)); + + var sourceMapStatus = Assert.IsType(error.Data![Error.KnownDataKeys.SourceMap]); + Assert.Equal("partial", sourceMapStatus["status"]); + var failures = Assert.IsType(sourceMapStatus["failures"]); + Assert.Collection( + failures, + failure => + { + Assert.Equal(invalidFileUrl, failure["generated_file_name"]); + Assert.Equal(SourceMapFailureReasons.Invalid, failure["reason"]); + }, + failure => + { + Assert.Equal(deferredFileUrl, failure["generated_file_name"]); + Assert.Equal(SourceMapFailureReasons.Unavailable, failure["reason"]); + }); + } + + [Fact] + public async Task SymbolicateAsync_WhenFailedFileRecoversAndAnotherLookupDefers_ClearsRecoveredDiagnostic() + { + var options = GetService(); + options.SourceMapOptions.MaximumAutoDiscoveriesPerProject = 0; + string suffix = Guid.NewGuid().ToString("N"); + string mappedFileUrl = $"https://cdn.example.com/{suffix}/mapped.js"; + string deferredFileUrl = $"https://cdn.example.com/{suffix}/deferred.js"; + using var httpClient = new HttpClient(new DelegateHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))); + using var service = CreateService(httpClient); + string projectId = $"project-{suffix}"; + await using (var sourceMapStream = new MemoryStream(SourceMap)) + await service.SaveUploadedAsync(projectId, mappedFileUrl, "mapped.js.map", sourceMapStream, TestContext.Current.CancellationToken); + + var error = CreateError(mappedFileUrl); + error.StackTrace!.Add(new StackFrame { FileName = deferredFileUrl, LineNumber = 1, Column = 1, Name = "b" }); + error.Data = new DataDictionary + { + [Error.KnownDataKeys.SourceMap] = new DataDictionary + { + ["status"] = "failed", + ["failures"] = new[] + { + new DataDictionary + { + ["generated_file_name"] = mappedFileUrl, + ["reason"] = SourceMapFailureReasons.NotFound + } + } + } + }; + var serializer = GetService(); + error = serializer.Deserialize(serializer.SerializeToString(error))!; + + Assert.True(await service.SymbolicateAsync(projectId, error, TestContext.Current.CancellationToken)); + Assert.False(error.Data?.ContainsKey(Error.KnownDataKeys.SourceMap)); + } + + [Fact] + public async Task EventProcessingAsync_WhenSourceMapDownloadFails_PersistsFailureDiagnostics() + { + var handler = new DelegateHandler(request => + { + if (request.RequestUri == new Uri(GeneratedFileUrl)) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("minified") }; + response.Headers.TryAddWithoutValidation("SourceMap", "app.min.js.map"); + return response; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("not a source map") }; + }); + using var httpClient = new HttpClient(handler); + using var service = CreateService(httpClient); + var serializer = GetService(); + var options = GetService(); + var sourceMapPlugin = new SourceMapPlugin(service, serializer, GetService(), options, GetService()); + var errorPlugin = new ErrorPlugin(serializer, options, GetService()); + var persistentEvent = new PersistentEvent { Type = Event.KnownTypes.Error }; + persistentEvent.SetError(CreateError()); + var context = new EventContext( + persistentEvent, + new Organization { Id = "507f1f77bcf86cd799439012" }, + new Project { Id = ProjectId, OrganizationId = "507f1f77bcf86cd799439012" }); + + await sourceMapPlugin.EventProcessingAsync(context); + await errorPlugin.EventProcessingAsync(context); + + Assert.Equal("a()", context.StackSignatureData["Method"]); + var processedError = Assert.IsType(context.Event.Data![Event.KnownDataKeys.Error]); + var sourceMapStatus = Assert.IsType(processedError.Data![Error.KnownDataKeys.SourceMap]); + Assert.Equal("failed", sourceMapStatus["status"]); + var failure = Assert.Single(Assert.IsType(sourceMapStatus["failures"])); + Assert.Equal(GeneratedFileUrl, failure["generated_file_name"]); + Assert.Equal(SourceMapFailureReasons.Invalid, failure["reason"]); + } + + [Fact] + public async Task SymbolicateAsync_WithManyFailedGeneratedFiles_BoundsFailureDiagnostics() + { + int requestCount = 0; + var handler = new DelegateHandler(_ => + { + requestCount++; + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + using var httpClient = new HttpClient(handler); + using var service = CreateService(httpClient); + string suffix = Guid.NewGuid().ToString("N"); + var error = CreateError($"https://cdn.example.com/{suffix}/0.js"); + for (int index = 1; index < 7; index++) + { + error.StackTrace!.Add(new StackFrame + { + FileName = $"https://cdn.example.com/{suffix}/{index}.js", + LineNumber = 1, + Column = 1, + Name = "a" + }); + } + error.StackTrace!.Add(new StackFrame + { + FileName = $"https://cdn.example.com/{suffix}/0.js", + LineNumber = 2, + Column = 1, + Name = "b" + }); + + Assert.False(await service.SymbolicateAsync($"project-{suffix}", error, TestContext.Current.CancellationToken)); + + var sourceMapStatus = Assert.IsType(error.Data![Error.KnownDataKeys.SourceMap]); + Assert.Equal(5, Assert.IsType(sourceMapStatus["failures"]).Length); + Assert.True(Assert.IsType(sourceMapStatus["truncated"])); + Assert.Equal(7, requestCount); } [Fact] @@ -1273,7 +1686,7 @@ private static Error CreateError(string generatedFileUrl = GeneratedFileUrl) }; } - private SourceMapService CreateService(HttpClient httpClient, IFileStorage? storage = null) + private SourceMapService CreateService(HttpClient httpClient, IFileStorage? storage = null, ILogger? logger = null) { return new SourceMapService( new TestHttpClientFactory(httpClient), @@ -1284,9 +1697,28 @@ private SourceMapService CreateService(HttpClient httpClient, IFileStorage? stor GetService(), GetService(), GetService(), - GetService>()); + logger ?? GetService>()); + } + + private sealed class CollectingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + => Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception)); } + private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception); + private sealed class DeleteInterceptingFileStorage(IFileStorage inner, string failingPath, bool throwOnDelete = false) : IFileStorage { public ISerializer Serializer => inner.Serializer; @@ -1333,6 +1765,40 @@ public void Dispose() } } + private sealed class SaveFailingFileStorage(IFileStorage inner) : IFileStorage + { + public ISerializer Serializer => inner.Serializer; + + public Task GetFileStreamAsync(string path, StreamMode streamMode, CancellationToken cancellationToken = default) + => inner.GetFileStreamAsync(path, streamMode, cancellationToken); + + public Task GetFileInfoAsync(string path) => inner.GetFileInfoAsync(path); + + public Task ExistsAsync(string path) => inner.ExistsAsync(path); + + public Task SaveFileAsync(string path, Stream stream, CancellationToken cancellationToken = default) + => throw new IOException("Unable to save the intercepted file."); + + public Task RenameFileAsync(string path, string newPath, CancellationToken cancellationToken = default) + => inner.RenameFileAsync(path, newPath, cancellationToken); + + public Task CopyFileAsync(string path, string targetPath, CancellationToken cancellationToken = default) + => inner.CopyFileAsync(path, targetPath, cancellationToken); + + public Task DeleteFileAsync(string path, CancellationToken cancellationToken = default) + => inner.DeleteFileAsync(path, cancellationToken); + + public Task DeleteFilesAsync(string? searchPattern = null, CancellationToken cancellation = default) + => inner.DeleteFilesAsync(searchPattern, cancellation); + + public Task GetPagedFileListAsync(int pageSize = 100, string? searchPattern = null, CancellationToken cancellationToken = default) + => inner.GetPagedFileListAsync(pageSize, searchPattern, cancellationToken); + + public void Dispose() + { + } + } + private sealed class BlockingDeleteFileStorage(IFileStorage inner) : IFileStorage { public TaskCompletionSource DeleteStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);