Add MessengerLog, async mailer, doctrine fixes

- MessengerLog entity: store all messenger failures with full details
- MessengerFailureSubscriber: log errors + send alert email synchronously
- MailerService: dispatch emails via Messenger bus (async)
- Makefile: add entity command
- Doctrine: enable Second Level Cache in prod, remove deprecated config
- Liip Imagine: set twig mode to lazy
- Fix app.scss @use/@import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-18 21:43:10 +01:00
parent 8d8d70cab4
commit 04becc238b
9 changed files with 280 additions and 6 deletions

View File

@@ -36,6 +36,10 @@ install_prod: ## Install les dependances et build les assets pour la prod
bun install bun install
bun run build bun run build
## —— Symfony ——————————————————————————————————————
entity: ## Creer ou modifier une entite via Docker dev
docker compose -f docker-compose-dev.yml exec php php bin/console make:entity
## —— Database —————————————————————————————————————— ## —— Database ——————————————————————————————————————
migration_dev: ## Genere une migration via Docker dev migration_dev: ## Genere une migration via Docker dev
docker compose -f docker-compose-dev.yml exec php php bin/console make:migration docker compose -f docker-compose-dev.yml exec php php bin/console make:migration

View File

@@ -1,3 +1,3 @@
@import "tailwindcss"; @use "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Intel+One+Mono:ital,wght@0,300..700;1,300..700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Intel+One+Mono:ital,wght@0,300..700;1,300..700&display=swap');

View File

@@ -20,8 +20,6 @@ doctrine:
dir: '%kernel.project_dir%/src/Entity' dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity' prefix: 'App\Entity'
alias: App alias: App
controller_resolver:
auto_mapping: false
when@test: when@test:
doctrine: doctrine:
@@ -38,6 +36,17 @@ when@prod:
result_cache_driver: result_cache_driver:
type: pool type: pool
pool: doctrine.result_cache_pool pool: doctrine.result_cache_pool
second_level_cache:
enabled: true
region_cache_driver:
type: pool
pool: doctrine.result_cache_pool
regions:
default:
lifetime: 3600
cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework: framework:
cache: cache:

View File

@@ -1,5 +1,7 @@
liip_imagine: liip_imagine:
driver: imagick driver: imagick
twig:
mode: lazy
loaders: loaders:
flysystem_loader: flysystem_loader:

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260318203734 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE email_tracking (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, message_id VARCHAR(64) NOT NULL, recipient VARCHAR(255) NOT NULL, subject VARCHAR(255) NOT NULL, state VARCHAR(10) NOT NULL, sent_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, opened_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_A31A7D55537A1329 ON email_tracking (message_id)');
$this->addSql('CREATE TABLE "user" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, email VARCHAR(180) NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON "user" (email)');
$this->addSql('CREATE TABLE messenger_messages (id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE email_tracking');
$this->addSql('DROP TABLE "user"');
$this->addSql('DROP TABLE messenger_messages');
}
}

125
src/Entity/MessengerLog.php Normal file
View File

@@ -0,0 +1,125 @@
<?php
namespace App\Entity;
use App\Repository\MessengerLogRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MessengerLogRepository::class)]
#[ORM\Index(columns: ['status'], name: 'idx_messenger_log_status')]
#[ORM\Index(columns: ['created_at'], name: 'idx_messenger_log_created_at')]
class MessengerLog
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $messageClass = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $messageBody = null;
#[ORM\Column(length: 20)]
private ?string $status = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $errorMessage = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $stackTrace = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $transportName = null;
#[ORM\Column]
private ?int $retryCount = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $failedAt = null;
public function __construct(
string $messageClass,
?string $messageBody,
string $errorMessage,
?string $stackTrace = null,
?string $transportName = null,
int $retryCount = 0,
) {
$this->messageClass = $messageClass;
$this->messageBody = $messageBody;
$this->status = 'failed';
$this->errorMessage = $errorMessage;
$this->stackTrace = $stackTrace;
$this->transportName = $transportName;
$this->retryCount = $retryCount;
$this->createdAt = new \DateTimeImmutable();
$this->failedAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getMessageClass(): ?string
{
return $this->messageClass;
}
public function getMessageBody(): ?string
{
return $this->messageBody;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getErrorMessage(): ?string
{
return $this->errorMessage;
}
public function getStackTrace(): ?string
{
return $this->stackTrace;
}
public function getTransportName(): ?string
{
return $this->transportName;
}
public function getRetryCount(): ?int
{
return $this->retryCount;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function getFailedAt(): ?\DateTimeImmutable
{
return $this->failedAt;
}
public function markAsResolved(): void
{
$this->status = 'resolved';
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\EventSubscriber;
use App\Entity\MessengerLog;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Messenger\Stamp\RedeliveryStamp;
class MessengerFailureSubscriber implements EventSubscriberInterface
{
public function __construct(
private EntityManagerInterface $em,
private MailerInterface $mailer,
) {}
public static function getSubscribedEvents(): array
{
return [
WorkerMessageFailedEvent::class => 'onMessageFailed',
];
}
public function onMessageFailed(WorkerMessageFailedEvent $event): void
{
$envelope = $event->getEnvelope();
$message = $envelope->getMessage();
$throwable = $event->getThrowable();
$retryCount = 0;
$redeliveryStamp = $envelope->last(RedeliveryStamp::class);
if ($redeliveryStamp instanceof RedeliveryStamp) {
$retryCount = $redeliveryStamp->getRetryCount();
}
$messageBody = null;
try {
$messageBody = serialize($message);
} catch (\Throwable) {
$messageBody = get_class($message) . ' (not serializable)';
}
$log = new MessengerLog(
messageClass: get_class($message),
messageBody: $messageBody,
errorMessage: $throwable->getMessage(),
stackTrace: $throwable->getTraceAsString(),
transportName: $event->getReceiverName(),
retryCount: $retryCount,
);
$this->em->persist($log);
$this->em->flush();
try {
$email = (new Email())
->from('contact@e-cosplay.fr')
->to('contact@e-cosplay.fr')
->subject('Alerte Messenger : Echec de traitement')
->priority(Email::PRIORITY_HIGH)
->text(
"Un message Messenger a echoue.\n\n" .
"Message: " . get_class($message) . "\n" .
"Transport: " . $event->getReceiverName() . "\n" .
"Retry: " . $retryCount . "\n" .
"Erreur: " . $throwable->getMessage() . "\n\n" .
"Stack trace:\n" . $throwable->getTraceAsString()
);
$this->mailer->send($email);
} catch (\Throwable) {
}
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\MessengerLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<MessengerLog>
*/
class MessengerLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MessengerLog::class);
}
}

View File

@@ -4,9 +4,10 @@ namespace App\Service;
use Symfony\Component\Mime\Crypto\SMimeSigner; use Symfony\Component\Mime\Crypto\SMimeSigner;
use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Email;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mailer\Messenger\SendEmailMessage;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
class MailerService class MailerService
@@ -16,7 +17,7 @@ class MailerService
]; ];
public function __construct( public function __construct(
private MailerInterface $mailer, private MessageBusInterface $bus,
#[Autowire('%kernel.project_dir%')] private string $projectDir, #[Autowire('%kernel.project_dir%')] private string $projectDir,
#[Autowire(env: 'SMIME_PASSPHRASE')] private string $smimePassphrase, #[Autowire(env: 'SMIME_PASSPHRASE')] private string $smimePassphrase,
private UrlGeneratorInterface $urlGenerator, private UrlGeneratorInterface $urlGenerator,
@@ -40,7 +41,7 @@ class MailerService
$email = $signer->sign($email); $email = $signer->sign($email);
} }
$this->mailer->send($email); $this->bus->dispatch(new SendEmailMessage($email));
} }
/** /**