chore: import upstream snapshot with attribution
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# format-errors
|
||||
|
||||
## 0.0.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#12753](https://github.com/cloudflare/workers-sdk/pull/12753) [`ea8b1a4`](https://github.com/cloudflare/workers-sdk/commit/ea8b1a4619b663598f03d72760090f5e67827d05) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Replace deprecated `promjs` library with `MetricsRegistry` from `@cloudflare/workers-utils/prometheus-metrics`
|
||||
|
||||
The `promjs` library has been unmaintained since 2022 and has a broken `package.json` requiring workarounds. It has been replaced with a lightweight `MetricsRegistry` class in `@cloudflare/workers-utils/prometheus-metrics` that produces byte-identical Prometheus text exposition format output.
|
||||
|
||||
## 0.0.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#12756](https://github.com/cloudflare/workers-sdk/pull/12756) [`c7d0d18`](https://github.com/cloudflare/workers-sdk/commit/c7d0d189a40bea786e0425f25d9aa15686f40e92) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - Fix error formatting to reliably return fallback responses on failure
|
||||
|
||||
Previously, if something went wrong while formatting a pretty error page, the failure could go unhandled, resulting in no response being returned to the user. Now, errors during formatting are properly caught, ensuring users always receive a 500 JSON fallback response.
|
||||
|
||||
## 0.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#11217](https://github.com/cloudflare/workers-sdk/pull/11217) [`9ed1542`](https://github.com/cloudflare/workers-sdk/commit/9ed1542e854be65f7c03ed9596ec36767ce8aa36) Thanks [@penalosa](https://github.com/penalosa)! - Use toucan-js v4
|
||||
|
||||
## 0.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9649](https://github.com/cloudflare/workers-sdk/pull/9649) [`ec9b417`](https://github.com/cloudflare/workers-sdk/commit/ec9b417f8ed711e7b5044410e83d781f123a6a62) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - patch release to trigger a test release
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9033](https://github.com/cloudflare/workers-sdk/pull/9033) [`2c50115`](https://github.com/cloudflare/workers-sdk/commit/2c501151d3d1a563681cdb300a298b83862b60e2) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - chore: convert wrangler.toml files into wrangler.jsonc ones
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7143](https://github.com/cloudflare/workers-sdk/pull/7143) [`4d7ce6f`](https://github.com/cloudflare/workers-sdk/commit/4d7ce6fd9fc80a0920a97dae14726c79012337b1) Thanks [@emily-shen](https://github.com/emily-shen)! - chore: enable observability on our internal infra Workers + bots
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#6046](https://github.com/cloudflare/workers-sdk/pull/6046) [`c643a81`](https://github.com/cloudflare/workers-sdk/commit/c643a8193a3c0739b33d3c0072ae716bc8f1565b) Thanks [@threepointone](https://github.com/threepointone)! - chore: Normalize more dependencies.
|
||||
|
||||
Follow up to https://github.com/cloudflare/workers-sdk/pull/6029, this normalizes some more dependencies : `get-port`, `chalk`, `yargs`, `toucan-js`, `@typescript-eslint/parser`, `@typescript-eslint/eslint-plugin`, `esbuild-register`, `hono`, `glob-to-regexp`, `@cloudflare/workers-types`
|
||||
|
||||
## 0.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#5482](https://github.com/cloudflare/workers-sdk/pull/5482) [`1b7739e`](https://github.com/cloudflare/workers-sdk/commit/1b7739e0af99860aa063f01c0a6e7712ac072fdb) Thanks [@DaniFoldi](https://github.com/DaniFoldi)! - docs: show new Discord url everywhere for consistency. The old URL still works, but https://discord.cloudflare.com is preferred.
|
||||
@@ -0,0 +1,91 @@
|
||||
# format-errors
|
||||
|
||||
A Worker that will nicely format errors, optionally inflating with source context if available.
|
||||
|
||||
<table>
|
||||
<tr><td><img src="./images/service-worker.png"></td><td><img src="./images/modules-worker.png"></td></tr>
|
||||
</table>
|
||||
|
||||
## Why is this needed?
|
||||
|
||||
In development, it's nice to have good error messages to help when debugging, and ideally the logic for that shouldn't be bundled into your user Worker. This Worker is ~50kb when gzipped, which doesn't add _that_ much, but there's still a benefit to keeping it separate from the user Worker. Additionally, in environments where we're unable to bundle complex Workers, this error formatting Worker allows for the display of useful errors where otherwise only a stack trace would be available.
|
||||
|
||||
## How does it work?
|
||||
|
||||
Fundamentally, this is based on [Youch](https://github.com/poppinss/youch) and [StackTracey](https://github.com/xpl/stacktracey). Neither of these projects are designed to be run in the Workers environment (although StackTracey does have support for running in the browser), and so both have been vendored in and modified. These modifications aren't upstreamable, since they involve things like changing how Youch detects node-internal modules and changing how sources are loaded.
|
||||
|
||||
### Youch changes:
|
||||
|
||||
- **"internal" module detection:** By default, Youch recognises modules with a prefix of `node:` to be internal modules. Instead, `format-error`s recognises modules with a prefix of `.internal` as internal modules.
|
||||
- **source loading:** Youch loads sources at error-page generation time, `format-error`s loads sources at error-page viewing time.
|
||||
- **cookies:** `format-error`s doesn't handle cookies.
|
||||
|
||||
### Stacktracey changes:
|
||||
|
||||
- **browser detection:** `format-error`s always reports itself to be running inside a browser, but removes references to browser globals like `window`.
|
||||
- **table rendering:** To remove the `as-table` dependency, `format-error` doesn't implement Stacktracey's `asTable` method, which Youch doesn't use.
|
||||
|
||||
## How can you get a formatted error page?
|
||||
|
||||
The first thing you'll need is an error. `format-errors` requires input conforming to the `Payload` interface below, and will reject invalid input at runtime:
|
||||
|
||||
```ts
|
||||
export interface JsonError {
|
||||
message?: string;
|
||||
name?: string;
|
||||
stack?: string;
|
||||
cause?: JsonError;
|
||||
}
|
||||
export interface Payload {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
error?: JsonError;
|
||||
}
|
||||
```
|
||||
|
||||
To call `format-errors` (assuming it's hosted at `https://format-errors.example.com`), send a `POST` request to `https://format-errors.example.com` with a body containing a serialised `Payload` object. `format-errors` will parse your input, pass it to Youch, and generate an HTML page which will be sent back as the response to the `POST` request.
|
||||
|
||||
If you don't need source context, you can stop there! However, if you'd like to get rid of the "Missing sources" message, there's a bit more complexity.
|
||||
|
||||
> _Why can't I just pass sources to `format-error` in the request body?_
|
||||
>
|
||||
> Often your sources won't be available at the location where your error is thrown. You may have access to a sourcemap, but your running code probably doesn't have access to your source code in serialised format. As such, this package is geared towards supporting use cases where you have access to your sources at the time of _viewing_ the generated error page (i.e. in-browser editors).
|
||||
|
||||
A requirement of passing sources to `format-errors` is that the generated HTML error page needs to be embedded in an iframe. Once you've done that, you can start passing sources from the embedder of the iframe into the generated HTML error page.
|
||||
|
||||
Assuming `iframeEl` is a reference to the iframe which embeds the generated error page, here's an example of how you could pass sources to the generated error page:
|
||||
|
||||
```ts
|
||||
const encoder = new TextEncoder();
|
||||
const channel = new MessageChannel();
|
||||
|
||||
iframeEl.addEventListener("load", () => {
|
||||
iframeEl.contentWindow?.postMessage("PORT", "*", [channel.port2]);
|
||||
});
|
||||
|
||||
channel.port1.onmessage = (data) => {
|
||||
if (data.type === "RequestSources") {
|
||||
const message = {
|
||||
type: "SourcesLoaded",
|
||||
body: {
|
||||
files: [
|
||||
{
|
||||
path: "index.js",
|
||||
contents: encoder.encode("console.log('hi')"),
|
||||
},
|
||||
],
|
||||
internalLines: 0,
|
||||
},
|
||||
};
|
||||
channel.port1.postMessage(
|
||||
message,
|
||||
message.body.files.map((f) => f.contents.buffer)
|
||||
);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
The interface is fairly simple. The embedder passes a `MessagePort` to the iframe on load, over which the error page requests sources. Of note: this does not currently support sourcemaps, and it's assumed that the lines within your error stack trace map to the exact line in your source.
|
||||
|
||||
There's one exception to that, to support _very_ limited source transformations, in the `internalLines` property. If you pass a value for `internalLines`, the error page will act as if any error messages from line numbers _below_ `internalLines` are internal, and shouldn't be surfaced to the user. Line numbers in error messages will be remapped so that it appears the user-visible source starts at line `0`, rather than line `internalLines`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@cloudflare/format-errors",
|
||||
"version": "0.0.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "wrangler build",
|
||||
"check:type": "tsc",
|
||||
"deploy": "wrangler deploy",
|
||||
"start": "wrangler dev",
|
||||
"test:ci": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:default",
|
||||
"@cloudflare/workers-utils": "workspace:*",
|
||||
"mustache": "^4.2.0",
|
||||
"toucan-js": "4.0.0",
|
||||
"tsconfig": "*",
|
||||
"vitest": "catalog:default",
|
||||
"wrangler": "workspace:*",
|
||||
"zod": "^3.22.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
},
|
||||
"workers-sdk": {
|
||||
"deploy": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Adapted from Stacktracey (https://github.com/xpl/stacktracey).
|
||||
*
|
||||
* For the original license, see https://github.com/xpl/stacktracey/blob/master/LICENSE
|
||||
*
|
||||
*/
|
||||
import partition from "./impl/partition";
|
||||
|
||||
const O = Object,
|
||||
isBrowser = true,
|
||||
nodeRequire = null,
|
||||
lastOf = (x) => x[x.length - 1],
|
||||
getSource = null,
|
||||
nixSlashes = (x) => x.replace(/\\/g, "/"),
|
||||
pathRoot = "/";
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
export default class StackTracey {
|
||||
constructor(input, offset) {
|
||||
const originalInput = input,
|
||||
isParseableSyntaxError =
|
||||
input && input instanceof SyntaxError && !isBrowser;
|
||||
|
||||
/* new StackTracey () */
|
||||
|
||||
if (!input) {
|
||||
input = new Error();
|
||||
offset = offset === undefined ? 1 : offset;
|
||||
}
|
||||
|
||||
/* new StackTracey (Error) */
|
||||
|
||||
if (input instanceof Error) {
|
||||
input = input.stack || "";
|
||||
}
|
||||
|
||||
/* new StackTracey (string) */
|
||||
|
||||
if (typeof input === "string") {
|
||||
input = this.rawParse(input)
|
||||
.slice(offset)
|
||||
.map((x) => this.extractEntryMetadata(x));
|
||||
}
|
||||
|
||||
/* new StackTracey (array) */
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
if (isParseableSyntaxError) {
|
||||
const rawLines = nodeRequire("util").inspect(originalInput).split("\n"),
|
||||
fileLine = rawLines[0].split(":"),
|
||||
line = fileLine.pop(),
|
||||
file = fileLine.join(":");
|
||||
|
||||
if (file) {
|
||||
input.unshift({
|
||||
file: nixSlashes(file),
|
||||
line: line,
|
||||
column: (rawLines[2] || "").indexOf("^") + 1,
|
||||
sourceLine: rawLines[1],
|
||||
callee: "(syntax error)",
|
||||
syntaxError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.items = input;
|
||||
} else {
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
|
||||
extractEntryMetadata(e) {
|
||||
const decomposedPath = this.decomposePath(e.file || "");
|
||||
const fileRelative = decomposedPath[0];
|
||||
const externalDomain = decomposedPath[1];
|
||||
|
||||
return O.assign(e, {
|
||||
calleeShort: e.calleeShort || lastOf((e.callee || "").split(".")),
|
||||
fileRelative: fileRelative,
|
||||
fileShort: this.shortenPath(fileRelative),
|
||||
fileName: lastOf((e.file || "").split("/")),
|
||||
thirdParty: this.isThirdParty(fileRelative, externalDomain) && !e.index,
|
||||
externalDomain: externalDomain,
|
||||
});
|
||||
}
|
||||
|
||||
shortenPath(relativePath) {
|
||||
return relativePath
|
||||
.replace(/^node_modules\//, "")
|
||||
.replace(/^webpack\/bootstrap\//, "")
|
||||
.replace(/^__parcel_source_root\//, "");
|
||||
}
|
||||
|
||||
decomposePath(fullPath) {
|
||||
let result = fullPath;
|
||||
|
||||
if (isBrowser) result = result.replace(pathRoot, "");
|
||||
|
||||
const externalDomainMatch = result.match(
|
||||
/^(http|https)\:\/\/?([^\/]+)\/(.*)/
|
||||
);
|
||||
const externalDomain = externalDomainMatch
|
||||
? externalDomainMatch[2]
|
||||
: undefined;
|
||||
result = externalDomainMatch ? externalDomainMatch[3] : result;
|
||||
|
||||
if (!isBrowser) result = nodeRequire("path").relative(pathRoot, result);
|
||||
|
||||
return [
|
||||
nixSlashes(result).replace(/^.*\:\/\/?\/?/, ""), // cut webpack:/// and webpack:/ things
|
||||
externalDomain,
|
||||
];
|
||||
}
|
||||
|
||||
isThirdParty(relativePath, externalDomain) {
|
||||
return (
|
||||
externalDomain ||
|
||||
relativePath[0] === "~" || // webpack-specific heuristic
|
||||
relativePath[0] === "/" || // external source
|
||||
relativePath.indexOf("node_modules") === 0 ||
|
||||
relativePath.indexOf("webpack/bootstrap") === 0
|
||||
);
|
||||
}
|
||||
|
||||
rawParse(str) {
|
||||
const lines = (str || "").split("\n");
|
||||
|
||||
const entries = lines.map((line) => {
|
||||
line = line.trim();
|
||||
|
||||
let callee,
|
||||
fileLineColumn = [],
|
||||
native,
|
||||
planA,
|
||||
planB;
|
||||
|
||||
if (
|
||||
(planA = line.match(/at (.+) \(eval at .+ \((.+)\), .+\)/)) || // eval calls
|
||||
(planA = line.match(/at (.+) \((.+)\)/)) ||
|
||||
(line.slice(0, 3) !== "at " && (planA = line.match(/(.*)@(.*)/)))
|
||||
) {
|
||||
callee = planA[1];
|
||||
native = planA[2] === "native";
|
||||
fileLineColumn = (
|
||||
planA[2].match(/(.*):(\d+):(\d+)/) ||
|
||||
planA[2].match(/(.*):(\d+)/) ||
|
||||
[]
|
||||
).slice(1);
|
||||
} else if ((planB = line.match(/^(at\s+)*(.+):(\d+):(\d+)/))) {
|
||||
fileLineColumn = planB.slice(2);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/* Detect things like Array.reduce
|
||||
TODO: detect more built-in types */
|
||||
|
||||
if (callee && !fileLineColumn[0]) {
|
||||
const type = callee.split(".")[0];
|
||||
if (type === "Array") {
|
||||
native = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
beforeParse: line,
|
||||
callee: callee || "",
|
||||
index: isBrowser && fileLineColumn[0] === "/",
|
||||
native: native || false,
|
||||
file: nixSlashes(fileLineColumn[0] || ""),
|
||||
line: parseInt(fileLineColumn[1] || "", 10) || undefined,
|
||||
column: parseInt(fileLineColumn[2] || "", 10) || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return entries.filter((x) => x !== undefined);
|
||||
}
|
||||
|
||||
withSourceAt(i) {
|
||||
return this.items[i] && this.withSource(this.items[i]);
|
||||
}
|
||||
|
||||
withSourceAsyncAt(i) {
|
||||
return this.items[i] && this.withSourceAsync(this.items[i]);
|
||||
}
|
||||
|
||||
withSource(loc) {
|
||||
if (this.shouldSkipResolving(loc)) {
|
||||
return loc;
|
||||
} else {
|
||||
let resolved = getSource(loc.file || "").resolve(loc);
|
||||
|
||||
if (!resolved.sourceFile) {
|
||||
return loc;
|
||||
}
|
||||
|
||||
return this.withSourceResolved(loc, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
withSourceAsync(loc) {
|
||||
if (this.shouldSkipResolving(loc)) {
|
||||
return Promise.resolve(loc);
|
||||
} else {
|
||||
return getSource
|
||||
.async(loc.file || "")
|
||||
.then((x) => x.resolve(loc))
|
||||
.then((resolved) => this.withSourceResolved(loc, resolved))
|
||||
.catch((e) =>
|
||||
this.withSourceResolved(loc, { error: e, sourceLine: "" })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
shouldSkipResolving(loc) {
|
||||
return (
|
||||
loc.sourceFile || loc.error || (loc.file && loc.file.indexOf("<") >= 0)
|
||||
); // skip things like <anonymous> and stuff that was already fetched
|
||||
}
|
||||
|
||||
withSourceResolved(loc, resolved) {
|
||||
if (resolved.sourceFile && !resolved.sourceFile.error) {
|
||||
resolved.file = nixSlashes(resolved.sourceFile.path);
|
||||
resolved = this.extractEntryMetadata(resolved);
|
||||
}
|
||||
|
||||
if (resolved.sourceLine.includes("// @hide")) {
|
||||
resolved.sourceLine = resolved.sourceLine.replace("// @hide", "");
|
||||
resolved.hide = true;
|
||||
}
|
||||
|
||||
if (
|
||||
resolved.sourceLine.includes("__webpack_require__") || // webpack-specific heuristics
|
||||
resolved.sourceLine.includes("/******/ ({")
|
||||
) {
|
||||
resolved.thirdParty = true;
|
||||
}
|
||||
|
||||
return O.assign({ sourceLine: "" }, loc, resolved);
|
||||
}
|
||||
|
||||
withSources() {
|
||||
return this.map((x) => this.withSource(x));
|
||||
}
|
||||
|
||||
withSourcesAsync() {
|
||||
return Promise.all(this.items.map((x) => this.withSourceAsync(x))).then(
|
||||
(items) => new StackTracey(items)
|
||||
);
|
||||
}
|
||||
|
||||
mergeRepeatedLines() {
|
||||
return new StackTracey(
|
||||
partition(this.items, (e) => e.file + e.line).map((group) => {
|
||||
return group.items.slice(1).reduce(
|
||||
(memo, entry) => {
|
||||
memo.callee =
|
||||
(memo.callee || "<anonymous>") +
|
||||
" → " +
|
||||
(entry.callee || "<anonymous>");
|
||||
memo.calleeShort =
|
||||
(memo.calleeShort || "<anonymous>") +
|
||||
" → " +
|
||||
(entry.calleeShort || "<anonymous>");
|
||||
return memo;
|
||||
},
|
||||
O.assign({}, group.items[0])
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clean() {
|
||||
const s = this.withSources().mergeRepeatedLines();
|
||||
return s.filter(s.isClean.bind(s));
|
||||
}
|
||||
|
||||
cleanAsync() {
|
||||
return this.withSourcesAsync().then((s) => {
|
||||
s = s.mergeRepeatedLines();
|
||||
return s.filter(s.isClean.bind(s));
|
||||
});
|
||||
}
|
||||
|
||||
isClean(entry, index) {
|
||||
return index === 0 || !(entry.thirdParty || entry.hide || entry.native);
|
||||
}
|
||||
|
||||
at(i) {
|
||||
return O.assign(
|
||||
{
|
||||
beforeParse: "",
|
||||
callee: "<???>",
|
||||
index: false,
|
||||
native: false,
|
||||
file: "<???>",
|
||||
line: 0,
|
||||
column: 0,
|
||||
},
|
||||
this.items[i]
|
||||
);
|
||||
}
|
||||
|
||||
maxColumnWidths() {
|
||||
return {
|
||||
callee: 30,
|
||||
file: 60,
|
||||
sourceLine: 80,
|
||||
};
|
||||
}
|
||||
|
||||
static resetCache() {
|
||||
getSource.resetCache();
|
||||
getSource.async.resetCache();
|
||||
}
|
||||
|
||||
static locationsEqual(a, b) {
|
||||
return a.file === b.file && a.line === b.line && a.column === b.column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Array methods
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
["map", "filter", "slice", "concat"].forEach((method) => {
|
||||
StackTracey.prototype[method] = function (/*...args */) {
|
||||
// no support for ...args in Node v4 :(
|
||||
return new StackTracey(this.items[method].apply(this.items, arguments));
|
||||
};
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
@@ -0,0 +1,314 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* Adapted from Youch (https://github.com/poppinss/youch). See below for original license:
|
||||
*
|
||||
* (c) Harminder Virk <virk@adonisjs.com>
|
||||
*
|
||||
* For the full copyright and license information, see https://github.com/poppinss/youch/blob/develop/LICENSE.md
|
||||
*/
|
||||
import Mustache from "mustache";
|
||||
import viewTemplate from "./error.compiled.mustache";
|
||||
import StackTracey from "./Stacktracey";
|
||||
|
||||
export default class Youch {
|
||||
constructor(error, request, options = {}) {
|
||||
this.options = options;
|
||||
this.options.postLines = options.postLines || 5;
|
||||
this.options.preLines = options.preLines || 5;
|
||||
|
||||
this._filterHeaders = ["cookie", "connection"];
|
||||
this.error = error;
|
||||
this.request = request;
|
||||
this.links = [];
|
||||
this.showAllFrames = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the source code for a given file. It unable to
|
||||
* read file it resolves the promise with a null.
|
||||
*
|
||||
* @param {Object} frame
|
||||
* @return {Promise}
|
||||
*/
|
||||
_getFrameSource(frame) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the error stack and returns serialized
|
||||
* frames out of it.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
_parseError() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stack = new StackTracey(this.error);
|
||||
Promise.all(
|
||||
stack.items.map(async (frame) => {
|
||||
if (this._isNode(frame)) {
|
||||
return Promise.resolve(frame);
|
||||
}
|
||||
return this._getFrameSource(frame).then((context) => {
|
||||
frame.context = context;
|
||||
return frame;
|
||||
});
|
||||
})
|
||||
)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the context with code for a given
|
||||
* frame.
|
||||
*
|
||||
* @param {Object}
|
||||
* @return {Object}
|
||||
*/
|
||||
_getContext(frame) {
|
||||
if (!frame.context) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
start: frame.line - (frame.context.pre || []).length,
|
||||
pre: frame.context.pre.join("\n"),
|
||||
line: frame.context.line,
|
||||
post: frame.context.post.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns classes to be used inside HTML when
|
||||
* displaying the frames list.
|
||||
*
|
||||
* @param {Object}
|
||||
* @param {Number}
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
_getDisplayClasses(frame, index) {
|
||||
const classes = [];
|
||||
if (index === 0) {
|
||||
classes.push("active");
|
||||
}
|
||||
|
||||
if (!frame.isApp) {
|
||||
classes.push("native-frame");
|
||||
}
|
||||
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles the view using HTML
|
||||
*
|
||||
* @param {String}
|
||||
* @param {Object}
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
_compileView(view, data) {
|
||||
return Mustache.render(view, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes frame to a usable error object.
|
||||
*
|
||||
* @param {Object}
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
_serializeFrame(frame) {
|
||||
return {
|
||||
file: frame.file,
|
||||
filePath: frame.file,
|
||||
line: frame.line,
|
||||
callee: frame.callee,
|
||||
calleeShort: frame.calleeShort,
|
||||
column: frame.column,
|
||||
context: this._getContext(frame),
|
||||
isModule: frame.thirdParty,
|
||||
isNative: frame.native,
|
||||
isApp: this._isApp(frame),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether frame belongs to nodejs
|
||||
* or not.
|
||||
*
|
||||
* @return {Boolean} [description]
|
||||
*/
|
||||
_isNode(frame) {
|
||||
if (frame.native) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const filename = frame.file || "";
|
||||
if (filename.startsWith(".internal")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
// return !path.isAbsolute(filename) && filename[0] !== '.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether code belongs to the app
|
||||
* or not.
|
||||
*
|
||||
* @return {Boolean} [description]
|
||||
*/
|
||||
_isApp(frame) {
|
||||
return !this._isNode(frame) && !this._isNodeModule(frame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether frame belongs to a node_module or
|
||||
* not
|
||||
*
|
||||
* @method _isNodeModule
|
||||
*
|
||||
* @param {Object} frame
|
||||
*
|
||||
* @return {Boolean}
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_isNodeModule(frame) {
|
||||
return (frame.file || "").indexOf("node_modules/") > -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes stack to Mustache friendly object to
|
||||
* be used within the view. Optionally can pass
|
||||
* a callback to customize the frames output.
|
||||
*
|
||||
* @param {Object}
|
||||
* @param {Function} [callback]
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
_serializeData(stack, callback) {
|
||||
callback = callback || this._serializeFrame.bind(this);
|
||||
return {
|
||||
message: this.error.message,
|
||||
help: this.error.help,
|
||||
cause: this.error.cause,
|
||||
name: this.error.name,
|
||||
status: this.error.status,
|
||||
frames:
|
||||
stack instanceof Array === true
|
||||
? stack.filter((frame) => frame.file).map(callback)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a serialized object with important
|
||||
* information.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
_serializeRequest() {
|
||||
const headers = [];
|
||||
|
||||
Object.keys(this.request.headers).forEach((key) => {
|
||||
if (this._filterHeaders.indexOf(key) > -1) {
|
||||
return;
|
||||
}
|
||||
headers.push({
|
||||
key: key.toUpperCase(),
|
||||
value: this.request.headers[key],
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
url: this.request.url,
|
||||
httpVersion: this.request.httpVersion,
|
||||
method: this.request.method,
|
||||
connection: this.request.headers.connection,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the link `callback` which
|
||||
* will be processed when rendering
|
||||
* the HTML view.
|
||||
*
|
||||
* @param {Function} callback
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
addLink(callback) {
|
||||
if (typeof callback === "function") {
|
||||
this.links.push(callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
throw new Error('Pass a callback function to "addLink"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the state of showing all frames by default
|
||||
*/
|
||||
toggleShowAllFrames() {
|
||||
this.showAllFrames = !this.showAllFrames;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns error stack as JSON.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
toJSON() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._parseError()
|
||||
.then((stack) => {
|
||||
resolve({
|
||||
error: this._serializeData(stack),
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTML representation of the error stack
|
||||
* by parsing the stack into frames and getting
|
||||
* important info out of it.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
toHTML() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._parseError()
|
||||
.then((stack) => {
|
||||
const data = this._serializeData(stack, (frame, index) => {
|
||||
const serializedFrame = this._serializeFrame(frame);
|
||||
serializedFrame.classes = this._getDisplayClasses(
|
||||
serializedFrame,
|
||||
index
|
||||
);
|
||||
return serializedFrame;
|
||||
});
|
||||
|
||||
if (this.request) {
|
||||
data.request = this._serializeRequest();
|
||||
}
|
||||
|
||||
data.links = this.links.map((renderLink) => renderLink(data));
|
||||
data.loadFA = !!data.links.find((link) => link.includes("fa-"));
|
||||
data.showAllFrames = this.showAllFrames;
|
||||
|
||||
return resolve(this._compileView(viewTemplate, data));
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, vi } from "vitest";
|
||||
import type { Payload } from "../index";
|
||||
|
||||
vi.mock("toucan-js", () => {
|
||||
return {
|
||||
Toucan: vi.fn().mockImplementation(() => ({
|
||||
captureException: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe("handlePrettyErrorRequest", () => {
|
||||
it("should propagate async rejections to callers", async ({ expect }) => {
|
||||
// Mock Youch to throw asynchronously
|
||||
vi.doMock("../Youch", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(function () {
|
||||
return {
|
||||
addLink: vi.fn(),
|
||||
toHTML: vi.fn().mockRejectedValue(new Error("Youch async error")),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// handlePrettyErrorRequest is async — if it's not awaited, async
|
||||
// rejections (e.g. from Youch.toHTML()) won't be caught by a
|
||||
// surrounding try/catch, leading to unhandled promise rejections.
|
||||
// This test verifies that the function properly rejects so that
|
||||
// callers who `await` it can catch the error.
|
||||
const { handlePrettyErrorRequest } = await import("../index");
|
||||
|
||||
const payload: Payload = {
|
||||
url: "https://example.com",
|
||||
method: "GET",
|
||||
headers: { "content-type": "text/html" },
|
||||
error: {
|
||||
message: "Test error",
|
||||
name: "Error",
|
||||
stack: "Error: Test error\n at test:1:1",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(handlePrettyErrorRequest(payload)).rejects.toThrow(
|
||||
"Youch async error"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviveError", () => {
|
||||
it("should revive a plain Error", async ({ expect }) => {
|
||||
const { reviveError } = await import("../index");
|
||||
const error = reviveError({
|
||||
message: "test",
|
||||
name: "Error",
|
||||
stack: "Error: test\n at foo:1:1",
|
||||
});
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toBe("test");
|
||||
expect(error.name).toBe("Error");
|
||||
expect(error.stack).toBe("Error: test\n at foo:1:1");
|
||||
});
|
||||
|
||||
it("should revive a TypeError", async ({ expect }) => {
|
||||
const { reviveError } = await import("../index");
|
||||
const error = reviveError({
|
||||
message: "x is not a function",
|
||||
name: "TypeError",
|
||||
});
|
||||
expect(error).toBeInstanceOf(TypeError);
|
||||
expect(error.message).toBe("x is not a function");
|
||||
});
|
||||
|
||||
it("should revive an error with a cause", async ({ expect }) => {
|
||||
const { reviveError } = await import("../index");
|
||||
const error = reviveError({
|
||||
message: "outer",
|
||||
name: "Error",
|
||||
cause: {
|
||||
message: "inner",
|
||||
name: "RangeError",
|
||||
},
|
||||
});
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toBe("outer");
|
||||
expect(error.cause).toBeInstanceOf(RangeError);
|
||||
expect((error.cause as Error).message).toBe("inner");
|
||||
});
|
||||
|
||||
it("should fall back to Error for unknown error names", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { reviveError } = await import("../index");
|
||||
const error = reviveError({
|
||||
message: "custom",
|
||||
name: "CustomError",
|
||||
});
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe("CustomError");
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = (arr_, pred) => {
|
||||
const arr = arr_ || [],
|
||||
spans = [];
|
||||
|
||||
let span = { label: undefined, items: [arr.first] };
|
||||
|
||||
arr.forEach((x) => {
|
||||
const label = pred(x);
|
||||
|
||||
if (span.label !== label && span.items.length) {
|
||||
spans.push((span = { label: label, items: [x] }));
|
||||
} else {
|
||||
span.items.push(x);
|
||||
}
|
||||
});
|
||||
|
||||
return spans;
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import { MetricsRegistry } from "@cloudflare/workers-utils/prometheus-metrics";
|
||||
import { Toucan } from "toucan-js";
|
||||
import { z } from "zod";
|
||||
import Youch from "./Youch";
|
||||
|
||||
export interface Env {
|
||||
PROMETHEUS_TOKEN: string;
|
||||
SENTRY_ACCESS_CLIENT_SECRET: string;
|
||||
SENTRY_ACCESS_CLIENT_ID: string;
|
||||
SENTRY_DSN: string;
|
||||
}
|
||||
export interface JsonError {
|
||||
message?: string;
|
||||
name?: string;
|
||||
stack?: string;
|
||||
cause?: JsonError;
|
||||
}
|
||||
export interface Payload {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
error?: JsonError;
|
||||
}
|
||||
export const JsonErrorSchema: z.ZodType<JsonError> = z.lazy(() =>
|
||||
z.object({
|
||||
message: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
stack: z.string().optional(),
|
||||
cause: JsonErrorSchema.optional(),
|
||||
})
|
||||
);
|
||||
|
||||
export const PayloadSchema = z.object({
|
||||
url: z.string().optional(),
|
||||
method: z.string().optional(),
|
||||
headers: z.record(z.string()),
|
||||
error: JsonErrorSchema.optional(),
|
||||
});
|
||||
|
||||
interface StandardErrorConstructor {
|
||||
new (message?: string, options?: { cause?: Error }): Error;
|
||||
}
|
||||
const ALLOWED_ERROR_SUBCLASS_CONSTRUCTORS: StandardErrorConstructor[] = [
|
||||
EvalError,
|
||||
RangeError,
|
||||
ReferenceError,
|
||||
SyntaxError,
|
||||
TypeError,
|
||||
URIError,
|
||||
];
|
||||
export function reviveError(jsonError: JsonError): Error {
|
||||
// At a high level, this function takes a JSON-serialisable representation of
|
||||
// an `Error`, and converts it to an `Error`. `Error`s may have `cause`s, so
|
||||
// we need to do this recursively.
|
||||
let cause: Error | undefined;
|
||||
if (jsonError.cause !== undefined) {
|
||||
cause = reviveError(jsonError.cause);
|
||||
}
|
||||
|
||||
// If this is one of the built-in error types, construct an instance of that.
|
||||
// For example, if we threw a `TypeError` in the Worker, we'd like to
|
||||
// construct a `TypeError` here, so it looks like the error has been thrown
|
||||
// through a regular function call, not an HTTP request (i.e. we want
|
||||
// `instanceof TypeError` to pass in Node for `TypeError`s thrown in Workers).
|
||||
let ctor: StandardErrorConstructor = Error;
|
||||
if (jsonError.name !== undefined && jsonError.name in globalThis) {
|
||||
const maybeCtor = (globalThis as Record<string, unknown>)[
|
||||
jsonError.name
|
||||
] as StandardErrorConstructor;
|
||||
if (ALLOWED_ERROR_SUBCLASS_CONSTRUCTORS.includes(maybeCtor)) {
|
||||
ctor = maybeCtor;
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the error, copying over the correct name and stack trace.
|
||||
// Because constructing an `Error` captures the stack trace at point of
|
||||
// construction, we override the stack trace to the one from the Worker in the
|
||||
// JSON-serialised error.
|
||||
const error = new ctor(jsonError.message, { cause });
|
||||
if (jsonError.name !== undefined) {
|
||||
error.name = jsonError.name;
|
||||
}
|
||||
error.stack = jsonError.stack;
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function handlePrettyErrorRequest({
|
||||
error: jsonError,
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
}: Payload): Promise<Response> {
|
||||
// Parse and validate the error we've been given from user code
|
||||
const caught = JsonErrorSchema.parse(jsonError);
|
||||
|
||||
// Convert the error into a regular `Error` object and try to source-map it.
|
||||
// We need to give `name`, `message` and `stack` to Youch, but StackTracy,
|
||||
// Youch's dependency for parsing `stack`s, will only extract `stack` from
|
||||
// an object if it's an `instanceof Error`.
|
||||
const error = reviveError(caught);
|
||||
|
||||
// Log source-mapped error to console if logging enabled
|
||||
|
||||
// Lazily import `youch` when required
|
||||
// `cause` is usually more useful than the error itself, display that instead
|
||||
// TODO(someday): would be nice if we could display both
|
||||
const youch = new Youch(error.cause ?? error, {
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
});
|
||||
youch.addLink(() => {
|
||||
return [
|
||||
'<a href="https://developers.cloudflare.com/workers/" target="_blank" style="text-decoration:none">📚 Workers Docs</a>',
|
||||
'<a href="https://discord.cloudflare.com" target="_blank" style="text-decoration:none">💬 Workers Discord</a>',
|
||||
].join("");
|
||||
});
|
||||
return new Response(await youch.toHTML(), {
|
||||
headers: { "Content-Type": "text/html;charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(
|
||||
request: Request,
|
||||
env: Env,
|
||||
ctx: ExecutionContext
|
||||
): Promise<Response> {
|
||||
const registry = new MetricsRegistry();
|
||||
const requestCounter = registry.createCounter(
|
||||
"devprod_format_errors_request_total",
|
||||
"Request counter for DevProd's format-errors service"
|
||||
);
|
||||
requestCounter.inc();
|
||||
|
||||
const sentry = new Toucan({
|
||||
dsn: env.SENTRY_DSN,
|
||||
context: ctx,
|
||||
request,
|
||||
requestDataOptions: {
|
||||
allowedHeaders: [
|
||||
"user-agent",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"cf-ray",
|
||||
"content-length",
|
||||
"content-type",
|
||||
"host",
|
||||
],
|
||||
},
|
||||
transportOptions: {
|
||||
headers: {
|
||||
"CF-Access-Client-ID": env.SENTRY_ACCESS_CLIENT_ID,
|
||||
"CF-Access-Client-Secret": env.SENTRY_ACCESS_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Validate payload outside of Sentry/metrics reporting
|
||||
let payload: Payload;
|
||||
try {
|
||||
payload = PayloadSchema.parse(await request.json());
|
||||
} catch {
|
||||
return new Response("Invalid payload", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
return await handlePrettyErrorRequest(payload);
|
||||
} catch (e) {
|
||||
sentry.captureException(e);
|
||||
const errorCounter = registry.createCounter(
|
||||
"devprod_format_errors_error_total",
|
||||
"Error counter for DevProd's format-errors service"
|
||||
);
|
||||
errorCounter.inc();
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: "UnexpectedError",
|
||||
message: "Something went wrong",
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
ctx.waitUntil(
|
||||
fetch("https://workers-logging.cfdata.org/prometheus", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.PROMETHEUS_TOKEN}`,
|
||||
},
|
||||
body: registry.metrics(),
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Language and Environment */
|
||||
"target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"lib": [
|
||||
"es2022"
|
||||
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
"jsx": "react-jsx" /* Specify what JSX code is generated. */,
|
||||
|
||||
/* Modules */
|
||||
"module": "es2022" /* Specify what module code is generated. */,
|
||||
"moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||
"types": [
|
||||
"@cloudflare/workers-types"
|
||||
] /* Specify type package names to be included without being referenced in a source file. */,
|
||||
"resolveJsonModule": true /* Enable importing .json files */,
|
||||
/* JavaScript Support */
|
||||
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
|
||||
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
|
||||
/* Emit */
|
||||
"noEmit": true /* Disable emitting files from a compilation. */,
|
||||
|
||||
/* Interop Constraints */
|
||||
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
|
||||
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
/* Completeness */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "http://turbo.build/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": ["dist/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineProject, mergeConfig } from "vitest/config";
|
||||
import configShared from "../../vitest.shared";
|
||||
|
||||
export default mergeConfig(
|
||||
configShared,
|
||||
defineProject({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.{test,spec}.{ts,js}"],
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "format-errors",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2023-03-30",
|
||||
"workers_dev": false,
|
||||
"account_id": "e35fd947284363a46fd7061634477114",
|
||||
"rules": [
|
||||
{
|
||||
"type": "Text",
|
||||
"globs": ["**/*.mustache"],
|
||||
"fallthrough": false,
|
||||
},
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "format-errors.devprod.cloudflare.dev",
|
||||
"custom_domain": true,
|
||||
},
|
||||
],
|
||||
"vars": {
|
||||
"SENTRY_DSN": "https://1ff9df95733c4e7d9c31dc13ab05d44a@sentry10.cfdata.org/891",
|
||||
},
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user