Todas as docs

Início

  1. 01Primeiros passos
  2. 02Instalação
  3. 03Rastreamento

Recursos

  1. 04O painel
  2. 05Metas
  3. 06Links curtos
  4. 07Origens e agentes de IA
  5. 08Relatórios por email
  6. 09Verificação agendada
  7. 10Pergunte à sua IA

Referência

  1. 11Configuração
  2. 12API HTTP

Plataformas

  1. 13Servidor independente
  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

Confiança

  1. 26Privacidade

Plataformas

PHP

O Runlight roda em PHP 8.2 ou mais recente, dentro de um app Laravel, Symfony ou PHP puro, ou em um domínio próprio em uma hospedagem PHP comum.

O pacote PHP é o Runlight reescrito em PHP. Ele serve o mesmo painel e a mesma API, e dá a cada requisição a mesma resposta que a biblioteca TypeScript dá. Os números vão para as mesmas tabelas, então qualquer um dos dois consegue ler um banco de dados que o outro escreveu. Ele precisa do PHP 8.2 ou mais recente, com PDO e o driver do seu banco de dados.

Instalação

Adicione o pacote com o Composer.

bash

composer require runlight/runlight

O Runlight usa as extensões curl, intl, sodium e APCu quando estão carregadas, e recorre a PHP puro para qualquer uma que esteja faltando.

Crie a instância

Crie um arquivo que retorna a instância e faça o require dele onde você precisar do 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'],
]);

As opções têm os mesmos nomes que em Configuração, então sites, trustProxy, rateLimit, mail e managedSites funcionam como lá. As tabelas são criadas na primeira requisição.

Stores

As tabelas do Runlight começam com rl_. Os stores de SQLite, MySQL e Postgres aceitam uma opção prefix para usar outro prefixo no lugar, como Stores::sqlite(['path' => $path, 'prefix' => 'wp_rl_']) ou Stores::mysql($url, ['prefix' => 'wp_rl_']), de modo que dois apps que compartilham um banco de dados mantêm cada um as suas próprias tabelas, e Stores::fromDb($db, 'wp_rl_') faz o mesmo sobre um Db seu. Um prefixo tem até 32 letras, dígitos e sublinhados e não começa com dígito. $store->tables() lista as tabelas do store pelo nome, para um app que esteja removendo o Runlight.

SQLite

php

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

O SQLite precisa da extensão pdo_sqlite, que a maioria das instalações de PHP tem. Mantenha o arquivo fora da raiz do site, em uma pasta em que o servidor web possa escrever, já que o SQLite grava um journal ao lado dele. A pasta é criada se não existir.

MySQL e MariaDB

php

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

Este store precisa de pdo_mysql e funciona com MySQL 8.0.20 ou mais recente e MariaDB 11.4 ou mais recente. Uma URL mariadb:// também funciona. Um segundo argumento como ['statementTimeout' => 30000] interrompe qualquer consulta depois desse número de milissegundos, e o padrão é 120000, como em Configuração.

Postgres

php

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

Este store precisa de pdo_pgsql. Ele também aceita statementTimeout no segundo argumento, e ['schema' => 'analytics'] mantém as tabelas em um schema próprio. Stores::url() escolhe o store a partir de uma DATABASE_URL que comece com postgres://, mysql://, mariadb:// ou sqlite:.

PHP puro

Em um app com um único index.php por onde passam todas as requisições, envie os caminhos sob /runlight e /go para o Runlight logo no início.

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() lê a requisição das variáveis globais do PHP e envia a resposta. observe() registra as leituras de agentes de IA e retorna na hora para todos os outros. Adicione o script a todas as páginas, logo antes de </head>.

html

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

Depois defina RUNLIGHT_TOKEN como uma string longa e aleatória e abra /runlight/?token= seguido dessa string uma vez para entrar, como descreve Primeiros passos. Com routes(['accounts' => true]) e um RUNLIGHT_SECRET, as pessoas entram com o próprio e-mail e senha, como em Contas.

Um domínio de links responde a links curtos na raiz de um nome próprio, então as requisições dele nunca casam com os caminhos acima. Para atender um a partir do app, pergunte primeiro a linkDomainResponse(). Ele só responde nos seus domínios de links e retorna null para qualquer outro host, então o resto do app segue como antes.

php

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

Laravel e Symfony fazem isso com a classe ShortLinks do Runlight, como mostram as seções deles.

Request::fromServer($server) lê uma requisição de um array no formato de $_SERVER, como a cópia sem barras invertidas que um CMS faz dele. Runlight\Log::to(fn (string $line) => ...) envia as mensagens do Runlight para um log seu em vez do log de erros do PHP.

Laravel

As rotas do Runlight ficam fora do grupo de middleware web do Laravel. Esse grupo exige um token CSRF em todo POST, que o script de rastreamento não consegue enviar, e criptografa os cookies, que o Runlight então não conseguiria ler de volta. O Runlight verifica as requisições do seu próprio jeito.

Registre a instância no 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'],
    ]));
}

Adicione um controller.

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);
    }
}

Dê a ele um arquivo de rotas próprio.

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);

Carregue esse arquivo em bootstrap/app.php com then, que registra rotas fora do 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 domínios de links, coloque o middleware ShortLinks do Runlight na frente de todas as rotas no mesmo arquivo. Ele responde a uma requisição em um domínio de links antes de o Laravel roteá-la, assim como a /go/<slug> quando o link existe, e deixa passar intacta qualquer outra requisição.

bootstrap/app.php

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

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

Leia o token pela configuração, para que ele continue funcionando depois de php artisan config:cache, e defina RUNLIGHT_TOKEN no .env.

config/services.php

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

O (string) no controller transforma um token ausente em um token vazio, o que mantém o painel fechado. Passar null o deixaria aberto para todo mundo. Rode a verificação agendada pelo scheduler do Laravel.

routes/console.php

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

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

Symfony

Um único controller atende tudo sob /runlight e os links curtos em /go. O método shortLinks dele escuta todas as requisições antes de o Symfony roteá-las, então um domínio de links também chega ao Runlight, e qualquer outra requisição segue 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'],
        ]);
    }
}

Defina RUNLIGHT_TOKEN no .env.local. O Symfony para com um erro quando ele está faltando, então o painel nunca fica aberto por acidente. Para a verificação agendada, chame $rl->check() a partir de um comando de console que o cron executa, ou defina CRON_SECRET e chame a verificação por HTTP.

Runlight\Bridge\HttpFoundation converte uma requisição do Symfony ou do Laravel para o formato do Runlight e a resposta de volta, e Runlight\Bridge\ShortLinks responde aos links curtos antes das rotas do app. Eles precisam de symfony/http-foundation, que os dois frameworks já têm.

ShortLinks só responde a /go/<name> quando o link existe e passa qualquer outro nome para as suas rotas. Lá, para um nome sem link, a rota /go/{slug} acima lança o erro de não encontrado do próprio framework, então a sua página 404 responde de qualquer forma. FrontController e o drop-in independente não têm nenhum app por trás e enviam eles mesmos esse 404. Num domínio de links, /go/<name> é um 404 em todo lugar, como qualquer outro caminho que não seja um dos links desse domínio.

Outros apps PHP

Qualquer app PHP pode entregar uma requisição ao Runlight e devolver a resposta dele. Monte um Runlight\Http\Request a partir da URL, do método, dos cabeçalhos, do corpo e do endereço da conexão, e passe-o para 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.

Passe o endereço de onde a conexão veio. O Runlight lê os cabeçalhos encaminhados por conta própria quando trustProxy permite.

Um site em WordPress, Drupal ou Craft não precisa de nada deste código. O plugin dele o conecta a um Runlight que você roda em outro lugar, como o drop-in abaixo, conforme mostram as páginas de WordPress, Drupal e Craft.

O drop-in independente

O drop-in é a contraparte em PHP do servidor independente. Ele roda o Runlight em um domínio próprio, como stats.example.com, com o painel na raiz, sites adicionados pelo painel, contas com login e links curtos em qualquer domínio que você apontar para ele. Ele é indicado para hospedagens que rodam PHP sem Node.

Configure

Crie uma pasta de projeto ao lado da raiz do seu site, instale o pacote nela e copie o drop-in para a pasta que o domínio serve.

bash

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

Aponte o domínio para public. No Apache e no LiteSpeed, o arquivo .htaccess que vem junto envia todas as requisições para index.php. No nginx, adicione um bloco 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;
    }
}

Os dados ficam em runlight-data na pasta do projeto, fora da raiz do site, e são criados na primeira requisição. Ali estão o arquivo SQLite e o segredo que assina os logins, com os dados de localização em uma pasta própria. O usuário do servidor web precisa poder escrever na pasta do projeto, ou em uma pasta runlight-data que você crie para ele.

Crie sua conta

A primeira requisição grava um link de configuração com um código de uso único em setup.txt, na pasta de dados. Abra esse link e informe seu endereço de e-mail e uma senha de pelo menos dez caracteres, digitada duas vezes. Sem RUNLIGHT_URL, o link começa com um endereço provisório, então coloque o seu na frente de /setup. O link só funciona enquanto não existe nenhuma conta, e o comando do cron apaga o arquivo assim que existe uma. Para mostrar o link de novo, rode isto na pasta do projeto.

bash

vendor/bin/runlight setup

Com RUNLIGHT_TOKEN definido, a página de configuração pede o token em vez disso, e nenhum arquivo é gravado. Você também pode criar a primeira conta pela linha de comando com vendor/bin/runlight password you@example.com, que mostra a senha dela. Funções, convites, login em duas etapas e a adição de sites funcionam como no servidor independente.

Configurações

As configurações vêm de variáveis de ambiente, ou de um config.php na pasta do projeto que retorna os mesmos nomes. As variáveis de ambiente prevalecem quando as duas estão definidas. Copie o exemplo para começar um.

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',
];
Configuração O que faz
RUNLIGHT_URL O endereço público do painel, como https://stats.example.com. Ele nunca pode virar um domínio de links, e os e-mails de convite e de relatório apontam para ele.
DATABASE_URL Um endereço postgres://, mysql:// ou mariadb:// guarda os dados nesse banco de dados em vez do SQLite.
DATA_DIR A pasta de dados, runlight-data na pasta do projeto por padrão. Um caminho relativo é lido a partir da pasta do projeto.
RUNLIGHT_SECRET A chave que assina os logins e criptografa as chaves salvas. Sem ela, o drop-in cria uma e a guarda na pasta de dados.
RUNLIGHT_TOKEN Um bearer token para scripts, que também cria a primeira conta no lugar do link de configuração.
TRUST_PROXY Defina como false quando não há nada na frente do servidor web, ou como x-real-ip ou cf-connecting-ip quando esse cabeçalho traz o endereço do visitante.
RUNLIGHT_GEO De onde vêm as localizações, como no servidor independente. O padrão é city, e pode ser country, off ou o caminho de um arquivo MMDB.
CRON_SECRET Permite que um agendador rode a verificação em POST /api/check quando não consegue rodar o comando do cron.
RUNLIGHT_OBSERVE_KEY Uma única chave com que qualquer site pode informar agentes de IA. A chave de agentes própria de cada site, mostrada em Configurações, Instalação, é a melhor escolha.
RUNLIGHT_CONFIG O caminho de um arquivo de configuração em outro lugar que não a pasta do projeto.

TRUST_PROXY importa em hospedagem PHP, onde o Apache ou o nginx muitas vezes atende os visitantes sem nenhum proxy na frente. Por padrão, o Runlight lê o endereço de cada visitante da última entrada de X-Forwarded-For, e sem nada na frente para adicionar essa entrada, um visitante pode escrevê-la. Defina TRUST_PROXY=false em um servidor assim.

Cron

Adicione uma linha ao crontab do usuário com que o site roda. A cada cinco minutos ela roda a verificação agendada, e uma vez por mês baixa os novos dados de localização.

bash

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

O comando não mostra nada quando está tudo bem, a não ser uma linha quando os dados de localização de um novo mês estão prontos. Se uma execução ainda estiver em andamento quando a próxima começar, a segunda deixa o trabalho para ela. Quando o host não tem cron, defina CRON_SECRET e faça qualquer agendador chamar /api/check com ele, como mostra Verificação agendada, embora nesse caso as localizações precisem de RUNLIGHT_GEO apontando para um arquivo seu.

Senhas esquecidas

Rode o comando password na pasta do projeto para dar uma nova senha a uma conta, que ele mostra. Ele também desativa o login em duas etapas dessa conta, e cria a conta quando ela não existe.

bash

vendor/bin/runlight password someone@example.com

Atualizações e backups

Rode composer update runlight/runlight na pasta do projeto. As tabelas se atualizam sozinhas na próxima requisição, ou rode vendor/bin/runlight migrate para atualizá-las na hora. Faça backup da pasta de dados e do config.php, já que as chaves salvas não podem ser lidas sem o segredo.

Agentes de IA a partir de um log

Agentes de IA como o GPTBot e o ClaudeBot leem páginas sem rodar JavaScript, então a tag do script nunca os vê. Um app PHP com o Runlight dentro os conta onde a página é servida, com observe(), como mostra PHP puro. Para qualquer outro site que você hospeda, o log de acesso do servidor web os registra, e o comando agents lê esse log.

bash

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

A chave é a chave de agentes do site, mostrada no Runlight em Configurações, Instalação, e ela só pode informar leituras desse site. Quando --to ou --key ficam de fora, o comando lê RUNLIGHT_URL ou RUNLIGHT_OBSERVE_KEY do ambiente ou do config.php, do mesmo jeito que os outros comandos leem suas configurações. --site é o endereço do site, para logs no formato usual do nginx ou do Apache, que deixam o host de fora. Os logs JSON do Caddy trazem o host, então ele não é necessário ali. O usuário que roda o comando precisa conseguir ler o log.

Com --follow, o comando continua rodando, envia as leituras conforme acontecem e segue em frente quando o log é rotacionado, terminando o arquivo antigo antes de passar para o novo. Um serviço systemd ou um gerenciador de processos o mantém rodando. Adicione --state agents.json para que um reinício continue de onde parou. Sem --follow, ele lê o log uma vez e para, e com --state a próxima execução começa onde a anterior terminou, o que combina com o 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

Só uma execução por vez pode usar um arquivo de estado. Ela mantém uma trava ao lado do arquivo (agents.json.lock), então uma segunda execução, como um job do cron que começa enquanto o anterior ainda está enviando, para com uma mensagem e não envia nada em dobro. Uma trava deixada por uma execução que caiu é assumida pela próxima.

Só as leituras de página bem-sucedidas de agentes de IA conhecidos saem da máquina, cada uma com o endereço da página, o user agent do agente e quando foi servida. Os endereços dos visitantes e todo o resto do log ficam onde estão. O Runlight só conta leituras de página da última semana, então a primeira execução sobre um log longo pula as antigas.

O que o servidor Node faz e o drop-in não

O PHP não mantém nada rodando entre requisições, então algumas coisas funcionam de forma diferente do npx runlight.sh.

  • A verificação agendada e o download mensal do DB-IP rodam pelo cron. Até o primeiro runlight cron, as visitas só ganham uma localização a partir de cabeçalhos como os da Cloudflare.
  • O link de configuração é gravado em um arquivo uma vez e mantido até existir uma conta.
  • Não há imagem Docker, e nada escuta em uma porta própria. O servidor web roda o drop-in.

Vários servidores web podem compartilhar um banco de dados, assim como cópias do servidor Node. Cada requisição lê a lista de sites de novo, então um site adicionado em um aparece nos outros na hora.

Diferenças em relação à biblioteca TypeScript

O pacote PHP passa nos testes de conformidade a que a biblioteca TypeScript é submetida, em SQLite, Postgres, MySQL e MariaDB. As diferenças vêm do jeito como o PHP roda.

  • Não há pool de conexões. Cada requisição abre a própria conexão com o banco de dados e a fecha ao terminar, então um site movimentado mantém uma conexão para cada worker do PHP.
  • O que a biblioteca TypeScript faz em segundo plano, como apagar visitas depois que uma retenção menor é escolhida, roda depois que a resposta é enviada. O PHP-FPM e o LiteSpeed fecham a conexão primeiro, e outros servidores a mantêm aberta até o trabalho terminar. A verificação agendada roda pelo cron ou pelo scheduler de um framework, já que nada no PHP roda por timer.
  • As senhas recebem hash com scrypt em PHP puro, o que leva cerca de 0,7 segundo por login sem o compilador JIT do OPcache e cerca de 0,2 segundo com ele. Os hashes batem com os da biblioteca TypeScript, então as contas são aproveitadas.
  • A biblioteca TypeScript guarda algumas contagens na memória, que o PHP esquece entre requisições. Os limites de senha errada ficam no banco de dados, então todos os workers os compartilham. O limite de requisições do script de rastreamento fica no APCu quando ele está carregado, e em pequenos arquivos na pasta temporária do sistema caso contrário, a não ser que você passe um Runlight\Cache seu como cache, que então guarda também o ícone do site.
  • O Runlight lê RUNLIGHT_TOKEN e as outras variáveis de getenv(), depois de $_ENV e depois de $_SERVER, já que os hosts as definem em lugares diferentes.