paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

February 04, 2022

Observability in WordPress: Logs, Metrics, Traces, and the LGTM + Prometheus Stack

A practical guide to instrumenting WordPress frontend and backend with modern observability tooling, from basic logging to distributed tracing

Introduction

Most WordPress sites run with effectively no observability at all. If something breaks, the typical response is checking the PHP error log, maybe installing a plugin like Query Monitor, and hoping the problem reproduces while someone is watching. This works for small sites with low stakes, but it falls apart precisely when observability matters most: a high-traffic WooCommerce store during a sale, a multi-site network serving enterprise clients, or a headless WordPress backend feeding a frontend application where a slow API response has no visible symptom until a customer complains.

This article treats WordPress as what it actually is at scale - a PHP application with a request lifecycle, a database, external HTTP calls, and (increasingly) a decoupled frontend - and walks through instrumenting it the way you'd instrument any production service: with structured logs, metrics, and distributed traces, exported to a modern observability backend. We'll cover the conceptual building blocks (logs, metrics, traces, spans, events), where WordPress makes this easy and where it actively resists it, how to wire WordPress into the Grafana LGTM stack (Loki, Grafana, Tempo, Mimir) alongside Prometheus, and the practical trade-offs between reaching for an existing plugin versus writing custom instrumentation.

Why WordPress Observability Is Different

WordPress's execution model creates specific observability challenges that don't exist in long-running application servers. Each HTTP request to a traditional (non-persistent) PHP-FPM worker starts a fresh PHP process with no memory of previous requests - there is no long-lived application state to attach a metrics registry to, no background thread collecting counters over time, and no natural place to buffer telemetry data before flushing it. Every logging call, every metric increment, and every trace span has to be created, recorded, and exported within the lifetime of a single request, which is fundamentally different from how observability libraries are typically designed for frameworks like Node.js or Go services that run as persistent processes.

This matters concretely for metrics in particular. Prometheus's standard model is pull-based: a Prometheus server scrapes a /metrics HTTP endpoint on a running service at regular intervals, reading counters and gauges that the service has been accumulating in memory since the last scrape. A stateless, short-lived PHP process has no "in memory since the last scrape" - each request is isolated, so naively using a standard Prometheus client library (like promphp/prometheus_client_php) would reset every counter to zero on every single request. The practical solutions are either an external persistence layer (Redis or APCu) that aggregates values across requests before they're exposed to Prometheus's scraper, or a push-based model using the Prometheus Pushgateway, where WordPress pushes metrics out after each request instead of waiting to be scraped.

A second structural difference is the split between WordPress's PHP-rendered backend and, increasingly, a separate frontend experience - whether that's a theme's client-side JavaScript, Gutenberg's React-based editor, or a fully headless frontend built in Next.js consuming the WordPress REST API. Observability has to span both halves: backend instrumentation (PHP execution time, database query counts, hook execution) and frontend instrumentation (page load performance, client-side errors, user interaction events) need to be correlated, ideally through a shared trace or request ID, or they end up as two disconnected data sets that each tell half a story.

The Building Blocks: Logs, Metrics, Traces, Spans, and Events

Logs: Structured Context Over Raw Text

WordPress's default logging is minimal: WP_DEBUG_LOG writes PHP errors, warnings, and notices to wp-content/debug.log as unstructured text lines. This is adequate for local development but inadequate for production observability, because unstructured log lines can't be reliably queried, filtered by field, or correlated across a distributed system. Professional WordPress observability replaces or augments this with structured logging - JSON-formatted log entries with consistent fields (timestamp, level, request ID, user ID, message, context) - written through a dedicated logging library rather than raw error_log() calls.

<?php
namespace MyApp\Observability;

use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;
use Monolog\Processor\UidProcessor;

class StructuredLogger {
    private static ?Logger $instance = null;

    public static function get(): Logger {
        if ( self::$instance === null ) {
            $logger = new Logger( 'wordpress' );
            $handler = new StreamHandler( WP_CONTENT_DIR . '/logs/app.jsonl', Logger::INFO );
            $handler->setFormatter( new JsonFormatter() );
            $logger->pushHandler( $handler );
            // Adds a unique ID per request, letting Loki correlate every
            // log line emitted during a single page load.
            $logger->pushProcessor( new UidProcessor() );
            self::$instance = $logger;
        }
        return self::$instance;
    }
}

// Usage inside a plugin, with structured context instead of string concatenation.
StructuredLogger::get()->info( 'Order processed', [
    'order_id'  => $order->get_id(),
    'total'     => $order->get_total(),
    'user_id'   => get_current_user_id(),
    'gateway'   => $order->get_payment_method(),
] );

This structured format is what makes a log aggregator like Grafana Loki genuinely useful. Loki indexes logs by labels (service, environment, level) rather than full-text, and queries them with LogQL, a query language deliberately similar to PromQL; feeding it unstructured text loses most of that value, while JSON-formatted lines let you filter and aggregate on specific fields directly in Grafana.

Metrics: Counters, Gauges, and Histograms

Metrics answer "how much" and "how often" questions in aggregate, as opposed to logs, which answer "what happened" for a specific event. The three metric types that matter most for WordPress are counters (monotonically increasing values, like total requests served or total failed logins), gauges (values that go up and down, like current active sessions or queue depth), and histograms (distributions of values, like request duration, which let you calculate percentiles such as p95 latency rather than just an average that hides outliers).

<?php
namespace MyApp\Observability;

use Prometheus\CollectorRegistry;
use Prometheus\Storage\Redis;

class MetricsRegistry {
    public static function get(): CollectorRegistry {
        // Using Redis as the storage adapter solves the stateless-PHP problem:
        // counters persist in Redis across requests, and Prometheus scrapes
        // an endpoint that reads the aggregated values back out.
        Redis::setDefaultOptions( [ 'host' => getenv( 'REDIS_HOST' ) ?: '127.0.0.1' ] );
        return new CollectorRegistry( new Redis() );
    }
}

// Recording a histogram observation for request duration, hooked into
// WordPress's request lifecycle.
add_action( 'template_redirect', function () {
    $start = $GLOBALS['myapp_request_start'] ?? microtime( true );
    add_action( 'shutdown', function () use ( $start ) {
        $duration = microtime( true ) - $start;
        $registry = MetricsRegistry::get();
        $histogram = $registry->getOrRegisterHistogram(
            'wordpress', 'request_duration_seconds', 'Request duration in seconds',
            [ 'route' ], [ 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5 ]
        );
        $histogram->observe( $duration, [ is_admin() ? 'admin' : 'frontend' ] );
    } );
} );

A separate, lightweight endpoint then exposes these accumulated values in Prometheus's text exposition format for scraping, typically registered as a REST route or a dedicated, non-cached PHP endpoint excluded from any page cache so it's never served stale.

Traces and Spans: Following a Request Across Boundaries

A trace represents the full journey of a single request as it moves through a system, and a span represents one discrete unit of work within that trace - a database query, an external API call, a specific function's execution. Traces matter most in exactly the scenario WordPress increasingly finds itself in: a request that touches multiple systems, such as a WordPress backend calling a payment gateway's API, querying a database, and triggering a webhook to an external service. Without tracing, diagnosing which of those three calls caused a slow response means guessing or adding ad hoc timing logs; with tracing, the full breakdown is visible as a waterfall in a tool like Grafana Tempo.

OpenTelemetry has become the vendor-neutral standard for this kind of instrumentation, with an official PHP SDK (open-telemetry/opentelemetry-php) that can be wired into WordPress to create spans around significant operations and export them via the OpenTelemetry Protocol (OTLP) to a Tempo instance.

<?php
namespace MyApp\Observability;

use OpenTelemetry\API\Globals;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SDK\Trace\SpanExporter\ConsoleSpanExporterFactory;
use OpenTelemetry\Contrib\Otlp\SpanExporterFactory;

class Tracing {
    public static function startSpan( string $name, callable $work ): mixed {
        $tracer = Globals::tracerProvider()->getTracer( 'wordpress-app' );
        $span = $tracer->spanBuilder( $name )->startSpan();
        $scope = $span->activate();

        try {
            return $work();
        } catch ( \Throwable $e ) {
            $span->recordException( $e );
            throw $e;
        } finally {
            $span->end();
            $scope->detach();
        }
    }
}

// Wrapping an external payment gateway call in a span, visible in Tempo
// as a child of the overall checkout request trace.
$result = Tracing::startSpan( 'payment.gateway.charge', function () use ( $order ) {
    return $payment_gateway->charge( $order->get_total(), $order->get_payment_token() );
} );

Implementation: Wiring WordPress into the LGTM + Prometheus Stack

The Grafana LGTM stack - Loki for logs, Grafana for visualization, Tempo for traces, and Mimir (or, more simply, Prometheus itself) for metrics - gives WordPress a coherent, open-source observability backend where all four signal types can be correlated in one place. A typical architecture runs these components outside the WordPress hosting environment entirely (since shared or cPanel-based hosting, rarely allows running sidecar processes), with WordPress exporting telemetry over the network to a centrally hosted or self-managed Grafana stack.

# docker-compose.yml: a minimal local LGTM + Prometheus stack for development,
# allowing WordPress running elsewhere to export telemetry to each component.
version: "3.8"
services:
  grafana:
    image: grafana/grafana:11.2.0
    ports: ["3000:3000"]
    volumes: ["grafana-data:/var/lib/grafana"]

  loki:
    image: grafana/loki:3.1.1
    ports: ["3100:3100"]
    command: -config.file=/etc/loki/local-config.yaml

  tempo:
    image: grafana/tempo:2.5.0
    ports: ["3200:3200", "4317:4317"]  # 4317: OTLP gRPC receiver
    command: ["-config.file=/etc/tempo/tempo.yaml"]

  prometheus:
    image: prom/prometheus:v2.54.1
    ports: ["9090:9090"]
    volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml"]

volumes:
  grafana-data:
# prometheus.yml: scrape configuration targeting a WordPress-exposed
# metrics endpoint, secured behind a token header checked at the app layer.
scrape_configs:
  - job_name: 'wordpress'
    metrics_path: '/wp-json/myapp/v1/metrics'
    scrape_interval: 15s
    static_configs:
      - targets: ['wordpress.example.com']
    scheme: https

For log shipping, rather than writing custom code to push logs to Loki directly, the standard pattern is to let WordPress write structured JSON logs to a file (as shown earlier with Monolog) and run a lightweight log-shipping agent - Grafana Alloy (Grafana Labs' unified telemetry collector, which succeeds the older Promtail agent) or Promtail itself - as a separate process that tails the log file and forwards entries to Loki. This decouples WordPress's PHP code from any knowledge of Loki's API, and it works identically whether WordPress runs on a VPS, in a container, or behind a managed hosting provider that allows a sidecar process.

# alloy-config.river (Grafana Alloy configuration): tail WordPress's
# structured log file and forward entries to Loki with labels attached.
local.file_match "wordpress_logs" {
  path_targets = [{"__path__" = "/var/www/wp-content/logs/app.jsonl"}]
}

loki.source.file "wordpress" {
  targets    = local.file_match.wordpress_logs.targets
  forward_to = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "https://loki.example.com/loki/api/v1/push"
  }
  external_labels = {
    service = "wordpress",
    env     = "production",
  }
}

On the frontend side, correlating client-side telemetry with backend traces requires propagating a trace context from server to browser. A pragmatic pattern is rendering the active trace ID into the page as a data attribute or inline script variable during the initial PHP render, then having frontend JavaScript (whether vanilla, or via the OpenTelemetry JavaScript SDK) attach that same trace ID to any client-side spans or events it records, letting Tempo and Grafana display backend and frontend activity as a single, connected trace.

// Frontend: read the trace ID WordPress embedded in the page, and attach it
// to custom frontend events sent to an analytics or observability endpoint.
const traceId = document.querySelector('meta[name="trace-id"]')?.content;

function trackEvent(eventName, properties = {}) {
  navigator.sendBeacon(
    '/wp-json/myapp/v1/events',
    JSON.stringify({
      event: eventName,
      trace_id: traceId,
      timestamp: Date.now(),
      ...properties,
    })
  );
}

// Example: track when a user abandons checkout, correlatable in Grafana
// against the exact backend trace for that same page load.
document.querySelector('#checkout-form')?.addEventListener('submit', () => {
  trackEvent('checkout_submitted', { cart_value: window.myappCartTotal });
});

Plugins, Paid Services, and Custom Code: Choosing the Right Layer

WordPress's plugin ecosystem offers real, mature options for parts of this observability stack, and understanding what each covers - and doesn't - is essential before deciding to build custom instrumentation. Query Monitor is the most widely used free diagnostic plugin among WordPress developers, surfacing database queries, hook execution, PHP errors, and HTTP API calls directly in an admin toolbar panel; it is excellent for local development and staging debugging, but it is not designed for production telemetry export and has no concept of sending data to Prometheus or Loki. New Relic and Datadog both offer commercial APM (Application Performance Monitoring) products with PHP agents that can instrument a WordPress installation with minimal code changes, automatically capturing traces, database query performance, and error rates; these are strong choices when a team wants managed infrastructure and is willing to pay for it, and both support exporting data in OpenTelemetry-compatible formats, which keeps the door open to a hybrid setup.

For WooCommerce specifically, commercial plugins exist that combine logging, error tracking, and basic performance monitoring tailored to e-commerce workflows, but they generally stop short of full distributed tracing or Prometheus-native metrics, since that's a narrower, more technical audience than their target market. This is the gap where custom development earns its cost: a plugin can tell you that checkout is slow in aggregate, but tracing which specific span - database write, payment gateway call, inventory sync webhook - caused a particular slow request requires the kind of span-level instrumentation shown earlier, which no off-the-shelf WordPress plugin currently provides out of the box.

A sensible decision framework, then, separates three layers. Use a free plugin like Query Monitor for local and staging development, where its lack of production telemetry export doesn't matter. Use a commercial APM product (New Relic, Datadog, or similar) when the team needs broad instrumentation quickly, doesn't have bandwidth for custom OpenTelemetry work, and the cost is justified by the business value of the sites involved. Write custom instrumentation - structured logging via Monolog, metrics via the Prometheus PHP client, and tracing via the OpenTelemetry PHP SDK, all exported to a self-hosted or Grafana Cloud-hosted LGTM stack - when the team wants full control over what's measured, needs to correlate backend and frontend telemetry precisely, or is already running Grafana/Prometheus for other services and wants WordPress to report into the same system rather than a separate vendor dashboard.

Trade-offs and Common Pitfalls

The most common early mistake is over-instrumenting without a cost model in mind. Every log write, every metric increment with Redis-backed storage, and every trace span introduces real latency and I/O on a stateless PHP request where every millisecond is visible to the end user. A team that wraps every function call in a trace span, or logs every hook execution at INFO level, will measurably slow down the site they're trying to observe - the instrumentation itself becomes a performance problem. The fix is sampling: Prometheus-style metrics are cheap enough to record on every request, but trace spans, especially detailed ones, should typically be sampled (recording only a percentage of requests, or only requests exceeding a latency threshold) in production, a practice directly supported by OpenTelemetry's sampler configuration.

A second pitfall is conflating business analytics with observability, which leads teams to either under-invest in one or bloat the other with irrelevant data. Marketing-oriented tracking (page views, conversion funnels, A/B test assignment) and engineering observability (error rates, latency percentiles, trace spans) solve different problems and often belong in different systems - Google Analytics or a product analytics tool for the former, Grafana/Prometheus for the latter - even though both are technically "frontend event tracking". Routing all frontend events through a single pipeline without this distinction tends to produce a system that serves neither audience well, since engineers querying for p95 checkout latency don't want to wade through marketing campaign attribution events, and marketers don't need PHP stack traces.

The stateless-PHP metrics problem deserves its own callout as a pitfall, because it's easy to get subtly wrong rather than obviously wrong. A naive Prometheus client implementation that keeps counters in a plain PHP static variable will appear to work in local testing (where one process handles many requests during a dev server session) and then silently report meaningless, reset-to-zero values in production under PHP-FPM, where each request may be handled by a different worker process entirely. This class of bug is particularly dangerous because it doesn't throw an error - it just produces metrics that look plausible but are wrong, and the only way to catch it is comparing the metrics against an independent source of truth (like access log counts) during initial rollout.

Finally, trace and metric cardinality is a real cost concern that catches teams used to thinking of "more labels" as strictly more useful. Adding a label like user_id or post_id to a Prometheus metric, where that label can take on thousands or millions of distinct values, causes cardinality explosion - Prometheus and Mimir both store a separate time series per unique label combination, and high-cardinality labels can degrade query performance and inflate storage costs dramatically. The correct pattern is keeping metric labels low-cardinality (route name, HTTP method, status code bucket) and reserving high-cardinality identifiers like user or post IDs for traces and logs, which are designed to handle that kind of dimensionality.

Best Practices for Sustainable WordPress Observability

Start with the signal that answers the question you actually have, rather than instrumenting everything up front. If the open question is "is the site slow", request duration histograms exposed to Prometheus answer that faster and more cheaply than a full tracing rollout. If the question is "why is this specific checkout failing intermittently", traces correlating the payment gateway call, database write, and webhook dispatch answer that far better than aggregate metrics ever could. Treating logs, metrics, and traces as complementary tools matched to specific diagnostic questions - rather than a checklist to complete uniformly - keeps the instrumentation effort proportional to its value.

# A small Python script run in CI or as a scheduled job, querying Prometheus's
# HTTP API directly to assert a service-level objective before a release
# is considered healthy, rather than relying on manual dashboard checks.
import requests

PROMETHEUS_URL = "https://prometheus.example.com"

def check_p95_latency_slo(threshold_seconds: float = 1.0) -> bool:
    query = (
        'histogram_quantile(0.95, '
        'sum(rate(wordpress_request_duration_seconds_bucket[5m])) by (le))'
    )
    response = requests.get(f"{PROMETHEUS_URL}/api/v1/query", params={"query": query})
    response.raise_for_status()
    result = response.json()["data"]["result"]

    if not result:
        print("No data returned; cannot verify SLO.")
        return False

    p95 = float(result[0]["value"][1])
    print(f"Current p95 request latency: {p95:.3f}s (threshold: {threshold_seconds}s)")
    return p95 <= threshold_seconds

if __name__ == "__main__":
    import sys
    sys.exit(0 if check_p95_latency_slo() else 1)

Correlate every signal through a shared identifier, consistently. A request ID generated at the start of each PHP request and attached to every log line, every trace span, and propagated to the frontend for client-side event correlation is what actually makes a multi-signal observability stack useful rather than four disconnected dashboards. Without this, an engineer debugging a production incident has to manually guess which log lines correspond to which trace, which defeats much of the purpose of investing in all four signal types.

Keep instrumentation code out of business logic through a thin, centralized observability layer - the StructuredLogger, MetricsRegistry, and Tracing classes shown earlier are examples of this pattern, where the rest of the codebase calls simple, well-named methods rather than directly interacting with Monolog, the Prometheus client, or the OpenTelemetry SDK. This matters because observability tooling changes more often than business logic does; a well-isolated observability layer means swapping Prometheus for Mimir-native remote write, or Loki for a different log backend, touches one file instead of every plugin and theme file that happens to log something.

Finally, alert on symptoms, not on every available metric. A dashboard with fifty panels is not the same thing as an effective alerting strategy; the goal is a small number of alerts tied to user-facing symptoms (elevated error rate, p95 latency breach, failed payment rate) that page someone, backed by a much larger set of dashboards used for investigation once an alert fires. Grafana's alerting integrates directly with Prometheus and Mimir queries, letting a team define these symptom-based alerts in the same place they define the dashboards used to investigate them.

Analogies and Mental Models

Think of logs, metrics, and traces as three different instruments a doctor might use, each answering a different kind of question. Metrics are like vital signs on a monitor - heart rate, blood pressure, temperature - continuous, cheap to measure, and good for noticing that something is wrong in aggregate, but they don't tell you why. Logs are like a patient's medical history - detailed, specific entries about particular events, useful for understanding exactly what happened at a given moment, but expensive to read through at scale. Traces are like an MRI scan of a specific incident - a detailed, structural view of exactly how a single event unfolded across every part of the system it touched, expensive enough that you don't run one for every patient, but invaluable when you need to understand a specific complex case in detail. A mature observability practice uses vital-sign metrics to notice something's wrong, logs to narrow down when and roughly what happened, and traces to get the detailed structural picture of a specific problematic request - using each instrument for what it's actually good at, rather than trying to diagnose everything with just one of the three.

Key Takeaways

Conclusion

Observability in WordPress is achievable with the same rigor applied to any other production system - it just requires acknowledging where WordPress's execution model (stateless PHP-FPM workers, a mixed backend/frontend surface, a plugin ecosystem not built with telemetry in mind) creates friction, and designing around it deliberately rather than bolting on a plugin and assuming the problem is solved. The LGTM stack and Prometheus give WordPress a path to the same open, vendor-neutral observability standard used across the rest of the industry, through structured logging with Monolog, metrics via a Redis-backed Prometheus client, and distributed tracing via the OpenTelemetry PHP SDK.

None of this requires abandoning the WordPress plugin ecosystem outright - Query Monitor still earns its place in local development, and commercial APM tools remain a legitimate choice for teams who want fast, broad coverage without custom engineering investment. What changes with the approach in this article is the ceiling: a WordPress site instrumented this way can answer the same precise, trace-level questions about production behavior that any other professionally operated service can, rather than being limited to guesswork and a PHP error log when something goes wrong.

References