# Next RUM Monitor

A comprehensive Real User Monitoring (RUM) solution for Next.js applications.

## Features

- **Client-side tracking**: JS errors, unhandled rejections, fetch/XHR failures, and page load performance.
- **Server-side tracking**: SSR errors, metadata generation errors, and instrumentation hooks.
- **Axios integration**: Automatically report HTTP 4xx/5xx errors and slow requests (>500ms by default).
- **Build-time tracking**: Capture Webpack compilation errors during dev and build.
- **API key authentication**: Optional `apiKey` sent as `X-API-Key` on every ingest request (e.g. Laravel `applications.api_key`).
- **Detailed context**: Device info, network quality, and arbitrary `extra` metadata you define.

## Installation

```bash
npm install @mwjb/next-rum-monitor
# or
pnpm add @mwjb/next-rum-monitor
# or
yarn add @mwjb/next-rum-monitor
```

## Setup

### 1. Environment variables

Create a `.env.local` file in your Next.js project and add:

```bash
# RUM ingest URL (e.g. Laravel POST /api/rum-events)
NEXT_PUBLIC_RUM_API=https://rum.mwjb.net/api/rum-events

# Application API key (sent as X-API-Key header)
NEXT_PUBLIC_RUM_API_KEY=your-application-api-key
```

| Variable                  | Required | Description                                                       |
| ------------------------- | -------- | ----------------------------------------------------------------- |
| `NEXT_PUBLIC_RUM_API`     | No       | RUM endpoint URL. Used on client and server.                      |
| `NEXT_PUBLIC_RUM_API_KEY` | Yes\*    | API key for browser ingest (`X-API-Key`).                         |
| `RUM_API_KEY`             | No       | Server-only fallback for SSR instrumentation and build reporting. |

Required when your backend enforces API key auth (e.g. Laravel `RumEventController`).

**Note:** Browser events are sent from the client, so the key must be available via `NEXT_PUBLIC_`\* and will be included in the client bundle. Treat it as a public ingest key (similar to analytics keys), not a secret server credential.

After adding or changing these variables, restart your Next.js dev server.

### 2. Central configuration (recommended)

Define shared settings once and reuse them across all integration points:

```ts
// src/lib/rum/config.ts
import type {RumConfig} from "@mwjb/next-rum-monitor";

const DEFAULT_RUM_ENDPOINT = "https://rum.mwjb.net/api/rum-events";

export const rumEndpoint =
  process.env.NEXT_PUBLIC_RUM_API?.trim() || DEFAULT_RUM_ENDPOINT;

export const rumApiKey =
  process.env.NEXT_PUBLIC_RUM_API_KEY?.trim() ||
  process.env.RUM_API_KEY?.trim() ||
  undefined;

export const rumConfig: RumConfig = {
  endpoint: rumEndpoint,
  apiKey: rumApiKey,
  lowSpeedThreshold: 500,
  includeResponse: true,
  includeMemory: true,
  includeDeviceInfo: true,
  includeNetworkInfo: true,
  extra: {
    // Any JSON-serializable key-value data your backend understands
    ...JSON.parse(process.env.NEXT_PUBLIC_RUM_EXTRA ?? "{}"),
  },
};

export const rumBoundaryReportOptions = {
  endpoint: rumEndpoint,
  apiKey: rumApiKey,
};
```

```ts
// src/lib/rum/index.ts
export {
  rumApiKey,
  rumConfig,
  rumEndpoint,
  rumBoundaryReportOptions,
} from "./config";
```

### 3. Browser initialization

Add this to your root `layout.tsx`:

```tsx
import {initRum} from "@mwjb/next-rum-monitor";
import {rumConfig} from "@/lib/rum";

initRum(rumConfig);
```

### 4. Next.js configuration

Wrap your config in `next.config.ts`:

```ts
import {withRumBuildReporter} from "@mwjb/next-rum-monitor";
import {rumConfig} from "./src/lib/rum/config";

export default withRumBuildReporter(nextConfig, rumConfig);
```

### 5. Server instrumentation

Create or update `src/instrumentation.ts`:

```ts
import {createInstrumentationHook} from "@mwjb/next-rum-monitor";
import {rumConfig} from "@/lib/rum";

export const onRequestError = createInstrumentationHook(rumConfig);
```

### 6. Axios interceptors

Apply to your axios instance:

```ts
import {applyAxiosRumInterceptors} from "@mwjb/next-rum-monitor";
import {rumConfig} from "@/lib/rum";

applyAxiosRumInterceptors(axiosInstance, rumConfig, () => ({
  key_1: value_1,
  key_2: value_2,
  // Any fields your ingest pipeline expects
}));
```

For per-request dynamic metadata, use the callback context:

```ts
applyAxiosRumInterceptors(axiosInstance, rumConfig, ({requestConfig, type, status}) => ({
  type,
  status,
  route: window.location.pathname,
  requestId: (requestConfig as any)?.headers?.["x-request-id"],
}));
```

### 7. Error boundaries (recommended)

React errors caught by `error.tsx` / `global-error.tsx` do not always reach `window.onerror`. Report them explicitly with your own UI (i18n, design system, etc.):

```tsx
// src/app/error.tsx
"use client";

import {useRumBoundaryError} from "@mwjb/next-rum-monitor/components";
import {rumBoundaryReportOptions} from "@/lib/rum";

export default function Error({
  error,
  reset,
}: {
  error: Error & {digest?: string};
  reset: () => void;
}) {
  useRumBoundaryError(error, "segment", rumBoundaryReportOptions);

  return (
    <div>
      {/* Your localized / styled fallback */}
      <button type="button" onClick={() => reset()}>
        Try again
      </button>
    </div>
  );
}
```

```tsx
// src/app/global-error.tsx
"use client";

import {useRumBoundaryError} from "@mwjb/next-rum-monitor/components";
import {rumBoundaryReportOptions} from "@/lib/rum";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & {digest?: string};
  reset: () => void;
}) {
  useRumBoundaryError(error, "global", rumBoundaryReportOptions);

  return (
    <html>
      <body>
        <button type="button" onClick={() => reset()}>
          Try again
        </button>
      </body>
    </html>
  );
}
```

Without React, call `reportRumBoundaryError` from the main package inside `useEffect`:

```tsx
import {reportRumBoundaryError} from "@mwjb/next-rum-monitor";
import {rumBoundaryReportOptions} from "@/lib/rum";

useEffect(() => {
  reportRumBoundaryError(error, "segment", rumBoundaryReportOptions);
}, [error]);
```

#### Optional: `@mwjb/next-rum-monitor/components`

`RumError` and `RumGlobalError` are **report-only** helpers (no built-in UI). They exist for quick wiring when you pass `children`:

```tsx
import {RumError} from "@mwjb/next-rum-monitor/components";
import {rumBoundaryReportOptions} from "@/lib/rum";

export default function Error(props: {error: Error; reset: () => void}) {
  return (
    <RumError {...props} {...rumBoundaryReportOptions}>
      <YourErrorUI error={props.error} onRetry={props.reset} />
    </RumError>
  );
}
```

Prefer `useRumBoundaryError` or `reportRumBoundaryError` for production apps.

## Configuration reference

### `RumConfig`

| Option               | Type      | Default | Description                                 |
| -------------------- | --------- | ------- | ------------------------------------------- |
| `endpoint`           | `string`  | —       | RUM ingest URL (required).                  |
| `apiKey`             | `string`  | —       | Sent as `X-API-Key` on all ingest requests. |
| `lowSpeedThreshold`  | `number`  | `500`   | Report requests slower than this (ms).      |
| `includeResponse`    | `boolean` | `true`  | Include HTTP response body in events when allowed by the rules below. |
| `includeResponseOnErrorOnly` | `boolean` | `false` | When `true`, attach `extra.response` only for HTTP status >= 400 (axios low-speed, axios http-error, fetch http-error). |
| `includeMemory`      | `boolean` | `true`  | Include memory usage in events.             |
| `includeDeviceInfo`  | `boolean` | `true`  | Include screen and viewport size.           |
| `includeNetworkInfo` | `boolean` | `true`  | Include `navigator.connection` quality.     |
| `extra`              | `RumExtraData \| (() => RumExtraData)` | — | Arbitrary metadata merged into every event's `extra` object. |

Per-event overrides: `sendRumEvent({ type: "custom", extra: { requestId: "abc" } })`. Config keys are merged first; later keys win.

```ts
extra: {
  // Any shape — strings, numbers, nested objects, arrays, etc.
  tenant: process.env.NEXT_PUBLIC_TENANT,
  tags: ["web", "nextjs"],
},

extra: () => ({
  locale: document.documentElement.lang || "en",
  path: window.location.pathname,
}),
```

### Event payload shape

Every ingest POST uses a minimal, system-agnostic envelope:

| Field | Description |
| --- | --- |
| `type` | Event category (`js-error`, `http-error`, `page-load`, …) |
| `timestamp` | Unix ms |
| `url` | Page URL (client) or request/build URL (server) |
| `memory` | Optional memory snapshot when enabled |
| `extra` | All other context — source, message, stack, HTTP details, your config metadata, etc. |

Example:

```json
{
  "type": "http-error",
  "timestamp": 1710000000000,
  "url": "https://app.example/en/dashboard",
  "memory": { "usedJSHeapSize": "42MB" },
  "extra": {
    "source": "axios",
    "method": "GET",
    "requestUrl": "/api/users",
    "status": 500,
    "userId": "123"
  }
}
```

### `RumExtraData`

`Record<string, unknown>` — attach any JSON-serializable metadata your ingest backend accepts.

### Error boundary helpers

| Export                                          | Package        | Description                                                                                                                                                                              |
| ----------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reportRumBoundaryError(error, kind, options?)` | main           | Report segment (`"segment"`) or global (`"global"`) boundary errors. Uses `sendRumEvent` when `initRum()` ran; otherwise `options.endpoint` / `options.apiKey` for a direct ingest POST. |
| `useRumBoundaryError(error, kind, options?)`    | `./components` | React hook wrapper around `reportRumBoundaryError`.                                                                                                                                      |
| `RumError` / `RumGlobalError`                   | `./components` | Optional report-only wrappers; pass your UI as `children`.                                                                                                                               |

### Laravel backend

When using a Laravel ingest endpoint that validates `X-API-Key` against `applications.api_key`:

```php
$apiKey = $request->header('X-API-Key');
$application = Application::where('api_key', $apiKey)
    ->where('is_active', true)
    ->first();
```

Set `apiKey` in `rumConfig` to the same value as the application's `api_key` column.

## Integration checklist

| File                       | Why?                                                          |
| -------------------------- | ------------------------------------------------------------- |
| `src/lib/rum/config.ts`    | Single source of truth for `endpoint`, `apiKey`, and options. |
| `next.config.ts`           | Wrap with `withRumBuildReporter` to catch compilation errors. |
| `src/instrumentation.ts`   | Capture SSR and Server Action errors via `onRequestError`.    |
| `src/app/layout.tsx`       | Call `initRum()` to bootstrap browser monitoring.             |
| `src/app/error.tsx`        | Catch React rendering errors within pages and segments.       |
| `src/app/global-error.tsx` | Safety net for errors in the root layout.                     |
| `src/lib/axios/axios.tsx`  | Track HTTP 4xx/5xx and latency (if using Axios).              |

## Docker & CI

`NEXT_PUBLIC_RUM_*` values are embedded in the client bundle at **build time**. Treat `NEXT_PUBLIC_RUM_API_KEY` as a **public ingest key** (like an analytics key), not a server secret. Use `RUM_API_KEY` (no `NEXT_PUBLIC_` prefix) for server-only reporting when you need a value that never ships to the browser.

**Do not** commit keys in Dockerfiles, shell scripts, or workflow YAML. Store them in [GitHub Actions secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) (or your CI provider's equivalent) and inject them at build/runtime.

### 1. Add repository secrets

In GitHub: **Settings → Secrets and variables → Actions → New repository secret**

| Secret | Used for |
| --- | --- |
| `NEXT_PUBLIC_RUM_API` | RUM ingest URL (client + server) |
| `NEXT_PUBLIC_RUM_API_KEY` | Browser ingest key (`X-API-Key`) |
| `RUM_API_KEY` | Optional server-only fallback (SSR / build reporting) |

### 2. Dockerfile (build args only — values come from CI)

```dockerfile
ARG NEXT_PUBLIC_RUM_API
ARG NEXT_PUBLIC_RUM_API_KEY
ARG RUM_API_KEY

ENV NEXT_PUBLIC_RUM_API=$NEXT_PUBLIC_RUM_API
ENV NEXT_PUBLIC_RUM_API_KEY=$NEXT_PUBLIC_RUM_API_KEY
ENV RUM_API_KEY=$RUM_API_KEY
```

Do not hardcode real URLs or keys in the Dockerfile.

### 3. GitHub Actions workflow

```yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build Docker image
        env:
          NEXT_PUBLIC_RUM_API: ${{ secrets.NEXT_PUBLIC_RUM_API }}
          NEXT_PUBLIC_RUM_API_KEY: ${{ secrets.NEXT_PUBLIC_RUM_API_KEY }}
          RUM_API_KEY: ${{ secrets.RUM_API_KEY }}
        run: |
          docker build \
            --build-arg NEXT_PUBLIC_RUM_API="$NEXT_PUBLIC_RUM_API" \
            --build-arg NEXT_PUBLIC_RUM_API_KEY="$NEXT_PUBLIC_RUM_API_KEY" \
            --build-arg RUM_API_KEY="$RUM_API_KEY" \
            -t your-app:${{ github.sha }} .
```

Secrets are masked in workflow logs and never committed to the repo.

### Local builds

For local Docker builds, pass values from your shell environment (e.g. `.env.local` loaded in the terminal — **not** copied into the image layer history in committed files):

```bash
export $(grep -v '^#' .env.local | xargs)

docker build \
  --build-arg NEXT_PUBLIC_RUM_API="$NEXT_PUBLIC_RUM_API" \
  --build-arg NEXT_PUBLIC_RUM_API_KEY="$NEXT_PUBLIC_RUM_API_KEY" \
  --build-arg RUM_API_KEY="$RUM_API_KEY" \
  -t your-app .
```
