Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
Naoray committed Jan 13, 2025
1 parent c8d9d91 commit d016838
Show file tree
Hide file tree
Showing 24 changed files with 1,123 additions and 321 deletions.
20 changes: 20 additions & 0 deletions src/Contracts/DeduplicationStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Naoray\LaravelGithubMonolog\DeduplicationStores;

use Monolog\LogRecord;

interface DeduplicationStoreInterface
{
/**
* Get all stored deduplication entries
*
* @return array<string>
*/
public function get(): array;

/**
* Add a new deduplication entry
*/
public function add(LogRecord $record, string $signature): void;
}
63 changes: 63 additions & 0 deletions src/Deduplication/DeduplicationHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace Naoray\LaravelGithubMonolog\Handlers;

use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Handler\HandlerInterface;
use Monolog\Level;
use Monolog\LogRecord;
use Naoray\LaravelGithubMonolog\Contracts\SignatureGenerator;
use Naoray\LaravelGithubMonolog\DefaultSignatureGenerator;
use Naoray\LaravelGithubMonolog\DeduplicationStores\DeduplicationStoreInterface;
use Naoray\LaravelGithubMonolog\DeduplicationStores\RedisDeduplicationStore;

class SignatureDeduplicationHandler extends AbstractProcessingHandler
{
private SignatureGenerator $signatureGenerator;
private HandlerInterface $handler;
private DeduplicationStoreInterface $store;
private int $time;

public function __construct(
HandlerInterface $handler,
?DeduplicationStoreInterface $store = null,
int|string|Level $level = Level::Error,
int $time = 60,
bool $bubble = true,
?SignatureGenerator $signatureGenerator = null,
) {
parent::__construct($level, $bubble);

$this->handler = $handler;
$this->time = $time;
$this->store = $store ?? new RedisDeduplicationStore(time: $time);
$this->signatureGenerator = $signatureGenerator ?? new DefaultSignatureGenerator;
}

protected function write(LogRecord $record): void
{
$signature = $this->signatureGenerator->generate($record);

if ($this->isDuplicate($record, $signature)) {
return;
}

$this->store->add($record, $signature);
$this->handler->handle($record);
}

protected function isDuplicate(LogRecord $record, string $signature): bool
{
$store = $this->store->get();
$timestampValidity = time() - $this->time;

foreach ($store as $entry) {
[$timestamp, $storedSignature] = explode(':', $entry, 2);
if ($storedSignature === $signature && (int) $timestamp > $timestampValidity) {
return true;
}
}

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public function generate(LogRecord $record): string
*/
private function generateFromMessage(LogRecord $record): string
{
return md5($record->message.json_encode($record->context));
return md5($record->message . json_encode($record->context));
}

/**
Expand All @@ -42,7 +42,7 @@ private function generateFromException(Throwable $exception): string
$exception::class,
$exception->getFile(),
$exception->getLine(),
$firstFrame ? ($firstFrame['file'] ?? '').':'.($firstFrame['line'] ?? '') : '',
$firstFrame ? ($firstFrame['file'] ?? '') . ':' . ($firstFrame['line'] ?? '') : '',
]));
}
}
25 changes: 25 additions & 0 deletions src/Deduplication/Stores/AbstractStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Naoray\LaravelGithubMonolog\DeduplicationStores;

abstract class AbstractDeduplicationStore implements DeduplicationStoreInterface
{
protected string $prefix;
protected int $time;

public function __construct(string $prefix = 'github-monolog:', int $time = 60)
{
$this->prefix = $prefix;
$this->time = $time;
}

protected function formatEntry(string $signature, int $timestamp): string
{
return $timestamp . ':' . $signature;
}

protected function isExpired(int $timestamp): bool
{
return $timestamp < time() - $this->time;
}
}
74 changes: 74 additions & 0 deletions src/Deduplication/Stores/DatabaseStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace Naoray\LaravelGithubMonolog\DeduplicationStores;

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Monolog\LogRecord;

class DatabaseDeduplicationStore extends AbstractDeduplicationStore
{
private string $table;
private string $connection;

public function __construct(
string $connection = 'default',
string $table = 'github_monolog_deduplication',
string $prefix = 'github-monolog:',
int $time = 60
) {
parent::__construct($prefix, $time);

$this->connection = $connection === 'default' ? config('database.default') : $connection;
$this->table = $table;

$this->ensureTableExists();
}

public function get(): array
{
$this->cleanup();

return DB::connection($this->connection)
->table($this->table)
->where('prefix', $this->prefix)
->get()
->map(fn($row) => $this->formatEntry($row->signature, $row->created_at))
->all();
}

public function add(LogRecord $record, string $signature): void
{
DB::connection($this->connection)
->table($this->table)
->insert([
'prefix' => $this->prefix,
'signature' => $signature,
'created_at' => time(),
]);
}

private function cleanup(): void
{
DB::connection($this->connection)
->table($this->table)
->where('prefix', $this->prefix)
->where('created_at', '<', time() - $this->time)
->delete();
}

private function ensureTableExists(): void
{
if (! Schema::connection($this->connection)->hasTable($this->table)) {
Schema::connection($this->connection)->create($this->table, function (Blueprint $table) {
$table->id();
$table->string('prefix')->index();
$table->string('signature');
$table->integer('created_at')->index();

$table->index(['prefix', 'signature', 'created_at']);
});
}
}
}
139 changes: 139 additions & 0 deletions src/Deduplication/Stores/FileStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

namespace Naoray\LaravelGithubMonolog\DeduplicationStores;

use Illuminate\Support\Facades\File;
use Monolog\LogRecord;
use RuntimeException;

class FileDeduplicationStore extends AbstractDeduplicationStore
{
private string $path;
private $handle = null;

public function __construct(
string $path,
string $prefix = 'github-monolog:',
int $time = 60
) {
parent::__construct($prefix, $time);

$this->path = $path;
$this->ensureDirectoryExists();
}

public function get(): array
{
$this->acquireLock();

try {
$content = $this->readContent();
$entries = array_filter(explode(PHP_EOL, $content));

// Filter out expired entries and rebuild file
$valid = array_filter($entries, function ($entry) {
if (!str_contains($entry, ':')) {
return false;
}
[$timestamp] = explode(':', $entry, 2);
return is_numeric($timestamp) && !$this->isExpired((int) $timestamp);
});

if (count($valid) !== count($entries)) {
$this->writeContent(implode(PHP_EOL, $valid));
}

return $valid;
} catch (RuntimeException $e) {
// If we can't read the file, assume no entries
return [];
} finally {
$this->releaseLock();
}
}

public function add(LogRecord $record, string $signature): void
{
$this->acquireLock();

try {
$entry = $this->formatEntry($signature, time());
$content = $this->readContent();

$this->writeContent(
($content ? $content . PHP_EOL : '') . $entry
);
} finally {
$this->releaseLock();
}
}

private function acquireLock(): void
{
if ($this->handle !== null) {
return; // Already have a lock
}

if (!$this->handle = fopen($this->path, 'c+')) {
throw new RuntimeException("Cannot open file: {$this->path}");
}

$attempts = 3;
while ($attempts--) {
if (flock($this->handle, LOCK_EX | LOCK_NB)) {
return;
}
if ($attempts) {
usleep(100000); // Sleep for 100ms between attempts
}
}

fclose($this->handle);
$this->handle = null;
throw new RuntimeException("Cannot acquire lock on file: {$this->path}");
}

private function releaseLock(): void
{
if ($this->handle) {
flock($this->handle, LOCK_UN);
fclose($this->handle);
$this->handle = null;
}
}

private function readContent(): string
{
if (!$this->handle) {
throw new RuntimeException('File handle not initialized');
}

fseek($this->handle, 0);
return stream_get_contents($this->handle) ?: '';
}

private function writeContent(string $content): void
{
if (!$this->handle) {
throw new RuntimeException('File handle not initialized');
}

ftruncate($this->handle, 0);
fseek($this->handle, 0);
fwrite($this->handle, $content);
fflush($this->handle);
}

private function ensureDirectoryExists(): void
{
$directory = dirname($this->path);
if (!File::exists($directory)) {
File::makeDirectory($directory, 0755, true);
}
}

public function __destruct()
{
$this->releaseLock();
}
}
Loading

0 comments on commit d016838

Please sign in to comment.