MonitorKit Documentation
MonitorKit gives PHP teams APM traces, Linux infrastructure monitoring, log aggregation, and alerting — in a single cloud platform. This guide gets you from zero to your first trace in under 5 minutes.
Quick start (5 minutes)
Step 1 — Create your account
Create a free account to get your API key. The Free tier includes 1 host and PHP APM (500 traces/day); paid plans start at $12/host/month ($25/month minimum) with a 14-day trial, no credit card required.
Step 2 — Install the agent
Run this on every Linux server you want to monitor:
curl -fsSL "https://monitorkit.co/static/install-agent.sh" | \
sudo bash -s -- \
--server https://monitorkit.co \
--api-key mk_your_key_here \
--host-name web-01
bash
The agent starts automatically and reports metrics every 30 seconds. Your server appears in the dashboard within one minute.
Step 3 — Add APM to your PHP app
Choose the approach that fits your workflow:
Option A — Zero-code install (no composer, no code changes)
A SysAdmin or DevOps can run this on the server. It works with Laravel, Symfony, WordPress, and any PHP app:
curl -fsSL "https://monitorkit.co/static/install-php-agent.sh" | \
sudo MK_SERVER=https://monitorkit.co \
MK_KEY=mk_your_key_here \
MK_SERVICE=my-app \
bash
bash
Option B — Composer package (Laravel / Symfony)
# Laravel 6–11
composer require monitorkit/laravel
# Symfony 3.4–7.x
composer require monitorkit/symfony
bash
Add your credentials to .env:
MONITORKIT_SERVER=https://monitorkit.co
MONITORKIT_KEY=mk_your_agent_key_here
MONITORKIT_SERVICE=my-app
env
Every HTTP request is traced automatically in both options.
Install the agent
The MonitorKit agent is a lightweight Python process that runs on your Linux server. It collects metrics every 30 seconds and ships them to your MonitorKit instance.
System requirements
- Linux (Debian 10+, Ubuntu 18.04+, CentOS 7+, RHEL 7+, Alpine 3.12+)
- Python 3.8+ (usually pre-installed)
- Outbound HTTPS to your MonitorKit server
- ~20MB disk, ~30MB RAM
Manual install
Run all these commands as root or prefix each one with sudo.
# 1. Create directory and download agent bundle
sudo mkdir -p /opt/monitorkit-agent
curl -fsSL "https://monitorkit.co/agent/bundle" | \
sudo tar -xzf - -C /opt/monitorkit-agent --strip-components=1
# 2. Create a virtual environment and install dependencies
sudo apt install -y python3-full
sudo python3 -m venv /opt/monitorkit-agent/venv
sudo /opt/monitorkit-agent/venv/bin/pip install psutil httpx
# 3. Create config
sudo tee /opt/monitorkit-agent/config.toml <<EOF
server_url = "https://monitorkit.co"
api_key = "mk_your_key_here"
host_name = "web-01"
interval_seconds = 30
EOF
bash
Run as a systemd service
Create a dedicated system user and give it permission to read log files:
# Create system user (no login shell, no home directory)
sudo useradd --system --no-create-home --shell /usr/sbin/nologin monitorkit
# Give it read access to log files via the adm group
sudo usermod -a -G adm monitorkit
# Give it ownership of the agent directory
sudo chown -R monitorkit:monitorkit /opt/monitorkit-agent
bash
adm group has read access to most system logs (/var/log/) on Debian/Ubuntu. On RHEL/CentOS, logs are typically owned by root:root 600 — you may need to adjust permissions manually for specific files.
Create the systemd service file:
sudo tee /etc/systemd/system/monitorkit-agent.service <<EOF
[Unit]
Description=MonitorKit Agent
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/monitorkit-agent
ExecStart=/opt/monitorkit-agent/venv/bin/python /opt/monitorkit-agent/agent.py
Restart=always
RestartSec=10
User=monitorkit
[Install]
WantedBy=multi-user.target
EOF
bash
sudo systemctl daemon-reload
sudo systemctl enable --now monitorkit-agent
sudo systemctl status monitorkit-agent
bash
Upgrading the agent
Pass --update to the installer. It auto-detects your server URL from the existing config.toml, downloads the latest agent files, updates dependencies, and restarts the service — your config is never overwritten.
curl -fsSL "https://monitorkit.co/static/install-agent.sh" | \
sudo bash -s -- --update
bash
The script runs these steps automatically:
- Stops the
monitorkit-agentservice - Backs up
config.tomlto/tmp/mk-config.toml.bak - Downloads and extracts the latest bundle from your MonitorKit server
- Restores
config.toml(the tarball never overwrites your keys) - Runs
uv syncto update Python dependencies - Restarts the service and confirms it is running
[[databases]]) are always opt-in. Upgrade first and add new config blocks at your own pace — the agent silently ignores keys it does not recognise.
If your MonitorKit instance is on a custom port or internal URL, pass --server explicitly:
curl -fsSL "https://monitorkit.co/static/install-agent.sh" | \
sudo bash -s -- --update --server https://your-instance.example.com
bash
Agent configuration reference
All options live in config.toml (in the agent directory).
| Key | Default | Description |
|---|---|---|
| server_url | — | Your MonitorKit server URL (required) |
| api_key | — | Agent API key from Settings → Agent Keys (required) |
| host_name | — | Display name for this host in the dashboard (required) |
| interval_seconds | 30 | Metric collection interval — minimum 30s, values below are ignored |
| logs.enabled | false | Enable log collection |
| logs.interval_seconds | 30 | Log shipping interval |
| logs.batch_size | 200 | Max log lines per batch |
| phpfpm.enabled | false | Enable PHP-FPM pool metrics |
| phpfpm.status_urls | ["http://127.0.0.1/fpm-status"] | PHP-FPM status URLs (one per pool) |
Full example config.toml
server_url = "https://monitorkit.co"
api_key = "mk_your_key_here"
host_name = "web-01"
interval_seconds = 30
[logs]
enabled = true
interval_seconds = 60
batch_size = 200
# Optional: only collect specific services
# services_filter = ["nginx", "php-fpm", "mysql"]
[phpfpm]
enabled = true
status_urls = ["http://127.0.0.1/fpm-status"]
toml
PHP APM — zero-code install
MonitorKit's zero-code APM uses PHP's auto_prepend_file directive to instrument your application without touching a single line of application code. A SysAdmin or DevOps engineer can complete the setup in under 2 minutes.
Requirements
- PHP 5.6 or newer (PHP 7.x and 8.x recommended)
curlorwgeton the server- PHP running under PHP-FPM (Nginx or Apache with PHP-FPM)
- Outbound HTTPS from the server to your MonitorKit instance
One-line install
Run this on the server where PHP runs. Replace the values with your own:
curl -fsSL "https://monitorkit.co/static/install-php-agent.sh" | \
sudo MK_SERVER=https://monitorkit.co \
MK_KEY=mk_your_agent_key_here \
MK_SERVICE=my-app \
bash
bash
The installer will:
- Auto-detect your PHP version
- Download the APM files to
/opt/monitorkit-apm/ - Write
/opt/monitorkit-apm/config.phpwith your credentials - Write
/etc/php/X.Y/fpm/conf.d/99-monitorkit.iniwithauto_prepend_file - Reload PHP-FPM automatically via systemd
Traces appear in the APM tab within seconds of the first request.
What ends up in 99-monitorkit.ini
Just the one directive that hooks in the agent — safe to read, no secrets:
; MonitorKit PHP APM — generated by installer 2026-01-15
; To disable: delete this file and restart PHP-FPM.
; To update config: edit /opt/monitorkit-apm/config.php
auto_prepend_file = /opt/monitorkit-apm/autoload.php
ini
Your server URL and API key live in /opt/monitorkit-apm/config.php instead (see Editing the config) — 99-monitorkit.ini only points PHP at the agent. This is also the exact output to expect from cat /etc/php/X.Y/fpm/conf.d/99-monitorkit.ini when verifying a multi-version server (see Legacy PHP, step 1) — if the file exists but looks different, or $MK_INSTALL_DIR was customized, the auto_prepend_file path won't match this default.
Optional environment variables (installer script)
These configure the install-php-agent.sh invocation itself — pass them on the same line as the curl | bash command:
| Variable | Default | Description |
|---|---|---|
| MK_SERVER | — | Your MonitorKit server URL (required) |
| MK_KEY | — | Agent API key from Settings → Agent Keys (required) |
| MK_SERVICE | php-app | Service name shown in APM traces |
| MK_INSTALL_DIR | /opt/monitorkit-apm | Where APM files are installed |
| MK_PHP_VERSION | auto-detected | PHP version to configure (e.g. 8.2) |
| MK_SKIP_RESTART | 0 | Set to 1 to skip PHP-FPM reload (reload manually after) |
MK_SAMPLE_RATE, MK_MAX_SPANS, MK_ENABLED, and MK_EXCLUDE_PATHS are a different kind of setting — the PHP agent reads them at request time, not the installer at install time. Passing them to the curl | bash line has no effect; set them in /opt/monitorkit-apm/config.php instead (see Editing the config below).
config.php to config.php.bak.<timestamp> before writing a new one from that run's MK_SERVER/MK_KEY/MK_SERVICE. Any settings you'd added by hand — MK_SAMPLE_RATE, MK_MAX_SPANS, MK_ENABLED, MK_EXCLUDE_PATHS — aren't carried forward automatically; diff the backup and re-add them.
Updating the agent
The zero-code agent is more than one file, and they only work together as a set. /opt/monitorkit-apm/ holds:
autoload.php— the entry pointauto_prepend_fileactually loads; readsconfig.phpand hands its settings tomonitorkit_apm.phpmonitorkit_apm.php— the core tracermonitorkit_doctrine.php— Doctrine 1.x listener (Symfony 1.4 / legacy stacks)hooks/laravel.php,hooks/symfony.php,hooks/wordpress.php— per-framework enrichment, loaded byautoload.phpconfig.php— your settings (MK_SERVER,MK_API_KEY, etc.) — the only file the installer won't silently overwrite (see above)
Updating only one of these (e.g. just monitorkit_apm.php to pick up a new setting or bugfix) can leave you on a version where autoload.php doesn't know how to pass that setting through yet — the files version together, not independently.
Recommended: re-run the same one-line install command from the top of this page. It re-downloads every file in the list above except config.php (backed up, not overwritten) and reloads PHP-FPM automatically:
curl -fsSL "https://monitorkit.co/static/install-php-agent.sh" | \
sudo MK_SERVER=https://monitorkit.co \
MK_KEY=mk_your_agent_key_here \
MK_SERVICE=my-app \
bash
bash
If you'd rather not touch config.php's backup/restore dance, update the same set of files by hand instead — all of them, in one pass, never just one:
# $MK_INSTALL_DIR is /opt/monitorkit-apm unless you customized it at install time
for f in monitorkit_apm.php monitorkit_doctrine.php autoload.php \
hooks/laravel.php hooks/symfony.php hooks/wordpress.php; do
sudo curl -fsSL "https://monitorkit.co/static/php-apm/$f" -o "/opt/monitorkit-apm/$f"
done
sudo systemctl reload php8.2-fpm # match your actual PHP-FPM version
bash
Uninstall
sudo rm -rf /opt/monitorkit-apm
sudo rm /etc/php/8.2/fpm/conf.d/99-monitorkit.ini
sudo systemctl reload php8.2-fpm
bash
PHP zero-code — config & frameworks
Editing the config
After install, edit /opt/monitorkit-apm/config.php to change any setting. Restart PHP-FPM after editing:
<?php
// /opt/monitorkit-apm/config.php
define('MK_SERVER', 'https://monitorkit.co');
define('MK_API_KEY', 'mk_your_key_here');
define('MK_SERVICE', 'my-laravel-app');
define('MK_SAMPLE_RATE', '0.2'); // trace 20% of requests — the default if you omit this line
define('MK_MAX_SPANS', '200'); // hard cap on spans per trace — the default if you omit this line
define('MK_ENABLED', 'true'); // set 'false' to disable without removing files
define('MK_EXCLUDE_PATHS', '#^/health$#;#^/llamadopaciente/llamadojson$#'); // semicolon-separated, see below
php
Sample rate defaults to 0.2 (20%), not 100%. A busy production service traced at 100% can generate enough sustained volume to run into your plan's trace rate limits and quotas — start conservative and raise it only for low-traffic services or short debugging windows.
Framework support
The APM auto-detects which framework is running and enriches the trace automatically:
| Framework | Route normalization | DB query tracing |
|---|---|---|
| Laravel 6–11 | ✅ /users/{id} style | ✅ via Query Log (see below) |
| Symfony 3.4–7.x | ✅ route name + params | ✅ via Doctrine DebugStack |
| WordPress | ✅ post type / taxonomy | ✅ via SAVEQUERIES (see below) |
| Plain PHP | Raw URL path | — |
Laravel — enable DB query tracing
Add ONE line to App\Providers\AppServiceProvider::boot():
// app/Providers/AppServiceProvider.php
public function boot()
{
if (env('MK_LARAVEL_DB_LOG')) {
\DB::enableQueryLog();
}
}
php
Then add to your .env:
MK_LARAVEL_DB_LOG=true
env
All Eloquent and raw DB queries will appear as spans in the APM waterfall. SQL values are automatically scrubbed to prevent PII leakage.
Symfony — enable Doctrine query tracing
Add to config/packages/doctrine.yaml:
doctrine:
dbal:
logging: '%kernel.debug%'
yaml
Or use a dedicated env var to control it independently from debug mode:
# config/packages/doctrine.yaml
doctrine:
dbal:
logging: '%env(bool:MK_DOCTRINE_LOG)%'
yaml
MK_DOCTRINE_LOG=true
env
WordPress — enable DB query tracing
Add ONE line to wp-config.php:
define('SAVEQUERIES', true);
// Or conditionally: define('SAVEQUERIES', defined('MK_ENABLED'));
php
WordPress stores all queries in $wpdb->queries. MonitorKit reads them at shutdown and forwards them as DB spans. The constant adds a small memory overhead; only enable it if you need query-level visibility.
Sampling (high-traffic apps)
To reduce overhead on high-traffic servers, set a sample rate below the 0.2 default in config.php:
define('MK_SAMPLE_RATE', '0.1'); // trace 10% of requests
php
You generally don't need to hand-tune this to avoid overloading MonitorKit itself — the server enforces its own rate limits and per-plan trace quotas and will automatically downsample a host that sends more than its plan allows, rather than dropping your traces entirely or failing requests. Lowering MK_SAMPLE_RATE is about reducing overhead on your app and getting a more predictable, representative sample — not about protecting MonitorKit.
Excluding specific paths
Set MK_EXCLUDE_PATHS to skip specific routes entirely — a health-check endpoint an uptime monitor pings every 15-30 seconds, for example, which otherwise counts against your sample rate and trace quota for zero diagnostic value:
define('MK_EXCLUDE_PATHS', '#^/health$#;#^/llamadopaciente/llamadojson$#');
php
Semicolon-separated PCRE patterns (each one a full regex, delimiters included) — a comma isn't used as the separator since regex patterns routinely contain commas themselves. An excluded path is checked before the sample-rate roll, so it never counts against MK_SAMPLE_RATE either — this is the same mechanism as exclude_paths in the Laravel/Symfony packages, just PHP-array-config there instead of one semicolon-joined string (a plain constant is simpler to express as an env var than an array).
Send timeout & local circuit breaker
Each sampled trace is sent with a short, fixed timeout (300ms to connect, 800ms total) — not configurable, by design. Even though fastcgi_finish_request() already lets PHP-FPM return the response to the client before the trace is sent, the worker process itself stays busy for however long that send takes. If the MonitorKit server is slow rather than down — which can happen under a sustained burst of trace volume from your own high-traffic app — a longer timeout would tie up more of your pm.max_children pool per request than the actual page render did, and enough of that can exhaust the pool and stall unrelated requests, including logins.
To guard against that, each host keeps a small local circuit breaker (a file under the system temp directory, no extra service required): after 3 consecutive sends that either fail outright or take 600ms or longer — even with a 200 OK — the agent stops attempting sends for 15 seconds. Traces are dropped (not queued) during that window; this trades a short gap in trace coverage for keeping your app responsive. The Laravel and Symfony packages use the same mechanism, deriving the connect timeout and slow threshold from their own timeout config setting (env MONITORKIT_TIMEOUT, default 2 seconds) instead of the fixed 800ms.
How a trace gets marked as an error
Three independent signals feed into error / error_message on the root span, so a failure gets flagged even if only one of them fires:
- HTTP status ≥ 500 — read via
http_response_code()at shutdown. - A PHP warning or notice during the request, via
set_error_handler(). - A fatal error —
E_ERROR,E_PARSE,E_CORE_ERROR,E_COMPILE_ERROR(uncaught exceptions included, since PHP treats those asE_ERRORonce nothing catches them).set_error_handler()never fires for these — that's a PHP engine restriction, not a bug — so the agent separately checkserror_get_last()at shutdown. This matters in practice: a truly fatal error doesn't always leave the HTTP status at 500 by the time the script dies, so the status-code check alone would miss it. On a legacy PHP 5.6 codebase, this class of error (undefined method on null, memory exhaustion, a typo'd function call) is common enough that skipping it would blind exactly the traces you most want to see when debugging a crash.
Legacy PHP — Symfony 1.4, Doctrine 1.x & multi-version servers
The zero-code installer (install-php-agent.sh) works identically on PHP 5.6+ legacy stacks — it's the same auto_prepend_file mechanism, and route naming for Symfony 1.4 is automatic (see step 2 below). What doesn't carry over is the DB query enrichment in hooks/*.php, which only recognizes namespaced, modern frameworks — Laravel 6–11 and Symfony 3.4–7.x. A Symfony 1.4 app (or any pre-namespace framework: CodeIgniter 2, CakePHP 1.x, Zend 1) is invisible to that detection, so DB spans stay empty until you add the manual hook in step 3.
1. Confirm you're configuring the PHP-FPM pool that's actually serving the app
Servers with a control panel (RunCloud, CloudPanel, Ploi) or a hand-rolled multi-PHP setup often run several PHP-FPM versions side by side. The installer detects the version by asking the php CLI binary — this can be a different version than the FPM pool that actually serves your site. The .ini the installer writes only takes effect for the version it detected.
Verify the match before assuming the install failed silently:
# 1. Find the fastcgi socket your vhost actually uses
grep fastcgi_pass /etc/nginx/sites-enabled/your-site
# → fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; (example: PHP 7.4, not whatever `php -v` reports)
# 2. Confirm the MonitorKit ini landed in that exact version's conf.d
cat /etc/php/7.4/fpm/conf.d/99-monitorkit.ini
bash
If the versions don't match, re-run the installer with MK_PHP_VERSION set explicitly:
curl -fsSL "https://monitorkit.co/static/install-php-agent.sh" | \
sudo MK_SERVER=https://monitorkit.co \
MK_KEY=mk_your_agent_key_here \
MK_SERVICE=my-legacy-app \
MK_PHP_VERSION=7.4 \
bash
bash
2. Symfony 1.4 — route naming (automatic)
No setup needed: at shutdown, the agent checks whether sfContext has an instance and, if so, resolves the module/action/route itself — renaming the root span to something like GET facturacion/verFactura (route_name) and tagging it with sf.module / sf.action / sf.route. Without this, traces would only show the raw request path (e.g. GET /index.php).
You only need to call this yourself if you want the name resolved before shutdown (e.g. to use it in your own logging mid-request), or to override it with custom naming:
MonitorKit\APM::setSymfonyContext(sfContext::getInstance());
php
3. Symfony 1.4 + Doctrine 1.x — DB query tracing
MonitorKit ships a dedicated Doctrine_EventListener for Doctrine 1.x. Add it via configureDoctrineConnection() in config/ProjectConfiguration.class.php:
// config/ProjectConfiguration.class.php
public function configureDoctrineConnection(Doctrine_Connection $connection)
{
require_once '/opt/monitorkit-apm/monitorkit_doctrine.php';
$connection->addListener(new MonitorKit\DoctrineListener());
}
php
configureDoctrineConnection(), not configureDoctrine(). sfDoctrineDatabase::initialize() attaches its own listener chain directly to the Doctrine_Connection object before your code runs; once a connection has its own chain, Doctrine_Manager::addListener() is silently ignored for every query. configureDoctrineConnection($connection) is the hook Symfony 1.4 calls right after attaching the built-in listeners, so $connection->addListener() joins the existing chain and actually fires.
This catches all three Doctrine 1.x query paths — Doctrine_Connection::query(), ::exec(), and prepared-statement execute() (DQL) — and reports each as a db span with the SQL statement tagged as db.statement.
4. Legacy apps without an ORM (raw PDO)
If the app talks to the database through plain PDO (no Doctrine, no Propel), wrap the connection once where it's created:
$pdo = MonitorKit\APM::wrapPDO(new PDO($dsn, $user, $pass));
// use $pdo exactly as before — query(), prepare(), exec() are all traced
php
5. Cache tracing (Redis, Memcache(d), APC, Doctrine cache, sfCache)
Unlike DB tracing, there's no per-backend listener — cache clients don't share a common interface across Redis/Memcache/APC/Doctrine_Cache_*/sfCache, so MonitorKit ships one generic wrapper that traces by matching the method name being called against known verbs (get/fetch/has… for reads, set/save/add… for writes, delete/remove/flush… for deletes). Anything else passes through untraced, so it's safe to wrap any cache object:
$cache = MonitorKit\APM::wrapCache($yourRedisOrMemcacheOrWhatever, 'redis');
// use $cache exactly as before — recognised methods are traced automatically
$cache->get('diagnostico_123');
$cache->set('diagnostico_123', $value);
$cache->delete('diagnostico_123');
php
The second argument to wrapCache() is just a label shown in the span name and the cache.backend tag (e.g. "redis", "memcached", "doctrine-cache") — it defaults to the wrapped object's class name if omitted. Spans appear in the waterfall as cache type, tagged with cache.operation, cache.backend, and cache.key (when the first argument to the call is a scalar key).
Doctrine_Cache_* drivers, Symfony 1.4's sfCache subclasses, raw Memcache/Memcached/Redis extension objects, or any custom cache class — as long as it exposes get/set/delete-style methods.
Troubleshooting checklist
If traces still don't appear after installing on a legacy stack, work through these in order — this is the exact path that resolves most cases:
- Confirm the install actually downloaded all files (no 404s) — a partial download aborts before
config.phpor the FPM.iniare ever written. - Confirm the FPM version/pool match from step 1 above — the most common silent failure on multi-version servers.
- Generate a real request and check the response body, not just the HTTP status — a fatal error in the prepend file can still return 200 with a broken/short body.
- Send a manual trace to
/api/traceswith your agent key (see API reference) — if that succeeds but your app's real traces don't, the problem is in the app-side prepend, not the server. - Check that the APM plugin is enabled under Settings → Plugins. APM itself is included on every plan, Free included — but the Free tier's budget is only 500 traces/day, so a busy service will be heavily downsampled (see Rate limits & quotas).
- For DB spans specifically: confirm you added the Doctrine or PDO hook above — auto-detection does not cover Symfony 1.4 or other pre-namespace frameworks.
Laravel APM — quickstart
Compatible with: Laravel 6, 7, 8, 9, 10, 11 · PHP 7.4, 8.0–8.3
1. Install the package
composer require monitorkit/laravel
bash
Laravel auto-discovers the service provider — no manual registration needed.
2. Add environment variables
MONITORKIT_SERVER=https://monitorkit.co
MONITORKIT_KEY=mk_your_agent_key_here
MONITORKIT_SERVICE=my-laravel-app
MONITORKIT_ENABLED=true
env
3. (Optional) Publish config
php artisan vendor:publish --tag=monitorkit-config
bash
What's traced automatically
| What | Span type | Details |
|---|---|---|
| HTTP request | web | Method, path, route name, status code, duration |
| Eloquent / DB queries | db | SQL statement, connection, duration. Slow-query threshold configurable. |
| Queue jobs | worker | Job class name, queue, success/failure, duration |
| Exceptions | — | Marks root trace as error with exception class + message |
Laravel — config reference
| Variable | Default | Description |
|---|---|---|
| MONITORKIT_SERVER | — | MonitorKit server URL (required) |
| MONITORKIT_KEY | — | Agent API key (required) |
| MONITORKIT_SERVICE | APP_NAME | Service name shown in traces |
| MONITORKIT_ENABLED | true | Set false to disable in local/CI |
| MONITORKIT_SAMPLE_RATE | 0.2 | Fraction of requests to trace (0.1 = 10%) |
| MONITORKIT_SLOW_QUERY_MS | 0 | Only trace DB queries slower than N ms (0 = all) |
| MONITORKIT_MAX_SPANS | 200 | Max child spans per trace |
| MONITORKIT_TIMEOUT | 2 | HTTP timeout for sending traces (seconds) — also bounds the local circuit breaker's connect timeout and slow-response threshold |
Exclude paths from tracing
Add patterns to config/monitorkit.php (after publishing):
return [
'exclude_paths' => [
'#^/health$#',
'#^/horizon#',
'#\.(js|css|png|ico)$#i',
],
];
php
Disable in test environment
# .env.testing
MONITORKIT_ENABLED=false
env
Laravel — manual instrumentation
Use the MonitorKit facade for code sections not traced automatically:
use MonitorKit\Laravel\MonitorKit;
// Wrap a callable — auto-finishes even on exception
$charge = MonitorKit::trace('stripe.charge', function () use ($amount) {
return $this->stripe->charges->create(['amount' => $amount]);
}, 'payment');
// Start/finish manually
$span = MonitorKit::startSpan('cache.warm', 'cache');
$this->warmCache();
MonitorKit::finishSpan($span);
// Mark as error
try {
$result = $this->riskyOperation();
MonitorKit::finishSpan($span);
} catch (\Exception $e) {
MonitorKit::finishSpan($span, true, $e->getMessage());
throw $e;
}
php
Symfony APM — quickstart
Compatible with: Symfony 3.4, 4.x, 5.x, 6.x, 7.x · PHP 7.4, 8.0–8.3
1. Install the package
composer require monitorkit/symfony
bash
2. Register the bundle
Symfony 4+ (Flex) — auto-registered. Skip this step.
Symfony 3.4 / manual — add to app/AppKernel.php:
public function registerBundles(): array
{
return [
// ...
new \MonitorKit\Symfony\MonitorKitBundle(),
];
}
php
Symfony 4+ without Flex — add to config/bundles.php:
return [
// ...
MonitorKit\Symfony\MonitorKitBundle::class => ['all' => true],
];
php
3. Create config file
Create config/packages/monitorkit.yaml:
monitorkit:
server: "%env(MONITORKIT_SERVER)%"
api_key: "%env(MONITORKIT_KEY)%"
service: "%env(default:kernel.project_dir:MONITORKIT_SERVICE)%"
enabled: "%env(bool:default::MONITORKIT_ENABLED)%"
yaml
4. Add environment variables
MONITORKIT_SERVER=https://monitorkit.co
MONITORKIT_KEY=mk_your_agent_key_here
MONITORKIT_SERVICE=my-symfony-app
MONITORKIT_ENABLED=true
env
doctrine/dbal ^2.0 (via a SQLLogger) and ^3.0 (via a doctrine.middleware-tagged service — requires doctrine/doctrine-bundle ^2.4+ to auto-collect it; see troubleshooting if you're on an older bundle version).
What's traced automatically
| What | Requires |
|---|---|
| HTTP request (start + terminate) | Always |
| Doctrine DBAL queries | doctrine/dbal ^2.0 or ^3.0 |
| Unhandled exceptions | Always |
| Messenger messages | symfony/messenger ^4.3 |
Symfony — config reference
| Option | Default | Description |
|---|---|---|
| server | — | MonitorKit server URL (required) |
| api_key | — | Agent API key (required) |
| service | symfony-app | Service name in traces |
| enabled | true | Disable in test/CI |
| sample_rate | 0.2 | Fraction of requests to trace |
| slow_query_threshold_ms | 0 | Only trace DB queries slower than N ms |
| max_spans | 200 | Max child spans per trace |
| timeout | 2 | HTTP timeout for sending traces (seconds) — also bounds the local circuit breaker's connect timeout and slow-response threshold |
| exclude_paths | ['#^/_profiler#', '#^/_wdt#', …] | Regex patterns to skip |
Disable in test environment
# config/packages/test/monitorkit.yaml
monitorkit:
enabled: false
yaml
Symfony — manual instrumentation
use MonitorKit\Symfony\MonitorKit;
// Wrap a callable
$result = MonitorKit::trace('stripe.charge', fn() => $this->stripe->charge($amount));
// Manual span
$span = MonitorKit::startSpan('redis.warm', 'cache');
$this->warmCache();
MonitorKit::finishSpan($span);
php
APM stats & percentiles
The Stats sub-tab inside the APM view gives you an aggregated picture of your application's performance over the last 24 hours — without having to browse individual traces.
Summary cards
| Card | What it shows |
|---|---|
| Total requests | All traces received in the selected window |
| Error rate | % of traces with error = true |
| Median (P50) | Half of requests finish faster than this |
| P95 latency | 95th-percentile response time — the "slow tail" |
Endpoint breakdown
Below the summary cards, a table groups traces by HTTP method + path and shows per-endpoint percentiles:
| Column | Meaning |
|---|---|
| Requests | Call count in the window |
| P50 / P95 / P99 | Latency percentiles in ms |
| Error % | Fraction of requests that returned an error |
Sort by any column to surface your slowest or most error-prone endpoints first.
Slow queries
At the bottom of the Stats tab, the Slow queries table lists the 20 slowest individual DB spans across all traces — normalized SQL statement, total calls, average duration, and the worst single run.
PHP-FPM pool metrics
MonitorKit can collect active/idle workers, listen queue depth, slow requests, and requests per second from PHP-FPM's built-in status endpoint.
1. Enable the status page in PHP-FPM
Edit your pool config (e.g. /etc/php/8.2/fpm/pool.d/www.conf):
; Add this line
pm.status_path = /fpm-status
ini
2. Expose via nginx
Add a location block to your nginx server block:
location = /fpm-status {
allow 127.0.0.1;
deny all;
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000; # or unix:/run/php/php8.2-fpm.sock
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
}
nginx
3. Reload services
systemctl reload php8.2-fpm
systemctl reload nginx
# Test: should return JSON
curl "http://127.0.0.1/fpm-status?json"
bash
4. Enable in agent config
[phpfpm]
enabled = true
status_urls = ["http://127.0.0.1/fpm-status"]
# Multiple pools:
# status_urls = ["http://127.0.0.1/fpm-status", "http://127.0.0.1/fpm-status-api"]
toml
Restart the agent and navigate to the PHP-FPM tab in your host modal to see pool metrics.
Database monitoring
MonitorKit can monitor MySQL and PostgreSQL instances and display key health metrics in the Databases view: connectivity, response time, active connections, queries per second, buffer/cache hit ratio, and replication lag.
Metrics are collected by the agent running on the same server as your database (or any server that can reach it). The agent checks TCP connectivity on every cycle and — when the mysql or psql CLI is available — collects the full metric set.
1. Create a read-only monitoring user
Use a dedicated, read-only user. The grants below cover health metrics, the Sessions tab, and the Top Queries tab.
MySQL
The host part of the user ('localhost' vs '%') must match how the agent connects. Use 'localhost' when the agent runs on the same server as MySQL; use '%' (or the agent's IP) when connecting from a remote server.
-- Run as root or a privileged MySQL user
-- Agent on the same server as MySQL:
CREATE USER 'monitorkit'@'localhost' IDENTIFIED BY 'strong-password';
-- OR: agent on a remote server (replace % with the agent IP for tighter security):
-- CREATE USER 'monitorkit'@'%' IDENTIFIED BY 'strong-password';
-- Core metrics: global status + replication info
-- PROCESS also enables the Sessions tab (see other users' connections)
GRANT PROCESS, REPLICATION CLIENT ON *.* TO 'monitorkit'@'localhost';
-- Access to the monitored database(s)
GRANT SELECT ON your_database.* TO 'monitorkit'@'localhost';
-- Top Queries view: read access to performance_schema
GRANT SELECT ON performance_schema.* TO 'monitorkit'@'localhost';
FLUSH PRIVILEGES;
sql
ERROR 1410: You are not allowed to create a user with GRANT, the host you typed doesn't match any existing user. Run SELECT user, host FROM mysql.user WHERE user = 'monitorkit'; to see the exact host and use that in your GRANT statements.
PostgreSQL
pg_stat_statements hooks into shared memory at server start, so it must be loaded via shared_preload_libraries and the server restarted before CREATE EXTENSION will actually produce data — running CREATE EXTENSION alone, without the restart, leaves the Top Queries tab empty with no error. Do the config change + restart first.
-- 1. Edit postgresql.conf (find its path with `SHOW config_file;` if unsure)
-- Uncomment/add this line, then restart PostgreSQL — a reload is not enough:
shared_preload_libraries = 'pg_stat_statements'
ini
systemctl restart postgresql
bash
-- 2. Run as the postgres superuser, after the restart above
CREATE USER monitorkit WITH PASSWORD 'strong-password';
-- pg_monitor role gives read access to all stats views including pg_stat_statements
-- and enables the Sessions tab (pg_stat_activity)
GRANT pg_monitor TO monitorkit;
-- Top Queries view: create the extension (requires the restart above to have any effect)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
sql
localhost with % (or the agent server's IP) in the CREATE USER and all GRANT statements, and ensure the DB port is reachable from the agent.
2. Add [[databases]] blocks to agent config
Open config.toml and add one [[databases]] block per instance. Note the double brackets — TOML uses them to define an array of tables.
MySQL example
[[databases]]
type = "mysql"
host = "127.0.0.1"
port = 3306
user = "monitorkit"
password = "strong-password"
dbname = "app" # optional, shown in the dashboard
label = "Production MySQL"
toml
PostgreSQL example
[[databases]]
type = "postgresql"
host = "127.0.0.1"
port = 5432
user = "monitorkit"
password = "strong-password"
dbname = "app"
label = "Production PostgreSQL"
toml
You can add as many blocks as you need — one per monitored instance.
Optional — collection intervals (at the top level, not inside a [[databases]] block):
# Health metrics interval (connections, QPS, cache hit, etc.)
# default 60 s, minimum 60 s — values below are ignored
db_interval_seconds = 120
# Top Queries collection interval — minimum 300 s (5 min), default 300 s
# Increasing either interval reduces storage usage. Values below the minimum are ignored.
db_queries_interval_seconds = 600
toml
3. Restart the agent
systemctl restart monitorkit-agent
bash
After the first collection cycle the instance appears in the Databases view. Click a card to open the detail panel with three tabs: Overview (sparkline charts), Sessions (live active connections), and Top Queries.
Metrics collected
| Metric | MySQL | PostgreSQL |
|---|---|---|
| TCP connectivity + response time | ✅ | ✅ |
| Active connections | ✅ Threads_connected | ✅ numbackends |
| Max connections | ✅ max_connections | ✅ max_connections |
| Queries / sec | ✅ delta on Queries | ✅ delta on xact_commit |
| Slow queries / sec | ✅ delta on Slow_queries | — |
| Buffer / cache hit ratio | ✅ InnoDB buffer pool | ✅ shared buffers |
| Replication lag (seconds) | ✅ SHOW SLAVE STATUS | ✅ replay timestamp delta |
| Active sessions (PID, user, state, query) | ✅ information_schema.PROCESSLIST | ✅ pg_stat_activity |
| Top 20 queries by total time | ✅ performance_schema | ✅ pg_stat_statements |
mysql or psql CLI on the agent server. Without the CLI, MonitorKit still shows the instance as online with its response time.
Sessions tab
The Sessions tab shows the currently active database connections, refreshed every 60 seconds. Each row represents one open connection:
| Column | Description |
|---|---|
| PID | Process / connection ID on the database server. |
| User | The database user that opened the connection. |
| DB | The database (schema) the connection is using. |
| State | Color-coded: active (running a query), idle in transaction (open transaction, not running), Lock wait / waiting (blocked), dimmed = idle. |
| Duration | How long the current command has been running. |
| Query | The last or current SQL statement (truncated at 300 chars). |
PROCESS privilege (already included in Step 1) is required to see connections from other users. Without it, only the monitoring user's own brief connection is visible, so the tab appears empty. PostgreSQL: The
pg_monitor role (Step 1) grants access to pg_stat_activity for all users.
Sessions are collected on every metrics cycle (default 60 s) and stored as a live snapshot — only the most recent state is kept, not a time-series. This means the Sessions tab is useful for diagnosing current slowdowns, lock waits, and idle-in-transaction connections, not historical analysis.
Top Queries tab
The Top Queries tab in the database detail panel shows the 20 queries with the highest total execution time, refreshed every 5 minutes. Columns shown:
| Column | Description |
|---|---|
| Query | Normalized query text (literals replaced by placeholders). Hover or click to expand. |
| Calls | Total number of times the query was executed. |
| Avg (ms) | Average execution time. Color-coded: green <100 ms, amber <500 ms, red ≥500 ms. |
| Max (ms) | Worst-case single execution time. |
| Rows | Rows examined (MySQL) or rows returned (PostgreSQL) per execution. |
Top query data is stored once every 5 minutes per instance (not on every metrics cycle) to keep storage usage low — roughly 20 MB/month per monitored database.
Troubleshooting
Instance doesn't appear after restarting the agent
- Check the agent log for lines starting with
db[…]:journalctl -u monitorkit-agent -f | grep db - Verify the port is reachable:
nc -zv 127.0.0.1 3306 - Test auth manually:
mysql -u monitorkit -p -h 127.0.0.1orpsql -U monitorkit -h 127.0.0.1 -d app
Metrics show only response time, not QPS or connections
- Install the CLI client:
apt install mysql-clientorapt install postgresql-client - Verify the monitoring user has the grants from Step 1.
Sessions tab shows no sessions
- MySQL: The monitoring user needs the
PROCESSprivilege:GRANT PROCESS ON *.* TO 'monitorkit'@'localhost';— without it only the user's own connections are visible, which are too brief to appear. - Verify the privilege was granted with the correct host:
SELECT user, host FROM mysql.user WHERE user = 'monitorkit';— the host in your GRANT must match exactly. - If there are genuinely no persistent connections (e.g. only the monitoring agent connects and disconnects), the tab will be empty. Sessions are useful when an application server maintains a connection pool.
Top Queries tab shows "No query data yet"
- MySQL: grant
SELECT ON performance_schema.*(see Step 1) and verifyperformance_schema = ONinmy.cnf. Test with:mysql -u monitorkit -p -e "SELECT COUNT(*) FROM performance_schema.events_statements_summary_by_digest;" - PostgreSQL: add
pg_stat_statementstoshared_preload_librariesinpostgresql.confand restart PostgreSQL first — only then runCREATE EXTENSION IF NOT EXISTS pg_stat_statements;. RunningCREATE EXTENSIONbefore the restart leaves the tab empty with no error. Verify with:SELECT * FROM pg_extension WHERE extname = 'pg_stat_statements'; - Data appears after the first 5-minute query collection cycle. Check logs for:
journalctl -u monitorkit-agent -f | grep db_queries
Log collection
The agent tails common log files and ships them to MonitorKit, where you can search and filter from the Logs tab in any host modal.
Supported services
nginx, Apache, MySQL (slow query log), PHP-FPM, Redis, PostgreSQL, supervisor, syslog, auth log.
Enable in agent config
[logs]
enabled = true
interval_seconds = 60 # ship logs every 60s
batch_size = 200 # max lines per batch
toml
File permissions
The agent user must be able to read the log files. For protected logs (e.g. /var/log/auth.log):
# Add agent user to adm group (Debian/Ubuntu)
usermod -a -G adm monitorkit
# Or set file permissions
chmod 644 /var/log/nginx/error.log
bash
Infrastructure map
The Infrastructure tab lets you build a live visual diagram of your stack — servers, databases, caches, queues, load balancers, and more — with health status reflected in real time.
Node types
Drag any node type from the left palette onto the canvas — the icon shown here is exactly what renders on the canvas. MonitorKit supports 13 types:
| Type | Icon | Typical use |
|---|---|---|
| Server / VM | Linux host monitored by the agent | |
| App / Service | Internal microservice or worker | |
| Container | Docker container or pod | |
| Database | MySQL, PostgreSQL, MariaDB | |
| Cache / Redis | Redis, Memcached | |
| Queue / MQ | RabbitMQ, SQS, Beanstalk | |
| Load Balancer | nginx, HAProxy, AWS ALB | |
| CDN | Cloudflare, Fastly, CloudFront | |
| DNS | Nameserver or resolver | |
| Firewall | pfSense, iptables, WAF | |
| API Gateway | Third-party integrations behind an API | |
| External SaaS | Stripe, SendGrid, or any third-party service you depend on | |
| Custom | Anything else — label it yourself |
Linking a node to a host
Click a node to open its edit panel. Set Host name to the exact name of a MonitorKit host to inherit its live health status (green / amber / red). Nodes with no linked host stay gray.
Connectors
Click Edit mode, then drag from one node to another to create a connector. Click the pencil (✎) that appears on hover to open the connector editor:
| Option | Values | Description |
|---|---|---|
| Label | Free text | Optional annotation on the line (e.g. "HTTPS 443") |
| Line style | Curved · Straight · Elbow | Elbow routes the line with a right-angle bend |
| Dash style | Solid · Dashed · Dotted | Use dashed/dotted for async or optional connections |
| Direction | None · Forward · Backward · Both | Arrow markers showing data flow direction |
| Flow animation | On / Off | Animates dashes along the connector to visualise live traffic |
Moving connector endpoints (anchor points)
When two connectors leave the same node they can overlap. In Edit mode, hover a connector to reveal small violet circles at each endpoint — drag them to any point on the node border to reposition the anchor. Double-click an anchor to reset it to the default position.
Adjusting curved connectors
Curved connectors expose a central control handle in edit mode. Drag it to reshape the bezier arc. The edit/delete buttons follow the visual midpoint of the curve, not the control point.
URL health checks
In the node editor, set a Check URL on any node. MonitorKit pings it every 2 minutes; the node border turns red if the check fails. Useful for services that don't run a MonitorKit agent (third-party APIs, internal admin panels, etc.).
Uptime monitoring
MonitorKit polls a target on an interval you choose (1/2/5/10 minutes) and tracks response time, status, and availability percentage. Two monitor types are supported: HTTP(S) (any publicly-reachable URL) and TCP port (a raw host:port connect check, for services with no HTTP endpoint — a database port, an SSH daemon, a custom TCP service).
How many monitors you get
Each paid tier includes an allowance that scales with your host tier — 10 monitors (1–5 hosts), 25 (6–20), 50 (21–50), 100 (51+). The Free tier includes 1. Need more? An Uptime Monitor Pack adds 25 monitors for +$10/month (flat, not per host) and they stack. Your current usage against the allowance is shown on the Billing page; creating a monitor past it returns a 402 pointing at /billing.
Adding a monitor
- Go to Dashboard → Uptime
- Click Add monitor
- Choose the monitor type — HTTP(S) (target must start with
http://orhttps://) or TCP Port (target is a barehost:port, e.g.db.internal:5432) - Save — first check runs within the configured interval
HTTP(S) check options
These only apply to HTTP(S) monitors — a TCP monitor only checks that the port accepts a connection, so status code, keyword, and SSL options don't apply to it.
| Option | Description |
|---|---|
| Expected status | Require an exact HTTP status code. Leave at 0 to accept any 2xx/3xx response. |
| Keyword check | Optional substring the response body must contain — catches a page that returns 200 but renders an error/maintenance message. |
| SSL expiry warning (days) | For https:// targets, warns before the certificate expires. Default 14 days — every new HTTPS monitor is covered automatically, no extra setup. Checked hourly (certificate lifetimes move in weeks/months, not seconds), independent of the regular up/down check interval. |
Alert if slower than (ms)
Set a response-time threshold (in milliseconds) to get a separate alert when the target is reachable but slow — independent of the up/down status. A slow-but-200-OK response still counts as "up" for uptime%; the slow alert flips once when response time crosses the threshold, and once more when it drops back below it (no repeat alerts while it stays slow). Leave blank to disable.
Status indicators
| Status | Meaning |
|---|---|
| Up | HTTP: 2xx/3xx (or your configured expected status) and keyword match, if set. TCP: port accepted a connection. |
| Down | Non-matching response, connection timeout, or (TCP) connection refused/timeout. |
| Pending | First check not yet completed |
Alerts on downtime, slow responses, and SSL expiry
Each monitor has its own alert email field (defaults to the org owner's email if left blank) — uptime alerts are sent directly from the monitor, not through a separate alert rule. MonitorKit emails on a down→up/up→down status flip, a slow-response threshold crossing/recovery, and an SSL expiry warning. If Slack or PagerDuty are configured under Settings → Plugins, uptime/slow/SSL alerts fan out to those channels too, the same as threshold and host-offline alerts.
Creating alert rules
Go to Dashboard → Alerts → New rule. MonitorKit has four alert types — pick the one that matches what you want to watch.
Threshold
Fires when a metric crosses a threshold and recovers when it drops back below. Applies to one host, or all hosts (*).
| Metric | Description | Typical threshold |
|---|---|---|
| cpu_percent | CPU usage % | > 85 for 2+ intervals |
| memory_percent | RAM usage % | > 90 |
| disk_percent | Disk usage % (root mount) | > 80 |
| load_1m | 1-minute load average | > number of vCPUs on that host |
| load_5m | 5-minute load average | > number of vCPUs on that host |
| network_rx_bytes_sec | Inbound bandwidth (entered/shown in MB/s) | Depends on NIC |
| network_tx_bytes_sec | Outbound bandwidth (entered/shown in MB/s) | Depends on NIC |
uptime), not normalized per core — compare it against the number of vCPUs on that specific host to pick a sensible threshold. Network thresholds are typed and displayed in MB/s; stored/evaluated internally as raw bytes/sec.
Host Offline
Fires when a host stops sending metrics for more than 5 minutes. Recovers automatically once the agent reports again.
Service Down
Fires when a named systemd service (e.g. nginx, mysql) disappears from a host's running-services list. Recovers when the service is seen running again.
APM Error Rate
Fires when a PHP APM service's error rate crosses a threshold (%), measured over a rolling 15-minute window of traces — the same error flag the APM Stats tab uses, so this never disagrees with what you see there. Needs at least 20 requests in that window to evaluate; below that the rule is skipped rather than treated as healthy.
Alert lifecycle
- Firing — condition met. Notification sent immediately.
- Cooldown — no repeat notification until cooldown period (default 30 min) passes.
- Resolved — condition clears. Recovery notification sent.
The Alerts tab refreshes automatically every 30 seconds while open, so a rule firing shows up without reloading the page.
Email alerts (SMTP)
MonitorKit sends alert notifications via any SMTP server. Set these in server/.env:
SMTP_HOST=smtp.your-provider.com
SMTP_PORT=587
SMTP_USER=your-smtp-user
SMTP_PASSWORD=your-smtp-password
[email protected]
SMTP_USE_TLS=true
env
smtp.sendgrid.net, user apikey), Mailgun, Postmark, or your own mail server. Leave SMTP_HOST empty to disable email alerts.
Once configured, enter any email address in the recipient field when creating an alert rule.
Slack webhooks
Go to Dashboard → Settings → Plugins → Slack and paste your webhook URL.
Create a Slack webhook
- Go to api.slack.com/apps → Create New App → From scratch
- Enable Incoming Webhooks
- Click Add New Webhook to Workspace, choose your channel
- Copy the webhook URL (starts with
https://hooks.slack.com/services/...)
Paste the URL in the Slack plugin settings. All alert firing and recovery events will be posted to that channel.
PagerDuty
Route alerts to an on-call rotation instead of — or in addition to — email/Slack. Included in every paid plan at no extra cost (it was a $15/mo add-on until 2026-07-26). Configure from Dashboard → Settings → Plugins.
PagerDuty
- In PagerDuty, go to the service you want alerts to land on → Integrations → Add Integration → Events API v2
- Copy the Integration Key (also called the routing key)
- Paste it into the PagerDuty plugin's Routing Key field. Optionally set Severity (
critical,error,warning, orinfo— defaultcritical)
MonitorKit sends a trigger event on firing and a matching resolve event on recovery, so the incident auto-resolves in PagerDuty when the underlying condition clears.
Daily digest
MonitorKit emails a daily summary to all organization members every morning. The digest covers the last 24 hours across all your hosts.
What's included
- Overall uptime % per host
- Peak CPU and memory for each host
- Top APM error endpoints (method, path, error count)
- Alert rules that fired during the period
Configure the delivery time
Set the UTC hour in server/.env:
DAILY_DIGEST_HOUR=8 # sends at 08:00 UTC every day
env
The default is 8 (08:00 UTC). Set to an empty string or remove the variable to disable digests entirely.
SMTP_HOST is not set, digests are silently skipped.
API reference
The MonitorKit server exposes a REST API. Agent endpoints use X-API-Key header authentication; dashboard endpoints use JWT session cookies.
Authentication
# Agent endpoints — include in every request
X-API-Key: mk_your_agent_key_here
http
POST /api/metrics
Ship infrastructure metrics from the agent.
{
"host_name": "web-01",
"hostname": "web-01.example.com",
"ip_address": "10.0.0.1",
"os_info": "Ubuntu 22.04",
"cpu_percent": 12.5,
"memory_percent": 67.2,
"memory_used_mb": 5432.1,
"memory_total_mb": 8192.0,
"disk_usage": "[{\"mountpoint\":\"/\",\"used_gb\":45.2,\"total_gb\":100.0,\"percent\":45.2}]",
"network_rx_bytes_sec": 12345.6,
"network_tx_bytes_sec": 5432.1,
"load_1m": 0.42,
"load_5m": 0.38,
"load_15m": 0.31,
"top_processes": "[{\"pid\":1234,\"name\":\"php-fpm\",\"cpu_percent\":3.2,\"memory_percent\":1.1}]",
"php_fpm_metrics": "[{\"pool\":\"www\",\"active\":2,\"idle\":3,\"total\":5,\"listen_queue\":0}]"
}
json
POST /api/traces
Ship an APM trace from a PHP APM package.
{
"trace_id": "a1b2c3d4e5f6",
"service_name": "my-laravel-app",
"host_name": "web-01",
"started_at": 1718000000.123,
"duration_ms": 212.4,
"http_method": "POST",
"http_path": "/api/orders",
"http_status": 201,
"error": false,
"error_message": "",
"spans": [
{
"span_id": "ab12cd34",
"parent_span_id": null,
"name": "SELECT * FROM products WHERE id IN (?)",
"type": "db",
"started_at": 1718000000.130,
"duration_ms": 42.1,
"error": false,
"tags": {"db.statement": "SELECT * FROM products WHERE id IN (?)"}
}
]
}
json
Response is always 201 on a well-formed, authenticated request — including when the trace is dropped by sampling or quota enforcement ({"status": "sampled_out"}) or is a duplicate trace_id ({"status": "duplicate"}), so APM packages never treat these as failures worth retrying. A 429 with a Retry-After header means you've hit a hard burst limit (see Rate limits & quotas) — the infrastructure agent already honors it automatically, and the PHP/Laravel/Symfony APM packages never retry a failed send in the first place (traces are fire-and-forget by design, so a rejected trace just doesn't block or affect your app).
POST /api/logs
Ship a batch of log lines from the agent.
{
"host_name": "web-01",
"entries": [
{
"service": "nginx",
"level": "error",
"message": "connect() failed (111: Connection refused)",
"source_file": "/var/log/nginx/error.log",
"log_timestamp": "2025-06-17T10:00:00Z"
}
]
}
json
GET /health
Liveness + DB readiness probe. Returns 200 when the server and database are healthy.
{"status": "ok", "db": true}
json
Rate limits & ingest quotas
MonitorKit protects itself at two layers, both scoped per host/org — a busy service on someone else's plan never affects yours, and staying within these is almost never something you need to think about with reasonable sampling.
| Layer | Limit | Behavior when exceeded |
|---|---|---|
| Burst rate limit (per host) | 300 traces/minute | 429 + Retry-After. Meant to catch bugs/retry loops, not normal traffic — you're unlikely to ever see this from a well-sampled app. |
| Ingest rate limit (per org) | 500 requests/minute, all endpoints combined | 429 + Retry-After. |
| Daily trace quota (per org) | 2,000–8,000 traces/host/day depending on your tier (500/day flat on Free) — your exact budget and current usage are on the Billing page | Graceful downsampling, not a hard cutoff. Once an org sustains usage above ~80% of its daily budget, the server starts randomly accepting a shrinking fraction of incoming traces (server-side, on top of whatever MK_SAMPLE_RATE your app already applies) — down to a floor where a minimum of 10% is always still accepted. You keep visibility; MonitorKit stays healthy for everyone. We email your alert recipients the first time this happens (at most once per day). |
| Daily log-line quota (per org) | 20,000–80,000 lines/host/day depending on your tier — also shown on the Billing page | Same graceful downsampling and same 10% floor as traces, applied per log entry at ingest. |
Your current usage against both budgets is shown on the Billing page (trace usage is also on the APM stats view). If you're consistently being downsampled you have two options: lower MK_SAMPLE_RATE (or the equivalent sample_rate config in the Laravel/Symfony packages), which is usually the better answer since most apps get representative APM data at 10-20% sampling; or buy capacity — a Trace Volume Pack adds 5,000 traces/host/day for +$3/host/mo, and a Log Volume Pack adds 50,000 log lines/host/day for +$2/host/mo. Both stack: buy as many as you need.
Volume packs are purchased on the Billing page but assigned to a specific host, in that host's Capacity tab (Infrastructure → click a host → Capacity) — a purchased-but-unassigned unit doesn't raise any host's budget on its own. This lets you put the extra capacity exactly on the host that's generating the volume instead of it applying to every host uniformly. You can reassign a unit from one host to another at any time (free it on the old host first, then assign it on the new one).
Assignment changes take effect within ~10 minutes, not instantly — the daily budget used to accept or downsample incoming data is recomputed by a background check that runs every 10 minutes, not per-request. This applies the first time you assign a pack and every time you reassign or unassign one: right after a change, a host may briefly keep operating on its previous budget until the next check runs.
Configuration backup
Export your organization's monitoring configuration as a JSON file — hosts, alert rules, plugin settings, the infrastructure map, uptime monitors, and database instances. No metrics, logs, or traces are included. Useful as a backup before risky changes, or to copy a setup into a new organization.
From the dashboard: Settings → General → Configuration backup (admin only). Or directly via the API:
GET /api/org/export
Admin session required. Downloads a JSON file with the shape below.
{
"format_version": 1,
"exported_at": "2026-07-20T15:56:30Z",
"org_name": "Acme Inc",
"hosts": [...],
"alert_rules": [...],
"plugin_configs": [...],
"infra_groups": [...],
"infra_nodes": [...],
"infra_edges": [...],
"infra_node_services": [...],
"uptime_monitors": [...],
"database_instances": [...]
}
json
POST /api/org/import
Admin session required. Re-applies an exported file into the current organization. Hosts and plugin settings are matched by name/plugin ID and updated in place; everything else (alert rules, infra map, uptime monitors, database instances) is added as new rows — importing the same file twice duplicates those. Returns a count of imported rows per table.
Troubleshooting
APM: seeing fewer traces than expected / "sampled_out"
- Check your
MK_SAMPLE_RATE(orsample_rate) config — the default is 0.2 (20%), so 4 out of 5 requests are never sent in the first place by design - Check the APM stats view in the dashboard for your trace quota usage — if you're above ~80% of your plan's daily budget, the server is downsampling automatically (see Rate limits & quotas)
- A
{"status": "sampled_out"}or{"status": "duplicate"}response fromPOST /api/tracesis expected behavior, not an error — nothing to fix on the client side - If you need more headroom, lowering
MK_SAMPLE_RATEfurther usually gives more representative data than raising the quota, since you're spreading the same budget across more of your traffic instead of a narrower time window
Agent: host not appearing in dashboard
- Check agent logs:
journalctl -u monitorkit-agent -n 50 - Verify
server_urlis correct and reachable:curl https://monitorkit.co/health - Confirm the
api_keyexists in Settings → Agent Keys and is active - Check that outbound HTTPS (port 443) is not blocked by a firewall
PHP zero-code: no traces appearing after install
- Confirm PHP-FPM was reloaded:
systemctl reload php8.2-fpm - Verify the INI file was written:
cat /etc/php/8.2/fpm/conf.d/99-monitorkit.ini - Check that
auto_prepend_fileis active:php -r "echo ini_get('auto_prepend_file');" - Confirm
MK_ENABLEDistruein/opt/monitorkit-apm/config.php - Confirm
MK_SERVERandMK_API_KEYare set correctly in the same file - Test connectivity:
curl -I "$MK_SERVER/health"from the PHP server - Check PHP error log for any parse errors:
tail -50 /var/log/php8.2-fpm.log - If traces stopped suddenly after working fine, the local circuit breaker may be open (3 slow or failed sends within the last 15s). It clears itself automatically — no action needed unless it stays open, which points to a real connectivity or server-load problem
APM: no traces appearing (Laravel)
- Confirm
MONITORKIT_ENABLED=trueandMONITORKIT_KEYis set - Run
php artisan config:clearto clear cached config - Check that the service provider was discovered:
php artisan package:discover - Temporarily set
MONITORKIT_TIMEOUT=10to rule out timeout issues - Test manually:
curl -X POST $MONITORKIT_SERVER/api/traces -H "X-API-Key: $MONITORKIT_KEY" -d '{}' - If traces stopped suddenly after working fine, the local circuit breaker may be open (3 slow or failed sends within the last 15s) — it clears itself automatically
APM: no traces appearing (Symfony)
monitorkit/symfony < 1.1.1, this is almost certainly why: every trace was rejected by the server with a 422 due to a payload field mismatch (wrong field names, and a required field the client never sent) present since the package's first release — it affected 100% of traces, not an edge case. Run composer show monitorkit/symfony; if it's below 1.1.1, run composer update monitorkit/symfony first before troubleshooting anything else below.
- Confirm the bundle is registered:
php bin/console debug:container MonitorKit - Confirm
MONITORKIT_ENABLED=truein your.env - Clear the cache:
php bin/console cache:clear - Check that
kernel.terminateis being called by your front controller - Check your app's error/access log for the actual HTTP status
POST /api/tracesreturns — a422means the payload itself was rejected (see the callout above), not a connectivity problem - If traces stopped suddenly after working fine, the local circuit breaker may be open (3 slow or failed sends within the last 15s) — it clears itself automatically
Doctrine query tracing not working (Symfony with DBAL 3.x)
monitorkit/symfony ^1.1+ traces DBAL 3.x automatically — no YAML config needed. It registers a service tagged doctrine.middleware, which doctrine/doctrine-bundle ^2.4+ auto-collects (together with its own middlewares, e.g. the web profiler's DB panel) into a single call. If you're still not seeing DB spans:
- Confirm your DoctrineBundle version:
composer show doctrine/doctrine-bundle. Below2.4, it doesn't scan for thedoctrine.middlewaretag at all — upgrade the bundle, there's no safe manual config workaround (see next bullet for why). - Don't add a
doctrine.dbal.connections.<name>.middlewaresblock todoctrine.yamlby hand as a workaround — that config key replaces the whole middleware list rather than adding to it, so it would silently disable DoctrineBundle's own middlewares (e.g. breaking the profiler's DB panel) even if it worked. - Confirm the package version:
composer show monitorkit/symfony— this needs^1.1or later.
PHP-FPM status returns 404
- Confirm
pm.status_path = /fpm-statusis set in your pool config (www.conf) - Check that PHP-FPM was reloaded after the config change:
systemctl reload php8.2-fpm - Verify the nginx location block points to the correct
fastcgi_passsocket/port - Test directly:
curl "http://127.0.0.1/fpm-status?json"
Alerts not sending email
- Check that
SMTP_HOST,SMTP_USER,SMTP_PASSWORD, andSMTP_FROMare set inserver/.env - Verify your SMTP credentials are correct and the account has send permissions
- Check server logs:
journalctl -u monitorkit-server -n 100 | grep -i alert - Confirm the alert rule is enabled and the metric is actually breaching the threshold