diff --git a/src/Formatter/SimpleFormatter.php b/src/Formatter/SimpleFormatter.php index a16cc24..1b50931 100644 --- a/src/Formatter/SimpleFormatter.php +++ b/src/Formatter/SimpleFormatter.php @@ -77,6 +77,9 @@ public function format(LogMessage $event): string { $output = $this->format; $context = $event->context(); + $context['timestamp'] = $event->timestamp()->format('c'); + $context['level'] = $event->level()->criticality(); + $context['levelName'] = $event->level()->name(); $context['message'] = $event->formattedMessage(); foreach ($context as $name => $value) { $output = str_replace("%$name%", (string) $value, $output); diff --git a/src/Formatter/XmlFormatter.php b/src/Formatter/XmlFormatter.php index 1b7765e..daeaa5c 100644 --- a/src/Formatter/XmlFormatter.php +++ b/src/Formatter/XmlFormatter.php @@ -71,7 +71,7 @@ public function format(LogMessage $event): string $dom = new DOMDocument(); $elt = $dom->appendChild(new DOMElement($this->options['elementEntry'])); - $elt->appendChild(new DOMElement($this->options['elementTimestamp'], date('c'))); + $elt->appendChild(new DOMElement($this->options['elementTimestamp'], $event->timestamp()->format('c'))); $elt->appendChild(new DOMElement($this->options['elementMessage'], $message)); $elt->appendChild(new DOMElement($this->options['elementLevel'], $level)); diff --git a/src/LogMessage.php b/src/LogMessage.php index c70d099..fdbe510 100644 --- a/src/LogMessage.php +++ b/src/LogMessage.php @@ -16,6 +16,8 @@ namespace Horde\Log; +use DateTimeImmutable; +use DateTimeInterface; use Horde\Util\HordeString; use Stringable; @@ -31,6 +33,7 @@ class LogMessage implements Stringable { private string $message; private LogLevel $level; + private DateTimeImmutable $timestamp; /** * Context may be a hash of anything, but only primitives and Stringables are expanded. * @@ -50,11 +53,13 @@ public function __construct(LogLevel $level, string $message, array $context = [ { $this->message = $message; $this->level = $level; - $this->context = $context; - // We cannot safely assume timestamp is any specific format - if (!isset($this->context['timestamp'])) { - $this->context['timestamp'] = time(); + if (isset($context['timestamp']) && $context['timestamp'] instanceof DateTimeInterface) { + $this->timestamp = DateTimeImmutable::createFromInterface($context['timestamp']); + unset($context['timestamp']); + } else { + $this->timestamp = new DateTimeImmutable(); } + $this->context = $context; } /** @@ -120,6 +125,11 @@ public function level(): LogLevel return $this->level; } + public function timestamp(): DateTimeImmutable + { + return $this->timestamp; + } + public function __toString(): string { return $this->formattedMessage(); diff --git a/test/Formatter/SimpleFormatterTest.php b/test/Formatter/SimpleFormatterTest.php index 10fb4d2..99448ab 100644 --- a/test/Formatter/SimpleFormatterTest.php +++ b/test/Formatter/SimpleFormatterTest.php @@ -15,6 +15,7 @@ use Horde\Log\Formatter\SimpleFormatter; use Horde\Log\LogMessage; use Horde\Log\LogLevel; +use DateTimeImmutable; use Horde_Log; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; @@ -58,19 +59,20 @@ public function testConstructorThrowsOnInvalidFormat(): void public function testDefaultFormatOutput(): void { $formatter = new SimpleFormatter(); - $message = new LogMessage($this->level, 'test message', ['timestamp' => '2026-03-10T10:00:00']); + $ts = new DateTimeImmutable('2026-03-10T10:00:00+00:00'); + $message = new LogMessage($this->level, 'test message', ['timestamp' => $ts]); $message->formatMessage([]); $output = $formatter->format($message); - $this->assertStringContainsString('2026-03-10T10:00:00', $output); + $this->assertStringContainsString('2026-03-10T10:00:00+00:00', $output); $this->assertStringContainsString('test message', $output); } public function testCustomFormatWithLevelName(): void { - $formatter = new SimpleFormatter('[%level%] %message%'); - $message = new LogMessage($this->level, 'custom message', ['level' => 'info']); + $formatter = new SimpleFormatter('[%levelName%] %message%'); + $message = new LogMessage($this->level, 'custom message'); $message->formatMessage([]); $output = $formatter->format($message); @@ -131,8 +133,21 @@ public function testFormatIncludesTimestamp(): void $output = $formatter->format($message); - // Should have a timestamp (auto-added by LogMessage) - $this->assertMatchesRegularExpression('/\d+/', $output); + // Record timestamp is always ISO 8601 + $this->assertMatchesRegularExpression('/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $output); + } + + public function testNumericContextTimestampDoesNotAffectFormattedDate(): void + { + $formatter = new SimpleFormatter('%timestamp% %message%'); + $message = new LogMessage($this->level, 'test', ['timestamp' => 1716825600]); + $message->formatMessage([]); + + $output = $formatter->format($message); + + // The %timestamp% placeholder shows the record's DateTimeImmutable, not the raw int + $this->assertMatchesRegularExpression('/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $output); + $this->assertStringNotContainsString('1716825600', $output); } public function testFormatAllLogLevels(): void diff --git a/test/LogMessageTest.php b/test/LogMessageTest.php index bbd4f73..c887163 100644 --- a/test/LogMessageTest.php +++ b/test/LogMessageTest.php @@ -15,6 +15,8 @@ use Horde\Log\LogMessage; use Horde\Log\LogLevel; use Horde\Log\LogFormatter; +use DateTimeImmutable; +use DateTimeInterface; use Horde_Log; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; @@ -54,20 +56,30 @@ public function testConstructorSetsContext(): void public function testConstructorAddsTimestampIfMissing(): void { + $before = new DateTimeImmutable(); $message = new LogMessage($this->level, 'test message'); - $context = $message->context(); + $after = new DateTimeImmutable(); - $this->assertArrayHasKey('timestamp', $context); - $this->assertIsInt($context['timestamp']); + $this->assertInstanceOf(DateTimeImmutable::class, $message->timestamp()); + $this->assertGreaterThanOrEqual($before, $message->timestamp()); + $this->assertLessThanOrEqual($after, $message->timestamp()); } - public function testConstructorPreservesExistingTimestamp(): void + public function testConstructorPromotesDateTimeInterfaceTimestamp(): void { - $customTimestamp = 1234567890; - $message = new LogMessage($this->level, 'test message', ['timestamp' => $customTimestamp]); - $context = $message->context(); + $dt = new DateTimeImmutable('2025-06-15T12:00:00+00:00'); + $message = new LogMessage($this->level, 'test message', ['timestamp' => $dt]); + + $this->assertEquals($dt, $message->timestamp()); + $this->assertArrayNotHasKey('timestamp', $message->context()); + } + + public function testConstructorIgnoresNonDateTimeInterfaceTimestamp(): void + { + $message = new LogMessage($this->level, 'test message', ['timestamp' => 1716825600]); - $this->assertEquals($customTimestamp, $context['timestamp']); + $this->assertInstanceOf(DateTimeImmutable::class, $message->timestamp()); + $this->assertEquals(1716825600, $message->context()['timestamp']); } public function testMergeContextAddsNewKeys(): void @@ -196,8 +208,7 @@ public function testEmptyContextWorks(): void $message = new LogMessage($this->level, 'test message', []); $context = $message->context(); - // Should only have timestamp added automatically - $this->assertCount(1, $context); - $this->assertArrayHasKey('timestamp', $context); + $this->assertCount(0, $context); + $this->assertInstanceOf(DateTimeImmutable::class, $message->timestamp()); } }