phpsymfonymessenger

Symfony notifier attach custom metadata to envelope


I'm using the Symfony notifier and messenger components to asynchronously send SMS messages (and in the future push and email notifications).

Everything works just fine, however once a message is sent, I'd like to log information about it.

I can catch a successful message by subscribing to WorkerMessageHandledEvent which provides me the Message object, along with the containing Envelope and all its Stamp objects inside. From all the available information, I'll be logging this in my database using an entity named MessageLog.

class MessengerSubscriber implements EventSubscriberInterface {

    public static function getSubscribedEvents() {
        return [
            WorkerMessageHandledEvent::class => ['onHandled']
        ];
    }

    public function onHandled(WorkerMessageHandledEvent $event) {
        $log = new MessageLog();
        $log->setSentAt(new DateTime());

        if($event->getEnvelope()->getMessage() instanceof SmsMessage) {
            $log->setSubject($event->getEnvelope()->getMessage()->getSubject());
            $log->setRecipient($event->getEnvelope()->getMessage()->getPhone());
        }

        // Do more tracking
    }

}

What I'd like to do, is track the object that "invoked" the message. For example, if I have a news feed, and posting a post sends out a notification, I'd like to attribute each logged message to that post (to display audience reach/delivery stats per post - and from an admin POV auditing and reporting).

I've tried to go about adding a Stamp, or other means of trying to attach custom metadata to the message, but it seems to be abstracted when using the symfony/notifier bundle.

The below is what I'm using to send notifications (more or less WIP):

class PostService {

    protected NotifierInterface $notifier;

    public function ___construct(NotifierInterface $notifier) {
        $this->notifier = $notifier;
    }

    public function sendNotifications(Post $post) {
        $notification = new PostNotification($post);
        
        $recipients = [];
        foreach($post->getNewsFeed()->getSubscribers() as $user) {
            $recipients[] = new Recipient($user->getEmail(), $user->getMobilePhone());
        }

        $this->notifier->send($notification, ...$recipients);
    }

}
class PostNotification extends Notification implements SmsNotificationInterface {

    protected Post $post;

    public function __construct(Post $post) {
        parent::__construct();
        $this->post = $post;
    }

    public function getChannels(RecipientInterface $recipient): array {
        return ['sms'];
    }

    public function asSmsMessage(SmsRecipientInterface $recipient, string $transport = null): ?SmsMessage {
        if($transport === 'sms') {
            return new SmsMessage($recipient->getPhone(), $this->getPostContentAsSms());
        }

        return null;
    }

    private function getPostContentAsSms() {
        return $post->getTitle()."\n\n".$post->getContent();
    }

}

By the time this is all done, this is all I have in the WorkerMessageHandledEvent

^ Symfony\Component\Messenger\Event\WorkerMessageHandledEvent^ {#5590
  -envelope: Symfony\Component\Messenger\Envelope^ {#8022
    -stamps: array:7 [
      "Symfony\Component\Messenger\Stamp\BusNameStamp" => array:1 [
        0 => Symfony\Component\Messenger\Stamp\BusNameStamp^ {#10417
          -busName: "messenger.bus.default"
        }
      ]
      "Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineReceivedStamp" => array:1 [
        0 => Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineReceivedStamp^ {#10419
          -id: "2031"
        }
      ]
      "Symfony\Component\Messenger\Stamp\TransportMessageIdStamp" => array:1 [
        0 => Symfony\Component\Messenger\Stamp\TransportMessageIdStamp^ {#10339
          -id: "2031"
        }
      ]
      "Symfony\Component\Messenger\Stamp\ReceivedStamp" => array:1 [
        0 => Symfony\Component\Messenger\Stamp\ReceivedStamp^ {#5628
          -transportName: "async"
        }
      ]
      "Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp" => array:1 [
        0 => Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp^ {#7306}
      ]
      "Symfony\Component\Messenger\Stamp\AckStamp" => array:1 [
        0 => Symfony\Component\Messenger\Stamp\AckStamp^ {#7159
          -ack: Closure(Envelope $envelope, Throwable $e = null)^ {#6205
            class: "Symfony\Component\Messenger\Worker"
            this: Symfony\Component\Messenger\Worker {#5108 …}
            use: {
              $transportName: "async"
              $acked: & false
            }
          }
        }
      ]
      "Symfony\Component\Messenger\Stamp\HandledStamp" => array:1 [
        0 => Symfony\Component\Messenger\Stamp\HandledStamp^ {#11445
          -result: Symfony\Component\Notifier\Message\SentMessage^ {#2288
            -original: Symfony\Component\Notifier\Message\NullMessage^ {#6625
              -decoratedMessage: Symfony\Component\Notifier\Message\SmsMessage^ {#10348
                -transport: null
                -subject: ".................................................."
                -phone: "0412345678"
              }
            }
            -transport: "null"
            -messageId: null
          }
          -handlerName: "Symfony\Component\Notifier\Messenger\MessageHandler::__invoke"
        }
      ]
    ]
    -message: Symfony\Component\Notifier\Message\SmsMessage^ {#10348}
  }
  -receiverName: "async"
}

The doco shows me ways to add my own stamps to the envelope, which I'm guessing I can use to attach metadata such as my Post object, but this means I need to use the MessageBusInterface to send notifications. I don't want to do because I would like to route messages through the NotifierInterface to gain all the benefits of channel policies, texter transports, etc.


tl;dr: how do I get some metadata through to a WorkerMessageHandledEvent if I send a message using the NotifierInterface


Solution

  • I've found a way to make it work!

    Essentially what happens is that we have two components here, the Symfony notifier and the Symfony messenger. When used together, they create a powerful way to send messages to any number of endpoints.

    Firstly what I did was create an interface called NotificationStampsInterface and a trait called NotificationStamps that satisfies the interface (by storing a protected array using the interface methods to read/write to it).

    class NotificationStampsInterface {
        
        public function getStamps(): array;
    
        public function addStamp(StampInterface $stamp);
    
        public function removeStamp(StampInterface $stamp);
    }
    

    This interface can then be added onto your custom notification object, in this instance PostNotification, alongside with the NotificationStamps trait to satisfy the interface methods.

    The trick here is that when sending a notification via the notifier, it ultimately calls on the messenger component to send the message. The bit that handles this is Symfony\Component\Notifier\Channel\SmsChannel. Essentially, if a MessageBus is available, it will push messages through that rather than going straight though the notifier.

    We can extend the SmsChannel class to add our own logic inside notify() method.

    class SmsNotify extends \Symfony\Component\Notifer\Channel\SmsChannel {
       
        public function notify(Notification $notification, RecipientInterface $recipient, string $transportName = null): void {
            $message = null;
            if ($notification instanceof SmsNotificationInterface) {
                $message = $notification->asSmsMessage($recipient, $transportName);
            }
    
            if (null === $message) {
                $message = SmsMessage::fromNotification($notification, $recipient);
            }
    
            if (null !== $transportName) {
                $message->transport($transportName);
            }
    
            if (null === $this->bus) {
                $this->transport->send($message);
            } else {
                // New logic
                if($notification instanceof NotificationStampsInterface) {
                    $envelope = Envelope::wrap($message, $notification->getStamps());
                    $this->bus->dispatch($envelope);
                } else {
                    $this->bus->dispatch($message);
                }
    
                // Old logic
                // $this->bus->dispatch($message);
            }
        }
       
    }
    

    Lastly we need to override the service by adding the following in services.yaml

        notifier.channel.sms:
            class: App\Notifier\Channel\SmsChannel
            arguments: ['@texter.transports', '@messenger.default_bus']
            tags:
                - { name: notifier.channel, channel: sms }
    

    And that's it! We now have a way to append stamps to our Notification object that will carry all the way through to the WorkerMessageHandledEvent.

    An example use would be (for my situation at least)

    class RelatedEntityStamp implements StampInterface {
    
        private string $className;
        private int $classId;
    
        public function __construct(object $entity) {
            $this->className = get_class($entity);
            $this->classId = $entity->getId();
        }
    
        /**
         * @return string
         */
        public function getClassName(): string {
            return $this->className;
        }
    
        /**
         * @return int
         */
        public function getClassId(): int {
            return $this->classId;
        }
    
    }
    
    class PostService {
    
        protected NotifierInterface $notifier;
    
        public function ___construct(NotifierInterface $notifier) {
            $this->notifier = $notifier;
        }
    
        public function sendNotifications(Post $post) {
            $notification = new PostNotification($post);
            $stamp = new RelatedEntityStamp($post);        // Solution
            $notification->addStamp($stamp);               // Solution
            
            $recipients = [];
            foreach($post->getNewsFeed()->getSubscribers() as $user) {
                $recipients[] = new Recipient($user->getEmail(), $user->getMobilePhone());
            }
    
            $this->notifier->send($notification, ...$recipients);
        }
    
    }
    

    Once the message is sent, dumping the result shows that we do indeed have our stamp registered at the point where our event fires.

    ^ Symfony\Component\Messenger\Event\WorkerMessageHandledEvent^ {#1078
      -envelope: Symfony\Component\Messenger\Envelope^ {#1103
        -stamps: array:8 [
          "App\Notification\Stamp\RelatedEntityStamp" => array:1 [
            0 => App\Notification\Stamp\RelatedEntityStamp^ {#1062
              -className: "App\Entity\Post"
              -classId: 207
            }
          ]
          "Symfony\Component\Messenger\Stamp\BusNameStamp" => array:1 [
            0 => Symfony\Component\Messenger\Stamp\BusNameStamp^ {#1063
              -busName: "messenger.bus.default"
            }
          ]
          "Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineReceivedStamp" => array:1 [
            0 => Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineReceivedStamp^ {#1066
              -id: "2590"
            }
          ]
          "Symfony\Component\Messenger\Stamp\TransportMessageIdStamp" => array:1 [
            0 => Symfony\Component\Messenger\Stamp\TransportMessageIdStamp^ {#1067
              -id: "2590"
            }
          ]
          "Symfony\Component\Messenger\Stamp\ReceivedStamp" => array:1 [
            0 => Symfony\Component\Messenger\Stamp\ReceivedStamp^ {#1075
              -transportName: "async"
            }
          ]
          "Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp" => array:1 [
            0 => Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp^ {#1076}
          ]
          "Symfony\Component\Messenger\Stamp\AckStamp" => array:1 [
            0 => Symfony\Component\Messenger\Stamp\AckStamp^ {#1077
              -ack: Closure(Envelope $envelope, Throwable $e = null)^ {#1074
                class: "Symfony\Component\Messenger\Worker"
                this: Symfony\Component\Messenger\Worker {#632 …}
                use: {
                  $transportName: "async"
                  $acked: & false
                }
              }
            }
          ]
          "Symfony\Component\Messenger\Stamp\HandledStamp" => array:1 [
            0 => Symfony\Component\Messenger\Stamp\HandledStamp^ {#1101
              -result: Symfony\Component\Notifier\Message\SentMessage^ {#1095
                -original: Symfony\Component\Notifier\Message\NullMessage^ {#1091
                  -decoratedMessage: Symfony\Component\Notifier\Message\SmsMessage^ {#1060
                    -transport: null
                    -subject: ".................................................."
                    -phone: "0412345678"
                  }
                }
                -transport: "null"
                -messageId: null
              }
              -handlerName: "Symfony\Component\Notifier\Messenger\MessageHandler::__invoke"
            }
          ]
        ]
        -message: Symfony\Component\Notifier\Message\SmsMessage^ {#1060}
      }
      -receiverName: "async"
    }