Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Formatter/SimpleFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/Formatter/XmlFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
18 changes: 14 additions & 4 deletions src/LogMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

namespace Horde\Log;

use DateTimeImmutable;
use DateTimeInterface;
use Horde\Util\HordeString;
use Stringable;

Expand All @@ -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.
*
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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();
Expand Down
27 changes: 21 additions & 6 deletions test/Formatter/SimpleFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
33 changes: 22 additions & 11 deletions test/LogMessageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
}
Loading