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/observabilityFastify 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
});| Option | Notes |
|---|---|
prefix | URL prefix for dashboard pages and /ingest. |
authorize | Called for dashboard requests. Use your existing admin session or auth middleware. |
ingestKey | Requires 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
| Command | Purpose |
|---|---|
serve | Runs the standalone dashboard server. |
migrate | Applies storage migrations. |
projects create / projects list | Manages projects and API keys. |
prune | Deletes old events/logs. |
rollup | Rolls raw metric points into hourly/daily aggregates. |
sourcemap | Resolves 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
authorizefor dashboard access. - Set
ingestKeybefore accepting captures from browsers or other services. - Run migrations during deploys or before starting the dashboard.
- Configure retention with
prunefor events and logs. - Keep frontend source maps available for the
sourcemapCLI. - Add rate limiting in front of public ingestion endpoints.
- Use project-specific API keys when multiple services report to the same database.
Troubleshooting
| Symptom | Check |
|---|---|
| Dashboard shows no events | Confirm the app and dashboard use the same connectionString and schema. |
| Browser ingestion returns unauthorized | Match browser apiKey to dashboard ingestKey or project API key. |
| tRPC errors are missing | Add trpcOptions.onError; Fastify hooks may not see formatted tRPC failures. |
| Source maps do not resolve | Build with source maps and point --maps at the directory containing .map files. |
| Logs grow quickly | Schedule prune and choose shorter log retention than event retention. |