Toda la documentación

Empezar

  1. 01Primeros pasos
  2. 02Instalación
  3. 03Seguimiento

Funciones

  1. 04El panel
  2. 05Objetivos
  3. 06Enlaces cortos
  4. 07Fuentes y agentes de IA
  5. 08Informes por email
  6. 09Comprobación programada
  7. 10Pregunta a tu IA

Referencia

  1. 11Configuración
  2. 12API HTTP

Plataformas

  1. 13Servidor independiente
  2. 14PHP
  3. 15WordPress
  4. 16Drupal
  5. 17Craft CMS
  6. 18Python
  7. 19Rails
  8. 20Ruby
  9. 21Go
  10. 22Java
  11. 23.NET
  12. 24Elixir
  13. 25Rust

Confianza

  1. 26Privacidad

Plataformas

PHP

Runlight funciona con PHP 8.2 o posterior, dentro de una aplicación Laravel, Symfony o PHP sin framework, o en un dominio propio en un hosting PHP corriente.

El paquete de PHP es Runlight escrito de nuevo en PHP. Sirve el mismo panel y la misma API, y da a cada petición la misma respuesta que da la biblioteca de TypeScript. Sus cifras van en las mismas tablas, así que cualquiera de los dos puede leer una base de datos que haya escrito el otro. Necesita PHP 8.2 o posterior con PDO y el driver de tu base de datos.

Instalación

Añade el paquete con Composer.

bash

composer require runlight/runlight

Runlight usa las extensiones curl, intl, sodium y APCu cuando están cargadas, y recurre a PHP puro para las que falten.

Crea la instancia

Crea un archivo que devuelva la instancia, y cárgalo donde necesites Runlight.

runlight.php

<?php

use Runlight\Runlight;
use Runlight\Store\Stores;

return new Runlight([
    'store' => Stores::sqlite(__DIR__ . '/data/runlight.db'),
    'site' => ['name' => 'example.com', 'hostnames' => ['example.com'], 'timezone' => 'Europe/London'],
]);

Las opciones tienen los mismos nombres que en Configuración, así que sites, trustProxy, rateLimit, mail y managedSites funcionan como allí. Las tablas se crean en la primera petición.

Almacenes

Las tablas de Runlight empiezan por rl_. Los almacenes de SQLite, MySQL y Postgres aceptan una opción prefix para usar otro en su lugar, como Stores::sqlite(['path' => $path, 'prefix' => 'wp_rl_']) o Stores::mysql($url, ['prefix' => 'wp_rl_']), así que dos aplicaciones que comparten una base de datos mantienen cada una sus propias tablas, y Stores::fromDb($db, 'wp_rl_') hace lo mismo sobre un Db propio. Un prefijo tiene como máximo 32 letras, dígitos y guiones bajos, y no empieza por un dígito. $store->tables() lista las tablas del almacén por nombre, para una aplicación que quiera quitar Runlight.

SQLite

php

Stores::sqlite(__DIR__ . '/data/runlight.db');

SQLite necesita la extensión pdo_sqlite, que tienen la mayoría de instalaciones de PHP. Guarda el archivo fuera de la raíz web, en una carpeta en la que el servidor web pueda escribir, ya que SQLite escribe un journal a su lado. La carpeta se crea si no existe.

MySQL y MariaDB

php

Stores::mysql('mysql://runlight:password@127.0.0.1:3306/runlight');

Este almacén necesita pdo_mysql y funciona con MySQL 8.0.20 o posterior y MariaDB 11.4 o posterior. También vale una URL mariadb://. Un segundo argumento como ['statementTimeout' => 30000] detiene cualquier consulta pasados esos milisegundos, y el valor por defecto es 120000, como en Configuración.

Postgres

php

Stores::postgres('postgres://runlight:password@127.0.0.1:5432/runlight');

Este almacén necesita pdo_pgsql. También acepta statementTimeout en su segundo argumento, y ['schema' => 'analytics'] guarda las tablas en un schema propio. Stores::url() elige el almacén a partir de un DATABASE_URL que empiece por postgres://, mysql://, mariadb:// o sqlite:.

PHP sin framework

En una aplicación con un único index.php por el que pasan todas las peticiones, envía al principio las rutas bajo /runlight y /go a Runlight.

public/index.php

<?php

require __DIR__ . '/../vendor/autoload.php';

use Runlight\Http\Request;
use Runlight\Server\FrontController;

$rl = require __DIR__ . '/../runlight.php';

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($path === '/runlight' || str_starts_with($path, '/runlight/') || str_starts_with($path, '/go/')) {
    FrontController::serve($rl, $rl->routes());
    return;
}

// AI agents reading your pages; optional.
$rl->observe(Request::fromGlobals());

// The rest of your app.

FrontController::serve() lee la petición de las variables globales de PHP y envía la respuesta. observe() registra los accesos de los agentes de IA y vuelve al instante para todos los demás. Añade el script a cada página, justo antes de </head>.

html

<script defer src="/runlight/s.js"></script>

Después define RUNLIGHT_TOKEN como una cadena larga y aleatoria y abre una vez /runlight/?token= seguido de esa cadena para iniciar sesión, como describe Primeros pasos. Con routes(['accounts' => true]) y un RUNLIGHT_SECRET, cada persona inicia sesión con su propio correo y contraseña, como en Cuentas.

Un dominio de enlaces responde a los enlaces cortos en la raíz de un nombre propio, así que sus peticiones nunca coinciden con las rutas de arriba. Para servir uno desde la aplicación, pregunta primero a linkDomainResponse(). Solo responde en tus dominios de enlaces y devuelve null para cualquier otro host, así que el resto de la aplicación sigue como antes.

php

$request = Request::fromGlobals();
$linked = $rl->linkDomainResponse($request, ['ip' => $request->remoteAddress]);
if ($linked !== null) {
    $linked->emit();
    return;
}

Laravel y Symfony lo hacen con la clase ShortLinks de Runlight, como muestran sus secciones.

Request::fromServer($server) lee una petición de un array con la forma de $_SERVER, como la copia sin barras invertidas que guarda un CMS. Runlight\Log::to(fn (string $line) => ...) envía las notas de Runlight a un log propio en lugar del log de errores de PHP.

Laravel

Las rutas de Runlight van fuera del grupo de middleware web de Laravel. Ese grupo pide un token CSRF en cada POST, que el tracker no puede enviar, y cifra las cookies, que Runlight entonces no podría volver a leer. Runlight comprueba las peticiones a su manera.

Registra la instancia en AppServiceProvider.

app/Providers/AppServiceProvider.php

use Runlight\Runlight;
use Runlight\Store\Stores;

public function register(): void
{
    $this->app->singleton(Runlight::class, fn () => new Runlight([
        'store' => Stores::sqlite(storage_path('app/runlight.db')),
        'site' => ['name' => 'example.com', 'hostnames' => ['example.com'], 'timezone' => 'Europe/London'],
    ]));
}

Añade un controlador.

app/Http/Controllers/RunlightController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Runlight\Bridge\HttpFoundation;
use Runlight\Runlight;
use Symfony\Component\HttpFoundation\Response;

class RunlightController
{
    public function __invoke(Request $request, Runlight $rl): Response
    {
        $routes = $rl->routes(['token' => (string) config('services.runlight.token')]);

        return HttpFoundation::handle($rl, $routes, $request);
    }
}

Dale un archivo de rutas propio.

routes/runlight.php

<?php

use App\Http\Controllers\RunlightController;
use Illuminate\Support\Facades\Route;

Route::any('/runlight/{path?}', RunlightController::class)->where('path', '.*');
Route::get('/go/{slug}', RunlightController::class);

Carga ese archivo en bootstrap/app.php con then, que registra rutas fuera del grupo web.

bootstrap/app.php

use Illuminate\Support\Facades\Route;

->withRouting(
    web: __DIR__.'/../routes/web.php',
    commands: __DIR__.'/../routes/console.php',
    health: '/up',
    then: fn () => Route::group([], base_path('routes/runlight.php')),
)

Para los dominios de enlaces, pon el middleware ShortLinks de Runlight delante de todas las rutas en el mismo archivo. Responde a una petición en un dominio de enlaces antes de que Laravel la enrute, igual que a /go/<slug> cuando el enlace existe, y deja pasar intacta cualquier otra petición.

bootstrap/app.php

use Illuminate\Foundation\Configuration\Middleware;
use Runlight\Bridge\ShortLinks;

->withMiddleware(function (Middleware $middleware) {
    $middleware->prepend(ShortLinks::class);
})

Lee el token a través de la configuración, para que siga funcionando después de php artisan config:cache, y define RUNLIGHT_TOKEN en .env.

config/services.php

'runlight' => [
    'token' => env('RUNLIGHT_TOKEN'),
],

El (string) del controlador convierte un token que falta en uno vacío, lo que mantiene cerrado el panel. Pasar null lo dejaría abierto a todo el mundo. Ejecuta la comprobación programada desde el scheduler de Laravel.

routes/console.php

use Illuminate\Support\Facades\Schedule;
use Runlight\Runlight;

Schedule::call(fn () => app(Runlight::class)->check())->hourly();

Symfony

Un controlador sirve todo lo que hay bajo /runlight y los enlaces cortos en /go. Su método shortLinks escucha cada petición antes de que Symfony la enrute, así que un dominio de enlaces también llega a Runlight, y cualquier otra petición sigue intacta.

src/Controller/RunlightController.php

<?php

namespace App\Controller;

use Runlight\Bridge\HttpFoundation;
use Runlight\Bridge\ShortLinks;
use Runlight\Runlight;
use Runlight\Store\Stores;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Attribute\Route;

final class RunlightController
{
    private ?Runlight $rl = null;

    public function __construct(
        #[Autowire('%kernel.project_dir%/var/runlight.db')] private readonly string $database,
        #[Autowire('%env(RUNLIGHT_TOKEN)%')] private readonly string $token,
    ) {
    }

    #[Route('/runlight/{path}', name: 'runlight', requirements: ['path' => '.*'], defaults: ['path' => ''])]
    #[Route('/go/{slug}', name: 'runlight_link', methods: ['GET'])]
    public function __invoke(Request $request): Response
    {
        $rl = $this->runlight();

        return HttpFoundation::handle($rl, $rl->routes(['token' => $this->token]), $request);
    }

    #[AsEventListener(event: KernelEvents::REQUEST, priority: 64)]
    public function shortLinks(RequestEvent $event): void
    {
        (new ShortLinks($this->runlight()))->onKernelRequest($event);
    }

    private function runlight(): Runlight
    {
        return $this->rl ??= new Runlight([
            'store' => Stores::sqlite($this->database),
            'site' => ['name' => 'example.com', 'hostnames' => ['example.com'], 'timezone' => 'Europe/London'],
        ]);
    }
}

Define RUNLIGHT_TOKEN en .env.local. Symfony se detiene con un error cuando falta, así que el panel nunca se queda abierto por accidente. Para la comprobación programada, llama a $rl->check() desde un comando de consola que ejecute cron, o define CRON_SECRET y llama a la comprobación por HTTP.

Runlight\Bridge\HttpFoundation convierte una petición de Symfony o Laravel en una de Runlight, y su respuesta de vuelta, y Runlight\Bridge\ShortLinks responde a los enlaces cortos antes que las rutas de la aplicación. Necesitan symfony/http-foundation, que ambos frameworks ya tienen.

ShortLinks solo responde a /go/<name> cuando el enlace existe y pasa cualquier otro nombre a tus rutas. Allí, para un nombre sin enlace, la ruta /go/{slug} de arriba lanza el error de no encontrado del propio framework, así que tu página 404 responde en cualquier caso. FrontController y el drop-in independiente no tienen ninguna aplicación detrás y envían ellos mismos ese 404. En un dominio de enlaces, /go/<name> es un 404 en todas partes, como cualquier otra ruta que no sea uno de los enlaces de ese dominio.

Otras aplicaciones PHP

Cualquier aplicación PHP puede pasarle una petición a Runlight y devolver su respuesta. Construye un Runlight\Http\Request a partir de la URL, el método, las cabeceras, el cuerpo y la dirección de la conexión, y pásalo a FrontController::answer().

php

use Runlight\Http\Request;
use Runlight\Server\FrontController;

$request = new Request($url, $method, $headers, $body, $_SERVER['REMOTE_ADDR'] ?? '');
$answer = FrontController::answer($rl, $rl->routes(), $request);
// $answer->status, $answer->headers->all(), and $answer->text() are what to send.

Pasa la dirección desde la que llegó la conexión. Runlight lee él mismo las cabeceras reenviadas cuando trustProxy lo permite.

Un sitio en WordPress, Drupal o Craft no necesita nada de este código. Su plugin lo conecta a un Runlight que ejecutas en otro lugar, como el drop-in de abajo, como muestran las páginas de WordPress, Drupal y Craft.

El drop-in independiente

El drop-in es el equivalente en PHP del servidor independiente. Ejecuta Runlight en un dominio propio, como stats.example.com, con el panel en la raíz, sitios añadidos desde el panel, cuentas con inicio de sesión y enlaces cortos en cualquier dominio que apuntes hacia él. Encaja con un hosting que ejecuta PHP sin Node.

Configúralo

Crea una carpeta de proyecto junto a tu raíz web, instala ahí el paquete y copia el drop-in en la carpeta que sirve el dominio.

bash

mkdir stats && cd stats
composer require runlight/runlight
cp -r vendor/runlight/runlight/standalone public

Apunta el dominio a public. En Apache y LiteSpeed, el archivo .htaccess que viene incluido envía todas las peticiones a index.php. En nginx, añade un bloque server como este.

nginx.conf

server {
    server_name stats.example.com;
    root /var/www/stats/public;
    index index.php;

    location / {
        try_files $uri /index.php$is_args$args;
    }

    location = /index.php {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root/index.php;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    location ~ /\.ht {
        deny all;
    }

    # In case the drop-in folder is also the project folder: never serve the data, the code, or the config.
    location ~ ^/(vendor|runlight-data|config\.php|composer\.(json|lock))(/|$) {
        deny all;
    }
}

Los datos viven en runlight-data, dentro de la carpeta del proyecto y fuera de la raíz web, y se crean en la primera petición. Contienen el archivo SQLite y el secreto que firma los inicios de sesión, con los datos de ubicación en una carpeta propia. El usuario del servidor web tiene que poder escribir en la carpeta del proyecto, o en una carpeta runlight-data que crees para él.

Crea tu cuenta

La primera petición escribe en setup.txt, dentro de la carpeta de datos, un enlace de configuración con un código de un solo uso. Abre ese enlace e introduce tu dirección de correo y una contraseña de al menos diez caracteres, escrita dos veces. Sin RUNLIGHT_URL, el enlace empieza por una dirección provisional, así que pon la tuya delante de /setup. El enlace solo funciona mientras no haya ninguna cuenta, y el comando de cron borra el archivo en cuanto la hay. Para volver a mostrar el enlace, ejecuta esto en la carpeta del proyecto.

bash

vendor/bin/runlight setup

Con RUNLIGHT_TOKEN definido, la página de configuración pide el token en su lugar, y no se escribe ningún archivo. También puedes crear la primera cuenta desde la línea de comandos con vendor/bin/runlight password you@example.com, que muestra su contraseña. Los roles, las invitaciones, el inicio de sesión en dos pasos y la forma de añadir sitios funcionan como en el servidor independiente.

Ajustes

Los ajustes vienen de variables de entorno, o de un config.php en la carpeta del proyecto que devuelva los mismos nombres. Si ambos están definidos, ganan las variables de entorno. Copia el ejemplo para empezar uno.

bash

cp vendor/runlight/runlight/config.example.php config.php

config.php

<?php

return [
    'RUNLIGHT_URL' => 'https://stats.example.com',
    'DATABASE_URL' => 'mysql://runlight:password@localhost:3306/runlight',
];
Ajuste Qué hace
RUNLIGHT_URL La dirección pública del panel, como https://stats.example.com. Nunca puede convertirse en dominio de enlaces, y los correos de invitación y de informes enlazan a ella.
DATABASE_URL Una dirección postgres://, mysql:// o mariadb:// guarda los datos en esa base de datos en lugar de SQLite.
DATA_DIR La carpeta de datos, runlight-data dentro de la carpeta del proyecto por defecto. Una ruta relativa se lee desde la carpeta del proyecto.
RUNLIGHT_SECRET La clave que firma los inicios de sesión y cifra las claves guardadas. Sin ella, el drop-in crea una y la guarda en la carpeta de datos.
RUNLIGHT_TOKEN Un token bearer para scripts, que además crea la primera cuenta en lugar del enlace de configuración.
TRUST_PROXY Defínela como false cuando no haya nada delante del servidor web, o como x-real-ip o cf-connecting-ip cuando esa cabecera lleve la dirección del visitante.
RUNLIGHT_GEO De dónde salen las ubicaciones, como en el servidor independiente. Por defecto es city, y puede ser country, off o la ruta a un archivo MMDB.
CRON_SECRET Permite que un programador de tareas ejecute la comprobación en POST /api/check cuando no puede ejecutar el comando de cron.
RUNLIGHT_OBSERVE_KEY Una sola clave para los informes de agentes de IA de todos los sitios. La clave de agentes propia de cada sitio, que aparece en Ajustes, Instalación, es la mejor opción.
RUNLIGHT_CONFIG La ruta de un archivo de configuración que no está en la carpeta del proyecto.

TRUST_PROXY importa en un hosting PHP, donde Apache o nginx suelen responder a los visitantes sin ningún proxy delante. Por defecto, Runlight lee la dirección de cada visitante de la última entrada de X-Forwarded-For, y si no hay nada delante que añada esa entrada, un visitante puede escribirla. Define TRUST_PROXY=false en un servidor así.

Cron

Añade una línea al crontab del usuario con el que se ejecuta el sitio. Cada cinco minutos ejecuta la comprobación programada, y una vez al mes descarga los nuevos datos de ubicación.

bash

*/5 * * * * cd /var/www/stats && vendor/bin/runlight cron

El comando no muestra nada cuando todo va bien, salvo una línea cuando están listos los datos de ubicación de un mes nuevo. Si una ejecución sigue en marcha cuando empieza la siguiente, la segunda le deja el trabajo. Cuando el hosting no tiene cron, define CRON_SECRET y haz que cualquier programador de tareas llame a /api/check con él, como muestra Comprobación programada, aunque entonces las ubicaciones necesitan RUNLIGHT_GEO apuntando a un archivo propio.

Contraseñas olvidadas

Ejecuta el comando password en la carpeta del proyecto para darle a una cuenta una contraseña nueva, que el comando muestra. También desactiva el inicio de sesión en dos pasos de esa cuenta, y crea la cuenta si no existe.

bash

vendor/bin/runlight password someone@example.com

Actualizaciones y copias de seguridad

Ejecuta composer update runlight/runlight en la carpeta del proyecto. Las tablas se actualizan solas en la siguiente petición, o ejecuta vendor/bin/runlight migrate para actualizarlas al momento. Haz copia de seguridad de la carpeta de datos y de config.php, ya que las claves guardadas no se pueden leer sin el secreto.

Agentes de IA desde un log

Los agentes de IA como GPTBot y ClaudeBot acceden a las páginas sin ejecutar JavaScript, así que la etiqueta de script nunca los ve. Una aplicación PHP con Runlight dentro los cuenta donde se sirve la página, con observe(), como muestra PHP sin framework. Para cualquier otro sitio que alojes tú, el log de acceso del servidor web los tiene, y el comando agents lo lee.

bash

vendor/bin/runlight agents --log /var/log/nginx/access.log --to https://stats.example.com --key rlo_... --site https://example.com --follow

La clave es la clave de agentes del sitio, que aparece en Runlight en Ajustes, Instalación, y solo puede informar de accesos a ese sitio. Cuando falta --to o --key, el comando lee RUNLIGHT_URL o RUNLIGHT_OBSERVE_KEY del entorno o de config.php, igual que los demás comandos leen sus ajustes. --site es la dirección del sitio, para logs en el formato habitual de nginx o Apache, que no incluyen el host. Los logs JSON de Caddy sí llevan el host, así que ahí no hace falta. El usuario que ejecuta el comando tiene que poder leer el log.

Con --follow, el comando sigue en marcha, envía los accesos a medida que ocurren y continúa cuando se rota el log, terminando el archivo antiguo antes de pasar al nuevo. Un servicio de systemd o un gestor de procesos lo mantiene en marcha. Añade --state agents.json para que un reinicio continúe donde se quedó. Sin --follow, lee el log una vez y se detiene, y con --state la siguiente ejecución empieza donde terminó la anterior, lo que encaja con cron.

bash

*/5 * * * * cd /var/www/stats && vendor/bin/runlight agents --log /var/log/nginx/access.log --to https://stats.example.com --key rlo_... --site https://example.com --state agents.json

Solo una ejecución a la vez puede usar un archivo de estado. Mantiene un bloqueo junto al archivo (agents.json.lock), así que una segunda ejecución, como una tarea de cron que arranca mientras la anterior sigue enviando, se detiene con un mensaje y no envía nada dos veces. Un bloqueo que dejó una ejecución que falló se toma de nuevo.

Solo salen de la máquina los accesos correctos a páginas de agentes de IA conocidos, cada uno con la dirección de la página, el user agent del agente y cuándo se sirvió. Las direcciones de los visitantes y todo lo demás del log se quedan donde están. Runlight solo cuenta los accesos a páginas de la última semana, así que la primera ejecución sobre un log largo se salta las antiguas.

Lo que hace el servidor de Node y el drop-in no

PHP no mantiene nada en marcha entre peticiones, así que algunas cosas funcionan de forma distinta a npx runlight.sh.

  • La comprobación programada y la descarga mensual de DB-IP se ejecutan desde cron. Hasta el primer runlight cron, las visitas solo obtienen ubicación de cabeceras como las de Cloudflare.
  • El enlace de configuración se escribe en un archivo una vez y se conserva hasta que haya una cuenta.
  • No hay imagen de Docker, y nada escucha en un puerto propio. El servidor web ejecuta el drop-in.

Varios servidores web pueden compartir una base de datos, igual que las copias del servidor de Node. Cada petición vuelve a leer la lista de sitios, así que un sitio añadido en uno aparece en los demás al instante.

En qué se diferencia de la biblioteca de TypeScript

El paquete de PHP pasa las pruebas de conformidad que se exigen a la biblioteca de TypeScript, en SQLite, Postgres, MySQL y MariaDB. Las diferencias vienen de cómo se ejecuta PHP.

  • No hay pool de conexiones. Cada petición abre su propia conexión a la base de datos y la cierra al terminar, así que un sitio con mucho tráfico mantiene una conexión por cada worker de PHP.
  • Lo que la biblioteca de TypeScript hace en segundo plano, como borrar visitas después de elegir una retención más corta, se ejecuta después de enviar la respuesta. PHP-FPM y LiteSpeed cierran antes la conexión, y otros servidores la mantienen abierta hasta que termina el trabajo. La comprobación programada se ejecuta desde cron o desde el scheduler de un framework, ya que en PHP nada se ejecuta con un temporizador.
  • El hash de las contraseñas se calcula con scrypt en PHP puro, lo que tarda unos 0,7 segundos por inicio de sesión sin el compilador JIT de OPcache y unos 0,2 segundos con él. Los hashes coinciden con los de la biblioteca de TypeScript, así que las cuentas se mantienen.
  • La biblioteca de TypeScript guarda algunos recuentos en memoria, que PHP olvida entre peticiones. Los límites de contraseñas incorrectas se guardan en la base de datos, así que todos los workers los comparten. El límite de peticiones del tracker vive en APCu cuando está cargado, y si no, en pequeños archivos en la carpeta temporal del sistema, salvo que pases un Runlight\Cache propio como cache, que entonces guarda también el icono del sitio.
  • Runlight lee RUNLIGHT_TOKEN y las demás variables de getenv(), luego de $_ENV y luego de $_SERVER, ya que cada hosting las define en un lugar distinto.