From 06cd21ed0fe14183baf2ef2fc5a36ede168d1199 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 26 Aug 2026 17:34:53 -0700 Subject: [PATCH] fix(observability): declare the optional OpenTelemetry peer dependencies (#12249) ## Thinking Path > - Paperclip manages AI agents for work. > - Paperclip includes an observability path that operators can enable for tracing. > - The server loads several OpenTelemetry packages only when tracing is enabled. > - The documentation calls these packages optional peer dependencies, but the server manifest does not declare them. > - This gap hides supported versions and stops Dependabot from maintaining the packages. > - This pull request aligns package metadata, runtime checks, and documentation with the opt-in tracing design. > - The change gives operators clear installation behavior and keeps the no-op default. ## Linked Issues or Issue Description This pull request fixes a package metadata and installation defect. Related observability work appears in [#8476](https://github.com/paperclipai/paperclip/pull/8476) and [#9672](https://github.com/paperclipai/paperclip/pull/9672). The server documentation described optional OpenTelemetry peer dependencies, but `server/package.json` did not declare them. Package managers and Dependabot could not see the supported version ranges. The UI and Claude local adapter also relied on automatic peer installation for `yjs` and `@anthropic-ai/sdk`. The package manifests now declare the optional runtime packages. A default install does not install optional tracing peers. The server keeps its no-op behavior when tracing is disabled or a peer is absent. ## What Changed - Add seven optional OpenTelemetry packages to `server/package.json` and mark each package as optional. - Keep `@opentelemetry/api` as a normal dependency for the no-op interface. - Disable automatic peer installation in `.npmrc`. - Declare `yjs` for the UI package and `@anthropic-ai/sdk` for the Claude local adapter. - Check declared peer versions before the server loads a dynamic OpenTelemetry import. - Keep the endpoint gate, dynamic imports, and fail-open behavior unchanged. - Update the observability and README documentation. - Tell Dependabot that its npm parser does not read `peerDependencies`. ## Verification - Targeted server tests pass: 34 passed and 2 skipped. - The skipped tests require the real OpenTelemetry SDK and remain pre-existing. - The pull request workflow regenerates the lockfile because manifest files and `.npmrc` changed. - The policy job confirms that the pull request does not include `pnpm-lock.yaml`. - GitHub checks pass except `security/snyk (cryppadotta)`, which remains pending after its authorized wait cap. - Greptile Review reports 5/5 with no open findings. - Server typecheck passes. ## Risks - Optional peers can produce a diagnostic when the installed version does not match the declared range. - A missing optional peer does not stop the server. - Disabling automatic peer installation can expose undeclared package use in other workspaces. - This pull request declares the affected packages and adds tests for the changed behavior. - This pull request makes no database or API changes. ## Model Used OpenAI Codex, GPT-5, with repository inspection and pull request preparation. ## 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 - [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 --- .github/dependabot.yml | 4 + .npmrc | 2 +- README.md | 2 +- doc/observability.md | 58 +++++-- packages/adapters/claude-local/package.json | 1 + server/package.json | 32 ++++ server/src/__tests__/instrumentation.test.ts | 151 +++++++++++++++++ .../server-package-otel-peer-metadata.test.ts | 53 ++++++ server/src/instrumentation.ts | 154 +++++++++++++++++- ui/package.json | 3 +- 10 files changed, 432 insertions(+), 28 deletions(-) create mode 100644 server/src/__tests__/server-package-otel-peer-metadata.test.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7015888496..41be9c9ee7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,6 +14,10 @@ updates: open-pull-requests-limit: 20 labels: - "dependencies" + # 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. ignore: # @types/node describes the APIs available in the supported Node runtime. # Runtime major upgrades are deliberate compatibility changes, so keep diff --git a/.npmrc b/.npmrc index 3e775efb0f..f301fedf98 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -auto-install-peers=true +auto-install-peers=false diff --git a/README.md b/README.md index 80ce5ce44f..5541a858b8 100644 --- a/README.md +++ b/README.md @@ -467,7 +467,7 @@ Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-p ## Observability -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. The `@opentelemetry/*` 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 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. diff --git a/doc/observability.md b/doc/observability.md index db3d8bea26..2a7865308b 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -8,42 +8,62 @@ separate first-party event system. Paperclip ships with **opt-in** OpenTelemetry auto-instrumentation for the server process. When activated it produces **traces only** — no metrics and no -logs are exported by this integration. The OTel packages are *optional peer -dependencies*: they are not in the default lockfile and are loaded dynamically -only when an operator turns the feature on. +logs are exported by this integration. -When `OTEL_EXPORTER_OTLP_ENDPOINT` is unset, none of the `@opentelemetry/*` +`@opentelemetry/api` is a normal dependency of `@paperclipai/server`. Every +install includes it. It stays a no-op interface until an SDK registers a +provider, so it exports no telemetry by itself. + +The SDK, the auto-instrumentation bundle, and the resources and +semantic-conventions helpers are *optional peer dependencies*: they are not in +the default lockfile, and the server loads them dynamically only when an +operator turns the feature on. The three exporters below are mutually +alternative peer dependencies — install exactly **one**, matching +`OTEL_EXPORTER_OTLP_PROTOCOL`. + +When `OTEL_EXPORTER_OTLP_ENDPOINT` is unset, none of the `@opentelemetry/*` SDK packages are imported and there is zero runtime overhead. +`server/package.json` declares each optional package at the exact version the +server tests against; install that exact version. Our Dependabot cannot bump +these versions: its npm parser reads only `dependencies`, `devDependencies`, +and `optionalDependencies`, never `peerDependencies`. A peer version here is a +compatibility claim, not an installed version, so raising it is a human +decision. `@opentelemetry/api` is the one OpenTelemetry package Paperclip +maintains as a dependency; once you install the packages below, they become +normal dependencies of **your own** project, and your own Dependabot updates +them. + ## Enabling tracing ### 1. Install the OTel peer dependencies Install the SDK, the auto-instrumentations bundle, the resources/semconv -helpers, and **one** exporter matching your chosen OTLP protocol. +helpers, and **one** exporter matching your chosen OTLP protocol, at the exact +versions below. Common to every protocol: ```bash pnpm add \ - @opentelemetry/sdk-node \ - @opentelemetry/auto-instrumentations-node \ - @opentelemetry/resources \ - @opentelemetry/semantic-conventions + @opentelemetry/sdk-node@0.221.0 \ + @opentelemetry/auto-instrumentations-node@0.79.0 \ + @opentelemetry/resources@2.10.0 \ + @opentelemetry/semantic-conventions@1.43.0 ``` Then add the exporter for the protocol you intend to use: -| `OTEL_EXPORTER_OTLP_PROTOCOL` | Exporter package | -| ----------------------------- | --------------------------------------------- | -| `grpc` (default if unset) | `@opentelemetry/exporter-trace-otlp-grpc` | -| `http/protobuf` | `@opentelemetry/exporter-trace-otlp-proto` | -| `http/json` | `@opentelemetry/exporter-trace-otlp-http` | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | Exporter package | Version | +| ------------------------------ | ------------------------------------------- | --------- | +| `grpc` (default if unset) | `@opentelemetry/exporter-trace-otlp-grpc` | `0.221.0` | +| `http/protobuf` | `@opentelemetry/exporter-trace-otlp-proto` | `0.221.0` | +| `http/json` | `@opentelemetry/exporter-trace-otlp-http` | `0.221.0` | For example, for the default gRPC path: ```bash -pnpm add @opentelemetry/exporter-trace-otlp-grpc +pnpm add @opentelemetry/exporter-trace-otlp-grpc@0.221.0 ``` ### 2. Set the environment @@ -94,9 +114,11 @@ can confirm the value. If `OTEL_EXPORTER_OTLP_PROTOCOL` is set to an unrecognized value, Paperclip logs a single warning and falls back to gRPC. -If `OTEL_EXPORTER_OTLP_ENDPOINT` is set but the OTel packages are not -installed, the server logs a single diagnostic line on boot and continues -without tracing — your server stays up. +Before it imports any OTel package, the server checks the four common +packages and the selected exporter against the exact versions +`server/package.json` declares. If `OTEL_EXPORTER_OTLP_ENDPOINT` is set and a +package is missing or installed at a different version, the server logs one +diagnostic line on boot and continues without tracing — your server stays up. ## Scope diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index a645a13709..b6bdb2c895 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -54,6 +54,7 @@ }, "dependencies": { "@agentclientprotocol/claude-agent-acp": "^0.70.0", + "@anthropic-ai/sdk": "0.121.0", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", "picocolors": "^1.1.1" diff --git a/server/package.json b/server/package.json index 75d2b88cbe..f3a057cd4e 100644 --- a/server/package.json +++ b/server/package.json @@ -100,6 +100,38 @@ "vite": "^8.2.2", "vitest": "^4.1.10" }, + "peerDependencies": { + "@opentelemetry/auto-instrumentations-node": "0.79.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.221.0", + "@opentelemetry/exporter-trace-otlp-http": "0.221.0", + "@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" + }, + "peerDependenciesMeta": { + "@opentelemetry/auto-instrumentations-node": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-grpc": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-node": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } + }, "engines": { "node": ">=24.11.0" } diff --git a/server/src/__tests__/instrumentation.test.ts b/server/src/__tests__/instrumentation.test.ts index e2a3937ceb..475dc77035 100644 --- a/server/src/__tests__/instrumentation.test.ts +++ b/server/src/__tests__/instrumentation.test.ts @@ -88,6 +88,157 @@ describe("instrumentationReady", () => { }); }); +describe("checkExactPeerVersions", () => { + it("passes when the installed version matches the declared version exactly", async () => { + const { checkExactPeerVersions } = await importFreshInstrumentation(); + const require = createRequire(import.meta.url); + const vitestVersion = (require("vitest/package.json") as { version: string }).version; + + const result = checkExactPeerVersions(["vitest"], { vitest: vitestVersion }); + + expect(result).toEqual({ ok: true }); + }); + + it("reports a package as missing when it cannot be resolved", async () => { + const { checkExactPeerVersions } = await importFreshInstrumentation(); + + const result = checkExactPeerVersions(["@paperclipai/does-not-exist-anywhere"], { + "@paperclipai/does-not-exist-anywhere": "1.0.0", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostic).toContain("@opentelemetry/* packages are not installed"); + expect(result.diagnostic).toContain("@paperclipai/does-not-exist-anywhere"); + } + }); + + it("reports a package as mismatched when the installed version differs from the declared version", async () => { + const { checkExactPeerVersions } = await importFreshInstrumentation(); + const require = createRequire(import.meta.url); + const vitestVersion = (require("vitest/package.json") as { version: string }).version; + + const result = checkExactPeerVersions(["vitest"], { + vitest: "0.0.0-not-the-installed-version", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diagnostic).toContain("a package is installed at an unsupported version"); + expect(result.diagnostic).toContain(`vitest@${vitestVersion}`); + expect(result.diagnostic).toContain("expected 0.0.0-not-the-installed-version"); + } + }); +}); + +describe("bootstrapOtel exact-version gate", () => { + it("does not mention the two exporters OTEL_EXPORTER_OTLP_PROTOCOL did not select", async () => { + process.env[ENDPOINT_ENV] = "http://collector:4318"; + process.env[PROTOCOL_ENV] = "http/json"; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { instrumentationReady } = await importFreshInstrumentation(); + await instrumentationReady; + + // The OTel packages are absent in this test environment, so the gate + // reports every checked package as missing. It must have checked the + // selected exporter (http/json → exporter-trace-otlp-http) and skipped + // the two the protocol did not select. + const diagnosticCall = warn.mock.calls.find((call) => + String(call[0]).includes("@opentelemetry/* packages are not installed"), + ); + expect(diagnosticCall).toBeDefined(); + const message = String(diagnosticCall![0]); + expect(message).toContain("@opentelemetry/exporter-trace-otlp-http"); + expect(message).not.toContain("@opentelemetry/exporter-trace-otlp-grpc"); + expect(message).not.toContain("@opentelemetry/exporter-trace-otlp-proto"); + }); + + it("emits exactly one diagnostic when the endpoint is set and packages are absent", async () => { + process.env[ENDPOINT_ENV] = "http://collector:4318"; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { instrumentationReady } = await importFreshInstrumentation(); + await instrumentationReady; + + const diagnosticCalls = warn.mock.calls.filter((call) => + String(call[0]).includes("@opentelemetry/* packages are not installed"), + ); + expect(diagnosticCalls).toHaveLength(1); + }); +}); + +describe("bootstrapOtel post-gate load failure", () => { + it("logs a load-failure diagnostic, not the missing-package message, when a package passes the gate but its dynamic import fails", async () => { + process.env[ENDPOINT_ENV] = "http://collector:4318"; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Fake the resolution steps the exact-version gate uses, so every checked + // package reports as installed at its declared version. The dynamic + // `import(...)` calls in bootstrapOtel are untouched, so they still fail + // for real — the packages are not actually installed in this test run. + // That forces the one path the catch block now must describe: a package + // that passed the gate but failed to load. + const FAKE_ROOT = "/fake-otel-pkg"; + const require = createRequire(import.meta.url); + const peerDependencies = ( + require("../../package.json") as { peerDependencies?: Record } + ).peerDependencies ?? {}; + + vi.resetModules(); + vi.doMock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (path: unknown) => + String(path).startsWith(FAKE_ROOT) ? true : actual.existsSync(path as never), + readFileSync: (path: unknown, options?: unknown) => { + const asString = String(path); + if (asString.startsWith(FAKE_ROOT) && asString.endsWith("package.json")) { + const name = decodeURIComponent( + asString.slice(FAKE_ROOT.length + 1, -"/package.json".length), + ); + return JSON.stringify({ name, version: peerDependencies[name] }); + } + return actual.readFileSync(path as never, options as never); + }, + }; + }); + vi.doMock("node:module", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRequire: (...args: Parameters) => { + const real = actual.createRequire(...args); + const fake = ((id: string) => real(id)) as typeof real; + fake.resolve = ((id: string, options?: unknown) => + id in peerDependencies + ? `${FAKE_ROOT}/${encodeURIComponent(id)}/index.js` + : real.resolve(id, options as never)) as typeof real.resolve; + return fake; + }, + }; + }); + + try { + const { instrumentationReady } = await import("../instrumentation.js"); + await instrumentationReady; + } finally { + vi.doUnmock("node:fs"); + vi.doUnmock("node:module"); + } + + for (const call of warn.mock.calls) { + expect(String(call[0])).not.toContain("packages are not installed"); + expect(String(call[0])).not.toContain("Install @opentelemetry/sdk-node"); + } + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("passed the version check"), + expect.anything(), + ); + }); +}); + describe("getStartupTracer", () => { it("returns a usable tracer-shaped object when OTEL_EXPORTER_OTLP_ENDPOINT is unset", async () => { const { getStartupTracer } = await importFreshInstrumentation(); diff --git a/server/src/__tests__/server-package-otel-peer-metadata.test.ts b/server/src/__tests__/server-package-otel-peer-metadata.test.ts new file mode 100644 index 0000000000..1473622d0b --- /dev/null +++ b/server/src/__tests__/server-package-otel-peer-metadata.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +/** + * The seven OpenTelemetry packages `server/src/instrumentation.ts` loads + * through a dynamic import. Each one 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; `instrumentation.test.ts` guards + * the runtime half (the bootstrap fails open when a package is absent). + */ + +const OPTIONAL_OTEL_PACKAGES = [ + "@opentelemetry/sdk-node", + "@opentelemetry/auto-instrumentations-node", + "@opentelemetry/resources", + "@opentelemetry/semantic-conventions", + "@opentelemetry/exporter-trace-otlp-grpc", + "@opentelemetry/exporter-trace-otlp-proto", + "@opentelemetry/exporter-trace-otlp-http", +] as const; + +const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url)); + +describe("server package OpenTelemetry peer metadata", () => { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + }; + + it.each(OPTIONAL_OTEL_PACKAGES)( + "declares %s as an optional peer dependency", + (packageName) => { + expect(packageJson.peerDependencies?.[packageName]).toBeDefined(); + expect(packageJson.peerDependenciesMeta?.[packageName]?.optional).toBe(true); + }, + ); + + it.each(OPTIONAL_OTEL_PACKAGES)( + "does not list %s in dependencies or devDependencies", + (packageName) => { + expect(packageJson.dependencies?.[packageName]).toBeUndefined(); + expect(packageJson.devDependencies?.[packageName]).toBeUndefined(); + }, + ); + + it("keeps @opentelemetry/api as a normal dependency", () => { + expect(packageJson.dependencies?.["@opentelemetry/api"]).toBeDefined(); + expect(packageJson.peerDependencies?.["@opentelemetry/api"]).toBeUndefined(); + }); +}); diff --git a/server/src/instrumentation.ts b/server/src/instrumentation.ts index 5f7a31bc96..4b2d744458 100644 --- a/server/src/instrumentation.ts +++ b/server/src/instrumentation.ts @@ -15,6 +15,12 @@ // - `http/json` → @opentelemetry/exporter-trace-otlp-http // Any other value logs a warning and falls back to grpc. // +// Before it imports any package, the bootstrap checks the four common +// packages and the selected exporter against the exact versions this +// manifest's `peerDependencies` declare. A missing or a mismatched version +// logs one diagnostic and leaves the server running without tracing; it +// never throws. +// // Timing guarantee: the bootstrap is async (dynamic imports), so it cannot // patch modules "before they are evaluated" — by the time the first await // yields, index.ts's static imports (http, express, pg) are already loaded. @@ -27,8 +33,9 @@ // handler before `process.exit`. import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; @@ -273,6 +280,20 @@ export function recordProviderPluginSpan(input: { } } +/** + * The four OTel packages that every protocol needs, regardless of which + * exporter `OTEL_EXPORTER_OTLP_PROTOCOL` selects. `bootstrapOtel` reads this + * synchronously (before its first `await`), so it must be declared above + * `instrumentationReady`: that export calls `bootstrapOtel` at module-init + * time, and a `const` declared below it is not yet initialized then. + */ +const OTEL_COMMON_PACKAGES = [ + "@opentelemetry/sdk-node", + "@opentelemetry/auto-instrumentations-node", + "@opentelemetry/resources", + "@opentelemetry/semantic-conventions", +] as const; + /** * Resolves once the OTel SDK has started (or once bootstrap has failed and * logged, or immediately when the feature is off). Await before constructing @@ -340,6 +361,112 @@ 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 { + try { + const pkgUrl = new URL("../package.json", import.meta.url); + const raw = readFileSync(pkgUrl, "utf8"); + const parsed = JSON.parse(raw) as { peerDependencies?: Record }; + 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 = 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) => unknown; }> { @@ -425,6 +552,17 @@ export function resolveServiceVersion( async function bootstrapOtel(endpoint: string): Promise { const { protocol, packageName: exporterPackage } = resolveProtocol(); + // Gate on exact peer versions before touching a single dynamic import: a + // package installed at the wrong version can still load and start, then + // fail in a way the operator only sees in the collector, not the server + // log. Checking first turns that into one precise, fail-open diagnostic. + const versionCheck = checkExactPeerVersions([...OTEL_COMMON_PACKAGES, exporterPackage]); + if (!versionCheck.ok) { + // eslint-disable-next-line no-console + console.warn(versionCheck.diagnostic, versionCheck.detail); + return; + } + try { // Dynamic imports so type-resolution doesn't require the packages to // be installed unless the operator actually opts in. @@ -517,14 +655,16 @@ async function bootstrapOtel(endpoint: string): Promise { process.once("SIGTERM", () => void shutdownInstrumentation()); process.once("SIGINT", () => void shutdownInstrumentation()); } catch (err) { - // OTel packages not installed, or dynamic import failed. Fall through - // with a single diagnostic so the opt-in path is self-documenting. + // The exact-version gate above already confirmed every checked package is + // installed at the declared version, so only a load failure after that + // point reaches this block: a bad build, a broken native binding, or a + // package that throws during its own module init. Fall through with a + // single diagnostic so the opt-in path is self-documenting. // eslint-disable-next-line no-console console.warn( - "[paperclip] OTEL_EXPORTER_OTLP_ENDPOINT is set but the @opentelemetry/* " + - `packages are not installed. Install @opentelemetry/sdk-node, ` + - `@opentelemetry/auto-instrumentations-node, ${exporterPackage}, ` + - `@opentelemetry/resources, and @opentelemetry/semantic-conventions to enable tracing.`, + "[paperclip] OTEL_EXPORTER_OTLP_ENDPOINT is set and the @opentelemetry/* " + + "packages passed the version check, but one of them failed to load. " + + "Continuing without tracing.", err, ); } diff --git a/ui/package.json b/ui/package.json index ec0e14d9f0..49e04bf283 100644 --- a/ui/package.json +++ b/ui/package.json @@ -69,7 +69,8 @@ "react-resizable-panels": "^4.12.3", "react-router-dom": "^7.18.2", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.6.0" + "tailwind-merge": "^3.6.0", + "yjs": "13.6.29" }, "devDependencies": { "@sentry/browser": "^10.71.0",