Observability

Self-hosted error tracking, logs, traces, and dashboard for ScheduleSpark apps.

Observability

@schedulespark/observability is a self-hosted error tracking and dashboard package. You bring your own Postgres connection string; the package owns a dedicated schema and does not phone home.

Related terms: npm, ScheduleSpark, @schedulespark/observability, observability, error tracking, exception tracking, Sentry alternative, self-hosted, dashboard, logs, traces, metrics, alerts, source maps, Fastify, Postgres, browser SDK, Node SDK, TypeScript.

It can run embedded inside an existing Fastify app or as a standalone dashboard service. Captures, logs, spans, metrics, saved views, comments, and issue assignment all stay in your Postgres database.

Install

npm install @schedulespark/observability

Fastify Quickstart

import { init, captureFastifyErrors } from "@schedulespark/observability/node";
import { createDashboard, registerDashboard } from "@schedulespark/observability/dashboard";
import { initStorage } from "@schedulespark/observability/storage";

const client = init({ connectionString: process.env.OBSERVABILITY_DATABASE_URL! });
captureFastifyErrors(app, client);

const storage = await initStorage({ connectionString: process.env.OBSERVABILITY_DATABASE_URL! });
registerDashboard(app, createDashboard(storage), { prefix: "/observability" });

This mounts the dashboard and ingestion endpoint under /observability. Use authorize before exposing the dashboard to real users.

Node SDK

import { init, captureFastifyErrors } from "@schedulespark/observability/node";

const client = init({
  connectionString: process.env.OBSERVABILITY_DATABASE_URL!,
  environment: process.env.NODE_ENV,
  release: process.env.GIT_SHA,
  captureUncaughtExceptions: true
});

captureFastifyErrors(app, client);

client.captureException(new Error("manual capture"), {
  route: "shift.create",
  tags: { area: "scheduler" }
});

client.captureMessage("worker invite sent", "info", {
  tags: { channel: "email" }
});

captureUncaughtExceptions captures uncaught exceptions and unhandled rejections, gives the client a bounded flush window, then exits so normal crash behavior is preserved.

tRPC Error Capture

Fastify hooks do not see all tRPC procedure errors because the adapter formats them. Add onError for unexpected failures:

app.register(fastifyTRPCPlugin, {
  trpcOptions: {
    router: appRouter,
    createContext,
    onError({ error, path }) {
      if (error.code === "INTERNAL_SERVER_ERROR") {
        client.captureException(error.cause ?? error, { route: path });
      }
    }
  }
});

Filter expected application errors like NOT_FOUND, UNAUTHORIZED, or validation failures so the dashboard stays actionable.

Dashboard Setup

import { createDashboard, registerDashboard } from "@schedulespark/observability/dashboard";
import { initStorage } from "@schedulespark/observability/storage";

const storage = await initStorage({
  connectionString: process.env.OBSERVABILITY_DATABASE_URL!,
  schema: "observability"
});

registerDashboard(app, createDashboard(storage), {
  prefix: "/observability",
  authorize: (request) => isAdmin(request),
  ingestKey: process.env.OBSERVABILITY_INGEST_KEY
});
OptionNotes
prefixURL prefix for dashboard pages and /ingest.
authorizeCalled for dashboard requests. Use your existing admin session or auth middleware.
ingestKeyRequires Authorization: Bearer <key> for HTTP ingestion.

The dashboard includes issue list/detail pages, status changes, assignees, comments, saved searches, logs, metrics, and transactions.

Browser SDK

import { init } from "@schedulespark/observability/browser";

init({
  ingestUrl: "https://your-app.example.com/observability/ingest",
  apiKey: "..."
});

The browser SDK captures window.onerror and unhandledrejection by default. It also records fetch breadcrumbs unless autoBreadcrumbs is disabled.

An ingest key in browser JavaScript is public by nature. Treat it as spam resistance, not as a secret. Put rate limiting or network restrictions in front of public ingestion endpoints when needed.

Breadcrumbs

client.addBreadcrumb({ category: "nav", message: "opened schedule" });
client.addBreadcrumb({
  category: "api",
  message: "POST /shifts",
  data: { status: 500 }
});

client.captureException(new Error("failed to create shift"));

The client stores a rolling buffer of recent breadcrumbs and snapshots it onto each capture. Secret-looking keys such as password, token, and authorization are redacted before storage.

Standalone Dashboard

npx @schedulespark/observability serve --db "$OBSERVABILITY_DATABASE_URL" --port 4318 \
  --token "$OBSERVABILITY_TOKEN" --ingest-key "$OBSERVABILITY_INGEST_KEY"

Use standalone mode when you want observability outside the main app process.

CLI Commands

CommandPurpose
serveRuns the standalone dashboard server.
migrateApplies storage migrations.
projects create / projects listManages projects and API keys.
pruneDeletes old events/logs.
rollupRolls raw metric points into hourly/daily aggregates.
sourcemapResolves minified browser stack traces with local source maps.

Examples:

npx @schedulespark/observability migrate --db "$OBSERVABILITY_DATABASE_URL"
npx @schedulespark/observability prune --db "$OBSERVABILITY_DATABASE_URL" --events-days 30 --logs-days 14
npx @schedulespark/observability projects create --db "$OBSERVABILITY_DATABASE_URL" --name "Web app"

Source Maps

Build your frontend with source maps, then resolve minified stack frames locally or in CI:

npx @schedulespark/observability sourcemap --maps ./dist/assets --stack-file trace.txt
npx @schedulespark/observability sourcemap --maps ./dist/assets --issue "$ISSUE_ID" --db "$OBSERVABILITY_DATABASE_URL"

The package does not upload or host source maps. Keep .map files wherever your build already produces them.

Tracing

const tx = client.startTransaction("shift.create", {
  route: "shift.create"
});

const span = tx.startSpan("db.query");
// ... work ...
span.finish("ok");

tx.finish("ok");

Tracing supports a transaction and direct child spans. Finished transactions are queued and flushed like captured events, then shown under the dashboard transactions page.

Metrics

client.metrics.increment("shifts.created", 1, { site: "north" });
client.metrics.gauge("queue.depth", 14);
client.metrics.histogram("shift.create.ms", 128);

Metric points are captured asynchronously. Use the rollup command to aggregate raw points for dashboard views.

Pino Logs

import pino from "pino";
import { createPinoLogStream } from "@schedulespark/observability/node";
import { initStorage } from "@schedulespark/observability/storage";

const storage = await initStorage({ connectionString: process.env.OBSERVABILITY_DATABASE_URL! });
const logger = pino(createPinoLogStream(storage));

logger.error({ route: "shift.create" }, "failed to create shift");

Logs are stored in Postgres and shown in the dashboard logs page. Use retention pruning for high-volume apps.

Prisma Instrumentation

import { instrumentPrismaClient } from "@schedulespark/observability/node";

const prismaWithObservability = instrumentPrismaClient(prisma, {
  addBreadcrumb: client.addBreadcrumb,
  getActiveTransaction: () => currentTransaction
});

@prisma/client is an optional peer dependency. The function returns a new Prisma client extension; use the returned client.

Alerts

import { createNotifier, slackWebhookChannel, startSpikeMonitor } from "@schedulespark/observability/alerts";

const notifier = createNotifier([
  slackWebhookChannel(process.env.SLACK_WEBHOOK_URL!)
]);

const dashboard = createDashboard(storage, {
  channels: [slackWebhookChannel(process.env.SLACK_WEBHOOK_URL!)]
});

const monitor = startSpikeMonitor(storage, {
  channels: [slackWebhookChannel(process.env.SLACK_WEBHOOK_URL!)],
  windowMinutes: 5,
  thresholdCount: 25,
  checkIntervalMs: 60_000
});

Alert channels dispatch asynchronously so a broken notification target does not block event capture.

Multi-Project Support

A fresh install creates and uses a default project. Add projects when multiple apps or services share one observability database:

npx @schedulespark/observability projects create --db "$OBSERVABILITY_DATABASE_URL" --name "Mobile app"
npx @schedulespark/observability projects list --db "$OBSERVABILITY_DATABASE_URL"

Node clients can pass a project id/name in init. Browser and HTTP ingestion clients send a project API key with Authorization: Bearer <key>.

Storage And Migrations

import { closeStorage, initStorage } from "@schedulespark/observability/storage";

const storage = await initStorage({
  connectionString: process.env.OBSERVABILITY_DATABASE_URL!,
  schema: "observability"
});

// later during graceful shutdown
await closeStorage(storage);

The package owns its configured schema. Keep observability tables in a dedicated schema so app migrations and observability migrations do not collide.

Included Tools

  • Error capture and grouping.
  • Dashboard with issues, comments, assignees, saved views, logs, and transactions.
  • Browser SDK with unhandled error/rejection capture.
  • Breadcrumbs with redaction for secret-looking keys.
  • Source map CLI for minified browser stacks.
  • Multi-project support with per-project API keys.

Production Checklist

  • Set authorize for dashboard access.
  • Set ingestKey before accepting captures from browsers or other services.
  • Run migrations during deploys or before starting the dashboard.
  • Configure retention with prune for events and logs.
  • Keep frontend source maps available for the sourcemap CLI.
  • Add rate limiting in front of public ingestion endpoints.
  • Use project-specific API keys when multiple services report to the same database.

Troubleshooting

SymptomCheck
Dashboard shows no eventsConfirm the app and dashboard use the same connectionString and schema.
Browser ingestion returns unauthorizedMatch browser apiKey to dashboard ingestKey or project API key.
tRPC errors are missingAdd trpcOptions.onError; Fastify hooks may not see formatted tRPC failures.
Source maps do not resolveBuild with source maps and point --maps at the directory containing .map files.
Logs grow quicklySchedule prune and choose shorter log retention than event retention.