How to instrument JS errors and report them to an endpoint in WP in WordPress

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

  1. Collect: message, stack (if available), filename, lineno, colno, user agent, current URL, referrer, timestamp, app version, user ID (if available and allowed).
  2. Fingerprint each error to deduplicate: fingerprint = hash(message filename lineno colno stack).
  3. Use sendBeacon if available for unload reliability fallback to fetch/XHR.
  4. Queue events in localStorage or IndexedDB when offline and retry on online events or with exponential backoff.
  5. 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

echo

Total errors: .intval(total).

echo echo foreach(rows as r){ echo echo echo echo echo echo echo echo } echo
IDMessageFile:LineCountCreatedActions
.intval(r->id)..esc_html(r->message).
.esc_html(substr(r->stack,0,300)).
.esc_html(r->filename).:.intval(r->lineno)..intval(r->occurrences)..esc_html(r->created_at).id)).>Export id)).>Delete
// Pagination links would be added here. 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

  1. Deploy reporter on a staging environment first with sampleRate=1.0 and logging to confirm events reach the endpoint.
  2. Trigger a test error in browser console: throw new Error(Test error from client) or window.WPErrorReporter.captureMessage(test).
  3. Test unhandled promise rejection: Promise.reject(new Error(Test rejection)).
  4. Test network failure: disable network and ensure events are queued and flushed on online.
  5. 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.

Advanced: client-side breadcrumb capture

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

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 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *