Contents
Introduction
This article is a comprehensive, step-by-step tutorial showing how to instrument JavaScript errors on a WordPress site and reliably report them to a WordPress endpoint for storage, processing and alerting. It covers client-side instrumentation (window.onerror, unhandledrejection, resource errors), best practices (sampling, batching, fingerprinting, privacy), server-side WordPress integration (REST endpoint, nonce verification, database schema, admin UI), symbolication with source maps, offline retry patterns, rate limiting and retention policies. Every code example is included in runnable form.
Goals and architecture
Goals:
- Capture uncaught JS errors, unhandled promise rejections, resource loading errors and contextual metadata (URL, user agent, user ID if available, app version, breadcrumbs).
- Deliver those error events reliably to a WordPress endpoint with minimal performance impact.
- Store errors in a safe and privacy-aware manner in WordPress (database table or custom post type).
- Alert on abnormal volumes or severe errors and provide admin UI for exploration.
High-level architecture:
- Client-side JavaScript error reporter library loaded on the public site
- Reporter sends events to a WP REST API route (or admin-ajax) using fetch, sendBeacon or XHR
- Server validates (nonce, rate-limit, payload size), sanitizes and stores to custom table or post type
- Optional processing: symbolication (source maps), de-duplication, alerts, retention cleanup
Overall considerations and security
- Same-origin vs cross-origin: If you report to the same WordPress origin, CORS is not required. If the endpoint is on another domain, set proper CORS headers and ensure credentials are not forwarded.
- Authentication: Use a REST nonce to protect the endpoint from CSRF. Do not rely on cookies for authentication unless intended.
- Rate limiting: Apply server-side rate limiting to prevent abuse (by IP and by fingerprint).
- Privacy: Do not send cookies, localStorage, or PII fields by default. Strip URL query parameters that may contain tokens or emails. Offer opt-out methods and obey cookie consent.
- Payload size: Limit incoming payload sizes reject very large stack blobs.
- Source maps: Do not publish source maps publicly. Use server-side symbolication service or privately stored maps.
Client-side instrumentation
Key events to capture:
- Global exceptions: window.onerror
- Promise rejections: window.addEventListener(unhandledrejection)
- Resource errors: window.addEventListener(error) for resource errors
- Manual logging: an API to record breadcrumbs or custom errors
Minimal reliable reporter: requirements
- Collect: message, stack (if available), filename, lineno, colno, user agent, current URL, referrer, timestamp, app version, user ID (if available and allowed).
- Fingerprint each error to deduplicate: fingerprint = hash(message filename lineno colno stack).
- Use sendBeacon if available for unload reliability fallback to fetch/XHR.
- Queue events in localStorage or IndexedDB when offline and retry on online events or with exponential backoff.
- Batch events to reduce requests.
Client reporter example
Below is a complete client-side reporter implementation you can include in your theme or plugin. It demonstrates hooking into global handlers, building the payload, batching, sendBeacon fallback, offline queuing and fingerprinting.
// data-enlighter-language=javascript (function(window){ use strict // Configuration populated by localized script from WP (see PHP enqueue example) var CONFIG = window.WP_ERROR_REPORTER_CONFIG { endpoint: /wp-json/my-errors/v1/report, nonce: null, sampleRate: 1.0, // 1.0 means 100% batchSize: 10, maxQueueSize: 100, flushIntervalMs: 5000 } // Utilities function now(){ return (new Date()).toISOString() } function sha1(str){ // Simple small SHA-1 using built-in crypto (modern browsers) try { var enc = new TextEncoder() return crypto.subtle.digest(SHA-1, enc.encode(str)).then(function(buf){ return Array.from(new Uint8Array(buf)).map(b=>(00 b.toString(16)).slice(-2)).join() }) }catch(e){ // Fallback: weak hash if crypto not available var h=0 for(var i=0i>>0).toString(16)) } } function safeStringify(obj, maxLen){ try { var s = JSON.stringify(obj) if(maxLen s.length>maxLen) return s.slice(0,maxLen) return s } catch(e) { return String(obj) } } // Queue store (simple localStorage fallback) var QUEUE_KEY = wp_error_q_v1 var queue = (function(){ try { var q = JSON.parse(localStorage.getItem(QUEUE_KEY) []) if(!Array.isArray(q)) q = [] return q } catch(e){ return [] } })() function persistQueue(){ try{ localStorage.setItem(QUEUE_KEY, JSON.stringify(queue)) }catch(e){} } function pushQueue(item){ queue.push(item) if(queue.length>CONFIG.maxQueueSize) queue.splice(0, queue.length-CONFIG.maxQueueSize) persistQueue() } function popBatch(n){ var batch = queue.splice(0,n) persistQueue() return batch } function queueLength(){ return queue.length } // Build event function buildEvent(err){ var evt = { message: err.message (err.type Error), filename: err.filename err.fileName (err.source null), lineno: err.lineno err.lineNumber null, colno: err.colno err.columnNumber null, stack: err.stack null, type: err.type error, url: location.href, referrer: document.referrer null, userAgent: navigator.userAgent, timestamp: now(), appVersion: window.APP_VERSION null, breadcrumbs: err.breadcrumbs null } return evt } // Send function: try sendBeacon, otherwise fetch function sendPayload(payload){ var url = CONFIG.endpoint var body = safeStringify(payload, 20000) // Use sendBeacon when possible (doesnt support custom headers reliably) if(navigator.sendBeacon){ try { var blob = new Blob([body], {type: application/json}) if(navigator.sendBeacon(url, blob)) return Promise.resolve({ok:true, beacon:true}) } catch(e){} } // Otherwise use fetch var headers = {Content-Type:application/json} if(CONFIG.nonce) headers[X-WP-Nonce] = CONFIG.nonce return fetch(url, { method: POST, body: body, headers: headers, keepalive: true, // help with unload credentials: same-origin }).then(function(resp){ if(!resp.ok) return Promise.reject(new Error(HTTP resp.status)) return resp.json().catch(function(){return {ok:true}}) }) } // Flush queue (batching) var flushing = false function flushNow(){ if(flushing) return if(queueLength()===0) return flushing = true var batch = popBatch(CONFIG.batchSize) var payload = {events: batch} sendPayload(payload).then(function(r){ flushing = false }).catch(function(){ // On failure, push back the batch and keep for retry with backoff // Prepend to queue to reattempt sooner queue = batch.concat(queue) if(queue.length>CONFIG.maxQueueSize) queue.splice(CONFIG.maxQueueSize) persistQueue() flushing = false }) } // Periodic flush setInterval(flushNow, CONFIG.flushIntervalMs) // Add to queue with fingerprinting sampling function reportRaw(evt){ // Sampling if(Math.random() > (CONFIG.sampleRate 1)) return var fingerprintInput = (evt.message) (evt.filename) (evt.lineno) (evt.colno) (evt.stack) // compute async sha1 sha1(fingerprintInput).then(function(fp){ evt.fingerprint = fp // optional deduping on client could be implemented here pushQueue(evt) if(queueLength() >= CONFIG.batchSize) flushNow() }) } // Handlers window.addEventListener(error, function(ev){ // Resource errors (ev.target != window) or JS runtime errors try { if(ev.error){ // JS exception reportRaw(buildEvent(ev.error)) } else if(ev.target (ev.target.src ev.target.href)){ // Resource load error var evObj = { message: ResourceError: (ev.target.src ev.target.href), filename: ev.target.src ev.target.href, type: resource, url: location.href, userAgent: navigator.userAgent, timestamp: now() } reportRaw(evObj) } else { reportRaw({message: Unknown error event, type:error, timestamp:now()}) } } catch(e){} }, true) window.addEventListener(unhandledrejection, function(ev){ try { var reason = ev.reason if(typeof reason === string) { reportRaw({message: reason, type:unhandledrejection, timestamp: now()}) } else if(reason reason.stack){ reportRaw(buildEvent(reason)) } else { reportRaw({message: UnhandledRejection, data: reason, type:unhandledrejection, timestamp: now()}) } } catch(e){} }) // Public API window.WPErrorReporter = { captureException: function(ex, metadata){ try { var evt = buildEvent(ex) if(metadata) evt.meta = metadata reportRaw(evt) } catch(e){} }, captureMessage: function(msg, metadata){ try { var evt = {message: String(msg), type:message, timestamp: now(), meta: metadata} reportRaw(evt) } catch(e){} }, flush: flushNow } // Flush on page hide window.addEventListener(visibilitychange, function(){ if(document.visibilityState === hidden) flushNow() }) // Retry when connection restores window.addEventListener(online, function(){ flushNow() }) })(window)
Notes about the client reporter
- Use CONFIG to pass endpoint URL, nonce and sample rate via wp_localize_script or wp_add_inline_script (see WordPress integration below).
- Fingerprinting should be consistent between client and server to deduplicate easily. Use the same hash algorithm server-side (e.g., md5 or sha1).
- Respect user privacy: do not collect cookies, localStorage or sensitive fields. Remove tokens/PII from URLs.
- For heavy production usage, consider switching to IndexedDB for larger offline queues.
WordPress server-side integration
We will implement a REST API endpoint at /wp-json/my-errors/v1/report that validates a nonce, rate-limits, sanitizes and stores events into a custom database table. This approach keeps data separate and fast to query. You can also use a custom post type instead if you prefer portability or WP features.
Register and expose the script to the front-end (functions.php or plugin)
Enqueue the JS reporter and pass configuration to it using wp_localize_script or wp_add_inline_script. Ensure you also output an app version if available, and generate a nonce for the REST route.
// data-enlighter-language=php function my_errors_enqueue_reporter(){ wp_enqueue_script( wp-error-reporter, plugin_dir_url(__FILE__) . js/wp-error-reporter.js, array(), filemtime(plugin_dir_path(__FILE__) . js/wp-error-reporter.js), true ) config = array( endpoint => esc_url_raw( home_url(/wp-json/my-errors/v1/report) ), nonce => wp_create_nonce(wp_rest), sampleRate => 1.0, // adjust as needed batchSize => 10, maxQueueSize => 200, flushIntervalMs => 5000, ) wp_localize_script(wp-error-reporter, WP_ERROR_REPORTER_CONFIG, config) } add_action(wp_enqueue_scripts,my_errors_enqueue_reporter)
Register REST API route and create DB table
We will create a table called wp_js_error_logs and a REST route. Use dbDelta for table creation.
// data-enlighter-language=php global jal_db_version jal_db_version = 1.0 function my_errors_install_table(){ global wpdb, jal_db_version table_name = wpdb->prefix . js_error_logs charset_collate = wpdb->get_charset_collate() sql = CREATE TABLE table_name ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, fingerprint VARCHAR(64) NOT NULL, message TEXT NOT NULL, filename VARCHAR(255) DEFAULT NULL, lineno INT DEFAULT NULL, colno INT DEFAULT NULL, stack LONGTEXT DEFAULT NULL, type VARCHAR(60) DEFAULT NULL, url VARCHAR(255) DEFAULT NULL, referrer VARCHAR(255) DEFAULT NULL, user_agent VARCHAR(255) DEFAULT NULL, app_version VARCHAR(100) DEFAULT NULL, user_id BIGINT UNSIGNED DEFAULT NULL, created_at DATETIME NOT NULL, occurrences INT DEFAULT 1, PRIMARY KEY (id), KEY fingerprint (fingerprint), KEY created_at (created_at) ) charset_collate require_once(ABSPATH . wp-admin/includes/upgrade.php) dbDelta(sql) add_option(my_errors_db_version, jal_db_version) } register_activation_hook(__FILE__, my_errors_install_table) // REST route add_action(rest_api_init, function(){ register_rest_route(my-errors/v1,/report, array( methods => POST, callback => my_errors_handle_report, permission_callback => __return_true, // well validate nonce inside )) })
Handler: validation, rate limiting, sanitization, insert and response
// data-enlighter-language=php function my_errors_handle_report(WP_REST_Request request){ global wpdb table = wpdb->prefix . js_error_logs // 1) Validate origin and nonce headers = getallheaders() nonce = isset(headers[X-WP-Nonce]) ? headers[X-WP-Nonce] : request->get_param(nonce) if( ! wp_verify_nonce( nonce, wp_rest ) ){ return new WP_REST_Response(array(error=>Invalid nonce), 403) } // 2) Limit request size body = request->get_body() // raw if(strlen(body) > 20000){ return new WP_REST_Response(array(error=>Payload too large), 413) } data = json_decode(body, true) if(!is_array(data) empty(data[events])){ return new WP_REST_Response(array(error=>Invalid payload), 400) } events = data[events] // 3) Iterate and sanitize each event inserted = 0 foreach(events as evt){ // Basic fields message = isset(evt[message]) ? sanitize_text_field( wp_trim_words(evt[message], 200, ) ) : filename = isset(evt[filename]) ? esc_url_raw( substr(evt[filename],0,255) ) : null lineno = isset(evt[lineno]) ? intval(evt[lineno]) : null colno = isset(evt[colno]) ? intval(evt[colno]) : null stack = isset(evt[stack]) ? wp_kses_post( substr(evt[stack], 0, 10000 ) ) : null type = isset(evt[type]) ? sanitize_text_field( substr(evt[type],0,60) ) : null url = isset(evt[url]) ? esc_url_raw( substr(evt[url],0,255) ) : null referrer = isset(evt[referrer]) ? esc_url_raw( substr(evt[referrer],0,255) ) : null user_agent = isset(evt[userAgent]) ? sanitize_text_field( substr(evt[userAgent],0,255) ) : (isset(_SERVER[HTTP_USER_AGENT]) ? sanitize_text_field(_SERVER[HTTP_USER_AGENT]) : null) app_version = isset(evt[appVersion]) ? sanitize_text_field(substr(evt[appVersion],0,100)) : null fingerprint = isset(evt[fingerprint]) ? sanitize_text_field(substr(evt[fingerprint],0,64)) : md5(message . . filename . . lineno . . colno) // 4) Rate limiting by fingerprint IP using transient ip = _SERVER[REMOTE_ADDR] ?? limit_key = err_rl_ . md5(fingerprint . . ip) count = intval(get_transient(limit_key)) if(count > 500){ // too many same errors from same IP in retention window continue } set_transient(limit_key, count 1, 6060) // 1 hour // 5) Upsert: if fingerprint exists recently, increment occurrences, else insert new row existing = wpdb->get_row(wpdb->prepare(SELECT id, occurrences FROM table WHERE fingerprint = %s ORDER BY created_at DESC LIMIT 1, fingerprint)) now = current_time(mysql) if(existing){ wpdb->update(table, array( occurrences => existing->occurrences 1, message => message, stack => stack, filename => filename, lineno => lineno, colno => colno, type => type, url => url, referrer => referrer, user_agent => user_agent, app_version => app_version, created_at => now ), array(id => existing->id), array(%d,%s,%s,%d,%d,%s,%s,%s,%s,%s,%s), array(%d)) } else { wpdb->insert(table, array( fingerprint => fingerprint, message => message, filename => filename, lineno => lineno, colno => colno, stack => stack, type => type, url => url, referrer => referrer, user_agent => user_agent, app_version => app_version, user_id => get_current_user_id() ?: null, created_at => now ), array(%s,%s,%s,%d,%d,%s,%s,%s,%s,%s,%s,%d,%s)) } inserted } return new WP_REST_Response(array(ok=>true, inserted=>inserted), 200) }
Notes about the server handler
- Always verify the REST nonce. Using permission_callback => __return_true is acceptable only if you validate nonce inside callback.
- Use small transients for rate-limiting per fingerprint and per IP to block floods. For global rate limit, keep a transient counter for the IP.
- Use dbDelta to create table at plugin activation.
- Trim and sanitize values limit lengths to prevent huge payloads.
Admin UI: view and manage errors
Provide an admin page that shows recent errors, counts, search by message or URL, view stack and optionally export CSV or delete old rows.
// data-enlighter-language=php add_action(admin_menu, function(){ add_menu_page(JS Error Logs,JS Error Logs,manage_options,my-js-errors,my_errors_admin_page,dashicons-warning, 26) }) function my_errors_admin_page(){ if(!current_user_can(manage_options)) wp_die(Permission denied) global wpdb table = wpdb->prefix . js_error_logs // Simple filters s = isset(_GET[s]) ? sanitize_text_field(_GET[s]) : paged = max(1, intval(_GET[paged] ?? 1)) per_page = 25 offset = (paged-1) per_page where = 1=1 params = array() if(s){ where = (message LIKE %s OR filename LIKE %s OR stack LIKE %s) like = % . wpdb->esc_like(s) . % params = array(like, like, like) } total = wpdb->get_var(wpdb->prepare(SELECT COUNT() FROM table WHERE where, params)) rows = wpdb->get_results(wpdb->prepare(SELECT FROM table WHERE where ORDER BY created_at DESC LIMIT %d OFFSET %d, array_merge(params, array(per_page, offset)))) echo}JS Error Logs
echo echoTotal errors: .intval(total).
echoecho
// Pagination links would be added here. echoforeach(rows as r){ echo ID Message File:Line Count Created Actions echo } echo.intval(r->id). echo.esc_html(r->message). echo
.esc_html(substr(r->stack,0,300))..esc_html(r->filename).:.intval(r->lineno). echo.intval(r->occurrences). echo.esc_html(r->created_at). echoid)).>Export id)).>Delete echo
Source maps and symbolication
Minified production code will give cryptic stack traces. Symbolicating stack traces using source maps yields original function/file/line. Do not serve source maps publicly. Options:
- Upload source maps to a private symbolication service you host (Node source-map library).
- Store source maps in a protected directory on the server accessible only to the symbolication code.
- Perform symbolication offline as a batch job with access to maps and the stored stack traces.
Example: Node symbolication service (simple)
This Node script loads a source map and applies it to a minified stack coordinate. You can adapt it to read stack traces from the DB and rewrite them.
// data-enlighter-language=javascript const fs = require(fs) const {SourceMapConsumer} = require(source-map) async function symbolicate(minifiedFile, line, column, mapPath){ const rawMap = fs.readFileSync(mapPath, utf8) const consumer = await new SourceMapConsumer(JSON.parse(rawMap)) const pos = consumer.originalPositionFor({line: parseInt(line,10), column: parseInt(column,10) }) consumer.destroy() return pos // { source, line, column, name } } // Example usage (async ()=>{ const pos = await symbolicate(app.min.js, 1, 2345, ./app.min.js.map) console.log(pos) })()
Batch processing, alerts and integrations
- Use WP Cron to aggregate errors and send hourly/daily summaries or trigger a Slack/Email alert when the count for a fingerprint exceeds a threshold. Use transients to prevent repeated alerts.
- Integrate with third-party error tracking like Sentry or Rollbar if you prefer advanced analytics and symbolication—send either raw payloads or summaries.
- Implement a small worker (WP Cron or external) to run batch symbolication with source maps and update DB rows with symbolicated stack traces.
Example: basic alerting via wp_mail
// data-enlighter-language=php function my_errors_alert_if_needed(){ global wpdb table = wpdb->prefix . js_error_logs // Example: find errors with occurrences > 100 in last hour rows = wpdb->get_results(wpdb->prepare( SELECT FROM table WHERE created_at >= %s AND occurrences >= %d, array(date(Y-m-d H:i:s, strtotime(-1 hour)), 100) )) if(!empty(rows)){ to = get_option(admin_email) subject = High JS error rate detected body = High error counts detected:nn foreach(rows as r){ body .= Message: {r->message}nCount: {r->occurrences}nURL: {r->url}n---n } wp_mail(to, subject, body) } } add_action(my_errors_hourly_event, my_errors_alert_if_needed) // schedule the cron if not scheduled if(!wp_next_scheduled(my_errors_hourly_event)){ wp_schedule_event(time(), hourly, my_errors_hourly_event) }
Performance and volume management
- Sample traffic (CONFIG.sampleRate) to reduce volume in high-traffic sites.
- Batch on the client to reduce request count.
- Server-side aggregate: store fingerprint and increment occurrences, avoiding a new row per individual event.
- Retention: delete rows older than N days or stash in cold storage. Use a scheduled cleanup job.
Cleanup cron example
// data-enlighter-language=php function my_errors_cleanup_old(){ global wpdb table = wpdb->prefix . js_error_logs days = 90 wpdb->query(wpdb->prepare(DELETE FROM table WHERE created_at < %s, date(Y-m-d H:i:s, strtotime(-{days} days)))) } add_action(my_errors_daily_cleanup, my_errors_cleanup_old) if(!wp_next_scheduled(my_errors_daily_cleanup)){ wp_schedule_event(time(), daily, my_errors_daily_cleanup) }
Privacy, GDPR and PII
- Minimize data collected. Avoid cookies, localStorage, or capturing user-entered values.
- If you collect user IDs or emails, ensure you have lawful basis and mechanisms to delete user-specific logs on request.
- Mask or remove URL query parameters that may carry PII such as emails, tokens, or auth codes.
Testing and debugging
- Deploy reporter on a staging environment first with sampleRate=1.0 and logging to confirm events reach the endpoint.
- Trigger a test error in browser console: throw new Error(Test error from client) or window.WPErrorReporter.captureMessage(test).
- Test unhandled promise rejection: Promise.reject(new Error(Test rejection)).
- Test network failure: disable network and ensure events are queued and flushed on online.
- Verify server rejects oversized payloads and invalid nonces.
Quick test examples (browser console)
// data-enlighter-language=javascript throw new Error(Test error: instrumentation check) Promise.reject(new Error(Test promise rejection)) window.WPErrorReporter.captureMessage(Manual test message, {some:meta})
CORS and headers
If your API endpoint is on the same origin as your site, CORS is not necessary. If reporting to a different domain, set the following header on the endpoint responses:
- Access-Control-Allow-Origin: https://your-site.example (avoid a wildcard unless you understand the risk)
- Access-Control-Allow-Methods: POST, OPTIONS
- Access-Control-Allow-Headers: Content-Type, X-WP-Nonce
For WP REST API you can add headers using rest_pre_serve_request or hook into rest_api_init for a specific route to send these headers for cross-origin requests.
Capture important user interactions (clicks, navigation, XHR/fetch lifecycle) as breadcrumbs to give context for errors. Keep the breadcrumb buffer small (e.g., last 100 events), and include in the error payload.
Summary checklist before production
- Decide between storing raw events vs aggregate fingerprint rows.
- Make sure nonce generation and wp_localize_script are wired up.
- Ensure server-side validation, rate-limiting and payload size checks in place.
- Set sampling rate and batching to appropriate production values.
- Plan symbolication: either private symbolication service or offline job using source maps.
- Implement retention policy and admin UI for investigation.
- Respect privacy and GDPR—don’t collect PII by default and provide opt-out.
- Test thoroughly: offline, large volumes, cross-origin and invalid payloads.
Useful links and libraries
- MDN: Window error event
- MDN: unhandledrejection
- MDN: navigator.sendBeacon
- source-map (Mozilla)
- WordPress REST API
Final notes
This tutorial gives a full, practical blueprint for catching and reporting JavaScript errors to a WordPress backend. Customize storage, retention, reporting thresholds and symbolication strategy to your needs. Carefully address security and privacy before capturing production user data.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |