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
71 changes: 54 additions & 17 deletions src/Formatter/AbstractFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,79 @@

use KaririCode\Contract\ImmutableValue;
use KaririCode\Contract\Logging\LogFormatter;
use KaririCode\Contract\Logging\Structural\FormatterAware;

abstract class AbstractFormatter implements LogFormatter, FormatterAware, ImmutableValue
abstract class AbstractFormatter implements LogFormatter
{
protected ImmutableValue $formatter;

/**
* @param string $dateFormat The date format pattern for timestamps
* @param bool $includeContext Whether to include context in formatted output
* @param bool $includeExtra Whether to include extra data in formatted output
*/
public function __construct(
protected string $dateFormat = 'Y-m-d H:i:s'
public readonly string $dateFormat = 'Y-m-d H:i:s',
public readonly bool $includeContext = true,
public readonly bool $includeExtra = false
) {
$this->formatter = $this;
}

/**
* Format a single log record.
*/
abstract public function format(ImmutableValue $record): string;

/**
* Format multiple log records.
*/
public function formatBatch(array $records): string
{
return implode("\n", array_map([$this, 'format'], $records));
return implode(PHP_EOL, array_map([$this, 'format'], $records));
}

/**
* Create a new formatter with different date format.
*/
public function withDateFormat(string $dateFormat): static
{
return new static(
$dateFormat,
$this->includeContext,
$this->includeExtra
);
}

public function setFormatter(ImmutableValue $formatter): AbstractFormatter
/**
* Create a new formatter with context inclusion setting.
*/
public function withContextInclusion(bool $include): static
{
$this->formatter = $formatter;
return new static(
$this->dateFormat,
$include,
$this->includeExtra
);
}

return $this;
/**
* Format the timestamp according to the configured format.
*/
protected function formatTimestamp(\DateTimeImmutable $datetime): string
{
return $datetime->format($this->dateFormat);
}

public function getFormatter(): ImmutableValue
/**
* Check if the formatter should include context.
*/
protected function shouldIncludeContext(array $context): bool
{
return $this->formatter;
return $this->includeContext && !empty($context);
}

public function toArray(): array
/**
* Check if the formatter should include extra data.
*/
protected function shouldIncludeExtra(array $extra): bool
{
return [
'dateFormat' => $this->dateFormat,
'formatter' => $this->formatter->toArray() ?? null,
];
return $this->includeExtra && !empty($extra);
}
}
127 changes: 115 additions & 12 deletions src/Formatter/JsonFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,145 @@
namespace KaririCode\Logging\Formatter;

use KaririCode\Contract\ImmutableValue;
use KaririCode\Logging\LogRecord;

class JsonFormatter extends AbstractFormatter
/**
* JSON formatter optimized for direct property access.
*/
final class JsonFormatter extends AbstractFormatter
{
private const JSON_OPTIONS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
private const JSON_OPTIONS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR;

/**
* @param string $dateFormat Date format pattern
* @param bool $includeContext Include context in output
* @param bool $includeExtra Include extra data in output
* @param bool $prettyPrint Pretty print JSON output
* @param bool $includeStacktraces Include stack traces for exceptions
*/
public function __construct(
string $dateFormat = 'Y-m-d H:i:s',
bool $includeContext = true,
bool $includeExtra = false,
public readonly bool $prettyPrint = false,
public readonly bool $includeStacktraces = false
) {
parent::__construct($dateFormat, $includeContext, $includeExtra);
}

/**
* Format log record to JSON using direct property access.
*/
public function format(ImmutableValue $record): string
{
$data = $this->prepareData($record);
if (!$record instanceof LogRecord) {
throw new \InvalidArgumentException('Record must be an instance of LogRecord');
}

// Direct property access - no toArray() needed
$data = [
'datetime' => $this->formatTimestamp($record->datetime),
'level' => $record->level->value,
'message' => $record->getMessageAsString(),
];

// Conditionally add context and extra
if ($this->shouldIncludeContext($record->context)) {
$data['context'] = $this->processContext($record->context);
}

if ($this->shouldIncludeExtra($record->extra)) {
$data['extra'] = $record->extra;
}

return $this->encodeJson($data);
}

/**
* Format batch of records.
*/
public function formatBatch(array $records): string
{
$formattedRecords = array_map([$this, 'prepareData'], $records);
$formattedRecords = array_map(
fn ($record) => $this->prepareData($record),
$records
);

return $this->encodeJson($formattedRecords);
}

/**
* Prepare data from record using direct property access.
*/
private function prepareData(ImmutableValue $record): array
{
$data = [
'datetime' => $record->datetime->format($this->dateFormat),
if (!$record instanceof LogRecord) {
throw new \InvalidArgumentException('Record must be an instance of LogRecord');
}

return [
'datetime' => $this->formatTimestamp($record->datetime),
'level' => $record->level->value,
'message' => $record->message,
'message' => $record->getMessageAsString(),
'context' => $record->hasContext() ? $this->processContext($record->context) : null,
'extra' => $record->hasExtra() ? $record->extra : null,
];
}

/**
* Process context to handle exceptions if needed.
*/
private function processContext(array $context): array
{
if (!$this->includeStacktraces) {
return $context;
}

// Handle exceptions in context
foreach ($context as $key => $value) {
if ($value instanceof \Throwable) {
$context[$key] = $this->formatException($value);
}
}

return $context;
}

/**
* Format exception for JSON output.
*/
private function formatException(\Throwable $exception): array
{
$formatted = [
'class' => get_class($exception),
'message' => $exception->getMessage(),
'code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
];

if (!empty($record->context)) {
$data['context'] = $record->context;
if ($this->includeStacktraces) {
$formatted['trace'] = $exception->getTraceAsString();
}

if ($exception->getPrevious()) {
$formatted['previous'] = $this->formatException($exception->getPrevious());
}

return $data;
return $formatted;
}

private function encodeJson($data): string
/**
* Encode data to JSON with configured options.
*/
private function encodeJson(mixed $data): string
{
return json_encode($data, self::JSON_OPTIONS | JSON_THROW_ON_ERROR);
$options = self::JSON_OPTIONS;

if ($this->prettyPrint) {
$options |= JSON_PRETTY_PRINT;
}

return json_encode($data, $options);
}
}
67 changes: 58 additions & 9 deletions src/LogRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@
use KaririCode\Contract\ImmutableValue;
use KaririCode\Contract\Logging\LogLevel;

class LogRecord implements ImmutableValue
final class LogRecord implements ImmutableValue
{
/**
* @param LogLevel $level The severity level of the log
* @param string|\Stringable $message The log message
* @param array<string, mixed> $context Additional contextual data
* @param \DateTimeImmutable $datetime Timestamp of the log record
* @param array<string, mixed> $extra Extra metadata
*/
public function __construct(
public readonly LogLevel $level,
public readonly string|\Stringable $message,
Expand All @@ -18,14 +25,56 @@ public function __construct(
) {
}

public function toArray(): array
/**
* Create a new instance with modified context
* Following the immutability pattern.
*/
public function withContext(array $context): self
{
return [
'level' => $this->level->value,
'message' => $this->message,
'context' => $this->context,
'datetime' => $this->datetime,
'extra' => $this->extra,
];
return new self(
$this->level,
$this->message,
array_merge($this->context, $context),
$this->datetime,
$this->extra
);
}

/**
* Create a new instance with modified extra data.
*/
public function withExtra(array $extra): self
{
return new self(
$this->level,
$this->message,
$this->context,
$this->datetime,
array_merge($this->extra, $extra)
);
}

/**
* Get the string representation of the message.
*/
public function getMessageAsString(): string
{
return (string) $this->message;
}

/**
* Check if the record has context data.
*/
public function hasContext(): bool
{
return !empty($this->context);
}

/**
* Check if the record has extra data.
*/
public function hasExtra(): bool
{
return !empty($this->extra);
}
}
3 changes: 1 addition & 2 deletions src/LoggerBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use KaririCode\Contract\Logging\LogFormatter;
use KaririCode\Contract\Logging\Logger;
use KaririCode\Contract\Logging\Structural\FormatterAware;
use KaririCode\Contract\Logging\Structural\HandlerAware;
use KaririCode\Contract\Logging\Structural\ProcessorAware;
use KaririCode\Logging\Formatter\LineFormatter;
Expand Down Expand Up @@ -38,7 +37,7 @@ public function withProcessor(ProcessorAware $processor): self
return $this;
}

public function withFormatter(FormatterAware $formatter): self
public function withFormatter(LogFormatter $formatter): self
{
$this->formatter = $formatter;

Expand Down
Loading
Loading