Error Tracking for RedwoodJS and Full-Stack GraphQL Apps: Cells, Services, and Resolvers
EngineeringAugust 8, 202614 min read

Error Tracking for RedwoodJS and Full-Stack GraphQL Apps: Cells, Services, and Resolvers

RedwoodJS cells mask GraphQL failures behind loading states. Here's how to trace errors from a crashed cell render back through the resolver that caused it.

Three weeks ago I shipped a Redwood app where the user profile page occasionally showed a generic "Something went wrong" message. No pattern I could see. Worked fine in dev. Worked fine in staging. Production? Maybe one in fifty users hit it.

The cell's Failure component was rendering. Which meant the GraphQL query failed. But the server logs showed... nothing. No errors. No warnings. Just successful requests.

Took me two days to figure out. A Prisma query in the resolver was throwing on a specific edge case — a user with a null profileId from a migration bug months ago. The resolver caught the error, logged it to console.error (which disappears into the void on serverless), and returned null. The cell saw the null, decided the query "failed," and rendered Failure. No actual error ever reached any tracking system.

So I fixed the bug. And then I spent a weekend wiring up proper error tracking. Because I don't want to lose two more days to the next one.

This is that setup. RedwoodJS error tracking that traces a cell failure back through the GraphQL resolver and into your services. One instrumentation layer that covers cells, resolvers, services, and Prisma. We're using JustAnalytics because it handles errors and analytics in one under-5KB script — no separate Sentry install.

What you'll have by the end

A RedwoodJS application with:

  • Server-side error tracking in your GraphQL context that catches resolver and service failures
  • Correlation IDs that link frontend cell failures to backend stack traces
  • Client-side error tracking for the React layer
  • Automatic pageview tracking with Redwood's router
  • Source map support for minified production errors

Prerequisites

  • RedwoodJS 7.0+ (tested on 7.6.x with GraphQL Yoga)
  • Node.js 20+ for the API side
  • A JustAnalytics account — free tier covers 100K events/month
  • Familiarity with Redwood's cell pattern and GraphQL SDL

If you're on a different full-stack setup, we've covered SvelteKit load functions and Django middleware separately. The GraphQL patterns here translate to any Yoga-based setup.

Step 1: Add error tracking to your GraphQL handler

Redwood's GraphQL lives in api/src/functions/graphql.ts. This is where every query and mutation passes through. Perfect place to catch errors.

// api/src/functions/graphql.ts
import { createGraphQLHandler } from '@redwoodjs/graphql-server'
import { db } from 'src/lib/db'
import { logger } from 'src/lib/logger'
import directives from 'src/directives/**/*.{js,ts}'
import sdls from 'src/graphql/**/*.sdl.{js,ts}'
import services from 'src/services/**/*.{js,ts}'

const JUSTANALYTICS_API_KEY = process.env.JUSTANALYTICS_API_KEY
const JUSTANALYTICS_SITE_ID = process.env.JUSTANALYTICS_SITE_ID

async function trackError(error: Error, context: Record<string, unknown>) {
  if (!JUSTANALYTICS_API_KEY) return

  const payload = {
    site_id: JUSTANALYTICS_SITE_ID,
    event: 'exception',
    properties: {
      type: 'graphql',
      error_name: error.name,
      error_message: error.message,
      stack_trace: error.stack?.slice(0, 5000),
      ...context,
      timestamp: new Date().toISOString(),
    },
  }

  try {
    await fetch('https://api.justanalytics.app/v1/events', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${JUSTANALYTICS_API_KEY}`,
      },
      body: JSON.stringify(payload),
    })
  } catch {
    // Don't let tracking failures break GraphQL
  }
}

export const handler = createGraphQLHandler({
  loggerConfig: { logger, options: {} },
  directives,
  sdls,
  services,
  onException: () => {
    db.$disconnect()
  },
  extraPlugins: [
    {
      onExecute({ args }) {
        return {
          onExecuteDone({ result }) {
            // Check for GraphQL errors in the response
            if (result && 'errors' in result && result.errors?.length) {
              for (const gqlError of result.errors) {
                const originalError = gqlError.originalError || gqlError

                trackError(
                  originalError instanceof Error
                    ? originalError
                    : new Error(gqlError.message),
                  {
                    operation_name: args.operationName,
                    operation_type: args.document.definitions[0]?.kind,
                    path: gqlError.path?.join('.'),
                    correlation_id:
                      args.contextValue?.correlationId || 'unknown',
                  }
                )
              }
            }
          },
        }
      },
    },
  ],
})

The extraPlugins array takes Envelop plugins — GraphQL Yoga's extension system. The onExecuteDone hook fires after every operation, letting you inspect the result for errors. This catches errors from resolvers that throw, services that fail, and Prisma queries that explode.

That ?.slice(0, 5000) on the stack trace? Trust me. I once had a recursive Prisma include that generated a 200KB stack trace. Embarrassing. Blew past every API limit and cost more in event bandwidth than I care to admit. (Yes, I'm the idiot who wrote include: { posts: { include: { author: { include: { posts: true } } } } } in production.)

Step 2: Add correlation IDs for tracing

Here's the problem with just tracking server errors: when a cell shows its Failure component, you know the query failed, but you don't know which server error caused it. Different users hitting the same cell might fail for different reasons.

Fix: correlation IDs. Generate one on the client, send it as a header, pull it out in the GraphQL context, attach it to errors.

First, update your GraphQL handler to extract the correlation ID:

// api/src/functions/graphql.ts — add to createGraphQLHandler options
export const handler = createGraphQLHandler({
  // ... existing config
  context: async ({ event }) => {
    const correlationId =
      event.headers['x-correlation-id'] ||
      `server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`

    return {
      correlationId,
    }
  },
  // ... extraPlugins from before
})

Now the client side. Redwood uses Apollo Client under the hood. We need to add the header to every request.

// web/src/App.tsx or web/src/graphql.ts depending on your setup
import { ApolloLink } from '@apollo/client'

// Generate correlation ID for each request
const correlationLink = new ApolloLink((operation, forward) => {
  const correlationId = `cell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`

  operation.setContext(({ headers = {} }) => ({
    headers: {
      ...headers,
      'x-correlation-id': correlationId,
    },
  }))

  // Store it so we can reference it in error handling
  operation.extensions.correlationId = correlationId

  return forward(operation)
})

Wire this into your Apollo Client setup. In Redwood 7+, you can customize the client in web/src/App.tsx via the graphQLClientConfig prop on RedwoodApolloProvider.

Now when a cell fails, you can grab the correlation ID and search your server logs. The frontend error says "correlation ID abc123" — search backend errors for "abc123" — find the exact resolver stack trace.

Step 3: Track cell failures on the client

Cells handle their own errors gracefully. That's usually good — users see a Failure component instead of a white screen. But it means errors don't bubble up to window.onerror or React error boundaries.

Track them explicitly in your cell's Failure component:

// web/src/components/UserProfileCell/UserProfileCell.tsx
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'

export const QUERY = gql`
  query UserProfileQuery($id: Int!) {
    user(id: $id) {
      id
      name
      email
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>User not found</div>

export const Failure = ({ error, queryResult }: CellFailureProps) => {
  // Track the failure
  if (typeof window !== 'undefined' && window.ja) {
    window.ja('event', 'cell_failure', {
      cell_name: 'UserProfileCell',
      error_message: error?.message || 'Unknown error',
      correlation_id:
        queryResult?.operation?.extensions?.correlationId || 'unknown',
      path: window.location.pathname,
    })
  }

  return (
    <div className="error-state">
      <p>Couldn't load profile. Try refreshing.</p>
    </div>
  )
}

export const Success = ({ user }: CellSuccessProps<UserProfileQuery>) => {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

That window.ja call sends the error to JustAnalytics on the client side. The correlation ID links it to the server error. Now you've got both ends of the trace.

If you've got fifty cells and don't want to add tracking to each one manually (I didn't), create a wrapper:

// web/src/lib/trackedCell.tsx
import { trackCellFailure } from './analytics'

export function createTrackedFailure(cellName: string) {
  return function TrackedFailure({ error, queryResult }: CellFailureProps) {
    trackCellFailure(cellName, error, queryResult)

    return (
      <div className="error-state">
        <p>Something went wrong. Try refreshing.</p>
      </div>
    )
  }
}

// Then in your cell:
export const Failure = createTrackedFailure('UserProfileCell')

Step 4: Add the analytics script and pageviews

Drop the script in your document head. Redwood uses web/src/index.html:

<!-- web/src/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script
      defer
      src="https://cdn.justanalytics.app/script.js"
      data-site="your-site-id"
      data-auto-pageview="false"
    ></script>
  </head>
  <body>
    <div id="redwood-app"></div>
  </body>
</html>

The data-auto-pageview="false" disables automatic pageview tracking. Redwood's router does client-side navigation, so we need to track pageviews manually on route changes. Every SPA framework has this problem. Kind of wish the analytics world had solved this by now, but here we are writing useEffect hooks like it's 2019.

Create a pageview tracker:

// web/src/components/Analytics/Analytics.tsx
import { useEffect } from 'react'
import { useLocation } from '@redwoodjs/router'

declare global {
  interface Window {
    ja?: (action: string, ...args: unknown[]) => void
  }
}

export const Analytics = () => {
  const { pathname, search } = useLocation()

  useEffect(() => {
    if (typeof window !== 'undefined' && window.ja) {
      window.ja('pageview', {
        url: pathname + search,
      })
    }
  }, [pathname, search])

  return null
}

Mount it in your layout:

// web/src/layouts/AppLayout/AppLayout.tsx
import { Analytics } from 'src/components/Analytics/Analytics'

const AppLayout = ({ children }) => {
  return (
    <>
      <Analytics />
      <main>{children}</main>
    </>
  )
}

export default AppLayout

Pageviews now fire on every navigation. Combined with cell failures and GraphQL errors, you've got visibility into what users were doing when things broke.

Step 5: Track service-level errors

Resolvers are thin in a well-structured Redwood app. The real logic lives in services. If a service throws, the resolver catches it... or doesn't, depending on how you wrote it.

Wrap your services with explicit tracking for critical operations:

// api/src/services/users/users.ts
import { db } from 'src/lib/db'
import { trackServiceError } from 'src/lib/analytics'

export const user = async ({ id }: { id: number }) => {
  try {
    const result = await db.user.findUnique({
      where: { id },
      include: { profile: true },
    })

    if (!result) {
      // This isn't an error — it's a valid "not found" case
      return null
    }

    return result
  } catch (error) {
    // This is an error — Prisma threw, or something unexpected happened
    trackServiceError('users.user', error, { userId: id })
    throw error // Re-throw so the resolver sees it
  }
}

The tracking helper:

// api/src/lib/analytics.ts
const API_KEY = process.env.JUSTANALYTICS_API_KEY
const SITE_ID = process.env.JUSTANALYTICS_SITE_ID

export function trackServiceError(
  serviceName: string,
  error: unknown,
  context: Record<string, unknown> = {}
) {
  if (!API_KEY) return

  const err = error instanceof Error ? error : new Error(String(error))

  fetch('https://api.justanalytics.app/v1/events', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${API_KEY}`,
    },
    body: JSON.stringify({
      site_id: SITE_ID,
      event: 'service_error',
      properties: {
        service: serviceName,
        error_name: err.name,
        error_message: err.message,
        stack_trace: err.stack?.slice(0, 5000),
        ...context,
        timestamp: new Date().toISOString(),
      },
    }),
  }).catch(() => {})
}

Is this more boilerplate than just letting errors bubble? Yeah. Honestly, I hate that I need to write it. In a perfect world, error tracking would just... work. Out of the box. No configuration. But we don't live there. We live in a world where GraphQL swallows exceptions and serverless logs vanish into the void. So here we are. When that null profileId bug hits production again, you'll have a stack trace and context instead of a two-day mystery.

Common errors and how to fix them

Cell shows Failure but no server error logged. Your resolver is catching errors and returning null or an empty array instead of re-throwing. Either let the error propagate (remove the try-catch) or explicitly track it before returning the fallback.

Correlation ID shows as "unknown" in cell failures. The Apollo Link isn't wired up correctly, or you're using Redwood's older GraphQL setup that doesn't expose queryResult.operation. Check that you're on Redwood 7+ and that the correlation link runs before the request goes out.

Errors fire twice — once from the plugin, once from the service. That's fine, actually. Annoying if you're paying per-event, but useful for debugging. The service error gives you more context (the specific function and arguments). The GraphQL plugin error gives you the operation name and path. Filter by type: 'service' vs type: 'graphql' in your dashboard to see them separately.

"Cannot find module 'src/lib/analytics'" in the API side. Redwood's import aliasing doesn't work the same in all contexts. Use relative imports (../lib/analytics) or configure your tsconfig paths explicitly.

What this won't fix

Prisma connection pool exhaustion. If you're running serverless and your connection pool fills up, Prisma hangs without throwing. No error to track. You need APM-level tracing to catch slow queries before they pool. JustAnalytics Pro includes distributed tracing that shows you P99 latencies across your resolvers — useful for spotting these before they become outages.

GraphQL subscriptions. This setup covers queries and mutations. Real-time subscriptions are a different beast — and frankly, I've given up trying to track them cleanly. Redwood's subscription support is still experimental. If you're using them, add separate tracking in your subscription resolvers, but don't expect it to be pretty.

Errors that happen before the GraphQL handler boots. Cold start failures, module resolution errors, environment variable issues — those die before your tracking code runs. Check your platform logs (Vercel Functions, Netlify Functions, whatever). For broader infrastructure visibility, uptime monitoring catches when the whole function stops responding.

Next steps

You've got Redwood error tracking from cell to service. The correlation ID pattern works for any GraphQL setup, not just Redwood — if you're on Apollo Server or Mercurius, the same idea applies.

From here:

  • Add session replay to see what users did before cells failed
  • Set up release tracking so you know which deploy broke things
  • Wire custom events into your cell Success components for conversion tracking
  • If you're running multiple client apps, JustBrowser helps manage separate browser profiles while testing
  • For teams managing developer communications, JustEmails handles transactional email delivery with built-in observability

Teams running paid acquisition into Redwood apps should look at ClickzProtect for click fraud detection — nothing worse than paying for traffic that hits a broken cell and bounces. And if you're making outbound calls to users experiencing issues, VeloCalls integrates call tracking with your observability stack.

Frequently Asked Questions

Why don't React error boundaries catch RedwoodJS cell failures properly?

React error boundaries catch component render errors, but RedwoodJS cells handle their own error states internally. When a GraphQL query fails, the cell renders its Failure component instead of throwing — so the error boundary never fires. The actual exception happened on the server in your resolver or service, not in React. You need server-side error tracking in your GraphQL context plus client-side tracking for the rare cases where the Failure component itself throws.

How do I correlate a cell's Failure state with the resolver that caused it?

Include a correlation ID in your GraphQL context that travels from the cell request through the resolver and into your error tracking. Generate the ID on the client before the query fires, pass it as a header, extract it in createGraphQLHandler, and attach it to every error event. When you see a cell failure in your frontend logs, search for that correlation ID in your backend errors to find the exact resolver and stack trace.

Can I use this setup with Redwood's Envelop plugins and GraphQL Yoga?

Yes. Redwood 7+ uses GraphQL Yoga under the hood, which supports Envelop plugins. You can add error tracking as an Envelop plugin that fires on every GraphQL error before the response leaves the server. This is cleaner than wrapping individual resolvers — one plugin covers your entire schema. The example in this tutorial shows both approaches.

Does error tracking work with Redwood's serverless and edge deployments?

Mostly. Serverless functions on Vercel, Netlify, or AWS Lambda handle errors normally — your createGraphQLHandler runs in Node.js and can fire HTTP requests to your error tracking endpoint. Edge deployments are trickier because GraphQL Yoga's full feature set isn't available everywhere. For edge, use lighter tracking that fits within the runtime constraints and accepts potential gaps. Most Redwood apps deploy to serverless, not edge, so this rarely matters in practice.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39/month billed annually).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts