@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 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
+ ```
@@ -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
+ }