-
-
Notifications
You must be signed in to change notification settings - Fork 507
Add JavaScript source map symbolication #2373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ejsmith
wants to merge
17
commits into
main
Choose a base branch
from
feature/source-map-symbolication
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
6550a8d
Add JavaScript source map symbolication
ejsmith 5e7dd25
Harden source map symbolication lifecycle
ejsmith 3a42b1f
Add project-scoped source map upload tokens
ejsmith 5bc2414
Add source map token creation to the UI
ejsmith 609846c
Harden source map parser limits
ejsmith 8bed4e0
Clean up source maps for deleted projects
ejsmith 911eb5d
Throttle automatic source map discovery
ejsmith a687fb5
Fix source map upload page render
ejsmith a9ab52a
Merge remote-tracking branch 'origin/main' into feature/source-map-sy…
ejsmith 618e4ce
Merge origin/main into feature/source-map-symbolication
niemyjski 758ddaf
Harden source map processing
niemyjski 1064caf
Fix OpenAPI snapshot terminator
niemyjski e1e4ab2
Honor source map response headers before size checks
niemyjski 7cb15bf
Validate source map symbolication inputs
niemyjski 9645042
Stabilize source map content validation tests
niemyjski 3a381b2
Bound source map segments before allocation
niemyjski 945a328
Stabilize source map validation and fallback
niemyjski File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| --- | ||
| 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 `<generated-file>.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. Self-hosted installations can tune these safeguards under the `SourceMaps` configuration section, including `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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| 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 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 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 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), | ||
| 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)) | ||
| }; | ||
| } | ||
|
|
||
| private static int ReadPositive(IConfiguration section, string name, int defaultValue) | ||
| => Math.Max(1, section.GetValue(name, defaultValue)); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 44 additions & 0 deletions
44
src/Exceptionless.Core/Plugins/EventProcessor/Default/15_SourceMapPlugin.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.