DEV Community

Sola Samuel
Sola Samuel

Posted on

I built a Chrome extension that explains your console errors in plain English

Here is an error message I got last week:

Warning: Each child in a list should have a unique "key" prop.
Check the render method of `ProductList`.
    at li
    at ProductList (webpack-internal:///./src/components/ProductList.jsx:34:5)
Enter fullscreen mode Exit fullscreen mode

I know what this error means. I've seen it a hundred times. I fixed it in thirty seconds.

Here is a different error I got the same day:

Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'map')
    at processApiResponse (api.js:847:23)
    at async fetchProducts (api.js:112:5)
Enter fullscreen mode Exit fullscreen mode

I did not know what that one meant. Not immediately. The function at line 847 was calling .map() on something, and that something was undefined when it shouldn't have been. But why? Was the API returning a different shape? A race condition? A missing null check upstream?

I spent twenty minutes on that error. I Googled it, read three Stack Overflow answers that all said "just add ?. before the map" — technically correct but doesn't explain why the value is undefined or whether the real fix is somewhere else.

I've been a developer long enough to know what undefined means. The problem was the twenty minutes.

So I built DebugBuddy.


What it does

DebugBuddy is a Chrome extension that captures JavaScript errors from any tab and explains them with the Claude API. Click the extension icon, and the active tab's errors show up in a popup. Click any error, and you get a structured explanation: a summary, the likely cause, a suggested fix, and relevant docs links.

That's the whole product. It is deliberately not a DevTools panel, a content script, or a paste-in-anything tool. The version that ships does one thing — capture errors from the active tab via the Chrome DevTools Protocol and explain them — and tries to do that part well.

The interesting work was not the UI. The interesting work was getting the capture pipeline to behave under a few constraints that are not obvious until you hit them.


Why CDP, not content scripts

The first design question was: how do you actually capture console.error and uncaught exceptions from an extension?

The two viable approaches are:

Content script + document_start injection. Inject a script into the page context that overrides window.console.error before any page code runs. Bridge messages back to the extension via window.postMessage. This is the classic approach and it works, but it has problems: the page can untrap your override, strict CSP can block the injected script, and you're racing against the page's own setup during hydration.

Chrome DevTools Protocol via chrome.debugger. Attach to the tab as a debugger client and subscribe to Runtime.exceptionThrown and Log.entryAdded. This is what DevTools itself uses. It can't be untrapped by page code, isn't affected by CSP, and gives you structured data — a real stackTrace.callFrames array, not a message string you have to re-parse.

I went with CDP. The cost is the yellow "DebugBuddy started debugging this browser" banner Chrome shows whenever a debugger is attached, which is annoying but unavoidable. The benefit is that capture is bulletproof in a way that injection just isn't.

The capture loop is small:

// src/background/index.ts
chrome.debugger.onEvent.addListener((source, method, params) => {
  if (!source.tabId) return;

  let captured = null;
  if (method === "Runtime.exceptionThrown") {
    captured = parseExceptionThrown(params);
  } else if (method === "Log.entryAdded") {
    captured = parseLogEntry(params);
  }

  if (captured) {
    addError(source.tabId, captured).then(async () => {
      const errors = await getErrors(source.tabId!);
      updateBadge(source.tabId!, errors.length);
      chrome.runtime
        .sendMessage({ type: "ERROR_CAPTURED", payload: captured })
        .catch(() => {});
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

That's it for capture. The rest of the work is making sure the debugger session stays attached to the right tabs, doesn't fight with anyone else, and survives Chrome's MV3 lifecycle.


Three constraints that took longer than the feature

1. The MV3 service worker dies. The CDP session doesn't.

In Manifest V3, the background script is a service worker that gets killed after about thirty seconds of inactivity. When it spins back up, every in-memory variable is gone. That includes the Set<number> I was using to track which tabs the extension had attached to.

The wrinkle: Chrome can keep the underlying CDP session alive across a service worker restart. So you wake up, your in-memory attachedTabs says "I'm not attached to anything," and you call chrome.debugger.attach() — which throws, because Chrome thinks you're already attached.

The fix uses two sources of truth and treats Runtime.enable as a probe:

// src/background/debugger.ts
async function isReallyAttached(tabId: number): Promise<boolean> {
  const targets = await chrome.debugger.getTargets();
  return targets.some((t) => t.tabId === tabId && t.attached);
}

async function tryEnableDomains(tabId: number): Promise<boolean> {
  try {
    await chrome.debugger.sendCommand({ tabId }, "Runtime.enable");
    await chrome.debugger.sendCommand({ tabId }, "Log.enable");
    return true;
  } catch {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

getTargets() answers "is anything attached to this tab?" but not "am I the one attached?" The CDP routing rules give you that for free: sendCommand only succeeds for the client that owns the session. If Runtime.enable succeeds, we're the owner. If it throws, someone else is — or the session genuinely doesn't exist.

The full attach logic uses both: if our in-memory set says we're attached, try to re-enable domains; if getTargets() shows something attached but we don't own it, try one more time in case Chrome held our session across the restart; otherwise fall through to a fresh attach() call.

2. There's no API for "is another extension attached?"

Only one debugger client can own a tab's CDP session at a time. If the user has DevTools open, or another debugger extension running, your attach() call fails with a generic "Another debugger is already attached" error.

There is no API that tells you who is attached. chrome.debugger.getTargets() returns an attached: boolean and an extensionId field, but extensionId is empty when native DevTools holds the session, and you can't programmatically distinguish "another extension" from "the user pressed F12."

The workaround is the probe pattern from constraint #1. Try to sendCommand. If it works, we already own the session and the user just opened a fresh popup. If it fails, surface a clean message — "Another debugger is already attached to this tab" — instead of letting the raw exception propagate to the UI.

This came from a real bug. I had a session where DevTools was open in another window, capture silently stopped working, and the only signal was a stack trace in the service worker logs that no user would ever see.

3. Restricted URLs and the auto-attach race

I wanted attaching to be invisible — when you switch tabs, the extension should attach to the new tab automatically so errors are captured from the moment you arrive. Two listeners cover that:

chrome.tabs.onActivated.addListener(async ({ tabId }) => {
  const tab = await chrome.tabs.get(tabId).catch(() => null);
  if (tab && !isRestrictedUrl(tab.url)) attachDebugger(tabId);
});

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.status === "loading" && !isRestrictedUrl(tab.url)) {
    attachDebugger(tabId);
  }
});
Enter fullscreen mode Exit fullscreen mode

The "obvious" version of this breaks immediately, because chrome.debugger.attach() throws on a long list of internal URLs: chrome://, chrome-extension://, devtools://, about:, view-source:, and a few others. You can't attach to the new-tab page. You can't attach to the extension's own options page. You can't attach to DevTools itself.

The fix is a protocol allowlist checked before every attach attempt:

const RESTRICTED_PROTOCOLS = [
  "chrome://", "chrome-extension://", "edge://", "about:",
  "view-source:", "devtools://", "chrome-search://", "chrome-untrusted://",
];

export function isRestrictedUrl(url: string | undefined): boolean {
  if (!url) return true;
  return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
}
Enter fullscreen mode Exit fullscreen mode

The second half of the fix is in the message handler. When the popup opens on a restricted page and asks the background to attach, returning an error is the wrong UX — there's nothing the user can do about it, and they don't care. So restricted pages return success: true, restricted: true. The popup uses the restricted flag to render a "this page can't be debugged" hint instead of an error.


The smaller details

Deduplication is by message hash. Storage groups errors using a djb2 hash of the message string. If the same error fires twice, the existing entry's count is incremented and the timestamp updated, instead of pushing a new row. A 50-error-per-tab rolling buffer keeps storage bounded.

const existing = errors.find((e) => e.hash === error.hash);
if (existing) {
  existing.count += 1;
  existing.timestamp = error.timestamp;
} else {
  errors.push(error);
}
Enter fullscreen mode Exit fullscreen mode

This is global per tab, not windowed. A render loop firing the same error 50 times per second collapses to a single row with count: 50, which is the behavior I want — render loops should be visible as a high count, not a wall of identical rows.

Explanations are cached for an hour. The cache is keyed by error hash, sits in the service worker's memory, and has a 1-hour TTL. Re-clicking the same error returns the cached explanation instantly without re-billing the API. The cache is wiped on service worker restart, which is fine — the worst case is one extra API call after thirty seconds of idle.

The model is Claude Haiku 4.5. Error explanations are short (a few hundred tokens at most), there's no benefit to a larger model, and Haiku is fast enough that the explanation feels instant when it isn't cached. The system prompt asks for a structured JSON response with summary, likelyCause, suggestedFix, and relevantLinks — which the popup parses into four sections.

It's BYOK. The user's Claude API key is stored in chrome.storage.local and sent directly to api.anthropic.com from the service worker, with the anthropic-dangerous-direct-browser-access header set. Nothing routes through any server I run. The options page validates the key by sending a tiny ping message before saving it.


What I'd build differently

The yellow banner is a tax on every user. Chrome shows "DebugBuddy started debugging this browser" whenever the debugger is attached, and there's no way to suppress it. A future version might detach during long quiet periods and re-attach lazily, but the heuristics are messy — you don't want to miss the first error after a quiet stretch.

Per-tab CDP sessions don't survive navigation cleanly. A full-page navigation can trigger onDetach, requiring a re-attach. The current code handles this via tabs.onUpdated with status === "loading", but it's racy — if the new page throws an error in the first 50ms before re-attach completes, that error is lost. A more robust approach would use webNavigation.onBeforeNavigate to pre-arm the attach.

There's no DevTools panel. A dedicated panel would let users see explanations alongside the Console tab without opening the popup, and it would dodge the yellow-banner problem because DevTools is already attached. It's the most-requested feature and it's on the roadmap.


Try it

Chrome Web Store: [https://chromewebstore.google.com/detail/debugbuddy/cccnbldheilinhcljcmililgalpialig?hl=en-GB&utm_source=ext_sidebar]
GitHub (MIT): [https://github.com/solasamuel/debugbuddy]

You'll need a Claude API key — paste it into the options page once. New Anthropic accounts get a small starting credit; after that, expect a fraction of a cent per explanation at current Haiku 4.5 pricing.

The most interesting file in the repo is src/background/debugger.ts — the attach/probe logic for constraints #1 and #2 is all there in about 100 lines.


The error that started this took me twenty minutes. With DebugBuddy, the same error would take thirty seconds. The twenty minutes wasn't because I'm a bad developer — it was because the tooling made me do unnecessary work.

I'm curious what errors you hit most often. Drop them in the comments — I want to make sure the explanations are good for the errors that matter, not just the ones I happen to encounter in my own work.

Top comments (0)