Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/error-text-handle-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@bounda-dev/core": minor
"@bounda-dev/cli": minor
"@bounda-dev/cloudflare": minor
"@bounda-dev/react-router": minor
"create-bounda": minor
---

React Router apps no longer log the text of what a request throws unless the rule of `runtime.telemetry.errorText` says so. `createBounda`, from `@bounda-dev/react-router` and from `@bounda-dev/react-router/cloudflare`, and `@bounda-dev/react-router/app` return a `handleError` to export from `app/entry.server.tsx`, which React Router calls for what a loader, an action or the render throws, and which the render's `onError` calls too: it logs the method and the error's class and `code`, and its message, its stack and the request's path only with `"include"`, where React Router's default logs every message and stack, in production too. On Node it follows the booted app's configuration, the dev server's `"include"` before the app has booted, and writes to the console as `boot()` does (`logger` and `errorText` options of `createBounda`); on Cloudflare it follows `BOUNDA_ERROR_TEXT` in the Worker's `env`, or else the configuration, and writes JSON lines (`logger` option). It skips a request the client aborted and unwraps what React Router throws itself. The `bounda()` Vite plugin warns when the dev server or the build starts if `app/entry.server.*` is missing or exports no `handleError`.

The environment variable `BOUNDA_ERROR_TEXT` (`ERROR_TEXT_VARIABLE`), `"omit"` or `"include"`, overrides `runtime.telemetry.errorText`: `boot()` reads it once `.env` is loaded, the CLI reads it, below `--error-text`, and the Durable Object and the Worker of `@bounda-dev/cloudflare` read it from their `env`, so `.dev.vars` turns the text of errors on in development while a deployment keeps it out. `boot({ errorText })` and `bounda --error-text` still win; any other value fails with `ConfigurationError`. Every host resolves that one precedence, its own override, then `BOUNDA_ERROR_TEXT`, then the configuration, then its own default, then `"omit"`, through `resolveErrorText`, and `withErrorText` returns the configuration with it applied, for a host of your own. `bounda status` opens the text of the subscribers' failures under that resolved rule, `--error-text` or `BOUNDA_ERROR_TEXT=include` alike, through the call the access log records. `@bounda-dev/cloudflare` exports `workersLogger`, the logger its object and Worker default to.

Projects from `create-bounda` with React Router get `app/entry.server.tsx`, React Router's default entry for their runtime wired to Bounda's `handleError`; projects on Cloudflare get `BOUNDA_ERROR_TEXT=include` in `.dev.vars`, and a note in `.dev.vars.example` that keeps it out of what Deploy to Cloudflare asks for.
11 changes: 10 additions & 1 deletion docs/src/content/docs/adapters/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ Object has no loop running between requests:
`createBoundaObject` accepts `passesPerAlarm` (50 by default): how many rounds of work one alarm
does before it yields and wakes itself again.

The object and the Worker log one JSON line per entry, which Workers Logs parses into fields;
`workersLogger` is that logger, and a `logger` option takes another. A failure is logged by its
class and its `code`, without its message, as [error text](/reference/observability/#error-text)
says by default. The configuration is the same bundle wherever it is deployed, so
`BOUNDA_ERROR_TEXT` in the Worker's `env` decides instead: `include` there, in `.dev.vars`, puts
the message and the stack in the logs under `wrangler dev`, for the object and the Worker alike,
and a deployment, which never reads `.dev.vars`, keeps them out. `create-bounda` writes that line;
`.dev.vars.example` only mentions it, so Deploy to Cloudflare never asks for it.

## Ports and `env`

A port implementation that exports `create` receives the object's `env`: the Worker's
Expand Down Expand Up @@ -180,7 +189,7 @@ global stream.

Refusals come back as `{ "error": { "code", "message" } }` with the statuses in
[errors over HTTP](/reference/errors/#over-http); anything else is a 500 whose message goes to the
logs, not to the caller. A body over `maxPayloadBytes`, `DEFAULT_MAX_PAYLOAD_BYTES` unless
logs, not to the caller, under the rule of [error text](/reference/observability/#error-text). A body over `maxPayloadBytes`, `DEFAULT_MAX_PAYLOAD_BYTES` unless
`createWorker({ config, tenantOf, maxPayloadBytes })` says otherwise, is refused with a 413
before it is read.

Expand Down
8 changes: 6 additions & 2 deletions docs/src/content/docs/guides/personal-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,12 @@ and never its text, and so does the last error the inbox and the scheduler keep
The runtime's own log lines and spans carry no error's text: an error is logged and traced by its
class, its code and the ids of what failed, and the line that files a dead letter names the letter,
whose text `app.deadLetters` opens for as long as the person is not forgotten. Only
[`runtime.telemetry.errorText: "include"`](/reference/observability/#error-text) puts the message
and the stack there, in the clear, out of `forget`'s reach: keep it to development.
[`runtime.telemetry.errorText: "include"`](/reference/observability/#error-text), or
`BOUNDA_ERROR_TEXT=include`, puts the message and the stack there, in the clear, out of `forget`'s
reach: keep it to development. A React Router app logs what its requests throw by the same rule
once `app/entry.server.tsx` exports Bounda's `handleError`
([logging errors](/guides/react-router/#logging-errors)); without it, React Router logs every
message and stack.

What `forget` does not reach is text your code writes elsewhere and data left unmarked: your logs
keep whatever you wrote to them, an event's payload written in the clear, with the data in a field
Expand Down
62 changes: 53 additions & 9 deletions docs/src/content/docs/guides/react-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ export default defineConfig({ plugins: [bounda(), reactRouter()] });
The plugin does two things. It runs `bounda generate` when the dev server or the build starts and
after every change under `app/domain` and `app/read`, so there is no generator to keep running on
the side. And it serves `@bounda-dev/react-router/app`: the `bounda` context, the
`boundaMiddleware`, a `dispose()` and `failure`, wired to the generated registry and typed for
your project through `.bounda/register.d.ts`.
`boundaMiddleware`, a `dispose()`, `failure` and `handleError`, wired to the generated registry and
typed for your project through `.bounda/register.d.ts`.

Bounda's modules live in the same `app/` directory as your routes: `app/domain` and `app/read`
next to `app/routes`, `app/root.tsx` and `app/routes.ts`. The generator only looks at those two
Expand Down Expand Up @@ -114,6 +114,42 @@ Cloudflare, which arrive as plain errors.
Anything else is rethrown and reaches the route's `ErrorBoundary`. `Failure`, the shape of that
data, is exported from `@bounda-dev/react-router`.

## Logging errors

React Router logs what a loader, an action or the render throws, and its default `handleError`
logs the message and the stack, in production too. A message may quote what the code read, a
person's email included, and a log keeps it in the clear, where `forget` never reaches. So export
Bounda's `handleError` from `app/entry.server.tsx`, and call it from the render's `onError`:

```tsx
// app/entry.server.tsx
import { handleError } from "@bounda-dev/react-router/app";

export { handleError };

export default function handleRequest(/* … */) {
// React Router's default entry, with `onError` calling
// `void handleError(error, { request })` where the default calls `console.error(error)`.
}
```

It logs one line per error, `request failed` with the method, the error's class and, for one of
Bounda's, its `code`, under the rule of
[error text](/reference/observability/#error-text): the message, the stack and the request's path,
which may quote what a user typed, only with `runtime.telemetry.errorText: "include"` or
`BOUNDA_ERROR_TEXT=include`. Like the default, it skips a request the client aborted, and it
unwraps what React Router throws itself, such as no route matching, to the error inside. On Node
it writes to the console as `boot()` does, and follows the app's configuration once it has booted:
the dev server's `"include"` before that, `"omit"` in a build. On Cloudflare it writes JSON lines,
as the Durable Object does, and follows `BOUNDA_ERROR_TEXT` in the Worker's `env`, or else the
configuration once a request has imported it: it never waits for an import, which the Worker could
cancel once the response is sent.

`create-bounda` writes the whole file, React Router's default entry for the runtime with these two
changes: in an existing project, `react-router reveal entry.server` writes the default into `app/`
to start from. Without the file, or when it does not export `handleError`, the `bounda()` plugin warns
when the dev server or the build starts.

## When the client goes away

A command keeps going when the browser closes the tab: the request has already arrived, and
Expand Down Expand Up @@ -230,8 +266,8 @@ right after.
the built app reads it from the directory it runs in.
- The dev server logs the text of errors, message and stack, unless `bounda.config.ts` sets
[`runtime.telemetry.errorText`](/reference/observability/#error-text); the built app does not,
since an error's message may quote personal data. On Cloudflare the app runs in its Durable
Object, which follows its own configuration.
since an error's message may quote personal data. `BOUNDA_ERROR_TEXT` in the environment or in
`.env` overrides both. On Cloudflare the app runs in its Durable Object: see below.
- `react-router build` bundles `bounda.config.ts` and the generated registry into the server
build, so the build runs from wherever it is deployed, with no need to ship either file.
- A component that touches `@bounda-dev/react-router/app` gets a clear error: the client build
Expand Down Expand Up @@ -325,6 +361,10 @@ processes and scheduled commands run in the object's alarm, as
- `react-router build` writes the Worker and its `wrangler.json`; `wrangler deploy` deploys it.
- Bounda loads no `.env` here: the Worker's variables and secrets come from Wrangler, locally from
`.dev.vars`, and port implementations receive them as `env` in `create`.
- The configuration is the same bundle in development and in production, so the text of errors
comes from `.dev.vars`: `BOUNDA_ERROR_TEXT=include` there makes the Durable Object's logs and
`handleError` in the Worker carry it under `react-router dev` and `wrangler dev`, and a
deployment, which never reads `.dev.vars`, logs without it. `create-bounda` writes that line.
- With `storage: cloudflare()` but without `@cloudflare/vite-plugin`, the server runs in Node,
which cannot import the Workers runtime, and the first request fails saying to add the plugin.

Expand All @@ -348,7 +388,7 @@ import { boot } from "@bounda-dev/core/node";
import { createBounda } from "@bounda-dev/react-router";
import { registry } from "../.bounda/registry.ts";

export const { bounda, boundaMiddleware, dispose } = createBounda({
export const { bounda, boundaMiddleware, dispose, handleError } = createBounda({
boot: () => boot({ registry, importConfig: () => import("../bounda.config.ts") }),
});
```
Expand All @@ -358,8 +398,11 @@ build bundles both and a change to either re-evaluates this file in development;
then stops the app booted before, and the next request boots a new one once that stop has
finished, so the two never hold the storage at the same time. Its options are `boot` (how to
create the app, `boot()` by default), `consistency`, `actor` (the function `app/actor.ts` would
export) and `key` (where the running app is kept on `globalThis`, one app per key). `dispose()` stops the running app and forgets it, and resolves
once every app booted under that key has stopped. Import `failure` from
export), `key` (where the running app is kept on `globalThis`, one app per key), and for
`handleError` a `logger` (`createConsoleLogger()` by default) and the `errorText` it follows before
an app has booted (`"omit"` by default). `dispose()` stops the running app and forgets it, and resolves
once every app booted under that key has stopped. Export `handleError` from
`app/entry.server.tsx` as [above](#logging-errors). Import `failure` from
`@bounda-dev/react-router`.

On Cloudflare, `createBounda` from `@bounda-dev/react-router/cloudflare` takes the configuration
Expand All @@ -370,12 +413,13 @@ and the tenant instead, and reads the binding from the Worker's `env`:
import { createBounda } from "@bounda-dev/react-router/cloudflare";
import config from "../bounda.config.ts";

export const { bounda, boundaMiddleware } = createBounda({
export const { bounda, boundaMiddleware, handleError } = createBounda({
config,
tenant: ({ params }) => params.workspace ?? "default",
});
```

It takes `consistency` and `actor` too, and throws `ConfigurationError` when `storage` is not `cloudflare()`,
It takes `consistency`, `actor` and a `logger` for `handleError` (`workersLogger` from
`@bounda-dev/cloudflare` by default) too, and throws `ConfigurationError` when `storage` is not `cloudflare()`,
when the Worker has no such binding, without a `tenant` function, or for a `consistency` it does
not know.
10 changes: 6 additions & 4 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ error: ValidationError (VALIDATION_FAILED); its message may quote personal data:
```

`--error-text`, on every command that loads the project, prints the message and lets the log lines
carry the text of errors for that run; a configuration that sets `errorText: "include"` does the
same without it. What fails before any handler runs is printed in full: a configuration or a
carry the text of errors for that run; `BOUNDA_ERROR_TEXT=include` in the environment or in `.env`,
or a configuration that sets `errorText: "include"`, does the same without it, and
`BOUNDA_ERROR_TEXT=omit` keeps the text out whatever the configuration says. What fails before any handler runs is printed in full: a configuration or a
registry that cannot be loaded, a read model that is not in the registry, a list `forget` cannot
read, and errors whose message Bounda writes from names and ids alone, such as a dead letter that is
not found or was already settled. To read what a reaction failed on, read its letter:
Expand Down Expand Up @@ -287,8 +288,9 @@ dead letter retention: 30d, next purge 2026-11-20T08:00:00.000Z
[retention](/guides/personal-data/#inactivity) and the [purge of resolved dead
letters](/guides/dead-letters/#keeping-resolved-letters), each `not configured` when it is off.

`--error-text` also prints, under each failure, the text it keeps, encrypted under the person the
event names: it is read through `app.subscriberFailures.list()`, which records it in the
Under the rule of [error text](#error-text) set to `"include"`, by `--error-text`,
`BOUNDA_ERROR_TEXT=include` or the configuration, it also prints, under each failure, the text it
keeps, encrypted under the person the event names: it is read through `app.subscriberFailures.list()`, which records it in the
[access log](/guides/personal-data/#who-opened-what) with the persons and `--actor`. A failure whose
person was forgotten says its text went with them.

Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ a stopped one left: see [delivery guarantees](/concepts/delivery-guarantees/#the
as `ConcurrencyError` or a dead letter not found, keep it. `"include"` adds the message and the
stack of every error, for development; `createTestApp` and the dev server of
`@bounda-dev/react-router` default to it, and `bounda --error-text` turns it on for one command.
See [error text](/reference/observability/#error-text).
The environment variable `BOUNDA_ERROR_TEXT` overrides it, as `.dev.vars` does in development on
Cloudflare. See [error text](/reference/observability/#error-text).

### Per aggregate

Expand Down
23 changes: 23 additions & 0 deletions docs/src/content/docs/reference/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,36 @@ import { errorFields } from "@bounda-dev/core";
logger.error("request failed", { path, ...errorFields({ error }) });
```

A React Router app exports Bounda's `handleError` from `app/entry.server.tsx`, which logs what a
loader, an action or the render throws by the same rule: React Router's default logs every message
and stack, in production too (see [logging errors](/guides/react-router/#logging-errors)).

[`runtime.telemetry.errorText: "include"`](/reference/configuration/#telemetry) adds the message
and the stack everywhere: `errorMessage` and `errorStack` in log lines, `exception.message` and
`exception.stacktrace` on spans, the message as a failed span's status and `errorMessage` in the
lag. It is for development: `createTestApp` and the dev server of `@bounda-dev/react-router`
default to it unless the configuration says otherwise, `bounda --error-text` turns it on for one
command, and the logger handed to port implementations follows it too.

The environment variable `BOUNDA_ERROR_TEXT`, `"omit"` or `"include"`, sets it for a deployment
without touching the configuration, which on Cloudflare is the same bundle everywhere: `boot()`,
the CLI, and the Durable Object and the Worker of `@bounda-dev/cloudflare` read it, over what the
configuration says. `bounda --error-text` and `boot({ errorText })` still win over it, and any
other value fails the boot with `ConfigurationError`. Projects from `create-bounda` on Cloudflare
set it in `.dev.vars`, which `wrangler dev` and `vite dev` read and a deployment never sees; the
constant `ERROR_TEXT_VARIABLE` from `@bounda-dev/core` names it.

Every host resolves the rule with the same precedence: its own override, then `BOUNDA_ERROR_TEXT`,
then the configuration, then the host's own default (the dev server's `"include"`), then
`"omit"`. A host of your own resolves it the same way with `resolveErrorText` from
`@bounda-dev/core`, and `withErrorText` returns the configuration with it applied:

```ts
import { withErrorText } from "@bounda-dev/core";

const app = await createApp({ registry, config: withErrorText({ config, env: process.env }) });
```

## How a command loaded its aggregate

A `bounda.command` span that loaded its aggregate says how, once it ends, whatever the outcome:
Expand Down
4 changes: 3 additions & 1 deletion examples/onboarding/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ export default defineConfig({ plugins: [bounda(), reactRouter()] });

The plugin runs `bounda generate` when the dev server or the build starts and after every change
under `app/domain` and `app/read`, and serves `@bounda-dev/react-router/app`: the `bounda`
context, the `boundaMiddleware` that `root.tsx` mounts, and `dispose`. Loaders and actions read
context, the `boundaMiddleware` that `root.tsx` mounts, `dispose`, and the `handleError` that
`entry.server.tsx` exports, so what a request throws is logged without its message, which may
quote personal data, except under the dev server. Loaders and actions read
the app with `context.get(bounda)`. The served module imports the generated registry by value, so
editing a domain module re-evaluates it and the next request boots an app from the new code.
`register.d.ts`, also generated, types `bounda` for this project.
Expand Down
Loading
Loading