@unhandled/nextjs 0.0.0-stage → 0.0.1

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/README.md CHANGED
@@ -1,3 +1,94 @@
1
- # Temporary Holding Version
1
+ # @unhandled/nextjs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sends traces, logs, metrics and server errors from a Next.js 15+ App Router app to Unhandled. It starts `@unhandled/node` from `instrumentation.ts` and reports errors through Next's `onRequestError` hook. Next's own request spans become the SERVER spans Unhandled groups by route.
4
+
5
+ ## Setup
6
+
7
+ Set `UNHANDLED_DSN` (from the service settings page) in the server environment, then add three things.
8
+
9
+ `instrumentation.ts` at the project root, or in `src/` when you use one:
10
+
11
+ ```ts
12
+ export { register, onRequestError } from '@unhandled/nextjs'
13
+ ```
14
+
15
+ `register` reads its options from the environment. To pass options, call `registerUnhandled` from your own `register`:
16
+
17
+ ```ts
18
+ import { registerUnhandled } from '@unhandled/nextjs'
19
+
20
+ export const register = () => {
21
+ registerUnhandled({ serviceName: 'storefront' })
22
+ }
23
+
24
+ export { onRequestError } from '@unhandled/nextjs'
25
+ ```
26
+
27
+ `next.config.ts`:
28
+
29
+ ```ts
30
+ import { withUnhandledConfig } from '@unhandled/nextjs'
31
+
32
+ export default withUnhandledConfig({
33
+ // your config
34
+ })
35
+ ```
36
+
37
+ `withUnhandledConfig` adds `@unhandled/nextjs` and `@unhandled/node` to `serverExternalPackages` and keeps any entries you already have. Without it Next bundles a separate copy of the SDK into `instrumentation.ts` and into every route. The copies do not share state, so `setUser` cannot reach the request span and a server action error is reported twice. If you prefer to write the setting yourself, it is `serverExternalPackages: ['@unhandled/nextjs', '@unhandled/node']`.
38
+
39
+ `registerUnhandled(options?)` takes every `@unhandled/node` option (`dsn`, `environment`, `serviceName`, `samplingRatio`, `http`, `instrumentations`, `integrations` and the rest) and returns the running client or `null`. It only starts the SDK when `NEXT_RUNTIME` is `nodejs`. On the edge runtime the package resolves to a build where `register`, `registerUnhandled`, `onRequestError`, `setUser` and `withServerAction` do nothing, so Edge routes and the edge compile of `instrumentation.ts` load no Node.js code and send nothing.
40
+
41
+ Like `@unhandled/node`, it redacts secret query values and token-like path segments from URL attributes by default (`http.sanitizeUrls: false` turns that off), and reads source lines only from `.js`/`.ts` source files under the app root, never from paths named in an error message or from dotfiles such as `.env`. The [`@unhandled/node` README](../node/README.md#options) has the rules.
42
+
43
+ ## What you get
44
+
45
+ Next opens a SERVER span for every request, named `GET /api/users/[id]`, with `http.route` set to the route pattern (`/api/users/[id]`, `/checkout`) and `next.route`, `http.method`, `http.target` and `http.status_code` next to it. Unhandled uses that span as the request.
46
+
47
+ `onRequestError` reports errors thrown by route handlers, Server Components, server actions and the proxy with mechanism `nextjs.on_request_error` and `handled: false`. Each report carries these tags:
48
+
49
+ | Tag | Value |
50
+ | --- | --- |
51
+ | `nextjs.route_path` | the route, for example `/api/boom` |
52
+ | `nextjs.route_type` | `route`, `render`, `action` or `proxy` |
53
+ | `nextjs.router_kind` | `App Router` or `Pages Router` |
54
+ | `nextjs.render_source` | when rendering, for example `react-server-components` |
55
+ | `nextjs.revalidate_reason` | `on-demand` or `stale` during revalidation |
56
+ | `nextjs.digest` | the digest Next shows in place of the message in production |
57
+
58
+ `notFound()`, `redirect()`, `permanentRedirect()`, `forbidden()`, `unauthorized()` and dynamic-rendering bailouts are control flow, not errors, and are never reported. The `ignoreExceptions`, `ignoreStatuses` and `ignoreCodes` options apply as well.
59
+
60
+ ## Users
61
+
62
+ ```ts
63
+ import { setUser } from '@unhandled/nextjs'
64
+
65
+ export const GET = async (request: Request) => {
66
+ const user = await currentUser(request)
67
+ setUser({ id: user.id, email: user.email })
68
+ return Response.json(user)
69
+ }
70
+ ```
71
+
72
+ `setUser` writes `user.id`, `user.email` and `user.name` on the active span and on the request's SERVER span.
73
+
74
+ ## Server actions
75
+
76
+ Next names a server action request after the page that posts it (`POST /checkout`), so two actions on one page look the same. Wrap an action to give it a span of its own:
77
+
78
+ ```ts
79
+ 'use server'
80
+
81
+ import { withServerAction } from '@unhandled/nextjs'
82
+
83
+ export const submitOrder = withServerAction('submitOrder', async (form: FormData) => {
84
+ // ...
85
+ })
86
+ ```
87
+
88
+ The action runs inside a `server action submitOrder` span with the attribute `nextjs.server_action`. If it throws, the error is reported once with mechanism `nextjs.server_action` and the tag `nextjs.server_action`, then rethrown, so Next still renders its error. A `redirect()` or `notFound()` from the action passes through and does not mark the span as failed.
89
+
90
+ Route handlers need no wrapper, because Next already names their spans by route and `onRequestError` already reports their errors.
91
+
92
+ ## Everything else
93
+
94
+ `Unhandled.track`, `captureException`, `captureMessage`, `record` and the decorators come from `@unhandled/node`, which is installed with this package. Import them from there.
@@ -0,0 +1,2 @@
1
+ import type { NextConfig } from "next";
2
+ export declare const withUnhandledConfig: (config?: NextConfig) => NextConfig;
@@ -0,0 +1,6 @@
1
+ // Bundled copies would split SDK state between instrumentation.ts and each route's chunk.
2
+ const SDK_PACKAGES = ["@unhandled/nextjs", "@unhandled/node"];
3
+ export const withUnhandledConfig = (config = {}) => ({
4
+ ...config,
5
+ serverExternalPackages: [...new Set([...(config.serverExternalPackages ?? []), ...SDK_PACKAGES])],
6
+ });
@@ -0,0 +1,8 @@
1
+ import type * as Node from "./index.ts";
2
+ export { withUnhandledConfig } from "./config.ts";
3
+ export type { NextUnhandledOptions } from "./index.ts";
4
+ export declare const registerUnhandled: typeof Node.registerUnhandled;
5
+ export declare const register: typeof Node.register;
6
+ export declare const onRequestError: typeof Node.onRequestError;
7
+ export declare const setUser: typeof Node.setUser;
8
+ export declare const withServerAction: typeof Node.withServerAction;
package/build/edge.js ADDED
@@ -0,0 +1,6 @@
1
+ export { withUnhandledConfig } from "./config.js";
2
+ export const registerUnhandled = () => null;
3
+ export const register = () => undefined;
4
+ export const onRequestError = () => undefined;
5
+ export const setUser = () => undefined;
6
+ export const withServerAction = (_name, action) => action;
@@ -0,0 +1,4 @@
1
+ import { type Mechanism } from "@unhandled/node/framework";
2
+ import type { Instrumentation } from "next";
3
+ export declare const reportNextError: (error: unknown, mechanism: Mechanism, tags: Record<string, string>) => void;
4
+ export declare const onRequestError: Instrumentation.onRequestError;
@@ -0,0 +1,42 @@
1
+ import { reportException } from "@unhandled/node/framework";
2
+ // unstable_rethrow cannot load from an external package, so match its digests (plus 15.0's NEXT_NOT_FOUND).
3
+ const SIGNAL_DIGESTS = new Set([
4
+ "NEXT_NOT_FOUND",
5
+ "BAILOUT_TO_CLIENT_SIDE_RENDERING",
6
+ "DYNAMIC_SERVER_USAGE",
7
+ "HANGING_PROMISE_REJECTION",
8
+ "NEXT_PRERENDER_INTERRUPTED",
9
+ ]);
10
+ const SIGNAL_PREFIXES = ["NEXT_REDIRECT;", "NEXT_HTTP_ERROR_FALLBACK;"];
11
+ const digestOf = (error) => {
12
+ const digest = typeof error === "object" && error !== null ? Reflect.get(error, "digest") : undefined;
13
+ return typeof digest === "string" ? digest : undefined;
14
+ };
15
+ const isNextSignal = (error) => {
16
+ const digest = digestOf(error);
17
+ if (digest !== undefined &&
18
+ (SIGNAL_DIGESTS.has(digest) || SIGNAL_PREFIXES.some((prefix) => digest.startsWith(prefix)))) {
19
+ return true;
20
+ }
21
+ return error instanceof Error && error.cause !== undefined && isNextSignal(error.cause);
22
+ };
23
+ export const reportNextError = (error, mechanism, tags) => {
24
+ if (isNextSignal(error)) {
25
+ return;
26
+ }
27
+ const digest = digestOf(error);
28
+ reportException(error, {
29
+ mechanism,
30
+ handled: false,
31
+ tags: { ...tags, ...(digest && { "nextjs.digest": digest }) },
32
+ });
33
+ };
34
+ export const onRequestError = (error, _request, context) => {
35
+ reportNextError(error, "nextjs.on_request_error", {
36
+ "nextjs.router_kind": context.routerKind,
37
+ "nextjs.route_path": context.routePath,
38
+ "nextjs.route_type": context.routeType,
39
+ ...(context.renderSource && { "nextjs.render_source": context.renderSource }),
40
+ ...(context.revalidateReason && { "nextjs.revalidate_reason": context.revalidateReason }),
41
+ });
42
+ };
@@ -0,0 +1,5 @@
1
+ export { setUser } from "@unhandled/node";
2
+ export { withUnhandledConfig } from "./config.ts";
3
+ export { onRequestError } from "./errors.ts";
4
+ export { type NextUnhandledOptions, register, registerUnhandled } from "./register.ts";
5
+ export { withServerAction } from "./server_action.ts";
package/build/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { setUser } from "@unhandled/node";
2
+ export { withUnhandledConfig } from "./config.js";
3
+ export { onRequestError } from "./errors.js";
4
+ export { register, registerUnhandled } from "./register.js";
5
+ export { withServerAction } from "./server_action.js";
@@ -0,0 +1,5 @@
1
+ import { type UnhandledClient } from "@unhandled/node";
2
+ import { type UnhandledOptions } from "@unhandled/node/framework";
3
+ export type NextUnhandledOptions = Omit<UnhandledOptions, "frameworkLayer">;
4
+ export declare const registerUnhandled: (options?: NextUnhandledOptions) => UnhandledClient | null;
5
+ export declare const register: () => void;
@@ -0,0 +1,14 @@
1
+ import { init } from "@unhandled/node";
2
+ import { frameworkLayer } from "@unhandled/node/framework";
3
+ export const registerUnhandled = (options = {}) => {
4
+ if (process.env.NEXT_RUNTIME !== "nodejs") {
5
+ return null;
6
+ }
7
+ return init({
8
+ ...options,
9
+ frameworkLayer: frameworkLayer(new URL("../package.json", import.meta.url), "nextjs"),
10
+ });
11
+ };
12
+ export const register = () => {
13
+ registerUnhandled();
14
+ };
@@ -0,0 +1 @@
1
+ export declare const withServerAction: <Args extends unknown[], Result>(name: string, action: (...args: Args) => Promise<Result>) => (...args: Args) => Promise<Result>;
@@ -0,0 +1,15 @@
1
+ import { trace } from "@opentelemetry/api";
2
+ import { reportNextError } from "./errors.js";
3
+ const tracer = trace.getTracer("@unhandled/nextjs");
4
+ export const withServerAction = (name, action) => (...args) => tracer.startActiveSpan(`server action ${name}`, { attributes: { "nextjs.server_action": name } }, async (span) => {
5
+ try {
6
+ return await action(...args);
7
+ }
8
+ catch (error) {
9
+ reportNextError(error, "nextjs.server_action", { "nextjs.server_action": name });
10
+ throw error;
11
+ }
12
+ finally {
13
+ span.end();
14
+ }
15
+ });
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@unhandled/nextjs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "Unhandled for Next.js: instrumentation.ts registration, request error reporting, route handler and server action wrappers on @unhandled/node",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/treboryx/unhandled.git",
8
+ "directory": "packages/nextjs"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "edge-light": {
15
+ "types": "./build/edge.d.ts",
16
+ "default": "./build/edge.js"
17
+ },
18
+ "types": "./build/index.d.ts",
19
+ "default": "./build/index.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "build"
25
+ ],
26
+ "dependencies": {
27
+ "@opentelemetry/api": "~1.9.1",
28
+ "@unhandled/node": "0.0.1"
29
+ },
30
+ "devDependencies": {
31
+ "@adonisjs/tsconfig": "^2.0.0",
32
+ "@types/node": "^25.9.9",
33
+ "@types/react": "^19.3.0",
34
+ "@unhandled/otlp-receiver": "0.0.0",
35
+ "next": "^16.4.2",
36
+ "react": "^19.3.1",
37
+ "react-dom": "^19.3.1",
38
+ "typescript": "~6.0.2"
39
+ },
40
+ "peerDependencies": {
41
+ "next": ">=15.0.0",
42
+ "react": ">=19.0.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=24.0.0"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc --project tsconfig.build.json",
52
+ "test": "node --test 'tests/**/*.test.ts'",
53
+ "typecheck": "tsrs --noEmit",
54
+ "typecheck:tsc": "tsc --noEmit"
55
+ }
6
56
  }