uengage.io / php-logger
Structured observability logging for uEngage platform services
Requires
- php: >=7.1
- ext-curl: *
Requires (Dev)
- aws/aws-sdk-php: >=3.0 <3.371
- phpunit/phpunit: ^7.5 || ^9.6
- yoast/phpunit-polyfills: ^3.0
Suggests
- aws/aws-sdk-php: >=3.0 <3.371 — required only for the 'firehose' transport / debug sink, which ships debug logs to S3 via Kinesis Data Firehose (firehose:PutRecord). Capped below 3.371, which raised its own PHP floor to 8.1 and would otherwise break installs on this package's PHP >=7.1 floor.
README
Structured observability logging for uEngage platform services. Write logs to a local file, POST them over HTTP, or emit to stdout - same JSON schema regardless of transport.
Table of Contents
- uengage.io/php-logger
Overview
uengage/logger provides a single Logger class with four log-level methods (info, error, debug, warn). On initialization you choose a transport:
| Transport | How it works | Best for |
|---|---|---|
file |
Appends NDJSON lines to {basePath}/application/{product}.log |
EC2/server - CloudWatch Agent, Datadog Agent, or Fluentd ships the file |
http |
POSTs JSON to an HTTP endpoint | Environments without a cloud agent |
stdout |
Writes one NDJSON line per entry to php://stdout |
Lambda (Bref / custom runtime), Docker |
In addition to the primary transport, a Logger can carry an optional debug sink - an always-on, minLevel-independent channel that ships only DEBUG entries to S3 via Amazon Kinesis Data Firehose. See Debug Sink (Firehose → S3).
The log schema is identical to the Node.js @uengage/logger package - uniform cross-service log analysis.
Requirements
- PHP >= 7.1
ext-curl(required for HTTP transporter; standard on virtually all hosting environments)aws/aws-sdk-php^3.0 - only if you use the Firehose debug sink (composer require aws/aws-sdk-php). Not needed forfile/http/stdout.ext-apcu- recommended on debug-sink producer hosts when using cross-account AssumeRole, so assumed credentials are cached across requests (see Cross-account). Optional; without it, STS is called per request on PHP-FPM.- Composer
Installation
composer require uengage.io/php-logger
Quick Start
File transporter (server / EC2)
<?php use Uengage\Logger\Logger; $logger = new Logger([ 'product' => 'edge', 'service' => 'ordering', 'component' => 'api-server', 'version' => '1.4.2', 'environment' => 'production', 'source' => 'server', 'transport' => ['type' => 'file'], // basePath defaults to /var/log/uengage/ // log written to: /var/log/uengage/application/edge.log ]); $logger->warn('Order placed', [ 'context' => ['order_id' => 'ord_8x2k', 'amount' => 450.00], 'tenant' => ['business_id' => '456', 'parent_id' => '123'], 'user_id' => 'usr_7x9k2m', ]);
HTTP transporter (no cloud agent)
<?php use Uengage\Logger\Logger; $logger = new Logger([ 'product' => 'edge', 'service' => 'ordering', 'component' => 'mobile-app', 'version' => '3.0.0', 'environment' => 'production', 'source' => 'client', 'transport' => ['type' => 'http', 'config' => [ 'apiKey' => 'your-api-key-here', 'batchSize' => 5, ]], ]); $logger->error('Payment webhook timeout', [ 'error' => ['code' => 'PAYMENT_WEBHOOK_TIMEOUT', 'category' => 'engineering', 'upstream' => 'razorpay'], 'context' => ['order_id' => 'ord_8x2k', 'latency_ms' => 30012], 'tenant' => ['business_id' => '456', 'parent_id' => '123'], ]);
Stdout transporter (Lambda / Docker)
$logger = new Logger([ 'product' => 'edge', 'service' => 'ordering', 'component' => 'worker', 'version' => '1.0.0', 'environment' => 'production', 'source' => 'server', 'transport' => ['type' => 'stdout'], ]);
Log Schema
{
"timestamp": "2026-04-07T14:32:01.847Z",
"level": "ERROR",
"product": "edge",
"service": "ordering",
"component": "mobile-app",
"version": "1.4.2",
"environment": "production",
"trace_id": "abc-123-def-456",
"tenant": { "business_id": "456", "parent_id": "123" },
"source": "server",
"message": "Payment webhook timeout",
"user_id": "usr_7x9k2m",
"error": {
"code": "PAYMENT_WEBHOOK_TIMEOUT",
"category": "engineering",
"stack": "TimeoutError: ...",
"upstream": "razorpay"
},
"context": { "order_id": "ord_8x2k", "amount": 450.0, "latency_ms": 30012 }
}
timestamp…message- always presentuser_id,error,context- omitted when not passed
Initialization
$logger = new Logger($config);
Validates synchronously; throws \InvalidArgumentException immediately if anything required is missing or invalid.
Config Reference
[
// ── Required ────────────────────────────────────────────────────────
'product' => string, // e.g. 'edge'
'service' => string, // e.g. 'ordering'
'component' => string, // e.g. 'mobile-app'
'version' => string, // e.g. '1.4.2'
'environment' => string, // 'production' | 'staging' | 'development'
'source' => string, // 'server' | 'client'
'transport' => [
'type' => string, // Required. 'file' | 'http' | 'stdout' | 'firehose'
'config' => [], // Optional. All fields have defaults - see sections below.
],
// ── Optional ────────────────────────────────────────────────────────
'minLevel' => string, // 'debug' | 'info' | 'warn' | 'error'. Default: 'warn'
'debugSink' => [ // Optional. Always-on sink for DEBUG entries only, independent
'type' => 'firehose',// of minLevel. Same { type, config } shape as transport.
'config' => [ ... ], // Typically Firehose → S3. See "Debug Sink (Firehose → S3)".
],
]
Transporters
File Transporter
Appends one NDJSON line per entry to {basePath}/application/{product}.log. The directory is created automatically; all services for the same product on a host share one file.
'transport' => [ 'type' => 'file', 'config' => [ 'basePath' => '/var/log/uengage', // Default: /var/log/uengage/ 'maxFileSizeBytes' => 10 * 1024 * 1024, // Default: 10 MB 'maxRotations' => 5, // Default: 5 ], ],
File rotation - when the file reaches maxFileSizeBytes:
edge.log → edge.log.1 (previous live file)
edge.log.1 → edge.log.2
...
edge.log.5 → deleted
Configure your cloud agent to watch application/edge.log* to pick up rotated files. Writes use FILE_APPEND | LOCK_EX - safe for concurrent PHP-FPM workers.
Files are created mode 0664, dropping the usual umask 022 default so the log is at least group-writable. This is necessary but not sufficient for cross-user appends: 0664 grants write to the file's group, and the group is fixed at creation (the creator's egid, or the directory's group if it is setgid). Whether another user can then append depends on that user being in that group — which the library cannot arrange. If your fpm user and your cron user don't share the log's group, you still need an ops-side fix.
Write failures are rate-limited (since 2.0.0 — see CHANGELOG). On a failed write (typically an unwritable path) the transporter reports once to error_log(), then stops attempting writes for 60 s before probing the path again. The count of dropped entries rides along on the next report, on the first successful write after recovery, and on destroy(). file_put_contents()'s own E_WARNING is suppressed and its reason recovered via error_get_last(), so a tripped breaker costs exactly one stderr line rather than two — PHP's plus ours.
Note the trade: entries in that 60 s window are dropped without an attempt, so a path fixed mid-window isn't picked up until the next probe.
The cooldown lives on the transporter instance, which is what the bound is relative to:
| Runtime | Instance lifetime | Effective bound on a persistently broken path |
|---|---|---|
| php-fpm / mod_php | one request | one stderr line per request |
| warm Lambda container, CLI daemon, Swoole/RoadRunner | many units of work | ~one line per minute |
Either way the per-response cost is capped, which is what prevents the 502. Don't write "~1/minute" into alerting copy for a php-fpm deployment.
This matters under php-fpm:
error_log()output goes to FCGI stderr, which nginx buffers in the same space as the response headers. Reporting every failed entry let a request that logs tens of entries overflowfastcgi_buffer_size, and nginx answered 502upstream sent too big headereven though PHP had completed the work. Capping it at one line per failure window keeps a broken log path from taking the endpoint down. Raisingfastcgi_buffer_sizeon the nginx side is still worthwhile defence in depth.
HTTP Transporter
POSTs log entries to an HTTP endpoint via cURL.
'transport' => [ 'type' => 'http', 'config' => [ 'endpoint' => 'https://observability.platform.uengage.in/logs', // Default 'apiKey' => 'your-api-key', // Optional. Sent as x-api-key header. 'batchSize' => 5, // Default: 5 'flushIntervalMs' => 5000, // Accepted for config parity; no-op in PHP 'timeoutMs' => 5000, // Default: 5000 ms ], ],
- Immediate mode (
batchSize: 1) - one POST per call; body is a plain JSON object. - Batch mode (default
batchSize: 5) - entries queue; body is a JSON array. Call$logger->destroy()before exit to flush the remaining queue. - cURL errors and non-2xx responses are written to
error_log()with prefix[uengage-logger][http]; the host application is never interrupted.
Stdout Transporter
Writes one NDJSON line per entry to php://stdout. No config knobs.
'transport' => ['type' => 'stdout'],
Best for AWS Lambda (Bref / custom PHP runtime) and Docker - the runtime captures stdout into CloudWatch Logs or your log-aggregation service. Uses php://stdout rather than the STDOUT constant, which is undefined under PHP-FPM and most Lambda runtime adapters.
Debug Sink (Firehose → S3)
DEBUG logs are high-volume and rarely belong in the primary observability stream. The debug sink is a dedicated, always-on channel that ships only DEBUG-level entries to S3 via Amazon Kinesis Data Firehose - separately from, and in addition to, the primary transport.
$logger = new Logger([ 'product' => 'edge', 'service' => 'ordering', 'component' => 'api-server', 'version' => '1.4.2', 'environment' => 'production', 'source' => 'server', // Primary transport — unchanged. minLevel still governs it (default 'warn'). 'transport' => ['type' => 'file'], // Always-on debug sink → Firehose → S3. 'debugSink' => ['type' => 'firehose', 'config' => [ 'deliveryStreamName' => 'uengage-debug-logs-prod', // Required 'region' => 'ap-south-1', // Default: ap-south-1 'timeoutMs' => 200, // Per-batch timeout. Default: 200 ms // 'autoFlush' => true, // Default. Set FALSE on Lambda. // 'maxBufferBytes' => 4194304, // Auto-flush at this size. Default: 4 MiB // Cross-account auth: assume a role in the platform account before // PutRecord. Defaults to the prod platform put-role (below), so most // producers set nothing here. // 'assumeRoleArn' => 'arn:aws:iam::822426641710:role/uengage-debug-logs-put-prod', // 'externalId' => 'uengage-debug-logs', // If the role requires one // 'stsTimeoutMs' => 1000, // AssumeRole timeout. Default: 1000 ms // 'endpoint' => 'http://localhost:4566', // Optional (LocalStack / VPC endpoint) ]], ]); $logger->debug('DB query executed', ['context' => ['table' => 'orders', 'duration_ms' => 45]]); // → NOT written to the file (below the default minLevel 'warn') // → BUFFERED in memory; shipped to Firehose → S3 on flush (see "Flushing" below)
Behaviour
| Aspect | Detail |
|---|---|
| Levels captured | DEBUG only. info/warn/error never reach the sink. |
| minLevel | Bypassed. DEBUG reaches the sink even when the primary minLevel (e.g. warn) drops it. |
| Delivery | In-memory batched, one record per entry. send() buffers the entry; nothing is sent until flush, which ships every buffered NDJSON line as its own Firehose record in aggregated PutRecordBatch calls (N entries still collapse into one HTTP call). One record = one JSON object is a hard requirement of the stream's dynamic partitioning — multi-object records are silently mis-routed to the errors/ prefix, invisible to Athena. |
| Flush triggers | (1) process shutdown when autoFlush is on (EC2/PHP-FPM), (2) buffer reaching maxBufferBytes (default 4 MiB), (3) explicit $logger->flush() / $logger->destroy(). See Flushing. |
| Timeout | timeoutMs (default 200 ms), connect + total, per batch call. SDK retries disabled so an outage fails fast. |
| Failure handling | Two hops. Entries a PutRecordBatch fails to ship (exception, timeout, or per-record reject) are re-sent to the SQS backup queue (on by default) and replayed into the stream by the platform redrive Lambda. Only when BOTH hops fail is one summary ERROR (code: FIREHOSE_DELIVERY_FAILED, context.dropped_entries) emitted to the primary transport + error_log(). Never throws. A transport failure also opens a per-hop circuit breaker (breakerCooldownSeconds, default 30 s; 0 disables): while open, that hop is skipped entirely, so an outage costs one failed call per cooldown window per host — not timeout×retries on every request (which would exhaust the FPM worker pool). |
| Backup | backup.queueUrl defaults to the prod redrive queue (same convention as assumeRoleArn); override for dev/uat or set '' to disable. Looser budget than the put — backup.timeoutMs (default 2000 ms) with backup.maxRetries (default 1) — because it only runs on the already-slow failure path. Shares the Firehose credentials (the put-role carries the sqs:SendMessage grant). |
| Billing | One entry = one Firehose record, so Firehose's 5 KB per-record rounding applies per entry — unchanged from 2.x, which also sent one record per entry. Batching cuts HTTP calls, not ingested bytes. Records cannot be aggregated to avoid the rounding: the stream's dynamic partitioning requires one JSON object per record. |
| Credentials | assumeRoleArn (default: prod platform put-role) > explicit accessKeyId/secretAccessKey > SDK default chain. Set assumeRoleArn => '' to force the default chain. |
Durability: at-least-once while the process lives. A Firehose failure is absorbed by the SQS backup and replayed, so an outage of either service alone loses nothing (expect occasional duplicates — dedupe downstream on
trace_id). Entries are still lost if the process dies hard (OOM / segfault /SIGKILL) before a flush, if a single entry exceeds the 248 KiB SQS body cap, or if Firehose AND SQS fail on the same flush — that last case is what the fallbackERRORreports. Two caveats: both hops share one credential provider, so a failed assume-role kills Firehose and SQS with the same error (the hops are only independent for service-side failures); and the backup queue auto-recovers from blips of roughly 20 minutes (8 receives × 180 s visibility) — beyond that, messages dead-letter and need a console redrive, so the 7-day queue retention is a forensics window, not the recovery window.
Dependency: the Firehose sink requires
aws/aws-sdk-php(composer require aws/aws-sdk-php). If it is not installed, constructing a logger with afirehosesink throws a clear\RuntimeExceptionat boot rather than silently dropping logs.
Flushing: EC2 vs Lambda
Because entries are batched in memory, something has to flush them. This differs by runtime:
EC2 / PHP-FPM / CLI — leave autoFlush on (default). The transporter registers a register_shutdown_function that flushes when the request/script ends, so you don't call anything. Two refinements:
- For lowest client latency, call
fastcgi_finish_request()after sending the response (framework post-response hook) so the flush runs after the client already has the response. - Long-running workers/daemons that never exit rely on the
maxBufferBytesauto-flush; also call$logger->flush()at the end of each job for timely delivery.
// PHP-FPM: nothing to do — autoFlush handles it. (Optionally register explicitly.) register_shutdown_function([$logger, 'flush']);
AWS Lambda — safe by default: when AWS_LAMBDA_FUNCTION_NAME is set and autoFlush is not configured, the sink flushes after every send() (the execution environment — and its buffer — persists across invocations, so the shutdown-function flush would only run on environment teardown, mixing invocations and losing data). Batching on Lambda is opt-in: set autoFlush => false and flush at the end of every invocation in a finally:
$logger = new Logger([ /* ... */, 'debugSink' => ['type' => 'firehose', 'config' => [ 'deliveryStreamName' => 'uengage-debug-logs-prod', 'autoFlush' => false, // ← required on Lambda ]]]); function handler($event) { global $logger; try { // ... work, $logger->debug(...) ... return $response; } finally { $logger->flush(); // one aggregated PutRecordBatch per invocation } }
flush() is idempotent, so an extra call (or a destroy() afterwards) is harmless.
Cross-account: legacy producers → platform-account S3
The debug-log bucket + Firehose stream live in the platform account, but producers run in the legacy account. Firehose has no resource-based policy for direct PutRecord, so cross-account access is via AssumeRole, not a grant on the stream:
- The producer's own EC2 instance role / Lambda exec role (legacy account) assumes
assumeRoleArn(platform account), which holdsfirehose:PutRecordon the stream. assumeRoleArndefaults toarn:aws:iam::822426641710:role/uengage-debug-logs-put-prod(provisioned by theDebugLogFirehoseCDK construct inservices/logs). Override it for dev/uat; set it to''when the producer already runs in the platform account.- The producer role must additionally be granted
sts:AssumeRoleon that ARN, out of band in the legacy account. Pure IAM - no static keys.
Credential caching (important for PHP-FPM): PHP is shared-nothing, so without a cross-request cache every web request would perform its own STS AssumeRole call. The transporter caches the assumed credentials in APCu when ext-apcu is present (shared across FPM workers on the host), collapsing that to ~one STS call per host per hour. Install ext-apcu on producer hosts - without it the transporter falls back to per-request memoization (per-request STS on PHP-FPM; fine on warm Lambda, where the process persists across invocations).
Firehose → S3 setup: the
DebugLogFirehoseCDK construct provisions the stream (GZIP → S3, date-partitioned), delivery role, and cross-account put-role. Records are newline-delimited JSON, so the S3 objects are directly queryable with Athena / S3 Select.
The same { type, config } shape is accepted, so a debug sink can also target file, http, or stdout - but firehose is the intended production path.
Log Methods
Method Signature
$logger->info ($message, $options = []) $logger->error($message, $options = []) $logger->debug($message, $options = []) $logger->warn ($message, $options = [])
Log Options Reference
[
'trace_id' => string, // UUID for distributed tracing. Auto-generated if not provided.
'user_id' => string, // Omitted from the entry when not provided.
'tenant' => [
'business_id' => string,
'parent_id' => string,
],
'error' => [
'code' => string, // Machine-readable error code
'category' => string, // 'business' | 'engineering'
'stack' => string, // Stack trace string
'upstream' => string, // External service that caused the error
],
// Include for error and warn events. Omitted when not provided.
'context' => [...], // Arbitrary key-value pairs. Deep-cloned at log time. Omitted when not provided.
]
Level Filtering
Set minLevel to suppress low-priority logs without changing call sites (default: 'warn'):
$logger = new Logger([..., 'minLevel' => 'warn']);
| minLevel | DEBUG | INFO | WARN | ERROR |
|---|---|---|---|---|
'warn' (default) |
- | - | ✓ | ✓ |
'info' |
- | ✓ | ✓ | ✓ |
'error' |
- | - | - | ✓ |
'debug' |
✓ | ✓ | ✓ | ✓ |
Graceful Shutdown
| Transport | Action needed |
|---|---|
file |
None - writes are synchronous. |
http (immediate, batchSize=1) |
None - each call fires synchronously. |
http (batch, default batchSize=5) |
Call $logger->destroy() before exit to flush the queue. |
stdout |
None - writes are synchronous. |
debugSink (firehose) |
EC2/PHP-FPM: none (autoFlush flushes at shutdown). Lambda: set autoFlush => false and $logger->flush() per invocation - see Flushing. |
Register shutdown for HTTP batch mode:
register_shutdown_function(function () use ($logger) { $logger->destroy(); });
CodeIgniter 2 Integration
Prerequisite - load Composer autoloader
CI2 does not load Composer's autoloader by default. Add one of the following:
Option A - application/config/config.php (recommended):
require_once APPPATH . '../vendor/autoload.php';
Option B - index.php (before the CI bootstrap):
require_once 'vendor/autoload.php';
Logger_service library
Logger_service acts as a lazy factory for the logger. Load it once per controller; it caches one Logger instance per service name for the lifetime of the request.
1. Load the library in your controller
class My_controller extends CI_Controller { public function __construct() { parent::__construct(); $this->load->library('logger_service'); } }
2. Get a logger and write a log
// error $this->logger_service->get('order-service')->error('Payment failed', [ 'error' => ['code' => 'PAYMENT_GATEWAY_TIMEOUT', 'category' => 'engineering'], 'tenant' => ['business_id' => (string) $businessId, 'parent_id' => '0'], 'context' => ['order_id' => $orderId], ]); // warn $this->logger_service->get('order-service')->warn('Retry attempt', [ 'context' => ['attempt' => 2], ]);
3. Use from a library / helper (no $this)
$CI =& get_instance(); $CI->logger_service->get('cart-service')->info('Item added', ['context' => ['sku' => $sku]]);
How it works internally
| What | Detail |
|---|---|
| Factory method | get(string $service, string $component = 'edge-server') |
| Caching | One Logger instance per "service:component" key per request |
| Log file | loggerlogs/edge-{service}.log |
| Min level | warn in production, debug in all other environments |
| Fallback | If Logger construction fails, a NullLogger is returned - your code never throws |
Tip - dynamic log level
$level = $isCritical ? 'error' : 'warn'; $this->logger_service->get('feed-service')->$level('Feed validation failed', $ctx);
Flushing HTTP batch mode in CI2
Register destroy() via a post_system hook:
// application/config/config.php $config['enable_hooks'] = TRUE; // application/config/hooks.php $hook['post_system'][] = [ 'function' => [$logger, 'destroy'], 'filename' => '', 'filepath' => '', ];
Or via register_shutdown_function in your base controller:
register_shutdown_function([$this->logger, 'destroy']);
CodeIgniter 4 Integration
CI4 includes Composer autoloading out of the box - no manual require needed.
Register as a CI4 Service (app/Config/Services.php):
<?php namespace Config; use Uengage\Logger\Logger; use CodeIgniter\Config\BaseService; class Services extends BaseService { public static function uengageLogger(bool $getShared = true): Logger { if ($getShared) { return static::getSharedInstance('uengageLogger'); } return new Logger([ 'product' => 'edge', 'service' => 'ordering', 'component' => 'api-server', 'version' => '1.0.0', 'environment' => ENVIRONMENT, // 'production' | 'testing' | 'development' 'source' => 'server', 'transport' => ['type' => 'file'], ]); } }
Use in any Controller, Model, or Library:
service('uengageLogger')->warn('Order placed', [ 'tenant' => ['business_id' => '456', 'parent_id' => '123'], 'context' => ['order_id' => 'ord_8x2k', 'amount' => 450.00], ]); service('uengageLogger')->error('Payment failed', [ 'error' => ['code' => 'PAYMENT_GATEWAY_TIMEOUT', 'category' => 'engineering', 'upstream' => 'razorpay'], 'context' => ['order_id' => $orderId], ]);
Flushing HTTP batch mode - register destroy() in a CI4 After-filter or your BaseController destructor:
// app/Filters/LoggerShutdown.php class LoggerShutdown implements FilterInterface { public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { service('uengageLogger')->destroy(); } }
PHP-Specific Notes
| Behaviour | PHP implementation | Note |
|---|---|---|
| Non-blocking writes | Synchronous - writes complete inline | PHP has no event loop |
flushIntervalMs |
Accepted in config but no-op | Use destroy() instead |
| UUID generation | openssl_random_pseudo_bytes (preferred) or mt_rand fallback |
|
| Deep clone | json_decode(json_encode($val), true) |
Equivalent to JS structuredClone() |
| Timestamp | gmdate() + microtime(true) ms |
Produces Z suffix matching ISO 8601 / Node output |
| Error output | error_log() |
Visible in /var/log/php_errors.log or Apache/Nginx error log |
| Concurrent file writes | file_put_contents(..., FILE_APPEND | LOCK_EX) |
Prevents torn writes from concurrent FPM workers |
Examples
Business event - order placed
$logger->warn('Order placed', [ 'trace_id' => $_SERVER['HTTP_X_TRACE_ID'] ?? null, 'user_id' => $user->id, 'tenant' => ['business_id' => '456', 'parent_id' => '123'], 'context' => ['order_id' => 'ord_8x2k', 'amount' => 450.00, 'items' => 3], ]);
{
"timestamp": "2026-04-07T14:30:00.000Z",
"level": "WARN",
"product": "edge",
"service": "ordering",
"component": "api-server",
"version": "1.4.2",
"environment": "production",
"trace_id": "abc-123-def-456",
"tenant": { "business_id": "456", "parent_id": "123" },
"source": "server",
"message": "Order placed",
"user_id": "usr_7x9k2m",
"context": { "order_id": "ord_8x2k", "amount": 450.0, "items": 3 }
}
Engineering error - payment gateway timeout
try { $razorpay->capturePayment($payload); } catch (Exception $e) { $logger->error('Payment webhook timeout', [ 'trace_id' => $_SERVER['HTTP_X_TRACE_ID'] ?? null, 'user_id' => $user->id, 'tenant' => ['business_id' => '456', 'parent_id' => '123'], 'error' => [ 'code' => 'PAYMENT_WEBHOOK_TIMEOUT', 'category' => 'engineering', 'stack' => $e->getTraceAsString(), 'upstream' => 'razorpay', ], 'context' => ['order_id' => 'ord_8x2k', 'amount' => 450.00, 'latency_ms' => 30012], ]); }
Warning - rate limit approaching
$logger->warn('Rate limit approaching', [ 'tenant' => ['business_id' => '456', 'parent_id' => '123'], 'error' => ['code' => 'RATE_LIMIT_NEAR_THRESHOLD', 'category' => 'engineering'], 'context' => [ 'endpoint' => '/v1/orders', 'requests_remaining' => 12, 'window_resets_at' => '2026-04-07T15:00:00Z', ], ]);
Debug - database query
$logger->debug('DB query executed', [ 'context' => ['table' => 'orders', 'duration_ms' => 45, 'rows_returned' => 1], ]);
Architecture
Logger
├── _validateConfig() validates required fields, transport + debugSink config
├── _log() builds entry, then routes to two independent sinks:
│ ├── strtoupper($level)
│ ├── _generateUuid() auto trace_id when not supplied
│ ├── json_decode(json_encode(...), true) deep-clones context/error/tenant
│ ├── if severity >= minLevel → _transporter->send($entry) (primary)
│ └── if level === 'debug' → _debugTransporter->send($entry) (debug sink, bypasses minLevel)
├── _transporter->send($entry) (primary transport)
│ ├── FileTransporter
│ │ ├── path: {basePath}/application/{product}.log (dir auto-created)
│ │ └── file_put_contents(..., FILE_APPEND | LOCK_EX)
│ │ └── _rotate() when file exceeds maxFileSizeBytes
│ ├── HttpTransporter
│ │ ├── immediate (batchSize=1): _post([$entry]) one cURL POST per call
│ │ └── batching (batchSize>1): queue → _flush()
│ │ triggered by batchSize threshold or destroy()
│ └── StdoutTransporter
│ └── fwrite($handle, json_encode($entry) . PHP_EOL)
└── _debugTransporter->send($entry) (optional debug sink)
└── FirehoseTransporter
├── send() → buffers NDJSON in memory (auto-flush at maxBufferBytes)
└── flush() → aggregate lines into ~1 MB records → putRecordBatch (<=500/4 MB)
├── triggered by shutdown (autoFlush, EC2), maxBufferBytes, or Logger::flush()/destroy()
└── on failure → one summary ERROR to the primary transport + error_log()
Error contract: every transporter catches all internal errors and writes to error_log(). A logging failure never throws to the caller. FileTransporter additionally rate-limits its own reporting to one line per 60 s failure window — see File Transporter for why that bound exists.
Running Tests
composer install ./vendor/bin/phpunit
Expected output:
PHPUnit 5.7.x by Sebastian Bergmann and contributors.
................................. 33 / 33 (100%)
Time: Xs, Memory: XMb
OK (33 tests, X assertions)