Skip to content

Commit 628bbaf

Browse files
committed
feat: add messenger stamp normalizers
1 parent 32fbc9e commit 628bbaf

21 files changed

Lines changed: 890 additions & 48 deletions

README.md

Lines changed: 108 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Messenger Logging Bundle
22

3-
Symfony bundle for Messenger-Lifecycle-Logging, suitable for monitoring.
3+
Symfony bundle for Messenger lifecycle logging, suitable for monitoring.
44

55
## Installation
66

@@ -26,23 +26,118 @@ c10k_messenger_logging:
2626
failed: error
2727
retried: warning
2828
skipped: warning
29+
stamp_normalizers: { }
2930
```
3031
31-
Alle PSR-3-Levels sind erlaubt. Wenn Failure-Logs in einer retry-lastigen
32-
Umgebung zu laut sind, kannst du `failed: info` setzen.
32+
All PSR-3 log levels are supported. If failure logs are too noisy in a
33+
retry-heavy environment, you can set `failed: info`.
3334

34-
Wenn `log_channel` gesetzt ist, werden nur die von diesem Bundle erzeugten
35-
Logs auf diesen Monolog-Channel gelegt. Andere Projekt-Logs bleiben davon
36-
unberührt, solange sie nicht separat auf denselben Channel konfiguriert werden.
37-
Ohne `log_channel` bleibt das bisherige Default-Logger-Verhalten unverändert.
35+
If `log_channel` is set, only the logs emitted by this bundle are sent to that
36+
Monolog channel. Other project logs remain unaffected unless they are
37+
explicitly configured to use the same channel. Without `log_channel`, the
38+
default logger behavior remains unchanged.
3839

39-
Das Bundle hängt sich an Messenger-Events und sorgt dafür, dass eine Message
40-
beim Queueing eine UUIDv7 erhält. Dieselbe UUID taucht dann in den Logs für
41-
Queueing, Consume, Success, Failure und Retry wieder auf, zusammen mit der
42-
Message-Klasse und dem vollständigen Stamp-Kontext.
40+
The bundle subscribes to Messenger events and ensures that each message
41+
receives a UUIDv7 when it is queued. The same UUID then appears in the logs for
42+
queueing, receiving, success, failure, and retry, together with the message
43+
class and a normalized `stamps` array.
4344

44-
Wenn die installierte Messenger-Version `WorkerMessageSkipEvent` unterstützt,
45-
werden auch übersprungene Messages geloggt.
45+
Stamp normalization is explicit. The bundle ships dedicated normalizers for a
46+
safe subset of Messenger stamps such as `BusNameStamp`, `DelayStamp`,
47+
`HandledStamp`, `RedeliveryStamp`, `RouterContextStamp`, `SentStamp`,
48+
`TransportNamesStamp`, and `ValidationStamp`. Unknown stamps are still listed
49+
by class name, but their `context` remains empty unless a normalizer is
50+
registered for them. This avoids reflecting every public getter on every stamp,
51+
which can expose sensitive data or large payloads such as handler results.
52+
53+
Custom normalizers are discovered automatically when they implement
54+
`C10k\MessengerLoggingBundle\Logging\StampNormalizerInterface` and are
55+
registered as autoconfigured services. The bundle tags them with
56+
`c10k_messenger_logging.stamp_normalizer` and maps them by supported stamp
57+
class.
58+
59+
You can also wire an explicit `StampClass -> NormalizerClass` mapping via
60+
configuration, which is useful for overrides:
61+
62+
```yaml
63+
c10k_messenger_logging:
64+
stamp_normalizers:
65+
App\Messenger\CustomStamp: App\Messenger\Logging\CustomStampNormalizer
66+
```
67+
68+
If the installed Messenger version supports `WorkerMessageSkipEvent`, skipped
69+
messages are logged as well.
70+
71+
## Example Lifecycle
72+
73+
The example below shows how a single message can appear in the logs when it is
74+
queued, fails once, is retried, and is eventually handled successfully. The
75+
same `uuid` is present in every log entry, which makes correlation
76+
straightforward.
77+
78+
<table>
79+
<thead>
80+
<tr>
81+
<th>event</th>
82+
<th>context</th>
83+
</tr>
84+
</thead>
85+
<tbody>
86+
<tr>
87+
<td><code>message queued</code></td>
88+
<td><pre>uuid: 018f0c0c-6f9e-7eec-bfc3-6f8d3426f5dc
89+
message_class: App\Message\SyncInvoice
90+
sender_names: ["async"]
91+
retry_count: 0</pre></td>
92+
</tr>
93+
<tr>
94+
<td><code>message received (attempt 1)</code></td>
95+
<td><pre>uuid: 018f0c0c-6f9e-7eec-bfc3-6f8d3426f5dc
96+
message_class: App\Message\SyncInvoice
97+
receiver_name: async
98+
received_transport_names: ["async"]
99+
retry_count: 0</pre></td>
100+
</tr>
101+
<tr>
102+
<td><code>message failed (will retry)</code></td>
103+
<td><pre>uuid: 018f0c0c-6f9e-7eec-bfc3-6f8d3426f5dc
104+
message_class: App\Message\SyncInvoice
105+
receiver_name: async
106+
received_transport_names: ["async"]
107+
retry_count: 0
108+
will_retry: true
109+
exception_class: RuntimeException
110+
exception_message: Temporary upstream timeout</pre></td>
111+
</tr>
112+
<tr>
113+
<td><code>message scheduled for retry</code></td>
114+
<td><pre>uuid: 018f0c0c-6f9e-7eec-bfc3-6f8d3426f5dc
115+
message_class: App\Message\SyncInvoice
116+
receiver_name: async
117+
retry_count: 1</pre></td>
118+
</tr>
119+
<tr>
120+
<td><code>message received (attempt 2)</code></td>
121+
<td><pre>uuid: 018f0c0c-6f9e-7eec-bfc3-6f8d3426f5dc
122+
message_class: App\Message\SyncInvoice
123+
receiver_name: async
124+
received_transport_names: ["async"]
125+
retry_count: 1</pre></td>
126+
</tr>
127+
<tr>
128+
<td><code>message handled</code></td>
129+
<td><pre>uuid: 018f0c0c-6f9e-7eec-bfc3-6f8d3426f5dc
130+
message_class: App\Message\SyncInvoice
131+
receiver_name: async
132+
received_transport_names: ["async"]
133+
retry_count: 1</pre></td>
134+
</tr>
135+
</tbody>
136+
</table>
137+
138+
Each entry also contains the normalized `stamps` array, plus fields such as
139+
`transport_message_id`, `from_failed_transport`, and
140+
`failed_transport_original_receiver_name` when those details are available.
46141

47142
## Local development
48143

src/C10kMessengerLoggingBundle.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@
44

55
namespace C10k\MessengerLoggingBundle;
66

7+
use C10k\MessengerLoggingBundle\DependencyInjection\Compiler\RegisterStampNormalizersPass;
8+
use Symfony\Component\DependencyInjection\ContainerBuilder;
79
use Symfony\Component\HttpKernel\Bundle\Bundle;
810

911
final class C10kMessengerLoggingBundle extends Bundle
1012
{
13+
public function build(ContainerBuilder $container): void
14+
{
15+
parent::build($container);
16+
17+
$container->addCompilerPass(new RegisterStampNormalizersPass());
18+
}
1119
}

src/DependencyInjection/C10kMessengerLoggingExtension.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use C10k\MessengerLoggingBundle\EventSubscriber\WorkerMessageReceivedEventSubscriber;
1111
use C10k\MessengerLoggingBundle\EventSubscriber\WorkerMessageRetriedEventSubscriber;
1212
use C10k\MessengerLoggingBundle\EventSubscriber\WorkerMessageSkipEventSubscriber;
13+
use C10k\MessengerLoggingBundle\Logging\StampNormalizerInterface;
1314
use Symfony\Component\Config\FileLocator;
1415
use Symfony\Component\DependencyInjection\ContainerBuilder;
1516
use Symfony\Component\DependencyInjection\Extension\Extension;
@@ -28,9 +29,15 @@ public function load(array $configs, ContainerBuilder $container): void
2829
$logChannel = is_string($config['log_channel']) ? $config['log_channel'] : null;
2930
/** @var array<string, string> $logLevels */
3031
$logLevels = $config['log_levels'];
32+
/** @var array<class-string, class-string<StampNormalizerInterface>> $stampNormalizers */
33+
$stampNormalizers = $config['stamp_normalizers'] ?? [];
34+
35+
$container->registerForAutoconfiguration(StampNormalizerInterface::class)
36+
->addTag(StampNormalizerInterface::SERVICE_TAG);
3137

3238
$container->setParameter('c10k_messenger_logging.enabled', $enabled);
3339
$container->setParameter('c10k_messenger_logging.log_channel', $logChannel);
40+
$container->setParameter('c10k_messenger_logging.stamp_normalizers', $stampNormalizers);
3441

3542
foreach ($logLevels as $event => $logLevel) {
3643
$container->setParameter(
@@ -50,6 +57,17 @@ public function load(array $configs, ContainerBuilder $container): void
5057

5158
$loader->load('services.php');
5259

60+
foreach ($stampNormalizers as $normalizerClass) {
61+
if ($container->hasDefinition($normalizerClass) || $container->hasAlias($normalizerClass)) {
62+
continue;
63+
}
64+
65+
$container
66+
->register($normalizerClass, $normalizerClass)
67+
->setAutowired(true)
68+
->setAutoconfigured(false);
69+
}
70+
5371
if (!is_string($logChannel)) {
5472
return;
5573
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace C10k\MessengerLoggingBundle\DependencyInjection\Compiler;
6+
7+
use C10k\MessengerLoggingBundle\Logging\MessengerLogContextBuilder;
8+
use C10k\MessengerLoggingBundle\Logging\StampNormalizerInterface;
9+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
10+
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
11+
use Symfony\Component\DependencyInjection\ContainerBuilder;
12+
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
13+
use Symfony\Component\DependencyInjection\Reference;
14+
use Symfony\Component\Messenger\Stamp\StampInterface;
15+
16+
use function array_unique;
17+
use function array_values;
18+
use function is_string;
19+
20+
final class RegisterStampNormalizersPass implements CompilerPassInterface
21+
{
22+
public function process(ContainerBuilder $container): void
23+
{
24+
if (!$container->hasDefinition(MessengerLogContextBuilder::class)) {
25+
return;
26+
}
27+
28+
/** @var array<class-string<StampInterface>, class-string<StampNormalizerInterface>> $configuredNormalizers */
29+
$configuredNormalizers = $container->getParameter('c10k_messenger_logging.stamp_normalizers');
30+
$normalizers = [];
31+
$registeredBy = [];
32+
33+
/** @var array<string, list<array<string, mixed>>> $taggedNormalizers */
34+
$taggedNormalizers = $container->findTaggedServiceIds(StampNormalizerInterface::SERVICE_TAG, true);
35+
36+
foreach ($taggedNormalizers as $serviceId => $tags) {
37+
foreach ($this->supportedStampClasses($container, $serviceId, $tags) as $stampClass) {
38+
if (
39+
isset($registeredBy[$stampClass])
40+
&& $registeredBy[$stampClass] !== $serviceId
41+
&& !isset($configuredNormalizers[$stampClass])
42+
) {
43+
throw new InvalidArgumentException(sprintf(
44+
'Multiple stamp normalizers are registered for "%s": "%s" and "%s". Configure an explicit mapping to resolve the ambiguity.',
45+
$stampClass,
46+
$registeredBy[$stampClass],
47+
$serviceId,
48+
));
49+
}
50+
51+
$normalizers[$stampClass] = new Reference($serviceId);
52+
$registeredBy[$stampClass] = $serviceId;
53+
}
54+
}
55+
56+
foreach ($configuredNormalizers as $stampClass => $normalizerClass) {
57+
$this->assertStampClass($stampClass);
58+
$this->assertNormalizerClass($normalizerClass);
59+
60+
$normalizers[$stampClass] = new Reference($normalizerClass);
61+
}
62+
63+
$container
64+
->getDefinition(MessengerLogContextBuilder::class)
65+
->setArgument(
66+
'$stampNormalizers',
67+
ServiceLocatorTagPass::register($container, $normalizers, MessengerLogContextBuilder::class),
68+
);
69+
}
70+
71+
/**
72+
* @param list<array<string, mixed>> $tags
73+
*
74+
* @return list<class-string<StampInterface>>
75+
*/
76+
private function supportedStampClasses(ContainerBuilder $container, string $serviceId, array $tags): array
77+
{
78+
$definition = $container->findDefinition($serviceId);
79+
$class = $definition->getClass();
80+
$class = is_string($class) ? $container->getParameterBag()->resolveValue($class) : $class;
81+
82+
if (!is_string($class) || $class === '') {
83+
throw new InvalidArgumentException(sprintf(
84+
'Stamp normalizer service "%s" must have a concrete class.',
85+
$serviceId,
86+
));
87+
}
88+
89+
$this->assertNormalizerClass($class);
90+
91+
$stampClasses = [];
92+
93+
foreach ($tags as $tag) {
94+
$stampClasses[] = isset($tag['stamp_class'])
95+
? $tag['stamp_class']
96+
: $class::getSupportedStampClass();
97+
}
98+
99+
foreach ($stampClasses as $stampClass) {
100+
if (!is_string($stampClass)) {
101+
throw new InvalidArgumentException(sprintf(
102+
'Stamp normalizer service "%s" must declare stamp classes as strings.',
103+
$serviceId,
104+
));
105+
}
106+
107+
$this->assertStampClass($stampClass);
108+
}
109+
110+
$uniqueStampClasses = [];
111+
112+
foreach ($stampClasses as $stampClass) {
113+
$uniqueStampClasses[$stampClass] = $stampClass;
114+
}
115+
116+
/** @var list<class-string<StampInterface>> */
117+
return array_values($uniqueStampClasses);
118+
}
119+
120+
private function assertStampClass(string $stampClass): void
121+
{
122+
if (!is_a($stampClass, StampInterface::class, true)) {
123+
throw new InvalidArgumentException(sprintf(
124+
'Configured stamp class "%s" must implement "%s".',
125+
$stampClass,
126+
StampInterface::class,
127+
));
128+
}
129+
}
130+
131+
private function assertNormalizerClass(string $normalizerClass): void
132+
{
133+
if (!is_a($normalizerClass, StampNormalizerInterface::class, true)) {
134+
throw new InvalidArgumentException(sprintf(
135+
'Configured normalizer class "%s" must implement "%s".',
136+
$normalizerClass,
137+
StampNormalizerInterface::class,
138+
));
139+
}
140+
}
141+
}

src/DependencyInjection/Configuration.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ public function getConfigTreeBuilder(): TreeBuilder
7171
->end()
7272
->end()
7373
->end()
74+
->arrayNode('stamp_normalizers')
75+
->useAttributeAsKey('stamp_class')
76+
->scalarPrototype()
77+
->cannotBeEmpty()
78+
->end()
79+
->end()
7480
->end()
7581
;
7682

0 commit comments

Comments
 (0)