Access Logging
OxPHP emits structured JSON access logs for every HTTP request, written to stdout. Logging is asynchronous, so it never blocks request handling.
How it works
When ACCESS_LOG is set, OxPHP writes one JSON line to stdout after each completed request. Those writes are buffered in a background writer thread, so logging never blocks the request pipeline.
The mode controls what gets recorded. ACCESS_LOG=all logs every request; ACCESS_LOG=error logs only responses with status 400 or higher.
ACCESS_LOG alone decides whether those lines are written; LOG_LEVEL has no effect on them. A server with its diagnostics set to warn still writes the access log that ACCESS_LOG enables. The one exception is a RUST_LOG directive that names the access_log target explicitly; see Fine-grained filtering.
Every log line carries a request_id field that correlates access log entries with your application logs. When W3C Trace Context propagation is enabled, entries also include trace_id and span_id fields.
Configuration
| Variable | Default | Description |
|---|---|---|
ACCESS_LOG |
(unset) | Access log mode. all logs every request; error logs only 4xx and 5xx responses. Leave unset or empty to disable |
The only accepted values are all and error. Setting an unrecognized value logs a warning and disables access logging.
Log format
Every access log entry is a single JSON line written to stdout:
{
"timestamp": "2026-02-11T12:34:56.789012Z",
"level": "INFO",
"fields": {
"request_id": "67890abc12341a2b0042",
"method": "GET",
"path": "/api/users",
"status": 200,
"duration_us": 1234,
"remote_ip": "10.0.0.1",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0",
"message": "request completed"
}
}When W3C Trace Context is active, trace_id and span_id are included alongside the standard fields:
{
"timestamp": "2026-02-11T12:34:56.789012Z",
"level": "INFO",
"fields": {
"request_id": "67890abc12341a2b0042",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"method": "POST",
"path": "/api/orders",
"status": 201,
"duration_us": 8421,
"remote_ip": "10.0.0.1",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0",
"message": "request completed"
}
}Fields
| Field | Type | Description |
|---|---|---|
request_id |
string | Unique request identifier. See Request IDs |
method |
string | HTTP method (GET, POST, etc.) |
path |
string | Request URI path |
status |
number | HTTP response status code |
duration_us |
number | Total request handling time in microseconds |
remote_ip |
string | Client IP address (no port). When TRUSTED_PROXIES is configured, shows the real client IP extracted from forwarding headers; the proxy's IP only when they yield no address (see Trusted Proxies) |
user_agent |
string | The request's User-Agent header (the first one, if it sent several), cut to 512 bytes (a …(truncated) marker included) when longer. Bytes that are not valid UTF-8 are replaced with U+FFFD. Absent when the request sent none |
trace_id |
string | W3C trace ID (present only when TRACE_CONTEXT=true) |
span_id |
string | W3C span ID (present only when TRACE_CONTEXT=true) |
Fine-grained filtering
OxPHP emits access log entries under the internal access_log logging target, and adds a directive for that target to its own log filter whenever ACCESS_LOG asks for an access log. This keeps the two settings independent: LOG_LEVEL=warn quietens the server and leaves the access log running, without further configuration.
# Quiet diagnostics, access log intact
LOG_LEVEL=warn
ACCESS_LOG=allA RUST_LOG directive that names the target in full takes precedence for that target, including over the directive OxPHP adds. A bare level does not override it, just as LOG_LEVEL does not (RUST_LOG=off quietens the server's own diagnostics and leaves the access log running). Neither does a shortened name such as access=off: it is the less specific of the two directives and loses. Use a full-name directive to silence the access log alone:
# Access log off, warnings and errors still reported
ACCESS_LOG=all
RUST_LOG=warn,access_log=off # `warn` here is what LOG_LEVEL would have beenRUST_LOG, when set and parsable, replaces LOG_LEVEL rather than adding to it, so include your level in it. The combination above is also contradictory (the access log is switched on and then filtered away), so the server reports it once at startup, naming both values. Prefer unsetting ACCESS_LOG.
The access_log target is used for RUST_LOG filtering but is not included in the JSON output. To identify access log entries in downstream systems, use the "message": "request completed" field and the characteristic field set (method, path, status, duration_us).
Troubleshooting
No access log entries appear
Access logging is disabled when ACCESS_LOG is unset or empty.
Fix: Set the variable to all or error:
ACCESS_LOG=allIf it is already set, the remaining possibility is a RUST_LOG that names the access_log target and switches it off; an operator's own directive wins over the one OxPHP adds. The server says so at startup:
{"timestamp":"…","level":"WARN","fields":{"message":"ACCESS_LOG is set but the log filter drops the access_log target — no access log entries will be written","access_log":"all","filter":"warn,access_log=off"}}The filter field shows the directives in force, which here are exactly the ones you set: the directive OxPHP adds for the target is dropped wherever your own overrides it. Remove the access_log=… you wrote from RUST_LOG and the entries are written again.
If the filter is quiet enough to drop WARN as well, the same report is written to stderr as a plain line rather than JSON, so this combination is always reported:
WARN ACCESS_LOG is set but the log filter drops the access_log target — no access log entries will be written (access_log=all, filter=error,access_log=off)A plain LOG_LEVEL (trace, debug, info, warn, error) never causes any of this: a bare level applies to every target at once, and the directive naming this one is the more specific of the two.
Access logs show `error` mode but successful requests are missing
ACCESS_LOG=error only logs responses with status 400 or higher. This is by design. Check the value and switch to all if you need all requests logged.
Check: Confirm the active setting:
curl -s http://localhost:9090/config | jq '.access_log'Log entries appear without `trace_id` and `span_id`
Trace context fields are only present when W3C Trace Context propagation is enabled.
Fix: Enable it with:
TRACE_CONTEXT=trueAnd ensure your upstream client or load balancer sends a traceparent header on requests.
Docker example
services:
app:
image: ghcr.io/oxphp/oxphp:0.13.0
ports:
- "80:80"
- "9090:9090"
volumes:
- ./src:/var/www/html:ro
environment:
ACCESS_LOG: "all"
ENTRY_FILE: "index.php"
INTERNAL_ADDR: "0.0.0.0:9090"Best practices
- Use
ACCESS_LOG=errorin production to reduce log volume while still capturing all failed requests. Successful requests are not logged, but errors at any status 400+ are always captured. - Include the request ID in your application logs via
oxphp_request_id()so you can correlate PHP-level log entries with access log entries. - Use a structured log aggregator such as Elasticsearch, Loki, or Datadog to query and filter JSON log lines efficiently.
Integration
Since logs are JSON lines on stdout, they integrate directly with container log drivers and aggregation tooling:
- Docker collects them automatically through the container log driver (json-file, fluentd, and others).
- On Kubernetes, the node's log agent (Fluentd, Fluent Bit, Filebeat, and others) picks them up.
- Under systemd, journald captures them when OxPHP runs as a systemd service with stdout logging.
No sidecar or file-based log shipping is needed.
Prompts for your agent
These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.
Get OxPHP's access logs into my log aggregator. Read the `features/access-logging` page through the OxPHP documentation MCP server.
Pick the mode this project should run in, check that my `LOG_LEVEL` or `RUST_LOG` settings do not filter the `access_log` target away, and show me how my aggregator should parse the JSON fields next to the application logs this repository already writes.See also
- Request IDs: every log entry includes a
request_idfor tracing - Configuration Reference: complete environment variable reference