@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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { MetricsApi } from "./metrics.js";
|
|
2
|
+
import type { Transaction } from "./tracing.js";
|
|
3
|
+
import type { NotificationChannel, SpikeMonitorOptions } from "../alerts/index.js";
|
|
4
|
+
import type { BreadcrumbInput, CaptureInput, EventContext, EventLevel } from "../core/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* Options for initializing the Node observability client.
|
|
7
|
+
*/
|
|
8
|
+
export interface ObservabilityClientOptions {
|
|
9
|
+
connectionString: string;
|
|
10
|
+
schema?: string;
|
|
11
|
+
environment?: string;
|
|
12
|
+
release?: string;
|
|
13
|
+
project?: string;
|
|
14
|
+
flushIntervalMs?: number;
|
|
15
|
+
maxQueueSize?: number;
|
|
16
|
+
beforeCapture?: (input: CaptureInput) => CaptureInput | null;
|
|
17
|
+
channels?: NotificationChannel[];
|
|
18
|
+
captureUncaughtExceptions?: boolean;
|
|
19
|
+
spikeMonitor?: Omit<SpikeMonitorOptions, "channels">;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The Node SDK's public capture API.
|
|
23
|
+
*/
|
|
24
|
+
export interface ObservabilityClient {
|
|
25
|
+
captureException: (error: unknown, context?: EventContext) => void;
|
|
26
|
+
captureMessage: (message: string, level?: EventLevel, context?: EventContext) => void;
|
|
27
|
+
addBreadcrumb: (breadcrumb: BreadcrumbInput) => void;
|
|
28
|
+
startTransaction: (name: string, tags?: Record<string, string>) => Transaction;
|
|
29
|
+
metrics: MetricsApi;
|
|
30
|
+
flush: () => Promise<void>;
|
|
31
|
+
close: () => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Initializes the observability client. Returns immediately (storage connects and
|
|
35
|
+
* migrates in the background) so a slow or briefly unreachable database never delays
|
|
36
|
+
* host application startup; captures made before storage is ready are buffered.
|
|
37
|
+
*/
|
|
38
|
+
export declare function init(options: ObservabilityClientOptions): ObservabilityClient;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createNotifier, startSpikeMonitor } from "../alerts/index.js";
|
|
2
|
+
import { captureInputSchema, createBreadcrumbBuffer, DEFAULT_PROJECT_ID } from "../core/index.js";
|
|
3
|
+
import { closeStorage, initStorage, recordMetricPoint, recordSpan } from "../storage/index.js";
|
|
4
|
+
import { exceptionToInput, messageToInput } from "./exception.js";
|
|
5
|
+
import { createMetricsApi } from "./metrics.js";
|
|
6
|
+
import { createCaptureQueue } from "./queue.js";
|
|
7
|
+
import { redactSecrets } from "./redact.js";
|
|
8
|
+
import { createTracer } from "./tracing.js";
|
|
9
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 2000;
|
|
10
|
+
const DEFAULT_MAX_QUEUE_SIZE = 1000;
|
|
11
|
+
/**
|
|
12
|
+
* Initializes the observability client. Returns immediately (storage connects and
|
|
13
|
+
* migrates in the background) so a slow or briefly unreachable database never delays
|
|
14
|
+
* host application startup; captures made before storage is ready are buffered.
|
|
15
|
+
*/
|
|
16
|
+
export function init(options) {
|
|
17
|
+
let handle;
|
|
18
|
+
let spikeMonitor;
|
|
19
|
+
const notifier = createNotifier(options.channels ?? []);
|
|
20
|
+
const ready = initStorage({ connectionString: options.connectionString, schema: options.schema })
|
|
21
|
+
.then((initialized) => {
|
|
22
|
+
handle = initialized;
|
|
23
|
+
if (options.spikeMonitor) {
|
|
24
|
+
spikeMonitor = startSpikeMonitor(initialized, { ...options.spikeMonitor, channels: options.channels ?? [] });
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
.catch((error) => {
|
|
28
|
+
console.error("[observability] failed to initialize storage", error);
|
|
29
|
+
});
|
|
30
|
+
const flushDeps = { notifier, projectId: options.project ?? DEFAULT_PROJECT_ID };
|
|
31
|
+
const { queue, spanQueue, metricQueue } = createQueues(options, ready, () => handle, flushDeps);
|
|
32
|
+
const tracer = createTracer(spanQueue);
|
|
33
|
+
const breadcrumbs = createBreadcrumbBuffer();
|
|
34
|
+
const capture = (input) => {
|
|
35
|
+
enqueueValidated(queue, applyDefaults({ ...input, breadcrumbs: breadcrumbs.snapshot() }, options), options.beforeCapture);
|
|
36
|
+
};
|
|
37
|
+
const client = {
|
|
38
|
+
captureException(error, context) {
|
|
39
|
+
capture(exceptionToInput(error, mergeContext(context, options)));
|
|
40
|
+
},
|
|
41
|
+
captureMessage(message, level = "info", context) {
|
|
42
|
+
capture(messageToInput(message, level, mergeContext(context, options)));
|
|
43
|
+
},
|
|
44
|
+
addBreadcrumb(breadcrumb) {
|
|
45
|
+
breadcrumbs.add({ ...breadcrumb, data: breadcrumb.data ? redactSecrets(breadcrumb.data) : undefined });
|
|
46
|
+
},
|
|
47
|
+
startTransaction: tracer.startTransaction,
|
|
48
|
+
metrics: createMetricsApi(metricQueue),
|
|
49
|
+
async flush() {
|
|
50
|
+
await Promise.all([queue.flush(), spanQueue.flush(), metricQueue.flush()]);
|
|
51
|
+
},
|
|
52
|
+
async close() {
|
|
53
|
+
spikeMonitor?.stop();
|
|
54
|
+
await Promise.all([queue.close(), spanQueue.close(), metricQueue.close()]);
|
|
55
|
+
await ready;
|
|
56
|
+
if (handle) {
|
|
57
|
+
await closeStorage(handle);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
if (options.captureUncaughtExceptions) {
|
|
62
|
+
registerProcessCrashHandlers(client);
|
|
63
|
+
}
|
|
64
|
+
return client;
|
|
65
|
+
}
|
|
66
|
+
const CRASH_FLUSH_TIMEOUT_MS = 2000;
|
|
67
|
+
/**
|
|
68
|
+
* Reports crashes that would otherwise never reach the SDK: an uncaught exception or
|
|
69
|
+
* unhandled rejection anywhere in the process, not just inside an instrumented
|
|
70
|
+
* request. Node stops terminating the process on its own once a listener is attached,
|
|
71
|
+
* so an uncaught exception is captured, given a bounded window to flush, and then the
|
|
72
|
+
* process is exited — preserving Node's normal crash-on-uncaught-exception behavior
|
|
73
|
+
* instead of leaving the process running in a possibly-corrupted state.
|
|
74
|
+
*/
|
|
75
|
+
function registerProcessCrashHandlers(client) {
|
|
76
|
+
process.on("uncaughtException", (error) => {
|
|
77
|
+
client.captureException(error);
|
|
78
|
+
void Promise.race([client.close(), delay(CRASH_FLUSH_TIMEOUT_MS)]).finally(() => {
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
process.on("unhandledRejection", (reason) => {
|
|
83
|
+
client.captureException(reason);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolves after `ms` milliseconds.
|
|
88
|
+
*/
|
|
89
|
+
function delay(ms) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
setTimeout(resolve, ms);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Creates the three capture queues `init()` needs (events, spans, metrics), each
|
|
96
|
+
* flushed on the same interval/size limits — extracted out of `init()` itself to
|
|
97
|
+
* keep that function's complexity down.
|
|
98
|
+
*/
|
|
99
|
+
function createQueues(options, ready, getHandle, flushDeps) {
|
|
100
|
+
const flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
101
|
+
const maxQueueSize = options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
|
|
102
|
+
return {
|
|
103
|
+
queue: createCaptureQueue({
|
|
104
|
+
flushIntervalMs,
|
|
105
|
+
maxQueueSize,
|
|
106
|
+
onFlush: (batch) => flushBatch(batch, ready, getHandle, flushDeps)
|
|
107
|
+
}),
|
|
108
|
+
spanQueue: createCaptureQueue({
|
|
109
|
+
flushIntervalMs,
|
|
110
|
+
maxQueueSize,
|
|
111
|
+
onFlush: (batch) => flushSpanBatch(batch, ready, getHandle)
|
|
112
|
+
}),
|
|
113
|
+
metricQueue: createCaptureQueue({
|
|
114
|
+
flushIntervalMs,
|
|
115
|
+
maxQueueSize,
|
|
116
|
+
onFlush: (batch) => flushMetricBatch(batch, ready, getHandle, flushDeps.projectId)
|
|
117
|
+
})
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Waits for storage to be ready, then persists a batch of buffered captures, notifying
|
|
122
|
+
* any configured alert channels about newly created issues along the way.
|
|
123
|
+
*/
|
|
124
|
+
async function flushBatch(batch, ready, getHandle, deps) {
|
|
125
|
+
await ready;
|
|
126
|
+
const handle = getHandle();
|
|
127
|
+
if (!handle) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const input of batch) {
|
|
131
|
+
await deps.notifier.recordEvent(handle, input, deps.projectId);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Waits for storage to be ready, then persists a batch of finished transactions/spans.
|
|
136
|
+
*/
|
|
137
|
+
async function flushSpanBatch(batch, ready, getHandle) {
|
|
138
|
+
await ready;
|
|
139
|
+
const handle = getHandle();
|
|
140
|
+
if (!handle) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
for (const span of batch) {
|
|
144
|
+
await recordSpan(handle, span);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Waits for storage to be ready, then persists a batch of queued metric points.
|
|
149
|
+
*/
|
|
150
|
+
async function flushMetricBatch(batch, ready, getHandle, projectId) {
|
|
151
|
+
await ready;
|
|
152
|
+
const handle = getHandle();
|
|
153
|
+
if (!handle) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
for (const point of batch) {
|
|
157
|
+
await recordMetricPoint(handle, point, projectId);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Fills in `environment`/`release` on a context object from client defaults when the
|
|
162
|
+
* caller didn't set them explicitly.
|
|
163
|
+
*/
|
|
164
|
+
function mergeContext(context, options) {
|
|
165
|
+
return {
|
|
166
|
+
...context,
|
|
167
|
+
environment: context?.environment ?? options.environment,
|
|
168
|
+
release: context?.release ?? options.release
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Applies client-level defaults to a capture input's context.
|
|
173
|
+
*/
|
|
174
|
+
function applyDefaults(input, options) {
|
|
175
|
+
return { ...input, context: mergeContext(input.context, options) };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Validates a capture input against the wire schema, runs it through the caller's
|
|
179
|
+
* `beforeCapture` hook if provided, and enqueues it unless either step drops it.
|
|
180
|
+
*/
|
|
181
|
+
function enqueueValidated(queue, input, beforeCapture) {
|
|
182
|
+
const parsed = captureInputSchema.safeParse(input);
|
|
183
|
+
if (!parsed.success) {
|
|
184
|
+
console.error("[observability] dropped invalid capture", parsed.error.message);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const filtered = beforeCapture ? beforeCapture(parsed.data) : parsed.data;
|
|
188
|
+
if (filtered) {
|
|
189
|
+
queue.enqueue(filtered);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CaptureInput, EventContext, EventLevel } from "../core/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Normalizes any thrown value into an `Error` instance so callers can always read a
|
|
4
|
+
* `message`/`stack`, even for thrown strings or non-Error objects.
|
|
5
|
+
*/
|
|
6
|
+
export declare function toError(value: unknown): Error;
|
|
7
|
+
/**
|
|
8
|
+
* Builds a capture payload from a thrown value and optional context, redacting
|
|
9
|
+
* likely-secret fields from the context before it's queued.
|
|
10
|
+
*/
|
|
11
|
+
export declare function exceptionToInput(error: unknown, context?: EventContext): CaptureInput;
|
|
12
|
+
/**
|
|
13
|
+
* Builds a capture payload for a plain message (no exception involved).
|
|
14
|
+
*/
|
|
15
|
+
export declare function messageToInput(message: string, level: EventLevel, context?: EventContext): CaptureInput;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { redactSecrets } from "./redact.js";
|
|
2
|
+
const DEFAULT_ERROR_MESSAGE = "Unknown error";
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes any thrown value into an `Error` instance so callers can always read a
|
|
5
|
+
* `message`/`stack`, even for thrown strings or non-Error objects.
|
|
6
|
+
*/
|
|
7
|
+
export function toError(value) {
|
|
8
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Builds a capture payload from a thrown value and optional context, redacting
|
|
12
|
+
* likely-secret fields from the context before it's queued.
|
|
13
|
+
*/
|
|
14
|
+
export function exceptionToInput(error, context) {
|
|
15
|
+
const normalized = toError(error);
|
|
16
|
+
return {
|
|
17
|
+
level: "error",
|
|
18
|
+
message: normalized.message || DEFAULT_ERROR_MESSAGE,
|
|
19
|
+
errorType: normalized.name,
|
|
20
|
+
stackTrace: normalized.stack,
|
|
21
|
+
context: context ? redactSecrets(context) : undefined
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Builds a capture payload for a plain message (no exception involved).
|
|
26
|
+
*/
|
|
27
|
+
export function messageToInput(message, level, context) {
|
|
28
|
+
return {
|
|
29
|
+
level,
|
|
30
|
+
message,
|
|
31
|
+
context: context ? redactSecrets(context) : undefined
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ObservabilityClient } from "./client.js";
|
|
2
|
+
import type { FastifyInstance } from "fastify";
|
|
3
|
+
/**
|
|
4
|
+
* Registers a Fastify `onError` hook that reports every request-lifecycle error to
|
|
5
|
+
* the observability client with method/route/status context attached.
|
|
6
|
+
*/
|
|
7
|
+
export declare function captureFastifyErrors(app: FastifyInstance, client: ObservabilityClient): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers a Fastify `onError` hook that reports every request-lifecycle error to
|
|
3
|
+
* the observability client with method/route/status context attached.
|
|
4
|
+
*/
|
|
5
|
+
export function captureFastifyErrors(app, client) {
|
|
6
|
+
app.addHook("onError", (request, reply, error, done) => {
|
|
7
|
+
client.captureException(error, requestContext(request, reply.statusCode));
|
|
8
|
+
done();
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Builds event context from a Fastify request/response pair.
|
|
13
|
+
*/
|
|
14
|
+
function requestContext(request, statusCode) {
|
|
15
|
+
return {
|
|
16
|
+
method: request.method,
|
|
17
|
+
route: request.url,
|
|
18
|
+
statusCode
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { captureFastifyErrors } from "./fastify.js";
|
|
2
|
+
export { init } from "./client.js";
|
|
3
|
+
export { createPinoLogStream } from "./logs.js";
|
|
4
|
+
export type { PinoLogStreamOptions } from "./logs.js";
|
|
5
|
+
export type { MetricsApi } from "./metrics.js";
|
|
6
|
+
export { instrumentPrismaClient } from "./prisma.js";
|
|
7
|
+
export type { InstrumentPrismaClientOptions } from "./prisma.js";
|
|
8
|
+
export { redactSecrets } from "./redact.js";
|
|
9
|
+
export type { ObservabilityClient, ObservabilityClientOptions } from "./client.js";
|
|
10
|
+
export type { Breadcrumb, BreadcrumbInput, LogEntry, LogLevel, MetricKind } from "../core/index.js";
|
|
11
|
+
export type { Span, Transaction } from "./tracing.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Writable } from "node:stream";
|
|
2
|
+
import type { LogLevel } from "../core/index.js";
|
|
3
|
+
import type { StorageHandle } from "../storage/index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Options for `createPinoLogStream`.
|
|
6
|
+
*/
|
|
7
|
+
export interface PinoLogStreamOptions {
|
|
8
|
+
/** Only lines at/above this level are forwarded (default `"warn"`). */
|
|
9
|
+
minLevel?: LogLevel;
|
|
10
|
+
projectId?: string;
|
|
11
|
+
flushIntervalMs?: number;
|
|
12
|
+
maxQueueSize?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Creates a `Writable` usable as pino's destination (`pino(opts, stream)`) that
|
|
16
|
+
* forwards log lines at/above `minLevel` into observability storage, batched and
|
|
17
|
+
* flushed the same non-blocking way every other capture path in this package is.
|
|
18
|
+
* Deliberately a plain stream rather than a worker-thread `pino.transport()` — the
|
|
19
|
+
* queue itself is already async/non-blocking, so a synchronous destination is
|
|
20
|
+
* simpler to build, test, and reason about without losing the "never block the host"
|
|
21
|
+
* guarantee.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createPinoLogStream(handle: StorageHandle, options?: PinoLogStreamOptions): Writable;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Writable } from "node:stream";
|
|
2
|
+
import { recordLog } from "../storage/index.js";
|
|
3
|
+
import { createCaptureQueue } from "./queue.js";
|
|
4
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 2000;
|
|
5
|
+
const DEFAULT_MAX_QUEUE_SIZE = 1000;
|
|
6
|
+
const DEFAULT_MIN_LEVEL = "warn";
|
|
7
|
+
const PINO_LEVEL_LABELS = {
|
|
8
|
+
10: "trace",
|
|
9
|
+
20: "debug",
|
|
10
|
+
30: "info",
|
|
11
|
+
40: "warn",
|
|
12
|
+
50: "error",
|
|
13
|
+
60: "fatal"
|
|
14
|
+
};
|
|
15
|
+
const LOG_LEVEL_RANK = {
|
|
16
|
+
trace: 10,
|
|
17
|
+
debug: 20,
|
|
18
|
+
info: 30,
|
|
19
|
+
warn: 40,
|
|
20
|
+
error: 50,
|
|
21
|
+
fatal: 60
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Creates a `Writable` usable as pino's destination (`pino(opts, stream)`) that
|
|
25
|
+
* forwards log lines at/above `minLevel` into observability storage, batched and
|
|
26
|
+
* flushed the same non-blocking way every other capture path in this package is.
|
|
27
|
+
* Deliberately a plain stream rather than a worker-thread `pino.transport()` — the
|
|
28
|
+
* queue itself is already async/non-blocking, so a synchronous destination is
|
|
29
|
+
* simpler to build, test, and reason about without losing the "never block the host"
|
|
30
|
+
* guarantee.
|
|
31
|
+
*/
|
|
32
|
+
export function createPinoLogStream(handle, options = {}) {
|
|
33
|
+
const minRank = LOG_LEVEL_RANK[options.minLevel ?? DEFAULT_MIN_LEVEL];
|
|
34
|
+
const queue = createCaptureQueue({
|
|
35
|
+
flushIntervalMs: options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
36
|
+
maxQueueSize: options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
|
|
37
|
+
onFlush: (batch) => flushLogBatch(handle, batch, options.projectId)
|
|
38
|
+
});
|
|
39
|
+
return new Writable({
|
|
40
|
+
write(chunk, _encoding, callback) {
|
|
41
|
+
for (const line of chunk.toString("utf8").split("\n")) {
|
|
42
|
+
const entry = line.trim().length > 0 ? parsePinoLine(line, minRank) : undefined;
|
|
43
|
+
if (entry) {
|
|
44
|
+
queue.enqueue(entry);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
callback();
|
|
48
|
+
},
|
|
49
|
+
// Flushes any buffered log lines when the stream is ended (e.g. at process
|
|
50
|
+
// shutdown) rather than waiting for the next interval tick — mirrors
|
|
51
|
+
// `ObservabilityClient.close()`'s flush-on-close behavior for the same reason.
|
|
52
|
+
final(callback) {
|
|
53
|
+
queue.close().then(() => {
|
|
54
|
+
callback();
|
|
55
|
+
}, (error) => {
|
|
56
|
+
callback(error instanceof Error ? error : new Error(String(error)));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Persists a batch of parsed log lines.
|
|
63
|
+
*/
|
|
64
|
+
async function flushLogBatch(handle, batch, projectId) {
|
|
65
|
+
for (const entry of batch) {
|
|
66
|
+
await recordLog(handle, entry, projectId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Parses one pino NDJSON line into a `PendingLogEntry`, or `undefined` if it's below
|
|
71
|
+
* `minRank`, malformed, or otherwise unusable.
|
|
72
|
+
*/
|
|
73
|
+
function parsePinoLine(line, minRank) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(line);
|
|
76
|
+
const rank = typeof parsed.level === "number" ? parsed.level : undefined;
|
|
77
|
+
if (rank === undefined || rank < minRank) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const { level: _level, msg: _msg, time: _time, pid: _pid, hostname: _hostname, ...context } = parsed;
|
|
81
|
+
return {
|
|
82
|
+
level: PINO_LEVEL_LABELS[rank] ?? "info",
|
|
83
|
+
message: typeof parsed.msg === "string" ? parsed.msg : line,
|
|
84
|
+
context
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CaptureQueue } from "./queue.js";
|
|
2
|
+
import type { MetricKind } from "../core/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* A metric point queued for storage — the same shape `storage/metrics-ingest.ts`'s
|
|
5
|
+
* `recordMetricPoint` accepts, before it generates an `id`/`recordedAt`.
|
|
6
|
+
*/
|
|
7
|
+
export interface PendingMetricPoint {
|
|
8
|
+
name: string;
|
|
9
|
+
kind: MetricKind;
|
|
10
|
+
value: number;
|
|
11
|
+
tags?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* StatsD-shaped metrics API, exposed as `client.metrics` on the Node SDK. Tags
|
|
15
|
+
* directly multiply row count — keep them to bounded-cardinality dimensions (e.g.
|
|
16
|
+
* `route`, `statusCode`), not free-form values like a user ID.
|
|
17
|
+
*/
|
|
18
|
+
export interface MetricsApi {
|
|
19
|
+
increment: (name: string, value?: number, tags?: Record<string, string>) => void;
|
|
20
|
+
gauge: (name: string, value: number, tags?: Record<string, string>) => void;
|
|
21
|
+
histogram: (name: string, value: number, tags?: Record<string, string>) => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Builds the `client.metrics` API bound to a capture queue, batched and flushed the
|
|
25
|
+
* same non-blocking way every other capture path in this package is.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createMetricsApi(queue: CaptureQueue<PendingMetricPoint>): MetricsApi;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const DEFAULT_INCREMENT_VALUE = 1;
|
|
2
|
+
/**
|
|
3
|
+
* Builds the `client.metrics` API bound to a capture queue, batched and flushed the
|
|
4
|
+
* same non-blocking way every other capture path in this package is.
|
|
5
|
+
*/
|
|
6
|
+
export function createMetricsApi(queue) {
|
|
7
|
+
return {
|
|
8
|
+
increment(name, value = DEFAULT_INCREMENT_VALUE, tags) {
|
|
9
|
+
queue.enqueue({ name, kind: "counter", value, tags });
|
|
10
|
+
},
|
|
11
|
+
gauge(name, value, tags) {
|
|
12
|
+
queue.enqueue({ name, kind: "gauge", value, tags });
|
|
13
|
+
},
|
|
14
|
+
histogram(name, value, tags) {
|
|
15
|
+
queue.enqueue({ name, kind: "histogram", value, tags });
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Transaction } from "./tracing.js";
|
|
2
|
+
import type { BreadcrumbInput } from "../core/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* The minimal shape of a Prisma `query` client extension's `$allOperations` callback
|
|
5
|
+
* — duck-typed rather than imported from `@prisma/client`, since that's an optional
|
|
6
|
+
* peer dependency this package shouldn't need at compile time to build.
|
|
7
|
+
*/
|
|
8
|
+
interface AllOperationsParams {
|
|
9
|
+
model?: string;
|
|
10
|
+
operation: string;
|
|
11
|
+
args: unknown;
|
|
12
|
+
query: (args: unknown) => Promise<unknown>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The minimal shape of a Prisma client this function needs: something extensible via
|
|
16
|
+
* `$extends`.
|
|
17
|
+
*/
|
|
18
|
+
interface ExtensibleClient {
|
|
19
|
+
$extends: (extension: {
|
|
20
|
+
query: {
|
|
21
|
+
$allModels: {
|
|
22
|
+
$allOperations: (params: AllOperationsParams) => Promise<unknown>;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
}) => unknown;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Options for `instrumentPrismaClient`.
|
|
29
|
+
*/
|
|
30
|
+
export interface InstrumentPrismaClientOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Returns the transaction to attach query spans to, or `undefined` if none is
|
|
33
|
+
* active. There's no `AsyncLocalStorage`-based context propagation in this package
|
|
34
|
+
* (a materially bigger, separately-scoped change touching every request boundary),
|
|
35
|
+
* so the caller is responsible for tracking "the active transaction" itself —
|
|
36
|
+
* spans are only recorded for queries issued while a transaction is supplied here.
|
|
37
|
+
*/
|
|
38
|
+
getActiveTransaction?: () => Transaction | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Called with a `"db"` breadcrumb for every query, regardless of whether a
|
|
41
|
+
* transaction is active — the highest-value part of this feature, since it gives
|
|
42
|
+
* every captured exception "last N queries before this error" for free even
|
|
43
|
+
* without tracing wired up.
|
|
44
|
+
*/
|
|
45
|
+
addBreadcrumb?: (breadcrumb: BreadcrumbInput) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Query spans are only recorded when the query fails or takes at least this long
|
|
48
|
+
* (default 100ms) — breadcrumbs are recorded for every query regardless, but
|
|
49
|
+
* flooding the transactions view with thousands of fast `SELECT` spans isn't
|
|
50
|
+
* useful. Set to `0` to record every query as a span.
|
|
51
|
+
*/
|
|
52
|
+
slowQueryThresholdMs?: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Wraps a Prisma client with a `$extends` query extension that times every query,
|
|
56
|
+
* recording a `"db"` breadcrumb for each one and (when a transaction is supplied via
|
|
57
|
+
* `getActiveTransaction`) a child span for slow or failed queries. Returns a new
|
|
58
|
+
* client — Prisma extensions can't mutate a client in place — so callers must use the
|
|
59
|
+
* returned value, not the original `prisma`. Never a dependency between `packages/db`
|
|
60
|
+
* and this package in either direction: the consuming app calls this itself.
|
|
61
|
+
*/
|
|
62
|
+
export declare function instrumentPrismaClient<T extends ExtensibleClient>(prisma: T, options?: InstrumentPrismaClientOptions): ReturnType<T["$extends"]>;
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const DEFAULT_SLOW_QUERY_THRESHOLD_MS = 100;
|
|
2
|
+
/**
|
|
3
|
+
* Wraps a Prisma client with a `$extends` query extension that times every query,
|
|
4
|
+
* recording a `"db"` breadcrumb for each one and (when a transaction is supplied via
|
|
5
|
+
* `getActiveTransaction`) a child span for slow or failed queries. Returns a new
|
|
6
|
+
* client — Prisma extensions can't mutate a client in place — so callers must use the
|
|
7
|
+
* returned value, not the original `prisma`. Never a dependency between `packages/db`
|
|
8
|
+
* and this package in either direction: the consuming app calls this itself.
|
|
9
|
+
*/
|
|
10
|
+
export function instrumentPrismaClient(prisma, options = {}) {
|
|
11
|
+
const threshold = options.slowQueryThresholdMs ?? DEFAULT_SLOW_QUERY_THRESHOLD_MS;
|
|
12
|
+
return prisma.$extends({
|
|
13
|
+
query: {
|
|
14
|
+
$allModels: {
|
|
15
|
+
$allOperations: (params) => runInstrumentedQuery(params, options, threshold)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Runs a single Prisma query, timing it and reporting a breadcrumb/span as configured.
|
|
22
|
+
*/
|
|
23
|
+
async function runInstrumentedQuery(params, options, slowQueryThresholdMs) {
|
|
24
|
+
const name = `prisma.${params.model ?? "raw"}.${params.operation}`;
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
try {
|
|
27
|
+
const result = await params.query(params.args);
|
|
28
|
+
reportQuery(options, { name, status: "ok", durationMs: Date.now() - start }, slowQueryThresholdMs);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
reportQuery(options, { name, status: "error", durationMs: Date.now() - start }, slowQueryThresholdMs);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Records the breadcrumb (always) and span (when a transaction is active and either
|
|
38
|
+
* the query failed or was at/above the slow-query threshold) for one finished query.
|
|
39
|
+
*/
|
|
40
|
+
function reportQuery(options, outcome, slowQueryThresholdMs) {
|
|
41
|
+
const { name, status, durationMs } = outcome;
|
|
42
|
+
options.addBreadcrumb?.({
|
|
43
|
+
category: "db",
|
|
44
|
+
message: name,
|
|
45
|
+
level: status === "error" ? "error" : "info",
|
|
46
|
+
data: { durationMs }
|
|
47
|
+
});
|
|
48
|
+
if (status === "ok" && durationMs < slowQueryThresholdMs) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const transaction = options.getActiveTransaction?.();
|
|
52
|
+
transaction?.startSpan(name).finish(status);
|
|
53
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for a bounded, batched queue.
|
|
3
|
+
*/
|
|
4
|
+
export interface CaptureQueueOptions<T> {
|
|
5
|
+
flushIntervalMs: number;
|
|
6
|
+
maxQueueSize: number;
|
|
7
|
+
onFlush: (batch: T[]) => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A fire-and-forget queue: `enqueue` never blocks or throws, batches are flushed on a
|
|
11
|
+
* timer, and a storage failure is logged rather than propagated into the host app.
|
|
12
|
+
* Generic over item type so both event captures and finished spans can share one
|
|
13
|
+
* implementation.
|
|
14
|
+
*/
|
|
15
|
+
export interface CaptureQueue<T> {
|
|
16
|
+
enqueue: (item: T) => void;
|
|
17
|
+
flush: () => Promise<void>;
|
|
18
|
+
close: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Creates a queue that buffers items in memory and flushes them in batches, dropping
|
|
22
|
+
* the oldest buffered item when full so a slow or unreachable database can never
|
|
23
|
+
* cause unbounded memory growth in the host process.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createCaptureQueue<T>(options: CaptureQueueOptions<T>): CaptureQueue<T>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a queue that buffers items in memory and flushes them in batches, dropping
|
|
3
|
+
* the oldest buffered item when full so a slow or unreachable database can never
|
|
4
|
+
* cause unbounded memory growth in the host process.
|
|
5
|
+
*/
|
|
6
|
+
export function createCaptureQueue(options) {
|
|
7
|
+
const buffer = [];
|
|
8
|
+
let flushing = false;
|
|
9
|
+
const flush = async () => {
|
|
10
|
+
if (flushing || buffer.length === 0) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
flushing = true;
|
|
14
|
+
const batch = buffer.splice(0, buffer.length);
|
|
15
|
+
try {
|
|
16
|
+
await options.onFlush(batch);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
console.error("[observability] failed to flush queued items", error);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
flushing = false;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const timer = setInterval(() => {
|
|
26
|
+
void flush();
|
|
27
|
+
}, options.flushIntervalMs);
|
|
28
|
+
timer.unref();
|
|
29
|
+
return {
|
|
30
|
+
enqueue(item) {
|
|
31
|
+
if (buffer.length >= options.maxQueueSize) {
|
|
32
|
+
buffer.shift();
|
|
33
|
+
}
|
|
34
|
+
buffer.push(item);
|
|
35
|
+
},
|
|
36
|
+
flush,
|
|
37
|
+
async close() {
|
|
38
|
+
clearInterval(timer);
|
|
39
|
+
await flush();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|