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 <noreply@paperclip.ing>
This commit is contained in:
parent
4277ecbb2e
commit
06cd21ed0f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> }
|
||||
).peerDependencies ?? {};
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
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<typeof import("node:module")>();
|
||||
return {
|
||||
...actual,
|
||||
createRequire: (...args: Parameters<typeof actual.createRequire>) => {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<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;
|
||||
}> {
|
||||
|
|
@ -425,6 +552,17 @@ export function resolveServiceVersion(
|
|||
async function bootstrapOtel(endpoint: string): Promise<void> {
|
||||
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<void> {
|
|||
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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue