diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 4644460..866b340 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -44,7 +44,7 @@ jobs: ports: - 6379:6379 mongo: - image: mongo:8.0 + image: mongo:8.2 ports: - 27017:27017 env: diff --git a/CHANGELOG.md b/CHANGELOG.md index b3887bc..21fd533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## [11.3.0](https://github.com/anzusystems/common-bundle/compare/11.2.0...11.3.0) (2026-07-13) + +### Features +* New `LogContextContentProcessor` — expands the JSON-encoded `LogContext::content` string back into an array for handlers that report structured context. Without it, structured log context (e.g. `['taskId' => 1, 'detail' => 'reason']`) reaches Sentry as one opaque JSON string inside `monolog.context.content` — not searchable and easy to miss. Registered as a plain service (no `monolog.processor` tag) on purpose, so the journal/audit mongo pipeline keeps persisting `content` as a string; apps push it onto their outbound handler: +```php +$services + ->set('sentry.monolog.handler', SentryHandler::class) + ->arg('$hub', service(HubInterface::class)) + ->arg('$level', Level::Warning) + ->arg('$bubble', true) + ->arg('$fillExtraContext', true) + ->call('pushProcessor', [service(LogContextContentProcessor::class)]) +; +``` + ## [11.2.0](https://github.com/anzusystems/common-bundle/compare/11.1.1...11.2.0) (2026-07-03) ### Features diff --git a/docker-compose.yml b/docker-compose.yml index 8c5523a..f116dd0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: hostname: redis mongo: - image: mongo:8.0 + image: mongo:8.2 command: --logappend ${MONGO_NOTABLESCAN:-} env_file: - .env.docker.dist diff --git a/src/Monolog/LogContextContentProcessor.php b/src/Monolog/LogContextContentProcessor.php new file mode 100644 index 0000000..5b55692 --- /dev/null +++ b/src/Monolog/LogContextContentProcessor.php @@ -0,0 +1,41 @@ +set('sentry.monolog.handler', SentryHandler::class) + * ->call('pushProcessor', [service(LogContextContentProcessor::class)]); + */ +final class LogContextContentProcessor +{ + public function __invoke(LogRecord $record): LogRecord + { + $content = $record->context['content'] ?? null; + if (false === is_string($content) || '' === $content) { + return $record; + } + + try { + $decodedContent = json_decode($content, associative: true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException) { + return $record; + } + if (false === is_array($decodedContent)) { + return $record; + } + + return $record->with(context: array_merge($record->context, ['content' => $decodedContent])); + } +} diff --git a/src/Resources/config/logs.php b/src/Resources/config/logs.php index cbdded4..3cb2c28 100644 --- a/src/Resources/config/logs.php +++ b/src/Resources/config/logs.php @@ -17,6 +17,7 @@ use AnzuSystems\CommonBundle\Messenger\Middleware\ContextIdentityMiddleware; use AnzuSystems\CommonBundle\Messenger\MonologHandler\MessengerHandler; use AnzuSystems\CommonBundle\Monolog\ContextProcessor; +use AnzuSystems\CommonBundle\Monolog\LogContextContentProcessor; use AnzuSystems\CommonBundle\Repository\Mongo\AbstractAnzuMongoRepository; use AnzuSystems\CommonBundle\Serializer\Service\BsonConverter; use AnzuSystems\SerializerBundle\Metadata\MetadataRegistry; @@ -35,6 +36,8 @@ $services->set(ContextProcessor::class) ->tag('monolog.processor'); + $services->set(LogContextContentProcessor::class); + $services->set(LogContextFactory::class) ->arg('$userProvider', service(CurrentAnzuUserProvider::class)) ->arg('$serializer', service(Serializer::class)) diff --git a/tests/Monolog/LogContextContentProcessorTest.php b/tests/Monolog/LogContextContentProcessorTest.php new file mode 100644 index 0000000..f2042f2 --- /dev/null +++ b/tests/Monolog/LogContextContentProcessorTest.php @@ -0,0 +1,69 @@ +processor = new LogContextContentProcessor(); + } + + public function testDecodesJsonContentIntoArray(): void + { + $record = $this->createRecord([ + 'appSystem' => 'cms', + 'content' => '{"taskId":123,"executionId":45,"detail":"missing_block_ids"}', + ]); + + $processed = ($this->processor)($record); + + self::assertSame( + [ + 'taskId' => 123, + 'executionId' => 45, + 'detail' => 'missing_block_ids', + ], + $processed->context['content'], + ); + self::assertSame('cms', $processed->context['appSystem']); + // the original record stays untouched — only the handler pipeline sees the decoded copy + self::assertSame('{"taskId":123,"executionId":45,"detail":"missing_block_ids"}', $record->context['content']); + } + + public function testKeepsRecordWhenContentIsNotDecodableToArray(): void + { + $emptyContent = $this->createRecord(['content' => '']); + $invalidJson = $this->createRecord(['content' => 'not a json']); + $scalarJson = $this->createRecord(['content' => '"just a string"']); + $noContent = $this->createRecord(['foo' => 'bar']); + $arrayContent = $this->createRecord(['content' => ['already' => 'decoded']]); + + self::assertSame($emptyContent, ($this->processor)($emptyContent)); + self::assertSame($invalidJson, ($this->processor)($invalidJson)); + self::assertSame($scalarJson, ($this->processor)($scalarJson)); + self::assertSame($noContent, ($this->processor)($noContent)); + self::assertSame($arrayContent, ($this->processor)($arrayContent)); + } + + private function createRecord(array $context): LogRecord + { + return new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'journal', + level: Level::Error, + message: '[RavenAi] Translate result cannot be applied', + context: $context, + ); + } +}