PHP error tracking
Error tracking is on by default in the PHP SDK (errors.enabled, environment variable RANETRACE_ERRORS_ENABLED), but it captures nothing until you install the handlers or report an exception yourself.
Using Laravel? See Error Tracking instead.
Automatic capture
$ranetrace->registerErrorHandlers();
That one call installs two handlers, because uncaught exceptions and fatal errors are two different failure modes:
- An exception handler, for exceptions nothing caught. Whatever exception handler your application registered before this call is kept and runs after Ranetrace has captured the exception, so wiring Ranetrace in never takes over your own error page. When there was no previous handler, the exception is rethrown after capture, so PHP still prints its usual uncaught-exception report and exits non-zero, exactly as it would without the SDK installed.
- A shutdown handler, for fatal errors (
E_ERROR,E_PARSE,E_CORE_ERROR,E_COMPILE_ERROR). These never reach an exception handler at all, so the last error of a dying process is inspected on the way out and reported as anErrorException.
Calling registerErrorHandlers() more than once is harmless; the second call does nothing.
What it deliberately does not install is set_error_handler(). Notices, warnings and deprecations are not errors this SDK reports, and taking over the error handler would change how your own error reporting behaves for every one of them.
Reporting manually
try {
$this->thirdPartyApi->sync();
} catch (Throwable $exception) {
$ranetrace->report($exception);
}
report() takes a single Throwable and returns nothing. It never throws: losing one error report is acceptable, breaking your application is not. If the SDK cannot capture the exception, it writes why to its own diagnostics file and moves on.
Two things are skipped on purpose: exceptions thrown from inside the SDK itself (otherwise a transport failure would be reported as one of your application errors and loop straight back), and everything at all while enabled, errors.enabled or the API key is missing.
What a report contains
- The exception: message, file, line, and the class name of the throwable.
- The stack trace, in two forms: PHP's own
getTraceAsString()output, and structured frames. Each frame is a file, line, class, function and call type (->or::), with a flag that says whether the file is invendor/. A trace keeps at most 50 frames, the 40 innermost and the 10 outermost, and says how many it left out in between. - Source lines around the line that threw and around your own frames: see Source lines below, including how to turn them off.
- Previous exceptions: up to 5, nearest first, each with its class, its message (secret-scrubbed), the file and line it was thrown at, the source lines there, and its own frames.
- Request context, when the process is not a CLI process: the full URL, the HTTP method, and the request headers.
- Console context, when it is: the command line the process was started with, and its arguments.
- The current user, when a
user_resolveris configured. - Environment facts: your environment name, the PHP version, and the framework name and version when you configured them.
File paths are reported relative to project_root, so your deployment layout does not travel with the report. A path outside the project root is kept absolute.
Source lines
Source lines are on by default. A report carries a few lines of your code: five either side of the line that threw, of each previous exception's throw site, and of each frame in your own code. The lines arrive dedented, and the throw site's window says which of its lines failed. Only files that are readable and under 1 MB are read. That is what lets Ranetrace show the failing code next to the trace without access to your repository. It also means some of your source code leaves your host with every error, so decide whether you want that.
Every line is secret-scrubbed first, a window at a time together with up to 50 lines above it, so a secret that spans lines is caught as well: a value on the line after its key, a call spread over several lines, a heredoc assigned to a sensitive key and the body of a private key block. What arrives as [REDACTED]:
- A value next to a sensitive key name, such as
'api_key' => '...',$password = '...',$config['password'] = '...'ordefine('STRIPE_SECRET', '...'). A quoted value goes whole, up to its closing quote. A value that is code keeps its code and loses only its string literals, so'password' => password_hash('x', PASSWORD_DEFAULT)arrives as'password' => password_hash('[REDACTED]', PASSWORD_DEFAULT). A literal that only names where a value lives is kept: the first argument ofenv(),getenv()andconfig(), and the subscript of$_ENVand$_SERVER, soenv('DB_PASSWORD', 'x')arrives asenv('DB_PASSWORD', '[REDACTED]'). - Every string literal passed to a call whose name holds a sensitive word, such as
setApiKey('...'),Http::withToken('...')or->setCredentials('user', 'pass'). A call whose name ends inExceptionorErrorstays readable, since its argument is a message the error already carries. - Every string literal passed to a known credential-taking call:
new PDO(...),new mysqli(...),mysqli_connect,mysqli_real_connect,ldap_bind,ldap_sasl_bind,ftp_login,ssh2_auth_password,imap_open, Laravel'swithBasicAuthandwithDigestAuth, andparent::__construct(...)in a class that extendsPDOormysqli. Sonew PDO($dsn, 'root', 'hunter2')arrives asnew PDO($dsn, '[REDACTED]', '[REDACTED]'). - A URL password or a credential-prefixed token, wherever it stands, as everywhere else in the report.
Not recognised: a credential passed by position to a call on neither list, such as your own connect($host, 'root', 'hunter2'), and any other secret under a name the key list does not know, such as $pin = '4821'. Add such names to scrubbing.extra_keys, keep secrets in the environment rather than in code, or turn source lines off as shown below.
Two things the structured frames never carry:
- Argument values. The SDK does not read a frame's arguments at all, whatever
zend.exception_ignore_argsis set to. The text trace is PHP's owngetTraceAsString(), which shows shortened argument values unlesszend.exception_ignore_argsis on (php.ini-productionturns it on); it is secret-scrubbed like the message. - Source from your dependencies. Frames in
vendor/carry their file, line, class and function, but no lines. The one place vendor code can appear is a throw site: when an exception is thrown inside a dependency, the lines around that throw site are sent.
To keep every source line inside your application:
RANETRACE_ERRORS_CAPTURE_SOURCE=false
Or in the config you pass to Ranetrace::init():
$ranetrace = Ranetrace::init([
'key' => getenv('RANETRACE_KEY') ?: '',
'errors' => ['capture_source' => false],
]);
With source capture off, no source line is sent at all, the throw site's included. The frames still travel, as file, line, class and function, so the trace reads the same without the code beside it.
Headers
Headers are captured from an allowlist, and everything else is masked as ***. The allowlist is: accept, accept-charset, accept-encoding, accept-language, cache-control, connection, content-length, content-type, host, referer, user-agent, x-requested-with, x-forwarded-proto and x-forwarded-host.
Masking by default means a header carrying a secret nobody anticipated is masked rather than leaked. x-forwarded-for is deliberately not on the list: it carries the client IP chain, and no IP leaves your host. The referer value is additionally URL-scrubbed, because a referring URL can carry reset tokens and signed-URL signatures in its query string.
The user
The SDK has no authentication system to ask, so it asks you. Configure a callable that returns the current user, or null:
$ranetrace = Ranetrace::init([
'key' => getenv('RANETRACE_KEY') ?: '',
'user_resolver' => static function (): ?array {
$user = Session::user();
return $user === null ? null : ['id' => $user->id, 'email' => $user->email];
},
]);
The resolver must return an array with an id, or null. It is called from inside the capture path, so if it throws, the failure is contained and the report is sent without a user rather than lost.
The email is not sent by default. errors.capture_user_email is false, so only the id travels. Turn it on when you want to see who hit an error:
RANETRACE_ERRORS_CAPTURE_USER_EMAIL=true
Secret scrubbing
Before anything is buffered, values stored under sensitive keys and key=value secrets written into strings are redacted to [REDACTED]. The built-in key fragments are matched case-insensitively as substrings: password, passwd, pwd, secret, token, api_key, apikey, api-key, authorization, credential, private_key, access_key and signature. URL query parameters are scrubbed the same way. In a message or a trace, an unquoted value after a key is redacted whole; how source lines are read is described under Source lines.
Two kinds of secret are recognised by their value alone, wherever they stand in a message, a trace, a source line or any string in log context and extra, event properties, breadcrumbs or an exception's context(): the password in a URL, with the scheme, user name and host kept (pgsql://app:secret@db/app arrives as pgsql://app:[REDACTED]@db/app), and a token whose prefix says it is a credential: Stripe sk_live_ and rk_live_ keys, AWS AKIA and ASIA access key ids, GitHub ghp_, gho_ and github_pat_ tokens, Slack xoxa-, xoxb-, xoxp- and xoxr- tokens, GitLab glpat- tokens and JWTs.
The exception message, the stack trace and every source line are scrubbed before they are truncated, so a secret cannot survive by sitting across the length boundary.
Add your own key fragments, which are added to the built-in list and never replace it:
$ranetrace = Ranetrace::init([
'key' => getenv('RANETRACE_KEY') ?: '',
'scrubbing' => ['extra_keys' => ['x_internal_signature']],
]);
Or through the environment, as a comma-separated list:
RANETRACE_SCRUBBING_EXTRA_KEYS=x_internal_signature,partner_ref
Scrubbing is defense in depth. Avoid deliberately putting secrets in exception messages regardless.
When you get notified
Ranetrace groups every report with the same exception class, file, line and environment into one error, and each report in that group is an occurrence. An error notifies you:
- on its first occurrence, so a new error never goes unseen
- on its 5th and 10th occurrence, which tell a one-off from a pattern
- when it starts happening 10 times within an hour after a calmer hour, however old the error is
- when it comes back after you resolved it, even if it happens only once
An error that keeps failing at a steady rate notifies once when the burst starts, not every hour. It notifies again only after it has been quiet for an hour and then bursts again. A snoozed error stays silent until the snooze ends, and an ignored error is not recorded at all.
The same error with the same message is recorded at most once every 5 minutes, so the occurrence count measures how often an error comes back, not every single request it failed. It also means a burst of 10 within an hour takes about 45 minutes of steady failure.
Notifications land in your inbox and on the channels you connected. Turn error notifications off or on under Notify, Rules.
Configuration
| Environment variable | Config key | Default | What it controls |
|---|---|---|---|
RANETRACE_ERRORS_ENABLED |
errors.enabled |
true |
Whether exceptions are captured at all. |
RANETRACE_ERRORS_TIMEOUT |
errors.timeout |
10 |
Seconds to wait for the API when a batch of errors is sent. |
RANETRACE_ERRORS_CAPTURE_USER_EMAIL |
errors.capture_user_email |
false |
Whether the resolved user's email is included. |
RANETRACE_ERRORS_CAPTURE_SOURCE |
errors.capture_source |
true |
Whether source lines are sent: around the throw site, each previous exception's throw site and each frame in your own code. |
To turn error tracking off without uninstalling:
RANETRACE_ERRORS_ENABLED=false
Captured errors reach the API on the next flush. See PHP installation for the two ways that happens.