mathiasgrimm/netwatch

Network service latency probing tool for PHP — measures Redis, PostgreSQL, MySQL, S3, HTTP endpoints with statistical analysis

Maintainers

Package info

github.com/mathiasgrimm/netwatch

pkg:composer/mathiasgrimm/netwatch

Transparency log

Statistics

Installs: 727

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.0 2026-07-20 17:16 UTC

README

Netwatch

Netwatch

Know your latency before your users do. Statistical network probing for Redis, databases, S3, and HTTP - from the CLI or inside Laravel.

Latest Version on Packagist Tests Total Downloads PHP Version License

When an application feels slow, the first question is always the same: is it the app, or is it the network? Answering it usually means SSH-ing into a box and juggling ping, redis-cli --latency, psql, and curl by hand - with no consistent numbers to compare across environments.

Netwatch answers it in one command. It probes each of your services - Redis, MySQL, PostgreSQL, S3, HTTP endpoints, raw TCP - for N iterations, splits every measurement into connect and request time, and reports min/max/avg and p50/p95/p99 percentiles. Run it as a standalone CLI in any PHP project, or drop it into Laravel and get an Artisan command plus a health dashboard preconfigured from your existing .env.

Netwatch health dashboard

Features

  • Multiple probe types - HTTP, TCP/IP, Redis (php-redis), MySQL/PostgreSQL/SQLite (PDO), AWS S3
  • Statistical analysis - min, max, avg, p50, p95, p99 for connect, request, and total latency
  • Latency thresholds - per-probe warn/crit limits drive the dashboard, JSON API and CLI alike, with --fail-on-crit for cron/CI
  • Parallel execution - probes run concurrently via subprocesses (default in CLI)
  • Per-probe configuration - individual iteration counts, enable/disable flags, serializable probe definitions
  • Laravel integration - service provider with auto-discovery, Artisan command, and health dashboard
  • Standalone CLI - works with any PHP project via Symfony Console
  • Fail-fast - stops probing after 3 consecutive failures
  • Custom probes - implement a single interface to measure anything

Requirements

  • PHP 8.3+
  • ext-curl (for HTTP and S3 probes)
  • ext-redis (for Redis probe, optional)
  • ext-pdo (for database probes, optional)

Installation

composer require mathiasgrimm/netwatch

Laravel Integration

Netwatch auto-registers via Laravel package discovery. No manual provider registration needed.

Install

php artisan netwatch:install

This publishes the config file and service provider, and registers the provider in bootstrap/providers.php.

To overwrite previously published files:

php artisan netwatch:install --force

You can also publish assets individually:

php artisan vendor:publish --tag=netwatch-config
php artisan vendor:publish --tag=netwatch-provider

The config file (config/netwatch.php) contains pre-configured probes that read from your existing Laravel environment variables (DB_*, REDIS_*, AWS_*, APP_URL). The config uses the serializable [Class::class => [args]] array format, so it is fully compatible with php artisan config:cache.

<?php

use MathiasGrimm\Netwatch\Laravel\Http\Middleware\Authorize;
use MathiasGrimm\Netwatch\Probe\HttpProbe;
use MathiasGrimm\Netwatch\Probe\PdoProbe;
use MathiasGrimm\Netwatch\Probe\PhpRedisProbe;
use MathiasGrimm\Netwatch\Probe\S3Probe;
use MathiasGrimm\Netwatch\Probe\TcpPingProbe;

return [
    'iterations' => (int) env('NETWATCH_ITERATIONS', 10),

    'health_route' => [
        'enabled' => (bool) env('NETWATCH_HEALTH_ENABLED', false),
        'domain' => env('NETWATCH_DOMAIN'),
        'path' => env('NETWATCH_PATH', 'netwatch'),
        'middleware' => ['web', Authorize::class],
        'token' => env('NETWATCH_HEALTH_TOKEN'),
    ],

    'probes' => [

        'database' => [
            'enabled' => env('NETWATCH_PROBE_DATABASE_ENABLED', false),
            'probe' => [
                PdoProbe::class => [
                    env('DB_CONNECTION').':host='.env('DB_HOST').';port='.env('DB_PORT').';dbname='.env('DB_DATABASE'),
                    env('DB_USERNAME'),
                    env('DB_PASSWORD'),
                ],
            ],
        ],

        'database-tcp' => [
            'enabled' => env('NETWATCH_PROBE_DATABASE_TCP_ENABLED', false),
            'probe' => [
                TcpPingProbe::class => [
                    parse_url((string) env('DB_HOST'), PHP_URL_HOST) ?: env('DB_HOST'),
                    (int) (env('DB_PORT') ?: match (env('DB_CONNECTION')) {
                        'pgsql' => 5432,
                        'sqlsrv' => 1433,
                        default => 3306,
                    }),
                ],
            ],
        ],

        'redis' => [
            'enabled' => env('NETWATCH_PROBE_REDIS_ENABLED', false),
            'probe' => [
                PhpRedisProbe::class => [
                    env('REDIS_HOST').':'.env('REDIS_PORT'),
                    env('REDIS_USERNAME'),
                    env('REDIS_PASSWORD'),
                ],
            ],
        ],

        'redis-tcp' => [
            'enabled' => env('NETWATCH_PROBE_REDIS_TCP_ENABLED', false),
            'probe' => [
                TcpPingProbe::class => [
                    parse_url((string) env('REDIS_HOST'), PHP_URL_HOST) ?: env('REDIS_HOST'),
                    (int) (env('REDIS_PORT') ?: 6379),
                ],
            ],
        ],

        's3' => [
            'enabled' => env('NETWATCH_PROBE_S3_ENABLED', false),
            'probe' => [
                S3Probe::class => [
                    env('AWS_BUCKET'),
                    env('AWS_DEFAULT_REGION'),
                    env('AWS_ACCESS_KEY_ID'),
                    env('AWS_SECRET_ACCESS_KEY'),
                    env('AWS_ENDPOINT'),
                ],
            ],
        ],

        'app' => [
            'enabled' => env('NETWATCH_PROBE_APP_ENABLED', false),
            'probe' => [
                HttpProbe::class => [
                    env('APP_URL'),
                ],
            ],
        ],

        'cloudflare-dns' => [
            'enabled' => env('NETWATCH_PROBE_CLOUDFLARE_DNS_ENABLED', false),
            'probe' => [
                TcpPingProbe::class => [
                    '1.1.1.1',
                    53,
                ],
            ],
        ],

        'google-dns' => [
            'enabled' => env('NETWATCH_PROBE_GOOGLE_DNS_ENABLED', false),
            'probe' => [
                TcpPingProbe::class => [
                    '8.8.8.8',
                    53,
                ],
            ],
        ],

    ],
];

Artisan Command

php artisan netwatch:run
php artisan netwatch:run --probe=redis --iterations=20 --json
php artisan netwatch:run --json --without-results
Option Description
--iterations=N Override iteration count for all probes
--probe=NAME Run only a specific probe by name (e.g. --probe=redis)
--json Output results as JSON instead of a table
--without-results Exclude individual iteration results from JSON output
--fail-on-crit Exit non-zero when any probe fails or breaches its crit latency threshold

CLI Table Output

Health Dashboard

Enable the health dashboard route by setting NETWATCH_HEALTH_ENABLED=true in your .env. Once enabled, access is restricted to the local environment until you register your own gate or token (see Authorization), so it is safe to leave on in production:

NETWATCH_HEALTH_ENABLED=true
NETWATCH_PATH=netwatch

Access the dashboard at /netwatch/health.

The HTML dashboard renders instantly and loads each probe asynchronously: every enabled probe appears immediately as a placeholder card, then its results stream in from GET /netwatch/health/probes/{name} (one concurrent request per probe, same auth as /health). Use the Auto 30s toolbar toggle to keep re-running the probes every 30 seconds while the tab is open (off by default, remembered per browser, paused while the tab is in the background).

Cards accumulate results across refreshes: the sparkline grows run by run (thin separators mark each run, capped at the last 60 iterations), the stats table and headline p95 are computed over the full accumulated history, and the headline shows a p95 trend versus the previous run. When a run completes, cards re-sort by severity (failing first, then over-threshold, then healthy) and the header shows a summary like 1 failing · 2 slow · 3 healthy.

Latency thresholds — each probe has optional warn/crit thresholds in milliseconds. The probe-level status compares total p95 against them (breaching warn turns the headline amber, crit red), and every individual iteration is checked too: over-threshold samples color their sparkline bars amber/red, dashed guide lines mark the thresholds on the chart, and the JSON payload carries a status per sample plus over_warn/over_crit counts. Defaults ship for the built-in probes (see Environment Variables below); set any threshold env to empty to disable it. The same thresholds drive the dashboard, the JSON API, and the CLI.

The JSON API (?format=json or an application/json Accept header) is unaffected: it still runs all requested probes synchronously and returns the complete result set in one response.

Upgrading? If you previously published the views (--tag=netwatch-views), re-publish them: the dashboard now requires the async view and the new partials/card.blade.php. A previously published config/netwatch.php keeps working: built-in probes whose config has no thresholds key fall back to the package defaults (still tunable via the *_WARN_MS/*_CRIT_MS env vars). Setting 'thresholds' => null — or any individual value to null/empty — explicitly disables the threshold.

Query Parameters

Parameter Example Description
format ?format=json Force response format: json or html. Without this parameter, the format is determined by the Accept header (defaults to HTML for browsers).
probes ?probes=redis,database Comma-separated list of probe names to run. Only the specified probes are executed; others are skipped.
without_results ?without_results=1 Exclude individual iteration results from the response, returning only aggregate stats. Useful for smaller payloads.

Each probe in the JSON response includes, alongside stats, failures and results: a status (ok / warn / crit / failing, evaluated from the latency thresholds), the resolved thresholds, over_warn/over_crit sample counts, and a per-iteration status on every entry in results.

Parameters can be combined: /netwatch/health?probes=redis,database&format=json&without_results=1

HTML view - interactive dashboard with per-probe latency stats, in dark and light themes:

Health Dashboard - dark theme

Health Dashboard - light theme

Export image - download a branded image summary of the current results (status, sparkline, and connect/request/total latency stats per probe) straight from the dashboard toolbar. Exports as WebP (~150 KB), falling back to PNG in browsers without WebP encoding:

Exported Health Summary Image - dark theme

Exported Health Summary Image - light theme

JSON panel - view raw JSON data directly within the dashboard:

Health Dashboard JSON Panel - dark theme

Health Dashboard JSON Panel - light theme

JSON API response - append ?format=json for a raw JSON endpoint:

Health Dashboard - JSON API Response

Authorization

Token-based auth (for monitoring tools)

Monitoring tools (Datadog, uptime checkers, etc.) that can't use session-based auth can authenticate with a query-parameter token:

NETWATCH_HEALTH_TOKEN=your-secret-token

Then access the endpoint as /netwatch/health?token=your-secret-token. When a valid token is provided, session/gate-based auth is bypassed. If the token is not set, regular auth applies unchanged.

Gate-based auth

By default, the health route is accessible only in the local environment: Netwatch registers a default gate that returns true only when app()->environment('local'), and the Authorize middleware denies the request (403) whenever no gate returns true and no valid token is supplied. To grant access in other environments, register your own gate, which replaces the default. Publish the service provider:

php artisan vendor:publish --tag=netwatch-provider

Then edit app/Providers/NetwatchServiceProvider.php:

protected function gate(): void
{
    Gate::define('viewNetwatch', function ($user = null) {
        return in_array(optional($user)->email, [
            'admin@example.com',
        ]);
    });
}

You can also use the static auth callback:

Netwatch::auth(function ($request) {
    return $request->user()?->isAdmin();
});

Environment Variables

NETWATCH_ITERATIONS=10                        # Default probe iterations
NETWATCH_HEALTH_ENABLED=false                 # Enable health dashboard route
NETWATCH_DOMAIN=null                          # Domain for health route
NETWATCH_PATH=netwatch                        # URL path prefix for health route
NETWATCH_HEALTH_TOKEN=null                    # Token for monitoring-tool access (null = disabled)

NETWATCH_PROBE_DATABASE_ENABLED=false         # Enable database (PDO) probe
NETWATCH_PROBE_DATABASE_TCP_ENABLED=false     # Enable raw TCP connect probe to DB_HOST:DB_PORT
NETWATCH_PROBE_REDIS_ENABLED=false            # Enable Redis probe
NETWATCH_PROBE_REDIS_TCP_ENABLED=false        # Enable raw TCP connect probe to REDIS_HOST:REDIS_PORT
NETWATCH_PROBE_S3_ENABLED=false               # Enable S3 probe
NETWATCH_PROBE_APP_ENABLED=false              # Enable HTTP probe for APP_URL
NETWATCH_PROBE_CLOUDFLARE_DNS_ENABLED=false   # Enable Cloudflare DNS (1.1.1.1) TCP probe
NETWATCH_PROBE_GOOGLE_DNS_ENABLED=false       # Enable Google DNS (8.8.8.8) TCP probe

NETWATCH_PROBE_DATABASE_WARN_MS=50            # Latency thresholds (total p95, ms) per probe.
NETWATCH_PROBE_DATABASE_CRIT_MS=100           # warn = amber, crit = red on the dashboard.
NETWATCH_PROBE_DATABASE_TCP_WARN_MS=10        # Set any of them to null/empty to disable
NETWATCH_PROBE_DATABASE_TCP_CRIT_MS=25        # that threshold.
NETWATCH_PROBE_REDIS_WARN_MS=50
NETWATCH_PROBE_REDIS_CRIT_MS=100
NETWATCH_PROBE_REDIS_TCP_WARN_MS=5
NETWATCH_PROBE_REDIS_TCP_CRIT_MS=25
NETWATCH_PROBE_S3_WARN_MS=150
NETWATCH_PROBE_S3_CRIT_MS=500
NETWATCH_PROBE_APP_WARN_MS=500
NETWATCH_PROBE_APP_CRIT_MS=1000
NETWATCH_PROBE_CLOUDFLARE_DNS_WARN_MS=35
NETWATCH_PROBE_CLOUDFLARE_DNS_CRIT_MS=50
NETWATCH_PROBE_GOOGLE_DNS_WARN_MS=35
NETWATCH_PROBE_GOOGLE_DNS_CRIT_MS=50

Standalone Usage

CLI

Generate a config file and run:

# Generate config
vendor/bin/netwatch netwatch:init

# Run all probes
vendor/bin/netwatch netwatch:run

# Run a specific probe with 20 iterations
vendor/bin/netwatch netwatch:run --probe redis --iterations 20

# JSON output
vendor/bin/netwatch netwatch:run --json

# Exit non-zero when any probe fails or breaches its crit threshold (cron/CI)
vendor/bin/netwatch netwatch:run --fail-on-crit

The table shows a per-probe Status column and colors the total p95 against the probe's latency threshold (amber over warn, red over crit) — the same thresholds the dashboard and JSON API use. netwatch:run inside Laravel (artisan) supports the same options.

Programmatic

use MathiasGrimm\Netwatch\Netwatch;
use MathiasGrimm\Netwatch\Probe\HttpProbe;
use MathiasGrimm\Netwatch\Probe\TcpPingProbe;

$netwatch = new Netwatch([
    'example' => ['probe' => new HttpProbe('https://example.com')],
    'dns'     => ['probe' => new TcpPingProbe('8.8.8.8', 53)],
], iterations: 10);

$results = $netwatch->run();

foreach ($results as $name => $aggregate) {
    echo "$name: avg={$aggregate->stats['total_ms']['avg']}ms p99={$aggregate->stats['total_ms']['p99']}ms\n";
}

Configuration

Standalone Config File

Create a netwatch.php in your project root (or use netwatch:init to generate one):

<?php

use MathiasGrimm\Netwatch\Probe\PhpRedisProbe;
use MathiasGrimm\Netwatch\Probe\PdoProbe;
use MathiasGrimm\Netwatch\Probe\HttpProbe;
use MathiasGrimm\Netwatch\Probe\TcpPingProbe;

return [
    'iterations' => 10,

    'probes' => [
        'redis' => [
            'enabled' => true,
            'probe' => new PhpRedisProbe('tcp://127.0.0.1:6379'),
        ],
        'mysql' => [
            'enabled' => true,
            'probe' => new PdoProbe('mysql:host=127.0.0.1;port=3306', 'root', ''),
        ],
        'pgsql' => [
            'enabled' => true,
            'probe' => new PdoProbe('pgsql:host=127.0.0.1;port=5432;dbname=postgres', 'postgres', ''),
        ],
        'app' => [
            'enabled' => true,
            'probe' => new HttpProbe('https://example.com'),
        ],
        'cloudflare' => [
            'enabled' => true,
            'probe' => new TcpPingProbe('1.1.1.1', 443),
        ],
    ],
];

Per-Probe Options

Each probe entry supports:

Key Type Default Description
probe ProbeInterface|string|array required Probe instance, class string, or [Class::class => [args]] array
enabled bool false Probe is skipped unless explicitly set to true
iterations int global default Override iteration count for this probe
'redis' => [
    'enabled' => true,
    'probe' => new PhpRedisProbe('tcp://127.0.0.1:6379'),
    'iterations' => 20,
],

The probe value supports three formats:

  • Instance - a ProbeInterface object, used as-is
  • Class string - e.g. PhpRedisProbe::class, instantiated with no arguments
  • Array - [Class::class => [arg1, arg2, ...]], instantiated with the given arguments. This format is serializable, making it compatible with php artisan config:cache.
// Array format (serializable)
'database' => [
    'enabled' => true,
    'probe' => [
        PdoProbe::class => ['mysql:host=127.0.0.1;port=3306', 'root', ''],
    ],
],

Available Probes

HttpProbe

Measures HTTP endpoint latency using cURL. Reports connect time and request time separately.

new HttpProbe(
    url: 'https://example.com',
    method: 'GET',          // HTTP method (default: GET)
    headers: [],            // Custom headers
    timeout: 3.0,           // Timeout in seconds
    expectedCode: null,     // Expected HTTP status code (null = 200-399)
)

TcpPingProbe

Measures raw TCP connection latency via fsockopen.

new TcpPingProbe(
    host: '8.8.8.8',
    port: 53,
    timeout: 3.0,
)

PdoProbe

Measures database connectivity by opening a PDO connection and running SELECT 1.

new PdoProbe(
    dsn: 'mysql:host=127.0.0.1;port=3306;dbname=mydb',
    username: 'root',
    password: 'secret',
    timeout: 3,
)

Supports any PDO driver: MySQL, PostgreSQL, SQLite, etc.

PhpRedisProbe

Measures Redis latency using the php-redis extension. Connects, authenticates, and runs PING.

new PhpRedisProbe(
    address: 'tcp://127.0.0.1:6379',
    username: null,
    password: null,
    timeout: 3.0,
)

S3Probe

Measures AWS S3 bucket latency by performing a HEAD request with AWS Signature V4 authentication (no SDK required).

new S3Probe(
    bucket: 'my-bucket',
    region: 'us-east-1',
    key: 'AKIAIOSFODNN7EXAMPLE',
    secret: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
    endpoint: null,         // Custom endpoint (e.g., MinIO)
    timeout: 3.0,
)

Custom Probes

Implement ProbeInterface to create your own:

use MathiasGrimm\Netwatch\Contract\ProbeInterface;
use MathiasGrimm\Netwatch\Result\ProbeResult;

class MyProbe implements ProbeInterface
{
    public function probe(): ProbeResult
    {
        $start = hrtime(true);
        // ... your logic ...
        $elapsed = (hrtime(true) - $start) / 1e6;

        return new ProbeResult(
            connectMs: $elapsed,
            requestMs: 0,
            totalMs: $elapsed,
            success: true,
        );
    }

    public function name(): string
    {
        return 'my-probe';
    }
}

CLI Reference

netwatch:run

Run probes and display latency statistics.

vendor/bin/netwatch netwatch:run [options]
Option Short Description
--config -c Config file path (default: netwatch.php)
--iterations -i Override iteration count for all probes
--probe -p Run only a specific probe by name
--sequential Run probes sequentially (default: parallel)
--json Output results as JSON
--without-results Exclude individual iteration results from JSON

netwatch:init

Generate a starter config file.

vendor/bin/netwatch netwatch:init [options]
Option Short Description
--force -f Overwrite existing netwatch.php

Output

Table Output (default)

+--------+--------+------------+----------+---------+----------+----------+----------+----------+----------+----------+
| Probe  | Status | Iterations | Failures | Metric  | Min (ms) | Max (ms) | Avg (ms) | P50 (ms) | P95 (ms) | P99 (ms) |
+--------+--------+------------+----------+---------+----------+----------+----------+----------+----------+----------+
| redis  | ok     | 10         | 0        | connect | 0.312    | 0.891    | 0.523    | 0.487    | 0.856    | 0.884    |
|        |        |            |          | request | 0.098    | 0.234    | 0.142    | 0.131    | 0.221    | 0.231    |
|        |        |            |          | total   | 0.421    | 1.102    | 0.665    | 0.618    | 1.054    | 1.092    |
+--------+--------+------------+----------+---------+----------+----------+----------+----------+----------+----------+

The Status column and the total P95 cell are colored against the probe's latency thresholds: green/plain when within them, amber at warn, red at crit or when iterations fail.

JSON Output

{
  "redis": {
    "name": "redis:tcp://127.0.0.1:6379",
    "iterations": 10,
    "stats": {
      "connect_ms": { "min": 0.312, "max": 0.891, "avg": 0.523, "p50": 0.487, "p95": 0.856, "p99": 0.884 },
      "request_ms": { "min": 0.098, "max": 0.234, "avg": 0.142, "p50": 0.131, "p95": 0.221, "p99": 0.231 },
      "total_ms":   { "min": 0.421, "max": 1.102, "avg": 0.665, "p50": 0.618, "p95": 1.054, "p99": 1.092 }
    },
    "failures": 0,
    "status": "ok",
    "thresholds": { "warn": 5, "crit": 25 },
    "over_warn": 0,
    "over_crit": 0,
    "results": [
      { "connect_ms": 0.312, "request_ms": 0.098, "total_ms": 0.421, "success": true, "error": null, "status": "ok" }
    ]
  }
}

status is ok / warn / crit (total p95 vs the thresholds) or failing (any iteration failed). Every entry in results also carries its own per-iteration status, and over_warn/over_crit count the samples at or over each threshold.

CLI JSON Output

Statistical Analysis

For each probe, Netwatch runs the configured number of iterations and computes statistics over successful runs. Three timing metrics are tracked:

Metric Description
connect_ms Time to establish the connection
request_ms Time to complete the request after connection
total_ms End-to-end latency (connect + request)

For each metric, the following statistics are computed:

Stat Description
min Minimum observed value
max Maximum observed value
avg Arithmetic mean
p50 50th percentile (median)
p95 95th percentile
p99 99th percentile

Percentiles use linear interpolation between nearest-rank values.

Testing

composer install
vendor/bin/pest

Check code style:

vendor/bin/pint --test

License

The MIT License (MIT). Please see License File for more information.