Skip to main content
All posts
Engineering

The week we instrumented the API and stopped guessing

Sentry on every v1 route, per-device session list backed by SECURITY DEFINER RPCs, auto-generated SDK types, a Scalar OpenAPI explorer, and the first OAuth adapter — all in one push. Notes from the release.

Most of what we shipped this week is invisible. No new screens to screenshot, no new node types on the canvas, no marketing copy to write. What changed is the floor under everything else: the API now reports its own errors, the SDK types itself, the OpenAPI document is browsable, every active session is visible to its owner, and the first third-party OAuth flow is wired end-to-end. Six small features that, taken together, mean we stop guessing what the platform is doing.

Sentry on every /api/v1 route

For the first six months we relied on logs to know when an API call had failed. Logs are fine until you have a hundred routes and a long tail of intermittent issues. So we wrote one helper, `reportApiError(err, ctx)`, and made it the only sanctioned way to capture exceptions inside the v1 surface. It tags every event with `surface=api/v1`, the route path, the HTTP method, the request id, the workspace id, and the user id, then calls `captureException`. The whole thing is wrapped in its own try/catch — observability is never allowed to break the request path.

The interesting bit isn’t the helper. It’s the discipline of having exactly one helper. We grep for `Sentry.captureException` and the only hit is inside `reportApiError`. That means a route author can’t accidentally forget to attach the request id, can’t accidentally leak a stack trace into the response body, and can’t accidentally double-report. The Sentry config itself is a no-op without `SENTRY_DSN` set, which keeps local dev quiet.

Per-device sessions, with a kill switch

Profile → Sessions used to be an honest empty state with the words "Coming soon." That’s now a real list of every active session for the current user, with device, browser, IP, last-seen-at, and a Revoke button on each row. The current session is marked "This device" and can’t be revoked from itself — you sign out instead.

The implementation is two SECURITY DEFINER Postgres functions: `list_user_sessions(uuid)` and `revoke_user_session(uuid, uuid)`. Both `SET search_path = pg_catalog, public` and are granted to `service_role` only. The route layer checks that the session id being revoked belongs to the calling user before invoking the RPC; the RPC checks again. Two checks for a destructive operation is the right number.

The thing we did not do: build our own session table. Supabase already tracks active sessions in the auth schema. Mirroring that into our own table would have been a synchronization bug waiting to happen. Reading directly via SECURITY DEFINER is one less moving part.

Auto-generated SDK types

The public SDK lives in `packages/sdk/`. Until this week, the request and response shapes were hand-written TypeScript that drifted from the real API every time a route changed. The fix was a single npm script: `sdk:generate-types` runs `openapi-typescript` against the in-process OpenAPI document and writes `packages/sdk/src/types.ts`. The file is 38 KB of `paths`, `components`, and `operations` interfaces, and it’s checked in so consumers don’t need to run code generation. We re-export it from the SDK’s entry point.

The script is idempotent and offline — it doesn’t hit the running server, it imports `buildOpenApiSpec` from the same module the `/api/v1/openapi.json` route uses. That means CI can regenerate types and diff the result, and any drift is a failed PR check rather than a Slack message six weeks later.

A real OpenAPI explorer at /docs/api/reference

We already had a Markdown reference at `/docs/api/reference` — useful, but flat. The new `/docs/api/reference` page mounts Scalar’s React API reference component pointed at `/api/v1/openapi.json`, with the modern layout and a curl-first code sample. You can browse every endpoint, see the schema for every request and response, and "Try it out" against your own workspace from the page itself. The route is `noindex` because the canonical reference is still the structured docs.

There’s a build-time warning about a transitive web-worker dependency in the Scalar bundle. We read the warning, decided it doesn’t affect the page in production, and shipped it anyway. Half of engineering is knowing which warnings to act on and which to leave alone.

The first OAuth adapter

The OAuth scaffolding has been in for a few releases — a registry, a token-envelope encryption helper, a database table, a 501 stub on the start route. This week the start route became a real authorize-redirect, the callback route went in, and we landed the first concrete adapter: GitHub. Read scope is `read:user user:email`, write adds `public_repo`. PKCE is off because GitHub’s web flow uses a client secret. The adapter exchanges the code for a token, calls the user endpoint to capture the external id and display name, and hands the envelope back to the callback route, which encrypts it and upserts into `oauth_connections`.

The callback route is the part most worth reading. It verifies the CSRF state cookie, re-checks workspace membership (the user’s permissions could have changed between redirect and callback), reports any failure to Sentry with a phase tag (`exchange`, `seal`, `upsert`), and redirects back to Settings → Integrations with either `?oauth_connected=github` or `?oauth_error=<code>`. The user never sees a stack trace. The cookie is cleared on every exit, success or failure, so a stale state can’t poison the next attempt.

Without `LAZYNEXT_OAUTH_GITHUB_CLIENT_ID` and `LAZYNEXT_OAUTH_GITHUB_CLIENT_SECRET` set, the start route 503s with the env names you need to set. With both set and a registered GitHub OAuth app pointing at `/api/v1/oauth/github/callback`, the entire flow works.

Tests colocated with the package they test

A small one, but it’s been bothering us for a while: the SDK client tests lived in `tests/unit/`, away from the SDK itself. We moved them to `packages/sdk/src/client.test.ts` and updated `vitest.config.ts` to include `packages/*/src/**/*.test.{ts,tsx}`. Now the package is self-contained — anyone vendoring `packages/sdk` gets the tests with it. The full suite is 370 passing.

What’s next

The OAuth path now exists end-to-end, but only one provider speaks it. Linear, Notion, and Slack are the next three, in that order, and each one is a copy of the GitHub adapter with provider-specific scopes and a different user endpoint. The Settings → Integrations page can finally render real connections, the Import Modal can finally pull from real sources, and the Sessions page can finally show what was always there.

Six features, no new screens, fewer unknowns. Worth the week.

Start measuring your team’s judgment.

If your team makes a lot of decisions and has no way to know if it’s getting better at them, we want to talk.