@schedulespark/observability 0.0.2-beta-1-0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +384 -0
  4. package/SECURITY_REVIEW.md +90 -0
  5. package/dist/alerts/dispatch.d.ts +6 -0
  6. package/dist/alerts/dispatch.js +11 -0
  7. package/dist/alerts/email.d.ts +37 -0
  8. package/dist/alerts/email.js +29 -0
  9. package/dist/alerts/format.d.ts +12 -0
  10. package/dist/alerts/format.js +30 -0
  11. package/dist/alerts/index.d.ts +8 -0
  12. package/dist/alerts/index.js +4 -0
  13. package/dist/alerts/notifier.d.ts +15 -0
  14. package/dist/alerts/notifier.js +31 -0
  15. package/dist/alerts/spike.d.ts +25 -0
  16. package/dist/alerts/spike.js +41 -0
  17. package/dist/alerts/types.d.ts +38 -0
  18. package/dist/alerts/types.js +1 -0
  19. package/dist/alerts/webhook.d.ts +14 -0
  20. package/dist/alerts/webhook.js +59 -0
  21. package/dist/browser/client.d.ts +26 -0
  22. package/dist/browser/client.js +131 -0
  23. package/dist/browser/index.d.ts +3 -0
  24. package/dist/browser/index.js +1 -0
  25. package/dist/cli/bin.d.ts +2 -0
  26. package/dist/cli/bin.js +6 -0
  27. package/dist/cli/index.d.ts +10 -0
  28. package/dist/cli/index.js +45 -0
  29. package/dist/cli/migrate.d.ts +6 -0
  30. package/dist/cli/migrate.js +14 -0
  31. package/dist/cli/options.d.ts +17 -0
  32. package/dist/cli/options.js +28 -0
  33. package/dist/cli/projects.d.ts +6 -0
  34. package/dist/cli/projects.js +52 -0
  35. package/dist/cli/prune.d.ts +8 -0
  36. package/dist/cli/prune.js +32 -0
  37. package/dist/cli/rollup.d.ts +6 -0
  38. package/dist/cli/rollup.js +24 -0
  39. package/dist/cli/serve.d.ts +5 -0
  40. package/dist/cli/serve.js +72 -0
  41. package/dist/cli/sourcemap.d.ts +8 -0
  42. package/dist/cli/sourcemap.js +113 -0
  43. package/dist/core/breadcrumbs.d.ts +21 -0
  44. package/dist/core/breadcrumbs.js +20 -0
  45. package/dist/core/fingerprint.d.ts +10 -0
  46. package/dist/core/fingerprint.js +35 -0
  47. package/dist/core/index.d.ts +4 -0
  48. package/dist/core/index.js +4 -0
  49. package/dist/core/types.d.ts +219 -0
  50. package/dist/core/types.js +35 -0
  51. package/dist/dashboard/core.d.ts +67 -0
  52. package/dist/dashboard/core.js +40 -0
  53. package/dist/dashboard/fastify.d.ts +17 -0
  54. package/dist/dashboard/fastify.js +247 -0
  55. package/dist/dashboard/html-secondary.d.ts +15 -0
  56. package/dist/dashboard/html-secondary.js +85 -0
  57. package/dist/dashboard/html-shared.d.ts +14 -0
  58. package/dist/dashboard/html-shared.js +39 -0
  59. package/dist/dashboard/html.d.ts +18 -0
  60. package/dist/dashboard/html.js +161 -0
  61. package/dist/dashboard/index.d.ts +5 -0
  62. package/dist/dashboard/index.js +3 -0
  63. package/dist/node/client.d.ts +38 -0
  64. package/dist/node/client.js +191 -0
  65. package/dist/node/exception.d.ts +15 -0
  66. package/dist/node/exception.js +33 -0
  67. package/dist/node/fastify.d.ts +7 -0
  68. package/dist/node/fastify.js +20 -0
  69. package/dist/node/index.d.ts +11 -0
  70. package/dist/node/index.js +5 -0
  71. package/dist/node/logs.d.ts +23 -0
  72. package/dist/node/logs.js +90 -0
  73. package/dist/node/metrics.d.ts +27 -0
  74. package/dist/node/metrics.js +18 -0
  75. package/dist/node/prisma.d.ts +63 -0
  76. package/dist/node/prisma.js +53 -0
  77. package/dist/node/queue.d.ts +25 -0
  78. package/dist/node/queue.js +42 -0
  79. package/dist/node/redact.d.ts +7 -0
  80. package/dist/node/redact.js +26 -0
  81. package/dist/node/tracing.d.ts +28 -0
  82. package/dist/node/tracing.js +49 -0
  83. package/dist/storage/comments.d.ts +13 -0
  84. package/dist/storage/comments.js +20 -0
  85. package/dist/storage/index.d.ts +31 -0
  86. package/dist/storage/index.js +33 -0
  87. package/dist/storage/logs.d.ts +19 -0
  88. package/dist/storage/logs.js +35 -0
  89. package/dist/storage/metrics-ingest.d.ts +14 -0
  90. package/dist/storage/metrics-ingest.js +16 -0
  91. package/dist/storage/metrics-rollup.d.ts +38 -0
  92. package/dist/storage/metrics-rollup.js +80 -0
  93. package/dist/storage/metrics.d.ts +7 -0
  94. package/dist/storage/metrics.js +10 -0
  95. package/dist/storage/migrations.d.ts +17 -0
  96. package/dist/storage/migrations.js +238 -0
  97. package/dist/storage/pool.d.ts +30 -0
  98. package/dist/storage/pool.js +28 -0
  99. package/dist/storage/projects.d.ts +17 -0
  100. package/dist/storage/projects.js +31 -0
  101. package/dist/storage/queries.d.ts +48 -0
  102. package/dist/storage/queries.js +146 -0
  103. package/dist/storage/retention.d.ts +28 -0
  104. package/dist/storage/retention.js +29 -0
  105. package/dist/storage/rows.d.ts +151 -0
  106. package/dist/storage/rows.js +134 -0
  107. package/dist/storage/saved-views.d.ts +15 -0
  108. package/dist/storage/saved-views.js +28 -0
  109. package/dist/storage/schema-ident.d.ts +7 -0
  110. package/dist/storage/schema-ident.js +13 -0
  111. package/dist/storage/spans.d.ts +14 -0
  112. package/dist/storage/spans.js +21 -0
  113. package/docs/screenshots/dashboard.jpg +0 -0
  114. package/docs/standalone-example.md +110 -0
  115. package/package.json +102 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@schedulespark/observability` are documented here.
4
+
5
+ This project follows a pre-1.0 beta release model. Until `1.0.0`, minor and patch beta releases may include API adjustments. Version changes must be approved as explicit release decisions before publishing.
6
+
7
+ ## 0.0.2-beta-1-0-0
8
+
9
+ - Changed `publishConfig.tag` from `beta` to `latest` so `npm publish` updates the `latest` dist-tag, fixing stale README/screenshots on the npmjs.com package page.
10
+
11
+ ## 0.0.1-beta-3-0-0
12
+
13
+ - Prepared package for npm publish: removed `private`, added `files`, repository metadata, keywords, and `publishConfig.tag: beta`.
14
+ - Added README screenshot (observability dashboard) served via jsDelivr.
15
+
16
+ ## Release Notes Process
17
+
18
+ For each future release:
19
+
20
+ 1. Add an entry above the previous release.
21
+ 2. Summarize user-facing changes, API changes, fixes, and migration notes.
22
+ 3. Link relevant Linear issues or pull requests when available.
23
+ 4. Confirm the package version separately before publishing.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stephen M
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,384 @@
1
+ # @schedulespark/observability
2
+
3
+ A self-hosted, Sentry-style error tracking and dashboard package. You bring your own
4
+ Postgres connection string; it owns a dedicated schema inside that database and never
5
+ phones home.
6
+
7
+ ## Screenshot
8
+
9
+ ![Observability dashboard showing issues, status, assignee, event counts, logs, metrics, and traces navigation](https://cdn.jsdelivr.net/npm/@schedulespark/observability/docs/screenshots/dashboard.jpg)
10
+
11
+ ## Quickstart
12
+
13
+ The smallest possible setup — capture errors in a Node/Fastify app and view them at
14
+ `/observability` inside that same app:
15
+
16
+ ```ts
17
+ import { init, captureFastifyErrors } from "@schedulespark/observability/node";
18
+ import { createDashboard, registerDashboard } from "@schedulespark/observability/dashboard";
19
+ import { initStorage } from "@schedulespark/observability/storage";
20
+
21
+ const client = init({ connectionString: process.env.OBSERVABILITY_DATABASE_URL! });
22
+ captureFastifyErrors(app, client);
23
+
24
+ const storage = await initStorage({ connectionString: process.env.OBSERVABILITY_DATABASE_URL! });
25
+ registerDashboard(app, createDashboard(storage), { prefix: "/observability" });
26
+ ```
27
+
28
+ That's it — no separate service to deploy, no external database to provision beyond
29
+ the one you already have. Everything below (tRPC, alerts, retention, standalone mode,
30
+ access control) is opt-in on top of this. See
31
+ [docs/standalone-example.md](./docs/standalone-example.md) for the same walkthrough
32
+ framed as a service outside this monorepo.
33
+
34
+ ## Node SDK
35
+
36
+ ```ts
37
+ import { init, captureFastifyErrors } from "@schedulespark/observability/node";
38
+
39
+ const client = init({
40
+ connectionString: process.env.OBSERVABILITY_DATABASE_URL!,
41
+ environment: process.env.NODE_ENV,
42
+ // Optional: also reports crashes outside request handling (startup, a cron job, a
43
+ // background task). Node stops auto-exiting on uncaught exceptions once a listener
44
+ // is attached, so this captures, flushes, then exits — preserving the normal
45
+ // crash-on-uncaught-exception behavior instead of leaving the process running.
46
+ captureUncaughtExceptions: true
47
+ });
48
+
49
+ captureFastifyErrors(app, client); // auto-captures request-lifecycle errors
50
+ client.captureException(new Error("manual capture"));
51
+ ```
52
+
53
+ If you're using tRPC, `captureFastifyErrors` alone won't see procedure errors — tRPC's
54
+ Fastify adapter formats them itself before they'd reach Fastify's `onError` hook. Wire
55
+ `trpcOptions.onError` too, filtering to unexpected failures so expected business-logic
56
+ errors (`NOT_FOUND`, `UNAUTHORIZED`, etc.) don't flood the dashboard:
57
+
58
+ ```ts
59
+ app.register(fastifyTRPCPlugin, {
60
+ trpcOptions: {
61
+ router: appRouter,
62
+ createContext,
63
+ onError({ error, path }) {
64
+ if (error.code === "INTERNAL_SERVER_ERROR") {
65
+ client.captureException(error.cause ?? error, { route: path });
66
+ }
67
+ }
68
+ }
69
+ });
70
+ ```
71
+
72
+ ## Dashboard
73
+
74
+ Mount it inside your own server ("same portal"):
75
+
76
+ ```ts
77
+ import { createDashboard, registerDashboard } from "@schedulespark/observability/dashboard";
78
+ import { initStorage } from "@schedulespark/observability/storage";
79
+
80
+ const storage = await initStorage({ connectionString: process.env.OBSERVABILITY_DATABASE_URL! });
81
+ registerDashboard(app, createDashboard(storage), {
82
+ prefix: "/observability",
83
+ authorize: (request) => isMyExistingAdminSession(request),
84
+ // Optional: requires "Authorization: Bearer <key>" on POST /ingest. Without it, the
85
+ // endpoint accepts unauthenticated writes — fine for a same-origin dogfood setup,
86
+ // worth setting once other services or public clients can reach it.
87
+ ingestKey: process.env.OBSERVABILITY_INGEST_KEY
88
+ });
89
+ ```
90
+
91
+ ...or run it as a standalone server ("separate page"):
92
+
93
+ ```sh
94
+ npx @schedulespark/observability serve --db "$OBSERVABILITY_DATABASE_URL" --port 4318 \
95
+ --token "$OBSERVABILITY_TOKEN" --ingest-key "$OBSERVABILITY_INGEST_KEY"
96
+ ```
97
+
98
+ Each issue's detail page supports assigning it to someone (free-text, no user
99
+ directory built in) and a comment thread, and the issues list has a search box
100
+ (`?q=` — case-insensitive substring match on the title), a `?status=` filter, and,
101
+ once more than one project exists, a `?project=` filter. All available
102
+ programmatically too: `assignIssue`, `addComment`, `listComments` from
103
+ `@schedulespark/observability/storage`, and `listIssues(storage, { q, status, projectId })`.
104
+
105
+ Any combination of those filters can be saved under a name ("Save this search…" on
106
+ the issues list) and reapplied later — a saved view is just a link to
107
+ `{prefix}?q=&status=&project=` built from the stored filters, shared across everyone
108
+ with dashboard access (no per-user scoping, matching the rest of the dashboard).
109
+ Programmatically: `createSavedView`, `listSavedViews`, `deleteSavedView` from
110
+ `@schedulespark/observability/storage`.
111
+
112
+ ## Browser SDK
113
+
114
+ ```ts
115
+ import { init } from "@schedulespark/observability/browser";
116
+
117
+ init({
118
+ ingestUrl: "https://your-app.example.com/observability/ingest",
119
+ apiKey: "..." // matches the dashboard's ingestKey, sent as "Authorization: Bearer <apiKey>"
120
+ });
121
+ // window.onerror / unhandledrejection are captured automatically
122
+ ```
123
+
124
+ Like a Sentry DSN, an ingest key shipped in browser JS is inherently public — it deters
125
+ casual spam/abuse, not a determined attacker. Rate limiting is tracked for a later phase.
126
+
127
+ ## Breadcrumbs
128
+
129
+ A rolling buffer (last 25, oldest dropped) of recent actions attached to the next
130
+ captured error or message — snapshotted, not cleared, so a burst of related errors
131
+ keeps overlapping context:
132
+
133
+ ```ts
134
+ client.addBreadcrumb({ category: "nav", message: "loaded /dashboard" });
135
+ client.addBreadcrumb({ category: "db", message: "SELECT shifts", data: { rows: 12 } });
136
+ client.captureException(new Error("boom")); // both breadcrumbs are attached
137
+ ```
138
+
139
+ The browser SDK auto-records a `"fetch"` breadcrumb for every request (its own
140
+ ingestion POSTs excluded) unless you pass `autoBreadcrumbs: false` to `init()`.
141
+ Breadcrumb `data` is redacted the same way `context` is — secret-looking keys
142
+ (`password`, `token`, `authorization`, ...) never reach storage.
143
+
144
+ ### Source maps
145
+
146
+ Minified browser stack traces are hard to read. Build with `sourcemap: true` (Vite:
147
+ `build.sourcemap`), then resolve a stack trace's frames back to original source:
148
+
149
+ ```sh
150
+ npx @schedulespark/observability sourcemap --maps ./dist/assets --stack-file trace.txt
151
+ # or fetch the latest event straight from an issue:
152
+ npx @schedulespark/observability sourcemap --maps ./dist/assets --issue <id> --db "$OBSERVABILITY_DATABASE_URL"
153
+ ```
154
+
155
+ CLI-only for now — there's no dashboard-side automatic resolution (that needs a
156
+ release-to-map mapping this package doesn't have yet), and no source-map upload
157
+ endpoint; run it locally or as a CI step, keeping `.map` files wherever your build
158
+ already produces them.
159
+
160
+ ## Tracing
161
+
162
+ One level of nesting — a transaction and its direct spans, no grandchildren:
163
+
164
+ ```ts
165
+ const tx = client.startTransaction("shift.create", { route: "shift.create" });
166
+ const span = tx.startSpan("db.query");
167
+ // ... do the work ...
168
+ span.finish("ok");
169
+ tx.finish("ok");
170
+ ```
171
+
172
+ Finished transactions are queued and flushed the same fire-and-forget way events are.
173
+ View them at `{prefix}/transactions` in the dashboard (name/status/duration/timestamp;
174
+ no drill-down page yet).
175
+
176
+ ### Prisma query instrumentation
177
+
178
+ Optional — wraps a Prisma client with a `$extends` query extension that records a
179
+ `"db"` breadcrumb for every query, plus a child span for slow (≥100ms by default) or
180
+ failed queries when a transaction is supplied:
181
+
182
+ ```ts
183
+ import { instrumentPrismaClient } from "@schedulespark/observability/node";
184
+
185
+ const instrumentedPrisma = instrumentPrismaClient(prisma, {
186
+ getActiveTransaction: () => currentTransaction, // however your app tracks it
187
+ addBreadcrumb: client.addBreadcrumb
188
+ });
189
+ ```
190
+
191
+ `@prisma/client` is an optional peer dependency — this package never imports it
192
+ directly, so it isn't required unless you use this function. `instrumentPrismaClient`
193
+ returns a **new** client (Prisma extensions can't mutate one in place); use the
194
+ returned value, not the original. There's no automatic request-scoped context
195
+ propagation here (no `AsyncLocalStorage`) — you're responsible for tracking "the
196
+ active transaction" yourself via `getActiveTransaction`; breadcrumbs are recorded for
197
+ every query regardless.
198
+
199
+ ## Multi-project support
200
+
201
+ A single self-hosted instance can serve more than one app/service, each with its own
202
+ API key. A fresh install needs none of this — every schema already has an
203
+ auto-created `"default"` project, and everything above behaves identically with zero
204
+ configuration.
205
+
206
+ ```sh
207
+ npx @schedulespark/observability projects create --db "$OBSERVABILITY_DATABASE_URL" --name "Mobile app"
208
+ npx @schedulespark/observability projects list --db "$OBSERVABILITY_DATABASE_URL"
209
+ ```
210
+
211
+ Give a Node SDK client its own project so its captures are tagged accordingly:
212
+
213
+ ```ts
214
+ const client = init({ connectionString: process.env.OBSERVABILITY_DATABASE_URL!, project: "mobile-app" });
215
+ ```
216
+
217
+ For HTTP ingestion (the browser SDK, or any other client), send the project's own API
218
+ key as the ingest request's bearer token — the same `Authorization: Bearer <key>`
219
+ header the browser SDK's `apiKey` option already sends. It's resolved independently of
220
+ the dashboard's optional `ingestKey` gate: `ingestKey`, when set, still authenticates
221
+ every request the same way it always has; the bearer token is *separately* looked up
222
+ against each project's API key to decide which project the event belongs to, falling
223
+ back to `"default"` when it doesn't match one. The issues list also gains a `?project=`
224
+ filter (a dropdown once more than one project exists).
225
+
226
+ ## Structured logs
227
+
228
+ A dedicated `logs` table, separate from `events`/`issues` — log lines don't share the
229
+ "same bug, many occurrences" fingerprint-grouping semantics errors do. Forward pino
230
+ logs into it with `createPinoLogStream`, a plain `Writable` usable as pino's
231
+ destination:
232
+
233
+ ```ts
234
+ import pino from "pino";
235
+ import { createPinoLogStream } from "@schedulespark/observability/node";
236
+
237
+ const stream = createPinoLogStream(storage, { minLevel: "warn" }); // default "warn"
238
+ const logger = pino(stream);
239
+ ```
240
+
241
+ Only lines at/above `minLevel` are forwarded (`"trace" | "debug" | "info" | "warn" |
242
+ "error" | "fatal"`, pino's own level names) — call `stream.end()` to flush any
243
+ buffered lines before shutdown, the same way `ObservabilityClient.close()` does for
244
+ other captures. View them at `{prefix}/logs`, filterable by `?level=`.
245
+
246
+ Logs are typically much higher-volume than errors, so they get their own retention
247
+ window (default 14 days vs. `pruneEvents`'s 90):
248
+
249
+ ```sh
250
+ npx @schedulespark/observability prune --db "$OBSERVABILITY_DATABASE_URL" --logs-older-than-days 14
251
+ ```
252
+
253
+ ## Metrics
254
+
255
+ StatsD-shaped counters/gauges/histograms, via `client.metrics`:
256
+
257
+ ```ts
258
+ client.metrics.increment("shift.create.count", 1, { route: "shift.create" }); // value defaults to 1
259
+ client.metrics.gauge("queue.depth", 42);
260
+ client.metrics.histogram("request.duration_ms", 120, { route: "shift.create" });
261
+ ```
262
+
263
+ Raw points land in a `metric_points_raw` table. Since nothing prunes it automatically,
264
+ schedule a rollup job (a daily/hourly cron job, Render cron service, etc.) to aggregate
265
+ raw points into hourly/daily buckets and prune old raw points in one step:
266
+
267
+ ```sh
268
+ npx @schedulespark/observability rollup --db "$OBSERVABILITY_DATABASE_URL" --raw-older-than-days 3
269
+ ```
270
+
271
+ Or programmatically: `rollupMetrics(storage, { rawRetentionDays: 3 })` from
272
+ `@schedulespark/observability/storage`. Rollups are a full replace (not additive) of
273
+ each bucket's `sum`/`count`/`min`/`max` from the current raw data, so rerunning the job
274
+ is always safe — it never double-counts a point already rolled up in a previous run.
275
+ Histogram buckets store `sum`/`count`/`min`/`max`/`avg` only, not true percentiles — a
276
+ sketch algorithm (t-digest, etc.) is real complexity not justified for the current
277
+ table-view-only dashboard.
278
+
279
+ View the latest bucket per metric at `{basePath}/metrics`, filterable by `?bucket=hour`
280
+ or `?bucket=day`. Keep tags to bounded-cardinality dimensions (e.g. `route`,
281
+ `statusCode`); a free-form value like a user ID or request ID directly multiplies row
282
+ count in both the raw and rollup tables.
283
+
284
+ ## Alerts
285
+
286
+ Three kinds of alerts, each delivered to the same channels:
287
+
288
+ - **`new_issue`** — the first time a fingerprint is ever seen.
289
+ - **`regression`** — a `resolved` issue gets a new event; it's automatically reopened
290
+ (`status` flips back to `unresolved`) and flagged distinctly from a brand-new issue.
291
+ `ignored` issues are left alone — that status means "don't tell me about this,"
292
+ not "this is fixed."
293
+ - **`spike`** — the error rate over a rolling window crosses a threshold. Checked
294
+ in-memory on an interval per process/instance (no shared state across instances),
295
+ with a cooldown of one window's length between repeat notifications.
296
+
297
+ Repeat occurrences of an already-unresolved issue notify nothing. Works the same
298
+ whether the event arrived via the Node SDK or the HTTP ingestion endpoint:
299
+
300
+ ```ts
301
+ import { emailChannel, slackWebhookChannel, webhookChannel } from "@schedulespark/observability/alerts";
302
+
303
+ const channels = [
304
+ slackWebhookChannel(process.env.SLACK_WEBHOOK_URL!, { dashboardUrl: "https://your-app.example.com/observability" }),
305
+ webhookChannel("https://your-app.example.com/internal/observability-hook"),
306
+ emailChannel({
307
+ to: ["oncall@your-app.example.com"],
308
+ from: "observability@your-app.example.com",
309
+ transport: { host: "smtp.example.com", port: 587, auth: { user: "...", pass: "..." } },
310
+ dashboardUrl: "https://your-app.example.com/observability"
311
+ })
312
+ ];
313
+
314
+ init({ connectionString, channels }); // Node SDK
315
+ createDashboard(storage, { channels }); // HTTP ingestion path
316
+ ```
317
+
318
+ `emailChannel` sends over plain SMTP via `nodemailer` — not tied to any mail vendor —
319
+ so `transport` accepts either connection options or a custom `nodemailer` `Transport`.
320
+
321
+ The CLI's `serve` command supports the same via flags or environment variables:
322
+
323
+ ```sh
324
+ npx @schedulespark/observability serve --db "$OBSERVABILITY_DATABASE_URL" \
325
+ --slack-webhook "$OBSERVABILITY_SLACK_WEBHOOK_URL" --webhook "$OBSERVABILITY_WEBHOOK_URL"
326
+ ```
327
+
328
+ A failing channel is logged and skipped — it never blocks event capture or other
329
+ channels.
330
+
331
+ Enable spike detection via the Node SDK (defaults: 20 errors in 5 minutes, checked
332
+ every 60 seconds):
333
+
334
+ ```ts
335
+ init({ connectionString, channels, spikeMonitor: {} });
336
+ // or tune it: spikeMonitor: { thresholdCount: 50, windowMinutes: 10 }
337
+ ```
338
+
339
+ ## Production migrations
340
+
341
+ Run migrations explicitly during deploy instead of relying on the SDK's implicit
342
+ on-`init()` migration:
343
+
344
+ ```sh
345
+ npx @schedulespark/observability migrate --db "$OBSERVABILITY_DATABASE_URL"
346
+ ```
347
+
348
+ ## Retention
349
+
350
+ The `events` table grows without bound otherwise — nothing prunes it automatically,
351
+ since this writes into your own database. Schedule this (a daily cron job, Render cron
352
+ service, etc.) to delete event detail older than a retention window; issues and their
353
+ aggregate counts are kept, only the individual event rows are dropped:
354
+
355
+ ```sh
356
+ npx @schedulespark/observability prune --db "$OBSERVABILITY_DATABASE_URL" --older-than-days 90
357
+ ```
358
+
359
+ Or programmatically: `pruneEvents(storage, 90)` from `@schedulespark/observability/storage`
360
+ (and `pruneLogs(storage, 14)` for the separate, shorter-retention `logs` table — the
361
+ same CLI `prune` command handles both, see [Structured logs](#structured-logs)).
362
+
363
+ If you'd rather not run a separate CLI process, expose an internal HTTP endpoint on
364
+ your existing server and point a cron job at it instead — that's how `apps/api` in
365
+ this repo does it (`POST /internal/prune-observability`, guarded by a shared secret
366
+ header, called daily by a Render cron job).
367
+
368
+ ## Status
369
+
370
+ Phase 1 (MVP) plus alerting, retention, the browser SDK, basic tracing,
371
+ multi-project support, breadcrumbs, an SMTP alert channel, optional Prisma query
372
+ instrumentation, source-map resolution, saved views, structured log ingestion, and a
373
+ metrics API (ingestion, rollups, and a dashboard view) are built and dogfooded on this
374
+ repo's own `apps/api`/`apps/web`. See [SECURITY_REVIEW.md](./SECURITY_REVIEW.md) for a
375
+ point-in-time audit of the ingestion/storage attack surface. See the repo's
376
+ observability platform plan for the remaining roadmap (public OSS release).
377
+
378
+ This package is part of the public ScheduleSpark npm package set. It is designed to be
379
+ installed as a self-hosted observability layer inside an existing Node/Fastify service
380
+ or run as a standalone dashboard process.
381
+
382
+ ## License
383
+
384
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,90 @@
1
+ # Security review
2
+
3
+ Dated 2026-07-05. A point-in-time review of `@schedulespark/observability`'s
4
+ attack surface, verified against the code in this repo rather than assumed. Anyone
5
+ adding a new table, ingestion surface, or auth-adjacent code path should re-check it
6
+ against the applicable finding below before merging.
7
+
8
+ ## 1. SQL injection surface
9
+
10
+ Every `${...}`-interpolated *identifier* in `src/storage/*.ts` traces back to
11
+ `handle.quotedSchema`, produced only by `schema-ident.ts`'s `quoteSchemaIdentifier()`,
12
+ which validates the schema name against `^[a-z_][a-z0-9_]*$` and throws otherwise.
13
+ Table/column/index names elsewhere are hardcoded string literals, never derived from
14
+ request input.
15
+
16
+ Every *value* — including `WHERE`/`ORDER BY` filter values built dynamically (e.g.
17
+ `queries.ts`'s `listIssues`, `logs.ts`'s `listLogs`, `metrics-rollup.ts`'s
18
+ `listMetricRollups`) — goes through parameterized `$1`/`$2`/... placeholders. The
19
+ dynamically-built pieces of SQL in those functions are only ever `column = $N` clauses
20
+ and `$N` positions themselves (both derived from `params.length`, an integer, not
21
+ request data), never a value.
22
+
23
+ **Review gate**: any new table/query added in a future pass must keep this shape —
24
+ only `handle.quotedSchema` (or a hardcoded literal) may be interpolated into a query
25
+ string; every value-shaped piece of data goes through a parameter placeholder.
26
+
27
+ ## 2. Ingestion validation
28
+
29
+ `captureInputSchema` (zod) gates the event ingestion path in
30
+ `dashboard/core.ts`'s `ingest()` via `safeParse` before anything touches storage;
31
+ malformed payloads are rejected with a 400, never written to the database.
32
+
33
+ **Review gate**: the structured-log and metrics ingestion paths added in this slice
34
+ write directly from the Node SDK's typed API (`client.log`, `client.metrics.*`) rather
35
+ than through a public HTTP endpoint, so there's currently no unvalidated external input
36
+ to those tables. If a public HTTP ingestion route for logs/metrics is added later, it
37
+ needs an equivalent zod `safeParse` gate before touching storage, matching the pattern
38
+ `captureInputSchema` already establishes.
39
+
40
+ ## 3. Secret redaction gap (top finding)
41
+
42
+ `node/redact.ts`'s `SECRET_KEY_PATTERN`
43
+ (`authorization|cookie|password|secret|token|api[-_]?key`) is applied only to
44
+ `context`, via `exception.ts`'s `exceptionToInput`/`messageToInput`. It is **not**
45
+ applied to `message` or `stackTrace`.
46
+
47
+ **Concrete failure scenario**: `throw new Error(\`Stripe request failed:
48
+ sk_live_${apiKey}\`)` — or any thrown error whose message or stack frame embeds a
49
+ token, or a URL with an API key in its query string — persists that secret verbatim
50
+ into the `events` table and renders it unescaped-but-unredacted on the issue detail
51
+ page (`dashboard/html.ts`'s `renderEvent`, inside a `<pre>` block).
52
+
53
+ **Recommendation**: add a configurable regex list (defaulting to a pattern like
54
+ `sk_live_[\w]+`, bearer-token-shaped substrings, etc.) applied to `message` and
55
+ `stackTrace` the same way `redactSecrets` already walks `context`/`extra`, exposed via
56
+ the existing `beforeCapture`-style hook point. Not fixed as a drive-by in this review —
57
+ scoping the right default pattern list (avoiding both under- and over-redaction of
58
+ legitimate error text) deserves its own pass with dedicated tests, called out here as
59
+ the review's top actionable recommendation.
60
+
61
+ ## 4. Dependency audit
62
+
63
+ Confirmed no Dependabot/CodeQL/Snyk/`pnpm audit` existed anywhere in this repo prior to
64
+ this package's CI work. Now in place:
65
+
66
+ - Root `.github/dependabot.yml` — weekly `npm` (pnpm-workspace-aware from the root) and
67
+ `github-actions` update checks.
68
+ - `.github/workflows/observability-package.yml` runs `pnpm audit --audit-level=high` as
69
+ a CI step on every push/PR touching this package.
70
+
71
+ No CodeQL/Snyk integration exists yet — out of scope for this review pass; flagged here
72
+ as a known gap rather than silently omitted.
73
+
74
+ ## 5. Dashboard auth defaults (verified intentional)
75
+
76
+ `guard()`/`guardIngest()` in `dashboard/fastify.ts` allow every request through when no
77
+ `authorize`/`ingestKey` option is configured. This is **deliberate**, not an oversight:
78
+ a zero-config self-hosted install should work out of the box, and the package's own
79
+ docs (README Quickstart) instruct every deployer to supply `authorize` before exposing
80
+ the dashboard beyond localhost. Restated here so a future reviewer doesn't flag it as a
81
+ false positive: the "unconfigured = allow everything" default is a documented,
82
+ intentional trust-the-deployer posture consistent with the rest of this package (e.g.
83
+ unbounded metric tag cardinality, no built-in identity system).
84
+
85
+ ## Out of scope for this pass
86
+
87
+ - Non-Node-language SDKs / a cross-language ingestion protocol doc (explicitly
88
+ out of scope for the whole "next slice," not just this review).
89
+ - CodeQL or a third-party SAST/dependency-scanning service beyond `pnpm audit`.
90
+ - A penetration test or fuzzing pass against the ingestion endpoint.
@@ -0,0 +1,6 @@
1
+ import type { Alert, NotificationChannel } from "./types.js";
2
+ /**
3
+ * Fires every channel independently; a channel that rejects is logged and otherwise
4
+ * ignored so one broken destination can't block the others or event capture itself.
5
+ */
6
+ export declare function dispatchAlert(channels: NotificationChannel[], alert: Alert): void;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Fires every channel independently; a channel that rejects is logged and otherwise
3
+ * ignored so one broken destination can't block the others or event capture itself.
4
+ */
5
+ export function dispatchAlert(channels, alert) {
6
+ for (const channel of channels) {
7
+ channel.notify(alert).catch((error) => {
8
+ console.error(`[observability] notification channel "${channel.name}" failed`, error);
9
+ });
10
+ }
11
+ }
@@ -0,0 +1,37 @@
1
+ import type { NotificationChannel } from "./types.js";
2
+ import type { Transport } from "nodemailer";
3
+ /**
4
+ * Plain SMTP connection options, passed straight through to
5
+ * `nodemailer.createTransport()` untouched. Shaped like (but not imported from)
6
+ * nodemailer's own `SMTPTransport.Options` — a `NodeNext`-resolution deep import of
7
+ * `nodemailer/lib/smtp-transport` doesn't resolve cleanly under this package's
8
+ * TypeScript config, and duplicating the handful of fields actually needed here is
9
+ * simpler than fighting that resolution.
10
+ */
11
+ export interface SmtpConnectionOptions {
12
+ host: string;
13
+ port?: number;
14
+ secure?: boolean;
15
+ auth?: {
16
+ user: string;
17
+ pass: string;
18
+ };
19
+ }
20
+ /**
21
+ * Options for an SMTP-based email notification channel. Accepts either plain SMTP
22
+ * connection options (the common case) or a custom `Transport` implementation.
23
+ */
24
+ export interface EmailChannelOptions {
25
+ to: string | string[];
26
+ from: string;
27
+ transport: SmtpConnectionOptions | Transport | string;
28
+ dashboardUrl?: string;
29
+ }
30
+ /**
31
+ * Creates a channel that emails alerts over plain SMTP via `nodemailer` — deliberately
32
+ * not tied to any specific mail vendor (unlike this repo's own `apps/api`, which uses
33
+ * SendGrid's API directly), since forcing every self-hoster of this package onto one
34
+ * vendor would be an unjustified lock-in. SMTP is universal; SendGrid itself supports
35
+ * an SMTP relay, so this doesn't even exclude SendGrid users.
36
+ */
37
+ export declare function emailChannel(options: EmailChannelOptions): NotificationChannel;
@@ -0,0 +1,29 @@
1
+ import nodemailer from "nodemailer";
2
+ import { alertIssueLink, summarizeAlert } from "./format.js";
3
+ /**
4
+ * Creates a channel that emails alerts over plain SMTP via `nodemailer` — deliberately
5
+ * not tied to any specific mail vendor (unlike this repo's own `apps/api`, which uses
6
+ * SendGrid's API directly), since forcing every self-hoster of this package onto one
7
+ * vendor would be an unjustified lock-in. SMTP is universal; SendGrid itself supports
8
+ * an SMTP relay, so this doesn't even exclude SendGrid users.
9
+ */
10
+ export function emailChannel(options) {
11
+ const transporter = nodemailer.createTransport(options.transport);
12
+ return {
13
+ name: "email",
14
+ notify: (alert) => sendAlertEmail(transporter, options, alert)
15
+ };
16
+ }
17
+ /**
18
+ * Sends a single alert as a plain-text email.
19
+ */
20
+ async function sendAlertEmail(transporter, options, alert) {
21
+ const summary = summarizeAlert(alert);
22
+ const link = alertIssueLink(alert, options.dashboardUrl);
23
+ await transporter.sendMail({
24
+ to: options.to,
25
+ from: options.from,
26
+ subject: summary,
27
+ text: link ? `${summary}\n\n${link}` : summary
28
+ });
29
+ }
@@ -0,0 +1,12 @@
1
+ import type { Alert } from "./types.js";
2
+ export declare const ALERT_LABEL: Record<Alert["kind"], string>;
3
+ /**
4
+ * A one-line, markup-free summary of an alert — the shared "what happened" wording
5
+ * every channel builds its own presentation on top of.
6
+ */
7
+ export declare function summarizeAlert(alert: Alert): string;
8
+ /**
9
+ * The dashboard issue-detail link for an alert, or `undefined` for alert kinds with
10
+ * no single issue to link to (a spike) or when no dashboard URL is configured.
11
+ */
12
+ export declare function alertIssueLink(alert: Alert, dashboardUrl?: string): string | undefined;
@@ -0,0 +1,30 @@
1
+ export /**
2
+ * Human-readable label for each alert kind, shared across every channel's own
3
+ * formatting so the wording doesn't drift between Slack, email, and webhook
4
+ * payloads.
5
+ */ const ALERT_LABEL = {
6
+ new_issue: "New issue",
7
+ regression: "Issue reopened",
8
+ spike: "Error spike"
9
+ };
10
+ /**
11
+ * A one-line, markup-free summary of an alert — the shared "what happened" wording
12
+ * every channel builds its own presentation on top of.
13
+ */
14
+ export function summarizeAlert(alert) {
15
+ const label = ALERT_LABEL[alert.kind];
16
+ if (alert.kind === "spike") {
17
+ return `${label}: ${String(alert.count)} errors in the last ${String(alert.windowMinutes)} minutes`;
18
+ }
19
+ return `${label} (${alert.issue.level}): ${alert.issue.title}`;
20
+ }
21
+ /**
22
+ * The dashboard issue-detail link for an alert, or `undefined` for alert kinds with
23
+ * no single issue to link to (a spike) or when no dashboard URL is configured.
24
+ */
25
+ export function alertIssueLink(alert, dashboardUrl) {
26
+ if (alert.kind === "spike" || !dashboardUrl) {
27
+ return undefined;
28
+ }
29
+ return `${dashboardUrl}/issues/${alert.issue.id}`;
30
+ }
@@ -0,0 +1,8 @@
1
+ export { emailChannel } from "./email.js";
2
+ export type { EmailChannelOptions, SmtpConnectionOptions } from "./email.js";
3
+ export { createNotifier } from "./notifier.js";
4
+ export type { Notifier } from "./notifier.js";
5
+ export { startSpikeMonitor } from "./spike.js";
6
+ export type { SpikeMonitor, SpikeMonitorOptions } from "./spike.js";
7
+ export type { Alert, NewIssueAlert, NotificationChannel, RegressionAlert, SpikeAlert } from "./types.js";
8
+ export { slackWebhookChannel, webhookChannel } from "./webhook.js";
@@ -0,0 +1,4 @@
1
+ export { emailChannel } from "./email.js";
2
+ export { createNotifier } from "./notifier.js";
3
+ export { startSpikeMonitor } from "./spike.js";
4
+ export { slackWebhookChannel, webhookChannel } from "./webhook.js";