Introduction
By day 99 of plugin development, you've likely shipped features, handled edge cases, and debugged production issues. But if your plugin doesn't log what it's doing, you're flying blind when something breaks. Logging in WordPress plugins: what developers should know covers the difference between slapping errors into a file during development and maintaining proper observability in production.
We'll walk through WordPress's built-in logging, structured approaches for production environments, and the critical mistakes that leak sensitive data into your logs.
Error_log in development
The fastest way to debug is often the simplest. WordPress has a built-in constant, WP_DEBUG_LOG, that writes to wp-content/debug.log. Enable it in wp-config.php and you have instant visibility into what your plugin is doing.
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );Once enabled, use the error_log function directly or rely on WordPress's do_action hooks that fire when things go wrong. Many plugins use this during early development to watch database queries, hook execution order, and variable state.
if ( WP_DEBUG ) {
error_log( 'My plugin: user ID ' . $user_id . ' attempted payment' );
}This works well locally and on staging. The log file grows quickly, you can grep it, tail it, and spot patterns. But there are serious problems with this approach in production.
First, disk space fills up. Second, you have no filtering or levels—everything is mixed together. Third, there's no centralized place to query logs across multiple servers. Fourth, sensitive data (API keys, payment tokens, user passwords) ends up in plaintext on disk. Many teams have shipped debug.log with credentials and never knew until an audit or breach.
Development logging is a tool, not a strategy. Outgrow it before production.
Structured logs for production
Production requires a different mindset. Instead of unstructured text, emit events as structured data—usually JSON—that can be parsed, filtered, aggregated, and searched. This is where most WordPress shops lag behind modern application development.
A structured log entry has a timestamp, a severity level, a message, and contextual fields. Think of it like a database record instead of a text line.
{
"timestamp": "2025-01-15T14:32:05.123Z",
"level": "error",
"plugin": "my-payments",
"action": "process_refund",
"user_id": 2847,
"refund_id": 456,
"amount": 99.99,
"gateway": "stripe",
"error_code": "REFUND_FAILED",
"message": "Stripe refund declined: insufficient_funds"
}With this structure, you can query for all payment errors in the last hour, filter by user_id, group failures by error_code, and track trends over time. Cloud providers like AWS CloudWatch, Google Cloud Logging, or services like Datadog ingest these and provide dashboards, alerts, and retention policies.
Most production WordPress environments send logs to a centralized system rather than the local filesystem. This keeps your server clean, ensures logs survive server restarts, and lets you correlate plugin logs with web server access logs and database logs.
Implement structured logging by choosing a library or using WordPress's native logging system with a handler that outputs JSON:
$logger = new \Monolog\Logger( 'my-plugin' );
$handler = new \Monolog\Handler\SyslogHandler( 'my-plugin' );
$formatter = new \Monolog\Formatter\JsonFormatter();
$handler->setFormatter( $formatter );
$logger->pushHandler( $handler );
$logger->info( 'Payment processed', [
'user_id' => $user_id,
'amount' => $amount,
'gateway' => 'stripe',
] );Or, if you prefer WordPress's native approach, use the wp_die or wp_remote_post hooks to send events to your logging endpoint in a format your APM or log aggregation service understands.
Logging plugins with levels
Not all events are equal. A debug message during development is different from a warning about a deprecated API, which is different from an error that broke a transaction. Logging levels let you filter noise.
Most logging systems follow this hierarchy:
- DEBUG: Verbose diagnostic information. Useful locally; usually disabled in production.
- INFO: General operational events. A scheduled sync completed. A user signed up. Normal behavior.
- WARNING: Something unexpected but not broken. A payment gateway took longer than usual. A deprecated function was called.
- ERROR: A problem occurred. A refund failed. A database query timed out. The user felt it.
- CRITICAL: The system is in serious trouble. Authentication failed. The database is down. Take action now.
Use the level that matches the severity. This lets operators and monitoring systems react appropriately. A warning might trigger a log entry; an error might send an SMS alert; a critical might page on-call engineers.
// Debug: only log locally during development
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( 'Processing webhook: ' . json_encode( $payload ) );
}
// Info: normal operational event
wp_remote_post( getenv( 'LOG_ENDPOINT' ), [
'body' => json_encode( [
'level' => 'info',
'action' => 'order_created',
'order_id' => $order_id,
'timestamp' => gmdate( 'c' ),
] ),
] );
// Warning: something unexpected
wp_remote_post( getenv( 'LOG_ENDPOINT' ), [
'body' => json_encode( [
'level' => 'warning',
'action' => 'slow_api_call',
'duration' => 8500, // milliseconds
'threshold' => 5000,
'endpoint' => 'stripe_charges',
'timestamp' => gmdate( 'c' ),
] ),
] );
// Error: something broke
wp_remote_post( getenv( 'LOG_ENDPOINT' ), [
'body' => json_encode( [
'level' => 'error',
'action' => 'refund_failed',
'order_id' => $order_id,
'reason' => 'Gateway timeout',
'timestamp' => gmdate( 'c' ),
] ),
] );By setting your log aggregation tool to only surface errors and critical events to your team, you reduce alert fatigue. Infos and warnings go to your logs but don't page you at 3 AM. This is how you scale from panic-driven debugging to intentional observability.
Never log sensitive data
This is non-negotiable. Never log passwords, API keys, payment tokens, SSNs, credit card numbers, or auth tokens. Full stop.
The moment you log it, you've created a new attack surface. If your log files get breached, copied to a backup, or accidentally exposed, that data is gone. Compliance frameworks (PCI-DSS, GDPR, SOC 2) explicitly forbid this. A single log line with a card number can trigger an audit, a breach notification, and legal liability.
Instead, log enough context to debug without exposing secrets:
// Bad: never do this
error_log( 'Payment attempt with card: ' . $credit_card_number . ' user: ' . $user_email );
// Good: redact and use references
error_log( 'Payment attempt with card ending in ' . substr( $credit_card_number, -4 ) . ' user_id: ' . $user_id );
// Better: use a hashed reference
error_log( 'Payment attempt hash: ' . hash( 'sha256', $credit_card_number ) . ' user_id: ' . $user_id );If you need to debug a payment failure and you have the user_id and order_id, you can look up the details in your secure payment system. You don't need the full card number in your logs.
Apply the same rule to:
- API keys or OAuth tokens (log that you called an endpoint, not the auth header)
- User email addresses in admin contexts (log user_id instead)
- Database connection strings (log "connected to default" instead of the DSN)
- Full request/response bodies from external APIs (log the status code and error message, redact the payload)
Make this a code review habit. Before a PR merges, scan for secret data in logs. Most breaches aren't malicious hacks; they're leaks from inside your own systems.
Common mistakes
- Logging too much: Every hook, every query, every variable. This creates noise and makes it hard to find signal. Log intentionally. Log when something changes state, when a user action completes, when an error occurs.
- Logging nothing: No observability means you're debugging blind in production. Ship logs from day one.
- Mixing development and production logging: Leaving WP_DEBUG enabled in production fills your disk and exposes internals. Use environment variables to toggle debug logging off in production.
- Not using log levels: Everything is INFO. This means your alerts are useless and your team is in constant triage mode.
- Logging without context: A log that says "Error occurred" with no user_id, no order_id, no request ID means you can't reproduce it. Always include context.
- Asynchronous logging that blocks: If your plugin tries to send logs synchronously over HTTP and the logging endpoint is slow or down, your plugin hangs. Use background jobs (WP Cron, job queues) to send logs asynchronously.
- No log retention strategy: Logs grow forever. Set retention policies. Delete old logs. Keep recent logs. Know your compliance requirements.
Key takeaways
- Use
WP_DEBUG_LOGduring development. It's fast and good enough for local work. - Switch to structured, JSON-based logging in production. Emit to a centralized system, not the local filesystem.
- Use log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL) to separate signal from noise.
- Never log sensitive data. Redact secrets, use hashed references, log only what you need to debug.
- Make logging intentional. Log state changes, user actions, errors, and performance issues. Skip the noise.
- Implement asynchronous logging so your plugin doesn't block on I/O.
- Plan for retention and compliance. Know how long you keep logs and who can access them.






Comments
Loading comments…