On This Page

See What a Bot Is Doing

Read the default log output, adjust levels and categories, and send records to an existing logger

Every client prints what it is doing by default: Startup, each shard (gateway connection) becoming ready, lost connections with their close code and next step, long rate-limit waits, shutdown, and every application error in full. Adjust the level for the whole client or one area, turn on Debug records while investigating, or send records to an existing logger. Records never contain tokens or other credentials

Read the Default Output

On a terminal the client prints one readable line per record: Time, level, category, the record's code in brackets, a message and its facts. Warnings and errors go to standard error, everything else to standard output. A two-shard bot that starts, loses one connection and stops prints lines like these

2026-09-27 03:14:20.850 INFO  lifecycle  [lifecycle.starting] Starting Fluxerly <version> for fluxer.app with 2 shards version=<version> instance=fluxer.app shards=2 totalShards=2
2026-09-27 03:14:20.853 INFO  lifecycle  [lifecycle.startupDeadline] Startup of 2 shards may take up to 31.0 s: The configured 30.0 s (connection.startupTimeoutMs) plus 1 s per additional shard, because the SDK starts shard sessions 1 s apart startupTimeoutMs=31000 configuredMs=30000 shards=2
2026-09-27 03:14:20.891 INFO  lifecycle  [lifecycle.ready] Shard 0 is ready shardId=0 attempt=1 durationMs=36 mode=identify shards=2
2026-09-27 03:14:21.890 INFO  lifecycle  [lifecycle.ready] Shard 1 is ready shardId=1 attempt=1 durationMs=1033 mode=identify shards=2
2026-09-27 03:14:21.891 INFO  lifecycle  [lifecycle.connected] Connected to Fluxer as MyBot in 1520 communities communities=1520 shards=2
2026-09-27 03:14:22.233 WARN  lifecycle  [lifecycle.connectionLost] Shard 1 lost its connection. Close code 4000 (UNKNOWN_ERROR): The gateway reported an unspecified error. Reconnecting and resuming the session in 755 ms shardId=1 closeCode=4000 attempt=1 delayMs=755 reason=closed classification=closed next=resume
2026-09-27 03:14:22.999 INFO  lifecycle  [lifecycle.ready] Shard 1 resumed its session after 766 ms offline shardId=1 attempt=2 durationMs=1 mode=resume shards=2
2026-09-27 03:14:23.737 INFO  lifecycle  [lifecycle.stopRequested] The signal passed to runBot was aborted, so the bot is shutting down
2026-09-27 03:14:23.739 INFO  lifecycle  [lifecycle.shutdown] Shutting down
2026-09-27 03:14:23.742 INFO  lifecycle  [lifecycle.shutdownComplete] Shutdown complete in 3 ms durationMs=3

When a stream is not a terminal, such as under a process manager or in a container, its records are printed as one JSON object per line instead. Standard output and standard error decide separately. Set format to choose explicitly, or set the FLUXERLY_LOG_FORMAT environment variable to pretty or json for a client without format. A supervisor prints forwarded child records in the format of the stream they go to

Readable output is colored on a terminal. Set FLUXERLY_LOG_COLOR to 1, true, yes or on to force color, or to 0, false, no or off to turn it off. The NO_COLOR variable also turns color off

A failed handler is printed at Error with its message, stack and cause chain, because the error came from the bot's own code. Credentials are masked in names, application error codes, messages and stacks, including causes. Stack frames inside the SDK and Effect collapse into one line, so the bot's own frames stand out

2026-09-26 21:27:52.689 ERROR events     [events.handlerFailed] The messageCreate handler failed: Cannot read properties of undefined (reading 'content') event=messageCreate subscriptionId=messageCreate#1 messageId=1456074443980800005 channelId=1456074443980800002 guildId=1456074443980800001
    TypeError: Cannot read properties of undefined (reading 'content')
      at messageCreate (file:///srv/bot/bot.js:3:78)
      ... 9 SDK and Effect frames hidden

An expected SDK error, such as a rejected request, prints its code, hint and details instead of stack frames. Here a handler returned a failed reply because the bot lacks permission in the channel. For a missing permission, the hint and requiredPermissions name the permissions Fluxer checks for the operation, because Fluxer does not report which one is missing

2026-09-26 21:27:52.804 ERROR events     [events.handlerFailed] The messageCreate handler failed: Message send failed: Fluxer reports that the bot lacks a required permission (MISSING_PERMISSIONS, HTTP 403) event=messageCreate subscriptionId=messageCreate#1 messageId=1456074443980800006 channelId=1456074443980800002 guildId=1456074443980800001
    MessageError [message.send.rejected]: Message send failed: Fluxer reports that the bot lacks a required permission (MISSING_PERMISSIONS, HTTP 403)
      Hint: The bot needs View channel and Send messages for this operation. Grant them to the bot's role in the community settings or the channel's permission overrides. Embeds also need Embed links, files need Attach files, and a forward needs View channel in its source channel
      Details: operation=send reason=rejected outcome=rejected status=403 apiError=MISSING_PERMISSIONS requiredPermissions=["ViewChannel","SendMessages"]

A 401 or 403 rejection waits up to one second for its handler to finish. If the handler fails with that same rejection, directly or in its cause chain, before the Warn is logged and no onError hook is configured for that failure, only the handler's failure record appears. A handler that fails more than one second later also logs its failure, because the rest.rejected Warn has already appeared

Different requests with the same status and code remain separate rejections

When the handler handles such a rejection itself, or passes it to an onError hook, the rest.rejected Warn still appears, because it usually points at a token or permission problem that persists. Repeats of it collapse like other Warn records

2026-09-26 21:27:52.802 WARN  rest       [rest.rejected] POST /channels/:id/messages was rejected for channel 1456074443980800002: Fluxer reports that the bot lacks a required permission (MISSING_PERMISSIONS, HTTP 403) route=/channels/:id/messages status=403 method=POST apiError=MISSING_PERMISSIONS hint="Grant the bot's role View channel and Send messages in this channel, plus Embed links or Attach files when the message has them, in the community settings or the channel's permission overrides"

Look Up a Record Code

Every record has a stable code, shown in brackets in readable output. The error and log codes page lists every code with its meaning and next step. These Warn records often surprise a new bot:

rest.rejected#

Fluxer rejected a request with HTTP 401 or 403, usually a token or permission problem. It is logged even when the application handles the Result. A handler that fails with the rejection within one second and has no onError hook logs only its own failure record. A later failure logs both. A bot that expects these rejections can set the rest category to error

rest.responseRejected#

A successful response to an SDK operation did not match the expected shape. The failing field is in fields.field

sdk.unknownEnvironmentValue#

The FLUXERLY_LOG_FORMAT or FLUXERLY_LOG_COLOR variable has a value the SDK does not recognize, so it is ignored

Choose Levels and Categories

The default level is info. Set level for the whole client and categories for single areas. The categories are lifecycle, gateway, rest, ratelimit, cache, events, commands, collectors, supervisor and sdk

import { createClient } from "@neontechspace/fluxerly"

export function createQuietClient(token) {
    return createClient({
        token,
        logging: {
            level: "warn",
            categories: { lifecycle: "info", rest: "debug" },
        },
    })
}
import { createClient } from "@neontechspace/fluxerly"

export function createQuietClient(token: string) {
    return createClient({
        token,
        logging: {
            level: "warn",
            categories: { lifecycle: "info", rest: "debug" },
        },
    })
}
error and fatal

Application errors, failed hooks, connections that cannot continue and cleanup failures

warn

Lost connections, session resets, rate-limit waits of one second or more, rejected requests and responses, skipped malformed gateway events and dropped events

info

Startup, shard readiness and shutdown with its duration

debug

Every REST request with its route template, status and duration, retries, gateway dispatch summaries, rejected and unmatched commands

trace

Heartbeats and every gateway opcode

The silent level turns a category or the whole client off, including application errors that have no onError hook and unsafe payload records

To change levels while the bot runs, for example to investigate a problem without a restart, pass new level and categories settings to client.logging.configure. Omitted level and categories return to their defaults. A debug setting there replaces the current Debug categories, as described in Turn on debug output without changing code, and an omitted one keeps them. The other logging settings stay as the client was created

export function traceRequests(client) {
    client.logging.configure({ level: "warn", categories: { rest: "debug" } })
}
import type { Client } from "@neontechspace/fluxerly"

export function traceRequests(client: Client) {
    client.logging.configure({ level: "warn", categories: { rest: "debug" } })
}

Turn on Debug Output Without Changing Code

Set the FLUXERLY_DEBUG environment variable before starting the bot. The value 1 enables Debug records for every category, while a comma-separated list such as gateway,rest enables only those. The variable is read when the client is created

.env
FLUXERLY_DEBUG=rest,ratelimit node bot.js

The debug option does the same from code, with true or a list of categories. Debug settings only lower a threshold, so level: "silent" with FLUXERLY_DEBUG=rest still prints REST Debug records

To change Debug output while the bot runs, pass debug to client.logging.configure. It replaces the categories chosen at creation, including those from FLUXERLY_DEBUG, and debug: false turns them all off

export function stopDebugOutput(client) {
    client.logging.configure({ debug: false })
}
import type { Client } from "@neontechspace/fluxerly"

export function stopDebugOutput(client: Client) {
    client.logging.configure({ debug: false })
}

Handle Failures in One Place

Pass onError to receive every failure that has no returned result: Handler and command failures, collector callbacks and filters, cleanup progress callbacks, subscriptions closed by a full queue with overflow: "stop", message cache callbacks and failed state observers. The report holds the original error and the IDs of the message involved, never its content. With a hook in place the SDK keeps those failures at Debug instead of printing them again

import { runBot } from "@neontechspace/fluxerly"

export function runReportedBot(token, report) {
    return runBot({
        token,
        onError: (failure) => report(failure.describe()),
        events: {
            messageCreate: ({ message, reply }) => {
                if (message.content !== "!ping")
                    return undefined
                return reply("Pong!")
            },
        },
    })
}
import { runBot } from "@neontechspace/fluxerly"

export function runReportedBot(token: string | undefined, report: (text: string) => void) {
    return runBot({
        token,
        onError: (failure) => report(failure.describe()),
        events: {
            messageCreate: ({ message, reply }) => {
                if (message.content !== "!ping") return undefined
                return reply("Pong!")
            },
        },
    })
}

A subscription's own onError takes precedence for that subscription. The reliability guide explains which failures produce reports

How Reports Are Delivered

A failure only queues its report, so a slow hook never holds up event handlers. Reports are delivered one at a time in order. Up to 256 wait while the hook is busy, and later ones are logged instead and counted in diagnostics().counters.reportsDropped. A hook that throws or rejects is logged together with the original failure and is not retried. Shutdown does not wait for a hook that never finishes, and logs the reports it had not delivered with a note. The record's fields.reportOutcome names why a report was logged instead of delivered: queueFull, interrupted, clientClosed or subscriptionClosed

Send Records to an Existing Logger

The sink option replaces the console output. It receives frozen LogRecord objects with a stable code, a readable message and optional facts such as shardId, route, status, closeCode and error. Other facts are in fields, such as next on lifecycle.retry and lifecycle.connectionLost records, which is resume when the next attempt resumes the session and identify when it starts a new one. Match on code and these facts, because message wording can change between releases

Many loggers have one method per level that takes an object of fields and a message. This sink passes each record to such a logger

import { createClient } from "@neontechspace/fluxerly"

export function createLevelLoggerClient(token, logger) {
    return createClient({
        token,
        logging: {
            sink: (record) => {
                const { level, message, ...fields } = record
                logger[level](fields, message)
            },
        },
    })
}
import { createClient, type LogRecord } from "@neontechspace/fluxerly"

/** A logger with one method per level, each taking fields and a message */
interface LevelLogger {
    trace(fields: object, message: string): void
    debug(fields: object, message: string): void
    info(fields: object, message: string): void
    warn(fields: object, message: string): void
    error(fields: object, message: string): void
    fatal(fields: object, message: string): void
}

export function createLevelLoggerClient(token: string, logger: LevelLogger) {
    return createClient({
        token,
        logging: {
            sink: (record: LogRecord) => {
                const { level, message, ...fields } = record
                logger[level](fields, message)
            },
        },
    })
}

Other loggers take one entry with the level inside it. This sink passes the whole record and maps fatal to error for loggers without a fatal level

import { createClient } from "@neontechspace/fluxerly"

export function createEntryLoggerClient(token, logger) {
    return createClient({
        token,
        logging: {
            sink: (record) => logger.log({ ...record, level: record.level === "fatal" ? "error" : record.level }),
        },
    })
}
import { createClient } from "@neontechspace/fluxerly"

/** A logger with one log method that reads the level from the entry */
interface EntryLogger {
    log(entry: { level: string; message: string; [key: string]: unknown }): void
}

export function createEntryLoggerClient(token: string, logger: EntryLogger) {
    return createClient({
        token,
        logging: {
            sink: (record) => logger.log({ ...record, level: record.level === "fatal" ? "error" : record.level }),
        },
    })
}

A sink runs synchronously and its return value is ignored. A sink that throws or returns a rejected Promise is counted in diagnostics().counters.sinkFailures and reported once on standard error, and SDK work continues. Pass an array to use several sinks

Collapse Repeated Errors

Identical Warn, Error and Fatal records within one minute are printed once. Records about different shards or subscriptions are not identical, so each one's first occurrence is printed. The next identical record after the window says how many were hidden, as in (repeated 12× since last shown), and shutdown prints any remainder. A sink finds that count in fields.repeated. Change the window with dedupe: { windowMs: 10_000 } or turn collapsing off with dedupe: false. Counters in diagnostics().counters still include every occurrence

Count What Happened

The client.diagnostics().counters object keeps running totals since the client was created: Handler and hook failures, dropped events by reason and by event name, rejected gateway messages, unknown dispatches and opcodes, REST retries, rate-limit waits, reconnects, resumes, sink failures, cleanup failures and command rejections

The eventsDroppedByEvent object counts the same drops by event name, such as messageCreate, so a bot can see which handler loses events. Drops that have no known event name appear only in the reason totals of eventsDropped, so the per-event sum can be lower

export function healthSnapshot(client) {
    const { counters, state } = client.diagnostics()
    return {
        state,
        reconnects: counters.reconnects,
        droppedEvents: counters.eventsDropped.overflow + counters.eventsDropped.malformed,
        handlerFailures: counters.handlerFailures,
    }
}
import type { Client } from "@neontechspace/fluxerly"

export function healthSnapshot(client: Client) {
    const { counters, state } = client.diagnostics()
    return {
        state,
        reconnects: counters.reconnects,
        droppedEvents: counters.eventsDropped.overflow + counters.eventsDropped.malformed,
        handlerFailures: counters.handlerFailures,
    }
}

Debug Payloads Safely

The setting unsafe: { payloads: true } adds Trace records with gateway and REST payload bodies, for all categories or the listed ones. Payloads can contain private message content, so the client prints a Warn banner once, at startup or before the first payload record, even when the level hides warnings. A category set to silent prints no payloads, and a received REST body shows at most its first 64 KiB. Tokens, Authorization headers, client secrets, passwords, cookies, invite codes and the user name and password in a URL such as https://user:password@host/path stay masked even then, including credential-key values in truncated JSON text. Leave it off in production

import { createClient } from "@neontechspace/fluxerly"

export function createPayloadDebugClient(token) {
    return createClient({ token, logging: { unsafe: { payloads: true, categories: ["gateway"] } } })
}
import { createClient } from "@neontechspace/fluxerly"

export function createPayloadDebugClient(token: string) {
    return createClient({ token, logging: { unsafe: { payloads: true, categories: ["gateway"] } } })
}

Use the Effect Logger in Native Programs

A native client, created from the Effect entry point @neontechspace/fluxerly/effect, sends records through the running program's Effect logger with Effect.logWithLevel. Each record carries fluxerly.* annotations such as fluxerly.code, fluxerly.category and fluxerly.shardId, so any Effect logger, span and tracer applies. Records with errors also carry a Cause with credentials masked

A record with an SDK error also carries fluxerly.error.code, fluxerly.error.hint and one fluxerly.error.<detail> annotation per safe detail, such as fluxerly.error.status. Each error in its cause chain adds the same annotations with one more cause. in the prefix, such as fluxerly.error.cause.code, fluxerly.error.cause.hint and fluxerly.error.cause.<detail>

Native clients accept the same logging settings. A sink or an explicit format replaces the Effect logger with SDK output

import { Effect, Logger } from "effect"
import { createClient } from "@neontechspace/fluxerly/effect"

export function runNativeBot(token: string, signal: AbortSignal) {
    const program = Effect.scoped(
        Effect.gen(function* () {
            const client = yield* createClient({ token, logging: { categories: { rest: "debug" } } })
            yield* client.run()
        }).pipe(Effect.withLogger(Logger.consoleJson), Effect.annotateLogs("bot", "gateway")),
    )
    return Effect.runPromise(program, { signal })
}

Debug and Trace records chosen by the SDK settings bypass the Effect minimum log level, while Info and higher records still respect it

Export Metrics and Traces with OpenTelemetry

Pass an observe function to the client to receive a measurement each time the SDK finishes a piece of work: A REST request attempt with its method, route template, status and duration, a rate-limit wait, a reconnection attempt, a resumed session, and a handler or command run with its duration and outcome. Each measurement is an Observation whose type field says which kind it is. Observations never contain tokens, payloads or message content, so they can go to any monitoring system. Both entry points accept the same option

A command router emits one handler observation per message, including event and command middleware in its duration. A matched command supplies its canonical name in command, even when a guard denies it or middleware stops it. A reported failure gives outcome: "failure" even if middleware recovers, interruption gives "cancelled", and other completions give "success". Messages with no command match still produce an observation without command

This example records durations and reconnection attempts with an OpenTelemetry meter. The Meter interface lists only the methods the example calls, so the code needs no extra package, and the meter from metrics.getMeter("bot") in @opentelemetry/api fits it

import { runBot } from "@neontechspace/fluxerly"

export function meterObserver(meter) {
    const requests = meter.createHistogram("bot.rest.duration", { unit: "ms" })
    const handlers = meter.createHistogram("bot.handler.duration", { unit: "ms" })
    const reconnects = meter.createCounter("bot.gateway.reconnects")
    return (observation) => {
        if (observation.type === "rest")
            requests.record(observation.durationMs, {
                method: observation.method,
                route: observation.route,
                status: observation.status ?? "none",
            })
        else if (observation.type === "handler")
            handlers.record(observation.durationMs, {
                event: observation.event,
import { runBot, type Observation } from "@neontechspace/fluxerly"

type Attributes = Record<string, string | number>

/** The parts of an OpenTelemetry Meter this bot uses */
interface Meter {
    createHistogram(name: string, options?: { unit?: string }): {
        record(value: number, attributes?: Attributes): void
    }
    createCounter(name: string): { add(value: number, attributes?: Attributes): void }
}

export function meterObserver(meter: Meter) {
    const requests = meter.createHistogram("bot.rest.duration", { unit: "ms" })
    const handlers = meter.createHistogram("bot.handler.duration", { unit: "ms" })
    const reconnects = meter.createCounter("bot.gateway.reconnects")

The SDK calls observe while it finishes the measured work, so the function should only record values and leave sending them to the exporter. An observe function that throws does not change what the bot does, and each throw is counted in diagnostics().counters.sinkFailures

In a native program, the SDK also opens spans named fluxerly.gateway.connect, fluxerly.rest.request, fluxerly.event.handle and fluxerly.command.execute, and updates the metrics fluxerly_rest_duration_ms, fluxerly_ratelimit_wait_ms, fluxerly_gateway_reconnects_total, fluxerly_events_dropped_total and fluxerly_handler_failures_total. The dropped-events metric carries the attribute reason and, where the event name is known, event

A command span covers its middleware, guards, argument conversion, cooldown and execution. Command failure metrics use the canonical command name

Without a metric exporter, metrics stay in memory. Provide the Layer from @effect/opentelemetry, the Effect value that supplies the exporter, to the native program to export them

import { Effect, type Layer } from "effect"
import { runBot } from "@neontechspace/fluxerly/effect"

/** Pass the tracing Layer built with @effect/opentelemetry, such as its NodeSdk layer */
export function runTracedBot(token: string | undefined, telemetry: Layer.Layer<never>) {
    return runBot({ token, processSignals: true, events: {} }).pipe(Effect.provide(telemetry))
}

Use client.diagnostics() for a current snapshot of connection state, request counts and cache accounting, and the troubleshooting guide to match common log lines with fixes