Centralizing PHP, Nginx and WooCommerce error logs: Better Stack vs self-hosted Loki
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
Run one log shipper, Vector, on the store's server. It tails five files: the Nginx access log (written as JSON), the Nginx error log, the PHP error log, the PHP-FPM log and WooCommerce's wc-logs folder. It joins multi-line entries such as a fatal error's stack trace into one event, drops successful static-file requests, masks emails, card numbers and the last part of IP addresses, and sends the events to Better Stack, to a Loki server you run, or to both. A disk buffer and end-to-end acknowledgements kept every event through an outage and a crash in our test. Better Stack is the faster path: 3 GB a month free, then $0.10 to $0.35 per GB, with alerting and on-call built in. Loki costs a second server and your time, but the data and the alert rules stay with you.
When a WooCommerce checkout breaks, the reason is usually already written down, in one of five files on the server: Nginx's access and error logs, PHP's error log, PHP-FPM's log, or WooCommerce's own logs. Nobody reads them until a customer complains. This guide ships all five to one place, turns them into searchable events, and alerts on the ones that cost sales. It compares a managed service, Better Stack, with Grafana Loki on a server you run, using the same shipper config for both.
We ran the setup below end to end. A real Nginx 1.30.6 served a WordPress 7.1.3 and WooCommerce 11.2.1 store through PHP 8.4. Test requests made the store write real entries: fatal errors on the checkout endpoint, warnings, 404s, 502 and 504 gateway errors, and WooCommerce log entries with an email address and a card number in them. Vector 0.59.0, using the config in this guide with only the file paths changed, shipped them to a real Loki 3.6.19 and to a mock of Better Stack's ingest API. The mock follows the documented contract: a bearer token, gzip, and a 202 response. Loki's ruler sent alerts to a mock Alertmanager. The PHP-FPM lines were written by the test, in the format PHP-FPM uses, because FPM doesn't run on Windows, where we ran the test. All 26 checks pass. We didn't use a Better Stack account; its pricing and limits are from its pricing page and docs (October 2026).
1. The short answer
| Question | Answer |
|---|---|
| Which files? | Nginx access (as JSON) and error logs, the PHP error log, the PHP-FPM log, wp-content/uploads/wc-logs/*.log |
| Which shipper? | Vector: one agent that parses, filters and masks before anything leaves the server, and can send to both destinations |
| What gets dropped? | Successful static-file requests (CSS, JS, images, fonts). Errors on static files are kept. |
| Personal data | Emails and card-like numbers become [REDACTED]; IP addresses are cut to their /24 network |
| Outages | Disk buffer plus acknowledgements: 50 of 50 events arrived after a 503 outage and a hard crash of the shipper |
| Managed option | Better Stack: 3 GB of logs a month free (3-day retention), then $0.10 per GB in Europe plus $0.05 per GB per month to keep it |
| Self-hosted option | Grafana Loki on a second server, alert rules in LogQL, notifications through Alertmanager |
| What should page you | PHP fatal errors, 5xx on checkout or cart, Nginx unable to reach PHP (502/504), PHP-FPM out of workers |
2. The pipeline
Shipping logs is the easy part. What makes the difference is what happens on the server first. A PHP fatal error is ten lines in the file and should be one event. A product page pulls in dozens of CSS, JS and image requests that nobody will ever search. A WooCommerce log entry can contain a customer's email. Doing that work before shipping keeps the bill and the privacy risk small, whichever destination you pick.
3. Make the logs worth shipping
Nginx: write the access log as JSON
The default "combined" format has to be parsed back apart with a regex. JSON with escape=json arrives already split into fields, and a " in a user agent can't break it:
# /etc/nginx/conf.d/store-log.conf (inside http {})
log_format store_json escape=json '{"time":"$time_iso8601","ip":"$remote_addr","method":"$request_method",'
'"uri":"$request_uri","status":$status,"bytes":$body_bytes_sent,"rt":$request_time,'
'"urt":"$upstream_response_time","ua":"$http_user_agent","host":"$host"}';
access_log /var/log/nginx/access.json.log store_json;
rt is the full request time; urt is PHP's share of it. When urt climbs while rt - urt doesn't, the slowness is in PHP or the database, not the network.
PHP: one error log, in UTC
; /etc/php/8.3/fpm/conf.d/99-logging.ini
log_errors = On
error_log = /var/log/php/error.log
date.timezone = UTC
; keep display_errors = Off in production
PHP stamps each line with its zone ([21-Oct-2026 21:12:03 UTC]), and the config below reads that zone. Watch this: Vector reads a time without a zone as the server's local time. In our first run, PHP times landed five hours in the future because the test machine wasn't on UTC. Loki rejected them with 400 timestamp too new, and Vector dropped them, because a 400 isn't retried. Better Stack accepted the same events. So a wrong time doesn't always show up as a wrong time; with Loki it shows up as missing logs.
WooCommerce: move the logs out of the web root
WooCommerce writes one file per source per day: wc-logs/stripe-2026-10-21-<hash>.log. The lines look like 2026-10-21T21:00:53+00:00 CRITICAL Payment gateway timeout CONTEXT: {"order_id":1234}. A multi-line message continues on lines without a timestamp. WooCommerce guards the folder with an .htaccess file, which Nginx ignores. Since WooCommerce 8.8 you can move the folder with a filter:
<?php
// wp-content/mu-plugins/wc-log-dir.php
add_filter( 'woocommerce_log_directory', fn() => '/var/log/woocommerce/' );
If you leave it where it is, add location ^~ /wp-content/uploads/wc-logs/ { deny all; } to the Nginx site config.
4. The Vector config
Install Vector from its official packages, save this as /etc/vector/vector.yaml, put the endpoints and token in /etc/vector/secrets.json (mode 600, owned by the vector user), and add vector to the adm group or whichever group can read the logs. Since Vector 0.57, ${VAR} in config files is no longer expanded unless you pass a flag whose name starts with "dangerously"; the secrets file is the supported way.
# /etc/vector/vector.yaml
# Tails Nginx, PHP, PHP-FPM and WooCommerce logs, turns each entry into one structured event,
# drops noise, masks personal data, and ships to Better Stack and/or Loki (delete the sink you don't use).
data_dir: /var/lib/vector
# Vector 0.57+ no longer expands ${ENV} in config files: endpoints and tokens come from a root-only JSON file,
# {"betterstack_url": "https://<ingesting host>/", "betterstack_token": "...", "loki_url": "http://10.0.0.5:3100"}
secret:
local:
type: file
path: /etc/vector/secrets.json
sources:
nginx_access:
type: file
include: ["/var/log/nginx/access.json.log"]
nginx_error:
type: file
include: ["/var/log/nginx/error.log"]
php:
type: file
include: ["/var/log/php/error.log"]
multiline: # a fatal error and its stack trace become one event
start_pattern: '^\[\d{2}-[A-Za-z]{3}-\d{4} '
condition_pattern: '^\[\d{2}-[A-Za-z]{3}-\d{4} '
mode: halt_before
timeout_ms: 1000
php_fpm:
type: file
include: ["/var/log/php8.3-fpm.log"]
woocommerce:
type: file
include: ["/var/www/shop/wp-content/uploads/wc-logs/*.log"]
ignore_older_secs: 86400 # one file per source per day: don't replay last month on first start
multiline: # multi-line messages continue on lines without a timestamp
start_pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'
condition_pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'
mode: halt_before
timeout_ms: 1000
transforms:
nginx_access_parsed:
type: remap
inputs: [nginx_access]
source: |
.service = "nginx"
j, err = parse_json(.message)
if err == null && is_object(j) {
. = merge!(., j)
status = to_int(.status) ?? 0
# Successful static files are most of the volume and never tell you anything: drop them here.
if status < 400 && match(to_string(.uri) ?? "", r'\.(?:css|js|mjs|png|jpe?g|gif|webp|avif|svg|ico|woff2?)(?:\?|$)') {
abort
}
.level = if status >= 500 { "error" } else if status >= 400 { "warning" } else { "info" }
.timestamp = parse_timestamp(.time, "%+") ?? .timestamp
del(.time)
.message = join([to_string(.method) ?? "-", to_string(.uri) ?? "-", to_string(status)], " ")
}
nginx_error_parsed:
type: remap
inputs: [nginx_error]
source: |
.service = "nginx"
p, err = parse_regex(.message, r'^(?P<ts>\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}) \[(?P<lvl>\w+)\] \d+#\d+: (?:\*\d+ )?(?P<msg>.*)$')
if err == null {
.timestamp = parse_timestamp(p.ts, "%Y/%m/%d %H:%M:%S") ?? .timestamp # local time, read in the same zone
.level = p.lvl
.message = strip_whitespace!(p.msg)
r, err = parse_regex(p.msg, r'request: "(?P<request>[^"]*)"')
if err == null { .request = r.request }
u, err = parse_regex(p.msg, r'upstream: "(?P<upstream>[^"]*)"')
if err == null { .upstream = u.upstream }
}
php_parsed:
type: remap
inputs: [php]
source: |
.service = "php"
p, err = parse_regex(.message, r'(?s)^\[(?P<ts>\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2})(?: (?P<tz>[\w/+-]+))?\] (?:PHP (?P<kind>[A-Za-z ]+?):\s+)?(?P<msg>.*)$')
if err == null {
# PHP names its zone ("UTC"); without one, Vector would read the time in the server's local zone.
.timestamp = parse_timestamp(p.ts, "%d-%b-%Y %H:%M:%S", timezone: string(p.tz) ?? "UTC") ?? .timestamp
kind = downcase(p.kind) ?? ""
.level = if contains(kind, "fatal") || contains(kind, "parse") { "critical" }
else if kind == "warning" { "warning" }
else if kind == "notice" || kind == "deprecated" { "notice" }
else { "error" } # plain error_log() lines, e.g. "WordPress database error"
.message = strip_whitespace!(p.msg)
}
php_fpm_parsed:
type: remap
inputs: [php_fpm]
source: |
.service = "php-fpm"
p, err = parse_regex(.message, r'^\[(?P<ts>\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2})(?:\.\d+)?\] (?P<lvl>[A-Z]+): (?P<msg>.*)$')
if err == null {
.timestamp = parse_timestamp(p.ts, "%d-%b-%Y %H:%M:%S") ?? .timestamp
.level = downcase!(p.lvl)
.message = p.msg
}
woocommerce_parsed:
type: remap
inputs: [woocommerce]
source: |
.service = "woocommerce"
f, err = parse_regex(.file, r'(?P<src>[^/\\]+?)-\d{4}-\d{2}-\d{2}-[0-9a-f]{32}\.log$')
if err == null { .source = f.src } # wc-logs/stripe-2026-10-21-<hash>.log -> "stripe"
p, err = parse_regex(.message, r'(?s)^(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}) (?P<lvl>[A-Z]+) (?P<msg>.*?)(?: CONTEXT: (?P<ctx>\{.*\}))?\s*$')
if err == null {
.timestamp = parse_timestamp(p.ts, "%+") ?? .timestamp
.level = downcase!(p.lvl)
.message = p.msg
if p.ctx != null { .context = parse_json(p.ctx) ?? p.ctx }
}
ship:
type: remap
inputs: [nginx_access_parsed, nginx_error_parsed, php_parsed, php_fpm_parsed, woocommerce_parsed]
source: |
.host = get_hostname() ?? "unknown"
lvl = to_string(.level) ?? "info"
.level = string(get({"warn": "warning", "crit": "critical", "emerg": "emergency", "err": "error"}, [lvl]) ?? null) ?? lvl
# Personal data never leaves the server: emails and card-like digit runs, anywhere in the event.
file = del(.file)
. = redact(., filters: [r'[\w.+-]+@[\w-]+(?:\.[\w-]+)+', r'\b(?:\d[ -]?){12,18}\d\b'])
.file = file
# Keep the network, not the person: 203.0.113.42 -> 203.0.113.0
if is_ipv4(.ip) ?? false { .ip = ip_subnet(.ip, "/24") ?? null } else if is_string(.ip) { .ip = ip_subnet(.ip, "/48") ?? null }
msg = replace(to_string(.message) ?? "", r'client: (\d+\.\d+\.\d+)\.\d+', "client: ${1}.0")
.message = truncate(msg, 8192)
del(.source_type)
to_better_stack:
type: remap
inputs: [ship]
source: .dt = del(.timestamp)
sinks:
better_stack:
type: http
inputs: [to_better_stack]
uri: "SECRET[local.betterstack_url]"
method: post
encoding: { codec: json }
compression: gzip
auth: { strategy: bearer, token: "SECRET[local.betterstack_token]" }
buffer: { type: disk, max_size: 268435488, when_full: block } # survives outages and restarts
acknowledgements: { enabled: true } # file checkpoints move only once the buffer has the event
loki:
type: loki
inputs: [ship]
endpoint: "SECRET[local.loki_url]" # over a private network or a WireGuard tunnel
encoding: { codec: json }
labels: # few, bounded labels; everything else stays in the JSON line
service: "{{ service }}"
level: "{{ level }}"
host: "{{ host }}"
out_of_order_action: accept
buffer: { type: disk, max_size: 268435488, when_full: block }
acknowledgements: { enabled: true }
The details that matter:
- Multi-line: a new PHP entry starts with
[dd-Mon-yyyy; anything else, such asStack trace:and#0 ..., belongs to the entry before it. In the test, each fatal error arrived as one event with its stack trace. - Static files: 30 asset requests in the test were dropped at the server. A 404 on
/missing.csswas kept, as a warning. - Masking: runs over the whole event, so it catches personal data in messages, URLs and WooCommerce's context JSON. A password-reset URL with
?email=in it arrived as[REDACTED]. The card pattern looks for 13 to 19 digits with optional spaces or dashes. Check that your order numbers are shorter. ignore_older_secs: without it, the first start ships every WooCommerce log file still on disk.- Labels: Loki indexes labels, not content. Three labels with a handful of values each keep it fast; a label per URL or status code would create thousands of streams.
5. Destination A: Better Stack
Create a source in Better Stack and choose Vector or HTTP. It gives you an ingesting host and a source token for the secrets file. The documented contract: POST / with Authorization: Bearer, JSON, NDJSON or MessagePack, gzip allowed, up to 10 MiB per request, the event time in a field called dt, and 202 on success. A full free quota returns 402 and a wrong token 403. In our test Vector retried the 503s and dropped the 400s that Loki sent, so treat any 4xx, the 402 for a full quota included, as lost events: watch Vector's log for "Non-retriable error".
Alerts are queries: you write a Live tail query or SQL against the source, then pick a threshold, a relative-change or an anomaly alert, how often it runs, and who gets it (email by default; call, SMS, push or an escalation policy). The useful starting set mirrors the Loki rules below: any service = php event at level critical, three or more status >= 500 on /checkout, wc-ajax= or /wc/store/ within five minutes, and any "upstream timed out" or "connect() failed" line.
6. Destination B: Loki you run
Loki runs as one binary on a small second server. Put it on the same private network as the store, or behind WireGuard. With auth_enabled: false anyone who can reach port 3100 can read and write every log.
# /etc/loki/config.yaml (single binary, filesystem storage, 31 days)
auth_enabled: false
server: {http_listen_address: 10.0.0.5, http_listen_port: 3100, grpc_listen_port: 9096}
common:
instance_addr: 127.0.0.1
path_prefix: /var/lib/loki
storage: {filesystem: {chunks_directory: /var/lib/loki/chunks, rules_directory: /var/lib/loki/rules}}
replication_factor: 1
ring: {kvstore: {store: inmemory}}
schema_config:
configs: [{from: 2026-01-01, store: tsdb, object_store: filesystem, schema: v13, index: {prefix: index_, period: 24h}}]
limits_config:
retention_period: 744h
discover_service_name: [] # Loki 3 otherwise adds a service_name label of its own
compactor: {working_directory: /var/lib/loki/compactor, retention_enabled: true, delete_request_store: filesystem}
ruler:
alertmanager_url: http://127.0.0.1:9093
storage: {type: local, local: {directory: /etc/loki/rules}}
rule_path: /var/lib/loki/ruler-tmp
enable_api: true
The alert rules, in LogQL:
# Loki ruler: /etc/loki/rules/fake/store-alerts.yaml ("fake" is the tenant when auth_enabled is false)
groups:
- name: store-errors
interval: 1m
rules:
- alert: PHPFatalError
expr: sum by (host) (count_over_time({service="php", level="critical"}[5m])) > 0
labels: { severity: page }
annotations: { summary: "PHP fatal error on {{ $labels.host }}" }
- alert: CheckoutServerErrors
expr: |
sum by (host) (count_over_time({service="nginx", level="error"} | json
| uri =~ `.*(checkout|wc-ajax=|/wc/store/).*` [5m])) >= 3
labels: { severity: page }
annotations: { summary: "{{ $value }} server errors on checkout or cart requests in 5 minutes" }
- alert: PHPUpstreamFailing
expr: sum by (host) (count_over_time({service="nginx"} |~ `upstream timed out|connect\(\) .*failed` [5m])) > 0
labels: { severity: page }
annotations: { summary: "Nginx can't get answers from PHP on {{ $labels.host }} (502/504)" }
- alert: WooCommerceCritical
expr: sum by (host) (count_over_time({service="woocommerce", level=~"critical|alert|emergency"}[10m])) > 0
labels: { severity: ticket }
annotations: { summary: "WooCommerce logged a critical entry" }
- alert: PHPFPMOutOfWorkers
expr: sum by (host) (count_over_time({service="php-fpm"} |= "max_children" [15m])) > 0
labels: { severity: ticket }
annotations: { summary: "PHP-FPM hit pm.max_children: requests are queueing" }
Point Grafana at Loki to search, and run Alertmanager next to it to route the alerts. That's three services to update and back up, which is the real cost of this route.
7. Test results
| Check | Result |
|---|---|
| Each PHP fatal error with its stack trace | One event, level critical |
Same fatal also in WooCommerce's fatal-errors log | Yes: one error, two events. The rules alert on the PHP one and treat WooCommerce's as a ticket |
| 3-line WooCommerce message | One event, context JSON parsed (order_id kept as a number) |
| 504 (PHP too slow) and 502 (PHP down) | In the access log as errors, and in the error log with request and upstream as fields |
| Email and card number in a WooCommerce entry | Both [REDACTED], in the message and in the context |
| IP addresses | Cut to the /24, including the client: inside Nginx error lines |
| Better Stack returns 503, Vector killed, restarted, service back | 50 of 50 requests delivered, 0 duplicates |
| Loki during the same outage | 50 of 50, unaffected: each destination has its own buffer |
Log rotation (rename, then nginx -s reopen) | 20 of 20 lines from before and after |
| Loki labels | Only service, level, host |
| All 5 Loki alert rules | Fired and reached Alertmanager |
| Size on the wire | About 350 bytes of JSON per access-log event; gzip brought requests down to 15% to 39% of that, depending on batch size |
One difference showed up between the two destinations. Three identical fatal errors within one second were three events at Better Stack and two in Loki. Loki keeps one copy of entries with the same timestamp and the same line in a stream, and PHP's log only has whole seconds. That doesn't change a "more than zero" alert, but don't count identical errors in Loki and expect an exact number.
8. What it costs
An estimate, not a measurement: a store with 20,000 page views a day, and about four logged requests per page view once static files are dropped (the page, cart fragments, AJAX and Store API calls). At about 350 bytes per event, that's around 28 MB a day, or 0.85 GB a month. Bots can multiply it by three to ten.
| Better Stack | Self-hosted Loki | |
|---|---|---|
| Logs at about 1 GB a month | Inside the free 3 GB, kept for 3 days. Longer retention: $0.10 per GB ingested plus $0.05 per GB per month stored (Europe; US $0.15 and $0.08) | A second server with 2 to 4 GB of RAM, plus its disk and backups |
| On-call | Responder: $34 a month, or $29 billed yearly, which covers Uptime and Telemetry (pricing page, October 2026). We didn't check which alert channels need it | Alertmanager is free; phone calls need a paging service |
| Your time | Setup in an afternoon | Setup plus updates for Loki, Grafana and Alertmanager, and keeping the box secure |
| Data location | Better Stack's regions (Europe, US, Singapore are priced separately) | Wherever your server is |
At store scale, the per-GB price is not the deciding factor; at 1 GB a month either way costs little. What decides is whether someone on the team wants to run three more services, and whether the logs may leave your infrastructure. Better Stack also does uptime checks and status pages, so a store that wants those anyway gets them in the same account.
Related: checkout monitoring with UptimeRobot vs scripts, StatusCake vs DIY SSL and API monitoring and carding attacks on WooCommerce, which this log setup will make visible.
9. Tools
Top pick
| Tool | Role | Why it fits |
|---|---|---|
| Hosted logs, uptime and on-call | Takes Vector's output as is: a bearer token and an ingesting host. 3 GB of logs a month free with 3-day retention; then $0.10 per GB ingested and $0.05 per GB per month retained in Europe. Alerts on SQL or Live tail queries, with threshold, relative and anomaly types, and call, SMS and push through its on-call product (pricing page and docs, October 2026). |
Alternatives: free tools and references
We don't earn anything from these links.
- Grafana Loki: the self-hosted destination in this guide, with Grafana to search and Alertmanager to route alerts.
- Elastic Stack (ELK): full-text search over everything, at the price of more memory and more to run. Vector has an Elasticsearch sink, so the config above only changes at the end.
- Vector: the shipper. Grafana's own agent, Alloy, does the same job if you go all-in on Grafana.
- Vector 0.57 upgrade guide: why
${ENV}stopped working in config files.
10. Pitfalls
- Times without a zone: read in the server's local zone. Run servers on UTC and keep PHP's
date.timezoneset. - Trusting a 400 to be retried: it isn't. Loki rejects too-new and too-old entries, and the shipper drops them.
- A memory buffer: the default. It loses whatever was queued when the shipper restarts. Use a disk buffer with acknowledgements.
- High-cardinality labels: URL, user agent or order ID as a Loki label. Keep them in the line and filter with
| json. - Personal data in logs: query strings and WooCommerce context carry emails. Mask on the server, before it's someone else's data too.
- Exposing Loki: no auth by default. Private network only.
- wc-logs in the web root under Nginx: the
.htaccessguard does nothing. Move the folder or deny it. - Alerting on every warning: page on fatals, checkout 5xx and PHP being unreachable; send the rest to a daily review.
Do the work on the server: one Vector config that joins stack traces, drops static noise, masks personal data and buffers to disk. After that the destination is a business choice. Better Stack gets a small team searching and alerting in an afternoon, at a few dollars a month or nothing at store volumes. Loki keeps the data on your servers at the cost of running three more services.