Logging Across Contexts Without Losing Messages
Build one log stream for an MV3 extension — capturing logs from the worker, pages and content scripts, surviving worker eviction, levels and sampling, and exporting a diagnostic bundle.
Table of Contents
Console output in an extension is scattered across as many consoles as there are contexts, and the most important context — the service worker — discards its console history every time it is evicted. The practical result is that the log line explaining a bug was written, correctly, into a console nobody had open, in a worker that no longer exists. A small shared logger that writes to storage turns those scattered, ephemeral consoles into one durable, ordered stream. This guide is part of debugging extension contexts.
Why console output alone is not enough
Step-by-step
1. One logger module for every context
1// log.js
2const CTX = typeof document === "undefined" ? "sw"
3 : location.protocol === "chrome-extension:" ? location.pathname.replace(/^\/|\.html$/g, "")
4 : "content";
5
6const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
7let minLevel = LEVELS.info;
8
9export const log = Object.fromEntries(Object.keys(LEVELS).map((lvl) => [lvl, (...args) => {
10 (console[lvl] ?? console.log)(`[${CTX}]`, ...args);
11 if (LEVELS[lvl] >= minLevel) enqueue({ t: Date.now(), lvl, ctx: CTX, msg: args.map(fmt).join(" ") });
12}]));
13
14function fmt(a) {
15 if (a instanceof Error) return `${a.name}: ${a.message}`;
16 if (typeof a === "object") { try { return JSON.stringify(a).slice(0, 500); } catch { return String(a); } }
17 return String(a);
18}
Execution context: any extension context and content scripts alike. The context tag is derived automatically — sw, popup, options, content — so every line says where it came from without each call site having to. Console output still happens for live debugging; the queue is what persists.
2. Batch writes to storage
Writing to storage on every log line is slow and, for synced areas, rate-limited. Buffer lines and flush them together.
1let queue = [];
2let flushing = null;
3
4function enqueue(entry) {
5 queue.push(entry);
6 flushing ??= Promise.resolve().then(flush);
7}
8
9async function flush() {
10 const batch = queue; queue = []; flushing = null;
11 const { traceLog = [] } = await chrome.storage.local.get("traceLog");
12 const next = traceLog.concat(batch).slice(-500);
13 await chrome.storage.local.set({ traceLog: next });
14}
Execution context: any extension context. Flushing on the next microtask coalesces a burst of lines into one write, and the cap keeps the log from growing without bound. storage.local is the right area — it survives worker eviction and browser restarts, and has no write-rate cap, unlike the sync area whose limits are covered in batching storage writes to stay under quota.
3. Route content-script logs through the worker
Content scripts can write to chrome.storage directly, but several tabs writing the same key at once race and lose lines. Sending them to the worker serialises the writes.
1// In content scripts, replace enqueue with a message:
2function enqueue(entry) {
3 chrome.runtime.sendMessage({ type: "log", entry }).catch(() => {});
4}
5
6// In the worker, at the top level:
7chrome.runtime.onMessage.addListener((msg, sender) => {
8 if (msg?.type !== "log") return false;
9 const host = sender.tab?.url ? new URL(sender.tab.url).host : "?";
10 workerEnqueue({ ...msg.entry, ctx: `content@${host}` });
11 return false;
12});
Execution context: the content script sends; the service worker receives and writes. Tagging with the host tells you which site the line came from — useful, and also a privacy consideration: keep the host, not the full URL, unless you genuinely need the path.
4. Survive the flush racing eviction
A worker can be evicted between enqueueing a line and the flush completing. Holding the worker open until pending writes finish closes that window for lines logged inside an event handler.
1export function flushPending() {
2 return flushing ?? Promise.resolve();
3}
4
5// In handlers that log important lines:
6chrome.alarms.onAlarm.addListener(async (a) => {
7 try { await runJob(a); }
8 catch (err) { log.error("job failed", a.name, err); }
9 finally { await flushPending(); }
10});
Execution context: the service worker. Awaiting the flush at the end of the handler keeps the returned promise pending until the log is on disk, so the worker is not evicted with the line still in memory. The lifetime rules this relies on are in persistent vs non-persistent service workers explained.
5. Read and export the log
1// options.js — a Diagnostics section
2document.querySelector("#copy-log").addEventListener("click", async () => {
3 const { traceLog = [] } = await chrome.storage.local.get("traceLog");
4 const text = traceLog.map((e) => `${new Date(e.t).toISOString()} ${e.lvl.padEnd(5)} [${e.ctx}] ${e.msg}`).join("\n");
5 await navigator.clipboard.writeText(`Reader ${chrome.runtime.getManifest().version}\n${text}`);
6 status.textContent = `Copied ${traceLog.length} lines`;
7});
Execution context: the options page, which has a focused document and therefore clipboard access. A user can paste this into a support request, and the version line at the top saves a round trip. Letting the user see what they are sending before they send it is the privacy-respecting default discussed in diagnosing crashes from user reports.
Levels, sampling and what not to log
A durable log is also a durable record of user activity, so what goes into it deserves the same care as anything else the extension stores.
Default to info, not debug. Debug lines are for live sessions; persisting them fills the capped buffer with noise and pushes out the warning you needed. Let the options page raise the level temporarily when a user is helping you diagnose something.
1chrome.storage.onChanged.addListener((c, area) => {
2 if (area === "local" && c.logLevel) minLevel = LEVELS[c.logLevel.newValue] ?? LEVELS.info;
3});
Execution context: every context that imports the logger. Changing the level in storage takes effect everywhere at once, without a reload.
Never log secrets or page content. Tokens, full URLs with query strings, form values and page text do not belong in a log a user might paste into a public issue. Log identifiers and outcomes — “sync 429, retry in 4 min” — not payloads.
Sample the noisy paths. A per-request log line in a content script on a busy page will fill 500 entries in seconds. Log the first occurrence and a count, not every instance.
Cross-browser variation
- Chrome / Edge:
chrome.storage.localfrom every context including content scripts; the worker’s console history is lost on eviction. - Firefox: same storage semantics. The Browser Console shows extension logs from all contexts in one place during development, which reduces — but does not remove — the need for a persisted log.
- Safari:
storage.localworks identically; Safari’s background context is evicted aggressively, making the persisted log more valuable there than anywhere else. - All three:
navigator.clipboard.writeTextrequires a focused document, so the export belongs on an extension page, not in the worker.
Verification
- Log from each context, then read the combined stream:
1(await chrome.storage.local.get("traceLog")).traceLog.slice(-4).map((e) => `${e.ctx}: ${e.msg}`);
2// ["sw: sync started", "content@news.example.com: highlighted 3", "popup: opened", "sw: sync done"]
Execution context: any extension page’s console. Lines from every context, in time order, confirm the routing works.
- Log inside an alarm handler, close all inspectors, wait for eviction, and confirm the line is still present.
- Open three tabs that each log on load and confirm no lines are lost to concurrent writes.
- Set
logLeveltowarnand confirm info lines stop being persisted in every context.
FAQ
Why not use IndexedDB for logs?
It works and scales further, but a capped list in storage.local is simpler, readable from every context, and large enough for diagnostic use. Reach for IndexedDB only if you need thousands of lines with querying.
Should logs be sent to a server automatically?
Not by default. Send errors through a reporting pipeline with a clear privacy story, and make the full log an explicit, user-initiated export.
Does logging slow the extension down?
Batched writes cost almost nothing. Unbatched per-line writes from a busy content script can — which is why the queue exists.
Related
- Finding the right DevTools target for each context — the consoles this complements.
- Capturing uncaught errors in every context — feeding uncaught errors into the same stream.
- Reporting errors without breaking your privacy policy — what may leave the device.
- Debugging extension contexts — the parent guide.