@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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +384 -0
- package/SECURITY_REVIEW.md +90 -0
- package/dist/alerts/dispatch.d.ts +6 -0
- package/dist/alerts/dispatch.js +11 -0
- package/dist/alerts/email.d.ts +37 -0
- package/dist/alerts/email.js +29 -0
- package/dist/alerts/format.d.ts +12 -0
- package/dist/alerts/format.js +30 -0
- package/dist/alerts/index.d.ts +8 -0
- package/dist/alerts/index.js +4 -0
- package/dist/alerts/notifier.d.ts +15 -0
- package/dist/alerts/notifier.js +31 -0
- package/dist/alerts/spike.d.ts +25 -0
- package/dist/alerts/spike.js +41 -0
- package/dist/alerts/types.d.ts +38 -0
- package/dist/alerts/types.js +1 -0
- package/dist/alerts/webhook.d.ts +14 -0
- package/dist/alerts/webhook.js +59 -0
- package/dist/browser/client.d.ts +26 -0
- package/dist/browser/client.js +131 -0
- package/dist/browser/index.d.ts +3 -0
- package/dist/browser/index.js +1 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +6 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +45 -0
- package/dist/cli/migrate.d.ts +6 -0
- package/dist/cli/migrate.js +14 -0
- package/dist/cli/options.d.ts +17 -0
- package/dist/cli/options.js +28 -0
- package/dist/cli/projects.d.ts +6 -0
- package/dist/cli/projects.js +52 -0
- package/dist/cli/prune.d.ts +8 -0
- package/dist/cli/prune.js +32 -0
- package/dist/cli/rollup.d.ts +6 -0
- package/dist/cli/rollup.js +24 -0
- package/dist/cli/serve.d.ts +5 -0
- package/dist/cli/serve.js +72 -0
- package/dist/cli/sourcemap.d.ts +8 -0
- package/dist/cli/sourcemap.js +113 -0
- package/dist/core/breadcrumbs.d.ts +21 -0
- package/dist/core/breadcrumbs.js +20 -0
- package/dist/core/fingerprint.d.ts +10 -0
- package/dist/core/fingerprint.js +35 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +4 -0
- package/dist/core/types.d.ts +219 -0
- package/dist/core/types.js +35 -0
- package/dist/dashboard/core.d.ts +67 -0
- package/dist/dashboard/core.js +40 -0
- package/dist/dashboard/fastify.d.ts +17 -0
- package/dist/dashboard/fastify.js +247 -0
- package/dist/dashboard/html-secondary.d.ts +15 -0
- package/dist/dashboard/html-secondary.js +85 -0
- package/dist/dashboard/html-shared.d.ts +14 -0
- package/dist/dashboard/html-shared.js +39 -0
- package/dist/dashboard/html.d.ts +18 -0
- package/dist/dashboard/html.js +161 -0
- package/dist/dashboard/index.d.ts +5 -0
- package/dist/dashboard/index.js +3 -0
- package/dist/node/client.d.ts +38 -0
- package/dist/node/client.js +191 -0
- package/dist/node/exception.d.ts +15 -0
- package/dist/node/exception.js +33 -0
- package/dist/node/fastify.d.ts +7 -0
- package/dist/node/fastify.js +20 -0
- package/dist/node/index.d.ts +11 -0
- package/dist/node/index.js +5 -0
- package/dist/node/logs.d.ts +23 -0
- package/dist/node/logs.js +90 -0
- package/dist/node/metrics.d.ts +27 -0
- package/dist/node/metrics.js +18 -0
- package/dist/node/prisma.d.ts +63 -0
- package/dist/node/prisma.js +53 -0
- package/dist/node/queue.d.ts +25 -0
- package/dist/node/queue.js +42 -0
- package/dist/node/redact.d.ts +7 -0
- package/dist/node/redact.js +26 -0
- package/dist/node/tracing.d.ts +28 -0
- package/dist/node/tracing.js +49 -0
- package/dist/storage/comments.d.ts +13 -0
- package/dist/storage/comments.js +20 -0
- package/dist/storage/index.d.ts +31 -0
- package/dist/storage/index.js +33 -0
- package/dist/storage/logs.d.ts +19 -0
- package/dist/storage/logs.js +35 -0
- package/dist/storage/metrics-ingest.d.ts +14 -0
- package/dist/storage/metrics-ingest.js +16 -0
- package/dist/storage/metrics-rollup.d.ts +38 -0
- package/dist/storage/metrics-rollup.js +80 -0
- package/dist/storage/metrics.d.ts +7 -0
- package/dist/storage/metrics.js +10 -0
- package/dist/storage/migrations.d.ts +17 -0
- package/dist/storage/migrations.js +238 -0
- package/dist/storage/pool.d.ts +30 -0
- package/dist/storage/pool.js +28 -0
- package/dist/storage/projects.d.ts +17 -0
- package/dist/storage/projects.js +31 -0
- package/dist/storage/queries.d.ts +48 -0
- package/dist/storage/queries.js +146 -0
- package/dist/storage/retention.d.ts +28 -0
- package/dist/storage/retention.js +29 -0
- package/dist/storage/rows.d.ts +151 -0
- package/dist/storage/rows.js +134 -0
- package/dist/storage/saved-views.d.ts +15 -0
- package/dist/storage/saved-views.js +28 -0
- package/dist/storage/schema-ident.d.ts +7 -0
- package/dist/storage/schema-ident.js +13 -0
- package/dist/storage/spans.d.ts +14 -0
- package/dist/storage/spans.js +21 -0
- package/docs/screenshots/dashboard.jpg +0 -0
- package/docs/standalone-example.md +110 -0
- 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
|
+

|
|
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";
|