fix(observability): pin the Sentry browser SDK and gate the optional Sentry server peer on the exact version (#12270)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip uses separate server and browser packages for runtime
services and the board.
> - Sentry integrations need an exact SDK version and safe optional
loading.
> - A version range can select an SDK that the privacy tests did not
audit.
> - Missing peer metadata does not describe the optional server SDK
contract.
> - This pull request pins the browser SDK and gates the optional server
SDK on its exact version.
> - The benefit is a clear SDK contract with fail-open startup behavior.
## Linked Issues or Issue Description
**What happened?**
The browser package used the range ^10.71.0, so a lockfile refresh could
select a newer SDK. The server loaded @sentry/node dynamically but did
not declare its optional peer contract.
**Expected behavior**
The browser package must use the audited 10.71.0 version. The server
must load @sentry/node only when the installed peer matches 10.71.0. The
server must start when the optional peer is absent.
**Steps to reproduce**
1. Install the project dependencies.
2. Inspect the browser Sentry version and the server package metadata.
3. Start the server without installing @sentry/node.
4. Confirm that the server starts and that the dynamic Sentry bootstrap
does not load an unsupported peer version.
**Paperclip version or commit**
9c57c0f119
**Deployment mode**
Built from source with pnpm dev or pnpm build.
**Installation method**
Built from source.
**Agent adapter(s) involved**
Not adapter-specific (core change).
**Database mode**
Not database-related.
## What Changed
- Pin @sentry/browser to exactly 10.71.0 as a UI development dependency.
- Declare @sentry/node as an optional server peer dependency at 10.71.0.
- Gate the dynamic server bootstrap on the exact peer version.
- Add tests for the browser pin, peer metadata, version gate, and
fail-open loading.
- Document the supported server SDK version.
- Keep the lockfile unchanged because the pull request workflow
regenerates it for manifest changes.
## Verification
- Server tests pass with six expected skips when @sentry/node is absent.
- UI tests pass.
- The UI build emits the lazy Sentry browser chunk.
- git diff --check passes.
- GitHub pull request checks must pass after this pull request opens.
- Greptile must return a 5/5 score with no open findings.
## Risks
The exact version gate prevents Sentry startup when an unsupported SDK
version exists. The integration remains optional and fail-open. The
lockfile workflow must regenerate the lockfile before frozen downstream
jobs run. The label-gated Storybook visual job must not run until it can
restore the generated lockfile artifact.
## Model Used
OpenAI Codex, GPT-5, tool use and code review support, exact context
window details are managed by the execution platform.
## 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
- [x] My branch name describes the change 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>
This commit is contained in:
parent
4d82f5eaea
commit
1de105c475
|
|
@ -17,7 +17,11 @@ updates:
|
|||
# Dependabot's npm parser reads only dependencies, devDependencies, and
|
||||
# optionalDependencies — never peerDependencies. It cannot see the
|
||||
# optional OpenTelemetry peer dependencies in server/package.json, so it
|
||||
# never bumps their declared versions.
|
||||
# never bumps their declared versions. The same limit applies to the
|
||||
# optional @sentry/node peer dependency in server/package.json: Dependabot
|
||||
# cannot bump it either, for the same reason. @sentry/browser stays a
|
||||
# normal devDependency of ui/package.json, so Dependabot does track that
|
||||
# one.
|
||||
ignore:
|
||||
# @types/node describes the APIs available in the supported Node runtime.
|
||||
# Runtime major upgrades are deliberate compatibility changes, so keep
|
||||
|
|
|
|||
|
|
@ -469,7 +469,7 @@ Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-p
|
|||
|
||||
Paperclip ships with opt-in OpenTelemetry auto-instrumentation for the server (traces only). It activates when `OTEL_EXPORTER_OTLP_ENDPOINT` is set and supports `grpc`, `http/protobuf`, and `http/json` via the standard `OTEL_EXPORTER_OTLP_PROTOCOL` env var. `@opentelemetry/api` is a normal server dependency; the SDK, auto-instrumentation, and exporter packages are optional peer dependencies — install them only if you want tracing. See [doc/observability.md](doc/observability.md) for install commands and the full env-var reference.
|
||||
|
||||
Paperclip also ships with opt-in Sentry error monitoring for the server and the browser. Set `SENTRY_DSN` to activate it — the server and the browser then report to the same Sentry project. `@sentry/node` is an optional peer dependency for the server; install it only if you want error monitoring. See [doc/observability.md](doc/observability.md#sentry-error-monitoring) for the install command, the privacy settings, and the full default capture set.
|
||||
Paperclip also ships with opt-in Sentry error monitoring for the server and the browser. Set `SENTRY_DSN` to activate it — the server and the browser then report to the same Sentry project. The supported server SDK version is `@sentry/node@10.71.0`; it is an optional peer dependency for the server, so install it only if you want error monitoring. The browser SDK, `@sentry/browser`, is pinned to the same exact version. See [doc/observability.md](doc/observability.md#sentry-error-monitoring) for the install command, the privacy settings, and the full default capture set.
|
||||
|
||||
## Telemetry
|
||||
|
||||
|
|
|
|||
|
|
@ -148,19 +148,27 @@ imports no Sentry package. The browser fetches no Sentry chunk.
|
|||
|
||||
#### 1. Install the Sentry peer dependency
|
||||
|
||||
Install `@sentry/node` in the server, the same way you install the
|
||||
The supported server SDK version is **`@sentry/node@10.71.0`** — the exact
|
||||
version this feature is audited against (see "Server request data"
|
||||
below). Install it in the server, the same way you install the
|
||||
OpenTelemetry packages above. `@sentry/node` is an *optional peer
|
||||
dependency*: it is not in the default lockfile, and the server loads it
|
||||
dynamically only when `SENTRY_DSN` is set.
|
||||
dynamically only when `SENTRY_DSN` is set. `server/package.json` declares
|
||||
this exact version; installing a different version defeats the audit, so
|
||||
the server checks the installed version against the declared one at
|
||||
startup and logs one diagnostic instead of enabling error monitoring on a
|
||||
mismatch (see "Server request data" below).
|
||||
|
||||
```bash
|
||||
pnpm add @sentry/node
|
||||
pnpm add @sentry/node@10.71.0
|
||||
```
|
||||
|
||||
The browser package, `@sentry/browser`, needs no install step. It is
|
||||
already a development dependency of the `ui` package, so the browser code
|
||||
ships inside every build. A signed-out browser, or a browser with no DSN,
|
||||
never fetches the Sentry chunk — see "DSN delivery to the browser" below.
|
||||
already a development dependency of the `ui` package, pinned to the same
|
||||
exact version, **`10.71.0`**, so the browser code ships inside every
|
||||
build at the audited version. A signed-out browser, or a browser with no
|
||||
DSN, never fetches the Sentry chunk — see "DSN delivery to the browser"
|
||||
below.
|
||||
|
||||
#### 2. Set the environment
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,8 @@
|
|||
"@opentelemetry/exporter-trace-otlp-proto": "0.221.0",
|
||||
"@opentelemetry/resources": "2.10.0",
|
||||
"@opentelemetry/sdk-node": "0.221.0",
|
||||
"@opentelemetry/semantic-conventions": "1.43.0"
|
||||
"@opentelemetry/semantic-conventions": "1.43.0",
|
||||
"@sentry/node": "10.71.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/auto-instrumentations-node": {
|
||||
|
|
@ -130,6 +131,9 @@
|
|||
},
|
||||
"@opentelemetry/semantic-conventions": {
|
||||
"optional": true
|
||||
},
|
||||
"@sentry/node": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ async function importFreshSentry() {
|
|||
* Register a fake `@sentry/node` module for the next dynamic import. Each
|
||||
* mock function is returned so a test can assert on the call it received.
|
||||
* The mock stays in place until `vi.doUnmock` runs, so `afterEach` clears it.
|
||||
*
|
||||
* `@sentry/node` is not installed on disk in this test environment, so the
|
||||
* exact-version gate would report it missing before the dynamic import ever
|
||||
* runs. This helper also mocks the gate module itself to report success, so
|
||||
* a test can exercise the "package present and at the right version" path
|
||||
* without installing the real package.
|
||||
*/
|
||||
function mockSentryPackage() {
|
||||
const init = vi.fn();
|
||||
|
|
@ -50,6 +56,9 @@ function mockSentryPackage() {
|
|||
httpIntegration,
|
||||
onUnhandledRejectionIntegration,
|
||||
}));
|
||||
vi.doMock("../peer-version-check.js", () => ({
|
||||
checkExactPeerVersions: () => ({ ok: true }),
|
||||
}));
|
||||
|
||||
return { init, captureException, close, httpIntegration, onUnhandledRejectionIntegration };
|
||||
}
|
||||
|
|
@ -88,6 +97,7 @@ afterEach(() => {
|
|||
else process.env[DSN_ENV] = originalDsn;
|
||||
vi.restoreAllMocks();
|
||||
vi.doUnmock("@sentry/node");
|
||||
vi.doUnmock("../peer-version-check.js");
|
||||
});
|
||||
|
||||
describe("sentryReady", () => {
|
||||
|
|
@ -262,6 +272,37 @@ describe("missing @sentry/node package", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("@sentry/node installed at an unsupported version", () => {
|
||||
it("logs one diagnostic and resolves without importing the package", async () => {
|
||||
process.env[DSN_ENV] = "https://public@o0.ingest.sentry.io/1";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.doMock("../peer-version-check.js", () => ({
|
||||
checkExactPeerVersions: () => ({
|
||||
ok: false,
|
||||
diagnostic: "unused by the Sentry gate; see server/src/sentry.ts",
|
||||
detail: {
|
||||
missing: [],
|
||||
mismatched: [{ name: "@sentry/node", installed: "9.0.0", expected: "10.71.0" }],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { sentryReady } = await importFreshSentry();
|
||||
|
||||
// Bootstrap must absorb the reported mismatch — the server keeps
|
||||
// booting without error monitoring rather than crashing on an opt-in
|
||||
// feature.
|
||||
await expect(sentryReady).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("@sentry/node package is not installed"),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
vi.doUnmock("../peer-version-check.js");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSentryInitOptions", () => {
|
||||
it("sets sendDefaultPii false, tracesSampleRate 0, and skipOpenTelemetrySetup true", async () => {
|
||||
const { buildSentryInitOptions } = await importFreshSentry();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* `server/src/sentry.ts` loads `@sentry/node` through a dynamic import. It
|
||||
* must be an optional peer dependency, not a normal dependency, so a default
|
||||
* install never pulls in the SDK. This test guards the manifest half of that
|
||||
* contract; `sentry.test.ts` guards the runtime half (the bootstrap fails
|
||||
* open when the package is absent or at an unsupported version).
|
||||
*/
|
||||
|
||||
const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
||||
|
||||
describe("server package Sentry peer metadata", () => {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
||||
};
|
||||
|
||||
it("declares @sentry/node as an optional peer dependency", () => {
|
||||
expect(packageJson.peerDependencies?.["@sentry/node"]).toBeDefined();
|
||||
expect(packageJson.peerDependenciesMeta?.["@sentry/node"]?.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("does not list @sentry/node in dependencies or devDependencies", () => {
|
||||
expect(packageJson.dependencies?.["@sentry/node"]).toBeUndefined();
|
||||
expect(packageJson.devDependencies?.["@sentry/node"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -33,9 +33,11 @@
|
|||
// handler before `process.exit`.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { checkExactPeerVersions } from "./peer-version-check.js";
|
||||
|
||||
export { checkExactPeerVersions } from "./peer-version-check.js";
|
||||
|
||||
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||
|
||||
|
|
@ -361,112 +363,6 @@ export function resolveProtocol(): {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read this package's own `peerDependencies`, so the exact-version gate
|
||||
* compares an installed package against the same version this manifest
|
||||
* declares — one source of truth, not a second hardcoded copy. Returns an
|
||||
* empty map on any read or parse failure (fail open: an unreadable manifest
|
||||
* skips the version check rather than blocking startup).
|
||||
*/
|
||||
function readOwnPeerDependencies(): Record<string, string> {
|
||||
try {
|
||||
const pkgUrl = new URL("../package.json", import.meta.url);
|
||||
const raw = readFileSync(pkgUrl, "utf8");
|
||||
const parsed = JSON.parse(raw) as { peerDependencies?: Record<string, string> };
|
||||
return parsed.peerDependencies ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an installed package's own declared `version`, without importing or
|
||||
* executing the package. Resolves the package's main entry point (which
|
||||
* respects its `exports` map) and then walks up the filesystem to the
|
||||
* nearest `package.json` whose `name` matches — a direct
|
||||
* `require.resolve(\`${packageName}/package.json\`)` throws for a package
|
||||
* whose `exports` map does not expose `./package.json` as a subpath, which
|
||||
* several `@opentelemetry/*` packages do not, even though the package is
|
||||
* correctly installed. Returns null when the package cannot be resolved or no
|
||||
* matching `package.json` is found.
|
||||
*/
|
||||
function readInstalledPackageVersion(packageName: string): string | null {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
let dir = dirname(require.resolve(packageName));
|
||||
for (;;) {
|
||||
const candidate = join(dir, "package.json");
|
||||
if (existsSync(candidate)) {
|
||||
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as {
|
||||
name?: unknown;
|
||||
version?: unknown;
|
||||
};
|
||||
if (parsed.name === packageName) {
|
||||
return typeof parsed.version === "string" ? parsed.version : null;
|
||||
}
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that every package in `packageNames` is installed at the exact
|
||||
* version `peerDependencies` declares. Checks only the packages the caller
|
||||
* passes in — the bootstrap passes the four common packages plus the one
|
||||
* exporter `OTEL_EXPORTER_OTLP_PROTOCOL` selected, never the two unselected
|
||||
* exporters. Never throws: a missing manifest, a missing package, or an
|
||||
* unreadable `package.json` all resolve to a reported issue, not an
|
||||
* exception.
|
||||
*
|
||||
* `peerDependencies` defaults to this manifest's own declared versions
|
||||
* (`readOwnPeerDependencies()`), which is what the bootstrap uses. A test
|
||||
* passes an explicit map instead, so it can check the comparison logic
|
||||
* against a package it controls without writing into `node_modules`.
|
||||
*/
|
||||
export function checkExactPeerVersions(
|
||||
packageNames: readonly string[],
|
||||
peerDependencies: Record<string, string> = readOwnPeerDependencies(),
|
||||
): { ok: true } | { ok: false; diagnostic: string; detail: unknown } {
|
||||
const missing: string[] = [];
|
||||
const mismatched: { name: string; installed: string; expected: string }[] = [];
|
||||
|
||||
for (const name of packageNames) {
|
||||
const expected = peerDependencies[name];
|
||||
const installed = readInstalledPackageVersion(name);
|
||||
if (installed === null) {
|
||||
missing.push(name);
|
||||
} else if (expected && installed !== expected) {
|
||||
mismatched.push({ name, installed, expected });
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === 0 && mismatched.length === 0) return { ok: true };
|
||||
|
||||
const parts: string[] = [];
|
||||
if (missing.length > 0) {
|
||||
parts.push(`the @opentelemetry/* packages are not installed: ${missing.join(", ")}`);
|
||||
}
|
||||
if (mismatched.length > 0) {
|
||||
const detail = mismatched
|
||||
.map((m) => `${m.name}@${m.installed} (expected ${m.expected})`)
|
||||
.join(", ");
|
||||
parts.push(`a package is installed at an unsupported version: ${detail}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
diagnostic:
|
||||
`[paperclip] OTEL_EXPORTER_OTLP_ENDPOINT is set but ${parts.join("; and ")}. ` +
|
||||
"Continuing without tracing.",
|
||||
detail: { missing, mismatched },
|
||||
};
|
||||
}
|
||||
|
||||
async function importExporter(protocol: ExporterProtocol): Promise<{
|
||||
OTLPTraceExporter: new (config?: Record<string, unknown>) => unknown;
|
||||
}> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
// Exact-version peer-dependency gate, shared by every optional-SDK bootstrap
|
||||
// (OpenTelemetry in `instrumentation.ts`, Sentry in `sentry.ts`).
|
||||
//
|
||||
// This module has no module-init side effect — it only defines functions. A
|
||||
// bootstrap module imports it and calls `checkExactPeerVersions` itself. That
|
||||
// matters for `sentry.ts`: a direct import of `instrumentation.ts` would run
|
||||
// the OpenTelemetry bootstrap (`instrumentationReady`) as a side effect of
|
||||
// loading the Sentry gate, which this module avoids.
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
/**
|
||||
* Read this package's own `peerDependencies`, so the exact-version gate
|
||||
* compares an installed package against the same version this manifest
|
||||
* declares — one source of truth, not a second hardcoded copy. Returns an
|
||||
* empty map on any read or parse failure (fail open: an unreadable manifest
|
||||
* skips the version check rather than blocking startup).
|
||||
*/
|
||||
function readOwnPeerDependencies(): Record<string, string> {
|
||||
try {
|
||||
const pkgUrl = new URL("../package.json", import.meta.url);
|
||||
const raw = readFileSync(pkgUrl, "utf8");
|
||||
const parsed = JSON.parse(raw) as { peerDependencies?: Record<string, string> };
|
||||
return parsed.peerDependencies ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an installed package's own declared `version`, without importing or
|
||||
* executing the package. Resolves the package's main entry point (which
|
||||
* respects its `exports` map) and then walks up the filesystem to the
|
||||
* nearest `package.json` whose `name` matches — a direct
|
||||
* `require.resolve(\`${packageName}/package.json\`)` throws for a package
|
||||
* whose `exports` map does not expose `./package.json` as a subpath, which
|
||||
* several `@opentelemetry/*` packages do not, even though the package is
|
||||
* correctly installed. Returns null when the package cannot be resolved or no
|
||||
* matching `package.json` is found.
|
||||
*/
|
||||
function readInstalledPackageVersion(packageName: string): string | null {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
let dir = dirname(require.resolve(packageName));
|
||||
for (;;) {
|
||||
const candidate = join(dir, "package.json");
|
||||
if (existsSync(candidate)) {
|
||||
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as {
|
||||
name?: unknown;
|
||||
version?: unknown;
|
||||
};
|
||||
if (parsed.name === packageName) {
|
||||
return typeof parsed.version === "string" ? parsed.version : null;
|
||||
}
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that every package in `packageNames` is installed at the exact
|
||||
* version `peerDependencies` declares. The caller passes only the packages it
|
||||
* needs checked — the OpenTelemetry bootstrap passes the four common packages
|
||||
* plus the one exporter `OTEL_EXPORTER_OTLP_PROTOCOL` selected, and the
|
||||
* Sentry gate passes `["@sentry/node"]`. Never throws: a missing manifest, a
|
||||
* missing package, or an unreadable `package.json` all resolve to a reported
|
||||
* issue, not an exception.
|
||||
*
|
||||
* `peerDependencies` defaults to this manifest's own declared versions
|
||||
* (`readOwnPeerDependencies()`), which is what each bootstrap uses. A test
|
||||
* passes an explicit map instead, so it can check the comparison logic
|
||||
* against a package it controls without writing into `node_modules`.
|
||||
*
|
||||
* The returned `diagnostic` string names the OpenTelemetry endpoint variable,
|
||||
* because the OpenTelemetry bootstrap logs it directly. The Sentry gate reads
|
||||
* `detail` instead and builds its own diagnostic line — see `sentry.ts`.
|
||||
*/
|
||||
export function checkExactPeerVersions(
|
||||
packageNames: readonly string[],
|
||||
peerDependencies: Record<string, string> = readOwnPeerDependencies(),
|
||||
): { ok: true } | { ok: false; diagnostic: string; detail: unknown } {
|
||||
const missing: string[] = [];
|
||||
const mismatched: { name: string; installed: string; expected: string }[] = [];
|
||||
|
||||
for (const name of packageNames) {
|
||||
const expected = peerDependencies[name];
|
||||
const installed = readInstalledPackageVersion(name);
|
||||
if (installed === null) {
|
||||
missing.push(name);
|
||||
} else if (expected && installed !== expected) {
|
||||
mismatched.push({ name, installed, expected });
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === 0 && mismatched.length === 0) return { ok: true };
|
||||
|
||||
const parts: string[] = [];
|
||||
if (missing.length > 0) {
|
||||
parts.push(`the @opentelemetry/* packages are not installed: ${missing.join(", ")}`);
|
||||
}
|
||||
if (mismatched.length > 0) {
|
||||
const detail = mismatched
|
||||
.map((m) => `${m.name}@${m.installed} (expected ${m.expected})`)
|
||||
.join(", ");
|
||||
parts.push(`a package is installed at an unsupported version: ${detail}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
diagnostic:
|
||||
`[paperclip] OTEL_EXPORTER_OTLP_ENDPOINT is set but ${parts.join("; and ")}. ` +
|
||||
"Continuing without tracing.",
|
||||
detail: { missing, mismatched },
|
||||
};
|
||||
}
|
||||
|
|
@ -33,6 +33,16 @@
|
|||
// 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 { checkExactPeerVersions } from "./peer-version-check.js";
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
|
||||
|
|
@ -146,6 +156,24 @@ export function buildSentryInitOptions(
|
|||
}
|
||||
|
||||
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] 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.
|
||||
|
|
@ -159,14 +187,14 @@ async function bootstrapSentry(dsn: string): Promise<void> {
|
|||
close: (timeout) => Sentry.close(timeout),
|
||||
};
|
||||
} catch (err) {
|
||||
// The package is not installed, or the dynamic import or init call
|
||||
// failed. Fall through with a single diagnostic so the opt-in path is
|
||||
// self-documenting. The gate fails open — the server keeps booting
|
||||
// without error monitoring rather than crashing on an opt-in feature.
|
||||
// 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] SENTRY_DSN is set but the @sentry/node package is not " +
|
||||
"installed. Install @sentry/node to enable server error monitoring.",
|
||||
"[paperclip] SENTRY_DSN is set and @sentry/node passed the version " +
|
||||
"check, but it failed to load or initialize. Continuing without " +
|
||||
"error monitoring.",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@
|
|||
"yjs": "13.6.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/browser": "^10.71.0",
|
||||
"@sentry/browser": "10.71.0",
|
||||
"@storybook/addon-a11y": "10.5.10",
|
||||
"@storybook/addon-docs": "10.5.10",
|
||||
"@storybook/react-vite": "10.5.10",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* `doc/observability.md` audits the browser Sentry privacy behavior against
|
||||
* one exact `@sentry/browser` release. A caret range lets a lockfile refresh
|
||||
* move the installed version and silently invalidate that audit. This test
|
||||
* guards the manifest: the declared version must be exact, not a range.
|
||||
*/
|
||||
|
||||
const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
||||
|
||||
describe("ui package Sentry version pin", () => {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
it("pins @sentry/browser to an exact version", () => {
|
||||
const declared = packageJson.devDependencies?.["@sentry/browser"];
|
||||
expect(declared).toBeDefined();
|
||||
// An exact version has no range operator (^, ~, >=, ||, x, *, …). A
|
||||
// caret range such as "^10.71.0" must fail this assertion.
|
||||
expect(declared).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it("keeps @sentry/browser a development dependency, not a normal dependency", () => {
|
||||
expect(packageJson.dependencies?.["@sentry/browser"]).toBeUndefined();
|
||||
expect(packageJson.devDependencies?.["@sentry/browser"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue