/webapp-private/api_debug.json`. The two * coexist (R1.md §Draft.3 / SD-3); only this class is the general-purpose * structured logger. * * @package Webapp */ declare(strict_types=1); defined( 'ABSPATH' ) || exit; final class Prweb_Log { /** Bytes; matches `log-entry.schema.json::msg.maxLength` and error_msg cap. */ private const MSG_MAX = 4096; /** Bytes; matches `log-entry.schema.json::error_stack.maxLength`. */ private const STACK_MAX = 16384; /** 14 bytes UTF-8 — ellipsis U+2026 (3) + `[TRUNCATED]` (11). */ private const TRUNCATION_MARKER = "\xE2\x80\xA6[TRUNCATED]"; /** Service name (closed enum from the schema). Set by ::init(). */ private static string $service = 'wp_plugin'; /** Environment from PRWEB_ENV; defaults to 'dev' per F-D3.1. */ private static string $env = 'dev'; /** Optional build SHA from GIT_SHA. */ private static ?string $git_sha = null; /** Whether ::init() has been invoked at least once. */ private static bool $initialized = false; /** * Initialize the logger. Reads PRWEB_ENV (default "dev") + GIT_SHA. * * Fail-soft per R1.md F-D3.1: never throws, never blocks boot. On any * internal error the logger falls back to safe defaults and continues to * accept calls — the control-plane logger is never the reason a customer- * traffic service refuses to come up. */ public static function init( string $service ): void { try { self::$service = $service; $env = Prweb_Brand::config( 'ENV' ); if ( ! is_string( $env ) || $env === '' ) { self::$env = 'dev'; if ( ! self::$initialized ) { // One-time stderr warning so the operator notices the gap // without spamming on every reinitialization. self::stderr( '[webapp-log] PRWEB_ENV unset; defaulting to dev' ); } } elseif ( ! in_array( $env, array( 'dev', 'staging', 'prod' ), true ) ) { self::$env = 'dev'; if ( ! self::$initialized ) { self::stderr( '[webapp-log] PRWEB_ENV invalid; defaulting to dev' ); } } else { self::$env = $env; } $git_sha = getenv( 'GIT_SHA' ); self::$git_sha = ( is_string( $git_sha ) && $git_sha !== '' ) ? $git_sha : null; self::$initialized = true; } catch ( \Throwable $e ) { // Swallow — init must never throw. Fall back to safe defaults. self::$service = $service; self::$env = 'dev'; self::$git_sha = null; self::$initialized = true; } } /** Emit an `info`-level entry. */ public static function info( string $msg, array $fields = array() ): void { self::emit( 'info', $msg, $fields ); } /** Emit a `warn`-level entry. */ public static function warn( string $msg, array $fields = array() ): void { self::emit( 'warn', $msg, $fields ); } /** Emit an `error`-level entry. */ public static function error( string $msg, array $fields = array() ): void { self::emit( 'error', $msg, $fields ); } /** Emit a `critical`-level entry (service refused traffic). */ public static function critical( string $msg, array $fields = array() ): void { self::emit( 'critical', $msg, $fields ); } /** Emit a `debug`-level entry. */ public static function debug( string $msg, array $fields = array() ): void { self::emit( 'debug', $msg, $fields ); } /** Whether ::init() has completed at least once in this process. */ public static function is_initialized(): bool { return self::$initialized; } /** * Test hook — returns the canonical JSON string an entry would emit. * * Applies sanitization + canonicalization. NO `error_log()` side effect. * Runs the same code path as production emit so the fixture roundtrip is * a true contract test, not a mock. */ public static function serialize_for_test( array $entry ): string { $sanitized = self::sanitize( $entry ); return self::canonical_encode( $sanitized ); } /** Build, sanitize, canonicalize, write. Catches every Throwable. */ private static function emit( string $level, string $msg, array $fields ): void { try { $entry = array( 'ts' => self::now_ts(), 'level' => $level, 'service' => self::$service, 'env' => self::$env, 'msg' => $msg, ); if ( self::$git_sha !== null && ! array_key_exists( 'git_sha', $fields ) ) { $entry['git_sha'] = self::$git_sha; } foreach ( $fields as $k => $v ) { $entry[ $k ] = $v; } if ( ( $level === 'error' || $level === 'critical' ) && ! isset( $entry['error_type'] ) ) { $entry['error_type'] = self::infer_error_type( $msg ); } $line = self::serialize_for_test( $entry ); self::stderr( $line ); } catch ( \Throwable $e ) { // Last-resort: never let the logger break the caller. self::stderr( '[webapp-log] emit failed: ' . $e->getMessage() ); } } /** * The single stderr sink. Every line this class writes goes through here. * * Gated per D-14: WordPress.org discourages unconditional `error_log()` in * distributed plugins, and a publisher who installed this from the * directory has no collector reading the stream. Service deployments — * where the structured corpus IS the observability story — opt in with the * `PRWEB_LOG_STDERR` constant or environment variable; `WP_DEBUG` is * honoured as the conventional local-development signal. */ private static function stderr( string $line ): void { if ( ! self::stderr_enabled() ) { return; } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Not debug code: this is the plugin's structured-log transport (wire format pinned by shared/schemas/log-entry.schema.json, byte-identical to the Rust + TS emitters). Gated by stderr_enabled() so a stock wp.org install writes nothing; service deployments opt in via PRWEB_LOG_STDERR. See D-14. @error_log( $line ); } /** * Is the stderr sink enabled for this process? * * Resolution order: `PRWEB_LOG_STDERR` constant → `PRWEB_LOG_STDERR` * environment variable → `WP_DEBUG`. Uncached so a test (or a wp-cli run * that defines the constant late) sees the current state. */ private static function stderr_enabled(): bool { $flag = Prweb_Brand::config( 'LOG_STDERR' ); if ( is_bool( $flag ) || is_int( $flag ) ) { return (bool) $flag; } if ( is_string( $flag ) && $flag !== '' ) { return ! in_array( strtolower( $flag ), array( '0', 'false', 'off', 'no' ), true ); } return defined( 'WP_DEBUG' ) && WP_DEBUG; } /** ISO 8601 UTC, millisecond precision, `Z` suffix. */ private static function now_ts(): string { $now = (float) microtime( true ); $sec = (int) floor( $now ); $ms = (int) round( ( $now - $sec ) * 1000 ); if ( $ms >= 1000 ) { $sec += 1; $ms = 0; } return gmdate( 'Y-m-d\TH:i:s', $sec ) . sprintf( '.%03dZ', $ms ); } /** * Apply the five sanitization rules from `docs/ops/log-spec.md` * §Sanitization. Pure — does not mutate the input. */ private static function sanitize( array $entry ): array { // Rule 4: query-string secrets in http_path. if ( isset( $entry['http_path'] ) && is_string( $entry['http_path'] ) ) { $entry['http_path'] = self::sanitize_http_path( $entry['http_path'] ); } // Rule 5: truncation of msg / error_msg / error_stack. if ( isset( $entry['msg'] ) && is_string( $entry['msg'] ) ) { $entry['msg'] = self::truncate_bytes( $entry['msg'], self::MSG_MAX ); } if ( isset( $entry['error_msg'] ) && is_string( $entry['error_msg'] ) ) { $entry['error_msg'] = self::truncate_bytes( $entry['error_msg'], self::MSG_MAX ); } if ( isset( $entry['error_stack'] ) && is_string( $entry['error_stack'] ) ) { $entry['error_stack'] = self::truncate_bytes( $entry['error_stack'], self::STACK_MAX ); } // Rule 3: base64 image redaction on string-typed fields (stack/msg are // the most common offenders; apply broadly to any string field). foreach ( $entry as $k => $v ) { if ( is_string( $v ) ) { $entry[ $k ] = self::redact_image_data( $v ); } } // Rules 1 + 2: meta walking — bearer + api-key/secret/password/token. if ( isset( $entry['meta'] ) && is_array( $entry['meta'] ) ) { $entry['meta'] = self::sanitize_meta( $entry['meta'] ); } return $entry; } /** Rule 1 + Rule 2 + Rule 3 applied recursively across `meta`. */ private static function sanitize_meta( array $meta ): array { foreach ( $meta as $k => $v ) { if ( is_array( $v ) ) { $meta[ $k ] = self::sanitize_meta( $v ); continue; } if ( is_string( $v ) ) { // Rule 1: Bearer redaction on `meta.authorization` (case-insensitive key). if ( is_string( $k ) && strcasecmp( $k, 'authorization' ) === 0 ) { if ( preg_match( '/^Bearer /', $v ) === 1 ) { $meta[ $k ] = 'Bearer [REDACTED]'; continue; } } // Rule 2: API key / secret / password / token names. if ( is_string( $k ) && preg_match( '/api_?key|secret|password|token/i', $k ) === 1 ) { if ( strlen( $v ) > 12 ) { $meta[ $k ] = substr( $v, 0, 8 ) . "\xE2\x80\xA6[REDACTED]"; continue; } } // Rule 3: base64 image data. $meta[ $k ] = self::redact_image_data( $v ); } } return $meta; } /** Rule 4: replace values of /token|key|secret|password/i query params. */ private static function sanitize_http_path( string $path ): string { $q = strpos( $path, '?' ); if ( $q === false ) { return $path; } $base = substr( $path, 0, $q ); $query = substr( $path, $q + 1 ); // Walk pairs preserving order; only rewrite values, never keys. $pairs = explode( '&', $query ); foreach ( $pairs as $i => $pair ) { $eq = strpos( $pair, '=' ); if ( $eq === false ) { continue; } $name = substr( $pair, 0, $eq ); if ( preg_match( '/token|key|secret|password/i', $name ) === 1 ) { $pairs[ $i ] = $name . '=[REDACTED]'; } } return $base . '?' . implode( '&', $pairs ); } /** Rule 3: base64 image-data fields → opaque marker. */ private static function redact_image_data( string $value ): string { if ( strncmp( $value, 'data:image/', 11 ) === 0 && strlen( $value ) > 256 ) { return '[IMAGE_DATA_REDACTED]'; } return $value; } /** * Byte-correct UTF-8 truncation to `$limit` bytes total (suffix included). * * Matches `scripts/canonicalize-fixtures.mjs::truncatedBody`: when the * input fits, return as-is; otherwise body = first (limit - marker_bytes) * bytes of input, then append marker. `mb_strcut` honors UTF-8 character * boundaries so we never split a multibyte sequence. */ private static function truncate_bytes( string $value, int $limit ): string { if ( strlen( $value ) <= $limit ) { return $value; } $marker_len = strlen( self::TRUNCATION_MARKER ); $body_limit = $limit - $marker_len; if ( $body_limit <= 0 ) { // Pathological — limit smaller than the marker itself; return marker only. return self::TRUNCATION_MARKER; } // For ASCII input, mb_strcut and substr agree. For UTF-8 multi-byte // input we want byte-exact truncation (the fixture contract is bytes, // not characters); substr gives that. mb_strcut would back off to the // last full character boundary, leaving fewer bytes than `body_limit` // — that breaks byte-identical comparison against the JS canonical // body which fills `body_limit` bytes exactly with single-byte chars. $body = substr( $value, 0, $body_limit ); return $body . self::TRUNCATION_MARKER; } /** Infer a bounded snake_case error_type when a caller omits one. */ private static function infer_error_type( string $msg ): string { $slug = strtolower( preg_replace( '/[^A-Za-z0-9]+/', '_', $msg ) ?? '' ); $slug = trim( $slug, '_' ); if ( $slug === '' ) { return 'unknown_error'; } return substr( $slug, 0, 64 ); } /** * Canonical JSON: recursive `ksort`, then * `json_encode( $entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION )`. */ private static function canonical_encode( array $entry ): string { $sorted = self::recursive_ksort( $entry ); $encoded = json_encode( $sorted, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION ); if ( $encoded === false ) { // Best-effort — never throw. Caller has the original $entry; emit a // marker so the operator can grep for canonicalization failures. return '{"prweb_log_encode_error":true}'; } return $encoded; } /** Sort associative-array keys lexicographically, recursively. */ private static function recursive_ksort( array $value ): array { if ( self::is_list( $value ) ) { // Preserve array order (lists are not sorted per the spec). foreach ( $value as $i => $v ) { if ( is_array( $v ) ) { $value[ $i ] = self::recursive_ksort( $v ); } } return $value; } ksort( $value, SORT_STRING ); foreach ( $value as $k => $v ) { if ( is_array( $v ) ) { $value[ $k ] = self::recursive_ksort( $v ); } } return $value; } /** * True when `$value` is a zero-indexed sequential list. Implemented inline * (rather than via `array_is_list()`) so the plugin carries no dependency * on a function WordPress only polyfills from 6.5 for its own baseline. */ private static function is_list( array $value ): bool { $expected = 0; foreach ( $value as $k => $_ ) { if ( $k !== $expected ) { return false; } $expected++; } return true; } }