diff --git a/CHANGELOG.md b/CHANGELOG.md index d1cb5080ed..8834b41e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ Main (unreleased) - Add `truncate` stage for `loki.process` to truncate log entries, label values, and structured_metadata values. (@dehaansa) +- `faro.receiver` can now fetch sourcemaps from remote locations. When multiple locations are configured, local on-disk paths will be checked before remote paths. (@Oxel40) + ### Enhancements - Add support of `tls` in components `loki.source.(awsfirehose|gcplog|heroku|api)` and `prometheus.receive_http` and `pyroscope.receive_http`. (@fgouteroux) diff --git a/docs/sources/reference/components/faro/faro.receiver.md b/docs/sources/reference/components/faro/faro.receiver.md index 4fa987477d..a952a1bdee 100644 --- a/docs/sources/reference/components/faro/faro.receiver.md +++ b/docs/sources/reference/components/faro/faro.receiver.md @@ -55,13 +55,13 @@ The following strings are valid log line formats: You can use the following blocks with `faro.receiver`: -| Block | Description | Required | -|----------------------------------------------|------------------------------------------------------|----------| -| [`output`][output] | Configures where to send collected telemetry data. | yes | -| [`server`][server] | Configures the HTTP server. | no | -| `server` > [`rate_limiting`][rate_limiting] | Configures rate limiting for the HTTP server. | no | -| [`sourcemaps`][sourcemaps] | Configures sourcemap retrieval. | no | -| `sourcemaps` > [`location`][location] | Configures on-disk location for sourcemap retrieval. | no | +| Block | Description | Required | +|----------------------------------------------|----------------------------------------------------|----------| +| [`output`][output] | Configures where to send collected telemetry data. | yes | +| [`server`][server] | Configures the HTTP server. | no | +| `server` > [`rate_limiting`][rate_limiting] | Configures rate limiting for the HTTP server. | no | +| [`sourcemaps`][sourcemaps] | Configures sourcemap retrieval. | no | +| `sourcemaps` > [`location`][location] | Configures the location for sourcemap retrieval. | no | The > symbol indicates deeper levels of nesting. For example, `sourcemaps` > `location` refers to a `location` block defined inside a `sourcemaps` block. @@ -151,7 +151,7 @@ The `*` character indicates a wildcard. By default, sourcemap downloads are subject to a timeout of `"1s"`, specified by the `download_timeout` argument. Setting `download_timeout` to `"0s"` disables timeouts. -To retrieve sourcemaps from disk instead of the network, specify one or more [`location` blocks][location]. +To retrieve sourcemaps from disk or another network location, specify one or more [`location` blocks][location]. When `location` blocks are provided, they're checked first for sourcemaps before falling back to downloading. #### `location` @@ -159,10 +159,10 @@ When `location` blocks are provided, they're checked first for sourcemaps before The `location` block declares a location where sourcemaps are stored on the filesystem. You can specify the `location` block multiple times to declare multiple locations where sourcemaps are stored. -| Name | Type | Description | Default | Required | -|------------------------|----------|-----------------------------------------------------|---------|----------| -| `minified_path_prefix` | `string` | The prefix of the minified path sent from browsers. | | yes | -| `path` | `string` | The path on disk where sourcemaps are stored. | | yes | +| Name | Type | Description | Default | Required | +|------------------------|----------|-----------------------------------------------------------|---------|----------| +| `minified_path_prefix` | `string` | The prefix of the minified path sent from browsers. | | yes | +| `path` | `string` | The path on disk or base URL where sourcemaps are stored. | | yes | The `minified_path_prefix` argument determines the prefix of paths to JavaScript files, such as `http://example.com/`. The `path` argument then determines where to find the sourcemap for the file. @@ -184,6 +184,35 @@ To look up the sourcemaps for a file hosted at `http://example.com/example.js`, Optionally, the value for the `path` argument may contain `{{ .Release }}` as a template value, such as `/var/my-app/{{ .Release }}/build`. The template value is replaced with the release value provided by the [Faro Web App SDK][faro-sdk]. +When you specify a remote location, the procedure for retrieving the sourcemaps is the same as for a location block with a local path, except that the component retrieves the sourcemap from a remote HTTP server. + + In the following example, the `faro.receiver` sends a GET request to `http://foo.com/blob/sourcemaps/example.js.map` and retrieves the sourcemap for a file hosted at +`http://example.com/example.js`. + +You can specify multiple location blocks. For example: + +```alloy +location { + path = "http://foo.com/blob/sourcemaps/" + minified_path_prefix = "http://example.com/" +} + +```alloy +location { + path = "/var/my-app/build" + minified_path_prefix = "http://example.com/" +} +location { + path = "http://foo.com/blob/sourcemaps/" + minified_path_prefix = "http://example.com/" +} +``` + +The `faro.receiver` component searches through all locations for the sourcemap files. +Local on-disk paths take precedence over remote paths. +For a file hosted at `http://example.com/example.js`, the `faro.receiver` first checks +the path `/var/my-app/build/example.js.map`, and then tries to retrieve `http://foo.com/blob/sourcemaps/example.js.map`. + ## Exported fields `faro.receiver` doesn't export any fields. diff --git a/internal/component/faro/receiver/sourcemaps.go b/internal/component/faro/receiver/sourcemaps.go index 48533a8a3b..78779b8158 100644 --- a/internal/component/faro/receiver/sourcemaps.go +++ b/internal/component/faro/receiver/sourcemaps.go @@ -185,14 +185,30 @@ func (store *sourceMapsStoreImpl) GetSourceMap(sourceURL string, release string) func (store *sourceMapsStoreImpl) getSourceMapContent(sourceURL string, release string) (content []byte, sourceMapURL string, err error) { // Attempt to find the source map in the filesystem first. for _, loc := range store.locs { + if hasHttpPrefix(loc.Path) { + continue + } + content, sourceMapURL, err = store.getSourceMapFromFileSystem(sourceURL, release, loc) if content != nil || err != nil { return content, sourceMapURL, err } } + // Attempt to find the source map in the remote locations. + for _, loc := range store.locs { + if !(hasHttpPrefix(loc.Path)) { + continue + } + + content, sourceMapURL, err = store.getSourceMapFromRemote(sourceURL, release, loc) + if content != nil || err != nil { + return content, sourceMapURL, err + } + } + // Attempt to download the sourcemap if enabled. - if strings.HasPrefix(sourceURL, "http") && urlMatchesOrigins(sourceURL, store.args.DownloadFromOrigins) && store.args.Download { + if store.args.Download && hasHttpPrefix(sourceURL) && urlMatchesOrigins(sourceURL, store.args.DownloadFromOrigins) { return store.downloadSourceMapContent(sourceURL) } return nil, "", nil @@ -242,6 +258,34 @@ func (store *sourceMapsStoreImpl) getSourceMapFromFileSystem(sourceURL string, r return content, sourceURL, err } +func (store *sourceMapsStoreImpl) getSourceMapFromRemote(sourceURL string, release string, loc *sourcemapFileLocation) (content []byte, sourceMapURL string, err error) { + if len(sourceURL) == 0 || !strings.HasPrefix(sourceURL, loc.MinifiedPathPrefix) || strings.HasSuffix(sourceURL, "/") { + return nil, "", nil + } + + var rootPath bytes.Buffer + + err = loc.pathTemplate.Execute(&rootPath, struct{ Release string }{Release: cleanFilePathPart(release)}) + if err != nil { + return nil, "", err + } + + subPath := strings.TrimPrefix(strings.Split(sourceURL, "?")[0], loc.MinifiedPathPrefix) + ".map" + mapURL, err := url.JoinPath(rootPath.String(), subPath) + if err != nil { + level.Debug(store.log).Log("msg", "failed to construct sourcemap url for remote location", "base_path", rootPath, "sub_path", subPath, "err", err) + return nil, "", err + } + + content, err = store.downloadFileContents(mapURL) + if err != nil { + level.Debug(store.log).Log("msg", "failed to download sourcemap file from remote location", "url", mapURL, "err", err) + return nil, "", err + } + + return content, sourceURL, err +} + func (store *sourceMapsStoreImpl) downloadSourceMapContent(sourceURL string) (content []byte, resolvedSourceMapURL string, err error) { level.Debug(store.log).Log("msg", "attempting to download source file", "url", sourceURL) @@ -341,6 +385,10 @@ func urlMatchesOrigins(URL string, origins []string) bool { return false } +func hasHttpPrefix(URL string) bool { + return strings.HasPrefix(URL, "http://") || strings.HasPrefix(URL, "https://") +} + func cleanFilePathPart(x string) string { return strings.TrimLeft(strings.ReplaceAll(strings.ReplaceAll(x, "\\", ""), "/", ""), ".") } diff --git a/internal/component/faro/receiver/sourcemaps_test.go b/internal/component/faro/receiver/sourcemaps_test.go index c4ed78f03e..8867abc255 100644 --- a/internal/component/faro/receiver/sourcemaps_test.go +++ b/internal/component/faro/receiver/sourcemaps_test.go @@ -431,6 +431,237 @@ func Test_sourceMapsStoreImpl_ReadFromFileSystemAndNotDownloadIfDisabled(t *test require.Equal(t, expect, actual) } +func Test_sourceMapsStoreImpl_ReadFromRemoteLocation(t *testing.T) { + var ( + logger = alloyutil.TestLogger(t) + + httpClient = &mockHTTPClient{ + responses: []struct { + *http.Response + error + }{ + {newResponseFromTestData(t, "foo.js.map"), nil}, + }, + } + + fileService = newTestFileService() + ) + + var store = newSourceMapsStore( + logger, + SourceMapsArguments{ + Download: false, + DownloadFromOrigins: []string{"*"}, + Locations: []LocationArguments{ + { + MinifiedPathPrefix: "http://foo.com/", + Path: "http://baz.com/baz", + }, + }, + }, + newSourceMapMetrics(prometheus.NewRegistry()), + httpClient, + fileService, + ) + + expect := &payload.Exception{ + Stacktrace: &payload.Stacktrace{ + Frames: []payload.Frame{ + { + Colno: 37, + Filename: "/__parcel_source_root/demo/src/actions.ts", + Function: "?", + Lineno: 6, + }, + { + Colno: 5, + Filename: "http://bar.com/foo.js", + Function: "callUndefined", + Lineno: 6, + }, + }, + }, + } + + actual := transformException(logger, store, &payload.Exception{ + Stacktrace: &payload.Stacktrace{ + Frames: []payload.Frame{ + { + Colno: 6, + Filename: "http://foo.com/foo.js", + Function: "eval", + Lineno: 5, + }, + { + Colno: 5, + Filename: "http://bar.com/foo.js", + Function: "callUndefined", + Lineno: 6, + }, + }, + }, + }, "123") + + require.Equal(t, []string{}, fileService.stats) + require.Equal(t, []string{}, fileService.reads) + require.Equal(t, []string{"http://baz.com/baz/foo.js.map"}, httpClient.requests) + require.Equal(t, expect, actual) +} + +func Test_sourceMapsStoreImpl_ReadFromFileSystemIfBothLocalAndRemoteLocation(t *testing.T) { + var ( + logger = alloyutil.TestLogger(t) + + httpClient = &mockHTTPClient{} + + fileService = newTestFileService() + ) + fileService.files = map[string][]byte{ + filepath.FromSlash("/var/build/latest/foo.js.map"): loadTestData(t, "foo.js.map"), + } + + var store = newSourceMapsStore( + logger, + SourceMapsArguments{ + Download: false, + DownloadFromOrigins: []string{"*"}, + Locations: []LocationArguments{ + { + MinifiedPathPrefix: "http://foo.com/", + Path: "http://baz.com/baz/", + }, + { + MinifiedPathPrefix: "http://foo.com/", + Path: filepath.FromSlash("/var/build/latest/"), + }, + }, + }, + newSourceMapMetrics(prometheus.NewRegistry()), + httpClient, + fileService, + ) + + expect := &payload.Exception{ + Stacktrace: &payload.Stacktrace{ + Frames: []payload.Frame{ + { + Colno: 37, + Filename: "/__parcel_source_root/demo/src/actions.ts", + Function: "?", + Lineno: 6, + }, + { + Colno: 5, + Filename: "http://bar.com/foo.js", + Function: "callUndefined", + Lineno: 6, + }, + }, + }, + } + + actual := transformException(logger, store, &payload.Exception{ + Stacktrace: &payload.Stacktrace{ + Frames: []payload.Frame{ + { + Colno: 6, + Filename: "http://foo.com/foo.js", + Function: "eval", + Lineno: 5, + }, + { + Colno: 5, + Filename: "http://bar.com/foo.js", + Function: "callUndefined", + Lineno: 6, + }, + }, + }, + }, "123") + + require.Equal(t, []string{"/var/build/latest/foo.js.map"}, fileService.stats) + require.Equal(t, []string{"/var/build/latest/foo.js.map"}, fileService.reads) + require.Nil(t, httpClient.requests) + require.Equal(t, expect, actual) +} + +func Test_sourceMapsStoreImpl_ReadFromRemoteLocationIfBothDownloadAndLocationIsSet(t *testing.T) { + var ( + logger = alloyutil.TestLogger(t) + + httpClient = &mockHTTPClient{ + responses: []struct { + *http.Response + error + }{ + {newResponseFromTestData(t, "foo.js.map"), nil}, + }, + } + + fileService = newTestFileService() + ) + + var store = newSourceMapsStore( + logger, + SourceMapsArguments{ + Download: true, + DownloadFromOrigins: []string{"*"}, + Locations: []LocationArguments{ + { + MinifiedPathPrefix: "http://foo.com/", + Path: "http://baz.com/baz/", + }, + }, + }, + newSourceMapMetrics(prometheus.NewRegistry()), + httpClient, + fileService, + ) + + expect := &payload.Exception{ + Stacktrace: &payload.Stacktrace{ + Frames: []payload.Frame{ + { + Colno: 37, + Filename: "/__parcel_source_root/demo/src/actions.ts", + Function: "?", + Lineno: 6, + }, + { + Colno: 5, + Filename: "http://bar.com/foo.js", + Function: "callUndefined", + Lineno: 6, + }, + }, + }, + } + + actual := transformException(logger, store, &payload.Exception{ + Stacktrace: &payload.Stacktrace{ + Frames: []payload.Frame{ + { + Colno: 6, + Filename: "http://foo.com/foo.js", + Function: "eval", + Lineno: 5, + }, + { + Colno: 5, + Filename: "http://bar.com/foo.js", + Function: "callUndefined", + Lineno: 6, + }, + }, + }, + }, "123") + + require.Equal(t, []string{}, fileService.stats) + require.Equal(t, []string{}, fileService.reads) + require.Equal(t, []string{"http://baz.com/baz/foo.js.map"}, httpClient.requests) + require.Equal(t, expect, actual) +} + func Test_sourceMapsStoreImpl_FilepathSanitized(t *testing.T) { var ( logger = alloyutil.TestLogger(t)