Skip to content

@connectum/otel

Full OpenTelemetry instrumentation for Connectum services. Provides ConnectRPC interceptors for server and client tracing/metrics, a correlated logger, deep tracing helpers (traced, traceAll), and OTLP exporter management. Follows OpenTelemetry semantic conventions for RPC.

Layer: 2 (Tooling)

Related Guides

Full API Reference

Complete TypeScript API documentation: API Reference

Installation

bash
pnpm add @connectum/otel

Requires: Node.js 22+

Quick Start

typescript
import { createServer } from '@connectum/core';
import { createOtelInterceptor, initProvider } from '@connectum/otel';

// Initialize provider (optional -- lazy-initialized from env by default)
initProvider({ serviceName: 'my-service' });

const server = createServer({
  services: [routes],
  interceptors: [
    createOtelInterceptor({ serverPort: 5000 }),
  ],
});

await server.start();

API Reference

Server Interceptor

createOtelInterceptor(options?)

Creates a ConnectRPC interceptor that instruments incoming RPC calls with OpenTelemetry tracing and metrics.

typescript
function createOtelInterceptor(options?: OtelInterceptorOptions): Interceptor;
OptionTypeDefaultDescription
withoutTracingbooleanfalseDisable span creation (metrics only)
withoutMetricsbooleanfalseDisable metric recording (tracing only)
filterOtelFilter--Filter callback to skip specific RPCs
attributeFilterOtelAttributeFilter--Filter to exclude specific attributes
recordMessagesbooleanfalseInclude message content in span events (may contain sensitive data)
trustRemotebooleanfalseUse extracted remote context as parent span
serverAddressstringos.hostname()Override server.address attribute
serverPortnumber--Opt-in server.port attribute
typescript
const interceptor = createOtelInterceptor({
  serverPort: 5000,
  filter: ({ service }) => !service.includes('Health'),
  trustRemote: false, // creates root spans with links to remote context
});

Client Interceptor

createOtelClientInterceptor(options)

Creates a ConnectRPC interceptor for outgoing client RPC calls.

typescript
function createOtelClientInterceptor(options: OtelClientInterceptorOptions): Interceptor;
OptionTypeDefaultDescription
serverAddressstring(required)Target server address
serverPortnumber--Target server port
withoutTracingbooleanfalseDisable span creation
withoutMetricsbooleanfalseDisable metric recording
filterOtelFilter--Filter callback
attributeFilterOtelAttributeFilter--Attribute filter
recordMessagesbooleanfalseRecord message content

Streaming RPC Instrumentation

Both server and client interceptors automatically instrument streaming RPCs (client streaming, server streaming, and bidirectional streaming).

Span lifecycle: For streaming calls, the span is not closed when the handler returns. Instead, the span lifecycle is deferred to stream completion:

  • Span starts when the RPC begins
  • Individual rpc.message events are recorded for each sent/received message (when recordMessages is enabled)
  • Span ends when the stream is fully consumed, errors, or is broken

This ensures accurate duration measurement and complete message tracking for long-lived streams.

Streaming Attributes

Additional semantic convention attributes for streaming messages:

AttributeValueDescription
rpc.message.idsequential numberMessage sequence number within the stream
rpc.message.type"SENT" / "RECEIVED"Message direction
rpc.message.uncompressed_sizebytes (estimated)Estimated message size
network.transport"tcp"Network transport protocol

Deep Tracing Helpers

traced(fn, options?)

Wraps a single function in an OpenTelemetry span. Preserves the original type signature. Supports sync and async functions.

typescript
function traced<T extends (...args: any[]) => any>(fn: T, options?: TracedOptions): T;
typescript
import { traced } from '@connectum/otel';

const findUser = traced(async (id: string) => {
  return await db.users.findById(id);
}, {
  name: 'UserService.findUser',
  recordArgs: true,
  argsFilter: (args) => args.map(a => typeof a === 'string' ? a : '[redacted]'),
});
OptionTypeDefaultDescription
namestringfn.name or "anonymous"Span name
recordArgsboolean | string[]falseRecord function arguments
argsFilterArgsFilter--Transform/mask recorded args
attributesRecord<string, string | number | boolean>--Custom span attributes

traceAll(obj, options?)

Proxy-based wrapper that instruments all methods of an object. Returns a proxy that creates spans for each method call.

typescript
function traceAll<T extends object>(obj: T, options?: TraceAllOptions): T;
typescript
import { traceAll } from '@connectum/otel';

const userRepo = traceAll(new UserRepository(), {
  prefix: 'UserRepository',
  exclude: ['toString'],
  recordArgs: false,
});
OptionTypeDefaultDescription
prefixstringconstructor.name or "Object"Span name prefix
includestring[]--Whitelist of methods to wrap
excludestring[]--Blacklist of methods to exclude
recordArgsboolean | string[]falseRecord method arguments
argsFilterMethodArgsFilter--Transform/mask recorded args

Logger

getLogger(name?, options?)

Returns a correlated logger that automatically enriches log records with the active trace_id and span_id.

typescript
function getLogger(name?: string, options?: LoggerOptions): Logger;
typescript
import { getLogger } from '@connectum/otel';

const log = getLogger('my-service');

log.info('User created', { userId: '123' });
log.warn('Rate limit approaching');
log.error('Database connection failed', { db: 'primary' });
log.debug('Cache hit');
typescript
interface Logger {
  info(message: string, attributes?: AnyValueMap): void;
  warn(message: string, attributes?: AnyValueMap): void;
  error(message: string, attributes?: AnyValueMap): void;
  debug(message: string, attributes?: AnyValueMap): void;
  emit(record: LogRecord): void;
}

Provider Management

initProvider(options?)

Explicitly initialize the OpenTelemetry provider. Must be called before any telemetry is emitted if custom configuration is needed.

typescript
function initProvider(options?: ProviderOptions): void;
OptionTypeDefaultDescription
serviceNamestringOTEL_SERVICE_NAME or npm_package_nameOverride service name
serviceVersionstringnpm_package_versionOverride service version
instanceIdstringOTEL_SERVICE_INSTANCE_ID envSets service.instance.id on the resource; explicit option takes precedence over the env var
resourceAttributesRecord<string, string | number | boolean>OTEL_RESOURCE_ATTRIBUTES envExtra resource attributes merged into all signals; explicit option takes precedence over the env var
settingsPartial<OTLPSettings>env-basedOverride OTLP settings

getProvider()

Returns the current provider instance. Lazily creates one with default (environment-based) configuration if not yet initialized.

typescript
function getProvider(): OtelProvider;

shutdownProvider()

Gracefully shuts down all OTLP providers and releases resources.

typescript
async function shutdownProvider(): Promise<void>;

Standalone Instances

typescript
import { getTracer, getMeter } from '@connectum/otel';

const tracer = getTracer();  // Lazy-initialized Tracer
const meter = getMeter();    // Lazy-initialized Meter

RPC Metrics

typescript
import { createRpcServerMetrics, createRpcClientMetrics } from '@connectum/otel';

const serverMetrics = createRpcServerMetrics(meter);
// serverMetrics.callDuration  -- rpc.server.call.duration (seconds)
// serverMetrics.requestSize   -- rpc.server.request.size (bytes)
// serverMetrics.responseSize  -- rpc.server.response.size (bytes)

const clientMetrics = createRpcClientMetrics(meter);
// clientMetrics.callDuration  -- rpc.client.call.duration (seconds)
// clientMetrics.requestSize   -- rpc.client.request.size (bytes)
// clientMetrics.responseSize  -- rpc.client.response.size (bytes)

Configuration

Environment Variables

VariableValuesDefaultDescription
OTEL_SERVICE_NAMEstringnpm_package_nameService name for all telemetry
OTEL_SERVICE_INSTANCE_IDstring--Sets service.instance.id on the resource. The explicit initProvider({ instanceId }) option takes precedence over this env var
OTEL_RESOURCE_ATTRIBUTESstring (k=v,k=v)--Extra resource attributes merged into all signals. The explicit initProvider({ resourceAttributes }) option takes precedence over this env var
OTEL_TRACES_EXPORTERconsole | otlp/http | otlp/grpc | none--Trace exporter type
OTEL_METRICS_EXPORTERconsole | otlp/http | otlp/grpc | none--Metric exporter type
OTEL_LOGS_EXPORTERconsole | otlp/http | otlp/grpc | none--Log exporter type
OTEL_EXPORTER_OTLP_ENDPOINTURL--Collector endpoint (e.g. http://localhost:4318)
OTEL_BSP_MAX_EXPORT_BATCH_SIZEnumber100Max spans per export batch
OTEL_BSP_MAX_QUEUE_SIZEnumber1000Max queue size (drops when full)
OTEL_BSP_SCHEDULE_DELAYnumber (ms)1000Auto-export interval
OTEL_BSP_EXPORT_TIMEOUTnumber (ms)10000Export operation timeout

ExporterType

typescript
const ExporterType = {
  CONSOLE: 'console',
  OTLP_HTTP: 'otlp/http',
  OTLP_GRPC: 'otlp/grpc',
  NONE: 'none',
} as const;

Semantic Conventions

Exported attribute constants for manual instrumentation:

typescript
import {
  ATTR_RPC_SYSTEM,
  ATTR_RPC_SERVICE,
  ATTR_RPC_METHOD,
  ATTR_RPC_CONNECT_RPC_STATUS_CODE,
  ATTR_SERVER_ADDRESS,
  ATTR_SERVER_PORT,
  ATTR_ERROR_TYPE,
  ATTR_RPC_MESSAGE_ID,
  ATTR_RPC_MESSAGE_TYPE,
  ATTR_RPC_MESSAGE_UNCOMPRESSED_SIZE,
  ATTR_NETWORK_TRANSPORT,
  RPC_SYSTEM_CONNECT_RPC,
  RPC_MESSAGE_EVENT,
} from '@connectum/otel';

Exports Summary

ExportSubpathDescription
createOtelInterceptor. / ./interceptorServer-side RPC interceptor
createOtelClientInterceptor. / ./client-interceptorClient-side RPC interceptor
traced. / ./tracedFunction-level tracing wrapper
traceAll. / ./traceAllObject-level tracing proxy
getLogger. / ./loggerCorrelated logger
getTracer, getMeter. / ./tracer, ./meterStandalone OTel instances
initProvider, getProvider, shutdownProvider. / ./providerProvider lifecycle
createRpcServerMetrics, createRpcClientMetrics. / ./metricsRPC metric factories
ATTR_*, ConnectErrorCode, etc.. / ./attributesSemantic conventions
ExporterType, getOTLPSettings, etc..Configuration utilities

In-Process Transport

The OTel interceptors are fully transport-aware: spans and RPC metrics are recorded identically whether a call traversed the network (createGrpcTransport) or the in-process router (createLocalTransport from @connectum/core).

The only deliberate difference between the two paths is a single provenance tag — every span carries a connectum.transport attribute and every RPC metric data point carries a transport label:

Pathconnectum.transport span attributetransport metric label
createGrpcTransport(...)"http""http"
createLocalTransport(server, ...)"in-process""in-process"

Detection is header-driven: createLocalTransport prepends an internal connectum-internal-transport: in-process request header that both the server and client interceptors observe. HTTP requests never carry that header, so the default tag is "http".

Parity guarantees

Every observable surface (instrument names, units, label key sets, span kinds, span events, status codes) is invariant across transports. A dedicated parity suite under packages/otel/tests/parity runs each scenario twice — once over HTTP/2 and once over the in-process pipe — and asserts structural equivalence after stripping connectum.transport and transport. Specifically:

  • SpanKind.CLIENT + SpanKind.SERVER spans are emitted on both paths.
  • Streaming rpc.message events (SENT / RECEIVED) are produced with identical sequence ids on both paths when recordMessages: true.
  • Error spans share the same rpc.connect_rpc.status_code, error.type, and SpanStatusCode.ERROR outcome.
  • Metric instrument set is a subset of the HTTP set — no transport-only instruments.
  • W3C traceparent / tracestate propagation works in both directions: with trustRemote: true, the server span is the child of the client span on both paths.

Notes

  • trustRemote: true is recommended when using both transports symmetrically. With the default trustRemote: false, HTTP server spans attach the remote span as a link (no parent), while in-process server spans inherit the active client context as their parent — producing a parent-shape diff that is unrelated to the OTel surface.
  • Payload-size histograms (rpc.*.request.size, rpc.*.response.size) use the same estimateMessageSize() helper on both transports; values may differ slightly between independent runs (timestamp drift) but the instrument shape is identical.

Example

typescript
import { createServer, createLocalTransport } from '@connectum/core';
import { createClient } from '@connectrpc/connect';
import { createOtelInterceptor, createOtelClientInterceptor } from '@connectum/otel';

const server = createServer({
  services: [routes],
  interceptors: [createOtelInterceptor({ serverAddress: 'svc', trustRemote: true })],
});

// In-process: identical telemetry to HTTP path, tagged `connectum.transport=in-process`.
const transport = createLocalTransport(server, {
  interceptors: [createOtelClientInterceptor({ serverAddress: 'svc' })],
});
const client = createClient(MyService, transport);