Official Plugins

OpenTelemetry

Traces and metrics for Seyfert interactions, events, Discord REST, cache operations, and gateway shard health.

@slipher/opentelemetry instruments the main paths through a Seyfert bot and exports standard OpenTelemetry data. Commands, components, modals, gateway events, Discord REST requests, and cache operations become spans with duration histograms alongside them. Gateway shard connectivity and heartbeat latency remain observable even when a bot goes quiet. You can add application-specific child spans through ctx.trace without passing a tracer through every layer.

The plugin is backend-neutral. Point it at Jaeger while developing, an OpenTelemetry Collector in production, or any vendor that accepts OTLP.

The plugin requires Seyfert v5. Exporters and processors are intentionally not bundled, so your application chooses where traces and metrics go.

Installation

Install the plugin, the OpenTelemetry API, and the trace exporter you want to use. This example uses OTLP over HTTP/protobuf:

pnpm add @slipher/opentelemetry @opentelemetry/api \
  @opentelemetry/sdk-trace-node \
  @opentelemetry/exporter-trace-otlp-proto

Quick start

Create the plugin once, register the plugin map for type inference, and pass it to the client:

import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
import { opentelemetry } from '@slipher/opentelemetry';
import { Client, definePlugins } from 'seyfert';

const plugins = definePlugins(
	opentelemetry({
		serviceName: 'my-bot',
		spanProcessors: [
			new BatchSpanProcessor(new OTLPTraceExporter()),
		],
	}),
);

declare module 'seyfert' {
	interface SeyfertRegistry {
		plugins: typeof plugins;
	}
}

const client = new Client({ plugins });
await client.start();

serviceName identifies the bot in your observability backend. If no real tracer provider exists yet, the plugin starts and owns an OpenTelemetry NodeSDK. If the process already registered a provider, the plugin reuses it instead.

See a trace in Jaeger

For a local waterfall view, run Jaeger's all-in-one image with its UI and OTLP/HTTP receiver exposed:

docker run --rm --name seyfert-jaeger \
  -p 127.0.0.1:16686:16686 \
  -p 127.0.0.1:4318:4318 \
  cr.jaegertracing.io/jaegertracing/jaeger:2.19.0

Point the OTLP exporter at it before starting the bot:

export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318

Run a command, then open http://127.0.0.1:16686, select my-bot, and open a trace such as command ping. The container uses in-memory storage and loses its traces when it stops; it is intended for local inspection, not production.

See the Jaeger quick start and OTLP endpoint configuration for other deployment shapes.

Architecture

register contributes interaction lifecycle instrumentation, while setup installs event, REST, cache, and gateway instrumentation and starts a NodeSDK only when the process does not already have a real provider. teardown removes every wrapper and observer and shuts down the SDK only when the plugin owns it.

Automatic instrumentation

Trace and metric signals are configured independently. Interaction, event, and REST traces are enabled by default; cache traces are disabled because one span per lookup is usually too noisy. All duration metrics and gateway health metrics are enabled by default.

SurfaceSpan kindSpansDuration metric
Commands, components, and modalsCONSUMERRoot span plus Options, Middlewares, and Run lifecycle childrenseyfert.interaction.duration
Gateway eventsCONSUMERevent {name}seyfert.event.duration
Discord RESTCLIENTHTTP {METHOD}seyfert.rest.duration
Cache adapterINTERNALcache {op} {resource} when enabledseyfert.cache.operation.duration
Gateway shardsNo spansseyfert.gateway.shard.connected, seyfert.gateway.shard.latency

Disable a noisy or unused surface without removing the plugin:

opentelemetry({
	serviceName: 'my-bot',
	traces: { cache: true },
	metrics: { gateway: false },
});

Errors are recorded on their active spans and rethrown to Seyfert. HTTP 4xx and 5xx responses are marked as errors, while Seyfert 502/503 retries remain part of one logical REST span with http.request.resend_count updated.

Collector and modal flows

Collector buttons and awaited modal submits continue the trace that created them while keeping a separate, short span per Discord interaction; no span stays open while waiting. Every span in that UI flow carries the same seyfert.flow_id.

Collector telemetryRecorded on
seyfert.collector.registered with type, matcher, and timeoutThe Run span that registers it
seyfert.button.presented / seyfert.modal.openedThe span whose successful Discord response presents the UI
seyfert.button.clicked / seyfert.modal.submittedThe collector interaction span
seyfert.collector.wait_duration_msThe collector interaction or terminal span
seyfert.collector.resultcompleted, timeout, stopped, or error
seyfert.interaction.ack_latency_ms and seyfert.interaction.response_typeThe interaction span after a successful reply, defer, or update

Collector callbacks execute with their interaction span active, so database, REST, and application spans created inside the callback become its children. Timeout and manual-stop results use short terminal spans parented to the registration span. Standalone ComponentCommand handlers remain independent traces because Discord does not propagate trace context between interactions.

Custom spans and attributes

The plugin adds the same request-aware TraceHandle to ctx.trace and client.trace. Inside an interaction, use it to enrich the root span or create a timed child operation:

import { Command, Declare, type CommandContext } from 'seyfert';

@Declare({ name: 'profile', description: 'Show your profile' })
export default class ProfileCommand extends Command {
	async run(ctx: CommandContext) {
		ctx.trace.setAttributes({ 'app.profile.variant': 'full' });

		const profile = await ctx.trace.record('profile.load', async span => {
			span.setAttribute('app.storage', 'postgres');
			return db.profiles.find(ctx.author.id);
		});

		await ctx.write({ content: `Hello ${profile.name}` });
	}
}

trace.record() creates an active child span, preserves the callback's synchronous or asynchronous return type, ends the span automatically, and records rejected or thrown errors before rethrowing them.

The handle exposes:

MemberBehavior
spanCurrent active span, or undefined outside a traced scope.
setAttributes(attributes)Adds attributes to the current span and returns whether a span was available.
recordException(error)Records an exception on the current span when one exists.
record(name, callback)Runs the callback inside an automatically ended child span.

createTraceHandle and its TraceHandle type are also exported for custom integrations.

For services that do not have a Seyfert context, import the module helpers:

import {
	getCurrentSpan,
	getMeter,
	getTracer,
	record,
	setAttributes,
	startActiveSpan,
	startSpan,
} from '@slipher/opentelemetry';

await record('billing.lookup', async span => {
	span.setAttribute('app.plan', 'pro');
	await loadSubscription();
});

setAttributes({ 'app.cache': 'hit' });
getCurrentSpan()?.addEvent('profile-ready');

const manual = startSpan('detached-work');
manual.end();

const meter = getMeter();
const tracer = getTracer();

record and startActiveSpan end spans automatically. A span created with startSpan is manual and must be ended by your code.

Filtering

checkIfShouldTrace runs before an automatic root span is created. It receives a discriminated TraceSource, so filtering can stay specific to the surface. It does not disable metrics for that operation:

opentelemetry({
	checkIfShouldTrace(source) {
		if (source.kind === 'event') {
			return source.name !== 'RAW' && !source.name.startsWith('RAW_');
		}

		if (source.kind === 'rest') {
			return source.path !== '/gateway/bot';
		}

		return true;
	},
	cache: {
		skipResources: ['presence', 'voice_state', 'messages'],
	},
});

The source variants are:

type TraceSource =
	| { kind: 'command' | 'component' | 'modal'; context: unknown }
	| { kind: 'event'; name: string; args: readonly unknown[] }
	| { kind: 'rest'; method: string; path: string }
	| { kind: 'cache'; op: string; resource: string };

presence and voice_state cache resources are skipped by default because they are high-churn. Passing cache.skipResources replaces that default set.

Options

OpenTelemetryPluginOptions also accepts NodeSDK options such as spanProcessors, traceExporter, metricReader, instrumentations, resource, and sampler.

OptionDefaultDescription
serviceName'seyfert'Tracer and meter name; also the resource service name when the plugin owns the SDK.
tracesInteractions, events, and REST enabled; cache disabledEnables or disables spans for each surface.
metricsAll surfaces and gateway enabledEnables or disables duration and gateway health metrics independently.
checkIfShouldTraceAlways trueFilters automatic root spans before recording begins.
contextManagerOpenTelemetry defaultA ContextManager registered only when no global context manager is active.
cache.skipResources['presence', 'voice_state']Cache namespaces that should never produce spans or duration metrics.
NodeSDK optionsUsed only when this plugin starts the SDK.

Plugin identity remains @slipher/opentelemetry; changing serviceName does not rename the plugin.

Attributes

Attributes are added only when the relevant value exists.

Interactions

AttributeDescription
seyfert.interaction.kindcommand, component, or modal.
seyfert.commandFull command name when known.
seyfert.custom_idComponent or modal custom ID, truncated to 64 characters.
seyfert.guild_idGuild ID.
seyfert.channel_idChannel ID.
seyfert.user_idInvoking user ID.
seyfert.interaction_idInteraction ID.
seyfert.shard_idShard ID when present.

Command roots are named command {name}. Component and modal roots use the handler's stable identity: an explicit spanName, a plain declared customId, or the class name. The runtime custom_id remains an attribute rather than becoming an unbounded span name. Lifecycle children are Options for commands, Middlewares, and Run.

Gateway events

AttributeDescription
seyfert.event.nameEvent name, such as MESSAGE_CREATE.
seyfert.shard_idShard ID when present.

The span name is event {name}.

Discord REST

AttributeDescription
http.request.methodHTTP method.
url.pathURI path with query strings omitted and Discord webhook or interaction tokens redacted.
url.templateLow-cardinality Discord route template, such as /channels/:id/messages.
http.response.status_codeResponse status when known.
http.request.resend_countSeyfert 502/503 resend count.
error.typeHTTP status or exception type for failed operations.

The span name is HTTP {METHOD}. HTTP 4xx/5xx responses and thrown client failures set span status to ERROR.

Cache

AttributeDescription
seyfert.cache.opSeyfert adapter method, including bulk and relationship operations.
seyfert.cache.resourceResource namespace derived from the key.
seyfert.cache.hitWhether a get result was non-nullish.

The span name is cache {op} {resource}. High-churn resources presence and voice_state are skipped by default.

Component and modal custom_id values are intentionally included on interaction metrics. Guild, channel, user, and interaction IDs remain span-only so metric dimensions do not grow with every Discord entity.

Metrics

Every duration histogram uses seconds and adds seyfert.error when the operation fails. Instruments are created only for enabled metrics.* surfaces.

InstrumentTypical attributes
seyfert.interaction.durationInteraction kind, command or custom ID when known, shard, and seyfert.error.
seyfert.event.durationEvent name and seyfert.error.
seyfert.rest.durationMethod, URL template, status, and seyfert.error.
seyfert.cache.operation.durationOperation, resource, cache hit when applicable, and seyfert.error.
seyfert.gateway.shard.connected1 or 0 per shard, with shard ID.
seyfert.gateway.shard.latencyHeartbeat round-trip in seconds per connected shard.

A metric reader is required to export them. For a quick local check use ConsoleMetricExporter; in production, configure an OTLP metric exporter or another reader supported by the OpenTelemetry SDK. Custom metrics created through getMeter() use the same global meter provider.

Privacy and cardinality

The REST instrumentor never captures request or response bodies, bot tokens, authorization headers, or cookies. Query strings are omitted, Discord webhook and interaction tokens are replaced with REDACTED, and metrics use route templates such as /channels/:id/messages instead of raw IDs.

IDs and custom IDs can still be sensitive in your application domain. Use checkIfShouldTrace to exclude interactions or paths that must not appear in telemetry, and apply any additional redaction or sampling in your collector.

The plugin's REST correlation uses a FIFO queue for concurrent requests with the same method and exact path because Seyfert observer payloads do not expose a request ID. If those requests finish out of order, status and duration may be attached to the wrong span. Requests to different paths are unaffected.

Preloading

If another instrumented library must see the SDK before application modules load, start OpenTelemetry from a preload entry:

// instrumentation.ts
import { startOpenTelemetry } from '@slipher/opentelemetry';

startOpenTelemetry({ serviceName: 'my-bot' });
node --import ./dist/instrumentation.js dist/index.js

The plugin detects the existing provider and installs only its Seyfert instrumentation. SDK options passed to the plugin do not replace the configuration owned by the preload entry.

Using an existing SDK

If the host process already registered a real tracer provider, the plugin does not start another NodeSDK. Automatic instrumentation, ctx.trace, and module helpers use that global provider, while SDK-specific options passed to the plugin are ignored. Configure processors, exporters, resources, and metric readers on the host-owned SDK instead.

In that host-owned case the plugin never calls NodeSDK.start() or sdk.shutdown(). It still unwraps REST, cache, and event instrumentation during teardown.

When the plugin owns the SDK, teardown flushes and shuts it down. Teardown is terminal for that plugin instance: create a new opentelemetry(...) instance with fresh processors and exporters before starting a new client lifecycle. The plugin works alongside @slipher/logger; logs and traces remain independent signals.