Files
PaperClipAI/server/src/instrumentation.ts
T
Nicky LeachandPaperclip 740554acc6 feat: add a no-op OpenTelemetry span seam for the sandbox startup path (#10522)
## Thinking Path

> - Paperclip helps people run and govern AI agent work
> - Sandbox startup needs a safe place to add telemetry spans without
forcing OpenTelemetry on every run
> - This change adds a no-op span seam, so the startup path can accept a
tracer later and still stay inert now
> - The server gets a lazy tracer accessor, and the adapter timing
helper gets an injected tracer hook
> - The change keeps the default path free of OpenTelemetry and keeps
the existing startup event path unchanged
> - The benefit is a future-safe seam with no runtime change today

## Linked Issues or Issue Description

This PR addresses a feature gap in the sandbox startup path.

### Problem

Sandbox startup has no safe span seam. A direct OpenTelemetry import
would load telemetry packages on every run.

### Proposed Solution

Add a lazy tracer accessor in the server. Add an injected no-op tracer
seam in startup timing.

### Alternatives

Import OpenTelemetry directly in the startup path. Reject that path
because the default startup flow must stay inert.

## What Changed

- Added a lazy startup tracer accessor in
`server/src/instrumentation.ts`.
- Added an injected startup tracer seam in
`packages/adapter-utils/src/acpx-engine/startup-timing.ts`.
- Kept the startup event path unchanged.
- Kept `adapter-utils` free of OpenTelemetry imports.

## Verification

- `pnpm exec vitest run
packages/adapter-utils/src/acpx-engine/startup-timing.test.ts`
- `pnpm exec vitest run server/src/__tests__/instrumentation.test.ts`
- `tsc --noEmit` for `@paperclipai/adapter-utils` and
`@paperclipai/server`

## Risks

Low risk. The default tracer is a no-op, so the runtime path stays inert
until a later change injects a real tracer.

## Model Used

OpenAI GPT-5. Tool use and code execution.

## 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 found none
- [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 or 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
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-30 18:23:44 -07:00

275 lines
10 KiB
TypeScript

// Optional OpenTelemetry auto-instrumentation for HTTP / Express / PG / …
//
// Activated only when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. When unset, no
// OTel packages are loaded at all.
//
// The imports are dynamic and the packages are treated as optional runtime
// dependencies — self-hosters who want tracing install them explicitly.
// That keeps OTel off the default dependency graph and avoids forcing a
// lockfile bump for an opt-in feature.
//
// The exporter protocol is selected via the standard `OTEL_EXPORTER_OTLP_PROTOCOL`
// env var (per the OTLP spec):
// - `grpc` (or unset) → @opentelemetry/exporter-trace-otlp-grpc [default]
// - `http/protobuf` → @opentelemetry/exporter-trace-otlp-proto
// - `http/json` → @opentelemetry/exporter-trace-otlp-http
// Any other value logs a warning and falls back to grpc.
//
// 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.
// What this module guarantees instead is `instrumentationReady`: the SDK has
// started (or failed and logged) before that promise resolves. index.ts
// awaits it at the top of `startServer()`, so tracing is active before any
// DB connection is opened or the HTTP server is constructed — the patching
// that matters happens at call time, not import time. Spans are flushed on
// exit via `shutdownInstrumentation()`, which index.ts awaits in its signal
// handler before `process.exit`.
import { createRequire } from "node:module";
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
let sdkShutdown: (() => Promise<void>) | null = null;
let shutdownPromise: Promise<void> | null = null;
/**
* A minimal, structural span/tracer surface. It is the subset of the
* `@opentelemetry/api` `Span` / `Tracer` shape that the startup timing seam
* calls. A real OTel tracer satisfies it. The no-op fallback below implements
* it too, so the caller never needs a null check.
*/
interface StartupTracerHandle {
startSpan(
name: string,
options?: unknown,
): {
setAttribute(key: string, value: unknown): void;
setStatus(status: { code: number; message?: string }): void;
end(): void;
};
}
const NOOP_SPAN = {
setAttribute() {},
setStatus() {},
end() {},
};
/**
* The no-op tracer returned when `@opentelemetry/api` is absent or a lookup
* fails. It opens a span that does nothing, so startup tracing stays a no-op
* without an installed OTel package.
*/
const NOOP_TRACER: StartupTracerHandle = {
startSpan: () => NOOP_SPAN,
};
let tracerApiLoadFailed = false;
/**
* Return a startup tracer. When `@opentelemetry/api` is installed, it returns
* `trace.getTracer(name)`. The `api` package itself returns a no-op tracer
* while no SDK is registered, so an unset endpoint still yields a safe no-op
* without loading any OTel SDK package. The api package loads lazily through
* `require`, so the module graph stays OTel-free until the first call. The
* accessor never throws: a load or lookup failure logs once and returns the
* local no-op tracer (fail open).
*/
export function getStartupTracer(name = "paperclip.startup"): StartupTracerHandle {
try {
const require = createRequire(import.meta.url);
const api = require("@opentelemetry/api") as {
trace?: { getTracer(n: string): StartupTracerHandle };
};
const tracer = api.trace?.getTracer(name);
return tracer ?? NOOP_TRACER;
} catch (err) {
if (!tracerApiLoadFailed) {
tracerApiLoadFailed = true;
// eslint-disable-next-line no-console
console.warn(
"[paperclip] @opentelemetry/api is not available; startup tracing uses a no-op tracer.",
err,
);
}
return NOOP_TRACER;
}
}
/**
* Resolves once the OTel SDK has started (or once bootstrap has failed and
* logged, or immediately when the feature is off). Await before constructing
* the HTTP server so trace coverage doesn't depend on incidental timing.
*/
export const instrumentationReady: Promise<void> = endpoint
? bootstrapOtel(endpoint)
: Promise.resolve();
/**
* Flush buffered spans and stop the SDK. Idempotent — concurrent callers
* share one shutdown. No-op when tracing is off or bootstrap failed.
*/
export function shutdownInstrumentation(): Promise<void> {
shutdownPromise ??= (async () => {
await instrumentationReady;
if (!sdkShutdown) return;
try {
// Awaiting matters: the SDK flushes buffered spans to the collector
// during shutdown; exiting before it settles silently drops them.
await sdkShutdown();
} catch (err) {
// eslint-disable-next-line no-console
console.error("[paperclip] OpenTelemetry shutdown failed", err);
}
})();
return shutdownPromise;
}
type ExporterProtocol = "grpc" | "http/protobuf" | "http/json";
export function resolveProtocol(): {
protocol: ExporterProtocol;
packageName: string;
} {
const raw = process.env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim().toLowerCase();
switch (raw) {
case undefined:
case "":
case "grpc":
return {
protocol: "grpc",
packageName: "@opentelemetry/exporter-trace-otlp-grpc",
};
case "http/protobuf":
return {
protocol: "http/protobuf",
packageName: "@opentelemetry/exporter-trace-otlp-proto",
};
case "http/json":
return {
protocol: "http/json",
packageName: "@opentelemetry/exporter-trace-otlp-http",
};
default:
// eslint-disable-next-line no-console
console.warn(
`[paperclip] Unknown OTEL_EXPORTER_OTLP_PROTOCOL=${raw}; falling back to grpc. ` +
`Valid values: grpc, http/protobuf, http/json.`,
);
return {
protocol: "grpc",
packageName: "@opentelemetry/exporter-trace-otlp-grpc",
};
}
}
async function importExporter(protocol: ExporterProtocol): Promise<{
OTLPTraceExporter: new (config?: Record<string, unknown>) => unknown;
}> {
switch (protocol) {
case "grpc":
// @ts-ignore optional peer dep
return await import("@opentelemetry/exporter-trace-otlp-grpc");
case "http/protobuf":
// @ts-ignore optional peer dep
return await import("@opentelemetry/exporter-trace-otlp-proto");
case "http/json":
// @ts-ignore optional peer dep
return await import("@opentelemetry/exporter-trace-otlp-http");
}
}
async function bootstrapOtel(endpoint: string): Promise<void> {
const { protocol, packageName: exporterPackage } = resolveProtocol();
try {
// Dynamic imports so type-resolution doesn't require the packages to
// be installed unless the operator actually opts in.
const [sdkNode, autoInstr, traceExporter, resources, semconv] =
await Promise.all([
// @ts-ignore optional peer dep
import("@opentelemetry/sdk-node"),
// @ts-ignore optional peer dep
import("@opentelemetry/auto-instrumentations-node"),
importExporter(protocol),
// @ts-ignore optional peer dep
import("@opentelemetry/resources"),
// @ts-ignore optional peer dep
import("@opentelemetry/semantic-conventions"),
]);
const { NodeSDK } = sdkNode;
const { getNodeAutoInstrumentations } = autoInstr;
const { OTLPTraceExporter } = traceExporter;
const { resourceFromAttributes } = resources;
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = semconv;
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "paperclip",
[ATTR_SERVICE_VERSION]: process.env.OTEL_SERVICE_VERSION || "unknown",
}),
// For the HTTP protocols OTEL_EXPORTER_OTLP_ENDPOINT is a *base* URL
// and the exporter appends /v1/traces only when it reads the env var
// itself — an explicit `url` is used verbatim and would silently POST
// to the wrong path. Pass `url` only for gRPC, which has no path.
traceExporter: protocol === "grpc"
? new OTLPTraceExporter({ url: endpoint })
: new OTLPTraceExporter(),
instrumentations: [
getNodeAutoInstrumentations({
// Too chatty for this workload.
"@opentelemetry/instrumentation-fs": { enabled: false },
"@opentelemetry/instrumentation-dns": { enabled: false },
"@opentelemetry/instrumentation-net": { enabled: false },
}),
],
});
try {
sdk.start();
} catch (err) {
// A bad gRPC endpoint, missing native bindings, or a collector that
// rejects the SDK's handshake should not take down the server.
// eslint-disable-next-line no-console
console.error(
"[paperclip] OpenTelemetry SDK failed to start; continuing without tracing",
err,
);
return;
}
sdkShutdown = () =>
Promise.race([
sdk.shutdown(),
// The SDK waits indefinitely for in-flight export batches; an
// unreachable collector must not block process exit. 5s matches the
// SDK's own default flush budget. unref() so the timer itself never
// keeps the event loop alive after a fast clean shutdown.
new Promise<void>((_, reject) => {
const timer = setTimeout(() => reject(new Error("OTel shutdown timed out")), 5_000);
timer.unref?.();
}),
]);
// index.ts awaits shutdownInstrumentation() in its own signal handler
// before process.exit, which is what actually guarantees the flush.
// These handlers are a backstop for entrypoints that import this module
// without coordinating; shutdownInstrumentation() is idempotent, so the
// two paths share a single flush.
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.
// 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.`,
err,
);
}
}