
Actualización 4 de diciembre de 2025. Ya tengo preparada la versión en plugin, que enviaré al repositorio de WordPress cuando esté bien afinada.
Actualización 18 de diciembre. Se aprueba el plugin en el repositorio.
A lo tonto, hace ya más de dos años que dejé de usar Google Analytics e hice la mudanza a Matomo On-Premise (autoalojado). Y tan contento.
Si utilizas Matomo On-Premise, ya sabes que es una herramienta potente y completa, pero tener que abrir una pestaña nueva cada vez que quieres ver las estadísticas básicas es incómodo y puede que hasta demasiado exigente con tu server, aunque ha mejorado muy mucho en rendimiento desde sus inicios, cuando se llamaba Piwic, que se merendaba el servidor del tirón a base peticiones.
Existen plugins de integración de Matomo, pero suelen pecar de dos problemas: o muestran iframes que se bloquean por seguridad, o realizan peticiones PHP síncronas que ralentizan el panel de administración.
Widget tirando de la API de Matomo
Me propuse crear un widget nativo para el escritorio de WordPress usando la API de Matomo que fuera ligero, estético y, sobre todo, en tiempo real, pero que tuviera impacto cero en el servidor y que todo corriera en el navegador del cliente. Para ello conté con la documentación de Matomo y la ayuda de Gemini 3.0, que ha mejorado mucho en su recién estrenada versión en lo que a tratamiento de código se refiere y porque no sé escribir más de tres líneas sin romper algo.
Advertencia: el widget está pensado para instalaciones On-Premise (mismo servidor o servidor propio), aunque al ser JS también funcionaría si tienes Matomo en otro dominio, siempre que el CORS (permisos del navegador) lo permita.
Tras la explicación de sus funciones y de cómo añadirlo, encontrarás el código completo solo para que lo añadas al functions.php de tu plantilla, tema hijo o con tu plugin de snippets y le incluyas tu URL, el ID de tu proyecto y el Auth Token de Matomo.
Datos básico en tiempo real
Como no necesito muchas métricas en el widget, lo configuré con las tres cajas básicas que consulto de forma rutinaria.
La primera es el contador de visitantes conectados, que se actualiza automáticamente cada diez segundos.
Debajo se muestran dos cajas, con el número total de visitas del día y a su derecha el de "Acciones", también del día, que aunque Matomo lo llama "Impresiones" en realidad eso no es del todo exacto porque suma también otras acciones como abrir una imagen, hacer una búsqueda, el disparo de un evento por clic o pulsar en un enlace a una URL externa. Ambas cajas se nutren de los datos históricos de 30 días más la inyección de datos en tiempo real

La gráfica de visitas es la única caja que no se actualiza en tiempo real porque no tiene mucho sentido forzar peticiones innecesarias para un dato que ya tenemos en la caja de "Visitas (hoy)" y se añade un botón de actualizar para refrescar la gráfica en cualquier momento una sola vez. Debajo hay un enlace para descargar un CSV o una imagen de la gráfica de los últimos 30 días.
Para esta gráfica se usa la librería Chart.js, que permite dibujarla en lugar de usar una imagen estática, permitiendo así la interacción y poder ver los datos por día en los puntos

La cuarta caja es el log detallado de visitas que muestra: banderas, navegadores, sistemas operativos y un enlace al informe nativo del perfil del visitante. Todas las acciones se representan visualmente con sus correspondientes iconos para visitas, descargas (de imágenes o pdf o lo que corresponda), clic en enlaces externos, búsquedas internas en el sitio y eventos.

Los iconos tienen tooltips "ricos" a modo de cajas negras imitando el estilo nativo de Matomo con toda la info mínima relevante.


Pasos a seguir
1. Consigue tu token de Matomo
Necesitas una "llave" para que tu WordPress pueda "comunicarse" con tu Matomo:
- Entra en tu Matomo.
- Ve a Administración (Engranaje) > Personal > Seguridad.
- Crea un nuevo Auth Token. Copia esa cadena de caracteres. Anótala enseguida y guárdala porque solo te la mostrará una vez y si la pierdes tendrás que crear otra nueva.
Debes asegurarte de que esas dos casillas que aparecen NO estén marcadas, si lo están, desmárcalas para que se muestren como en la imagen:

2. Configuración
Después de añadir el código a tu functions.php, solo tienes que editar estas tres líneas en // --- CONFIGURACIÓN --- para añadir tus datos:
$matomo_url = 'https://tu-dominio.com/matomo/'; // La URL donde tienes instalado Matomo
$id_site = '1'; // El ID de tu sitio (suele ser 1)
$token_auth = 'PEGA_AQUI_TU_TOKEN_DE_32_CARACTERES';3. Código
Actualización 22/11/2025. Versión 27 con métricas de tendencias en las cajas "Visitas" y "Acciones".

Actualización 28/11/2025. Versión 45 . Se añade gráfica con promedio de rendimiento por día. Con tooltip detallado y totales con botón de refresco (últimos 30 días).

/**
* Widget Matomo V45 - Fix Refresco Gráfica Rendimiento (Inyección "Hoy")
*/
if ( ! function_exists( 'jrmora_setup_matomo_v45' ) ) {
// 1. REGISTRO
function jrmora_setup_matomo_v45() {
wp_add_dashboard_widget(
'jrmora_matomo_widget_v45',
'Estadísticas Matomo (En Vivo)',
'jrmora_render_matomo_v45'
);
}
add_action( 'wp_dashboard_setup', 'jrmora_setup_matomo_v45' );
// 2. RENDERIZADO
function jrmora_render_matomo_v45() {
// --- CONFIGURACIÓN ---
$matomo_url = 'https://tudominio/matomo/';
$id_site = '1';
$token_auth = 'PON_AQUI_TU_TOKEN_REAL'; // <--- TU TOKEN
// ---------------------
$export_csv = $matomo_url . "index.php?module=API&method=VisitsSummary.get&idSite=$id_site&period=day&date=last30&format=CSV&token_auth=$token_auth";
$export_img = $matomo_url . "index.php?module=API&method=ImageGraph.get&idSite=$id_site&apiModule=VisitsSummary&apiAction=get&token_auth=$token_auth&graphType=evolution&period=day&date=last30&width=800&height=400";
?>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
.matomo-js-wrapper { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; position: relative; min-height: 250px; }
.status-bar { text-align: right; font-size: 11px; color: #888; margin-bottom: 10px; margin-top: -30px; }
.loading-dot { display: inline-block; width: 8px; height: 8px; background-color: #ccc; border-radius: 50%; margin-right: 5px; transition: background 0.3s; }
.loading-dot.active { background-color: #2271b1; box-shadow: 0 0 5px #2271b1; }
/* HERO & KPI */
.hero-box { background: #fff; border: 1px solid #ccd0d4; border-left: 4px solid #2271b1; padding: 20px; text-align: center; margin-bottom: 15px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
.hero-num { font-size: 64px; font-weight: 700; color: #1d2327; line-height: 1; display: block; margin-bottom: 5px; }
.hero-desc { font-size: 13px; color: #646970; }
.kpi-grid { display: flex; gap: 10px; margin-bottom: 15px; }
.kpi-card { background: #fff; border: 1px solid #ccd0d4; padding: 15px 10px; flex: 1; text-align: center; }
.kpi-card h4 { margin: 0; font-size: 11px; color: #666; text-transform: uppercase; }
.kpi-val-group { display: flex; align-items: baseline; justify-content: center; gap: 8px; margin-top: 5px; }
.kpi-card .val { font-size: 24px; font-weight: 600; color: #333; line-height: 1; }
.trend-badge { font-size: 11px; font-weight: 500; padding: 2px 6px; border-radius: 10px; display: inline-flex; align-items: center; }
.trend-up { color: #00a32a; background: #eaffea; }
.trend-down { color: #d63638; background: #ffebeb; }
.trend-neutral { color: #666; background: #f0f0f1; }
.trend-icon { font-size: 10px; margin-right: 2px; }
/* GRAPHS COMMON */
.graph-container { background: #fff; border: 1px solid #ccd0d4; margin-bottom: 15px; padding: 15px; position: relative; }
.graph-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.graph-title-area { display: flex; align-items: center; gap: 8px; }
.graph-title { font-size: 14px; font-weight: 600; color: #1d2327; }
.btn-graph-refresh { cursor: pointer; color: #888; font-size: 16px; display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; transition: all 0.3s; }
.btn-graph-refresh:hover { background: #f0f0f1; color: #2271b1; }
.btn-graph-refresh.spinning { animation: spin 1s linear infinite; color: #2271b1; }
@keyframes spin { 100% { transform: rotate(360deg); } }
.chart-canvas-box { position: relative; height: 250px; width: 100%; }
.graph-actions { display: flex; gap: 15px; padding-top: 10px; border-top: 1px solid #eee; margin-top: 5px; justify-content: flex-start; }
.action-icon { text-decoration: none; color: #2e8b57; font-size: 18px; opacity: 0.7; transition: opacity 0.2s; }
.action-icon:hover { opacity: 1; color: #1e7040; }
/* LOG */
.log-container { border: 1px solid #ccd0d4; background: #fff; }
.log-header { background: #fcfcfc; padding: 8px 12px; border-bottom: 1px solid #e5e5e5; font-weight: 600; font-size: 13px; }
.summary-table { width: 100%; font-size: 12px; border-collapse: collapse; border-bottom: 1px solid #eee; }
.summary-table td { padding: 6px 12px; border-bottom: 1px solid #f6f6f6; text-align: right; color: #555; }
.summary-table td:first-child { text-align: left; font-weight: 500; }
.visitor-row { padding: 10px 12px; border-bottom: 1px solid #f0f0f1; font-size: 12px; }
.visitor-row:hover { background: #f8f9fa; }
.v-line { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; position: relative; flex-wrap: wrap; }
.v-icons img { height: 14px; vertical-align: middle; margin-right: 3px; }
.v-ref { color: #666; margin-left: 2px; }
.v-actions { margin-top: 4px; margin-left: 2px; color: #888; display: flex; flex-wrap: wrap; gap: 4px; }
.error-box { color: red; padding: 20px; text-align: center; display: none; }
/* TOOLTIPS & ICONS */
.m-tooltip-box { visibility: hidden; background-color: #000; color: #fff; text-align: left; border-radius: 4px; padding: 10px; position: absolute; z-index: 99999; bottom: 135%; opacity: 0; transition: opacity 0.2s; font-size: 11px; line-height: 1.4; box-shadow: 0 4px 12px rgba(0,0,0,0.5); pointer-events: none; white-space: normal; }
.m-tooltip-box::after { content: ""; position: absolute; top: 100%; left: 20px; margin-left: -5px; border-width: 5px; border-style: solid; border-color: #000 transparent transparent transparent; }
.flag-wrapper { position: relative; display: inline-block; cursor: help; }
.flag-wrapper .m-tooltip-box { width: 280px; left: 50%; margin-left: -20px; }
.flag-wrapper:hover .m-tooltip-box { visibility: visible; opacity: 1; }
.ft-row { display: block; margin-bottom: 2px; }
.ft-label { color: #aaa; font-weight: 400; margin-right: 4px; }
.ft-val { color: #fff; font-weight: 600; }
.action-wrapper { position: relative; display: inline-block; margin-right: 5px; text-decoration: none; }
.action-wrapper .m-tooltip-box { width: 320px; left: -10px; bottom: 140%; }
.action-wrapper:hover .m-tooltip-box { visibility: visible; opacity: 1; }
.at-url { color: #9ec2e6; font-weight: 600; word-break: break-all; margin-bottom: 4px; font-family: monospace; font-size: 11px; }
.at-title { color: #fff; font-weight: 600; margin-bottom: 4px; font-size: 12px; display: block; }
.at-meta { color: #ccc; display: block; margin-bottom: 2px; }
.profile-link { color: #b0b0b0; text-decoration: none; cursor: pointer; margin-left: 2px; }
.profile-link:hover { color: #2271b1; }
.action-wrapper .dashicons { font-size: 18px; color: #999; transition: color 0.2s; }
.action-wrapper:hover .dashicons { color: #2271b1; cursor: pointer; }
.load-time-badge { font-size: 12px; color: #1d2327; background: #fff3c9; padding: 2px 5px; border-radius: 4px; font-weight: 600; margin-left: 10px; line-height: 1; }
</style>
<div class="status-bar"><span class="loading-dot" id="m-dot"></span> <span id="m-status">Conectando...</span></div>
<div class="matomo-js-wrapper">
<div id="m-error" class="error-box"></div>
<div class="hero-box">
<span class="hero-num" id="val-live">--</span>
<div class="hero-desc"><span id="val-live-vis">--</span> visitas y <span id="val-live-act">--</span> acciones (últimos 3 min)</div>
</div>
<div class="kpi-grid">
<div class="kpi-card"><h4>Visitas (Hoy)</h4><div class="kpi-val-group"><span class="val" id="val-uniq">--</span><span class="trend-badge" id="trend-uniq"></span></div></div>
<div class="kpi-card"><h4>Acciones (Hoy)</h4><div class="kpi-val-group"><span class="val" id="val-page">--</span><span class="trend-badge" id="trend-page"></span></div></div>
</div>
<div class="graph-container">
<div class="graph-header">
<div class="graph-title-area">
<span class="graph-title">Gráfica de visitas</span>
<span id="btn-refresh-visits" class="btn-graph-refresh dashicons dashicons-update" title="Actualizar"></span>
</div>
</div>
<div class="chart-canvas-box"><canvas id="matomoChart"></canvas></div>
<div class="graph-actions">
<span style="font-size:11px; color:#666; padding-top:2px;">Últimos 30 días</span>
<a href="<?php echo esc_url($export_csv); ?>" target="_blank" class="action-icon dashicons dashicons-media-spreadsheet" title="CSV"></a>
<a href="<?php echo esc_url($export_img); ?>" target="_blank" class="action-icon dashicons dashicons-format-image" title="Imagen"></a>
</div>
</div>
<div class="graph-container">
<div class="graph-header">
<div class="graph-title-area">
<span class="graph-title">Evolución del Rendimiento (Segundos)</span>
<span id="btn-refresh-perf" class="btn-graph-refresh dashicons dashicons-update" title="Actualizar"></span>
</div>
</div>
<div class="chart-canvas-box"><canvas id="perfChart"></canvas></div>
</div>
<div class="log-container">
<div class="log-header">Log de Visitas</div>
<table class="summary-table">
<tr><td>Últimas 24 horas</td><td id="val-24-v">--</td><td id="val-24-a">--</td></tr>
<tr><td>Últimos 30 minutos</td><td id="val-30-v">--</td><td id="val-30-a">--</td></tr>
</table>
<div class="log-rows" id="log-content">
<div style="padding:20px; text-align:center; color:#ccc;">Cargando...</div>
</div>
</div>
</div>
<script>
(function() {
const API_URL = "<?php echo $matomo_url; ?>";
const SITE_ID = "<?php echo $id_site; ?>";
const TOKEN = "<?php echo $token_auth; ?>";
const getEl = (id) => document.getElementById(id);
const buildUrl = (m, e = '') => `${API_URL}index.php?module=API&method=${m}&idSite=${SITE_ID}&format=JSON&token_auth=${TOKEN}${e}&random=${Date.now()}`;
const valOrUnk = (val) => (val && val !== '' && val !== '-' && val !== null) ? val : 'Desconocido';
function getMinutesSinceMidnight() {
const now = new Date();
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
return Math.max(1, Math.floor((now - midnight) / 60000));
}
function getLocalDateStr() {
const d = new Date();
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function getYesterdayDateStr() {
const d = new Date();
d.setDate(d.getDate() - 1);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function renderTrend(elementId, todayVal, yesterdayVal) {
const el = getEl(elementId);
if (yesterdayVal === 0) {
el.innerHTML = '<span class="trend-icon">−</span> 0%';
el.className = 'trend-badge trend-neutral';
el.title = `Ayer: 0`; return;
}
const diff = todayVal - yesterdayVal;
const percent = Math.round((Math.abs(diff) / yesterdayVal) * 100);
if (diff > 0) {
el.innerHTML = `<span class="trend-icon">▲</span> ${percent}%`;
el.className = 'trend-badge trend-up';
} else if (diff < 0) {
el.innerHTML = `<span class="trend-icon">▼</span> ${percent}%`;
el.className = 'trend-badge trend-down';
} else {
el.innerHTML = `<span class="trend-icon">=</span> 0%`;
el.className = 'trend-badge trend-neutral';
}
el.title = `Ayer: ${yesterdayVal}`;
}
// --- VARIABLES GLOBALES ---
let chartVisits = null;
let chartPerf = null;
let globalHistoryData = null;
async function fetchAllGraphs() {
const btnV = getEl('btn-refresh-visits');
const btnP = getEl('btn-refresh-perf');
if(btnV) btnV.classList.add('spinning');
if(btnP) btnP.classList.add('spinning');
try {
const todayStr = getLocalDateStr();
// 1. VISITAS (Histórico + Live)
const resVisits = await fetch(buildUrl('VisitsSummary.get', '&period=day&date=last30')).then(r => r.json());
globalHistoryData = resVisits;
const minsToday = getMinutesSinceMidnight();
const resLiveToday = await fetch(buildUrl('Live.getCounters', '&lastMinutes=' + minsToday)).then(r => r.json());
const liveVisitsVal = (resLiveToday && resLiveToday[0] && resLiveToday[0].visits) ? resLiveToday[0].visits : 0;
// 2. RENDIMIENTO (Histórico + "Today")
// Pedimos histórico
const resPerfHistory = await fetch(buildUrl('PagePerformance.get', '&period=day&date=last30')).then(r => r.json());
// Pedimos específicamente HOY para tener el dato fresco (aunque sea un cálculo lento, Matomo lo sirve)
const resPerfToday = await fetch(buildUrl('PagePerformance.get', '&period=day&date=today')).then(r => r.json());
// --- RENDER VISITAS ---
const labels = [];
const dataPoints = [];
for (const [dateStr, metrics] of Object.entries(resVisits)) {
const d = new Date(dateStr);
labels.push(d.toLocaleDateString('es-ES', {weekday:'short', day:'numeric', month:'short'}));
if (dateStr === todayStr) dataPoints.push(liveVisitsVal);
else dataPoints.push((metrics && metrics.nb_visits) ? metrics.nb_visits : 0);
}
if (chartVisits) chartVisits.destroy();
const ctxV = document.getElementById('matomoChart');
if(ctxV) {
chartVisits = new Chart(ctxV, {
type: 'line',
data: { labels: labels, datasets: [{ label: 'Visitas', data: dataPoints, borderColor: '#2271b1', backgroundColor: '#2271b1', borderWidth: 2, pointRadius: 3, pointHoverRadius: 5, tension: 0, fill: false }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(0,0,0,0.9)', displayColors: false, callbacks: { label: (c) => c.parsed.y + ' Visitas' } } }, scales: { y: { beginAtZero: true }, x: { grid: { display: false } } } }
});
}
// --- RENDER RENDIMIENTO ---
// Arrays
const dsNetwork = [], dsServer = [], dsTransfer = [], dsDomProc = [], dsDomComp = [], dsOnLoad = [];
// Fusionamos Histórico + Hoy fresco
// Primero, iteramos el histórico. Si encontramos la fecha de hoy, la sustituimos.
for (const [dateStr, metrics] of Object.entries(resPerfHistory)) {
let finalMetrics = metrics;
// Si es HOY, usamos el dato fresco si existe (viene como array en resPerfToday)
if (dateStr === todayStr && resPerfToday && resPerfToday[0]) {
finalMetrics = resPerfToday[0];
}
dsNetwork.push(finalMetrics.avg_time_network || 0);
dsServer.push(finalMetrics.avg_time_server || 0);
dsTransfer.push(finalMetrics.avg_time_transfer || 0);
dsDomProc.push(finalMetrics.avg_time_dom_processing || 0);
dsDomComp.push(finalMetrics.avg_time_dom_completion || 0);
dsOnLoad.push(finalMetrics.avg_time_on_load || 0);
}
if (chartPerf) chartPerf.destroy();
const ctxP = document.getElementById('perfChart');
if(ctxP) {
chartPerf = new Chart(ctxP, {
type: 'bar',
data: {
labels: labels,
datasets: [
{ label: 'Tiempo de red medio', data: dsNetwork, backgroundColor: '#0077b6' },
{ label: 'Avg. server time', data: dsServer, backgroundColor: '#e08e0b' },
{ label: 'Avg. transfer time', data: dsTransfer, backgroundColor: '#d63638' },
{ label: 'Avg. DOM processing', data: dsDomProc, backgroundColor: '#6f42c1' },
{ label: 'Avg. DOM completion', data: dsDomComp, backgroundColor: '#28a745' },
{ label: 'Avg. on load time', data: dsOnLoad, backgroundColor: '#17a2b8' }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: { x: { stacked: true, grid: { display: false } }, y: { stacked: true, beginAtZero: true } },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(0,0,0,0.9)',
titleFont: { size: 11 },
bodyFont: { size: 10 },
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
let val = context.parsed.y !== null ? parseFloat(context.parsed.y).toFixed(2) + 's' : '';
return val + ' ' + label;
},
footer: function(tooltipItems) {
let sum = 0;
tooltipItems.forEach(function(tooltipItem) {
sum += tooltipItem.parsed.y;
});
return sum.toFixed(2) + 's Total';
}
}
}
}
}
});
}
} catch(e) { console.log("Error Graph: " + e); } finally {
if(btnV) btnV.classList.remove('spinning');
if(btnP) btnP.classList.remove('spinning');
}
}
// --- REAL TIME (Igual) ---
async function fetchRealTimeData() {
const dot = getEl('m-dot');
const status = getEl('m-status');
try {
dot.classList.add('active');
status.innerText = 'Actualizando...';
const minsToday = getMinutesSinceMidnight();
const [res3m, res30m, resTodayLive, res24h, resLog] = await Promise.all([
fetch(buildUrl('Live.getCounters', '&lastMinutes=3')).then(r => r.json()),
fetch(buildUrl('Live.getCounters', '&lastMinutes=30')).then(r => r.json()),
fetch(buildUrl('Live.getCounters', '&lastMinutes=' + minsToday)).then(r => r.json()),
fetch(buildUrl('Live.getCounters', '&lastMinutes=1440')).then(r => r.json()),
fetch(buildUrl('Live.getLastVisitsDetails', '&filter_limit=10')).then(r => r.json())
]);
if(res3m.result === 'error') throw new Error(res3m.message);
const todayVisits = resTodayLive[0]?.visits || 0;
const todayActions = resTodayLive[0]?.actions || 0;
const yesterdayStr = getYesterdayDateStr();
let yVisits = 0; let yActions = 0;
if (globalHistoryData && globalHistoryData[yesterdayStr]) {
yVisits = globalHistoryData[yesterdayStr].nb_visits || 0;
yActions = globalHistoryData[yesterdayStr].nb_actions || 0;
}
getEl('val-live').innerText = res3m[0]?.visits || 0;
getEl('val-live-vis').innerText = res3m[0]?.visits || 0;
getEl('val-live-act').innerText = res3m[0]?.actions || 0;
getEl('val-uniq').innerText = todayVisits;
getEl('val-page').innerText = todayActions;
renderTrend('trend-uniq', todayVisits, yVisits);
renderTrend('trend-page', todayActions, yActions);
getEl('val-24-v').innerText = (res24h[0]?.visits || 0) + ' Visitas';
getEl('val-24-a').innerText = (res24h[0]?.actions || 0) + ' Acciones';
getEl('val-30-v').innerText = (res30m[0]?.visits || 0) + ' Visitas';
getEl('val-30-a').innerText = (res30m[0]?.actions || 0) + ' Acciones';
let html = '';
if(resLog && resLog.length > 0) {
resLog.forEach(v => {
const fixUrl = (u) => u.startsWith('http') ? u : API_URL + u;
const tCountry = valOrUnk(v.countryPretty || v.country);
const tRegion = valOrUnk(v.region);
const tCity = valOrUnk(v.city);
const tIP = valOrUnk(v.visitIp);
const tID = valOrUnk(v.visitorId);
let rawLang = v.browserLanguage || v.languageCode;
let tLang = (rawLang && rawLang !== '-') ? 'Código de idioma ' + rawLang : 'Desconocido';
const rawDuration = v.visitDuration || 0;
let durationFormatted = '';
if (rawDuration > 0) {
const min = Math.floor(rawDuration / 60);
const sec = rawDuration % 60;
durationFormatted = `${min}m ${sec}s`;
}
const loadBadge = durationFormatted ? `<span class="load-time-badge">${durationFormatted}</span>` : '';
const flagTooltipHtml = `<div class="m-tooltip-box"><span class="ft-row"><span class="ft-label">País:</span> <span class="ft-val">${tCountry}</span></span><span class="ft-row"><span class="ft-label">Región:</span> <span class="ft-val">${tRegion}</span></span><span class="ft-row"><span class="ft-label">Ciudad:</span> <span class="ft-val">${tCity}</span></span><span class="ft-row"><span class="ft-label">Idioma:</span> <span class="ft-val">${tLang}</span></span><span class="ft-row"><span class="ft-label">IP:</span> <span class="ft-val">${tIP}</span></span><span class="ft-row"><span class="ft-label">ID:</span> <span class="ft-val">${tID}</span></span></div>`;
const flagImg = v.countryFlag ? `<img src="${fixUrl(v.countryFlag)}">` : '';
const flag = flagImg ? `<div class="flag-wrapper">${flagImg}${flagTooltipHtml}</div>` : '';
const date = new Date(v.serverTimestamp * 1000);
const timeStr = date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', second:'2-digit'});
const dateStr = date.toLocaleDateString([], {weekday: 'short', day: 'numeric'});
const browser = v.browserIcon ? `<img src="${fixUrl(v.browserIcon)}" title="${v.browserName}">` : '';
const os = v.operatingSystemIcon ? `<img src="${fixUrl(v.operatingSystemIcon)}" title="${v.operatingSystemName}">` : '';
const profileUrl = `${API_URL}index.php?module=CoreHome&action=index&idSite=${SITE_ID}&period=day&date=today#?idSite=${SITE_ID}&period=day&date=today&category=Dashboard_Dashboard&subcategory=1&popover=visitorProfile%243A${v.visitorId}`;
let refHtml = 'Entrada directa';
if(v.referrerTypeName === 'Motores de búsqueda') {
refHtml = `<span style="color:#d63638;font-weight:bold">G</span> ${v.referrerName || ''}`;
if(v.referrerKeyword) refHtml += ` <i>"${v.referrerKeyword}"</i>`;
} else if(v.referrerTypeName === 'Sitios web') {
refHtml = `Ref: <a href="${v.referrerUrl}" target="_blank" style="color:#666">${v.referrerName}</a>`;
}
let actHtml = '';
if(v.actionDetails) {
v.actionDetails.forEach(a => {
if(a.type) {
let dashiconClass = 'dashicons-portfolio';
let isEvent = false;
let isSearch = false;
switch(a.type) {
case 'download': dashiconClass = 'dashicons-download'; break;
case 'outlink': dashiconClass = 'dashicons-external'; break;
case 'event': dashiconClass = 'dashicons-megaphone'; isEvent = true; break;
case 'search':
case 'siteSearch': dashiconClass = 'dashicons-search'; isSearch = true; break;
}
const pUrl = a.url || '#';
let actionDateStr = '';
if(a.timestamp) {
const ad = new Date(a.timestamp * 1000);
actionDateStr = ad.toLocaleDateString('es-ES', {day: 'numeric', month: 'short', year: 'numeric'}) + ' ' + ad.toLocaleTimeString();
}
let timeSpent = '0s';
if(a.timeSpent) {
const m = Math.floor(a.timeSpent / 60);
const s = a.timeSpent % 60;
timeSpent = (m > 0 ? m + 'm ' : '') + s + 's';
}
let tooltipInner = '';
if (isEvent) {
let evText = `Evento ${a.eventCategory || ''} - ${a.eventAction || ''}`;
if(a.eventName) evText += ` - ${a.eventName}`;
if(a.pageTitle) evText += ` - ${a.pageTitle}`;
const evValue = (a.eventValue !== undefined) ? a.eventValue : '0';
evText += ` - ${evValue}`;
tooltipInner = `<div class="at-title">${evText}</div><div class="at-meta">${actionDateStr}</div>`;
} else if (isSearch) {
const keyword = a.siteSearchKeyword || a.actionName || 'Sin palabras clave';
tooltipInner = `<div class="at-title">Búsqueda interna: ${keyword}</div><div class="at-meta">${actionDateStr}</div>`;
} else {
const pTitle = (a.pageTitle || 'Título desconocido').replace(/"/g, '"');
tooltipInner = `<div class="at-url">${pUrl}</div><div class="at-title">${pTitle}</div><div class="at-meta">${actionDateStr}</div><div class="at-meta">Tiempo en la página: ${timeSpent}</div>`;
}
actHtml += `<a href="${pUrl}" target="_blank" class="action-wrapper"><span class="dashicons ${dashiconClass}"></span><div class="m-tooltip-box">${tooltipInner}</div></a>`;
}
});
}
html += `
<div class="visitor-row">
<div class="v-line">
<b>${timeStr}</b> ${loadBadge}
<span style="font-size:11px;color:#888">(${dateStr})</span>
<span class="v-icons" style="display:flex;align-items:center;gap:3px;">${flag}${browser}${os}</span>
<a href="${profileUrl}" target="_blank" class="profile-link dashicons dashicons-businessperson" title="Ver Perfil"></a>
</div>
<div class="v-line v-ref">${refHtml}</div>
<div class="v-line v-actions">${actHtml}</div>
</div>`;
});
} else {
html = '<div style="padding:20px;text-align:center;color:#ccc;">Cargando...</div>';
}
getEl('log-content').innerHTML = html;
status.innerText = 'En vivo (Actualizado: ' + new Date().toLocaleTimeString() + ')';
} catch (err) {
getEl('m-error').style.display = 'block';
getEl('m-error').innerText = 'Error: ' + err.message;
status.innerText = 'Error';
} finally {
dot.classList.remove('active');
}
}
// Init
fetchAllGraphs();
fetchRealTimeData();
setInterval(fetchRealTimeData, 10000);
// Listeners para AMBOS botones
const btnV = getEl('btn-refresh-visits');
const btnP = getEl('btn-refresh-perf');
if(btnV) btnV.addEventListener('click', fetchAllGraphs);
if(btnP) btnP.addEventListener('click', fetchAllGraphs);
})();
</script>
<?php
}
}4. Resumen de características
Arquitectura 100% JavaScript (Client-Side): A diferencia de los widgets tradicionales que usan PHP (cURL) para pedir los datos, este widget usa JavaScript (fetch) directamente desde tu navegador.
- ¿La ventaja? Tu servidor de WordPress no trabaja. No hay bloqueos de seguridad por llamarse a sí mismo (Loopback) ni tiempos de carga lentos en el admin. La carga es asíncrona.
Tiempo real "puro" (Auto-Refresh): El widget refresca automáticamente los contadores y el log de visitas cada 10 segundos. Puedes dejar la pestaña abierta y ver cómo entran las visitas y suben los contadores sin tocar nada.
Gráfica híbrida inteligente: Aquí está el truco técnico más interesante. Matomo suele "archivar" los datos cada hora, por lo que la gráfica del día de hoy suele estar desactualizada o a cero. Este widget combina dos fuentes de datos:
Resultado: Una gráfica de 30 días donde el punto de "Hoy" es real y preciso. Además, tiene un botón de refrescar manual independiente para no machacar la API innecesariamente.
Datos de los últimos 29 días: Los toma del archivo histórico (carga rápido).
El día de hoy: Lo calcula en tiempo real pidiendo a la API los minutos transcurridos desde la medianoche hasta el segundo actual.
El Plugin, en desarrollo
Esta función, siguiendo su evolución lógica, sigue su camino para convertirse en un plugin que se llamará "Real-Time Widget for Matomo" y se envió al repositorio el 10 de diciembre de 2025, justo el día que terminó el proceso de revisión y aprobación de este otro plugin.
A continuación se detallan las novedades del futuro plugin con respecto a la función.
Notificaciones en tiempo real en el navegador
La principal novedad de la última versión estable (1.2.0) es el sistema de las notificaciones emergentes en el navegador en tiempo real configurables desde la administración del plugin.

A diferencia de otros plugins que dependen de Cron Jobs del servidor o servicios externos de Web Push, el sistema de alertas de Matomo Real-Time Widget se ejecuta 100% en el lado del cliente (Client-Side). Esto garantiza un impacto cero en el rendimiento del servidor de WordPress, ya que es el navegador del usuario quien realiza el trabajo sucio.
1. Arquitectura "Polling" inteligente
El widget establece un ciclo de actualización (setInterval) cada 10 segundos. En cada ciclo, consulta directamente a la API de Matomo para obtener el estado actual del tráfico.
- Detección de Cambios: El script compara el
visitorIdde la última visita y el conteo total de visitas activas (last3Minutes) con los datos almacenados en memoria de la iteración anterior. - API Nativa: Si se detecta una novedad relevante, se dispara una notificación de escritorio utilizando la API nativa del navegador (
new Notification()), lo que permite que las alertas aparezcan fuera de la ventana del navegador, integrándose con el sistema operativo (Windows/macOS/Linux).
2. Lógica de notificación híbrida
El plugin implementa una lógica condicional avanzada para evitar la "fatiga de alertas" sin perder información crítica. Dependiendo de la configuración del usuario, el sistema se comporta de dos formas:
- Modo "Cada Visita": Notifica cada entrada individual. El sistema extrae metadatos de la API (País, acción realizada, título de página) para mostrar un resumen contextual: "Nueva visita desde España viendo [Título del Post]".

Ejemplo de notificaciones del modo "Cada visita".
- Modo "Por Umbral" (Lógica Híbrida): Esta es la característica más destacada de la versión 1.2.0. Combina monitoreo pasivo y alarma activa:
- Zona Normal (Bajo el umbral): Si configuras el umbral en 10 y tienes 5 visitas, el sistema funciona en modo informativo, avisando de cada visita individualmente.
- Zona de Alarma (Sobre el umbral): En el momento en que el tráfico iguala o supera el umbral (ej: 10), la lógica cambia. Deja de notificar visitas individuales y se convierte en una Alerta de Alto Tráfico. Solo vuelve a sonar si el número total de visitas aumenta (Rising Edge), evitando el spam si el tráfico se mantiene estable en cifras altas.

Ejemplo de notificación del modo "Por umbral" de tráfico alto repentino.
El modo umbral pretende ser una alerta inmediata para picos de tráfico por si necesitas crear una página estática para mitigar el impacto en el servidor o simplemente saber en cualquier momento el origen de un incremento repentino de visitas tras configurar el umbral mínimo desde el que empezar a recibir avisos. Basta dejar la página del widget abierta en segundo plano con las notificaciones activadas en su configuración para empezar a recibirlas.
Sobre la reproducción de audio en las notificaciones, estas son las especificaciones:
3. Sistema de audio robusto (DOM-Based)
Para cumplir con las estrictas Políticas de Autoplay de los navegadores modernos (Chrome, Edge, Safari), que bloquean la reproducción de sonido no iniciada por el usuario, se implementa una solución de doble capa:
- Audio en el DOM: En lugar de instanciar objetos
new Audio()volátiles en JavaScript (que a menudo son recolectados por el Garbage Collector o bloqueados), el plugin inyecta un elemento HTML<audio>oculto pero persistente en la página. - Desbloqueo por Interacción: Un listener global detecta la primera interacción del usuario (clic o tecla) para "bendecir" el sistema de audio, permitiendo que las alertas subsiguientes suenen automáticamente sin intervención.
4. Gestión de errores y móviles
Dado que los navegadores móviles (Android Chrome / iOS Safari) congelan los procesos en segundo plano para ahorrar batería y no soportan el constructor clásico de notificaciones sin Service Workers, el plugin incluye un sistema de manejo de excepciones (try/catch). Esto permite que el widget siga funcionando y actualizando datos visuales en el móvil sin bloquearse, degradando de forma efectiva la funcionalidad: si no puede lanzar la alerta nativa, simplemente la omite y continúa registrando el tráfico en la pantalla.
Opción de desinstalación limpia
Se añade la instalación limpia en los ajustes del plugin para eliminar el token y la configuración de la base de datos al borrar el plugin a través del archivo uninstall.php para gestionar la limpieza de esos datos.











Buenos dias.
Creo que has exagerado un poco (con lo de las actualizaciones, el compromiso con el soporte, etc.)... ¿Seguro que estamos hablando del mismo repositorio de WordPress? jajaja. El repositorio no es una "tienda" de profesionales, es justo lo contrario, un sitio donde compartir codigo gratuito por parte de la comunidad de aficionados. De hecho el foro de soporte esta pensado para que toda la comunidad ayude en las dudas, no solo el desarrollador.
Creo que has subestimado un poco… (lo de usar IA, los programadores que hacen plugins, etc.). Te sigo desde hace un tiempo, he leido todo lo que has publicado sobre wordpress, y creo que tienes bastante nivel tecnico, como para poder meterte en "ese jardín" (independientemente de si lo haces con ayuda de otros programadores humanos, o artificiales)
Además has sobredimensionado un poco la cuestión de “publicar todos tus plugins”… en realidad puedes subir únicamente este plugin y ver que tal.
Piénsalo: ya estás compartiendo un plugin con toda la comunidad, lo has actualizado varias veces y has ido editando el post para añadir nuevas versiones… ¿de verdad crees que esta forma es mejor que publicarlo en el repositorio? ¿Cómo esperas que podamos seguir tus futuras actualizaciones?
Te doy otro enfoque: Si todo el mundo hiciera lo mismo que tu, nadie subiría sus plugins al repositorio (salvo quienes tienen versión de pago). ¿te imaginas tener que ir por las webs de la gente buscando plugins y comprobando si lo han actualizado o no?
Otro enfoque mas a ve si por ahí.... ¿Cuántos plugins gratuitos te habrás descargado del repositorio? Plantéate que quizá a algunos de esos autores que compartieron sus creaciones contigo, ahora puedas devolverles el favor con tu plugin!
En definitiva, su lugar natural es, sin ninguna duda, el repositorio oficial de WordPress, y por eso queria animarte a que lo hicieras. Pero eso si, respecto tu decisión, y muchisimas gracias por tus publicaciones (y tus codigos) sobre Wordpress!!!
Eo, hola de nuevo. Gracias a ti por dejar tu opinión por aquí. Hasta se me hace raro recibir el aviso de un comentario :P
Es muy posible que tenga una idea equivocada del repositorio según mi experiencia por allí, hasta han baneado un par de veces por poner algún enlace a un jodido staging y alguno hasta me ha contestado de una forma regulera, como poco.
También me leí en su día toda la cosa del proceso de subir plugins al repo y sus normas varias, entiendo que lógicas, no es una crítica, y se antojó que quizá iba a perder el tiempo.
Y sí, tienes toda la razón, cuando empecé a trastear lo primero que pensé es en subir algo que fuera funcional como mínimo por aquello de compartir o "devolver" un poco de lo recibido durante años, pero también ha influido muchas veces saber (o descubrir) que hay otros plugins que hacen lo mismo y mejor. Y que esto, que parece algo simple y "suave": https://es.wordpress.org/plugins/developers/ que contrasta con cosas espesas como esta otra: https://wpdirecto.com/directrices-del-directorio-de-plugins-de-wordpress-una-guia-actualizada/
Es posible que la evolución natural sea que deje de darle vueltas y suba la primera cosa a prueba error, pero al final es simplemente una cuestión de tiempo, nunca encuentro el que necesito para tanta cosa que empiezo (y no siempre termino).
Nota: Cuando digo "todos" mis plugins hablamos de dos y medio como mucho XDD
Para cuándo el repositorio de wordpress.org? No es broma.
Buenas noches, Jamiro.
Pues me lo he planteado más de una vez.
No lo descarto de todo porque con el tiempo me he hecho algunos plugins y widgets para mis movidas de optimización, seo y tal e incluso alguno está refactorizado varias veces y pasado por Plugin Check (PCP) con éxito, pero no soy programador, solo un aficionado del trasteo, y algunos de estos plugins están hechos en gran parte con apoyo de Gémini, cosas que nunca escondo, o son modificaciones de otros (tengo uno para directorio que es completamente un rediseño de otro existente) y no me parece muy honesto.
Además, si los aceptan en el repositorio hay que comprometerse a actualizarlos, mantenerlos y estar atento a peticiones, intentar solucionar errores en distintos entornos, actualizaciones periódicas para compatibilidad, sugerencias, etc. y no sé si me daría la vida si subiera alguno.