@farm.js/sentry 0.1.0-beta.68
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/LICENSE +22 -0
- package/README.md +133 -0
- package/dist/index.d.ts +158 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +336 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Farm.js Team
|
|
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.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# @farm.js/sentry
|
|
2
|
+
|
|
3
|
+
Sentry error reporting and tracing for Farm.js applications. It maps Farm's request, render and
|
|
4
|
+
build lifecycles onto Sentry, so errors arrive with route context and requests are traced.
|
|
5
|
+
|
|
6
|
+
Farm.js is currently in beta.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add @farm.js/sentry @sentry/node
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`@sentry/node` is an optional peer dependency. If a `dsn` is configured and it is not installed,
|
|
15
|
+
the plugin logs a clear error and continues without reporting rather than preventing the
|
|
16
|
+
application from starting.
|
|
17
|
+
|
|
18
|
+
## Configure
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { defineConfig } from "@farm.js/core";
|
|
22
|
+
import { sentryPlugin } from "@farm.js/sentry";
|
|
23
|
+
|
|
24
|
+
export default defineConfig({
|
|
25
|
+
plugins: [
|
|
26
|
+
sentryPlugin({
|
|
27
|
+
dsn: process.env.SENTRY_DSN,
|
|
28
|
+
tracesSampleRate: 0.1,
|
|
29
|
+
sourceMaps: true,
|
|
30
|
+
}),
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Initialize early
|
|
36
|
+
|
|
37
|
+
The Node SDK patches other modules as they load, so it has to run before the rest of the
|
|
38
|
+
application. Add an instrumentation file for that:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// src/instrumentation.ts
|
|
42
|
+
import { registerSentry } from "@farm.js/sentry";
|
|
43
|
+
|
|
44
|
+
export const register = registerSentry({
|
|
45
|
+
dsn: process.env.SENTRY_DSN,
|
|
46
|
+
tracesSampleRate: 0.1,
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Without this the plugin still reports errors, but the SDK's automatic database and HTTP
|
|
51
|
+
instrumentation will not attach.
|
|
52
|
+
|
|
53
|
+
## What it does
|
|
54
|
+
|
|
55
|
+
| Hook | Behavior |
|
|
56
|
+
| ----------------- | ------------------------------------------------------------------------ |
|
|
57
|
+
| `runtime.start` | initializes the SDK if nothing has already, and listens for error events |
|
|
58
|
+
| `runtime.context` | names the request span by route pattern |
|
|
59
|
+
| `runtime.after` | sets the span status from the response |
|
|
60
|
+
| `runtime.error` | captures the exception with route, kind and request context |
|
|
61
|
+
| `runtime.close` | flushes pending events on shutdown |
|
|
62
|
+
| `build.configure` | enables source maps when `sourceMaps` is set |
|
|
63
|
+
|
|
64
|
+
Spans are named by route pattern rather than pathname, so `/users/[id]` stays one span name
|
|
65
|
+
instead of one per user.
|
|
66
|
+
|
|
67
|
+
Where the SDK's own HTTP instrumentation has already opened a span for the request, the plugin
|
|
68
|
+
renames that one rather than starting another, so automatic HTTP and database spans stay nested
|
|
69
|
+
under the request. It only creates a span when nothing is active.
|
|
70
|
+
|
|
71
|
+
## Serverless
|
|
72
|
+
|
|
73
|
+
Hosts that can terminate a process without a shutdown signal never run `runtime.close`, so
|
|
74
|
+
pending events are lost. Flush inside the request instead:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
sentryPlugin({
|
|
78
|
+
dsn: process.env.SENTRY_DSN,
|
|
79
|
+
flushOnResponse: true,
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The flush is handed to `waitUntil`, so the host keeps the invocation alive until it completes.
|
|
84
|
+
|
|
85
|
+
## Options
|
|
86
|
+
|
|
87
|
+
| Option | Default | Description |
|
|
88
|
+
| ------------------ | -------------------- | ------------------------------------------------------------------------------ |
|
|
89
|
+
| `dsn` | | Sentry project DSN. |
|
|
90
|
+
| `environment` | instrumentation mode | Environment name. |
|
|
91
|
+
| `release` | | Release identifier for the deploy. |
|
|
92
|
+
| `tracesSampleRate` | | Fraction of requests traced. Errors are always sent. |
|
|
93
|
+
| `sendDefaultPii` | `false` | Include request headers and user data. |
|
|
94
|
+
| `enabled` | `true` | Set false to register the hooks but report nothing. |
|
|
95
|
+
| `sdk` | `@sentry/node` | An SDK module to use instead of importing it. |
|
|
96
|
+
| `flushOnResponse` | `false` | Flush after every response and after a failed request. |
|
|
97
|
+
| `flushTimeoutMs` | `2000` | Flush timeout. |
|
|
98
|
+
| `sourceMaps` | `false` | Generate source maps in the production build. Does not upload them, see below. |
|
|
99
|
+
|
|
100
|
+
`sendDefaultPii` stays off by default because error events can carry request headers and user
|
|
101
|
+
data.
|
|
102
|
+
|
|
103
|
+
## Source maps
|
|
104
|
+
|
|
105
|
+
`sourceMaps: true` only generates source maps during the production build. It does not upload
|
|
106
|
+
them to Sentry, so production stack traces stay minified until the maps are uploaded separately,
|
|
107
|
+
for example with `sentry-cli`. Uploading from the build is not implemented yet.
|
|
108
|
+
|
|
109
|
+
Farm only uses its fast esbuild minifier while source maps are off, so enabling them moves
|
|
110
|
+
minification to Nitro's terser:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pnpm add -D @rollup/plugin-terser
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Without it the production build fails with `Cannot find module '@rollup/plugin-terser'`.
|
|
117
|
+
|
|
118
|
+
## Runtime support
|
|
119
|
+
|
|
120
|
+
Node presets. `@sentry/node` does not run on Cloudflare Workers, which need `@sentry/cloudflare`
|
|
121
|
+
instead, so `registerSentry` is a no-op on `edge` and `bun` runtimes. Edge support is tracked
|
|
122
|
+
separately.
|
|
123
|
+
|
|
124
|
+
## Bring your own SDK
|
|
125
|
+
|
|
126
|
+
`sdk` accepts anything matching the small structural interface the plugin uses, which is also
|
|
127
|
+
how the package is unit tested:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import * as Sentry from "@sentry/node";
|
|
131
|
+
|
|
132
|
+
sentryPlugin({ sdk: Sentry });
|
|
133
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { FarmInstrumentationCleanup, FarmInstrumentationContext } from "@farm.js/core/instrumentation";
|
|
2
|
+
import { type FarmEvent } from "@farm.js/core/observability";
|
|
3
|
+
/** Minimal span surface used by the plugin, satisfied by `@sentry/node`. */
|
|
4
|
+
export interface SentrySpanLike {
|
|
5
|
+
end(): void;
|
|
6
|
+
setStatus?(status: {
|
|
7
|
+
code: number;
|
|
8
|
+
message?: string;
|
|
9
|
+
}): void;
|
|
10
|
+
setAttribute?(key: string, value: unknown): void;
|
|
11
|
+
}
|
|
12
|
+
/** Minimal scope surface used by the plugin, satisfied by `@sentry/node`. */
|
|
13
|
+
export interface SentryScopeLike {
|
|
14
|
+
setTag(key: string, value: string): void;
|
|
15
|
+
setContext(key: string, value: Record<string, unknown> | null): void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The parts of the Sentry SDK this plugin uses. Declared structurally so the
|
|
19
|
+
* plugin can be unit tested and so an application can pass an SDK it already
|
|
20
|
+
* initialized, the way `@farm.js/cache-redis` accepts any Redis client.
|
|
21
|
+
*/
|
|
22
|
+
export interface SentrySdkLike {
|
|
23
|
+
init?(options: Record<string, unknown>): void;
|
|
24
|
+
/** Returns the active client, or undefined before `init`. */
|
|
25
|
+
getClient?(): unknown;
|
|
26
|
+
captureException(error: unknown, hint?: {
|
|
27
|
+
captureContext?: unknown;
|
|
28
|
+
}): string;
|
|
29
|
+
withScope?<T>(callback: (scope: SentryScopeLike) => T): T;
|
|
30
|
+
/** The span the SDK's own instrumentation opened for this request. */
|
|
31
|
+
getActiveSpan?(): SentrySpanLike | undefined;
|
|
32
|
+
getRootSpan?(span: SentrySpanLike): SentrySpanLike | undefined;
|
|
33
|
+
updateSpanName?(span: SentrySpanLike, name: string): void;
|
|
34
|
+
startInactiveSpan?(options: {
|
|
35
|
+
name: string;
|
|
36
|
+
op?: string;
|
|
37
|
+
/** Send the span as a transaction root rather than a child. */
|
|
38
|
+
forceTransaction?: boolean;
|
|
39
|
+
attributes?: Record<string, unknown>;
|
|
40
|
+
}): SentrySpanLike | undefined;
|
|
41
|
+
flush?(timeout?: number): Promise<boolean>;
|
|
42
|
+
}
|
|
43
|
+
export interface SentryPluginOptions {
|
|
44
|
+
dsn?: string;
|
|
45
|
+
environment?: string;
|
|
46
|
+
release?: string;
|
|
47
|
+
tracesSampleRate?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Error events can carry request headers and user data, so this stays off
|
|
50
|
+
* unless the application opts in.
|
|
51
|
+
*/
|
|
52
|
+
sendDefaultPii?: boolean;
|
|
53
|
+
/** Set false to register the hooks but do no reporting. */
|
|
54
|
+
enabled?: boolean;
|
|
55
|
+
/** An SDK module to use instead of importing `@sentry/node`. */
|
|
56
|
+
sdk?: SentrySdkLike;
|
|
57
|
+
/**
|
|
58
|
+
* Flush after every response and after a failed request. Required on hosts
|
|
59
|
+
* that can terminate a process without a shutdown signal, where
|
|
60
|
+
* `runtime.close` never runs.
|
|
61
|
+
*/
|
|
62
|
+
flushOnResponse?: boolean;
|
|
63
|
+
flushTimeoutMs?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Extra options merged into `Sentry.init`, for anything this plugin does not
|
|
66
|
+
* model directly such as `debug`, `beforeSend`, `ignoreErrors` or
|
|
67
|
+
* `integrations`. Explicit options above win over keys repeated here.
|
|
68
|
+
*/
|
|
69
|
+
sentryOptions?: Record<string, unknown>;
|
|
70
|
+
/**
|
|
71
|
+
* Emit source maps in the production build. This only generates them, it does
|
|
72
|
+
* not upload anything to Sentry, so stack traces stay minified until the maps
|
|
73
|
+
* are uploaded separately.
|
|
74
|
+
*
|
|
75
|
+
* Farm only uses its fast esbuild minifier while `sourceMap` is false, so
|
|
76
|
+
* turning this on moves minification to Nitro's terser. Install
|
|
77
|
+
* `@rollup/plugin-terser` alongside it, otherwise the build fails with
|
|
78
|
+
* `Cannot find module '@rollup/plugin-terser'`.
|
|
79
|
+
*/
|
|
80
|
+
sourceMaps?: boolean;
|
|
81
|
+
}
|
|
82
|
+
/** Per-request state stored under the `sentry` context key. */
|
|
83
|
+
export interface SentryRequestContext {
|
|
84
|
+
span?: SentrySpanLike;
|
|
85
|
+
/** True when the plugin created the span and therefore has to end it. */
|
|
86
|
+
ownsSpan: boolean;
|
|
87
|
+
startedAt: number;
|
|
88
|
+
ended: boolean;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Returns false when the same error is already being delivered through another
|
|
92
|
+
* Farm error path. The claim expires so reusing an error in a later request is
|
|
93
|
+
* still reported.
|
|
94
|
+
*/
|
|
95
|
+
export declare function claimError(error: unknown): boolean;
|
|
96
|
+
export declare function resolveSentrySdk(options: SentryPluginOptions): Promise<SentrySdkLike | undefined>;
|
|
97
|
+
/**
|
|
98
|
+
* Report loudly when a DSN was configured but no SDK resolved. Silently doing
|
|
99
|
+
* nothing hides a missing or broken install until errors are already lost.
|
|
100
|
+
*
|
|
101
|
+
* This logs rather than throws. It runs during instrumentation, before the
|
|
102
|
+
* application loads, so throwing takes the whole process down. Monitoring must
|
|
103
|
+
* never do that to the application it observes.
|
|
104
|
+
*/
|
|
105
|
+
export declare function assertSentrySdk(sdk: SentrySdkLike | undefined, options: SentryPluginOptions): SentrySdkLike | undefined;
|
|
106
|
+
export declare function requireSentrySdk(options: SentryPluginOptions): Promise<SentrySdkLike | undefined>;
|
|
107
|
+
export declare function buildSentryInitOptions(options: SentryPluginOptions, context?: Pick<FarmInstrumentationContext, "mode">): Record<string, unknown>;
|
|
108
|
+
/** Initialize once. A second `init` would replace a working client. */
|
|
109
|
+
export declare function initSentryOnce(sdk: SentrySdkLike, options: SentryPluginOptions, context?: Pick<FarmInstrumentationContext, "mode">): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* True for observability events that carry a reportable error.
|
|
112
|
+
*
|
|
113
|
+
* Farm handles many failures internally rather than letting them reach the
|
|
114
|
+
* plugin's `runtime.error` hook. A page that throws during render becomes a
|
|
115
|
+
* `render.error` event and a 500 response, and the request pipeline never
|
|
116
|
+
* throws, so the event stream is the only place those are visible.
|
|
117
|
+
*/
|
|
118
|
+
export declare function isErrorEvent(event: FarmEvent): event is FarmEvent & {
|
|
119
|
+
error: unknown;
|
|
120
|
+
};
|
|
121
|
+
/** Route hint carried by the error-bearing events, where one is present. */
|
|
122
|
+
export declare function errorEventRoute(event: FarmEvent): string | undefined;
|
|
123
|
+
/**
|
|
124
|
+
* Name spans by route pattern rather than pathname. `/users/[id]` keeps one
|
|
125
|
+
* span name, where `/users/1` and `/users/2` would create one per user.
|
|
126
|
+
*/
|
|
127
|
+
export declare function spanNameFor(method: string, route: {
|
|
128
|
+
pathname: string;
|
|
129
|
+
pattern?: string | null;
|
|
130
|
+
} | undefined, fallbackPathname: string): string;
|
|
131
|
+
/**
|
|
132
|
+
* Early Sentry initialization for `src/instrumentation.ts`.
|
|
133
|
+
*
|
|
134
|
+
* The Node SDK patches other modules as they load, so it has to run before the
|
|
135
|
+
* rest of the application. A plugin `setup` cannot guarantee that ordering.
|
|
136
|
+
*
|
|
137
|
+
* ```ts
|
|
138
|
+
* // src/instrumentation.ts
|
|
139
|
+
* export const register = registerSentry({ dsn: process.env.SENTRY_DSN });
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
export declare function registerSentry(options?: SentryPluginOptions): (context: FarmInstrumentationContext) => Promise<FarmInstrumentationCleanup>;
|
|
143
|
+
/**
|
|
144
|
+
* Maps Farm's request, render and build lifecycles onto Sentry.
|
|
145
|
+
*
|
|
146
|
+
* Pair with `registerSentry` in `src/instrumentation.ts` when using the Node
|
|
147
|
+
* SDK, so initialization happens before the application loads.
|
|
148
|
+
*/
|
|
149
|
+
export declare function sentryPlugin(options?: SentryPluginOptions): import("@farm.js/core/plugin").FarmPlugin<{
|
|
150
|
+
enabled: boolean;
|
|
151
|
+
options: SentryPluginOptions;
|
|
152
|
+
flushTimeoutMs: number;
|
|
153
|
+
sdk: SentrySdkLike | undefined;
|
|
154
|
+
unsubscribe: (() => void) | undefined;
|
|
155
|
+
}, {
|
|
156
|
+
sentry: SentryRequestContext;
|
|
157
|
+
}, unknown, undefined, unknown, false>;
|
|
158
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { onFarmEvent } from "@farm.js/core/observability";
|
|
2
|
+
import { definePlugin } from "@farm.js/core/plugin";
|
|
3
|
+
const DEFAULT_FLUSH_TIMEOUT_MS = 2_000;
|
|
4
|
+
/** Sentry status codes: 1 is ok, 2 is error. */
|
|
5
|
+
const SPAN_STATUS_OK = 1;
|
|
6
|
+
const SPAN_STATUS_ERROR = 2;
|
|
7
|
+
/** Errors reported during the current event-loop turn. */
|
|
8
|
+
const recentlyReportedErrors = new WeakSet();
|
|
9
|
+
/**
|
|
10
|
+
* Thrown primitives reported during the current event-loop turn.
|
|
11
|
+
*
|
|
12
|
+
* A WeakSet cannot hold a string or a number, so these are tracked separately.
|
|
13
|
+
* The claim is by value rather than identity, so two requests throwing the
|
|
14
|
+
* identical primitive within one turn report once. That is the better trade
|
|
15
|
+
* against reporting every primitive twice, since a value carries no stack and
|
|
16
|
+
* Sentry groups the events together regardless.
|
|
17
|
+
*/
|
|
18
|
+
const recentlyReportedValues = new Set();
|
|
19
|
+
/**
|
|
20
|
+
* Returns false when the same error is already being delivered through another
|
|
21
|
+
* Farm error path. The claim expires so reusing an error in a later request is
|
|
22
|
+
* still reported.
|
|
23
|
+
*/
|
|
24
|
+
export function claimError(error) {
|
|
25
|
+
if (typeof error === "object" && error !== null) {
|
|
26
|
+
if (recentlyReportedErrors.has(error))
|
|
27
|
+
return false;
|
|
28
|
+
recentlyReportedErrors.add(error);
|
|
29
|
+
setTimeout(() => recentlyReportedErrors.delete(error), 0);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (recentlyReportedValues.has(error))
|
|
33
|
+
return false;
|
|
34
|
+
recentlyReportedValues.add(error);
|
|
35
|
+
setTimeout(() => recentlyReportedValues.delete(error), 0);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const MISSING_SDK_MESSAGE = "@farm.js/sentry needs @sentry/node. Install it with `pnpm add @sentry/node`, " +
|
|
39
|
+
"or pass an SDK through the `sdk` option.";
|
|
40
|
+
export async function resolveSentrySdk(options) {
|
|
41
|
+
if (options.sdk)
|
|
42
|
+
return options.sdk;
|
|
43
|
+
try {
|
|
44
|
+
// The specifier has to be a literal. A variable is invisible to bundlers,
|
|
45
|
+
// so the dependency is never traced into serverless output and the import
|
|
46
|
+
// fails at runtime. That is what happened on Vercel, where the plugin
|
|
47
|
+
// reported a missing SDK even though the package was installed.
|
|
48
|
+
return (await import("@sentry/node"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Report loudly when a DSN was configured but no SDK resolved. Silently doing
|
|
56
|
+
* nothing hides a missing or broken install until errors are already lost.
|
|
57
|
+
*
|
|
58
|
+
* This logs rather than throws. It runs during instrumentation, before the
|
|
59
|
+
* application loads, so throwing takes the whole process down. Monitoring must
|
|
60
|
+
* never do that to the application it observes.
|
|
61
|
+
*/
|
|
62
|
+
export function assertSentrySdk(sdk, options) {
|
|
63
|
+
if (sdk)
|
|
64
|
+
return sdk;
|
|
65
|
+
if (options.dsn)
|
|
66
|
+
console.error(`[farm:sentry] ${MISSING_SDK_MESSAGE}`);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
export async function requireSentrySdk(options) {
|
|
70
|
+
return assertSentrySdk(await resolveSentrySdk(options), options);
|
|
71
|
+
}
|
|
72
|
+
export function buildSentryInitOptions(options, context) {
|
|
73
|
+
const explicit = {
|
|
74
|
+
dsn: options.dsn,
|
|
75
|
+
environment: options.environment ?? context?.mode,
|
|
76
|
+
release: options.release,
|
|
77
|
+
tracesSampleRate: options.tracesSampleRate,
|
|
78
|
+
sendDefaultPii: options.sendDefaultPii ?? false,
|
|
79
|
+
};
|
|
80
|
+
// Drop undefined so a passthrough key is not overwritten by an unset option.
|
|
81
|
+
for (const key of Object.keys(explicit)) {
|
|
82
|
+
if (explicit[key] === undefined)
|
|
83
|
+
delete explicit[key];
|
|
84
|
+
}
|
|
85
|
+
return { ...options.sentryOptions, ...explicit };
|
|
86
|
+
}
|
|
87
|
+
/** Initialize once. A second `init` would replace a working client. */
|
|
88
|
+
export function initSentryOnce(sdk, options, context) {
|
|
89
|
+
if (!options.dsn)
|
|
90
|
+
return false;
|
|
91
|
+
if (sdk.getClient?.())
|
|
92
|
+
return false;
|
|
93
|
+
sdk.init?.(buildSentryInitOptions(options, context));
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* True for observability events that carry a reportable error.
|
|
98
|
+
*
|
|
99
|
+
* Farm handles many failures internally rather than letting them reach the
|
|
100
|
+
* plugin's `runtime.error` hook. A page that throws during render becomes a
|
|
101
|
+
* `render.error` event and a 500 response, and the request pipeline never
|
|
102
|
+
* throws, so the event stream is the only place those are visible.
|
|
103
|
+
*/
|
|
104
|
+
export function isErrorEvent(event) {
|
|
105
|
+
if (!("error" in event) || event.error === undefined)
|
|
106
|
+
return false;
|
|
107
|
+
return event.type === "error" || event.type.endsWith(".error");
|
|
108
|
+
}
|
|
109
|
+
/** Route hint carried by the error-bearing events, where one is present. */
|
|
110
|
+
export function errorEventRoute(event) {
|
|
111
|
+
const route = event.route;
|
|
112
|
+
return typeof route === "string" ? route : undefined;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Name spans by route pattern rather than pathname. `/users/[id]` keeps one
|
|
116
|
+
* span name, where `/users/1` and `/users/2` would create one per user.
|
|
117
|
+
*/
|
|
118
|
+
export function spanNameFor(method, route, fallbackPathname) {
|
|
119
|
+
return `${method} ${route?.pattern || route?.pathname || fallbackPathname}`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Early Sentry initialization for `src/instrumentation.ts`.
|
|
123
|
+
*
|
|
124
|
+
* The Node SDK patches other modules as they load, so it has to run before the
|
|
125
|
+
* rest of the application. A plugin `setup` cannot guarantee that ordering.
|
|
126
|
+
*
|
|
127
|
+
* ```ts
|
|
128
|
+
* // src/instrumentation.ts
|
|
129
|
+
* export const register = registerSentry({ dsn: process.env.SENTRY_DSN });
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export function registerSentry(options = {}) {
|
|
133
|
+
return async function register(context) {
|
|
134
|
+
if (options.enabled === false)
|
|
135
|
+
return;
|
|
136
|
+
// Node only for now. `@sentry/node` does not run on Workers, which need
|
|
137
|
+
// `@sentry/cloudflare` instead.
|
|
138
|
+
if (context.runtime !== "nodejs")
|
|
139
|
+
return;
|
|
140
|
+
// Instrumentation runs before the application loads, so anything thrown
|
|
141
|
+
// here stops the process from booting. Losing telemetry is acceptable,
|
|
142
|
+
// taking the application down with it is not.
|
|
143
|
+
try {
|
|
144
|
+
const sdk = await requireSentrySdk(options);
|
|
145
|
+
if (!sdk)
|
|
146
|
+
return;
|
|
147
|
+
initSentryOnce(sdk, options, context);
|
|
148
|
+
return () => flushSentrySafely(sdk, options.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS, "[farm:sentry] instrumentation flush failed:");
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
console.error("[farm:sentry] failed to initialize, continuing without it:", error);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Maps Farm's request, render and build lifecycles onto Sentry.
|
|
158
|
+
*
|
|
159
|
+
* Pair with `registerSentry` in `src/instrumentation.ts` when using the Node
|
|
160
|
+
* SDK, so initialization happens before the application loads.
|
|
161
|
+
*/
|
|
162
|
+
export function sentryPlugin(options = {}) {
|
|
163
|
+
const enabled = options.enabled !== false;
|
|
164
|
+
const flushTimeoutMs = options.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
|
|
165
|
+
return definePlugin({
|
|
166
|
+
name: "farm:sentry",
|
|
167
|
+
// Wrap as much of the request as possible.
|
|
168
|
+
enforce: "pre",
|
|
169
|
+
setup() {
|
|
170
|
+
// No SDK work here. `setup` can run in a build manager and again in a
|
|
171
|
+
// deployed runtime, so it has to stay deterministic.
|
|
172
|
+
return {
|
|
173
|
+
enabled,
|
|
174
|
+
options,
|
|
175
|
+
flushTimeoutMs,
|
|
176
|
+
sdk: options.sdk,
|
|
177
|
+
unsubscribe: undefined,
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
runtime: {
|
|
181
|
+
async start({ state }) {
|
|
182
|
+
if (!state.enabled)
|
|
183
|
+
return;
|
|
184
|
+
try {
|
|
185
|
+
state.sdk ??= await requireSentrySdk(state.options);
|
|
186
|
+
// `registerSentry` usually initialized already, and `initSentryOnce`
|
|
187
|
+
// checks for a live client so this does not replace it.
|
|
188
|
+
if (state.sdk)
|
|
189
|
+
initSentryOnce(state.sdk, state.options);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
// Server startup must not fail because reporting could not start.
|
|
193
|
+
console.error("[farm:sentry] failed to initialize, continuing without it:", error);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (state.unsubscribe || !state.sdk)
|
|
197
|
+
return;
|
|
198
|
+
state.unsubscribe = onFarmEvent((event) => {
|
|
199
|
+
if (!isErrorEvent(event))
|
|
200
|
+
return;
|
|
201
|
+
if (!claimError(event.error))
|
|
202
|
+
return;
|
|
203
|
+
const route = errorEventRoute(event);
|
|
204
|
+
const capture = () => state.sdk?.captureException(event.error);
|
|
205
|
+
if (state.sdk?.withScope) {
|
|
206
|
+
state.sdk.withScope((scope) => {
|
|
207
|
+
scope.setTag("farm.event", event.type);
|
|
208
|
+
if (route)
|
|
209
|
+
scope.setTag("farm.route", route);
|
|
210
|
+
capture();
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
capture();
|
|
215
|
+
}, { unfiltered: true });
|
|
216
|
+
},
|
|
217
|
+
context({ request, route, state }) {
|
|
218
|
+
const empty = { ownsSpan: false, startedAt: Date.now(), ended: true };
|
|
219
|
+
if (!state.enabled || !state.sdk)
|
|
220
|
+
return { sentry: empty };
|
|
221
|
+
const url = new URL(request.url);
|
|
222
|
+
const name = spanNameFor(request.method, route, url.pathname);
|
|
223
|
+
// Prefer the span the SDK's own HTTP instrumentation already opened.
|
|
224
|
+
// It is the active span, so automatic HTTP and database spans nest
|
|
225
|
+
// under it. Creating our own would leave those siblings of the request.
|
|
226
|
+
const active = state.sdk.getActiveSpan?.();
|
|
227
|
+
if (active) {
|
|
228
|
+
const root = state.sdk.getRootSpan?.(active) ?? active;
|
|
229
|
+
state.sdk.updateSpanName?.(root, name);
|
|
230
|
+
if (route?.pattern)
|
|
231
|
+
root.setAttribute?.("farm.route", route.pattern);
|
|
232
|
+
return { sentry: { span: root, ownsSpan: false, startedAt: Date.now(), ended: false } };
|
|
233
|
+
}
|
|
234
|
+
// No automatic instrumentation attached, so record the request itself.
|
|
235
|
+
// `forceTransaction` is required, an orphan span is never sent.
|
|
236
|
+
const span = state.sdk.startInactiveSpan?.({
|
|
237
|
+
name,
|
|
238
|
+
op: "http.server",
|
|
239
|
+
forceTransaction: true,
|
|
240
|
+
attributes: {
|
|
241
|
+
"http.request.method": request.method,
|
|
242
|
+
"url.path": url.pathname,
|
|
243
|
+
...(route?.pattern ? { "farm.route": route.pattern } : {}),
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
return { sentry: { span, ownsSpan: true, startedAt: Date.now(), ended: false } };
|
|
247
|
+
},
|
|
248
|
+
after({ ctx, response, state, waitUntil }) {
|
|
249
|
+
const sentry = ctx.sentry;
|
|
250
|
+
if (sentry && !sentry.ended) {
|
|
251
|
+
sentry.span?.setStatus?.({
|
|
252
|
+
code: response.status >= 500 ? SPAN_STATUS_ERROR : SPAN_STATUS_OK,
|
|
253
|
+
});
|
|
254
|
+
// Only end a span this plugin created. The SDK owns the lifecycle of
|
|
255
|
+
// its own request span.
|
|
256
|
+
if (sentry.ownsSpan)
|
|
257
|
+
sentry.span?.end();
|
|
258
|
+
sentry.ended = true;
|
|
259
|
+
}
|
|
260
|
+
flushWithinRequest(state, waitUntil);
|
|
261
|
+
},
|
|
262
|
+
error({ ctx, error, request, route, kind, state, waitUntil }) {
|
|
263
|
+
const sentry = ctx.sentry;
|
|
264
|
+
if (sentry && !sentry.ended) {
|
|
265
|
+
sentry.span?.setStatus?.({ code: SPAN_STATUS_ERROR });
|
|
266
|
+
if (sentry.ownsSpan)
|
|
267
|
+
sentry.span?.end();
|
|
268
|
+
sentry.ended = true;
|
|
269
|
+
}
|
|
270
|
+
if (!state.enabled || !state.sdk)
|
|
271
|
+
return;
|
|
272
|
+
// The event stream may already have reported this one.
|
|
273
|
+
if (!claimError(error)) {
|
|
274
|
+
flushWithinRequest(state, waitUntil);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const url = new URL(request.url);
|
|
278
|
+
const capture = () => state.sdk?.captureException(error);
|
|
279
|
+
if (state.sdk.withScope) {
|
|
280
|
+
state.sdk.withScope((scope) => {
|
|
281
|
+
scope.setTag("farm.kind", String(kind));
|
|
282
|
+
if (route?.pattern)
|
|
283
|
+
scope.setTag("farm.route", route.pattern);
|
|
284
|
+
scope.setContext("request", {
|
|
285
|
+
method: request.method,
|
|
286
|
+
path: url.pathname,
|
|
287
|
+
});
|
|
288
|
+
capture();
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
capture();
|
|
293
|
+
}
|
|
294
|
+
// A failing request never reaches `runtime.after`, so without this the
|
|
295
|
+
// exception is lost on hosts that stop the process straight after.
|
|
296
|
+
flushWithinRequest(state, waitUntil);
|
|
297
|
+
},
|
|
298
|
+
async close({ state }) {
|
|
299
|
+
state.unsubscribe?.();
|
|
300
|
+
state.unsubscribe = undefined;
|
|
301
|
+
if (!state.enabled)
|
|
302
|
+
return;
|
|
303
|
+
await flushSentrySafely(state.sdk, state.flushTimeoutMs, "[farm:sentry] flush on shutdown failed:");
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
build: {
|
|
307
|
+
configure(buildConfig, { state }) {
|
|
308
|
+
if (!state.options.sourceMaps)
|
|
309
|
+
return;
|
|
310
|
+
// Generating maps moves minification from Farm's esbuild pass to
|
|
311
|
+
// Nitro's terser, so the application needs `@rollup/plugin-terser`.
|
|
312
|
+
return { ...buildConfig, sourceMap: true };
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/** Monitoring failures must never escape into the application lifecycle. */
|
|
318
|
+
async function flushSentrySafely(sdk, timeoutMs, failureMessage) {
|
|
319
|
+
try {
|
|
320
|
+
await sdk?.flush?.(timeoutMs);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
console.error(failureMessage, error);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/** Flush inside the request for hosts that may not run `runtime.close`. */
|
|
327
|
+
function flushWithinRequest(state, waitUntil) {
|
|
328
|
+
if (!state.enabled || !state.options.flushOnResponse)
|
|
329
|
+
return;
|
|
330
|
+
if (!state.sdk?.flush)
|
|
331
|
+
return;
|
|
332
|
+
// The host owns this promise once handed over, and Farm passes it through
|
|
333
|
+
// untouched when the host supplies waitUntil. An unhandled rejection there
|
|
334
|
+
// can terminate the process, so a failed flush has to stay contained.
|
|
335
|
+
waitUntil(flushSentrySafely(state.sdk, state.flushTimeoutMs, "[farm:sentry] flush failed:"));
|
|
336
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@farm.js/sentry",
|
|
3
|
+
"version": "0.1.0-beta.68",
|
|
4
|
+
"description": "Sentry error reporting and tracing for Farm.js",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"error-tracking",
|
|
7
|
+
"farm.js",
|
|
8
|
+
"monitoring",
|
|
9
|
+
"observability",
|
|
10
|
+
"sentry",
|
|
11
|
+
"tracing"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/farming-labs/farm.js",
|
|
17
|
+
"directory": "packages/farm-sentry"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"module": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@farm.js/core": "0.1.0-beta.68"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@sentry/node": "^10.71.0",
|
|
40
|
+
"typescript": "^5.3.3",
|
|
41
|
+
"vitest": "^3.2.7"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@sentry/node": ">=8.47.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@sentry/node": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
|
|
53
|
+
"dev": "tsc --watch",
|
|
54
|
+
"type-check": "tsc --noEmit",
|
|
55
|
+
"test": "vitest run"
|
|
56
|
+
}
|
|
57
|
+
}
|