JavaScript/TypeScript client for Splatty.
Captures exceptions and logs and ships them over the envelope protocol.
Mirrors splatty-ruby.
No runtime dependencies. Every integration is duck-typed against the library it adapts, so installing Splatty never pulls in Express, pino, winston or BullMQ — you only wire up the ones you already use.
- Installation
- Quick start
- Configuration
- Capturing events
- Express
- Background jobs
- Logs
- Shutting down
- Entry points
- API reference
- Wire protocol
npm install splattyRequires Node 18 or newer. Ships both CommonJS and ESM builds with TypeScript
declarations, so import and require both work:
import * as splatty from "splatty";const splatty = require("splatty");import * as splatty from "splatty";
splatty.init({
url: process.env.SPLATTY_URL ?? "https://splatty.app",
dsn: process.env.SPLATTY_DSN,
environment: process.env.NODE_ENV ?? "development",
release: process.env.SPLATTY_RELEASE,
});
try {
doSomething();
} catch (e) {
await splatty.captureException(e);
}init() validates the config, creates the client and installs whichever
integrations the config asks for. Call it once, as early in boot as you can.
| option | env | default | what it does |
|---|---|---|---|
url |
SPLATTY_URL |
https://splatty.app |
Server base URL; the SDK posts to <url>/api/envelope |
dsn |
SPLATTY_DSN |
— (required) | Project key, sent as Authorization: Bearer <dsn> |
environment |
NODE_ENV / RAILS_ENV |
development |
Stamped on every event and log entry |
release |
SPLATTY_RELEASE |
— | Stamped on every event and log entry |
enabled |
— | true |
Set false to turn every capture into a no-op |
logs |
— | true |
Install the batching log appender |
logOptions |
— | — | Appender tuning, see Logs |
captureConsole |
— | false |
Patch console so its output is shipped as logs |
captureUnhandled |
— | false |
Install uncaughtException / unhandledRejection handlers |
sendDefaultPii |
— | false |
Send request headers verbatim instead of filtering them |
contextLines |
— | 5 |
Source lines sent either side of a stack frame; 0 disables |
serverName |
— | os.hostname() |
Overrides the reported host |
openTimeoutMs |
— | 5000 |
Connect timeout |
readTimeoutMs |
— | 10000 |
Idle/read timeout |
logger |
— | null (falls back to console) |
Where the SDK writes its own warnings |
beforeSend |
— | — | Last chance to mutate or drop an event |
A configure-block form is available too, matching the Ruby SDK:
splatty.init((config) => {
config.dsn = process.env.SPLATTY_DSN;
config.environment = "production";
config.logs = false;
});By default (sendDefaultPii = false) sensitive request headers — Cookie,
Authorization, CSRF tokens, API keys, session and password headers — are
replaced with [Filtered] before an event leaves the process. Matching is
case-insensitive, so cookie and Cookie are both caught.
Set sendDefaultPii = true only if you understand that cookies and auth tokens
will then be transmitted and stored.
A missing dsn, an empty url or a url that won't parse makes Splatty warn
once and disable itself. init() still returns a client, every capture becomes
a no-op, and your app boots as normal.
splatty.init({ dsn: undefined });
// [Splatty] disabled: config.dsn is required
splatty.enabled(); // falseawait splatty.captureException(err);
await splatty.captureMessage("hello", { level: "info" });Both resolve to the event id, or to null when Splatty is disabled or the
event was dropped by beforeSend.
Note the two signatures differ: captureException takes a scope as its second
argument, while captureMessage takes an options object with the scope nested
under scope.
await splatty.captureException(err, {
level: "error", // default "error"
transaction: "POST /checkout",
tags: { area: "billing" }, // indexed, string values
extra: { orderId, cartSize: 12 }, // free-form JSON
contexts: { app: { build: "1.2.3" } },
request: { url, method, headers },
});
await splatty.captureMessage("cache miss storm", {
level: "warn", // default "info"
scope: { tags: { area: "cache" }, extra: { misses: 4213 } },
});An exception object is only reported once. A job failure that surfaces through
both a worker hook and a process handler produces a single event — the second
call resolves to null. Deduplication is per object identity, so two distinct
new Error("boom") instances are two events.
Runs after header scrubbing, right before the event is serialized. Return
null to drop it.
splatty.init({
dsn,
beforeSend: (event) => {
if (event.exception?.values.at(-1)?.type === "AbortError") return null;
event.tags["region"] = process.env.FLY_REGION ?? "unknown";
return event;
},
});import express from "express";
import { errorHandler } from "splatty/express";
const app = express();
// ...routes...
app.use(errorHandler());Register it after your routes and before any error renderer. The middleware reports the exception and hands it to the next error handler, so your own error responses are untouched.
Events carry the request URL, method and headers, a request_id tag (from
req.id if something like pino-http set one, otherwise the X-Request-Id
header) and a transaction built from the matched route, e.g. GET /users/:id.
To report an error yourself from inside a route while keeping the same request
context, reuse buildScope:
import { buildScope } from "splatty/express";
app.get("/users/:id", async (req, res) => {
try {
res.json(await loadUser(req.params.id));
} catch (e) {
await splatty.captureException(e, buildScope(req));
res.status(500).end();
}
});Job failures happen outside the request cycle, so they need their own wiring.
import { Worker } from "bullmq";
import { instrumentWorker } from "splatty/bullmq";
const worker = new Worker("mailers", handler);
const uninstall = instrumentWorker(worker);Reports the failure that exhausts a job's retries — attempts BullMQ still
intends to retry stay quiet, the same way the Ruby SDK ignores Active Job
retries. Worker-level errors (a dropped Redis connection, an unparseable
payload) are reported too. instrumentWorker returns a function that detaches
both listeners again.
instrumentWorker(worker, {
captureRetries: true, // report every attempt, not just the last (default false)
backend: "bullmq", // value of the job_backend tag
});Events are tagged with job_backend, job_class (the job name) and
job_queue, get a transaction of the job name, and carry job_id,
job_attempts and job_args as extra data. Arguments are JSON-serialized and
truncated at 2048 characters with a ...(truncated) suffix.
Agenda, Bee-Queue, Graphile Worker, a hand-rolled poller — anything with an error hook can report through the same shape:
import { captureJobException } from "splatty/bullmq";
agenda.on("fail", async (err, job) => {
await captureJobException(err, {
backend: "agenda",
jobClass: job.attrs.name,
queue: "default",
jobId: job.attrs._id,
attempts: job.attrs.failCount,
args: job.attrs.data,
extra: { lockedAt: job.attrs.lockedAt },
});
});splatty.init({ dsn, captureUnhandled: true });Installs uncaughtException (reported at fatal) and unhandledRejection
(reported at error) handlers, both tagged with mechanism. The event is
shipped and pending logs are flushed, then Node's default crash behaviour is
restored — print the error and exit 1 — as long as Splatty is the only listener
for that event.
To keep the process alive and handle the crash yourself, install the handlers directly:
import { installProcessHandlers } from "splatty/process";
installProcessHandlers({ exit: false });init() installs a batching log appender unless you pass logs: false. It
buffers entries in memory and ships them as log envelope items every 15
seconds, or immediately once batchSize entries have piled up. The flush timer
is unref'd, so it never keeps a process alive on its own.
Entries about Splatty's own intake paths are dropped, so a dogfooded app can't
feed itself: every shipped batch would otherwise become a new request log, which
becomes another batch. The dropped paths are /api/envelope, /api/logs and
/api/metrics, each also matching an optional numeric project id
(/api/42/logs) and a trailing slash.
import pino from "pino";
import { splattyStream } from "splatty/pino";
const logger = pino(
{ level: "info" },
pino.multistream([{ stream: process.stdout }, { stream: splattyStream() }]),
);splattyStream() parses pino's NDJSON output, maps its numeric levels, drops
pid/hostname, and forwards the rest as fields. Pass
splattyStream({ messageKey: "message", ignore: ["req"] }) if you've customized
pino's output.
import winston from "winston";
import { SplattyTransport } from "splatty/winston";
const logger = winston.createLogger({
transports: [
new winston.transports.Console(),
new SplattyTransport({ level: "info" }),
],
});splatty.init({ dsn, captureConsole: true });Patches console.log/info/warn/error/debug/trace so their output is
forwarded as well as printed. Arguments are formatted with util.format, so
console.warn("disk %s%% full", 91) ships as disk 91% full.
For finer control — a subset of methods, or install/remove at will:
import { installConsoleCapture, uninstallConsoleCapture } from "splatty/console";
const restore = installConsoleCapture({ methods: ["warn", "error"] });
// ...
restore(); // or uninstallConsoleCapture()Hand the appender a record directly:
splatty.captureLog({
level: "info",
message: "checkout completed",
time: new Date(),
fields: { request_id: reqId, path: "/checkout", status: 200, duration_ms: 42 },
});Returns false when the entry was dropped (Splatty disabled, no appender
installed, below the configured level, or an intake path).
request_id, method, path, status, duration_ms (or duration),
controller and action are lifted out of fields into top-level columns.
Everything left in fields is stringified into a string map. A sql field is
inlined into the message: Load — SELECT 1.
Levels are normalized to debug, info, warn, error or fatal; anything
unrecognized becomes info.
splatty.init({
dsn,
logOptions: {
level: "info", // drop anything below this level
batchSize: 100, // flush once this many entries are queued
flushIntervalMs: 15_000,
queueLimit: 5_000, // over this, the oldest entry is dropped
host: os.hostname(),
},
});await splatty.flush(); // ship queued logs, keep running
await splatty.close(); // flush, remove installed integrations, drop the clientclose() is async because the final log batch still has to go over the wire.
It removes the integrations init() installed (console patch, process
handlers); teardowns you got back from instrumentWorker or
installConsoleCapture are yours to call.
| import | contents |
|---|---|
splatty |
init, capture functions, config, and everything below re-exported |
splatty/express |
errorHandler, buildScope |
splatty/bullmq |
instrumentWorker, captureJobException, jobScope |
splatty/process |
installProcessHandlers, uninstallProcessHandlers |
splatty/pino |
splattyStream |
splatty/winston |
SplattyTransport |
splatty/console |
installConsoleCapture, uninstallConsoleCapture |
The BullMQ, console and process helpers are also re-exported from the root, so
import { instrumentWorker } from "splatty" works. The Express, pino and
winston adapters are subpath-only, so importing the root never drags an adapter
you don't use into your bundle.
Lifecycle — init(options | configureFn): Client, flush(): Promise<void>,
close(): Promise<void>.
Accessors — client(): Client | null, configuration(): Configuration | null, enabled(): boolean, logAppender(): LogAppender | null.
Capture — captureException(err, scope?), captureMessage(message, { level?, scope? }), captureLog(record), captureJobException(err, context).
Building blocks, if you're assembling your own pipeline — Client,
Configuration, Transport, Scrubber, LogAppender, buildExceptionEvent,
buildMessageEvent, jobScope, encodeArgs, mapLevel, flushLogs.
Constants — VERSION, SDK_NAME, DEFAULT_URL, FILTERED,
SENSITIVE_HEADER_PATTERN, INTAKE_PATH_PATTERN, MAX_ARGS_LENGTH,
DEFAULT_BATCH_SIZE, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_QUEUE_LIMIT.
Types — Level, Scope, RequestContext, StackFrame, ExceptionValue,
EventPayload, LogRecord, LogEntry, ConfigurationOptions, InitOptions,
LogAppenderOptions, ProcessHandlerOptions, JobContext, BullMQJob,
BullMQWorker, BullMQOptions, PostResult.
Everything is POSTed gzipped to <url>/api/envelope over a keep-alive
connection, with Content-Type: application/x-splatty-envelope and
Authorization: Bearer <dsn>. The body is three newline-separated lines: an
envelope header, an item header, and the JSON payload.
{"event_id":"…","sent_at":"…","dsn":"…","sdk":{"name":"splatty.js","version":"0.1.0"}}
{"type":"event","content_type":"application/json","length":1234}
{"event_id":"…","timestamp":"…","platform":"node","level":"error","exception":{…}}
Log batches use the same shape. Their envelope header carries no event_id,
and the item header is {"type":"log","item_count":N,"content_type": "application/vnd.splatty.items.log+json","length":…} over a
{"host":…,"items":[…]} payload.
Transport failures never throw and never bubble into your code — they're warned
about through config.logger and the send resolves to null.
npm run typecheck
npm test
npm run buildMIT.