diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 42bb52feaa..b548f1f58d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -580,8 +580,72 @@ jobs: kubectl patch cronjob/ex-dev-jobs-maintain-indexes -p '{"spec":{"suspend": false}}' --namespace $DEV_NAMESPACE kubectl patch cronjob/ex-dev-jobs-migration -p '{"spec":{"suspend": false}}' --namespace $DEV_NAMESPACE - kubectl wait --for=condition=available --timeout=300s deployment/ex-dev-app --namespace $DEV_NAMESPACE - kubectl wait --for=condition=available --timeout=300s deployment/ex-dev-api --namespace $DEV_NAMESPACE + wait_for_versioned_pod() { + component="$1" + replica_set="" + for attempt in {1..12}; do + replica_set=$(kubectl get replicasets --namespace "$DEV_NAMESPACE" --selector "component=${component}" --output json \ + | jq --raw-output --arg image_tag ":${VERSION}" ' + [.items[] + | select(any(.spec.template.spec.containers[]; .image | endswith($image_tag))) + | { name: .metadata.name, created: .metadata.creationTimestamp }] + | sort_by(.created) + | last + | .name // empty') + [[ -n "$replica_set" ]] && break + sleep 5 + done + if [[ -z "$replica_set" ]]; then + echo "::error::No ReplicaSet for ${component} uses version ${VERSION}." + return 1 + fi + + pod_template_hash=$(kubectl get replicaset "$replica_set" --namespace "$DEV_NAMESPACE" \ + --output jsonpath='{.metadata.labels.pod-template-hash}') + kubectl wait --for=create --timeout=60s pod \ + --namespace "$DEV_NAMESPACE" \ + --selector "component=${component},pod-template-hash=${pod_template_hash}" + if ! kubectl wait --for=condition=Ready --timeout=300s pod \ + --namespace "$DEV_NAMESPACE" \ + --selector "component=${component},pod-template-hash=${pod_template_hash}"; then + echo "::group::${component} rollout diagnostics" + kubectl get pods --namespace "$DEV_NAMESPACE" \ + --selector "component=${component},pod-template-hash=${pod_template_hash}" \ + --output wide + for pod in $(kubectl get pods --namespace "$DEV_NAMESPACE" \ + --selector "component=${component},pod-template-hash=${pod_template_hash}" \ + --output name); do + kubectl describe "$pod" --namespace "$DEV_NAMESPACE" + kubectl logs "$pod" --namespace "$DEV_NAMESPACE" --all-containers --tail=300 \ + | grep --extended-regexp 'Now listening on:|Application started|Application is shutting down' || true + done + echo "::endgroup::" + return 1 + fi + } + + wait_for_versioned_pod ex-dev-app + wait_for_versioned_pod ex-dev-api + wait_for_versioned_pod ex-dev-jobs-event-posts + + for endpoint in dev-app.exceptionless.io dev-api.exceptionless.io dev-collector.exceptionless.io; do + deployed_version="" + for attempt in {1..12}; do + if deployed_version=$(curl --fail --silent --show-error --retry 2 --retry-all-errors --retry-delay 2 \ + --header "Cache-Control: no-cache" \ + "https://${endpoint}/api/v2/about?deployment=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${attempt}" \ + | jq --raw-output '.informational_version'); then + if [[ "$deployed_version" == "${VERSION}+"* ]]; then + break + fi + fi + sleep 5 + done + if [[ "$deployed_version" != "${VERSION}+"* ]]; then + echo "::error::${endpoint} is serving ${deployed_version}; expected ${VERSION}." + exit 1 + fi + done kubectl annotate namespace $DEV_NAMESPACE exceptionless.io/dev-started-at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" --overwrite kubectl annotate namespace $DEV_NAMESPACE exceptionless.io/dev-auto-stop-days="1" --overwrite diff --git a/Dockerfile b/Dockerfile index 8d02400ca1..80cc955548 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,6 +44,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0.9 AS job WORKDIR /app COPY --from=job-publish /app/src/Exceptionless.Job/out ./ +ENV ASPNETCORE_URLS=http://+:8080 + EXPOSE 8080 ENTRYPOINT [ "dotnet", "Exceptionless.Job.dll" ] @@ -61,6 +63,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0.9 AS api WORKDIR /app COPY --from=api-publish /app/src/Exceptionless.Web/out ./ +ENV ASPNETCORE_URLS=http://+:8080 + EXPOSE 8080 ENTRYPOINT [ "dotnet", "Exceptionless.Web.dll" ] diff --git a/docs/docs/source-maps.md b/docs/docs/source-maps.md new file mode 100644 index 0000000000..9e84b59205 --- /dev/null +++ b/docs/docs/source-maps.md @@ -0,0 +1,61 @@ +--- +title: "JavaScript Source Maps" +--- + +# JavaScript Source Maps + +Exceptionless uses source maps to turn minified JavaScript stack frames into the original file names, line and column numbers, and function names. Symbolication happens before an event is assigned to a stack, so readable function names also improve stack grouping. + +## Automatic discovery + +No domain allowlist or project setup is required for public source maps. When an error contains an absolute HTTPS JavaScript URL, Exceptionless checks the generated file's `SourceMap` or `X-SourceMap` response header and its `sourceMappingURL` comment. If neither is present, it also checks the conventional `.map` URL. + +Downloaded maps are validated and cached in project-scoped file storage. Automatically downloaded maps are revalidated after one hour so a stable generated-file URL cannot retain a map from an older deployment indefinitely. If refresh fails, Exceptionless leaves the generated frame unchanged instead of risking a misleading stack trace from the stale map. + +Exceptionless only makes anonymous HTTPS requests on the standard port to public network addresses. Redirects and every resolved address are revalidated. New automatic discoveries use plan-aware limits per client key, project, and organization; outbound requests are also limited per destination, IP address, and cluster. Free plans allow five new discoveries per client key and ten per project or organization in each 15-minute window by default. Paid plans have higher limits. Failed URLs are cached for 15 minutes, duplicate discoveries share the same in-flight work, and refreshes use a separate smaller outbound budget. If any limit is reached, Exceptionless leaves the generated frame unchanged without rejecting or delaying the event. + +Downloads also have time, redirect, size, and local and cluster-wide concurrency limits. Parsed maps use a bounded in-memory cache. Manual and deployment uploads do not consume automatic-discovery quotas. Paid projects can store up to 1,000 maps and 1 GiB by default. Free projects have a smaller default limit of 100 maps and 100 MiB; replacing a map for the same generated URL does not consume another slot. + +Exceptionless records a map's last successful use through a debounced cache update, then periodically persists that timestamp with the small metadata file instead of rewriting the map. The cleanup job removes unused maps after 14 days on free plans and 90 days on paid plans by default. Pending usage is checked before any map is removed, and stale maps are also cleaned before an upload or automatic download is rejected for exceeding storage limits. + +Self-hosted installations can tune these safeguards under the `SourceMaps` configuration section, including `MaximumArtifactsPerProject`, `MaximumStorageSizePerProject`, `MaximumArtifactsPerFreeProject`, `MaximumStorageSizePerFreeProject`, `FreeArtifactRetentionDays`, `ArtifactRetentionDays`, `UsageTrackingDebounceMinutes`, `AutoDownloadRateLimitPeriodMinutes`, `MaximumAutoDiscoveriesPerFreeClientKey`, `MaximumAutoDiscoveriesPerProject`, `MaximumAutoDownloadRequestsPerDestination`, `MaximumAutoRefreshRequestsGlobally`, `AutoDownloadRefreshIntervalMinutes`, `ParsedSourceMapCacheLifetimeMinutes`, and `MaximumParsedSourceMapCacheSize`. + +## Uploading a source map + +Upload a map when it is private or is not deployed next to the generated JavaScript: + +1. Open the project and select **Source Maps** under **Project Settings**. +2. Enter the exact absolute URL that appears in the generated stack frame, including any path or query string used to identify the build. +3. Select the corresponding source map and upload it. + +Uploading another map for the same generated file URL replaces the previous map. Uploaded and automatically discovered maps appear together on the Source Maps page and can be deleted there. + +### Uploading during deployment + +For build automation, create a project-scoped token that has only the `source-maps:write` scope. Set `EXCEPTIONLESS_SERVER_URL` to `https://be.exceptionless.io` for the hosted service or to the root URL of your self-hosted installation. Create the token once with a user-scoped token, then store the returned `id` as a protected CI/CD secret: + +```shell +curl --fail-with-body --request POST \ + "${EXCEPTIONLESS_SERVER_URL}/api/v2/projects/${EXCEPTIONLESS_PROJECT_ID}/tokens" \ + --header "Authorization: Bearer ${EXCEPTIONLESS_USER_TOKEN}" \ + --header "Content-Type: application/json" \ + --data "{\"organization_id\":\"${EXCEPTIONLESS_ORGANIZATION_ID}\",\"project_id\":\"${EXCEPTIONLESS_PROJECT_ID}\",\"scopes\":[\"source-maps:write\"],\"notes\":\"CI source map uploads\"}" +``` + +Upload each map after the generated JavaScript has been deployed. The `generated_file_url` must be the exact absolute URL that will appear in stack frames: + +```shell +curl --fail-with-body --request POST \ + "${EXCEPTIONLESS_SERVER_URL}/api/v2/projects/${EXCEPTIONLESS_PROJECT_ID}/source-maps" \ + --header "Authorization: Bearer ${EXCEPTIONLESS_SOURCE_MAP_TOKEN}" \ + --form "generated_file_url=https://cdn.example.com/assets/app.a1b2c3.js" \ + --form "file=@dist/assets/app.a1b2c3.js.map;type=application/json" +``` + +The upload token is accepted only for its assigned project and cannot read events, manage the project, list source maps, or use ordinary user APIs. Do not use a normal client API key for source map uploads because client keys are commonly embedded in distributed applications. + +Source maps must use the version 3 flat-map format. Indexed source maps with a `sections` property and authenticated automatic downloads are planned follow-up capabilities; private maps can be uploaded in the meantime. + +## Deployment guidance + +Generate source maps as part of the same build that produces the minified JavaScript. Content-hashed generated file names are preferred because the generated URL then identifies a specific build. You can publish the `.map` file for zero-configuration discovery or keep it private and upload it to Exceptionless during deployment. diff --git a/src/Exceptionless.Core/Authorization/AuthorizationRoles.cs b/src/Exceptionless.Core/Authorization/AuthorizationRoles.cs index 546b06e864..0ac1565c2e 100644 --- a/src/Exceptionless.Core/Authorization/AuthorizationRoles.cs +++ b/src/Exceptionless.Core/Authorization/AuthorizationRoles.cs @@ -12,12 +12,14 @@ public static class AuthorizationRoles public const string ProjectsReadPolicy = nameof(ProjectsReadPolicy); public const string StacksReadPolicy = nameof(StacksReadPolicy); public const string StacksWritePolicy = nameof(StacksWritePolicy); + public const string SourceMapsWritePolicy = nameof(SourceMapsWritePolicy); public const string EventsReadPolicy = nameof(EventsReadPolicy); public const string McpRead = "mcp:read"; public const string ProjectsRead = "projects:read"; public const string StacksRead = "stacks:read"; public const string StacksWrite = "stacks:write"; + public const string SourceMapsWrite = "source-maps:write"; public const string EventsRead = "events:read"; public const string OfflineAccess = "offline_access"; - public static readonly ISet AllScopes = new HashSet([Client, User, GlobalAdmin]); + public static readonly ISet AllScopes = new HashSet([Client, User, GlobalAdmin, SourceMapsWrite]); } diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index efcfae9efb..a72e0cc65f 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -25,6 +25,7 @@ using Exceptionless.Core.Seed; using Exceptionless.Core.Serialization; using Exceptionless.Core.Services; +using Exceptionless.Core.Services.SourceMaps; using Exceptionless.Core.Utility; using Exceptionless.Core.Validation; using Foundatio.Caching; @@ -193,6 +194,16 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO AllowAutoRedirect = false, ConnectCallback = ConnectToPublicAddressAsync }); + services.AddSingleton(); + services.AddHttpClient(SourceMapService.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(serviceProvider => CreateSourceMapHttpMessageHandler( + serviceProvider.GetRequiredService(), + DecompressionMethods.All)); + services.AddHttpClient(SourceMapService.GeneratedFileHttpClientName) + .ConfigurePrimaryHttpMessageHandler(serviceProvider => CreateSourceMapHttpMessageHandler( + serviceProvider.GetRequiredService(), + DecompressionMethods.None)); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -223,9 +234,21 @@ private static async ValueTask ConnectToPublicAddressAsync(SocketsHttpCo } } - throw new HttpRequestException($"OAuth client metadata host '{context.DnsEndPoint.Host}' did not resolve to a reachable public address.", lastException); + throw new HttpRequestException($"Host '{context.DnsEndPoint.Host}' did not resolve to a reachable public address.", lastException); } + internal static SocketsHttpHandler CreateSourceMapHttpMessageHandler(SourceMapRequestThrottle throttle, DecompressionMethods automaticDecompression) + => new() + { + ActivityHeadersPropagator = null, + AllowAutoRedirect = false, + AutomaticDecompression = automaticDecompression, + ConnectCallback = throttle.ConnectToPublicAddressAsync, + PreAuthenticate = false, + UseCookies = false, + UseProxy = false + }; + public static void LogConfiguration(IServiceProvider serviceProvider, AppOptions appOptions, ILogger logger) { if (!logger.IsEnabled(LogLevel.Warning)) diff --git a/src/Exceptionless.Core/Configuration/AppOptions.cs b/src/Exceptionless.Core/Configuration/AppOptions.cs index ff6217829a..99da0ce9d3 100644 --- a/src/Exceptionless.Core/Configuration/AppOptions.cs +++ b/src/Exceptionless.Core/Configuration/AppOptions.cs @@ -80,6 +80,7 @@ public class AppOptions public StripeOptions StripeOptions { get; internal set; } = null!; public AuthOptions AuthOptions { get; internal set; } = null!; public OAuthServerOptions OAuthServerOptions { get; internal set; } = null!; + public SourceMapOptions SourceMapOptions { get; internal set; } = null!; public static AppOptions ReadFromConfiguration(IConfiguration config) { @@ -133,6 +134,7 @@ public static AppOptions ReadFromConfiguration(IConfiguration config) options.StripeOptions = StripeOptions.ReadFromConfiguration(config); options.AuthOptions = AuthOptions.ReadFromConfiguration(config); options.OAuthServerOptions = OAuthServerOptions.ReadFromConfiguration(config); + options.SourceMapOptions = SourceMapOptions.ReadFromConfiguration(config); return options; } diff --git a/src/Exceptionless.Core/Configuration/SourceMapOptions.cs b/src/Exceptionless.Core/Configuration/SourceMapOptions.cs new file mode 100644 index 0000000000..57bd00b9ad --- /dev/null +++ b/src/Exceptionless.Core/Configuration/SourceMapOptions.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Configuration; + +namespace Exceptionless.Core.Configuration; + +public sealed class SourceMapOptions +{ + public bool EnableAutoDownload { get; internal set; } + public int RequestTimeoutMilliseconds { get; internal set; } + public int MaximumGeneratedFileSize { get; internal set; } + public int MaximumSourceMapSize { get; internal set; } + public int MaximumArtifactsPerProject { get; internal set; } + public long MaximumStorageSizePerProject { get; internal set; } + public int MaximumArtifactsPerFreeProject { get; internal set; } + public long MaximumStorageSizePerFreeProject { get; internal set; } + public int MaximumMappingSegments { get; internal set; } + public int MaximumRedirects { get; internal set; } + public int MaximumConcurrentDownloads { get; internal set; } + public int MaximumConcurrentDownloadsGlobally { get; internal set; } + public int AutoDownloadRateLimitPeriodMinutes { get; internal set; } + public int MaximumAutoDiscoveriesPerFreeClientKey { get; internal set; } + public int MaximumAutoDiscoveriesPerClientKey { get; internal set; } + public int MaximumAutoDiscoveriesPerFreeProject { get; internal set; } + public int MaximumAutoDiscoveriesPerProject { get; internal set; } + public int MaximumAutoDiscoveriesPerFreeOrganization { get; internal set; } + public int MaximumAutoDiscoveriesPerOrganization { get; internal set; } + public int MaximumAutoDownloadRequestsPerDestination { get; internal set; } + public int MaximumAutoDownloadConnectionsPerIpAddress { get; internal set; } + public int MaximumAutoDownloadRequestsGlobally { get; internal set; } + public int MaximumAutoRefreshRequestsPerDestination { get; internal set; } + public int MaximumAutoRefreshRequestsGlobally { get; internal set; } + public int MaximumFramesPerError { get; internal set; } + public int MaximumProcessingTimeMilliseconds { get; internal set; } + public int AutoDownloadRefreshIntervalMinutes { get; internal set; } + public int ParsedSourceMapCacheLifetimeMinutes { get; internal set; } + public long MaximumParsedSourceMapCacheSize { get; internal set; } + public int UsageTrackingDebounceMinutes { get; internal set; } + public int FreeArtifactRetentionDays { get; internal set; } + public int ArtifactRetentionDays { get; internal set; } + + public TimeSpan RequestTimeout => TimeSpan.FromMilliseconds(RequestTimeoutMilliseconds); + public TimeSpan MaximumProcessingTime => TimeSpan.FromMilliseconds(MaximumProcessingTimeMilliseconds); + public TimeSpan AutoDownloadRefreshInterval => TimeSpan.FromMinutes(AutoDownloadRefreshIntervalMinutes); + public TimeSpan ParsedSourceMapCacheLifetime => TimeSpan.FromMinutes(ParsedSourceMapCacheLifetimeMinutes); + public TimeSpan AutoDownloadRateLimitPeriod => TimeSpan.FromMinutes(AutoDownloadRateLimitPeriodMinutes); + public TimeSpan UsageTrackingDebounce => TimeSpan.FromMinutes(UsageTrackingDebounceMinutes); + public TimeSpan FreeArtifactRetention => TimeSpan.FromDays(FreeArtifactRetentionDays); + public TimeSpan ArtifactRetention => TimeSpan.FromDays(ArtifactRetentionDays); + + public static SourceMapOptions ReadFromConfiguration(IConfiguration configuration) + { + var section = configuration.GetSection("SourceMaps"); + return new SourceMapOptions + { + EnableAutoDownload = section.GetValue(nameof(EnableAutoDownload), true), + RequestTimeoutMilliseconds = ReadPositive(section, nameof(RequestTimeoutMilliseconds), 3000), + MaximumGeneratedFileSize = ReadPositive(section, nameof(MaximumGeneratedFileSize), 5 * 1024 * 1024), + MaximumSourceMapSize = ReadPositive(section, nameof(MaximumSourceMapSize), 20 * 1024 * 1024), + MaximumArtifactsPerProject = ReadPositive(section, nameof(MaximumArtifactsPerProject), 1000), + MaximumStorageSizePerProject = ReadPositive(section, nameof(MaximumStorageSizePerProject), 1024L * 1024 * 1024), + MaximumArtifactsPerFreeProject = ReadPositive(section, nameof(MaximumArtifactsPerFreeProject), 100), + MaximumStorageSizePerFreeProject = ReadPositive(section, nameof(MaximumStorageSizePerFreeProject), 100L * 1024 * 1024), + MaximumMappingSegments = ReadPositive(section, nameof(MaximumMappingSegments), 1_000_000), + MaximumRedirects = Math.Max(0, section.GetValue(nameof(MaximumRedirects), 3)), + MaximumConcurrentDownloads = ReadPositive(section, nameof(MaximumConcurrentDownloads), 4), + MaximumConcurrentDownloadsGlobally = ReadPositive(section, nameof(MaximumConcurrentDownloadsGlobally), 16), + AutoDownloadRateLimitPeriodMinutes = ReadPositive(section, nameof(AutoDownloadRateLimitPeriodMinutes), 15), + MaximumAutoDiscoveriesPerFreeClientKey = Math.Max(0, section.GetValue(nameof(MaximumAutoDiscoveriesPerFreeClientKey), 5)), + MaximumAutoDiscoveriesPerClientKey = Math.Max(0, section.GetValue(nameof(MaximumAutoDiscoveriesPerClientKey), 25)), + MaximumAutoDiscoveriesPerFreeProject = Math.Max(0, section.GetValue(nameof(MaximumAutoDiscoveriesPerFreeProject), 10)), + MaximumAutoDiscoveriesPerProject = Math.Max(0, section.GetValue(nameof(MaximumAutoDiscoveriesPerProject), 50)), + MaximumAutoDiscoveriesPerFreeOrganization = Math.Max(0, section.GetValue(nameof(MaximumAutoDiscoveriesPerFreeOrganization), 10)), + MaximumAutoDiscoveriesPerOrganization = Math.Max(0, section.GetValue(nameof(MaximumAutoDiscoveriesPerOrganization), 100)), + MaximumAutoDownloadRequestsPerDestination = Math.Max(0, section.GetValue(nameof(MaximumAutoDownloadRequestsPerDestination), 100)), + MaximumAutoDownloadConnectionsPerIpAddress = Math.Max(0, section.GetValue(nameof(MaximumAutoDownloadConnectionsPerIpAddress), 200)), + MaximumAutoDownloadRequestsGlobally = Math.Max(0, section.GetValue(nameof(MaximumAutoDownloadRequestsGlobally), 1000)), + MaximumAutoRefreshRequestsPerDestination = Math.Max(0, section.GetValue(nameof(MaximumAutoRefreshRequestsPerDestination), 20)), + MaximumAutoRefreshRequestsGlobally = Math.Max(0, section.GetValue(nameof(MaximumAutoRefreshRequestsGlobally), 200)), + MaximumFramesPerError = ReadPositive(section, nameof(MaximumFramesPerError), 100), + MaximumProcessingTimeMilliseconds = ReadPositive(section, nameof(MaximumProcessingTimeMilliseconds), 5000), + AutoDownloadRefreshIntervalMinutes = ReadPositive(section, nameof(AutoDownloadRefreshIntervalMinutes), 60), + ParsedSourceMapCacheLifetimeMinutes = ReadPositive(section, nameof(ParsedSourceMapCacheLifetimeMinutes), 5), + MaximumParsedSourceMapCacheSize = Math.Max(1, section.GetValue(nameof(MaximumParsedSourceMapCacheSize), 100L * 1024 * 1024)), + UsageTrackingDebounceMinutes = ReadPositive(section, nameof(UsageTrackingDebounceMinutes), 60), + FreeArtifactRetentionDays = ReadPositive(section, nameof(FreeArtifactRetentionDays), 14), + ArtifactRetentionDays = ReadPositive(section, nameof(ArtifactRetentionDays), 90) + }; + } + + private static int ReadPositive(IConfiguration section, string name, int defaultValue) + => Math.Max(1, section.GetValue(name, defaultValue)); + + private static long ReadPositive(IConfiguration section, string name, long defaultValue) + => Math.Max(1, section.GetValue(name, defaultValue)); +} diff --git a/src/Exceptionless.Core/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj index bbf87bf0e3..f06675222a 100644 --- a/src/Exceptionless.Core/Exceptionless.Core.csproj +++ b/src/Exceptionless.Core/Exceptionless.Core.csproj @@ -31,6 +31,7 @@ + diff --git a/src/Exceptionless.Core/Jobs/CleanupDataJob.cs b/src/Exceptionless.Core/Jobs/CleanupDataJob.cs index f212c67cf4..3482dbaf9a 100644 --- a/src/Exceptionless.Core/Jobs/CleanupDataJob.cs +++ b/src/Exceptionless.Core/Jobs/CleanupDataJob.cs @@ -4,6 +4,7 @@ using Exceptionless.Core.Repositories; using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Services; +using Exceptionless.Core.Services.SourceMaps; using Exceptionless.Core.Utility; using Exceptionless.DateTimeExtensions; using Foundatio.Caching; @@ -40,6 +41,8 @@ public class CleanupDataJob : JobWithLockBase, IHealthCheck private readonly IWebHookRepository _webHookRepository; private readonly BillingManager _billingManager; private readonly UsageService _usageService; + private readonly SourceMapService _sourceMapService; + private readonly BillingPlans _billingPlans; private readonly AppOptions _appOptions; private readonly ILockProvider _lockProvider; private readonly ICacheClient _cacheClient; @@ -60,7 +63,9 @@ public CleanupDataJob( ICacheClient cacheClient, IFileStorage fileStorage, BillingManager billingManager, + BillingPlans billingPlans, UsageService usageService, + SourceMapService sourceMapService, AppOptions appOptions, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, @@ -77,7 +82,9 @@ ILoggerFactory loggerFactory _oauthTokenRepository = oauthTokenRepository; _webHookRepository = webHookRepository; _billingManager = billingManager; + _billingPlans = billingPlans; _usageService = usageService; + _sourceMapService = sourceMapService; _appOptions = appOptions; _lockProvider = lockProvider; _cacheClient = cacheClient; @@ -93,6 +100,8 @@ protected override async Task RunInternalAsync(JobContext context) { _lastRun = _timeProvider.GetUtcNow().UtcDateTime; + bool canCleanupSourceMaps = await FlushSourceMapUsagesAsync(context.CancellationToken); + await MarkTokensSuspended(context); await CleanupOAuthTokensAsync(context); await CleanupSyntheticOrganizationsAsync(context); @@ -101,7 +110,7 @@ protected override async Task RunInternalAsync(JobContext context) await CleanupSoftDeletedProjectsAsync(context); await CleanupSoftDeletedStacksAsync(context); - await EnforceRetentionAsync(context); + await EnforceRetentionAsync(context, canCleanupSourceMaps); _logger.CleanupFinished(); @@ -286,6 +295,8 @@ private async Task RemoveOrganizationAsync(Organization organization, JobContext await RenewLockAsync(context); long removedStacks = await _stackRepository.RemoveAllByOrganizationIdAsync(organization.Id); + await RemoveOrganizationProjectFilesAsync(organization.Id, context); + await RenewLockAsync(context); long removedProjects = await _projectRepository.RemoveAllByOrganizationIdAsync(organization.Id); @@ -316,6 +327,23 @@ private async Task RemoveSyntheticUserAsync(User user) private Task RemoveOrganizationFilesAsync(Organization organization, JobContext context) => RemoveFilesAsync(OrganizationStoragePaths.GetProfileImagesPath(organization.Id), context.CancellationToken); + private async Task RemoveOrganizationProjectFilesAsync(string organizationId, JobContext context) + { + var projects = await _projectRepository.GetByOrganizationIdAsync( + organizationId, + options => options.Include(project => project.Id).SoftDeleteMode(SoftDeleteQueryMode.All).SearchAfterPaging().PageLimit(100)); + + while (projects.Documents.Count > 0 && !context.CancellationToken.IsCancellationRequested) + { + foreach (var project in projects.Documents) + await _sourceMapService.DeleteProjectArtifactsAsync(project.Id, context.CancellationToken); + + await RenewLockAsync(context); + if (!await projects.NextPageAsync()) + break; + } + } + private async Task RemoveFilesAsync(string path, CancellationToken cancellationToken) { string searchPattern = $"{path}/*"; @@ -341,6 +369,7 @@ private async Task RemoveProjectsAsync(Project project, JobContext context) await RenewLockAsync(context); long removedStacks = await _stackRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); + await _sourceMapService.DeleteProjectArtifactsAsync(project.Id, context.CancellationToken); await _projectRepository.RemoveAsync(project); _logger.RemoveProjectComplete(project.Name, project.Id, removedStacks, removedEvents); } @@ -378,9 +407,23 @@ private async Task RemoveStacksAsync(IReadOnlyCollection stacks, JobConte _logger.RemoveStacksComplete(stacks.Count, totalRemovedEvents); } - private async Task EnforceRetentionAsync(JobContext context) + private async Task FlushSourceMapUsagesAsync(CancellationToken cancellationToken) { - var results = await _organizationRepository.FindAsync(q => q.Include(o => o.Id, o => o.Name, o => o.RetentionDays), o => o.SearchAfterPaging().PageLimit(100)); + try + { + await _sourceMapService.SaveUsagesAsync(cancellationToken); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to persist pending source map usage. Stale source map cleanup will be skipped."); + return false; + } + } + + private async Task EnforceRetentionAsync(JobContext context, bool canCleanupSourceMaps) + { + var results = await _organizationRepository.FindAsync(q => q.Include(o => o.Id, o => o.Name, o => o.PlanId, o => o.RetentionDays), o => o.SearchAfterPaging().PageLimit(100)); while (results.Documents.Count > 0 && !context.CancellationToken.IsCancellationRequested) { foreach (var organization in results.Documents) @@ -397,6 +440,8 @@ private async Task EnforceRetentionAsync(JobContext context) // adding 60 days to retention in order to keep track of whether a stack is new or not await EnforceStackRetentionDaysAsync(organization, retentionDays + 60, context); await EnforceEventRetentionDaysAsync(organization, retentionDays, context); + if (canCleanupSourceMaps) + await CleanupSourceMapsAsync(organization, context); } catch (Exception ex) { @@ -412,6 +457,28 @@ private async Task EnforceRetentionAsync(JobContext context) } } + private async Task CleanupSourceMapsAsync(Organization organization, JobContext context) + { + bool isFreePlan = String.Equals(organization.PlanId, _billingPlans.FreePlan.Id, StringComparison.OrdinalIgnoreCase); + var projects = await _projectRepository.GetByOrganizationIdAsync( + organization.Id, + options => options.Include(project => project.Id).SearchAfterPaging().PageLimit(100)); + + while (projects.Documents.Count > 0 && !context.CancellationToken.IsCancellationRequested) + { + foreach (var project in projects.Documents) + { + int removed = await _sourceMapService.CleanupStaleArtifactsAsync(project.Id, isFreePlan, context.CancellationToken); + if (removed > 0) + _logger.LogInformation("Removed {SourceMapCount} stale source map(s) from project {ProjectId}.", removed, project.Id); + } + + await RenewLockAsync(context); + if (!await projects.NextPageAsync()) + break; + } + } + private async Task EnforceStackRetentionDaysAsync(Organization organization, int retentionDays, JobContext context) { await RenewLockAsync(context); diff --git a/src/Exceptionless.Core/Jobs/EventPostsJob.cs b/src/Exceptionless.Core/Jobs/EventPostsJob.cs index 0b7aaffec3..40db24f038 100644 --- a/src/Exceptionless.Core/Jobs/EventPostsJob.cs +++ b/src/Exceptionless.Core/Jobs/EventPostsJob.cs @@ -312,6 +312,7 @@ await _eventPostService.EnqueueAsync(new EventPost(false) { ApiVersion = ep.ApiVersion, CharSet = ep.CharSet, + ClientKeyHash = ep.ClientKeyHash, ContentEncoding = null, IpAddress = ep.IpAddress, MediaType = ep.MediaType, diff --git a/src/Exceptionless.Core/Models/Data/StackFrame.cs b/src/Exceptionless.Core/Models/Data/StackFrame.cs index ccb7f67b51..fb06e90a0d 100644 --- a/src/Exceptionless.Core/Models/Data/StackFrame.cs +++ b/src/Exceptionless.Core/Models/Data/StackFrame.cs @@ -2,6 +2,11 @@ public class StackFrame : Method { + public static class KnownDataKeys + { + public const string SourceMap = "@source_map"; + } + public string? FileName { get; set; } public int? LineNumber { get; set; } public int? Column { get; set; } diff --git a/src/Exceptionless.Core/Models/Queues/EventPostInfo.cs b/src/Exceptionless.Core/Models/Queues/EventPostInfo.cs index 5ba9ecb819..69223071d8 100644 --- a/src/Exceptionless.Core/Models/Queues/EventPostInfo.cs +++ b/src/Exceptionless.Core/Models/Queues/EventPostInfo.cs @@ -10,6 +10,7 @@ public record EventPostInfo public string? UserAgent { get; init; } public string? ContentEncoding { get; init; } public string? IpAddress { get; init; } + public string? ClientKeyHash { get; init; } } public record EventPost : EventPostInfo diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/15_SourceMapPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/15_SourceMapPlugin.cs new file mode 100644 index 0000000000..a92c8bc692 --- /dev/null +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/15_SourceMapPlugin.cs @@ -0,0 +1,44 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Billing; +using Exceptionless.Core.Models; +using Exceptionless.Core.Pipeline; +using Exceptionless.Core.Services.SourceMaps; +using Foundatio.Serializer; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Plugins.EventProcessor.Default; + +[Priority(15)] +public sealed class SourceMapPlugin : EventProcessorPluginBase +{ + private readonly SourceMapService _sourceMapService; + private readonly ITextSerializer _serializer; + private readonly BillingPlans _billingPlans; + + public SourceMapPlugin(SourceMapService sourceMapService, ITextSerializer serializer, BillingPlans billingPlans, AppOptions options, ILoggerFactory loggerFactory) + : base(options, loggerFactory) + { + _sourceMapService = sourceMapService; + _serializer = serializer; + _billingPlans = billingPlans; + ContinueOnError = true; + } + + public override async Task EventProcessingAsync(EventContext context) + { + if (!context.Event.IsError()) + return; + + var error = context.Event.GetError(_serializer, _logger); + if (error is null) + return; + + var request = new SourceMapRequest( + context.Organization.Id, + context.Project.Id, + context.EventPostInfo?.ClientKeyHash, + String.Equals(context.Organization.PlanId, _billingPlans.FreePlan.Id, StringComparison.OrdinalIgnoreCase)); + if (await _sourceMapService.SymbolicateAsync(request, error)) + context.Event.SetError(error); + } +} diff --git a/src/Exceptionless.Core/Repositories/Interfaces/ITokenRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/ITokenRepository.cs index 2ef7338f01..ca00d629b1 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/ITokenRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/ITokenRepository.cs @@ -9,6 +9,7 @@ public interface ITokenRepository : IRepositoryOwnedByOrganizationAndProject> GetByTypeAndUserIdAsync(TokenType type, string userId, CommandOptionsDescriptor? options = null); Task> GetByTypeAndOrganizationIdAsync(TokenType type, string organizationId, CommandOptionsDescriptor? options = null); Task> GetByTypeAndProjectIdAsync(TokenType type, string projectId, CommandOptionsDescriptor? options = null); + Task> GetDefaultClientTokenAsync(string projectId, CommandOptionsDescriptor? options = null); Task> GetByRefreshTokenAsync(string refreshToken, CommandOptionsDescriptor? options = null); Task RemoveAllByUserIdAsync(string userId, CommandOptionsDescriptor? options = null); } diff --git a/src/Exceptionless.Core/Repositories/TokenRepository.cs b/src/Exceptionless.Core/Repositories/TokenRepository.cs index 47a3f5031b..025637058b 100644 --- a/src/Exceptionless.Core/Repositories/TokenRepository.cs +++ b/src/Exceptionless.Core/Repositories/TokenRepository.cs @@ -1,3 +1,4 @@ +using Exceptionless.Core.Authorization; using Exceptionless.Core.Messaging.Models; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories.Configuration; @@ -39,6 +40,19 @@ public Task> GetByTypeAndProjectIdAsync(TokenType type, strin .Sort(f => f.CreatedUtc), options); } + public Task> GetDefaultClientTokenAsync(string projectId, CommandOptionsDescriptor? options = null) + { + return FindAsync(q => q + .FieldOr(g => g + .FieldEquals(t => t.ProjectId, projectId) + .FieldEquals(t => t.DefaultProjectId, projectId)) + .FieldEquals(t => t.Type, (int)TokenType.Access) + .FieldOr(g => g + .FieldEquals(t => t.Scopes, AuthorizationRoles.Client) + .FieldEquals(t => t.Scopes, null!)) + .Sort(f => f.CreatedUtc), options); + } + public Task> GetByRefreshTokenAsync(string refreshToken, CommandOptionsDescriptor? options = null) { return FindAsync(q => q.FieldEquals(t => t.Refresh, refreshToken).SortDescending(f => f.CreatedUtc), options); diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapArtifact.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapArtifact.cs new file mode 100644 index 0000000000..6ed34cb22c --- /dev/null +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapArtifact.cs @@ -0,0 +1,13 @@ +namespace Exceptionless.Core.Services.SourceMaps; + +public sealed record SourceMapArtifact +{ + public required string Id { get; init; } + public required string GeneratedFileUrl { get; init; } + public string? SourceMapUrl { get; init; } + public string? FileName { get; init; } + public required long Size { get; init; } + public required bool IsAutoDownloaded { get; init; } + public required DateTime CreatedUtc { get; init; } + public DateTime? LastUsedUtc { get; init; } +} diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapContent.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapContent.cs new file mode 100644 index 0000000000..59306e9d18 --- /dev/null +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapContent.cs @@ -0,0 +1,20 @@ +namespace Exceptionless.Core.Services.SourceMaps; + +internal static class SourceMapContent +{ + public static async Task ReadLimitedAsync(Stream stream, int maximumBytes, CancellationToken cancellationToken) + { + using var memoryStream = new MemoryStream(Math.Min(maximumBytes, 64 * 1024)); + byte[] buffer = new byte[81920]; + int read; + while ((read = await stream.ReadAsync(buffer, cancellationToken)) > 0) + { + if (memoryStream.Length + read > maximumBytes) + throw new InvalidOperationException("The file exceeded the configured maximum size."); + + await memoryStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + + return memoryStream.ToArray(); + } +} diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapDocument.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapDocument.cs new file mode 100644 index 0000000000..5ac1e90668 --- /dev/null +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapDocument.cs @@ -0,0 +1,262 @@ +using System.Text.Json; + +namespace Exceptionless.Core.Services.SourceMaps; + +public sealed class SourceMapDocument +{ + private const string Base64Characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + private const int MaximumSourceEntries = 1_000_000; + private readonly IReadOnlyList> _lines; + private readonly string[] _names; + private readonly string[] _sources; + + private SourceMapDocument( + string? sourceRoot, + string[] sources, + string[] names, + IReadOnlyList> lines, + long estimatedMemorySize) + { + SourceRoot = sourceRoot; + _sources = sources; + _names = names; + _lines = lines; + EstimatedMemorySize = estimatedMemorySize; + } + + public string? SourceRoot { get; } + internal long EstimatedMemorySize { get; } + + public static SourceMapDocument Parse(byte[] sourceMap, int maximumSegments = 1_000_000) + => Parse(sourceMap, maximumSegments, 100_000, MaximumSourceEntries); + + internal static SourceMapDocument Parse(byte[] sourceMap, int maximumSegments, int maximumLines, int maximumSourceEntries = MaximumSourceEntries) + { + using var document = JsonDocument.Parse(sourceMap); + var root = document.RootElement; + + if (!root.TryGetProperty("version", out var versionElement) + || versionElement.ValueKind != JsonValueKind.Number + || !versionElement.TryGetInt32(out int version) + || version != 3) + throw new JsonException("Only source map version 3 is supported."); + + if (root.TryGetProperty("sections", out _)) + throw new JsonException("Indexed source maps are not supported."); + + if (!root.TryGetProperty("mappings", out var mappingsElement) || mappingsElement.ValueKind != JsonValueKind.String) + throw new JsonException("The source map mappings are required."); + + string mappings = mappingsElement.GetString() ?? throw new JsonException("The source map mappings are required."); + string[] sources = ReadStringArray(root, "sources", maximumSourceEntries, out long sourcesMemorySize); + long namesMemorySize = 0; + string[] names = root.TryGetProperty("names", out _) + ? ReadStringArray(root, "names", maximumSourceEntries, out namesMemorySize) + : []; + string? sourceRoot = root.TryGetProperty("sourceRoot", out var sourceRootElement) ? sourceRootElement.GetString() : null; + + var lines = DecodeMappings(mappings, sources.Length, names.Length, maximumSegments, maximumLines, out int segmentCount); + long estimatedMemorySize = sourceMap.LongLength + + sourcesMemorySize + + namesMemorySize + + EstimateStringMemorySize(sourceRoot) + + (segmentCount * 64L) + + (lines.Count * 64L); + return new SourceMapDocument(sourceRoot, sources, names, lines, estimatedMemorySize); + } + + public SourceMapLocation? FindOriginalLocation(int generatedLine, int generatedColumn) + { + if (generatedLine < 0 || generatedLine >= _lines.Count || generatedColumn < 0) + return null; + + var segments = _lines[generatedLine]; + int lowerBound = 0; + int upperBound = segments.Count; + while (lowerBound < upperBound) + { + int middle = lowerBound + ((upperBound - lowerBound) / 2); + if (segments[middle].GeneratedColumn <= generatedColumn) + { + lowerBound = middle + 1; + } + else + { + upperBound = middle; + } + } + + MappingSegment? match = lowerBound > 0 ? segments[lowerBound - 1] : null; + + if (match?.SourceIndex is not int sourceIndex || match.OriginalLine is not int originalLine || match.OriginalColumn is not int originalColumn) + return null; + + string? name = match.NameIndex is int nameIndex ? _names[nameIndex] : null; + return new SourceMapLocation(CombineSource(SourceRoot, _sources[sourceIndex]), originalLine, originalColumn, name); + } + + private static string[] ReadStringArray(JsonElement root, string propertyName, int maximumEntries, out long estimatedMemorySize) + { + if (!root.TryGetProperty(propertyName, out var element) || element.ValueKind != JsonValueKind.Array) + throw new JsonException($"The source map {propertyName} array is required."); + if (maximumEntries < 1) + throw new ArgumentOutOfRangeException(nameof(maximumEntries)); + + estimatedMemorySize = 0; + var values = new List(Math.Min(element.GetArrayLength(), maximumEntries)); + foreach (var value in element.EnumerateArray()) + { + if (values.Count >= maximumEntries) + throw new JsonException($"The source map {propertyName} array contains too many entries."); + + string text = value.GetString() ?? throw new JsonException($"The source map {propertyName} array contains a null value."); + values.Add(text); + estimatedMemorySize += IntPtr.Size + EstimateStringMemorySize(text); + } + + return values.ToArray(); + } + + private static long EstimateStringMemorySize(string? value) => value is null ? 0 : 24L + (value.Length * sizeof(char)); + + private static IReadOnlyList> DecodeMappings( + string mappings, + int sourceCount, + int nameCount, + int maximumSegments, + int maximumLines, + out int segmentCount) + { + if (maximumSegments < 1) + throw new ArgumentOutOfRangeException(nameof(maximumSegments)); + if (maximumLines < 1) + throw new ArgumentOutOfRangeException(nameof(maximumLines)); + + int lineCount = 1; + int encodedSegmentCount = 0; + bool hasEncodedSegment = false; + foreach (char character in mappings) + { + if (character is ',' or ';') + { + if (hasEncodedSegment && ++encodedSegmentCount > maximumSegments) + throw new JsonException("The source map contains too many mapping segments."); + hasEncodedSegment = false; + } + else + { + hasEncodedSegment = true; + } + + if (character == ';' && ++lineCount > maximumLines) + throw new JsonException("The source map contains too many generated lines."); + } + if (hasEncodedSegment && ++encodedSegmentCount > maximumSegments) + throw new JsonException("The source map contains too many mapping segments."); + + var lines = new List>(lineCount); + int sourceIndex = 0; + int originalLine = 0; + int originalColumn = 0; + int nameIndex = 0; + segmentCount = 0; + + foreach (string encodedLine in mappings.Split(';')) + { + int generatedColumn = 0; + int previousGeneratedColumn = -1; + var line = new List(); + foreach (string encodedSegment in encodedLine.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + if (++segmentCount > maximumSegments) + throw new JsonException("The source map contains too many mapping segments."); + + int index = 0; + generatedColumn += DecodeValue(encodedSegment, ref index); + if (generatedColumn < 0) + throw new JsonException("A source map generated column cannot be negative."); + if (generatedColumn < previousGeneratedColumn) + throw new JsonException("Source map generated columns must be in ascending order."); + previousGeneratedColumn = generatedColumn; + + if (index == encodedSegment.Length) + { + line.Add(new MappingSegment(generatedColumn, null, null, null, null)); + continue; + } + + sourceIndex += DecodeValue(encodedSegment, ref index); + originalLine += DecodeValue(encodedSegment, ref index); + originalColumn += DecodeValue(encodedSegment, ref index); + if (sourceIndex < 0 || sourceIndex >= sourceCount || originalLine < 0 || originalColumn < 0) + throw new JsonException("A source map mapping points outside its source data."); + + int? segmentNameIndex = null; + if (index < encodedSegment.Length) + { + nameIndex += DecodeValue(encodedSegment, ref index); + if (nameIndex < 0 || nameIndex >= nameCount) + throw new JsonException("A source map mapping points outside its names array."); + + segmentNameIndex = nameIndex; + } + + if (index != encodedSegment.Length) + throw new JsonException("A source map segment has an invalid field count."); + + line.Add(new MappingSegment(generatedColumn, sourceIndex, originalLine, originalColumn, segmentNameIndex)); + } + + lines.Add(line); + } + + return lines; + } + + private static int DecodeValue(string segment, ref int index) + { + long result = 0; + int shift = 0; + bool hasContinuation; + + do + { + if (index >= segment.Length) + throw new JsonException("A source map VLQ value is incomplete."); + + int digit = Base64Characters.IndexOf(segment[index++]); + if (digit < 0) + throw new JsonException("A source map VLQ value contains an invalid character."); + + hasContinuation = (digit & 32) != 0; + digit &= 31; + result += (long)digit << shift; + shift += 5; + if (shift > 35 || result > (long)Int32.MaxValue * 2 + 1) + throw new JsonException("A source map VLQ value is too large."); + } while (hasContinuation); + + bool isNegative = (result & 1) == 1; + result >>= 1; + return isNegative ? -(int)result : (int)result; + } + + private static string CombineSource(string? sourceRoot, string source) + { + if (String.IsNullOrWhiteSpace(sourceRoot) || Uri.TryCreate(source, UriKind.Absolute, out _)) + return source; + + if (Uri.TryCreate(sourceRoot, UriKind.Absolute, out var sourceRootUri)) + { + var sourceRootBuilder = new UriBuilder(sourceRootUri) { Path = sourceRootUri.AbsolutePath.TrimEnd('/') + '/' }; + if (Uri.TryCreate(sourceRootBuilder.Uri, source.TrimStart('/'), out var combinedUri)) + return combinedUri.ToString(); + } + + return $"{sourceRoot.TrimEnd('/')}/{source.TrimStart('/')}"; + } + + private sealed record MappingSegment(int GeneratedColumn, int? SourceIndex, int? OriginalLine, int? OriginalColumn, int? NameIndex); +} + +public sealed record SourceMapLocation(string Source, int Line, int Column, string? Name); diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapDownloader.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapDownloader.cs new file mode 100644 index 0000000000..3581b69428 --- /dev/null +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapDownloader.cs @@ -0,0 +1,332 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Exceptionless.Core.Configuration; + +namespace Exceptionless.Core.Services.SourceMaps; + +internal sealed class SourceMapDownloader +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly SourceMapOptions _options; + private readonly SourceMapRequestThrottle _throttle; + + public SourceMapDownloader(IHttpClientFactory httpClientFactory, AppOptions options, SourceMapRequestThrottle throttle) + { + _httpClientFactory = httpClientFactory; + _options = options.SourceMapOptions; + _throttle = throttle; + } + + public async Task DownloadAsync(Uri generatedFileUri, bool isRefresh, CancellationToken cancellationToken) + { + using var generatedResponse = await DownloadGeneratedFileAsync(generatedFileUri, isRefresh, cancellationToken); + if (!generatedResponse.Response.IsSuccessStatusCode) + return null; + + string? sourceMapReference = GetSourceMapHeader(generatedResponse.Response); + if (String.IsNullOrWhiteSpace(sourceMapReference) + && (generatedResponse.Response.Content.Headers.ContentLength is not long contentLength + || contentLength <= _options.MaximumGeneratedFileSize)) + { + try + { + byte[] generatedContent = await SourceMapContent.ReadLimitedAsync( + await generatedResponse.Response.Content.ReadAsStreamAsync(cancellationToken), + _options.MaximumGeneratedFileSize, + cancellationToken); + sourceMapReference = FindSourceMapReference(Encoding.UTF8.GetString(generatedContent)); + } + catch (InvalidOperationException) + { + // A CDN may ignore the range request and return the entire bundle. Continue to the conventional .map fallback. + } + } + + if (String.IsNullOrWhiteSpace(sourceMapReference)) + { + var fallbackUriBuilder = new UriBuilder(generatedResponse.Uri) { Path = generatedResponse.Uri.AbsolutePath + ".map" }; + if (String.IsNullOrEmpty(fallbackUriBuilder.Query)) + { + return await DownloadContentAsync(fallbackUriBuilder.Uri, isRefresh, cancellationToken); + } + + try + { + var downloaded = await DownloadContentAsync(fallbackUriBuilder.Uri, isRefresh, cancellationToken); + if (downloaded is not null) + { + var document = SourceMapDocument.Parse(downloaded.Content, _options.MaximumMappingSegments); + return downloaded with { Document = document }; + } + } + catch (Exception ex) when (ex is InvalidOperationException or JsonException) + { + // The query-specific URL may resolve to an invalid CDN or SPA fallback. Try the conventional URL without its query. + } + + fallbackUriBuilder.Query = String.Empty; + return await DownloadContentAsync(fallbackUriBuilder.Uri, isRefresh, cancellationToken); + } + + if (sourceMapReference.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + return new DownloadedSourceMap(DecodeDataUri(sourceMapReference, _options.MaximumSourceMapSize), null); + + if (!Uri.TryCreate(generatedResponse.Uri, sourceMapReference, out var sourceMapUri) || !IsAutoDownloadUri(sourceMapUri)) + return null; + + return await DownloadContentAsync(sourceMapUri, isRefresh, cancellationToken); + } + + private async Task DownloadGeneratedFileAsync(Uri generatedFileUri, bool isRefresh, CancellationToken cancellationToken) + { + var result = await SendAsync( + generatedFileUri, + _options.MaximumGeneratedFileSize, + validateContentLength: false, + request => ConfigureGeneratedFileRequest(request, useRange: true), + SourceMapService.GeneratedFileHttpClientName, + isRefresh, + cancellationToken); + if (result.Response.StatusCode != HttpStatusCode.RequestedRangeNotSatisfiable) + return result; + + Uri retryUri = result.Uri; + result.Dispose(); + return await SendAsync( + retryUri, + _options.MaximumGeneratedFileSize, + validateContentLength: false, + request => ConfigureGeneratedFileRequest(request, useRange: false), + SourceMapService.GeneratedFileHttpClientName, + isRefresh, + cancellationToken); + } + + private static void ConfigureGeneratedFileRequest(HttpRequestMessage request, bool useRange) + { + if (useRange) + request.Headers.Range = new RangeHeaderValue(null, 64 * 1024); + request.Headers.AcceptEncoding.Clear(); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("identity")); + } + + private async Task DownloadContentAsync(Uri sourceMapUri, bool isRefresh, CancellationToken cancellationToken) + { + using var result = await SendAsync( + sourceMapUri, + _options.MaximumSourceMapSize, + validateContentLength: true, + null, + SourceMapService.HttpClientName, + isRefresh, + cancellationToken); + if (!result.Response.IsSuccessStatusCode) + return null; + + byte[] content = await SourceMapContent.ReadLimitedAsync( + await result.Response.Content.ReadAsStreamAsync(cancellationToken), + _options.MaximumSourceMapSize, + cancellationToken); + return new DownloadedSourceMap(content, result.Uri.AbsoluteUri); + } + + private async Task SendAsync( + Uri uri, + int maximumBytes, + bool validateContentLength, + Action? configureRequest, + string httpClientName, + bool isRefresh, + CancellationToken cancellationToken) + { + Uri currentUri = uri; + for (int redirectCount = 0; ; redirectCount++) + { + if (!IsAutoDownloadUri(currentUri)) + throw new InvalidOperationException("Source map auto-download only supports public HTTPS URLs."); + if (!await _throttle.TryReserveOutboundRequestAsync(currentUri, isRefresh)) + throw new SourceMapRequestThrottledException(); + + using var request = new HttpRequestMessage(HttpMethod.Get, currentUri); + request.Headers.AcceptEncoding.ParseAdd("gzip, deflate, br"); + configureRequest?.Invoke(request); + + var client = _httpClientFactory.CreateClient(httpClientName); + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (validateContentLength && response.Content.Headers.ContentLength > maximumBytes) + { + response.Dispose(); + throw new InvalidOperationException("The downloaded file exceeded the configured maximum size."); + } + + if (!IsRedirect(response.StatusCode)) + return new HttpDownloadResult(currentUri, response); + + if (redirectCount >= _options.MaximumRedirects || response.Headers.Location is null) + { + response.Dispose(); + throw new InvalidOperationException("The source map download exceeded the allowed redirects."); + } + + Uri redirectUri = response.Headers.Location.IsAbsoluteUri ? response.Headers.Location : new Uri(currentUri, response.Headers.Location); + response.Dispose(); + currentUri = redirectUri; + } + } + + private static bool IsAutoDownloadUri(Uri uri) + => uri.IsDefaultPort && SourceMapService.TryNormalizeGeneratedFileUrl(uri.AbsoluteUri, requireHttps: true, out _); + + private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is HttpStatusCode.MovedPermanently + or HttpStatusCode.Redirect + or HttpStatusCode.RedirectMethod + or HttpStatusCode.TemporaryRedirect + or HttpStatusCode.PermanentRedirect; + + private static string? GetSourceMapHeader(HttpResponseMessage response) + { + if (response.Headers.TryGetValues("SourceMap", out var sourceMapValues)) + return sourceMapValues.FirstOrDefault(); + if (response.Headers.TryGetValues("X-SourceMap", out var legacySourceMapValues)) + return legacySourceMapValues.FirstOrDefault(); + return null; + } + + private static string? FindSourceMapReference(string generatedContent) + { + string? reference = null; + for (int index = 0; index < generatedContent.Length;) + { + char current = generatedContent[index]; + if (current is '\'' or '"' or '`') + { + index = SkipQuotedValue(generatedContent, index, current); + continue; + } + + if (current != '/' || index + 1 >= generatedContent.Length) + { + index++; + continue; + } + + char next = generatedContent[index + 1]; + if (next == '/') + { + int end = generatedContent.IndexOfAny(['\r', '\n'], index + 2); + if (end < 0) + end = generatedContent.Length; + reference = ParseSourceMapComment(generatedContent.AsSpan(index + 2, end - index - 2)) ?? reference; + index = end; + continue; + } + + if (next == '*') + { + int end = generatedContent.IndexOf("*/", index + 2, StringComparison.Ordinal); + if (end < 0) + return reference; + reference = ParseSourceMapComment(generatedContent.AsSpan(index + 2, end - index - 2)) ?? reference; + index = end + 2; + continue; + } + + index++; + } + + return FindTrailingSourceMapReference(generatedContent) ?? reference; + } + + private static string? FindTrailingSourceMapReference(string generatedContent) + { + ReadOnlySpan content = generatedContent.AsSpan().TrimEnd(); + if (content.IsEmpty) + return null; + + if (content.EndsWith("*/", StringComparison.Ordinal)) + { + int commentStart = content.LastIndexOf("/*", StringComparison.Ordinal); + if (commentStart >= 0) + { + string? blockReference = ParseSourceMapComment(content[(commentStart + 2)..^2]); + if (IsSafeTrailingReference(blockReference)) + return blockReference; + } + } + + int lineStart = content.LastIndexOfAny('\r', '\n') + 1; + ReadOnlySpan lastLine = content[lineStart..]; + int commentIndex = Math.Max( + lastLine.LastIndexOf("//#", StringComparison.Ordinal), + lastLine.LastIndexOf("//@", StringComparison.Ordinal)); + if (commentIndex < 0) + return null; + + string? lineReference = ParseSourceMapComment(lastLine[(commentIndex + 2)..]); + return IsSafeTrailingReference(lineReference) ? lineReference : null; + } + + private static bool IsSafeTrailingReference(string? reference) + => !String.IsNullOrWhiteSpace(reference) && reference.AsSpan().IndexOfAny(['\'', '"', '`', '\r', '\n']) < 0; + + private static int SkipQuotedValue(string content, int start, char quote) + { + for (int index = start + 1; index < content.Length; index++) + { + if (content[index] == '\\') + { + index++; + continue; + } + + if (content[index] == quote) + return index + 1; + } + + return content.Length; + } + + private static string? ParseSourceMapComment(ReadOnlySpan comment) + { + comment = comment.Trim(); + if (comment.IsEmpty || comment[0] is not ('#' or '@')) + return null; + + comment = comment[1..].TrimStart(); + const string marker = "sourceMappingURL"; + if (!comment.StartsWith(marker, StringComparison.Ordinal)) + return null; + + comment = comment[marker.Length..].TrimStart(); + if (comment.IsEmpty || comment[0] != '=') + return null; + + string value = comment[1..].Trim().ToString(); + return String.IsNullOrWhiteSpace(value) ? null : value; + } + + private static byte[] DecodeDataUri(string value, int maximumBytes) + { + int commaIndex = value.IndexOf(','); + if (commaIndex < 0) + throw new FormatException("The inline source map data URI is invalid."); + + string metadata = value[..commaIndex]; + string data = value[(commaIndex + 1)..]; + byte[] decoded = metadata.Contains(";base64", StringComparison.OrdinalIgnoreCase) + ? Convert.FromBase64String(data) + : Encoding.UTF8.GetBytes(Uri.UnescapeDataString(data)); + if (decoded.Length > maximumBytes) + throw new InvalidOperationException("The inline source map exceeded the configured maximum size."); + return decoded; + } + + internal sealed record DownloadedSourceMap(byte[] Content, string? SourceMapUrl, SourceMapDocument? Document = null); + + private sealed record HttpDownloadResult(Uri Uri, HttpResponseMessage Response) : IDisposable + { + public void Dispose() => Response.Dispose(); + } +} diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapRequestThrottle.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapRequestThrottle.cs new file mode 100644 index 0000000000..7cb158c94d --- /dev/null +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapRequestThrottle.cs @@ -0,0 +1,133 @@ +using System.Net; +using System.Net.Sockets; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Extensions; +using Foundatio.Caching; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Services.SourceMaps; + +public sealed class SourceMapRequestThrottle +{ + private readonly ICacheClient _cache; + private readonly SourceMapOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public SourceMapRequestThrottle(ICacheClient cache, AppOptions options, TimeProvider timeProvider, ILogger logger) + { + _cache = cache; + _options = options.SourceMapOptions; + _timeProvider = timeProvider; + _logger = logger; + } + + internal async Task TryReserveDiscoveryAsync(SourceMapRequest request) + { + int clientKeyLimit = request.IsFreePlan + ? _options.MaximumAutoDiscoveriesPerFreeClientKey + : _options.MaximumAutoDiscoveriesPerClientKey; + if (!String.IsNullOrEmpty(request.ClientKeyHash) + && !await TryReserveBucketAsync("client-key", $"{request.ProjectId}:{request.ClientKeyHash}", clientKeyLimit)) + { + return false; + } + + int projectLimit = request.IsFreePlan + ? _options.MaximumAutoDiscoveriesPerFreeProject + : _options.MaximumAutoDiscoveriesPerProject; + if (!await TryReserveBucketAsync("project", request.ProjectId, projectLimit)) + return false; + + int organizationLimit = request.IsFreePlan + ? _options.MaximumAutoDiscoveriesPerFreeOrganization + : _options.MaximumAutoDiscoveriesPerOrganization; + return await TryReserveBucketAsync("organization", request.OrganizationId, organizationLimit); + } + + internal async Task TryReserveOutboundRequestAsync(Uri uri, bool isRefresh = false) + { + string destination = uri.IdnHost.ToLowerInvariant().ToSHA256(); + string scopePrefix = isRefresh ? "refresh-" : String.Empty; + int destinationLimit = isRefresh + ? _options.MaximumAutoRefreshRequestsPerDestination + : _options.MaximumAutoDownloadRequestsPerDestination; + if (!await TryReserveBucketAsync(scopePrefix + "destination", destination, destinationLimit)) + return false; + + int globalLimit = isRefresh + ? _options.MaximumAutoRefreshRequestsGlobally + : _options.MaximumAutoDownloadRequestsGlobally; + return await TryReserveBucketAsync(scopePrefix + "global", "all", globalLimit); + } + + internal async ValueTask ConnectToPublicAddressAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken) + { + var addresses = await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, cancellationToken); + Exception? lastException = null; + bool addressThrottled = false; + foreach (var address in addresses) + { + if (!OAuthClientMetadataService.IsPublicAddress(address)) + continue; + + string addressHash = address.ToString().ToSHA256(); + if (!await TryReserveBucketAsync("ip-address", addressHash, _options.MaximumAutoDownloadConnectionsPerIpAddress)) + { + addressThrottled = true; + continue; + } + + var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + try + { + await socket.ConnectAsync(new IPEndPoint(address, context.DnsEndPoint.Port), cancellationToken); + return new NetworkStream(socket, ownsSocket: true); + } + catch (SocketException ex) + { + lastException = ex; + socket.Dispose(); + } + } + + if (addressThrottled) + throw new SourceMapRequestThrottledException(); + + throw new HttpRequestException($"Host '{context.DnsEndPoint.Host}' did not resolve to a reachable public address.", lastException); + } + + private async Task TryReserveBucketAsync(string scope, string identifier, int limit) + { + var window = GetCurrentWindow(); + string counterKey = $"source-maps:rate:{scope}:{identifier}:{window.Id}"; + string blockedKey = counterKey + ":blocked"; + if ((await _cache.GetAsync(blockedKey)).HasValue) + return false; + + long count = await _cache.IncrementAsync(counterKey, 1, window.CounterLifetime); + if (count <= limit) + return true; + + await _cache.SetAsync(blockedKey, true, window.Remaining); + if (count == limit + 1) + _logger.LogWarning("Source map automatic download {RateLimitScope} rate limit reached.", scope); + + return false; + } + + private RateLimitWindow GetCurrentWindow() + { + long periodSeconds = Math.Max(1, (long)_options.AutoDownloadRateLimitPeriod.TotalSeconds); + long now = _timeProvider.GetUtcNow().ToUnixTimeSeconds(); + long id = now / periodSeconds; + long remainingSeconds = Math.Max(1, ((id + 1) * periodSeconds) - now); + return new RateLimitWindow(id, TimeSpan.FromSeconds(remainingSeconds), TimeSpan.FromSeconds(periodSeconds * 2)); + } + + private sealed record RateLimitWindow(long Id, TimeSpan Remaining, TimeSpan CounterLifetime); +} + +internal sealed record SourceMapRequest(string OrganizationId, string ProjectId, string? ClientKeyHash, bool IsFreePlan); + +internal sealed class SourceMapRequestThrottledException : Exception; diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs new file mode 100644 index 0000000000..0705417883 --- /dev/null +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapService.cs @@ -0,0 +1,712 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Foundatio.Caching; +using Foundatio.Lock; +using Foundatio.Storage; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Services.SourceMaps; + +public sealed class SourceMapService : IDisposable +{ + public const string HttpClientName = "SourceMaps"; + internal const string GeneratedFileHttpClientName = "SourceMapGeneratedFiles"; + private static readonly TimeSpan FailureCacheLifetime = TimeSpan.FromMinutes(15); + private static readonly TimeSpan DeletedProjectCacheLifetime = TimeSpan.FromDays(1); + private const int MaximumLocalUsageEntries = 100_000; + private readonly SemaphoreSlim _downloadSemaphore; + private readonly ConcurrentDictionary>> _inflightSourceMaps = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _parsedSourceMapEntries = new(StringComparer.Ordinal); + private readonly MemoryCache _parsedSourceMaps; + private readonly MemoryCache _recentlyTrackedUsages; + private readonly SourceMapDownloader _downloader; + private readonly SourceMapStorage _storage; + private readonly SourceMapRequestThrottle _throttle; + private readonly ICacheClient _cache; + private readonly ILockProvider _lockProvider; + private readonly SourceMapOptions _options; + private readonly TimeSpan _usageCacheLifetime; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public SourceMapService( + IHttpClientFactory httpClientFactory, + IFileStorage storage, + ICacheClient cache, + ILockProvider lockProvider, + SourceMapRequestThrottle throttle, + JsonSerializerOptions serializerOptions, + AppOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _downloader = new SourceMapDownloader(httpClientFactory, options, throttle); + _storage = new SourceMapStorage(storage, serializerOptions, logger); + _throttle = throttle; + _cache = cache; + _lockProvider = lockProvider; + _options = options.SourceMapOptions; + _usageCacheLifetime = TimeSpan.FromDays(Math.Max(_options.FreeArtifactRetentionDays, _options.ArtifactRetentionDays) + 1L); + _downloadSemaphore = new SemaphoreSlim(_options.MaximumConcurrentDownloads); + _parsedSourceMaps = new MemoryCache(new MemoryCacheOptions { SizeLimit = _options.MaximumParsedSourceMapCacheSize }); + _recentlyTrackedUsages = new MemoryCache(new MemoryCacheOptions { SizeLimit = MaximumLocalUsageEntries }); + _timeProvider = timeProvider; + _logger = logger; + } + + public Task SaveUploadedAsync( + string projectId, + string generatedFileUrl, + string? fileName, + Stream stream, + CancellationToken cancellationToken = default) + => SaveUploadedAsync(projectId, generatedFileUrl, fileName, stream, false, cancellationToken); + + public async Task SaveUploadedAsync( + string projectId, + string generatedFileUrl, + string? fileName, + Stream stream, + bool isFreePlan, + CancellationToken cancellationToken = default) + { + if (!TryNormalizeGeneratedFileUrl(generatedFileUrl, requireHttps: false, out var generatedFileUri)) + throw new ArgumentException("The generated file URL must be an absolute HTTP or HTTPS URL without credentials or a fragment.", nameof(generatedFileUrl)); + + byte[] sourceMap = await SourceMapContent.ReadLimitedAsync(stream, _options.MaximumSourceMapSize, cancellationToken); + _ = SourceMapDocument.Parse(sourceMap, _options.MaximumMappingSegments); + + string normalizedUrl = generatedFileUri.AbsoluteUri; + var artifact = new SourceMapArtifact + { + Id = GetArtifactId(normalizedUrl), + GeneratedFileUrl = normalizedUrl, + FileName = String.IsNullOrWhiteSpace(fileName) ? null : Path.GetFileName(fileName), + Size = sourceMap.LongLength, + IsAutoDownloaded = false, + CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime + }; + + string cacheKey = GetMemoryCacheKey(projectId, normalizedUrl); + await WaitForInflightSourceMapAsync(cacheKey, cancellationToken); + await using var artifactLock = await _lockProvider.TryAcquireAsync(GetArtifactLockKey(projectId, artifact.Id), TimeSpan.FromSeconds(30), cancellationToken); + if (artifactLock is null) + { + cancellationToken.ThrowIfCancellationRequested(); + throw new IOException("Unable to acquire the source map storage lock."); + } + + await using var projectLock = await TryAcquireProjectStorageLockAsync(projectId, cancellationToken); + if (projectLock is null) + throw new IOException("Unable to acquire the project source map storage lock."); + if (await IsProjectDeletedAsync(projectId)) + throw new InvalidOperationException("Source maps cannot be saved for a deleted project."); + + await ValidateStorageLimitAsync(projectId, artifact, isFreePlan, cancellationToken); + await _storage.SaveAsync(projectId, artifact, sourceMap, cancellationToken); + await ClearCachesAsync(projectId, normalizedUrl); + return artifact; + } + + public Task> GetArtifactsAsync(string projectId, CancellationToken cancellationToken = default) + => _storage.GetArtifactsAsync(projectId, cancellationToken); + + public async Task DeleteArtifactAsync(string projectId, string artifactId, CancellationToken cancellationToken = default) + { + if (!IsArtifactId(artifactId)) + return false; + + await WaitForInflightArtifactAsync(projectId, artifactId, cancellationToken); + await using var artifactLock = await _lockProvider.TryAcquireAsync(GetArtifactLockKey(projectId, artifactId), TimeSpan.FromSeconds(30), cancellationToken); + if (artifactLock is null) + { + cancellationToken.ThrowIfCancellationRequested(); + return false; + } + + await using var projectLock = await TryAcquireProjectStorageLockAsync(projectId, cancellationToken); + if (projectLock is null) + return false; + + var artifact = await _storage.DeleteAsync(projectId, artifactId, cancellationToken); + if (artifact is null) + return false; + + await RemoveUsageTrackingAsync(projectId, artifactId); + await ClearCachesAsync(projectId, artifact.GeneratedFileUrl); + return true; + } + + public async Task DeleteProjectArtifactsAsync(string projectId, CancellationToken cancellationToken = default) + { + await using var projectLock = await TryAcquireProjectStorageLockAsync(projectId, cancellationToken); + if (projectLock is null) + throw new IOException("Unable to acquire the project source map storage lock."); + if (!await _cache.SetAsync(GetDeletedProjectCacheKey(projectId), true, DeletedProjectCacheLifetime)) + throw new IOException("Unable to prevent source map writes for the deleted project."); + + var artifacts = await _storage.GetArtifactsAsync(projectId, cancellationToken); + await _storage.DeleteAllAsync(projectId, cancellationToken); + foreach (var artifact in artifacts) + await RemoveUsageTrackingAsync(projectId, artifact.Id); + await AdvanceProjectCacheVersionAsync(projectId); + foreach (string key in _parsedSourceMapEntries.Keys.Where(key => key.StartsWith(projectId + ':', StringComparison.Ordinal))) + _parsedSourceMaps.Remove(key); + foreach (string key in _inflightSourceMaps.Keys.Where(key => key.StartsWith(projectId + ':', StringComparison.Ordinal))) + _inflightSourceMaps.TryRemove(key, out _); + } + + public Task SymbolicateAsync(string projectId, InnerError? error, CancellationToken cancellationToken = default) + => SymbolicateAsync(new SourceMapRequest(projectId, projectId, null, false), error, cancellationToken); + + internal async Task SymbolicateAsync(SourceMapRequest request, InnerError? error, CancellationToken cancellationToken = default) + { + bool changed = false; + int framesProcessed = 0; + using var processingCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + processingCancellationTokenSource.CancelAfter(_options.MaximumProcessingTime); + try + { + while (error is not null) + { + 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; + } + } + + error = error.Inner; + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogDebug("Source map processing exceeded its time budget for project {ProjectId}.", request.ProjectId); + } + + return changed; + } + + public void Dispose() + { + _parsedSourceMaps.Dispose(); + _recentlyTrackedUsages.Dispose(); + _downloadSemaphore.Dispose(); + } + + 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; + + if (!TryNormalizeGeneratedFileUrl(frame.FileName, requireHttps: false, out var generatedFileUri)) + return false; + + var resolved = await GetSourceMapAsync(request, generatedFileUri, cancellationToken); + if (resolved is null) + return false; + + int generatedColumn = frame.Column.Value - 1; + + var original = resolved.Document.FindOriginalLocation(frame.LineNumber.Value - 1, generatedColumn); + if (original is null) + return false; + if (!await TrackUsageAsync(request.ProjectId, resolved.Artifact.Id, cancellationToken)) + return false; + + frame.Data ??= new DataDictionary(); + frame.Data[StackFrame.KnownDataKeys.SourceMap] = new DataDictionary + { + ["generated_file_name"] = frame.FileName, + ["generated_line_number"] = frame.LineNumber, + ["generated_column"] = frame.Column, + ["generated_name"] = frame.Name, + ["source_map_id"] = resolved.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; + } + + 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; + _parsedSourceMaps.Remove(cacheKey); + } + + var lazy = _inflightSourceMaps.GetOrAdd(cacheKey, _ => new Lazy>( + () => LoadAndCacheSourceMapAsync(request, generatedFileUri, cacheKey), + LazyThreadSafetyMode.ExecutionAndPublication)); + Task loadTask = lazy.Value; + + try + { + return await loadTask.WaitAsync(cancellationToken); + } + finally + { + if (loadTask.IsCompleted) + RemoveInflightSourceMap(cacheKey, lazy); + else + _ = RemoveInflightSourceMapWhenCompleteAsync(cacheKey, lazy, loadTask); + } + } + + 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)) + { + resolved = await LoadSourceMapAsync(request, generatedFileUri, currentCacheVersion); + } + + if (resolved is not null && resolved.Document.EstimatedMemorySize <= _options.MaximumParsedSourceMapCacheSize) + { + _parsedSourceMapEntries[cacheKey] = resolved; + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _options.ParsedSourceMapCacheLifetime, + Size = Math.Max(1, resolved.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); + } + + return resolved; + } + + private async Task RemoveInflightSourceMapWhenCompleteAsync( + string cacheKey, + Lazy> lazy, + Task loadTask) + { + try + { + await loadTask; + } + catch + { + // The caller observes the load failure; this continuation only releases the in-flight entry. + } + + RemoveInflightSourceMap(cacheKey, lazy); + } + + private void RemoveInflightSourceMap(string cacheKey, Lazy> lazy) + { + ICollection>>> entries = _inflightSourceMaps; + entries.Remove(new KeyValuePair>>(cacheKey, lazy)); + } + + private void RemoveParsedSourceMapEntry(string cacheKey, ResolvedSourceMap sourceMap) + { + ICollection> entries = _parsedSourceMapEntries; + entries.Remove(new KeyValuePair(cacheKey, sourceMap)); + } + + private async Task WaitForInflightSourceMapAsync(string cacheKey, CancellationToken cancellationToken) + { + if (_inflightSourceMaps.TryGetValue(cacheKey, out var sourceMap)) + await WaitForInflightSourceMapAsync(sourceMap, cancellationToken); + } + + private async Task WaitForInflightArtifactAsync(string projectId, string artifactId, CancellationToken cancellationToken) + { + string cacheKeyPrefix = projectId + ':'; + var sourceMaps = _inflightSourceMaps + .Where(entry => entry.Key.StartsWith(cacheKeyPrefix, StringComparison.Ordinal) + && GetArtifactId(entry.Key[cacheKeyPrefix.Length..]) == artifactId) + .Select(entry => entry.Value) + .ToArray(); + + foreach (var sourceMap in sourceMaps) + await WaitForInflightSourceMapAsync(sourceMap, cancellationToken); + } + + private static async Task WaitForInflightSourceMapAsync(Lazy> sourceMap, CancellationToken cancellationToken) + { + try + { + await sourceMap.Value.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // A failed lookup must not prevent an explicit upload or delete from repairing the artifact. + } + } + + private async Task LoadSourceMapAsync(SourceMapRequest request, Uri generatedFileUri, long cacheVersion) + { + string projectId = request.ProjectId; + string generatedFileUrl = generatedFileUri.AbsoluteUri; + string artifactId = GetArtifactId(generatedFileUrl); + var stored = await _storage.GetAsync(projectId, artifactId, _options.MaximumSourceMapSize, CancellationToken.None); + bool refreshStoredMap = stored is not null && ShouldRefresh(stored.Artifact, generatedFileUri); + if (stored is not null && !refreshStoredMap) + return Resolve(stored.Artifact, stored.Content, cacheVersion); + + if (!_options.EnableAutoDownload || !String.Equals(generatedFileUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + return null; + + string failureCacheKey = GetFailureCacheKey(projectId, generatedFileUrl); + if ((await _cache.GetAsync(failureCacheKey)).HasValue) + return null; + + try + { + using var timeoutCancellationTokenSource = new CancellationTokenSource(_options.RequestTimeout); + await using var artifactLock = await _lockProvider.TryAcquireAsync( + GetArtifactLockKey(projectId, artifactId), + TimeSpan.FromSeconds(30), + timeoutCancellationTokenSource.Token); + if (artifactLock is null) + return await CacheFailureAsync(failureCacheKey); + + stored = await _storage.GetAsync(projectId, artifactId, _options.MaximumSourceMapSize, CancellationToken.None); + if (stored is not null && !ShouldRefresh(stored.Artifact, generatedFileUri)) + return Resolve(stored.Artifact, stored.Content, await GetProjectCacheVersionAsync(projectId)); + + if (stored is null && !await _throttle.TryReserveDiscoveryAsync(request)) + return null; + + if (!await _downloadSemaphore.WaitAsync(TimeSpan.Zero, timeoutCancellationTokenSource.Token)) + return null; + SourceMapDownloader.DownloadedSourceMap? downloaded; + try + { + await using var globalDownloadSlot = await TryAcquireGlobalDownloadSlotAsync(artifactId, timeoutCancellationTokenSource.Token); + if (globalDownloadSlot is null) + return null; + + downloaded = await _downloader.DownloadAsync(generatedFileUri, stored is not null, timeoutCancellationTokenSource.Token); + if (downloaded is null) + return await CacheFailureAsync(failureCacheKey); + } + finally + { + _downloadSemaphore.Release(); + } + + var artifact = new SourceMapArtifact + { + Id = artifactId, + GeneratedFileUrl = generatedFileUrl, + SourceMapUrl = downloaded.SourceMapUrl, + FileName = GetDownloadedFileName(downloaded.SourceMapUrl), + Size = downloaded.Content.LongLength, + IsAutoDownloaded = true, + CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime + }; + var document = downloaded.Document ?? SourceMapDocument.Parse(downloaded.Content, _options.MaximumMappingSegments); + await using var projectLock = await TryAcquireProjectStorageLockAsync(projectId, timeoutCancellationTokenSource.Token); + if (projectLock is null) + return await CacheFailureAsync(failureCacheKey); + if (await IsProjectDeletedAsync(projectId)) + return null; + + await ValidateStorageLimitAsync(projectId, artifact, request.IsFreePlan, timeoutCancellationTokenSource.Token); + await _storage.SaveAsync(projectId, artifact, downloaded.Content, CancellationToken.None); + long updatedCacheVersion = await ClearCachesAsync(projectId, generatedFileUrl); + return new ResolvedSourceMap(artifact, document, updatedCacheVersion); + } + catch (OperationCanceledException ex) + { + _logger.LogDebug(ex, "Timed out downloading a source map for {GeneratedFileUrl}.", generatedFileUrl); + return await CacheFailureAsync(failureCacheKey); + } + catch (SourceMapRequestThrottledException) + { + return null; + } + catch (Exception ex) when (ex is HttpRequestException or IOException or JsonException or InvalidOperationException or FormatException) + { + _logger.LogWarning(ex, "Unable to download a source map for {GeneratedFileUrl}.", generatedFileUrl); + return await CacheFailureAsync(failureCacheKey); + } + } + + private ResolvedSourceMap Resolve(SourceMapArtifact artifact, byte[] content, long cacheVersion) + => new(artifact, SourceMapDocument.Parse(content, _options.MaximumMappingSegments), cacheVersion); + + private bool ShouldRefresh(SourceMapArtifact artifact, Uri generatedFileUri) + => artifact.IsAutoDownloaded + && _options.EnableAutoDownload + && String.Equals(generatedFileUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && _timeProvider.GetUtcNow().UtcDateTime - artifact.CreatedUtc >= _options.AutoDownloadRefreshInterval; + + private async Task ClearCachesAsync(string projectId, string generatedFileUrl) + { + _parsedSourceMaps.Remove(GetMemoryCacheKey(projectId, generatedFileUrl)); + await _cache.RemoveAsync(GetFailureCacheKey(projectId, generatedFileUrl)); + return await AdvanceProjectCacheVersionAsync(projectId); + } + + private Task GetProjectCacheVersionAsync(string projectId) + => _cache.GetAsync(GetProjectCacheVersionKey(projectId), 0); + + private async Task AdvanceProjectCacheVersionAsync(string projectId) + { + string cacheKey = GetProjectCacheVersionKey(projectId); + long cacheVersion = await _cache.IncrementAsync(cacheKey, 1); + if (!await _cache.SetAsync(cacheKey, cacheVersion)) + throw new IOException("Unable to persist the source map cache generation."); + return cacheVersion; + } + + private Task TryAcquireProjectStorageLockAsync(string projectId, CancellationToken cancellationToken) + => _lockProvider.TryAcquireAsync(GetProjectStorageLockKey(projectId), TimeSpan.FromSeconds(30), cancellationToken); + + public async Task SaveUsagesAsync(CancellationToken cancellationToken = default) + { + string pendingCacheKey = GetUsagePendingCacheKey(); + var pending = await _cache.GetListAsync(pendingCacheKey); + if (!pending.HasValue) + return; + + foreach (var usage in pending.Value) + { + cancellationToken.ThrowIfCancellationRequested(); + await _cache.ListRemoveAsync(pendingCacheKey, usage); + + try + { + DateTime lastUsedUtc = await GetPendingLastUsedUtcAsync(usage.ProjectId, usage.ArtifactId); + if (lastUsedUtc == DateTime.MinValue) + continue; + + await using var projectLock = await TryAcquireProjectStorageLockAsync(usage.ProjectId, cancellationToken); + if (projectLock is null) + { + await _cache.ListAddAsync(pendingCacheKey, usage); + continue; + } + + var result = await _storage.SetLastUsedUtcAsync(usage.ProjectId, usage.ArtifactId, lastUsedUtc, cancellationToken); + if (result == SourceMapStorage.SetLastUsedResult.Failed) + await _cache.ListAddAsync(pendingCacheKey, usage); + else if (result == SourceMapStorage.SetLastUsedResult.NotFound) + await _cache.RemoveAsync(GetLastUsedCacheKey(usage.ProjectId, usage.ArtifactId)); + } + catch + { + await _cache.ListAddAsync(pendingCacheKey, usage); + throw; + } + } + } + + public async Task CleanupStaleArtifactsAsync(string projectId, bool isFreePlan, CancellationToken cancellationToken = default) + { + await using var projectLock = await TryAcquireProjectStorageLockAsync(projectId, cancellationToken); + if (projectLock is null) + throw new IOException("Unable to acquire the project source map storage lock."); + + return await CleanupStaleArtifactsUnderLockAsync(projectId, isFreePlan, cancellationToken); + } + + private async Task ValidateStorageLimitAsync(string projectId, SourceMapArtifact artifact, bool isFreePlan, CancellationToken cancellationToken) + { + await CleanupStaleArtifactsUnderLockAsync(projectId, isFreePlan, cancellationToken); + var usage = await _storage.GetProjectStorageUsageAsync(projectId, artifact.Id, cancellationToken); + var otherArtifacts = usage.Artifacts.Where(existing => !String.Equals(existing.Id, artifact.Id, StringComparison.Ordinal)).ToArray(); + int maximumArtifacts = isFreePlan ? _options.MaximumArtifactsPerFreeProject : _options.MaximumArtifactsPerProject; + long maximumStorageSize = isFreePlan ? _options.MaximumStorageSizePerFreeProject : _options.MaximumStorageSizePerProject; + if (otherArtifacts.Length >= maximumArtifacts) + throw new SourceMapStorageLimitException($"The project source map artifact limit of {maximumArtifacts:N0} has been reached."); + + if (usage.RetainedBytes > maximumStorageSize + || artifact.Size > maximumStorageSize - usage.RetainedBytes) + throw new SourceMapStorageLimitException("The project source map storage limit has been reached."); + } + + private async Task TrackUsageAsync(string projectId, string artifactId, CancellationToken cancellationToken) + { + string localCacheKey = $"{projectId}:{artifactId}"; + if (_recentlyTrackedUsages.TryGetValue(localCacheKey, out _)) + return true; + + await using var projectLock = await TryAcquireProjectStorageLockAsync(projectId, cancellationToken); + if (projectLock is null) + { + cancellationToken.ThrowIfCancellationRequested(); + return false; + } + + if (_recentlyTrackedUsages.TryGetValue(localCacheKey, out _)) + return true; + if (await IsProjectDeletedAsync(projectId) || !await _storage.ExistsAsync(projectId, artifactId, cancellationToken)) + return false; + + var usage = new SourceMapUsageKey(projectId, artifactId); + DateTime lastUsedUtc = _timeProvider.GetUtcNow().UtcDateTime; + try + { + await Task.WhenAll( + _cache.ListAddAsync(GetUsagePendingCacheKey(), usage), + _cache.SetIfHigherAsync(GetLastUsedCacheKey(projectId, artifactId), lastUsedUtc, _usageCacheLifetime)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to queue source map usage for project {ProjectId} artifact {SourceMapArtifactId}.", projectId, artifactId); + try + { + if (await _storage.SetLastUsedUtcAsync(projectId, artifactId, lastUsedUtc, cancellationToken) != SourceMapStorage.SetLastUsedResult.Updated) + return false; + } + catch (Exception fallbackException) when (fallbackException is IOException or JsonException) + { + _logger.LogWarning(fallbackException, "Unable to persist source map usage for project {ProjectId} artifact {SourceMapArtifactId}.", projectId, artifactId); + return false; + } + } + + _recentlyTrackedUsages.Set(localCacheKey, true, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _options.UsageTrackingDebounce, + Size = 1 + }); + return true; + } + + private async Task CleanupStaleArtifactsUnderLockAsync(string projectId, bool isFreePlan, CancellationToken cancellationToken) + { + DateTime nowUtc = _timeProvider.GetUtcNow().UtcDateTime; + DateTime cutoffUtc = nowUtc - (isFreePlan ? _options.FreeArtifactRetention : _options.ArtifactRetention); + var artifacts = await _storage.GetArtifactsAsync(projectId, cancellationToken); + int removed = 0; + + foreach (var artifact in artifacts) + { + cancellationToken.ThrowIfCancellationRequested(); + DateTime pendingLastUsedUtc = await GetPendingLastUsedUtcAsync(projectId, artifact.Id); + DateTime effectiveLastUsedUtc = artifact.LastUsedUtc ?? artifact.CreatedUtc; + if (pendingLastUsedUtc > effectiveLastUsedUtc) + { + effectiveLastUsedUtc = pendingLastUsedUtc; + if (await _storage.SetLastUsedUtcAsync(projectId, artifact.Id, effectiveLastUsedUtc, cancellationToken) != SourceMapStorage.SetLastUsedResult.Updated) + throw new IOException("Unable to update source map usage metadata."); + } + + if (effectiveLastUsedUtc > cutoffUtc) + continue; + + var deleted = await _storage.DeleteAsync(projectId, artifact.Id, cancellationToken); + if (deleted is null) + continue; + + await RemoveUsageTrackingAsync(projectId, artifact.Id); + await ClearCachesAsync(projectId, artifact.GeneratedFileUrl); + removed++; + } + + return removed; + } + + private Task GetPendingLastUsedUtcAsync(string projectId, string artifactId) + => _cache.GetUnixTimeMillisecondsAsync(GetLastUsedCacheKey(projectId, artifactId), DateTime.MinValue); + + private async Task IsProjectDeletedAsync(string projectId) + => (await _cache.GetAsync(GetDeletedProjectCacheKey(projectId))).HasValue; + + private Task RemoveUsageTrackingAsync(string projectId, string artifactId) + { + _recentlyTrackedUsages.Remove($"{projectId}:{artifactId}"); + return Task.WhenAll( + _cache.ListRemoveAsync(GetUsagePendingCacheKey(), new SourceMapUsageKey(projectId, artifactId)), + _cache.RemoveAsync(GetLastUsedCacheKey(projectId, artifactId))); + } + + private async Task CacheFailureAsync(string failureCacheKey) + { + await _cache.SetAsync(failureCacheKey, true, FailureCacheLifetime); + return null; + } + + private async Task TryAcquireGlobalDownloadSlotAsync(string artifactId, CancellationToken cancellationToken) + { + int start = (int)(Convert.ToUInt32(artifactId[..8], 16) % _options.MaximumConcurrentDownloadsGlobally); + for (int offset = 0; offset < _options.MaximumConcurrentDownloadsGlobally; offset++) + { + cancellationToken.ThrowIfCancellationRequested(); + int slot = (start + offset) % _options.MaximumConcurrentDownloadsGlobally; + var globalDownloadSlot = await _lockProvider.TryAcquireAsync( + $"source-maps:download-slot:{slot}", + _options.RequestTimeout + TimeSpan.FromSeconds(1), + TimeSpan.Zero); + if (globalDownloadSlot is not null) + return globalDownloadSlot; + } + + return null; + } + + public static bool TryNormalizeGeneratedFileUrl(string? value, bool requireHttps, out Uri uri) + { + uri = null!; + if (String.IsNullOrWhiteSpace(value) || !Uri.TryCreate(value.Trim(), UriKind.Absolute, out var parsedUri)) + return false; + + bool validScheme = String.Equals(parsedUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || (!requireHttps && String.Equals(parsedUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)); + if (!validScheme || !String.IsNullOrEmpty(parsedUri.UserInfo) || !String.IsNullOrEmpty(parsedUri.Fragment)) + return false; + + uri = parsedUri; + return true; + } + + private static bool IsArtifactId(string value) => value.Length == 64 && value.All(Uri.IsHexDigit); + private static string GetArtifactId(string generatedFileUrl) => generatedFileUrl.ToSHA256(); + private static string GetArtifactLockKey(string projectId, string artifactId) => $"source-maps:artifact:{projectId}:{artifactId}"; + private static string GetProjectStorageLockKey(string projectId) => $"source-maps:project-storage:{projectId}"; + 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 GetUsagePendingCacheKey() => "source-maps:usage:pending"; + private static string GetLastUsedCacheKey(string projectId, string artifactId) => $"source-maps:usage:last:{projectId}:{artifactId}"; + + private static string? GetDownloadedFileName(string? sourceMapUrl) + { + if (sourceMapUrl is null || !Uri.TryCreate(sourceMapUrl, UriKind.Absolute, out var uri)) + return null; + return Path.GetFileName(uri.AbsolutePath); + } + + private sealed record ResolvedSourceMap(SourceMapArtifact Artifact, SourceMapDocument Document, long CacheVersion); + private sealed record ParsedSourceMapCacheRegistration(SourceMapService Service, ResolvedSourceMap SourceMap); +} + +internal sealed record SourceMapUsageKey(string ProjectId, string ArtifactId); + +public sealed class SourceMapStorageLimitException(string message) : InvalidOperationException(message); diff --git a/src/Exceptionless.Core/Services/SourceMaps/SourceMapStorage.cs b/src/Exceptionless.Core/Services/SourceMaps/SourceMapStorage.cs new file mode 100644 index 0000000000..7e98079e2d --- /dev/null +++ b/src/Exceptionless.Core/Services/SourceMaps/SourceMapStorage.cs @@ -0,0 +1,276 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Foundatio.Storage; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Services.SourceMaps; + +internal sealed class SourceMapStorage +{ + private const string RootPath = "source-maps"; + private readonly IFileStorage _storage; + private readonly JsonSerializerOptions _serializerOptions; + private readonly ILogger _logger; + + public SourceMapStorage(IFileStorage storage, JsonSerializerOptions serializerOptions, ILogger logger) + { + _storage = storage; + _serializerOptions = serializerOptions; + _logger = logger; + } + + public async Task SaveAsync(string projectId, SourceMapArtifact artifact, byte[] sourceMap, CancellationToken cancellationToken) + { + string metadataPath = GetMetadataPath(projectId, artifact.Id); + var previousMetadata = await ReadMetadataAsync(metadataPath, cancellationToken); + string mapFileName = $"{artifact.Id}-{Convert.ToHexString(SHA256.HashData(sourceMap)).ToLowerInvariant()}.map"; + string mapPath = GetMapPath(projectId, mapFileName); + + await using (var mapStream = new MemoryStream(sourceMap, writable: false)) + { + if (!await _storage.SaveFileAsync(mapPath, mapStream, cancellationToken)) + throw new IOException("Unable to save the source map."); + } + + try + { + var metadata = new StoredSourceMapMetadata(artifact, mapFileName); + if (!await SaveMetadataAsync(metadataPath, metadata, cancellationToken)) + throw new IOException("Unable to save the source map metadata."); + + if (previousMetadata is not null + && previousMetadata.MapFileName != mapFileName + && !await DeleteAndVerifyAsync(GetMapPath(projectId, previousMetadata.MapFileName), cancellationToken)) + { + throw new IOException("Unable to remove the superseded source map."); + } + } + catch + { + await RollbackSaveAsync(projectId, artifact.Id, metadataPath, mapFileName, mapPath, previousMetadata); + throw; + } + } + + public async Task GetProjectStorageUsageAsync(string projectId, string replacementArtifactId, CancellationToken cancellationToken) + { + var metadataFiles = await _storage.GetFileListAsync($"{RootPath}/{projectId}/*.json", cancellationToken: cancellationToken); + var artifacts = new List(metadataFiles.Count); + var referencedMapPaths = new HashSet(StringComparer.Ordinal); + string? replacementMapPath = null; + foreach (var metadataFile in metadataFiles) + { + try + { + var metadata = await ReadMetadataAsync(metadataFile.Path, cancellationToken); + if (metadata is null || !IsValidMapFileName(metadata.Artifact.Id, metadata.MapFileName)) + continue; + + string mapPath = GetMapPath(projectId, metadata.MapFileName); + referencedMapPaths.Add(mapPath); + artifacts.Add(metadata.Artifact); + if (String.Equals(metadata.Artifact.Id, replacementArtifactId, StringComparison.Ordinal)) + replacementMapPath = mapPath; + } + catch (Exception ex) when (ex is JsonException or IOException) + { + _logger.LogWarning(ex, "Unable to read source map metadata {SourceMapMetadataPath} while calculating storage usage.", metadataFile.Path); + } + } + + var mapFiles = await _storage.GetFileListAsync($"{RootPath}/{projectId}/*.map", cancellationToken: cancellationToken); + long retainedBytes = 0; + foreach (var mapFile in mapFiles) + { + if (!referencedMapPaths.Contains(mapFile.Path) && await DeleteAndVerifyAsync(mapFile.Path, cancellationToken)) + continue; + + if (!String.Equals(mapFile.Path, replacementMapPath, StringComparison.Ordinal)) + { + long fileSize = Math.Max(0, mapFile.Size); + retainedBytes = fileSize > Int64.MaxValue - retainedBytes ? Int64.MaxValue : retainedBytes + fileSize; + } + } + + return new ProjectStorageUsage(artifacts, retainedBytes); + } + + public async Task GetAsync(string projectId, string artifactId, int maximumBytes, CancellationToken cancellationToken) + { + var metadata = await ReadMetadataAsync(GetMetadataPath(projectId, artifactId), cancellationToken); + if (metadata is null || !IsValidMapFileName(artifactId, metadata.MapFileName)) + return null; + + string mapPath = GetMapPath(projectId, metadata.MapFileName); + if (!await _storage.ExistsAsync(mapPath)) + return null; + + await using var stream = await _storage.GetFileStreamAsync(mapPath, StreamMode.Read, cancellationToken); + if (stream is null) + return null; + + byte[] content = await SourceMapContent.ReadLimitedAsync(stream, maximumBytes, cancellationToken); + return new StoredSourceMap(metadata.Artifact, content); + } + + public async Task ExistsAsync(string projectId, string artifactId, CancellationToken cancellationToken) + { + var metadata = await ReadMetadataAsync(GetMetadataPath(projectId, artifactId), cancellationToken); + return metadata is not null + && IsValidMapFileName(artifactId, metadata.MapFileName) + && await _storage.ExistsAsync(GetMapPath(projectId, metadata.MapFileName)); + } + + public async Task> GetArtifactsAsync(string projectId, CancellationToken cancellationToken) + { + var files = await _storage.GetFileListAsync($"{RootPath}/{projectId}/*.json", cancellationToken: cancellationToken); + var artifacts = new List(files.Count); + foreach (var file in files) + { + try + { + var metadata = await ReadMetadataAsync(file.Path, cancellationToken); + if (metadata is not null) + artifacts.Add(metadata.Artifact); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + _logger.LogWarning(ex, "Unable to read source map metadata {SourceMapMetadataPath}.", file.Path); + } + } + + return artifacts.OrderByDescending(artifact => artifact.CreatedUtc).ToArray(); + } + + public async Task SetLastUsedUtcAsync(string projectId, string artifactId, DateTime lastUsedUtc, CancellationToken cancellationToken) + { + string metadataPath = GetMetadataPath(projectId, artifactId); + var metadata = await ReadMetadataAsync(metadataPath, cancellationToken); + if (metadata is null) + return SetLastUsedResult.NotFound; + + if (metadata.Artifact.LastUsedUtc.HasValue && metadata.Artifact.LastUsedUtc.Value >= lastUsedUtc) + return SetLastUsedResult.Updated; + + return await SaveMetadataAsync(metadataPath, metadata with { Artifact = metadata.Artifact with { LastUsedUtc = lastUsedUtc } }, cancellationToken) + ? SetLastUsedResult.Updated + : SetLastUsedResult.Failed; + } + + public async Task DeleteAsync(string projectId, string artifactId, CancellationToken cancellationToken) + { + string metadataPath = GetMetadataPath(projectId, artifactId); + var metadata = await ReadMetadataAsync(metadataPath, cancellationToken); + if (!await DeleteAndVerifyAsync(metadataPath, cancellationToken)) + return null; + + if (metadata is not null && IsValidMapFileName(artifactId, metadata.MapFileName)) + { + string mapPath = GetMapPath(projectId, metadata.MapFileName); + if (!await DeleteAndVerifyAsync(mapPath, cancellationToken)) + _logger.LogWarning("Unable to remove deleted source map content {SourceMapPath}.", mapPath); + } + else + await _storage.DeleteFilesAsync($"{RootPath}/{projectId}/{artifactId}-*.map", cancellationToken); + + return metadata?.Artifact; + } + + public Task DeleteAllAsync(string projectId, CancellationToken cancellationToken) + => _storage.DeleteFilesAsync($"{RootPath}/{projectId}/*", cancellationToken); + + private async Task ReadMetadataAsync(string path, CancellationToken cancellationToken) + { + if (!await _storage.ExistsAsync(path)) + return null; + + await using var stream = await _storage.GetFileStreamAsync(path, StreamMode.Read, cancellationToken); + return stream is null + ? null + : await JsonSerializer.DeserializeAsync(stream, _serializerOptions, cancellationToken); + } + + private async Task SaveMetadataAsync(string path, StoredSourceMapMetadata metadata, CancellationToken cancellationToken) + { + byte[] metadataBytes = JsonSerializer.SerializeToUtf8Bytes(metadata, _serializerOptions); + await using var metadataStream = new MemoryStream(metadataBytes, writable: false); + return await _storage.SaveFileAsync(path, metadataStream, cancellationToken); + } + + private async Task DeleteAndVerifyAsync(string path, CancellationToken cancellationToken) + { + for (int attempt = 0; attempt < 2; attempt++) + { + await _storage.DeleteFileAsync(path, cancellationToken); + if (!await _storage.ExistsAsync(path)) + return true; + } + + return false; + } + + private async Task RollbackSaveAsync( + string projectId, + string artifactId, + string metadataPath, + string mapFileName, + string mapPath, + StoredSourceMapMetadata? previousMetadata) + { + bool metadataRestored; + try + { + if (previousMetadata is null) + { + metadataRestored = await DeleteAndVerifyAsync(metadataPath, CancellationToken.None); + } + else if (previousMetadata.MapFileName == mapFileName + || await _storage.ExistsAsync(GetMapPath(projectId, previousMetadata.MapFileName))) + { + metadataRestored = await SaveMetadataAsync(metadataPath, previousMetadata, CancellationToken.None); + } + else + { + // The old content is already gone, so retain the new metadata and map rather than leaving either version unreadable. + return; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to restore source map metadata while rolling back project {ProjectId} artifact {SourceMapArtifactId}.", projectId, artifactId); + return; + } + + bool newMapDeleted = previousMetadata?.MapFileName == mapFileName; + if (metadataRestored && !newMapDeleted) + { + try + { + newMapDeleted = await DeleteAndVerifyAsync(mapPath, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to remove new source map content while rolling back project {ProjectId} artifact {SourceMapArtifactId}.", projectId, artifactId); + } + } + + if (!metadataRestored || !newMapDeleted) + { + _logger.LogError("Unable to roll back a failed source map replacement for project {ProjectId} and artifact {SourceMapArtifactId}.", projectId, artifactId); + } + } + + private static bool IsValidMapFileName(string artifactId, string mapFileName) + => mapFileName.Length == artifactId.Length + 1 + 64 + 4 + && mapFileName.StartsWith(artifactId + '-', StringComparison.Ordinal) + && mapFileName.EndsWith(".map", StringComparison.Ordinal) + && mapFileName.AsSpan(artifactId.Length + 1, 64).ToArray().All(character => Uri.IsHexDigit(character)); + + private static string GetMetadataPath(string projectId, string artifactId) => $"{RootPath}/{projectId}/{artifactId}.json"; + private static string GetMapPath(string projectId, string mapFileName) => $"{RootPath}/{projectId}/{mapFileName}"; + + internal sealed record StoredSourceMap(SourceMapArtifact Artifact, byte[] Content); + internal sealed record ProjectStorageUsage(IReadOnlyCollection Artifacts, long RetainedBytes); + internal enum SetLastUsedResult { Updated, NotFound, Failed } + private sealed record StoredSourceMapMetadata(SourceMapArtifact Artifact, string MapFileName); +} diff --git a/src/Exceptionless.Core/Utility/ErrorSignature.cs b/src/Exceptionless.Core/Utility/ErrorSignature.cs index 2762922fb2..b0966e6acf 100644 --- a/src/Exceptionless.Core/Utility/ErrorSignature.cs +++ b/src/Exceptionless.Core/Utility/ErrorSignature.cs @@ -1,4 +1,3 @@ -using System.Text; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Models.Data; @@ -132,21 +131,23 @@ public void RecalculateHash() private static string GetStackFrameSignature(Method method) { - var builder = new StringBuilder(255); - if (method is null) - return builder.ToString(); - - builder.Append(method.GetSignature()); + if (method is StackFrame frame && TryGetSourceMappedLocationSignature(frame, out string signature)) + { + return signature; + } - return builder.ToString(); + return method.GetSignature(); } private bool IsUserFrame(StackFrame frame) { ArgumentNullException.ThrowIfNull(frame); - if (frame.Name is null) + bool hasSourceMappedLocation = TryGetSourceMappedLocationSignature(frame, out string sourceMappedLocationSignature); + if (frame.Name is null && !hasSourceMappedLocation) + { return false; + } // Assume user method if no namespace bool isEmptyNamespaceMethod = EmptyNamespaceIsUserMethod && frame.DeclaringNamespace.IsNullOrEmpty(); @@ -157,7 +158,25 @@ private bool IsUserFrame(StackFrame frame) return false; } - return !UserCommonMethods.Any(frame.GetSignature().Contains); + string frameSignature = hasSourceMappedLocation ? sourceMappedLocationSignature : frame.GetSignature(); + return !UserCommonMethods.Any(frameSignature.Contains); + } + + private static bool TryGetSourceMappedLocationSignature(StackFrame frame, out string signature) + { + signature = String.Empty; + if (frame.Name is not null + || String.IsNullOrWhiteSpace(frame.FileName) + || frame.LineNumber is null + || frame.Data?.ContainsKey(StackFrame.KnownDataKeys.SourceMap) != true) + { + return false; + } + + signature = frame.Column is null + ? $"{frame.FileName}:{frame.LineNumber}" + : $"{frame.FileName}:{frame.LineNumber}:{frame.Column}"; + return true; } private bool IsUserNamespace(string? ns) diff --git a/src/Exceptionless.Job/Program.cs b/src/Exceptionless.Job/Program.cs index 2c04155463..3fee9cf352 100644 --- a/src/Exceptionless.Job/Program.cs +++ b/src/Exceptionless.Job/Program.cs @@ -65,7 +65,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) var apmConfig = new ApmConfig(config, $"job-{jobOptions.JobName.ToLowerUnderscoredWords('-')}", options.InformationalVersion, options.CacheOptions.Provider == "redis"); - Log.Information("Bootstrapping Exceptionless {JobName} job(s) in {AppMode} mode ({InformationalVersion}) on {MachineName} with options {@Options}", jobOptions.JobName ?? "All", environment, options.InformationalVersion, Environment.MachineName, options); + Log.Information("Bootstrapping Exceptionless {JobName} job(s) in {AppMode} mode ({InformationalVersion}) on {MachineName} with scope {AppScope}", jobOptions.JobName ?? "All", environment, options.InformationalVersion, Environment.MachineName, options.AppScope); var builder = Host.CreateDefaultBuilder() .UseEnvironment(environment) diff --git a/src/Exceptionless.Web/Api/ApiEndpoints.cs b/src/Exceptionless.Web/Api/ApiEndpoints.cs index a804c7dd7d..1c649dcb08 100644 --- a/src/Exceptionless.Web/Api/ApiEndpoints.cs +++ b/src/Exceptionless.Web/Api/ApiEndpoints.cs @@ -23,6 +23,7 @@ public static WebApplication MapApiEndpoints(this WebApplication app) app.MapOAuthApplicationEndpoints(); app.MapOAuthEndpoints(); app.MapEventEndpoints(); + app.MapSourceMapEndpoints(); app.MapMediatorEndpoints(); return app; diff --git a/src/Exceptionless.Web/Api/Endpoints/SourceMapEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/SourceMapEndpoints.cs new file mode 100644 index 0000000000..9fa2bb2686 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/SourceMapEndpoints.cs @@ -0,0 +1,199 @@ +using System.Text.Json; +using Exceptionless.Core; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Billing; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services.SourceMaps; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Utility.OpenApi; +using Foundatio.Repositories; +using Microsoft.AspNetCore.Mvc; +using HttpIResult = Microsoft.AspNetCore.Http.IResult; +using HttpResults = Microsoft.AspNetCore.Http.Results; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class SourceMapEndpoints +{ + public static IEndpointRouteBuilder MapSourceMapEndpoints(this IEndpointRouteBuilder endpoints) + { + long maximumUploadRequestSize = GetMaximumUploadRequestSize(endpoints.ServiceProvider.GetRequiredService().SourceMapOptions); + var group = endpoints.MapGroup("api/v2") + .WithTags("Source Map"); + + group.MapGet("projects/{projectId:objectid}/source-maps", GetAsync) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces>() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get source maps for a project") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapPost("projects/{projectId:objectid}/source-maps", PostAsync) + .RequireAuthorization(AuthorizationRoles.SourceMapsWritePolicy) + .Accepts("multipart/form-data") + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithMetadata( + new MultipartFileUploadAttribute("file", "generated_file_url") { FileDescription = "The source map file to upload." }, + new RequestSizeLimitAttribute(maximumUploadRequestSize), + new RequestFormLimitsAttribute { MultipartBodyLengthLimit = maximumUploadRequestSize }) + .WithSummary("Upload a source map for a generated JavaScript file") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["201"] = "Created", + ["404"] = "The project could not be found.", + ["422"] = "The source map file or generated file URL is invalid.", + } + }) + .DisableAntiforgery(); + + group.MapDelete("projects/{projectId:objectid}/source-maps/{sourceMapId}", DeleteAsync) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Delete a source map from a project") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["projectId"] = "The identifier of the project.", + ["sourceMapId"] = "The identifier of the source map.", + }, + ResponseDescriptions = new() { + ["404"] = "The project or source map could not be found.", + } + }); + + return endpoints; + } + + internal static long GetMaximumUploadRequestSize(SourceMapOptions options) + => (long)options.MaximumSourceMapSize + 1024 * 1024; + + private static async Task GetAsync( + string projectId, + HttpContext httpContext, + IProjectRepository projectRepository, + SourceMapService sourceMapService, + CancellationToken cancellationToken) + { + if (await GetAccessibleProjectAsync(projectId, httpContext, projectRepository) is null) + return HttpResults.NotFound(); + + return HttpResults.Ok(await sourceMapService.GetArtifactsAsync(projectId, cancellationToken)); + } + + private static async Task PostAsync( + string projectId, + HttpContext httpContext, + IProjectRepository projectRepository, + IOrganizationRepository organizationRepository, + BillingPlans billingPlans, + SourceMapService sourceMapService, + CancellationToken cancellationToken) + { + var project = await GetAccessibleProjectAsync(projectId, httpContext, projectRepository); + if (project is null) + return HttpResults.NotFound(); + + var organization = await organizationRepository.GetByIdAsync(project.OrganizationId, options => options.Cache()); + if (organization is null) + return HttpResults.NotFound(); + + var (form, formError) = await ReadFormAsync(httpContext.Request, cancellationToken); + if (formError is not null) + return formError; + + var errors = new Dictionary(); + string? generatedFileUrl = form!["generated_file_url"].FirstOrDefault(); + var file = form.Files.GetFile("file"); + + if (String.IsNullOrWhiteSpace(generatedFileUrl)) + errors["generated_file_url"] = ["The generated file URL is required."]; + if (file is null || file.Length == 0) + errors["file"] = ["A source map file is required."]; + if (errors.Count > 0) + return HttpResults.ValidationProblem(errors, statusCode: StatusCodes.Status422UnprocessableEntity); + + try + { + await using var stream = file!.OpenReadStream(); + bool isFreePlan = String.Equals(organization.PlanId, billingPlans.FreePlan.Id, StringComparison.OrdinalIgnoreCase); + var artifact = await sourceMapService.SaveUploadedAsync(projectId, generatedFileUrl!, file.FileName, stream, isFreePlan, cancellationToken); + return HttpResults.Json(artifact, statusCode: StatusCodes.Status201Created); + } + catch (ArgumentException ex) + { + errors["generated_file_url"] = [ex.Message]; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + errors["file"] = [ex.Message]; + } + + return HttpResults.ValidationProblem(errors, statusCode: StatusCodes.Status422UnprocessableEntity); + } + + internal static async Task<(IFormCollection? Form, HttpIResult? Error)> ReadFormAsync( + HttpRequest request, + CancellationToken cancellationToken) + { + if (!request.HasFormContentType) + { + return (null, HttpResults.ValidationProblem( + new Dictionary { ["file"] = ["The request must use multipart/form-data."] }, + statusCode: StatusCodes.Status422UnprocessableEntity)); + } + + try + { + return (await request.ReadFormAsync(cancellationToken), null); + } + catch (Exception ex) when (ex is InvalidDataException or BadHttpRequestException) + { + return (null, HttpResults.ValidationProblem( + new Dictionary { ["file"] = ["The multipart form data is invalid."] }, + statusCode: StatusCodes.Status422UnprocessableEntity)); + } + } + + private static async Task DeleteAsync( + string projectId, + string sourceMapId, + HttpContext httpContext, + IProjectRepository projectRepository, + SourceMapService sourceMapService, + CancellationToken cancellationToken) + { + if (await GetAccessibleProjectAsync(projectId, httpContext, projectRepository) is null) + return HttpResults.NotFound(); + + return await sourceMapService.DeleteArtifactAsync(projectId, sourceMapId, cancellationToken) + ? HttpResults.NoContent() + : HttpResults.NotFound(); + } + + private static async Task GetAccessibleProjectAsync(string projectId, HttpContext httpContext, IProjectRepository projectRepository) + { + var project = await projectRepository.GetByIdAsync(projectId, options => options.Cache()); + if (project is null || !httpContext.Request.CanAccessOrganization(project.OrganizationId)) + return null; + + string? tokenProjectId = httpContext.Request.GetProjectId(); + if (httpContext.User.IsInRole(AuthorizationRoles.SourceMapsWrite) && !httpContext.User.IsInRole(AuthorizationRoles.User)) + return String.Equals(tokenProjectId, projectId, StringComparison.Ordinal) ? project : null; + + return String.IsNullOrEmpty(tokenProjectId) || String.Equals(tokenProjectId, projectId, StringComparison.Ordinal) ? project : null; + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/TokenEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/TokenEndpoints.cs index de940b0ec1..36bf374960 100644 --- a/src/Exceptionless.Web/Api/Endpoints/TokenEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/TokenEndpoints.cs @@ -98,7 +98,7 @@ public static IEndpointRouteBuilder MapTokenEndpoints(this IEndpointRouteBuilder .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Create") - .WithDescription("To create a new token, you must specify an organization_id. There are three valid scopes: client, user and admin.") + .WithDescription("To create a new token, you must specify an organization_id and project_id. Valid scopes are client, user, admin, and source-maps:write. The source-maps:write scope requires a project-scoped token.") .WithMetadata(new EndpointDocumentation { RequestBodyDescription = "The token.", ResponseDescriptions = new() { @@ -119,7 +119,7 @@ public static IEndpointRouteBuilder MapTokenEndpoints(this IEndpointRouteBuilder .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict) .WithSummary("Create for project") - .WithDescription("This is a helper action that makes it easier to create a token for a specific project. You may also specify a scope when creating a token. There are three valid scopes: client, user and admin.") + .WithDescription("This is a helper action that makes it easier to create a token for a specific project. Valid scopes are client, user, admin, and source-maps:write.") .WithMetadata(new EndpointDocumentation { RequestBodyDescription = "The token.", ParameterDescriptions = new() { diff --git a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs index eb77c82c92..1d5cdd7105 100644 --- a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -535,6 +535,7 @@ await eventPostService.EnqueueAsync(new EventPost(appOptions.EnableArchive) MediaType = mediaType, OrganizationId = project.OrganizationId, ProjectId = project.Id, + ClientKeyHash = httpContext.Request.GetClientKeyHash(), UserAgent = message.UserAgent }, stream); } @@ -601,6 +602,7 @@ public async Task Handle(SubmitEventByPost message) MediaType = mediaType, OrganizationId = project.OrganizationId, ProjectId = project.Id, + ClientKeyHash = httpContext.Request.GetClientKeyHash(), UserAgent = message.UserAgent, }, requestBody, httpContext.RequestAborted); diff --git a/src/Exceptionless.Web/Api/Handlers/TokenHandler.cs b/src/Exceptionless.Web/Api/Handlers/TokenHandler.cs index 72547f4b6e..aef15d8f04 100644 --- a/src/Exceptionless.Web/Api/Handlers/TokenHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/TokenHandler.cs @@ -69,7 +69,7 @@ public async Task> Handle(GetDefaultToken message) if (project is null) return Result.NotFound("Project not found."); - var defaultTokenResults = await repository.GetByTypeAndProjectIdAsync(TokenType.Access, message.ProjectId, o => o.PageLimit(1)); + var defaultTokenResults = await repository.GetDefaultClientTokenAsync(message.ProjectId, o => o.PageLimit(1)); var token = defaultTokenResults.Documents.FirstOrDefault(); if (token is not null) return MapToView(token); @@ -235,6 +235,9 @@ private async Task> CreateTokenImplAsync(NewToken value) if (value.Scopes.Count == 0) value.Scopes.Add(AuthorizationRoles.Client); + if (value.Scopes.Contains(AuthorizationRoles.SourceMapsWrite) && String.IsNullOrEmpty(value.ProjectId)) + return Result.Invalid(ValidationError.Create("project_id", "The source-maps:write scope requires a project-scoped token.")); + if ((value.Scopes.Contains(AuthorizationRoles.Client) && !hasUserRole) || (value.Scopes.Contains(AuthorizationRoles.User) && !hasUserRole) || (value.Scopes.Contains(AuthorizationRoles.GlobalAdmin) && !hasGlobalAdminRole)) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts index 189193d7cd..85a0120b24 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts @@ -1,8 +1,9 @@ -import type { ClientConfiguration, NewProject, NotificationSettings, UpdateProject, ViewProject } from '$features/projects/models'; +import type { ClientConfiguration, NewProject, NotificationSettings, SourceMapArtifact, UpdateProject, ViewProject } from '$features/projects/models'; import type { StringValueFromBody, WorkInProgressResult } from '$features/shared/models'; import type { WebSocketMessageValue } from '$features/websockets/models'; import { accessToken } from '$features/auth/index.svelte'; +import { fetchApiJson } from '$features/shared/api/api.svelte'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; @@ -44,6 +45,7 @@ export const queryKeys = { putIntegrationNotificationSettings: (id: string | undefined, integration: string) => [...queryKeys.id(id), integration, 'put-notification-settings'] as const, resetData: (id: string | undefined) => [...queryKeys.id(id), 'reset-data'] as const, + sourceMaps: (id: string | undefined) => [...queryKeys.id(id), 'source-maps'] as const, type: ['Project'] as const, userNotificationSettings: (id: string | undefined, userId: string | undefined) => [...queryKeys.id(id), userId, 'notification-settings'] as const }; @@ -188,6 +190,17 @@ export interface ResetDataRequest { }; } +export interface SourceMapRequest { + route: { + id: string | undefined; + }; +} + +export interface SourceMapUpload { + file: File; + generated_file_url: string; +} + export interface UpdateProjectRequest { route: { id: string; @@ -283,6 +296,23 @@ export function deleteSlack(request: DeleteSlackRequest) { })); } +export function deleteSourceMapMutation(request: SourceMapRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async (sourceMapId: string) => { + const client = useFetchClient(); + const response = await client.delete(`projects/${request.route.id}/source-maps/${sourceMapId}`); + return response.ok; + }, + mutationKey: [...queryKeys.sourceMaps(request.route.id), 'delete'], + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.sourceMaps(request.route.id) }); + } + })); +} + export function generateSampleData(request: GenerateSampleDataRequest) { return createMutation(() => ({ enabled: () => !!accessToken.current && !!request.route.id, @@ -412,6 +442,18 @@ export function getProjectUserNotificationSettings(request: GetProjectUserNotifi })); } +export function getSourceMapsQuery(request: SourceMapRequest) { + return createQuery(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON(`projects/${request.route.id}/source-maps`, { signal }); + return response.data!; + }, + queryKey: queryKeys.sourceMaps(request.route.id) + })); +} + export function postProject() { const queryClient = useQueryClient(); @@ -530,6 +572,27 @@ export function postSlack(request: PostSlackRequest) { })); } +export function postSourceMapMutation(request: SourceMapRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async (sourceMap: SourceMapUpload) => { + const data = new FormData(); + data.append('generated_file_url', sourceMap.generated_file_url); + data.append('file', sourceMap.file); + return await fetchApiJson(`projects/${request.route.id}/source-maps`, { + body: data, + method: 'POST' + }); + }, + mutationKey: [...queryKeys.sourceMaps(request.route.id), 'post'], + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.sourceMaps(request.route.id) }); + } + })); +} + export function putProjectIntegrationNotificationSettings(request: PutProjectIntegrationNotificationSettingsRequest) { const queryClient = useQueryClient(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/models.ts index a055dfb212..306d41b196 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/models.ts @@ -4,3 +4,14 @@ export interface ClientConfigurationSetting { key: string; value: string; } + +export interface SourceMapArtifact { + created_utc: string; + file_name?: string; + generated_file_url: string; + id: string; + is_auto_downloaded: boolean; + last_used_utc?: string; + size: number; + source_map_url?: string; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts index e495e7dd4f..8c9b789bf3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts @@ -5,7 +5,7 @@ import { type NotificationSettingsFormData, NotificationSettingsSchema } from '$generated/schemas'; -import { type infer as Infer, object, string } from 'zod'; +import { custom, type infer as Infer, object, string } from 'zod'; export { type NewProjectFormData, NewProjectSchema, type NotificationSettingsFormData, NotificationSettingsSchema }; @@ -17,3 +17,9 @@ export type ClientConfigurationSettingFormData = Infer; + +export const SourceMapUploadSchema = object({ + file: custom((value) => typeof File !== 'undefined' && value instanceof File, 'Source map file is required'), + generated_file_url: string().url('Enter the absolute URL of the generated JavaScript file') +}); +export type SourceMapUploadFormData = Infer; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/disable-token-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/disable-token-dialog.svelte index 9201f757d4..b53df29ac8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/disable-token-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/disable-token-dialog.svelte @@ -20,14 +20,14 @@ - Disable API Key + Disable Token Are you sure you want to disable "{id}" {#if notes}({notes}){/if}? Cancel - Disable API Key + Disable Token diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/enable-token-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/enable-token-dialog.svelte index a0679bad08..10d581c7bd 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/enable-token-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/enable-token-dialog.svelte @@ -19,14 +19,14 @@ - Enable API Key + Enable Token Are you sure you want to enable "{id}" {#if notes}({notes}){/if}? Cancel - Enable API Key + Enable Token diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/remove-token-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/remove-token-dialog.svelte index 9382fd1d5f..49041755bf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/remove-token-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/remove-token-dialog.svelte @@ -20,14 +20,14 @@ - Delete API Key + Delete Token Are you sure you want to delete "{id}" {#if notes}({notes}){/if}? Cancel - Delete API Key + Delete Token diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/update-token-notes-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/update-token-notes-dialog.svelte index f375843862..5226ef59ef 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/update-token-notes-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/dialogs/update-token-notes-dialog.svelte @@ -57,7 +57,7 @@ }} > - API Key Notes + Token Notes state.errors}> diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts index 8142bc8593..fef24c4c75 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/options.svelte.ts @@ -5,6 +5,7 @@ import type { CreateQueryResult } from '@tanstack/svelte-query'; import { getSharedTableOptions } from '$features/shared/table.svelte'; import TokenActionsCell from '$features/tokens/components/table/token-actions-cell.svelte'; import TokenIdCell from '$features/tokens/components/table/token-id-cell.svelte'; +import TokenScopesCell from '$features/tokens/components/table/token-scopes-cell.svelte'; import { type ColumnDef, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; import type { GetProjectTokensParams } from '../../api.svelte'; @@ -16,7 +17,17 @@ export function getColumns(): ColumnDef renderComponent(TokenIdCell, { token: info.row.original }), enableHiding: false, enableSorting: false, - header: 'API Key', + header: 'Token', + meta: { + class: 'w-45' + } + }, + { + accessorKey: 'scopes', + cell: (info) => renderComponent(TokenScopesCell, { scopes: info.getValue() }), + enableHiding: true, + enableSorting: false, + header: 'Scope', meta: { class: 'w-45' } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/token-actions-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/token-actions-cell.svelte index 0281e538b0..3f6e22616e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/token-actions-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/token-actions-cell.svelte @@ -31,7 +31,7 @@ const clipboard = new UseClipboard(); async function copyToClipboard() { - await clipboard.copy(token.id); // Assuming token.apiKey holds the API key to copy + await clipboard.copy(token.id); if (clipboard.copied) { toast.success('Copy to clipboard succeeded'); } else { @@ -49,7 +49,7 @@ async function updateDisabled(is_disabled: boolean) { await updateToken.mutateAsync({ is_disabled, notes: token.notes }); - toast.success(`Successfully ${is_disabled ? 'disabled' : 'enabled'} API key`); + toast.success(`Successfully ${is_disabled ? 'disabled' : 'enabled'} token`); } function onEnableDisableClick() { @@ -75,7 +75,7 @@ async function remove() { await removeToken.mutateAsync(); - toast.success('Successfully deleted API key'); + toast.success('Successfully deleted token'); } @@ -91,7 +91,7 @@ - Copy Api Key + Copy Token (showUpdateNotesDialog = true)}> @@ -100,10 +100,10 @@ {#if token.is_disabled} - Enable API Key + Enable Token {:else} - Disable API Key + Disable Token {/if} (showRemoveTokenDialog = true)} disabled={removeToken.isPending}> diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/token-scopes-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/token-scopes-cell.svelte new file mode 100644 index 0000000000..712c707746 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/components/table/token-scopes-cell.svelte @@ -0,0 +1,15 @@ + + +
+ {#each scopes as scope (scope)} + {scope} + {/each} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/project-token.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/project-token.test.ts new file mode 100644 index 0000000000..666b93b963 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/project-token.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { createProjectToken } from './project-token'; + +describe('createProjectToken', () => { + it('creates a project-scoped source map upload token', () => { + const token = createProjectToken('organization-id', 'project-id', 'source-maps:write'); + + expect(token).toEqual({ + notes: 'Source map deployment', + organization_id: 'organization-id', + project_id: 'project-id', + scopes: ['source-maps:write'] + }); + }); + + it('preserves normal client API key creation', () => { + const token = createProjectToken('organization-id', 'project-id', 'client'); + + expect(token).toEqual({ + notes: undefined, + organization_id: 'organization-id', + project_id: 'project-id', + scopes: ['client'] + }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/project-token.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/project-token.ts new file mode 100644 index 0000000000..286abcbe1c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/tokens/project-token.ts @@ -0,0 +1,12 @@ +import type { NewToken } from '$features/tokens/models'; + +export type ProjectTokenScope = 'client' | 'source-maps:write'; + +export function createProjectToken(organizationId: string, projectId: string, scope: ProjectTokenScope): NewToken { + return { + notes: scope === 'source-maps:write' ? 'Source map deployment' : undefined, + organization_id: organizationId, + project_id: projectId, + scopes: [scope] + }; +} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte index dd8f022464..e514eab702 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte @@ -32,7 +32,18 @@ }); const currentPath = $derived(page.url.pathname); const configurePath = $derived(resolve('/(app)/project/[projectId]/configure', { projectId })); + const settingsPath = $derived(resolve('/(app)/project/[projectId]/settings', { projectId })); + const sourceMapsPath = $derived(resolve('/(app)/project/[projectId]/source-maps', { projectId })); const isConfigurePage = $derived(currentPath === configurePath || currentPath.startsWith(configurePath + '/')); + const navigationRoutes = $derived(routes().filter((route) => route.href !== sourceMapsPath)); + + function isNavigationRouteActive(routeHref: string): boolean { + if (currentPath === routeHref || currentPath.startsWith(routeHref + '/')) { + return true; + } + + return routeHref === settingsPath && (currentPath === sourceMapsPath || currentPath.startsWith(sourceMapsPath + '/')); + } $effect(() => { if (projectQuery.isError) { @@ -74,8 +85,8 @@
{#if !isConfigurePage}