mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent runs retain execution results and output in the instance. > - Operators can report run failures to Sentry for investigation. > - A backend Sentry DSN currently enables those reports with no separate choice. > - A repair bot also needs the exact instance, company, agent, and saved run. > - This pull request adds an explicit reporting mode and authenticated run references. > - The benefit is that operators can investigate every failure on selected deployments while keeping other deployments quiet. ## Linked Issues or Issue Description **What existing behavior does this improve?** Optional server reporting of terminal agent run failures to Sentry. **Current behavior** A backend DSN enables run failure reports. Known account, quota, and connection failures are excluded. Reports omit company and agent references. Runs without a task are skipped. Tool-name collision errors omit the conflicting bindings. **Proposed behavior** Set `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected` to retain the existing filters. Set it to `all` to report every failed or timed-out run, including known setup and quota failures. The default is `off`. Expected stops stay excluded. Each report supplies identifiers and API paths that a bot can use with its own authorized Paperclip connection. **Reason and benefit** Operators can enable broad failure reporting on one staging deployment. A bot can fetch the saved run, events, and logs instead of relying on a short error label or copying complete transcripts into Sentry. **Breaking changes** A backend DSN alone no longer enables run failure reports. Existing operators must set `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected` to retain the previous policy. Ordinary server exception capture remains enabled by the DSN. Failure grouping gains the phase and original cause code when available. Refs #16281, #16297, #16298. These cover related tool discovery and failure diagnostics. This change retains their existing boundaries and does not depend on their open changes. ## What Changed - Add `off`, `unexpected`, and `all` modes. Missing and invalid values keep reporting off before diagnostic reads. - Keep the Sentry backend DSN and audited optional SDK requirements. - Include company, agent, run, and optional task identifiers. Include versioned paths for the saved run, events, log, and task. Add absolute run URLs only for a valid public HTTP(S) origin. - Report company runs without a task. Prefer the first-class issue binding when selecting the task reference. - Select saved native provider/model settings, timestamps, and original failure codes. Preserve gateway reason codes through redacted exception projection. - Group by error code, adapter, and available phase/cause. Keep run IDs and error text out of grouping. - Include the tool names and connection bindings in collision errors without copying tool schemas or provider metadata. - Document configuration, upgrade behavior, bot access, redaction, retention, and best-effort delivery. ## Verification - All 56 checks passed for `cccb47a721eaa1cc36e63278fc177b9101477541`. Two unrelated Storybook jobs were skipped by their path filters. No checks remain pending. - Complete [Paperclip CI run](https://github.com/paperclipai/paperclip/actions/runs/38591422284): passed. This covers general and serialized tests, Runner checks, browser E2E shards, typecheck, build, release-registry checks, canary dry run, and Docker checks. - Greptile reviewed that exact head: 5/5, with no unresolved threads or actionable findings. Superagent, Socket, and Snyk checks passed. - Local focused reporting, diagnostics, collision, and real SDK contract tests: 410 passed across five suites. This includes the external-wake filter in both reporting modes. - The audited `@sentry/node@10.71.0` tests use an in-memory transport. No events were sent to Sentry. - `pnpm -r typecheck` and `pnpm build`: passed locally after the final rebase. - `node scripts/check-module-boundaries.mjs` and `git diff --check`: passed. - The full local `pnpm test:run` command was stopped to avoid duplicating the complete CI shards. It did not finish locally. The full suite passed in CI. ## Risks - The new default disables existing run failure exports until the operator selects a mode. The upgrade setting is documented. - `all` can increase event volume and include known user-action failures. Secret redaction and diagnostic limits still apply. - Reporting remains asynchronous and best effort. This PR adds no durable delivery queue and does not install a bot or Sentry alert rule. - API references grant no authority. Existing company, private-task, and run-output checks still apply. Complete transcripts stay in Paperclip. - This changes optional Sentry error reporting. It changes neither first-party Telemetry nor OpenTelemetry span exports. It adds no database migration. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, shell, file editing, and test execution. The exact backend deployment ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
410 lines
17 KiB
TypeScript
410 lines
17 KiB
TypeScript
// Optional Sentry error monitoring for the server process.
|
|
//
|
|
// Activated only when the backend DSN resolves to a value — see
|
|
// `resolveSentryDsns` in `sentry-dsn.ts` for the precedence between
|
|
// `SENTRY_DSN_BACKEND` and the legacy `SENTRY_DSN` fallback. When it
|
|
// resolves to `null`, no Sentry package is loaded at all.
|
|
//
|
|
// The import is dynamic and the package is an optional runtime dependency —
|
|
// operators who want server-side error monitoring install `@sentry/node`
|
|
// themselves. That keeps Sentry off the default dependency graph and avoids
|
|
// forcing a lockfile bump for an opt-in feature. This gate mirrors the
|
|
// OpenTelemetry gate in `instrumentation.ts`.
|
|
//
|
|
// OpenTelemetry keeps ownership of trace setup: the initializer passes
|
|
// `skipOpenTelemetrySetup: true` and `tracesSampleRate: 0`, so this module
|
|
// adds error monitoring only and starts no span or trace behavior of its
|
|
// own.
|
|
//
|
|
// `serverName`: the initializer sets this to the host name of the process,
|
|
// with `os.hostname()`. The `@sentry/node` client already falls back to the
|
|
// same host name when the caller omits this option, so this line makes an
|
|
// existing default explicit instead of changing captured event content. An
|
|
// explicit value stays correct if a future SDK version changes its default.
|
|
//
|
|
// An operator can still send a different value in place of the host name.
|
|
// When the environment variable `SENTRY_NAME` holds a non-empty string, the
|
|
// initializer uses that value instead. This keeps the same order the
|
|
// `@sentry/node` client itself uses when the caller omits `serverName`.
|
|
//
|
|
// Default-integration privacy note: `sendDefaultPii: false` filters values
|
|
// by name, inside the `RequestData` integration only. Three other default
|
|
// integrations copy raw values past that filter, so the initializer removes
|
|
// or narrows them with built-in Sentry options — no custom filter code:
|
|
// - `Console` turns a `console.*` call into a breadcrumb with the raw
|
|
// arguments. The initializer drops it.
|
|
// - `ContextLines` reads local source lines around each stack frame off
|
|
// the host disk. The initializer drops it.
|
|
// - `Http` records a breadcrumb for each outbound request, with its URL
|
|
// and query string. The initializer keeps the integration (`RequestData`
|
|
// and request isolation need it) and turns the breadcrumb off with the
|
|
// integration's own `breadcrumbs` option.
|
|
//
|
|
// `onUnhandledRejectionIntegration` defaults to `mode: "warn"`, which
|
|
// registers a `process.on("unhandledRejection")` listener. Node cancels its
|
|
// own crash-on-unhandled-rejection behavior when any listener is registered.
|
|
// The server relies on that crash today, so the initializer passes
|
|
// `mode: "strict"`: Sentry still captures the event, then exits the process,
|
|
// so the existing crash-and-restart behavior stays.
|
|
//
|
|
// Before it imports the package, the bootstrap checks the installed
|
|
// `@sentry/node` version against the exact version this manifest's
|
|
// `peerDependencies` declares — the same audited version documented in
|
|
// `doc/observability.md`. A missing or a mismatched version logs one
|
|
// diagnostic and leaves the server running without error monitoring; it
|
|
// never throws. This gate mirrors the OpenTelemetry gate in
|
|
// `instrumentation.ts`.
|
|
|
|
import os from "node:os";
|
|
import { AdapterStopTimeoutError } from "./services/adapter-stop-timeout.js";
|
|
import { CloudPortfolioError } from "./services/cloud-portfolio-error.js";
|
|
import type { RunFailureDiagnostics } from "./services/run-failure-diagnostics.js";
|
|
import { readBuildCommit } from "./build-commit.js";
|
|
import { checkExactPeerVersions } from "./peer-version-check.js";
|
|
import { resolveSentryDsns } from "./sentry-dsn.js";
|
|
import { readRunFailureReportingMode } from "./sentry-run-failure-policy.js";
|
|
|
|
const { backend: dsn, legacyFallbackUsed } = resolveSentryDsns();
|
|
|
|
if (legacyFallbackUsed) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
"[paperclip] SENTRY_DSN_FRONTEND or SENTRY_DSN_BACKEND is not set. " +
|
|
"The server uses the legacy SENTRY_DSN value for the affected " +
|
|
"component. Set SENTRY_DSN_FRONTEND and SENTRY_DSN_BACKEND to send " +
|
|
"each component to its own Sentry project.",
|
|
);
|
|
}
|
|
|
|
/** Event-local context accepted by the optional Sentry package. */
|
|
interface SentryCaptureContext {
|
|
tags: Record<string, string>;
|
|
contexts: Record<string, Record<string, unknown>>;
|
|
fingerprint: string[];
|
|
}
|
|
|
|
/** The subset of the `@sentry/node` client surface this gate calls. */
|
|
interface SentryHandle {
|
|
captureException(error: unknown, context?: SentryCaptureContext): string;
|
|
close(timeout?: number): Promise<boolean>;
|
|
}
|
|
|
|
let sentryHandle: SentryHandle | null = null;
|
|
let shutdownPromise: Promise<void> | null = null;
|
|
|
|
/**
|
|
* Resolves once the Sentry SDK has started, or once bootstrap has failed and
|
|
* logged, or at once when the backend DSN resolves to `null`. No caller
|
|
* needs to await this before calling `captureException` — it is a no-op
|
|
* until ready — but `index.ts` awaits it at startup so the first real error
|
|
* has a live client.
|
|
*/
|
|
export const sentryReady: Promise<void> = dsn ? bootstrapSentry(dsn) : Promise.resolve();
|
|
|
|
/**
|
|
* Report an error to Sentry. A no-op before the gate opens, when the gate
|
|
* never opens (the backend DSN resolves to `null`), or when bootstrap
|
|
* failed. Never throws — observability must not change control flow.
|
|
*/
|
|
export function captureException(error: unknown): void {
|
|
if (!sentryHandle) return;
|
|
try {
|
|
if (error instanceof AdapterStopTimeoutError) {
|
|
// Event-local, fixed-shape context: no ambient scope or raw error fields.
|
|
const exception = new Error(error.message);
|
|
exception.stack = error.stack;
|
|
sentryHandle.captureException(exception, {
|
|
tags: { error_code: "adapter_stop_unconfirmed" },
|
|
contexts: { adapter_stop: { ...error.diagnostics } },
|
|
fingerprint: ["{{ default }}"],
|
|
});
|
|
} else if (error instanceof CloudPortfolioError) {
|
|
const exception = new Error(error.message);
|
|
exception.stack = error.stack;
|
|
sentryHandle.captureException(exception, {
|
|
tags: { error_code: "cloud_portfolio_failure" },
|
|
contexts: { cloud_portfolio: { ...CloudPortfolioError.diagnosticsFor(error) } },
|
|
fingerprint: ["{{ default }}"],
|
|
});
|
|
} else {
|
|
sentryHandle.captureException(error);
|
|
}
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error("[paperclip] Sentry captureException failed", err);
|
|
}
|
|
}
|
|
|
|
/** The run status values that mark a run as a genuine terminal failure. */
|
|
export type RunFailureStatus = "failed" | "timed_out" | "cancelled";
|
|
|
|
/**
|
|
* The diagnostic values `captureRunFailure` sends with a terminal-failure
|
|
* event. `errorCode` is `null` when the run holds no error code.
|
|
*/
|
|
export interface RunFailureEvent {
|
|
taskId: string | null;
|
|
companyId?: string;
|
|
agentId?: string;
|
|
/** The `heartbeat_runs` row id. */
|
|
runId: string;
|
|
/** The redacted error message. */
|
|
errorMessage: string;
|
|
/** The run's error code, or `null` when the run holds none. */
|
|
errorCode: string | null;
|
|
/** The agent's adapter type, or `"unknown"` when the agent row is absent. */
|
|
agentAdapter: string;
|
|
/** The run status that triggered this report. */
|
|
runStatus: RunFailureStatus;
|
|
/** Bounded, redacted diagnostics selected by the run failure reporter. */
|
|
diagnostics?: RunFailureDiagnostics;
|
|
/** Recorded process exit evidence, when available. Validated before capture. */
|
|
exitCode?: number | null;
|
|
signal?: string | null;
|
|
}
|
|
|
|
function normalizeRunExitCode(value: unknown): number | null {
|
|
// Match the persisted PostgreSQL integer. Do not coerce adapter-supplied text.
|
|
return typeof value === "number" &&
|
|
Number.isInteger(value) &&
|
|
value >= -2147483648 &&
|
|
value <= 2147483647
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
function normalizeRunSignal(value: unknown): string | null {
|
|
if (value == null) return null;
|
|
// Only host signal constants may leave the process; arbitrary adapter text
|
|
// can contain output or credentials even when stored in the signal column.
|
|
return typeof value === "string" && Object.hasOwn(os.constants.signals, value)
|
|
? value
|
|
: "unknown";
|
|
}
|
|
|
|
/**
|
|
* Report one terminal run failure to Sentry. A no-op before the gate opens
|
|
* or when the gate never opens. Never throws — observability must not
|
|
* change run control flow.
|
|
*
|
|
*/
|
|
export function captureRunFailure(event: RunFailureEvent): void {
|
|
if (!sentryHandle || readRunFailureReportingMode() === "off") return;
|
|
const handle = sentryHandle;
|
|
try {
|
|
const errorCode = event.errorCode ?? "unknown";
|
|
// Sentry's async scope isolation is absent when OTel setup is skipped.
|
|
// A withScope mutation can then persist into unrelated later captures.
|
|
// Pass these fields on this event only; do not mutate the ambient scope.
|
|
const diagnostics = event.diagnostics;
|
|
const rootCodeCandidate = diagnostics?.execution.originalFailureCode ??
|
|
diagnostics?.exceptions.at(-1)?.code;
|
|
const rootErrorCode = typeof rootCodeCandidate === "string" && /^[a-zA-Z][a-zA-Z0-9_]{1,99}$/.test(rootCodeCandidate)
|
|
? rootCodeCandidate : undefined;
|
|
const phase = diagnostics?.execution.failurePhase;
|
|
const failurePhase = phase === "setup" || phase === "execute" ? phase : undefined;
|
|
const contexts: Record<string, Record<string, unknown>> = {};
|
|
if (event.companyId && event.agentId) {
|
|
const runPath = `/api/heartbeat-runs/${encodeURIComponent(event.runId)}`;
|
|
contexts.paperclip = {
|
|
schemaVersion: 1,
|
|
companyId: event.companyId,
|
|
agentId: event.agentId,
|
|
runPath,
|
|
eventsPath: `${runPath}/events`,
|
|
logPath: `${runPath}/log`,
|
|
...(event.taskId ? { taskPath: `/api/issues/${encodeURIComponent(event.taskId)}` } : {}),
|
|
};
|
|
try {
|
|
const base = new URL(process.env.PAPERCLIP_PUBLIC_URL ?? "");
|
|
if ((base.protocol === "https:" || base.protocol === "http:") &&
|
|
!base.username && !base.password && !base.search && !base.hash && base.pathname === "/") {
|
|
contexts.paperclip.runUrl = new URL(runPath, base).href;
|
|
contexts.paperclip.eventsUrl = new URL(`${runPath}/events`, base).href;
|
|
contexts.paperclip.logUrl = new URL(`${runPath}/log`, base).href;
|
|
}
|
|
} catch {}
|
|
}
|
|
if (diagnostics) {
|
|
contexts.run_execution = { ...diagnostics.execution, truncatedFields: diagnostics.truncatedFields };
|
|
if (Object.keys(diagnostics.adapter).length) contexts.adapter_failure = diagnostics.adapter;
|
|
if (Object.keys(diagnostics.provider).length) contexts.provider_failure = diagnostics.provider;
|
|
diagnostics.exceptions.forEach(({ name, code, status, requestId }, index) => {
|
|
contexts[`run_exception_${index}`] = { name, code, status, requestId };
|
|
});
|
|
}
|
|
// Rebuild only sanitized exception fields. Passing a raw SDK Error can
|
|
// serialize its request/response, headers, or other enumerable properties.
|
|
let cause: Error | undefined;
|
|
for (const entry of [...(diagnostics?.exceptions ?? [])].reverse()) {
|
|
const error: Error = new Error(entry.message ?? event.errorMessage, cause ? { cause } : undefined);
|
|
error.name = entry.name ?? "Error";
|
|
error.stack = entry.stack;
|
|
cause = error;
|
|
}
|
|
const exception = cause ?? new Error(event.errorMessage);
|
|
if (!cause) {
|
|
// A saved adapter result is not a thrown Error. Do not pretend that the
|
|
// reporter's own stack is the failure location.
|
|
exception.stack = typeof diagnostics?.adapter.stackPreview === "string"
|
|
? diagnostics.adapter.stackPreview : undefined;
|
|
}
|
|
handle.captureException(exception, {
|
|
tags: {
|
|
event_kind: "run_failure",
|
|
...(rootErrorCode ? { root_error_code: rootErrorCode } : {}),
|
|
...(failurePhase ? { failure_phase: failurePhase } : {}),
|
|
run_id: event.runId,
|
|
...(event.taskId ? { task_id: event.taskId } : {}),
|
|
...(event.companyId ? { company_id: event.companyId } : {}),
|
|
...(event.agentId ? { agent_id: event.agentId } : {}),
|
|
error_code: errorCode,
|
|
agent_adapter: event.agentAdapter,
|
|
run_status: event.runStatus,
|
|
},
|
|
contexts: {
|
|
...contexts,
|
|
run_failure: {
|
|
taskId: event.taskId,
|
|
runId: event.runId,
|
|
errorMessage: event.errorMessage,
|
|
errorCode,
|
|
agentAdapter: event.agentAdapter,
|
|
exitCode: normalizeRunExitCode(event.exitCode),
|
|
signal: normalizeRunSignal(event.signal),
|
|
},
|
|
},
|
|
fingerprint: [errorCode, event.agentAdapter, ...(failurePhase ? [failurePhase] : []), ...(rootErrorCode ? [rootErrorCode] : [])],
|
|
});
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error("[paperclip] Sentry captureRunFailure failed", err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flush buffered events and close the Sentry client. Idempotent — concurrent
|
|
* callers share one shutdown. A no-op when monitoring is off or bootstrap
|
|
* failed.
|
|
*/
|
|
export function shutdownSentry(): Promise<void> {
|
|
shutdownPromise ??= (async () => {
|
|
await sentryReady;
|
|
if (!sentryHandle) return;
|
|
try {
|
|
// Awaiting matters: the client flushes buffered events to Sentry
|
|
// during close; exiting before it settles silently drops them.
|
|
await sentryHandle.close(5_000);
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error("[paperclip] Sentry shutdown failed", err);
|
|
}
|
|
})();
|
|
return shutdownPromise;
|
|
}
|
|
|
|
/**
|
|
* The subset of the `@sentry/node` module surface the initializer needs to
|
|
* build its options object. A structural type, not the real Sentry type —
|
|
* the real type is unavailable at compile time because the package is an
|
|
* optional runtime dependency (see the module comment above).
|
|
*/
|
|
interface SentryModuleLike {
|
|
httpIntegration(options: { breadcrumbs: boolean }): { name: string };
|
|
onUnhandledRejectionIntegration(options: { mode: "strict" }): { name: string };
|
|
}
|
|
|
|
/** The `Sentry.init` options this gate builds. */
|
|
export interface SentryInitOptions {
|
|
dsn: string;
|
|
release?: string;
|
|
skipOpenTelemetrySetup: boolean;
|
|
tracesSampleRate: number;
|
|
sendDefaultPii: boolean;
|
|
serverName: string;
|
|
integrations: (defaults: Array<{ name: string }>) => Array<{ name: string }>;
|
|
}
|
|
|
|
/**
|
|
* Build the `Sentry.init` options object. A pure function, split out from
|
|
* `bootstrapSentry` so a test can call it with a real `@sentry/node` module
|
|
* and assert the resolved integration list and the captured-event shape
|
|
* against the true SDK, not a stand-in.
|
|
*/
|
|
export function buildSentryInitOptions(
|
|
dsn: string,
|
|
Sentry: SentryModuleLike,
|
|
): SentryInitOptions {
|
|
return {
|
|
dsn,
|
|
release: process.env.SENTRY_RELEASE?.trim() || readBuildCommit() || undefined,
|
|
skipOpenTelemetrySetup: true,
|
|
tracesSampleRate: 0,
|
|
sendDefaultPii: false,
|
|
serverName: process.env.SENTRY_NAME || os.hostname(),
|
|
integrations: (defaults: Array<{ name: string }>) => {
|
|
const kept = defaults.filter(
|
|
(integration) =>
|
|
integration.name !== "Console" &&
|
|
integration.name !== "ContextLines" &&
|
|
integration.name !== "Http" &&
|
|
integration.name !== "OnUnhandledRejection",
|
|
);
|
|
return [
|
|
...kept,
|
|
// Keep the rest of the Http integration — RequestData and request
|
|
// isolation need it — but turn the outbound breadcrumb off.
|
|
Sentry.httpIntegration({ breadcrumbs: false }),
|
|
// Keep today's crash-on-unhandled-rejection behavior. See the
|
|
// module comment above for why the default mode cannot stay.
|
|
Sentry.onUnhandledRejectionIntegration({ mode: "strict" }),
|
|
];
|
|
},
|
|
};
|
|
}
|
|
|
|
async function bootstrapSentry(dsn: string): Promise<void> {
|
|
// Gate on the exact peer version before touching the dynamic import: a
|
|
// package installed at the wrong version can still load and start, which
|
|
// would silently invalidate the privacy audit `doc/observability.md`
|
|
// records against one exact version. Checking first turns that into one
|
|
// precise, fail-open diagnostic.
|
|
const versionCheck = checkExactPeerVersions(["@sentry/node"]);
|
|
if (!versionCheck.ok) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
"[paperclip] The backend Sentry DSN is set, but the @sentry/node " +
|
|
"package is not installed, or is installed at an unsupported " +
|
|
"version. Install the declared version of @sentry/node to enable " +
|
|
"server error monitoring. Continuing without it.",
|
|
versionCheck.detail,
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Dynamic import so type-resolution doesn't require the package to be
|
|
// installed unless the operator actually opts in.
|
|
// @ts-ignore optional peer dep
|
|
const Sentry = await import("@sentry/node");
|
|
|
|
Sentry.init(buildSentryInitOptions(dsn, Sentry));
|
|
|
|
sentryHandle = {
|
|
captureException: (...args) => Sentry.captureException(...args),
|
|
close: (timeout) => Sentry.close(timeout),
|
|
};
|
|
} catch (err) {
|
|
// The exact-version gate above already confirmed @sentry/node is
|
|
// installed at the declared version, so only a load or init failure
|
|
// after that point reaches this block.
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
"[paperclip] The backend Sentry DSN is set, and @sentry/node passed " +
|
|
"the version check, but it failed to load or initialize. " +
|
|
"Continuing without error monitoring.",
|
|
err,
|
|
);
|
|
}
|
|
}
|