From d97354e485189e1dc6db691353b4499805f73a05 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 22 Aug 2026 17:28:51 -0500 Subject: [PATCH 1/8] Handle source map lookup failures gracefully --- src/Exceptionless.Core/Models/Data/Error.cs | 1 + .../Default/15_SourceMapPlugin.cs | 3 +- .../Services/SourceMaps/SourceMapService.cs | 194 ++++++++++++------ .../stack-trace/source-map-status.svelte | 63 ++++++ .../source-map-status.svelte.test.ts | 40 ++++ .../stack-trace/stack-trace.stories.svelte | 31 ++- .../components/stack-trace/stack-trace.svelte | 15 +- .../events/components/views/error.svelte | 2 +- .../events/components/views/overview.svelte | 2 +- .../lib/features/events/models/event-data.ts | 12 ++ .../SourceMaps/SourceMapServiceTests.cs | 122 ++++++++++- 11 files changed, 406 insertions(+), 79 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/source-map-status.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/source-map-status.svelte.test.ts 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..81006857c5 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; @@ -161,13 +162,22 @@ 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; int framesProcessed = 0; + bool failureDetailsTruncated = false; + var failures = new List(); + InnerError rootError = error; using var processingCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); processingCancellationTokenSource.CancelAfter(_options.MaximumProcessingTime); try @@ -179,10 +189,18 @@ internal async Task SymbolicateAsync(SourceMapRequest request, InnerError? foreach (var frame in error.StackTrace) { if (++framesProcessed > _options.MaximumFramesPerError) - return changed; - - if (await SymbolicateFrameAsync(request, frame, processingCancellationTokenSource.Token)) - changed = true; + break; + + var result = await SymbolicateFrameAsync(request, frame, processingCancellationTokenSource.Token); + if (result.Symbolicated) + symbolicated = true; + if (result.Failure is not null && !failures.Any(f => String.Equals(f.GeneratedFileUrl, result.Failure.GeneratedFileUrl, StringComparison.Ordinal))) + { + if (failures.Count < MaximumFailureDetails) + failures.Add(result.Failure); + else + failureDetailsTruncated = true; + } } } @@ -194,7 +212,22 @@ internal async Task SymbolicateAsync(SourceMapRequest request, InnerError? _logger.LogDebug("Source map processing exceeded its time budget for project {ProjectId}.", request.ProjectId); } - return changed; + if (failures.Count > 0) + { + rootError.Data ??= new DataDictionary(); + rootError.Data[Error.KnownDataKeys.SourceMap] = new DataDictionary + { + ["status"] = symbolicated ? "partial" : "failed", + ["failures"] = failures.Select(f => new DataDictionary + { + ["generated_file_name"] = f.GeneratedFileUrl, + ["reason"] = f.Reason + }).ToArray(), + ["truncated"] = failureDetailsTruncated + }; + } + + return new SourceMapProcessingResult(symbolicated, failures.Count > 0); } public void Dispose() @@ -204,25 +237,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) + { + return lookup.FailureReason is null + ? default + : new SourceMapFrameResult(false, new SourceMapFailure(generatedFileUri.AbsoluteUri, lookup.FailureReason)); + } 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 default; frame.Data ??= new DataDictionary(); frame.Data[StackFrame.KnownDataKeys.SourceMap] = new DataDictionary @@ -231,31 +268,31 @@ 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 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 +307,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 +340,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 +355,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 +386,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 +402,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 +413,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 +428,27 @@ 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 bool ShouldRefresh(SourceMapArtifact artifact, Uri generatedFileUri) => artifact.IsAutoDownloaded @@ -646,10 +693,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 +739,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 +751,30 @@ 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); + private readonly record struct SourceMapLookupResult(ResolvedSourceMap? SourceMap, string? FailureReason) + { + 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 HasFailures) +{ + public bool Modified => Symbolicated || HasFailures; +} + +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..d8bbe9a2ae --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/source-map-status.svelte @@ -0,0 +1,63 @@ + + +{#if failures.length > 0} + + {#snippet icon()}{/snippet} + {#snippet action()} + + {/snippet} + {title} + + Exceptionless couldn't map {failures.length === 1 ? 'a JavaScript file' : `${failures.length} JavaScript files`} to original source. The stack trace below + 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} 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..5d2e999758 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/source-map-status.svelte.test.ts @@ -0,0 +1,40 @@ +import type { ErrorInfo } from '$features/events/models/event-data'; + +import { 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('renders source map failures with a management link', () => { + 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 }); + + expect(screen.getByRole('alert')).toBeTruthy(); + expect(screen.getByText('Source map unavailable')).toBeTruthy(); + 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('alert')).toBeNull(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.stories.svelte index eaa6ccb32f..a0429a127a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.stories.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.stories.svelte @@ -440,23 +440,48 @@ ], type: 'System.NullReferenceException' }; + + const sourceMapError: ErrorInfo = { + ...error, + data: { + '@source_map': { + failures: [ + { + generated_file_name: 'https://cdn.example.com/assets/app.min.js', + reason: 'invalid' + } + ], + status: 'failed' + } + } + }; + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte index 5fc2821140..90aab5a1d1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte @@ -4,19 +4,24 @@ import { Code } from '$comp/typography'; import { getErrors } from '$features/events/persistent-event'; + import SourceMapStatus from './source-map-status.svelte'; import StackTraceFrames from './stack-trace-frames.svelte'; import StackTraceHeader from './stack-trace-header.svelte'; interface Props { error: ErrorInfo; + projectId: string; } - let { error }: Props = $props(); + let { error, projectId }: Props = $props(); const errors = $derived(getErrors(error)); -
{#each errors.reverse() as error, index (index)}{#if index < errors.length - 1}
{/if}{/each}
+
+ +
{#each errors.reverse() as error, index (index)}{#if index < errors.length - 1}
{/if}{/each}
+
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..dea6c08635 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 @@ -69,7 +69,7 @@
{#if event.data?.['@error']} - + {:else if event.data?.['@simple_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..5a6bef4196 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 @@ -243,7 +243,7 @@
{#if event.data?.['@error']} - + {:else if event.data?.['@simple_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..68d2b6b58e 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,17 @@ export interface SimpleErrorInfo { type?: string; } +export interface SourceMapFailureInfo { + generated_file_name: string; + reason: string; +} + +export interface SourceMapStatusInfo { + failures: SourceMapFailureInfo[]; + 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..c5b1524b93 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,104 @@ 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 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 +1366,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 +1377,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; From c082298f8cfdba1015a46c4f14e75ec74b372b25 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 22 Aug 2026 17:42:47 -0500 Subject: [PATCH 2/8] Clear recovered source map diagnostics --- .../Services/SourceMaps/SourceMapService.cs | 11 ++++---- .../SourceMaps/SourceMapServiceTests.cs | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs index 81006857c5..77f36f68d1 100644 --- a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs @@ -212,6 +212,7 @@ internal async Task ProcessAsync(SourceMapRequest req _logger.LogDebug("Source map processing exceeded its time budget for project {ProjectId}.", request.ProjectId); } + bool sourceMapStatusModified; if (failures.Count > 0) { rootError.Data ??= new DataDictionary(); @@ -225,9 +226,12 @@ internal async Task ProcessAsync(SourceMapRequest req }).ToArray(), ["truncated"] = failureDetailsTruncated }; + sourceMapStatusModified = true; } + else + sourceMapStatusModified = rootError.Data?.Remove(Error.KnownDataKeys.SourceMap) == true; - return new SourceMapProcessingResult(symbolicated, failures.Count > 0); + return new SourceMapProcessingResult(symbolicated, symbolicated || sourceMapStatusModified); } public void Dispose() @@ -761,10 +765,7 @@ private readonly record struct SourceMapLookupResult(ResolvedSourceMap? SourceMa private sealed record ParsedSourceMapCacheRegistration(SourceMapService Service, ResolvedSourceMap SourceMap); } -internal readonly record struct SourceMapProcessingResult(bool Symbolicated, bool HasFailures) -{ - public bool Modified => Symbolicated || HasFailures; -} +internal readonly record struct SourceMapProcessingResult(bool Symbolicated, bool Modified); internal static class SourceMapFailureReasons { diff --git a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs index c5b1524b93..87a322b7c9 100644 --- a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs @@ -502,6 +502,34 @@ public async Task SymbolicateAsync_WhenDownloadedSourceMapIsInvalid_DoesNotPersi 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 EventProcessingAsync_WhenSourceMapDownloadFails_PersistsFailureDiagnostics() { From 4888f9d0cd5a8022850ac1fa4ec481567bee719b Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 22 Aug 2026 17:58:05 -0500 Subject: [PATCH 3/8] Preserve source map retry diagnostics --- .../Services/SourceMaps/SourceMapService.cs | 31 ++++++-- .../SourceMaps/SourceMapServiceTests.cs | 70 +++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs index 77f36f68d1..ee6090192d 100644 --- a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs @@ -174,8 +174,10 @@ internal async Task ProcessAsync(SourceMapRequest req return default; bool symbolicated = false; + bool hasSymbolicatedFrames = false; int framesProcessed = 0; bool failureDetailsTruncated = false; + string? activeGeneratedFileUrl = null; var failures = new List(); InnerError rootError = error; using var processingCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -191,16 +193,18 @@ internal async Task ProcessAsync(SourceMapRequest req if (++framesProcessed > _options.MaximumFramesPerError) 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; - if (result.Failure is not null && !failures.Any(f => String.Equals(f.GeneratedFileUrl, result.Failure.GeneratedFileUrl, StringComparison.Ordinal))) { - if (failures.Count < MaximumFailureDetails) - failures.Add(result.Failure); - else - failureDetailsTruncated = true; + symbolicated = true; + hasSymbolicatedFrames = true; } + if (result.Failure is not null) + AddFailure(failures, result.Failure, ref failureDetailsTruncated); + activeGeneratedFileUrl = null; } } @@ -210,6 +214,8 @@ internal async Task ProcessAsync(SourceMapRequest req 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 sourceMapStatusModified; @@ -218,7 +224,7 @@ internal async Task ProcessAsync(SourceMapRequest req rootError.Data ??= new DataDictionary(); rootError.Data[Error.KnownDataKeys.SourceMap] = new DataDictionary { - ["status"] = symbolicated ? "partial" : "failed", + ["status"] = hasSymbolicatedFrames ? "partial" : "failed", ["failures"] = failures.Select(f => new DataDictionary { ["generated_file_name"] = f.GeneratedFileUrl, @@ -282,6 +288,17 @@ private async Task SymbolicateFrameAsync(SourceMapRequest 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 async Task GetSourceMapAsync(SourceMapRequest request, Uri generatedFileUri, CancellationToken cancellationToken) { string cacheKey = GetMemoryCacheKey(request.ProjectId, generatedFileUri.AbsoluteUri); diff --git a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs index 87a322b7c9..1cb722b9e7 100644 --- a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs @@ -530,6 +530,76 @@ public async Task SymbolicateAsync_WhenSourceMapBecomesAvailable_ClearsFailureDi 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 EventProcessingAsync_WhenSourceMapDownloadFails_PersistsFailureDiagnostics() { From adf58a3eec296c8564b48be3955c2111b909d8d7 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 22 Aug 2026 18:15:08 -0500 Subject: [PATCH 4/8] Cover source map processing limits --- .../Services/SourceMaps/SourceMapService.cs | 48 ++++++-- .../stack-trace/source-map-status.svelte | 32 ++++-- .../source-map-status.svelte.test.ts | 17 +++ .../lib/features/events/models/event-data.ts | 1 + .../SourceMaps/SourceMapServiceTests.cs | 108 ++++++++++++++++++ 5 files changed, 183 insertions(+), 23 deletions(-) diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs index ee6090192d..bc5adfdc79 100644 --- a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs @@ -177,6 +177,7 @@ internal async Task ProcessAsync(SourceMapRequest req bool hasSymbolicatedFrames = false; int framesProcessed = 0; bool failureDetailsTruncated = false; + bool processingTruncated = false; string? activeGeneratedFileUrl = null; var failures = new List(); InnerError rootError = error; @@ -184,14 +185,17 @@ internal async Task ProcessAsync(SourceMapRequest req 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) + { + processingTruncated = true; break; + } if (frame.Data?.ContainsKey(StackFrame.KnownDataKeys.SourceMap) == true) hasSymbolicatedFrames = true; @@ -219,7 +223,7 @@ internal async Task ProcessAsync(SourceMapRequest req } bool sourceMapStatusModified; - if (failures.Count > 0) + if (failures.Count > 0 || processingTruncated) { rootError.Data ??= new DataDictionary(); rootError.Data[Error.KnownDataKeys.SourceMap] = new DataDictionary @@ -230,6 +234,7 @@ internal async Task ProcessAsync(SourceMapRequest req ["generated_file_name"] = f.GeneratedFileUrl, ["reason"] = f.Reason }).ToArray(), + ["processing_truncated"] = processingTruncated, ["truncated"] = failureDetailsTruncated }; sourceMapStatusModified = true; @@ -434,7 +439,7 @@ private async Task LoadSourceMapAsync(SourceMapRequest re return Resolve(stored.Artifact, stored.Content, cacheVersion); if (!_options.EnableAutoDownload || !String.Equals(generatedFileUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - return default; + return SourceMapLookupResult.Failed(SourceMapFailureReasons.NotFound); string failureCacheKey = GetFailureCacheKey(projectId, generatedFileUrl); var cachedFailure = await _cache.GetAsync(failureCacheKey); @@ -467,7 +472,14 @@ private async Task LoadSourceMapAsync(SourceMapRequest re if (globalDownloadSlot is null) return default; - downloaded = await _downloader.DownloadAsync(generatedFileUri, stored is not null, timeoutCancellationTokenSource.Token); + try + { + downloaded = await _downloader.DownloadAsync(generatedFileUri, stored is not null, timeoutCancellationTokenSource.Token); + } + catch (Exception ex) when (ex is HttpRequestException or IOException or JsonException or InvalidOperationException or FormatException) + { + return await CacheExpectedFailureAsync(failureCacheKey, generatedFileUrl, ex, GetFailureReason(ex)); + } if (downloaded is null) return await CacheFailureAsync(failureCacheKey, SourceMapFailureReasons.NotFound); } @@ -486,7 +498,15 @@ private async Task LoadSourceMapAsync(SourceMapRequest re IsAutoDownloaded = true, CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime }; - var document = downloaded.Document ?? SourceMapDocument.Parse(downloaded.Content, _options.MaximumMappingSegments); + SourceMapDocument document; + try + { + document = downloaded.Document ?? SourceMapDocument.Parse(downloaded.Content, _options.MaximumMappingSegments); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException) + { + return await CacheExpectedFailureAsync(failureCacheKey, generatedFileUrl, ex, SourceMapFailureReasons.Invalid); + } await using var projectLock = await TryAcquireProjectStorageLockAsync(projectId, timeoutCancellationTokenSource.Token); if (projectLock is null) return await CacheFailureAsync(failureCacheKey, SourceMapFailureReasons.Unavailable); @@ -509,12 +529,8 @@ private async Task LoadSourceMapAsync(SourceMapRequest re } catch (Exception ex) when (ex is HttpRequestException or IOException or JsonException or InvalidOperationException or FormatException) { - _logger.LogDebug( - "Unable to download a source map for {GeneratedFileUrl}: {FailureType}: {FailureMessage}", - generatedFileUrl, - ex.GetType().Name, - ex.Message); - return await CacheFailureAsync(failureCacheKey, GetFailureReason(ex)); + _logger.LogWarning(ex, "Unable to persist a source map for {GeneratedFileUrl}.", generatedFileUrl); + return await CacheFailureAsync(failureCacheKey, SourceMapFailureReasons.Unavailable); } } @@ -526,6 +542,16 @@ private static string GetFailureReason(Exception exception) ? 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 && _options.EnableAutoDownload 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 index d8bbe9a2ae..6786750c5d 100644 --- 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 @@ -15,6 +15,7 @@ const sourceMapStatus = $derived(error.data?.['@source_map']); const failures = $derived(sourceMapStatus?.failures ?? []); + const processingTruncated = $derived(sourceMapStatus?.processing_truncated === true); const title = $derived(sourceMapStatus?.status === 'partial' ? 'Stack trace partially symbolicated' : 'Source map unavailable'); function getFailureDescription(failure: SourceMapFailureInfo): string { @@ -33,7 +34,7 @@ } -{#if failures.length > 0} +{#if failures.length > 0 || processingTruncated} {#snippet icon()}{/snippet} {#snippet action()} @@ -47,17 +48,24 @@ {/snippet} {title} - Exceptionless couldn't map {failures.length === 1 ? 'a JavaScript file' : `${failures.length} JavaScript files`} to original source. The stack trace below - 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 failures.length > 0} + Exceptionless couldn't map {failures.length === 1 ? 'a JavaScript file' : `${failures.length} JavaScript files`} to original source. The stack trace + below 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 below 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 index 5d2e999758..225074818d 100644 --- 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 @@ -37,4 +37,21 @@ describe('SourceMapStatus', () => { expect(screen.queryByRole('alert')).toBeNull(); }); + + it('renders when source map processing reaches the frame limit', () => { + const error: ErrorInfo = { + data: { + '@source_map': { + failures: [], + processing_truncated: true, + status: 'failed' + } + } + }; + + render(SourceMapStatus, { error, projectId }); + + expect(screen.getByRole('alert')).toBeTruthy(); + expect(screen.getByText(/stack-frame processing limit/i)).toBeTruthy(); + }); }); 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 68d2b6b58e..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 @@ -124,6 +124,7 @@ export interface SourceMapFailureInfo { export interface SourceMapStatusInfo { failures: SourceMapFailureInfo[]; + processing_truncated?: boolean; status: 'failed' | 'partial' | string; truncated?: boolean; } diff --git a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs index 1cb722b9e7..3913380cf7 100644 --- a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs @@ -600,6 +600,80 @@ public async Task SymbolicateAsync_WhenProcessingBudgetExpires_RecordsTimeoutFai } } + [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 EventProcessingAsync_WhenSourceMapDownloadFails_PersistsFailureDiagnostics() { @@ -1543,6 +1617,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); From f681e9b5d5780ed02605c5b72ec556986a7f61e4 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 22 Aug 2026 18:29:17 -0500 Subject: [PATCH 5/8] Preserve deferred source map diagnostics --- .../Services/SourceMaps/SourceMapService.cs | 31 ++++++++++------- .../SourceMaps/SourceMapServiceTests.cs | 34 +++++++++++++++++++ 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs index bc5adfdc79..cea026dc78 100644 --- a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs @@ -178,9 +178,11 @@ internal async Task ProcessAsync(SourceMapRequest req int framesProcessed = 0; bool failureDetailsTruncated = false; bool processingTruncated = false; + bool processingDeferred = false; string? activeGeneratedFileUrl = null; var failures = new List(); InnerError rootError = error; + bool hasExistingSourceMapStatus = rootError.Data?.ContainsKey(Error.KnownDataKeys.SourceMap) == true; using var processingCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); processingCancellationTokenSource.CancelAfter(_options.MaximumProcessingTime); try @@ -208,6 +210,8 @@ internal async Task ProcessAsync(SourceMapRequest req } if (result.Failure is not null) AddFailure(failures, result.Failure, ref failureDetailsTruncated); + if (result.IsDeferred) + processingDeferred = true; activeGeneratedFileUrl = null; } } @@ -223,7 +227,9 @@ internal async Task ProcessAsync(SourceMapRequest req } bool sourceMapStatusModified; - if (failures.Count > 0 || processingTruncated) + if (processingDeferred && hasExistingSourceMapStatus) + sourceMapStatusModified = false; + else if (failures.Count > 0 || processingTruncated) { rootError.Data ??= new DataDictionary(); rootError.Data[Error.KnownDataKeys.SourceMap] = new DataDictionary @@ -240,7 +246,7 @@ internal async Task ProcessAsync(SourceMapRequest req sourceMapStatusModified = true; } else - sourceMapStatusModified = rootError.Data?.Remove(Error.KnownDataKeys.SourceMap) == true; + sourceMapStatusModified = !processingDeferred && rootError.Data?.Remove(Error.KnownDataKeys.SourceMap) == true; return new SourceMapProcessingResult(symbolicated, symbolicated || sourceMapStatusModified); } @@ -263,9 +269,9 @@ private async Task SymbolicateFrameAsync(SourceMapRequest var lookup = await GetSourceMapAsync(request, generatedFileUri, cancellationToken); if (lookup.SourceMap is null) { - return lookup.FailureReason is null - ? default - : new SourceMapFrameResult(false, new SourceMapFailure(generatedFileUri.AbsoluteUri, lookup.FailureReason)); + 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; @@ -274,7 +280,7 @@ private async Task SymbolicateFrameAsync(SourceMapRequest if (original is null) return new SourceMapFrameResult(false, new SourceMapFailure(generatedFileUri.AbsoluteUri, SourceMapFailureReasons.NoMatchingMapping)); if (!await TrackUsageAsync(request.ProjectId, lookup.SourceMap.Artifact.Id, cancellationToken)) - return default; + return new SourceMapFrameResult(false, null, true); frame.Data ??= new DataDictionary(); frame.Data[StackFrame.KnownDataKeys.SourceMap] = new DataDictionary @@ -461,16 +467,16 @@ private async Task LoadSourceMapAsync(SourceMapRequest re return Resolve(stored.Artifact, stored.Content, await GetProjectCacheVersionAsync(projectId)); if (stored is null && !await _throttle.TryReserveDiscoveryAsync(request)) - return default; + return SourceMapLookupResult.Deferred; if (!await _downloadSemaphore.WaitAsync(TimeSpan.Zero, timeoutCancellationTokenSource.Token)) - return default; + return SourceMapLookupResult.Deferred; SourceMapDownloader.DownloadedSourceMap? downloaded; try { await using var globalDownloadSlot = await TryAcquireGlobalDownloadSlotAsync(artifactId, timeoutCancellationTokenSource.Token); if (globalDownloadSlot is null) - return default; + return SourceMapLookupResult.Deferred; try { @@ -525,7 +531,7 @@ private async Task LoadSourceMapAsync(SourceMapRequest re } catch (SourceMapRequestThrottledException) { - return default; + return SourceMapLookupResult.Deferred; } catch (Exception ex) when (ex is HttpRequestException or IOException or JsonException or InvalidOperationException or FormatException) { @@ -799,9 +805,10 @@ 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); - private readonly record struct SourceMapLookupResult(ResolvedSourceMap? SourceMap, string? FailureReason) + 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); } diff --git a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs index 3913380cf7..f4311db16c 100644 --- a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs @@ -674,6 +674,40 @@ public async Task SymbolicateAsync_WhenDownloadedMapCannotBePersisted_LogsOperat 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"); + var existingStatus = new DataDictionary + { + ["failures"] = new[] + { + new DataDictionary + { + ["generated_file_name"] = GeneratedFileUrl, + ["reason"] = SourceMapFailureReasons.NotFound + } + }, + ["status"] = "failed" + }; + var error = CreateError($"https://cdn.example.com/{suffix}/app.min.js"); + error.Data = new DataDictionary { [Error.KnownDataKeys.SourceMap] = existingStatus }; + + Assert.False(await service.SymbolicateAsync($"project-{suffix}", error, TestContext.Current.CancellationToken)); + + Assert.Same(existingStatus, error.Data[Error.KnownDataKeys.SourceMap]); + Assert.Equal(0, requestCount); + } + [Fact] public async Task EventProcessingAsync_WhenSourceMapDownloadFails_PersistsFailureDiagnostics() { From c37971b31eec6e00a5c1f45640044e77a4828567 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 22 Aug 2026 18:47:08 -0500 Subject: [PATCH 6/8] Merge deferred source map retry results --- .../Services/SourceMaps/SourceMapService.cs | 50 +++++++++++-- .../SourceMaps/SourceMapServiceTests.cs | 71 +++++++++++++++++++ 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs index cea026dc78..9b4f5457c4 100644 --- a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs @@ -31,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; @@ -52,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 }); @@ -179,10 +181,10 @@ internal async Task ProcessAsync(SourceMapRequest req 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; - bool hasExistingSourceMapStatus = rootError.Data?.ContainsKey(Error.KnownDataKeys.SourceMap) == true; using var processingCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); processingCancellationTokenSource.CancelAfter(_options.MaximumProcessingTime); try @@ -211,7 +213,11 @@ internal async Task ProcessAsync(SourceMapRequest req 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; } } @@ -226,10 +232,17 @@ internal async Task ProcessAsync(SourceMapRequest req AddFailure(failures, new SourceMapFailure(generatedFileUri.AbsoluteUri, SourceMapFailureReasons.Timeout), ref failureDetailsTruncated); } + 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); + } + bool sourceMapStatusModified; - if (processingDeferred && hasExistingSourceMapStatus) - sourceMapStatusModified = false; - else if (failures.Count > 0 || processingTruncated) + if (failures.Count > 0 || processingTruncated) { rootError.Data ??= new DataDictionary(); rootError.Data[Error.KnownDataKeys.SourceMap] = new DataDictionary @@ -310,6 +323,35 @@ private static void AddFailure(List failures, SourceMapFailure 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) { string cacheKey = GetMemoryCacheKey(request.ProjectId, generatedFileUri.AbsoluteUri); diff --git a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs index f4311db16c..235768056f 100644 --- a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs @@ -708,6 +708,77 @@ public async Task SymbolicateAsync_WhenDiscoveryIsDeferred_PreservesExistingDiag 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 EventProcessingAsync_WhenSourceMapDownloadFails_PersistsFailureDiagnostics() { From 81c0072679b91d48e90c3eadcaee2dab75af923c Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 22 Aug 2026 19:01:42 -0500 Subject: [PATCH 7/8] Clear recovered source map retry failures --- .../Services/SourceMaps/SourceMapService.cs | 7 ++- .../SourceMaps/SourceMapServiceTests.cs | 49 +++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs index 9b4f5457c4..cb8394df32 100644 --- a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs @@ -232,13 +232,17 @@ internal async Task ProcessAsync(SourceMapRequest req 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; + } } bool sourceMapStatusModified; @@ -259,7 +263,8 @@ internal async Task ProcessAsync(SourceMapRequest req sourceMapStatusModified = true; } else - sourceMapStatusModified = !processingDeferred && rootError.Data?.Remove(Error.KnownDataKeys.SourceMap) == true; + sourceMapStatusModified = (!processingDeferred || existingSourceMapStatusMerged) + && rootError.Data?.Remove(Error.KnownDataKeys.SourceMap) == true; return new SourceMapProcessingResult(symbolicated, symbolicated || sourceMapStatusModified); } diff --git a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs index 235768056f..18b588fe01 100644 --- a/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/SourceMaps/SourceMapServiceTests.cs @@ -687,24 +687,29 @@ public async Task SymbolicateAsync_WhenDiscoveryIsDeferred_PreservesExistingDiag })); 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, + ["generated_file_name"] = generatedFileUrl, ["reason"] = SourceMapFailureReasons.NotFound } }, ["status"] = "failed" }; - var error = CreateError($"https://cdn.example.com/{suffix}/app.min.js"); + var error = CreateError(generatedFileUrl); error.Data = new DataDictionary { [Error.KnownDataKeys.SourceMap] = existingStatus }; Assert.False(await service.SymbolicateAsync($"project-{suffix}", error, TestContext.Current.CancellationToken)); - Assert.Same(existingStatus, error.Data[Error.KnownDataKeys.SourceMap]); + 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); } @@ -779,6 +784,44 @@ public async Task SymbolicateAsync_WithMixedRetryOutcomes_MergesDeferredAndDefin }); } + [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() { From b7f1c79c2cf57aeed6f766ab63293a97cf68f1c9 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 22 Aug 2026 20:23:49 -0500 Subject: [PATCH 8/8] Make source map failures less intrusive --- .../stack-trace/source-map-status.svelte | 81 +++++++++++-------- .../source-map-status.svelte.test.ts | 19 +++-- .../stack-trace/stack-trace.stories.svelte | 31 +------ .../components/stack-trace/stack-trace.svelte | 15 ++-- .../events/components/views/error.svelte | 8 +- .../events/components/views/overview.svelte | 8 +- 6 files changed, 79 insertions(+), 83 deletions(-) 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 index 6786750c5d..b39b28be0e 100644 --- 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 @@ -2,8 +2,8 @@ import type { ErrorInfo, SourceMapFailureInfo } from '$features/events/models/event-data'; import { resolve } from '$app/paths'; - import { Notification, NotificationDescription, NotificationTitle } from '$comp/notification'; import { Button } from '$comp/ui/button'; + import * as Popover from '$comp/ui/popover'; import TriangleAlert from '@lucide/svelte/icons/triangle-alert'; interface Props { @@ -35,37 +35,50 @@ {#if failures.length > 0 || processingTruncated} - - {#snippet icon()}{/snippet} - {#snippet action()} - - {/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 - below 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 below may remain minified. -

- {/if} -
-
+ + + {#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 index 225074818d..69a4d199a2 100644 --- 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 @@ -1,6 +1,6 @@ import type { ErrorInfo } from '$features/events/models/event-data'; -import { render, screen } from '@testing-library/svelte'; +import { fireEvent, render, screen } from '@testing-library/svelte'; import { describe, expect, it } from 'vitest'; import SourceMapStatus from './source-map-status.svelte'; @@ -8,7 +8,7 @@ import SourceMapStatus from './source-map-status.svelte'; const projectId = '507f1f77bcf86cd799439011'; describe('SourceMapStatus', () => { - it('renders source map failures with a management link', () => { + it('shows source map failure details on demand', async () => { const error: ErrorInfo = { data: { '@source_map': { @@ -25,8 +25,12 @@ describe('SourceMapStatus', () => { render(SourceMapStatus, { error, projectId }); - expect(screen.getByRole('alert')).toBeTruthy(); - expect(screen.getByText('Source map unavailable')).toBeTruthy(); + 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`); @@ -35,10 +39,10 @@ describe('SourceMapStatus', () => { it('does not render without failure metadata', () => { render(SourceMapStatus, { error: {}, projectId }); - expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.queryByRole('button', { name: /source map/i })).toBeNull(); }); - it('renders when source map processing reaches the frame limit', () => { + it('shows processing limit details on demand', async () => { const error: ErrorInfo = { data: { '@source_map': { @@ -51,7 +55,8 @@ describe('SourceMapStatus', () => { render(SourceMapStatus, { error, projectId }); - expect(screen.getByRole('alert')).toBeTruthy(); + 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/stack-trace/stack-trace.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.stories.svelte index a0429a127a..eaa6ccb32f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.stories.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.stories.svelte @@ -440,48 +440,23 @@ ], type: 'System.NullReferenceException' }; - - const sourceMapError: ErrorInfo = { - ...error, - data: { - '@source_map': { - failures: [ - { - generated_file_name: 'https://cdn.example.com/assets/app.min.js', - reason: 'invalid' - } - ], - status: 'failed' - } - } - }; - diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte index 90aab5a1d1..5fc2821140 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte @@ -4,24 +4,19 @@ import { Code } from '$comp/typography'; import { getErrors } from '$features/events/persistent-event'; - import SourceMapStatus from './source-map-status.svelte'; import StackTraceFrames from './stack-trace-frames.svelte'; import StackTraceHeader from './stack-trace-header.svelte'; interface Props { error: ErrorInfo; - projectId: string; } - let { error, projectId }: Props = $props(); + let { error }: Props = $props(); const errors = $derived(getErrors(error)); -
- -
{#each errors.reverse() as error, index (index)}{#if index < errors.length - 1}
{/if}{/each}
-
+
{#each errors.reverse() as error, index (index)}{#if index < errors.length - 1}
{/if}{/each}
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 dea6c08635..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,13 +64,16 @@

Stack Trace

-
+
+ {#if event.data?.['@error']} + + {/if}
{#if event.data?.['@error']} - + {:else if event.data?.['@simple_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 5a6bef4196..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,13 +238,16 @@ {#if hasError}

Stack Trace

-
+
+ {#if event.data?.['@error']} + + {/if}
{#if event.data?.['@error']} - + {:else if event.data?.['@simple_error']} {/if}