- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
532 lines
17 KiB
PHP
532 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Logging\Formatter\StructuredFormatter;
|
|
use App\Framework\Logging\LogLevel;
|
|
use App\Framework\Logging\LogRecord;
|
|
use App\Framework\Logging\ValueObjects\LogContext;
|
|
|
|
describe('StructuredFormatter', function () {
|
|
beforeEach(function () {
|
|
$this->timestamp = new DateTimeImmutable('2024-01-15 10:30:45', new DateTimeZone('Europe/Berlin'));
|
|
});
|
|
|
|
describe('constructor', function () {
|
|
it('accepts default format', function () {
|
|
$formatter = new StructuredFormatter();
|
|
|
|
expect($formatter instanceof StructuredFormatter)->toBeTrue();
|
|
});
|
|
|
|
it('accepts logfmt format', function () {
|
|
$formatter = new StructuredFormatter(format: 'logfmt');
|
|
|
|
expect($formatter instanceof StructuredFormatter)->toBeTrue();
|
|
});
|
|
|
|
it('accepts kv format', function () {
|
|
$formatter = new StructuredFormatter(format: 'kv');
|
|
|
|
expect($formatter instanceof StructuredFormatter)->toBeTrue();
|
|
});
|
|
|
|
it('accepts array format', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
expect($formatter instanceof StructuredFormatter)->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('logfmt format', function () {
|
|
it('formats as logfmt string', function () {
|
|
$formatter = new StructuredFormatter(format: 'logfmt');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test message',
|
|
context: LogContext::withData(['user_id' => 123]),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result)->toBeString();
|
|
// Implementation uses abbreviated keys: ts, level, msg
|
|
expect(str_contains($result, 'level=INFO'))->toBeTrue();
|
|
expect(str_contains($result, 'msg="Test message"'))->toBeTrue();
|
|
expect(str_contains($result, 'user_id=123'))->toBeTrue();
|
|
});
|
|
|
|
it('quotes values with spaces', function () {
|
|
$formatter = new StructuredFormatter(format: 'logfmt');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Multi word message',
|
|
context: LogContext::empty(),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect(str_contains($result, 'msg="Multi word message"'))->toBeTrue();
|
|
});
|
|
|
|
it('quotes values with equals sign', function () {
|
|
$formatter = new StructuredFormatter(format: 'logfmt');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::withData(['equation' => '2+2=4']),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect(str_contains($result, 'equation="2+2=4"'))->toBeTrue();
|
|
});
|
|
|
|
it('escapes quotes in values', function () {
|
|
$formatter = new StructuredFormatter(format: 'logfmt');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::withData(['quote' => 'He said "hello"']),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect(str_contains($result, 'quote="He said \\"hello\\""'))->toBeTrue();
|
|
});
|
|
|
|
it('includes timestamp', function () {
|
|
$formatter = new StructuredFormatter(format: 'logfmt');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test message',
|
|
context: LogContext::empty(),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
// Implementation uses 'ts' key
|
|
expect(str_contains($result, 'ts='))->toBeTrue();
|
|
expect(str_contains($result, '2024-01-15'))->toBeTrue();
|
|
});
|
|
|
|
it('includes channel when provided', function () {
|
|
$formatter = new StructuredFormatter(format: 'logfmt');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test message',
|
|
context: LogContext::empty(),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp,
|
|
channel: 'security'
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect(str_contains($result, 'channel=security'))->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('kv format', function () {
|
|
it('formats as key-value string', function () {
|
|
$formatter = new StructuredFormatter(format: 'kv');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test message',
|
|
context: LogContext::withData(['user_id' => 123]),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result)->toBeString();
|
|
expect(str_contains($result, 'level: INFO'))->toBeTrue();
|
|
expect(str_contains($result, 'msg: Test message'))->toBeTrue();
|
|
expect(str_contains($result, 'user_id: 123'))->toBeTrue();
|
|
});
|
|
|
|
it('uses colon separator', function () {
|
|
$formatter = new StructuredFormatter(format: 'kv');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::withData(['key' => 'value']),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect(str_contains($result, 'key: value'))->toBeTrue();
|
|
expect(str_contains($result, '='))->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('array format', function () {
|
|
it('returns array', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test message',
|
|
context: LogContext::empty(),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result)->toBeArray();
|
|
});
|
|
|
|
it('includes all standard fields', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test message',
|
|
context: LogContext::empty(),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp,
|
|
channel: 'app'
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
// Implementation uses abbreviated keys
|
|
expect(isset($result['ts']))->toBeTrue();
|
|
expect(isset($result['level']))->toBeTrue();
|
|
expect(isset($result['msg']))->toBeTrue();
|
|
expect(isset($result['channel']))->toBeTrue();
|
|
});
|
|
|
|
it('includes context data', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test message',
|
|
context: LogContext::withData([
|
|
'user_id' => 123,
|
|
'action' => 'login'
|
|
]),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['user_id'])->toBe('123'); // Sanitized to string
|
|
expect($result['action'])->toBe('login');
|
|
});
|
|
|
|
it('includes extras', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = (new LogRecord(
|
|
message: 'Test message',
|
|
context: LogContext::empty(),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
))->addExtra('request_id', 'req-123');
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['request_id'])->toBe('req-123');
|
|
});
|
|
|
|
it('flattens context and extras', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = (new LogRecord(
|
|
message: 'Test message',
|
|
context: LogContext::withData(['ctx_key' => 'ctx_value']),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
))->addExtra('extra_key', 'extra_value');
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['ctx_key'])->toBe('ctx_value');
|
|
expect($result['extra_key'])->toBe('extra_value');
|
|
});
|
|
});
|
|
|
|
describe('key sanitization', function () {
|
|
it('removes leading underscores', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::withData([
|
|
'_internal' => 'value'
|
|
]),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect(isset($result['internal']))->toBeTrue();
|
|
});
|
|
|
|
it('makes keys aggregation friendly', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::withData([
|
|
'some.nested.key' => 'value1',
|
|
'another-key' => 'value2'
|
|
]),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect(isset($result['some_nested_key']))->toBeTrue();
|
|
expect(isset($result['another_key']))->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('value sanitization', function () {
|
|
it('handles string values', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::withData(['text' => 'simple string']),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['text'])->toBe('simple string');
|
|
});
|
|
|
|
it('converts numeric values to strings', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::withData([
|
|
'integer' => 42,
|
|
'float' => 3.14
|
|
]),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['integer'])->toBe('42');
|
|
expect($result['float'])->toBe('3.14');
|
|
});
|
|
|
|
it('converts boolean values to strings', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::withData([
|
|
'success' => true,
|
|
'failed' => false
|
|
]),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['success'])->toBe('true');
|
|
expect($result['failed'])->toBe('false');
|
|
});
|
|
|
|
it('converts null to string', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::withData(['nullable' => null]),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['nullable'])->toBe('null');
|
|
});
|
|
|
|
it('converts arrays to JSON strings', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::withData([
|
|
'items' => ['item1', 'item2', 'item3']
|
|
]),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['items'])->toBeString();
|
|
expect(str_contains($result['items'], 'item1'))->toBeTrue();
|
|
expect(str_contains($result['items'], 'item2'))->toBeTrue();
|
|
});
|
|
|
|
it('converts objects to class name', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$obj = new stdClass();
|
|
$obj->prop = 'value';
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::withData(['object' => $obj]),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['object'])->toBe('stdClass');
|
|
});
|
|
});
|
|
|
|
describe('log levels', function () {
|
|
it('formats DEBUG level', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Debug',
|
|
context: LogContext::empty(),
|
|
level: LogLevel::DEBUG,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['level'])->toBe('DEBUG');
|
|
});
|
|
|
|
it('formats all log levels correctly', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$levels = [
|
|
LogLevel::DEBUG,
|
|
LogLevel::INFO,
|
|
LogLevel::NOTICE,
|
|
LogLevel::WARNING,
|
|
LogLevel::ERROR,
|
|
LogLevel::CRITICAL,
|
|
LogLevel::ALERT,
|
|
LogLevel::EMERGENCY
|
|
];
|
|
|
|
foreach ($levels as $level) {
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::empty(),
|
|
level: $level,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['level'])->toBe($level->getName());
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('readonly behavior', function () {
|
|
it('is a readonly class', function () {
|
|
$reflection = new ReflectionClass(StructuredFormatter::class);
|
|
|
|
expect($reflection->isReadOnly())->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', function () {
|
|
it('handles empty context and extras', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::empty(),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
// Should still include standard fields
|
|
expect(isset($result['ts']))->toBeTrue();
|
|
expect(isset($result['level']))->toBeTrue();
|
|
expect(isset($result['msg']))->toBeTrue();
|
|
});
|
|
|
|
it('handles very long values', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$longValue = str_repeat('x', 10000);
|
|
|
|
$record = new LogRecord(
|
|
message: $longValue,
|
|
context: LogContext::empty(),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect($result['msg'])->toHaveLength(10000);
|
|
});
|
|
|
|
it('handles unicode characters', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Unicode: 你好 🌍',
|
|
context: LogContext::withData(['key' => 'мир']),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
expect(str_contains($result['msg'], '你好'))->toBeTrue();
|
|
expect(str_contains($result['msg'], '🌍'))->toBeTrue();
|
|
expect($result['key'])->toBe('мир');
|
|
});
|
|
|
|
it('handles null channel', function () {
|
|
$formatter = new StructuredFormatter(format: 'array');
|
|
|
|
$record = new LogRecord(
|
|
message: 'Test',
|
|
context: LogContext::empty(),
|
|
level: LogLevel::INFO,
|
|
timestamp: $this->timestamp,
|
|
channel: null
|
|
);
|
|
|
|
$result = $formatter($record);
|
|
|
|
// Channel should not be in array if null
|
|
expect(isset($result['channel']))->toBeFalse();
|
|
});
|
|
});
|
|
});
|