diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 68ab7c9..ddef4e2 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -17,13 +17,14 @@ jobs: strategy: matrix: include: - - php-version: '8.1' - - php-version: '8.2' - - php-version: '8.4' + - { php-version: '8.2', symfony-version: '6.4.*' } + - { php-version: '8.3', symfony-version: '7.4.*' } + - { php-version: '8.4', symfony-version: '8.0.*' } + - { php-version: '8.5', symfony-version: '8.0.*' } fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP version uses: shivammathur/setup-php@v2 @@ -33,23 +34,34 @@ jobs: - name: Validate composer.json and composer.lock run: composer validate --strict + - name: Pin Symfony version + run: | + composer require --no-update \ + symfony/config:"${{ matrix.symfony-version }}" \ + symfony/dependency-injection:"${{ matrix.symfony-version }}" \ + symfony/http-foundation:"${{ matrix.symfony-version }}" \ + symfony/http-kernel:"${{ matrix.symfony-version }}" \ + symfony/security-bundle:"${{ matrix.symfony-version }}" + - name: Cache Composer packages id: composer-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: vendor - key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-php${{ matrix.php-version }}-sf${{ matrix.symfony-version }}-${{ hashFiles('**/composer.json') }} restore-keys: | - ${{ runner.os }}-php- + ${{ runner.os }}-php${{ matrix.php-version }}-sf${{ matrix.symfony-version }}- - name: Install dependencies - run: composer install --prefer-dist --no-progress + run: composer update --prefer-dist --no-progress ${{ matrix.composer-flags }} + + - name: Security audit + run: composer audit - name: Run static analysis run: | vendor/bin/php-cs-fixer fix --allow-risky=yes --config=.php-cs-fixer.php --dry-run --verbose - vendor/bin/phpmd src text .pmd-ruleset.xml - vendor/bin/psalm --show-info=true --php-version=${{ matrix.php-version }} + vendor/bin/phpstan analyse --memory-limit=512M - name: Run test suite run: composer run-script test diff --git a/.gitignore b/.gitignore index 20fff9c..c6b566b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /.php-cs-fixer.cache +/.phpunit.result.cache /vendor /composer.lock \ No newline at end of file diff --git a/.pmd-ruleset.xml b/.pmd-ruleset.xml deleted file mode 100644 index 70380f7..0000000 --- a/.pmd-ruleset.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d70490e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,91 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [3.0.0] + +### Breaking Changes + +- **PHP 8.2** minimum — PHP 8.1 (EOL since December 2024) is no longer supported. +- **Symfony LTS-only** — supported versions are 6.4 (LTS), 7.4 (LTS), and 8.0. Intermediate versions (7.0–7.3) are not supported. +- **`monolog.formatter.ecs`** now produces a different JSON structure: `log.level` is nested under `log` and lowercased (was a root-level dot-notation key). Existing Elasticsearch index mappings may need to be updated. +- **`AutoLabelProcessor::FIELDS_ALL`** content has changed: `os`, `vlan`, `interface`, and `tracing` have been removed; `entity` (ECS 9.x) and `gen_ai` (ECS 9.1) have been added. If you reference this constant directly in code, review your usage. +- **`AutoLabelProcessor`** — non-ECS fields are now **dropped** by default instead of being moved to `labels`. Add `move_to_labels: true` to restore the previous behaviour. +- **`AutoLabelProcessor`** — the `fields` config key is replaced by `mode` (`bundle` | `full` | `custom`). Use `mode: custom` with `fields: [...]` for the previous behaviour of passing a raw list. +- **`AutoLabelProcessor::FIELDS_MINIMAL`** constant removed. Use `mode: bundle` instead. +- **`AbstractProcessor`** — `getTargetField()` and `support()` visibility changed from `public` to `protected`. Code calling these methods from outside a subclass will fail. +- **`AbstractProcessor`** — `getTargetField()` is now `final` and no longer abstract. Subclasses must pass the target field name as the second argument to `parent::__construct()` instead of implementing `getTargetField()`. +- A misconfigured processor (enabled but no channel or handler defined) now throws an `InvalidConfigurationException` at container compile time. Previously it silently had no effect. +- **`EcsUserProvider`** — `getUserIdentifier()` is now mapped to `user.name` instead of `user.id`. Symfony's identifier is a login or email (`user.name`), not a technical database ID (`user.id`). If you rely on `user.id` in your index mappings, implement a custom `EcsUserProviderInterface`. +- **`tags`** config option — static tags added to every log record via the ECS `tags` field (e.g. `['env:prod', 'region:eu-west-1']`). Passed through to the underlying `ElasticCommonSchemaFormatter`. + +### Added + +- **`ecs_version`** config option — declare which ECS version to output (default `9.3.0`). Override for older Elastic Stack deployments (e.g. `8.11.0`). Invalid values throw an `InvalidConfigurationException` at boot. +- **`HttpRequestProcessor`** — injects ECS `http.*` and `url.*` fields from the current HTTP request. Optional `include_full_url` (disabled by default — may expose sensitive query parameters), `include_client_ip` (disabled by default), and `include_referrer` (disabled by default — Referer header may carry sensitive external URLs). +- **`HostProcessor`** — injects ECS `host.*` fields (`host.name`, `host.ip`, `host.architecture`) resolved at boot time. Optional `resolve_ip` (default `false`) to auto-detect `host.ip` via DNS. +- **`ErrorProcessor`** — new `map_exception_key` option to automatically process Symfony's `context['exception']` as ECS `error.*`. +- **`AutoLabelProcessor`** — new `mode` option (`bundle` | `full` | `custom`, default `bundle`) replacing the raw `fields` list. `bundle` whitelists fields used by this bundle's processors; `full` covers all ECS field sets; `custom` uses the explicit `fields` list. +- **`AutoLabelProcessor`** — new `move_to_labels` option (default `false`). Non-ECS fields are now **dropped** by default; set `move_to_labels: true` to preserve them under `labels` as before. +- **`AutoLabelProcessor`** — new `include_extra` option to also process non-ECS keys from Monolog's `extra` array. +- **`AutoLabelProcessor`** — new `non_scalar_strategy` option (`skip` | `json`, default `skip`). Non-scalar context values are either dropped (`skip`) or JSON-encoded into `labels` (`json`) when `move_to_labels` is enabled. +- **`TracingProcessor`** — new `span_id` input key. When present in the tracing array, injects ECS `span.id` into the log record. +- **`processor.user.provider`** — the referenced service is now validated at container compile time: it must implement `EcsUserProviderInterface`. Invalid configurations throw at boot instead of silently failing at log time. +- **`EcsUserProvider`** and **`UserProcessor`** — both implement `ResetInterface` for compatibility with FrankenPHP worker mode. State is cleared between requests automatically. + +### Fixed + +- **`AutoLabelProcessor`** — non-scalar values in non-ECS context fields were previously placed in `labels` as-is, violating ECS (labels must be scalar). They are now handled via `non_scalar_strategy`. +- **`AutoLabelProcessor`** — a non-array `context['labels']` value no longer throws `\InvalidArgumentException` at runtime. The invalid value is silently overwritten to preserve ECS compliance. + +## [2.0.2] + +### Fixed + +- **`AutoLabelProcessor`** — ensure it runs last and validate `labels` type. +- Monolog configuration error. + +## [2.0.1] + +### Changed + +- Symfony 7 and 8 compatibility. + +### Fixed + +- Custom user providers configured via `ecs_logging.processor.user.provider` were never used. +- Missing `address` field in configuration for the service processor. + +## [2.0.0] + +### Breaking Changes + +- Requires Symfony 7.x and Monolog 3.x. Symfony 6.x and Monolog 2.x are no longer supported. + +### Added + +- Symfony 7 and Monolog 3 compatibility. + +## [1.0.0] + +### Added + +- Initial release. +- `monolog.formatter.ecs` service for ECS-compliant log formatting. +- **`ServiceProcessor`** — injects static service metadata into every log record. +- **`ErrorProcessor`** — converts a `\Throwable` in context to ECS `error.*`. +- **`TracingProcessor`** — converts a tracing array in context to ECS `trace.*` / `transaction.*`. +- **`UserProcessor`** — injects the current authenticated user as ECS `user.*`. +- **`AutoLabelProcessor`** — moves non-ECS context keys into `labels`. Built-in field lists: `FIELDS_MINIMAL`, `FIELDS_BUNDLE`, `FIELDS_ALL`. +- Symfony 6.4, 7.x, and 8.x compatibility. +- PHP 8.1+ support. + +[Unreleased]: https://github.com/aubes/ecs-logging-bundle/compare/v3.0.0...HEAD +[3.0.0]: https://github.com/aubes/ecs-logging-bundle/compare/v2.0.2...v3.0.0 +[2.0.2]: https://github.com/aubes/ecs-logging-bundle/compare/v2.0.1...v2.0.2 +[2.0.1]: https://github.com/aubes/ecs-logging-bundle/compare/v2.0.0...v2.0.1 +[2.0.0]: https://github.com/aubes/ecs-logging-bundle/compare/v1.0.0...v2.0.0 +[1.0.0]: https://github.com/aubes/ecs-logging-bundle/releases/tag/v1.0.0 diff --git a/README.md b/README.md index e69f2d5..6572f49 100644 --- a/README.md +++ b/README.md @@ -1,427 +1,127 @@ -# Ecs Logging Bundle +# ECS Logging Bundle ![CI](https://github.com/aubes/ecs-logging-bundle/actions/workflows/php.yml/badge.svg) [![Latest Stable Version](https://img.shields.io/packagist/v/aubes/ecs-logging-bundle)](https://packagist.org/packages/aubes/ecs-logging-bundle) [![License](https://img.shields.io/packagist/l/aubes/ecs-logging-bundle)](https://packagist.org/packages/aubes/ecs-logging-bundle) [![PHP Version](https://img.shields.io/packagist/dependency-v/aubes/ecs-logging-bundle/php)](https://packagist.org/packages/aubes/ecs-logging-bundle) -This Symfony bundle provides the [Ecs](https://www.elastic.co/guide/en/ecs/current/ecs-reference.html) log format for Monolog. +A Symfony bundle that formats Monolog logs as [Elastic Common Schema (ECS)](https://www.elastic.co/guide/en/ecs/current/ecs-reference.html) NDJSON, ready to be ingested by Elasticsearch and visualised in Kibana without any index mapping configuration. -It uses [elastic/ecs-logging](https://github.com/elastic/ecs-logging). +Built on top of [elastic/ecs-logging](https://github.com/elastic/ecs-logging-php). -It is compatible with : - * PHP >=8.1 - * Symfony 6.4 | 7.x - * Monolog 3.x +### What's included -## Installation - -```shell -composer require aubes/ecs-logging-bundle -``` - -## Configuration - -### Formatter - -First, you need to configure the Ecs formatter in monolog: - -```yaml -# config/packages/monolog.yaml -monolog: - handlers: - ecs: - # [...] - formatter: 'monolog.formatter.ecs' -``` - -Then configure the bundle, the configuration looks as follows : - -```yaml -# config/packages/ecs-logging.yaml -ecs_logging: - monolog: - # Register the processors on channels or handlers, not both - # To configure channels or handlers is recommended - # Default logging channels the processors should be pushed to - handlers: [] - - # Default logging handlers the processors should be pushed to - #channels: [] - - processor: - # https://www.elastic.co/guide/en/ecs/current/ecs-service.html - service: - enabled: false - name: ~ # Name of the service data is collected from. - version: ~ # Version of the service the data was collected from. - ephemeral_id: ~ # Ephemeral identifier of this service (if one exists). - id: ~ # Unique identifier of the running service. - node_name: ~ # Name of a service node. - state: ~ # Current state of the service. - type: ~ # The type of the service data is collected from. - - #handlers: [] # Logging channels the processor should be pushed to - #channels: [] # Logging handlers the processor should be pushed to - - error: - enabled: false - field_name: 'error' - - #handlers: [] # Logging channels the processor should be pushed to - #channels: [] # Logging handlers the processor should be pushed to - - tracing: - enabled: false - field_name: 'tracing' - - #handlers: [] # Logging channels the processor should be pushed to - #channels: [] # Logging handlers the processor should be pushed to - - user: - enabled: false - domain: ~ # Ecs user domain, example: ldap - provider: ~ # Service Id of the Ecs user provider, default: Aubes\EcsLoggingBundle\Security\EcsUserProvider - - #handlers: [] # Logging channels the processor should be pushed to - #channels: [] # Logging handlers the processor should be pushed to - - auto_label: - enabled: false - fields: [] # Name of internal fields, these fields will not be moved - - #handlers: [] # Logging channels the processor should be pushed to - #channels: [] # Logging handlers the processor should be pushed to -``` - -### Configuration example - -```yaml -# config/packages/ecs-logging.yaml -ecs_logging: - monolog: - handlers: ['ecs'] - - processor: - service: - enabled: true - name: 'MyApp' - version: '%env(string:ECS_LOGGING_SERVICE_VERSION)%' - # [...] - - user: - enabled: true - domain: ~ # Ecs user domain, example: ldap -``` - -```yaml -# config/packages/monolog.yaml -monolog: - handlers: - # [...] - - ecs: - type: stream - path: "%kernel.logs_dir%/%kernel.environment%.ecs.log" - level: info - channels: [ "app" ] - formatter: 'monolog.formatter.ecs' -``` - -## Usage +| Component | Description | +|---|---| +| **`EcsFormatter`** | Produces ECS-compliant NDJSON (`log.level` lowercase, `ecs.version` and `tags` configurable) | +| **`ServiceProcessor`** | Injects static `service.*` metadata (name, version, id…) into every record | +| **`ErrorProcessor`** | Converts a `\Throwable` in context to ECS `error.*` fields | +| **`TracingProcessor`** | Maps a tracing array to ECS `trace.id`, `transaction.id`, `span.id` | +| **`UserProcessor`** | Injects the authenticated user as ECS `user.*` via a customisable provider | +| **`HttpRequestProcessor`** | Injects ECS `http.*`, `url.*`, and optionally `client.ip` from the current request | +| **`HostProcessor`** | Injects static ECS `host.*` fields resolved once at boot time | +| **`AutoLabelProcessor`** | Removes non-ECS context keys to protect the ECS namespace, optionally moving them into `labels` | -### Service processor - -#### Without service processor - -```php -use Elastic\Types\Service; - -$service = new Service() -$service->setName(/* [...] */); -$service->setVersion(/* [...] */); - -$logger->info('exception.message', [ - 'service' => new Service(), -]); -``` - -#### With service processor - -Enable the processor: - -```yaml -# config/packages/ecs-logging.yaml -ecs_logging: - # [...] - processor: - service: - enabled: true - name: # [...] - version: # [...] -``` - -```php -$logger->info('message'); -``` - -### Log error - -#### Without error processor - -```php -use Elastic\Types\Error as EcsError; - -try { - // [...] -} catch (\Exception $e) { - $logger->info('exception.message', [ - 'error' => new EcsError($e), - ]); -} -``` - -#### With error processor - -Enable the processor: - -```yaml -# config/packages/ecs-logging.yaml -ecs_logging: - # [...] - processor: - error: - enabled: true -``` - -```php -try { - // [...] -} catch (\Exception $e) { - $logger->info('exception.message', [ - 'error' => $e, - ]); -} -``` +### Notable defaults -### Tracing +- **Sensitive fields opt-in** — `client.ip`, `url.query`, `http.request.referrer`, and `user.*` (PII — see [UserProcessor](docs/processors/user.md)) are disabled by default +- **FrankenPHP worker mode** — stateful processors implement `ResetInterface` +- **ECS namespace protection** — `AutoLabelProcessor` prevents non-ECS fields from polluting root-level keys +- **ECS 8.x and 9.x** — `ecs.version` defaults to `9.3.0`, configurable per deployment -#### Without tracing processor - -```php -use Elastic\Types\Tracing; - -// [...] - -$logger->info('tracing.message', [ - 'tracing' => new Tracing($traceId, $transactionId), -]); -``` - -#### With tracing processor - -Enable the processor: - -```yaml -# config/packages/ecs-logging.yaml -ecs_logging: - # [...] - processor: - tracing: - enabled: true -``` - -```php -// [...] - -$logger->info('tracing.message', [ - 'tracing' => [ - 'trace_id' => $traceId, - 'transaction_id' => $transactionId, - ], -]); -``` - -### User - -#### Without user processor - -```php -use Elastic\Types\User; - -// [...] - -$ecsUser = new User(); -$ecsUser->setId($userId); -$ecsUser->setName($userName); - -$logger->info('exception.message', [ - 'user' => $ecsUser, -]); -``` - -#### With user processor - -Enable the processor: - -```yaml -# config/packages/ecs-logging.yaml -ecs_logging: - # [...] - processor: - user: - enabled: true -``` - -```php -// [...] - -$logger->info('message'); -``` - -### Auto label - -To automatically move all additional fields into the Ecs `labels` field, useful for internal Symfony bundle log. - -For example without the processor, a Symfony log contains these fields : +### Output example ```json { - "route": "_wdt", - "route_parameters": { - "_route": "_wdt", - "_controller": "web_profiler.controller.profiler::toolbarAction", - "token": "..." + "@timestamp": "2025-03-21T10:00:00.000000+00:00", + "message": "Payment failed", + "ecs.version": "9.3.0", + "log": { + "level": "error", + "logger": "app" }, - "request_uri": "...", - "method": "GET" -} -``` - -With the processor, the Symfony log looks like : - -```json -{ - "labels": { - "route": "_wdt", - "route_parameters": { - "_route": "_wdt", - "_controller": "web_profiler.controller.profiler::toolbarAction", - "token": "..." + "service": { + "name": "checkout", + "version": "1.4.2" + }, + "error": { + "type": "RuntimeException", + "message": "Gateway timeout", + "code": "504" + }, + "trace": { + "id": "123abc123abc123abc123abc123abc12" + }, + "user": { + "name": "alice" + }, + "http": { + "request": { + "method": "POST", + "mime_type": "application/json" }, - "request_uri": "...", - "method": "GET" + "version": "1.1" + }, + "url": { + "path": "/checkout/pay", + "scheme": "https", + "domain": "shop.example.com" } } ``` -Warning, this processor can impact performance. - -#### Configuration +### Compatibility -First, you need to configure the processor: +- PHP >= 8.2 +- Symfony 6.4 | 7.4 | 8.0 — LTS versions only +- Monolog 3.x +- FrankenPHP (worker mode) +- ECS 8.x and 9.x -```yaml -# config/packages/ecs-logging.yaml -ecs_logging: - # [...] +## Installation - processor: - auto_label: - enabled: true - fields: [] # Name of internal fields, these fields will not be moved - #fields: !php/const Aubes\EcsLoggingBundle\Logger\AutoLabelProcessor::FIELDS_MINIMAL - #fields: !php/const Aubes\EcsLoggingBundle\Logger\AutoLabelProcessor::FIELDS_BUNDLE - #fields: !php/const Aubes\EcsLoggingBundle\Logger\AutoLabelProcessor::FIELDS_ALL +```shell +composer require aubes/ecs-logging-bundle ``` -You can define a custom list or use the built-in constant: - - * `Aubes\EcsLoggingBundle\Logger\AutoLabelProcessor::FIELDS_MINIMAL`: minimal fields supported by the bundle - * `Aubes\EcsLoggingBundle\Logger\AutoLabelProcessor::FIELDS_BUNDLE`: all fields supported by the bundle - * `Aubes\EcsLoggingBundle\Logger\AutoLabelProcessor::FIELDS_ALL`: all [Ecs fields](https://www.elastic.co/guide/en/ecs/current/ecs-field-reference.html) and all bundle fields +## Quick start -For performance reasons, use only necessary fields. - -#### Configuration example - -```yaml -# config/packages/ecs-logging.yaml -ecs_logging: - monolog: - handlers: ['app', 'main'] - - processor: - # [...] - - auto_label: - enabled: true - fields: !php/const Aubes\EcsLoggingBundle\Logger\AutoLabelProcessor::FIELDS_BUNDLE - handlers: ['main'] # do not apply on ecs channel -``` +**1. Configure the formatter in Monolog:** ```yaml # config/packages/monolog.yaml monolog: handlers: main: - type: stream - path: "%kernel.logs_dir%/%kernel.environment%.log" - level: warning - channels: [ "!event", "!app" ] - formatter: 'monolog.formatter.ecs' - app: type: stream path: "%kernel.logs_dir%/%kernel.environment%.log" level: info - channels: [ "app" ] formatter: 'monolog.formatter.ecs' ``` -## Custom Ecs user provider - -The default Ecs user provider is [Aubes\EcsLoggingBundle\Security\EcsUserProvider](src/Security/EcsUserProvider.php), but you can use your own provider. - -First you need to create an Ecs User Provider class and implement [EcsUserProviderInterface](src/Security/EcsUserProviderInterface.php) : - -```php -// src/Security/CustomEcsUserProvider.php -namespace App\Security; - -use Elastic\Types\User; -use Symfony\Component\Security\Core\Security; - -class CustomEcsUserProvider implements EcsUserProviderInterface -{ - public function getUser(): ?User - { - // [...] - } - - public function getDomain(): ?string - { - return 'custom_user_provider'; - } -} -``` - -Next, register your class as a service : +**2. Enable the bundle and configure at least one processor:** ```yaml -# config/services.yaml -services: - App\Security\CustomEcsUserProvider: ~ -``` - -Then, configure the provider : - -```yaml -# config/packages/ecs-logging.yaml +# config/packages/ecs_logging.yaml ecs_logging: - # [...] + monolog: + handlers: ['main'] processor: - user: + service: enabled: true - provider: 'App\Security\CustomEcsUserProvider' + name: 'my-app' + version: '%env(string:APP_VERSION)%' ``` + +## Documentation + +- [Configuration reference](docs/configuration-reference.md) +- Processors + - [ServiceProcessor](docs/processors/service.md) + - [ErrorProcessor](docs/processors/error.md) + - [TracingProcessor](docs/processors/tracing.md) + - [UserProcessor](docs/processors/user.md) — includes custom provider + - [AutoLabelProcessor](docs/processors/auto-label.md) + - [HttpRequestProcessor](docs/processors/http-request.md) + - [HostProcessor](docs/processors/host.md) diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..8bc7012 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,140 @@ +# Upgrade Guide + +## Upgrading from 2.x to 3.0 + +### PHP 8.2 required + +### Symfony LTS versions only + +If you are on Symfony 7.0–7.3, upgrade to 7.4 before updating this bundle. + +### `log.level` moved into the `log` object + +The ECS formatter now produces a fully compliant structure. `log.level` is nested under `log` and lowercased: + +```json +// Before (2.x) +{ "log.level": "INFO", ... } + +// After (3.x) +{ "log": { "level": "info", "logger": "app" }, ... } +``` + +If you have Elasticsearch index mappings or Kibana queries targeting `log.level` as a root-level dot-notation key, update them to `log.level` (nested field path). + +### `AutoLabelProcessor` — configuration overhaul + +#### `fields` replaced by `mode` + +The raw `fields` list is replaced by a `mode` option: + +```yaml +# Before (2.x) +ecs_logging: + processor: + auto_label: + enabled: true + fields: ['error', 'user', 'service'] + +# After (3.x) — use mode: custom +ecs_logging: + processor: + auto_label: + enabled: true + mode: custom + fields: ['error', 'user', 'service'] +``` + +Available modes: `bundle` (default), `full`, `custom`. + +#### Non-ECS fields are now dropped by default + +In 2.x, non-ECS context fields were moved to `labels`. In 3.x, they are **silently dropped** by default. To restore the previous behaviour: + +```yaml +ecs_logging: + processor: + auto_label: + enabled: true + move_to_labels: true +``` + +#### `FIELDS_MINIMAL` constant removed + +`AutoLabelProcessor::FIELDS_MINIMAL` has been removed. Replace with `mode: bundle`. + +#### `FIELDS_ALL` content changed + +`os`, `vlan`, `interface`, and `tracing` have been removed; `entity` and `gen_ai` have been added. If you reference `AutoLabelProcessor::FIELDS_ALL` directly in code, switch to `mode: full` instead. + +### `EcsUserProvider` — `user.name` instead of `user.id` + +`getUserIdentifier()` (Symfony login/email) is now mapped to `user.name` instead of `user.id`. This matches the ECS specification: `user.name` is the login, `user.id` is a technical database identifier. + +If you rely on `user.id` in your Elasticsearch index mappings, implement a custom provider: + +```php +class MyUserProvider implements EcsUserProviderInterface +{ + public function getUser(): ?User + { + $ecsUser = new User(); + $ecsUser->setId($this->getCurrentUser()->getId()); + $ecsUser->setName($this->getCurrentUser()->getUserIdentifier()); + return $ecsUser; + } + + public function getDomain(): ?string { return null; } +} +``` + +### `AbstractProcessor` — `getTargetField()` and `support()` are now `protected` + +If you extended `AbstractProcessor` and override these methods, change their visibility from `public` to `protected`: + +```php +// Before (2.x) +public function getTargetField(): string { ... } +public function support(LogRecord $record): bool { ... } + +// After (3.x) +protected function getTargetField(): string { ... } +protected function support(LogRecord $record): bool { ... } +``` + +### `AbstractProcessor` — `getTargetField()` is now `final` + +`getTargetField()` can no longer be overridden. Pass the target field name as the second argument to `parent::__construct()` instead: + +```php +// Before (2.x / early 3.x) +final class MyProcessor extends AbstractProcessor +{ + protected function getTargetField(): string + { + return 'my_field'; + } +} + +// After (3.x) +final class MyProcessor extends AbstractProcessor +{ + public function __construct(string $fieldName) + { + parent::__construct($fieldName, 'my_field'); + } +} +``` + +### Misconfigured processors now throw at boot + +A processor that is enabled but has no `channels` or `handlers` configured now throws an `InvalidConfigurationException` at container compile time. Previously it silently had no effect. Fix your configuration: + +```yaml +ecs_logging: + monolog: + channels: ['app'] # at least one channel or handler required + processor: + error: + enabled: true +``` diff --git a/composer.json b/composer.json index e768828..0e3131c 100644 --- a/composer.json +++ b/composer.json @@ -2,29 +2,38 @@ "name": "aubes/ecs-logging-bundle", "type": "symfony-bundle", "description": "Symfony bundle providing the Ecs log format", - "keywords": ["symfony", "bundle", "ecs", "mobolog"], + "keywords": ["symfony", "bundle", "ecs", "elastic", "elastic-common-schema", "monolog", "logging"], "license": "MIT", "authors": [ { "name": "Aurélian Bes" } ], + "support": { + "issues": "https://github.com/aubes/ecs-logging-bundle/issues", + "source": "https://github.com/aubes/ecs-logging-bundle" + }, "require": { - "php": ">=8.1", - "elastic/ecs-logging": "^2.0.0", + "php": ">=8.2", + "elastic/ecs-logging": "^2.0", "monolog/monolog": "^3.0", - "symfony/http-foundation": "^6.4 | ^7.0 | ^8.0", - "symfony/http-kernel": "^6.4 | ^7.0 | ^8.0", - "symfony/yaml": "^6.4 | ^7.0 | ^8.0" + "symfony/config": "^6.4 | ^7.4 | ^8.0", + "symfony/dependency-injection": "^6.4 | ^7.4 | ^8.0", + "symfony/http-foundation": "^6.4 | ^7.4 | ^8.0", + "symfony/http-kernel": "^6.4 | ^7.4 | ^8.0" + }, + "suggest": { + "symfony/security-core": "Required for the user processor (default EcsUserProvider)" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.1", - "phpmd/phpmd": "^2.10", - "phpunit/phpunit": ">=9.6", - "phpspec/prophecy-phpunit": ">=v2.0.1", - "symfony/security-bundle": "^6.4 | ^7.0 | ^8.0", - "vimeo/psalm": "^5.9 | ^6.0" + "phpunit/phpunit": "^11.0 || ^12.0", + "symfony/security-bundle": "^6.4 | ^7.4 | ^8.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-phpunit": "^2.0" }, + "minimum-stability": "stable", "autoload": { "psr-4": { "Aubes\\EcsLoggingBundle\\": "src" }, "exclude-from-classmap": [ @@ -37,14 +46,13 @@ "scripts": { "analyse": [ "@cs", - "@pmd", - "@psalm" + "@phpstan" ], "cs": "php-cs-fixer fix --allow-risky=yes --config=.php-cs-fixer.php --dry-run --verbose", "fix-cs": "php-cs-fixer fix --allow-risky=yes --config=.php-cs-fixer.php --show-progress=dots --verbose", - "pmd": "phpmd src text .pmd-ruleset.xml", - "psalm": "psalm --show-info=true", + "phpstan": "phpstan analyse --memory-limit=512M", "test": "phpunit tests", - "coverage": "phpunit tests --coverage-html coverage/ --coverage-clover coverage/clover.xml" + "coverage": "phpunit tests --coverage-html coverage/ --coverage-clover coverage/clover.xml", + "coverage-check": "phpunit tests --coverage-text" } } diff --git a/config/services.yaml b/config/services.yaml deleted file mode 100644 index b6662d2..0000000 --- a/config/services.yaml +++ /dev/null @@ -1,3 +0,0 @@ -services: - monolog.formatter.ecs: - class: 'Elastic\Monolog\Formatter\ElasticCommonSchemaFormatter' diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md new file mode 100644 index 0000000..6649cdc --- /dev/null +++ b/docs/configuration-reference.md @@ -0,0 +1,127 @@ +# Configuration reference + +```yaml +ecs_logging: + + # ECS version declared in the ecs.version field. Use '9.3.0' for Elastic Stack 9.x. + ecs_version: '9.3.0' + + # Static tags added to every log record (ECS tags field). Values must be strings. + tags: [] + + monolog: + + # Default logging channel list the processors should be pushed to + channels: [] + + # Default logging handler list the processors should be pushed to + handlers: [] + processor: + service: + enabled: false + name: ~ + address: ~ + version: ~ + ephemeral_id: ~ + id: ~ + node_name: ~ + state: ~ + type: ~ + + # Logging handler list the processor should be pushed to + handlers: [] + + # Logging channel list the processor should be pushed to + channels: [] + error: + enabled: false + field_name: error + + # Also process context['exception'] (Symfony convention) and map it to error.* + map_exception_key: false + + # Logging handler list the processor should be pushed to + handlers: [] + + # Logging channel list the processor should be pushed to + channels: [] + tracing: + enabled: false + field_name: tracing + + # Logging handler list the processor should be pushed to + handlers: [] + + # Logging channel list the processor should be pushed to + channels: [] + user: + enabled: false + domain: null + provider: null + + # Logging handler list the processor should be pushed to + handlers: [] + + # Logging channel list the processor should be pushed to + channels: [] + auto_label: + enabled: false + + # ECS field whitelist: "bundle" (fields covered by this bundle), "full" (all ECS field sets), "custom" (only fields listed in "fields"). + mode: bundle # One of "bundle"; "full"; "custom" + + # Extra ECS field names to whitelist when mode is "custom". Ignored for other modes. + fields: [] + + # Move non-ECS context fields into labels instead of dropping them. + move_to_labels: false + + # Also process non-ECS keys from Monolog extra (e.g. from ProcessIdProcessor, HostnameProcessor). + include_extra: false + + # Strategy for non-ECS context values that are not scalar. "skip" removes them silently; "json" converts them via json_encode (falls back to skip on failure). + non_scalar_strategy: skip # One of "skip"; "json" + + # Logging handler list the processor should be pushed to + handlers: [] + + # Logging channel list the processor should be pushed to + channels: [] + host: + enabled: false + + # host.name - auto-detected via gethostname() if null + name: null + + # host.ip - auto-detected via gethostbyname() if empty and resolve_ip is true + ip: [] + + # Resolve host.ip via gethostbyname() when ip is not provided. WARNING: this is a blocking DNS call at container build time. + resolve_ip: false + + # host.architecture - auto-detected via php_uname('m') if null + architecture: null + + # Logging handler list the processor should be pushed to + handlers: [] + + # Logging channel list the processor should be pushed to + channels: [] + http_request: + enabled: false + + # Log url.full and url.query. WARNING: may expose sensitive data (tokens, API keys) present in query parameters. + include_full_url: false + + # Log client.ip from the request. Uses Symfony trusted proxies to resolve the real IP behind load balancers. + include_client_ip: false + + # Log http.request.referrer. WARNING: the Referer header may contain external URLs with sensitive data (tokens, session identifiers). + include_referrer: false + + # Logging handler list the processor should be pushed to + handlers: [] + + # Logging channel list the processor should be pushed to + channels: [] +``` diff --git a/docs/processors/auto-label.md b/docs/processors/auto-label.md new file mode 100644 index 0000000..b126ed9 --- /dev/null +++ b/docs/processors/auto-label.md @@ -0,0 +1,128 @@ +# AutoLabelProcessor + +Removes non-ECS context keys from log records to protect the ECS namespace. Optionally moves them into [`labels`](https://www.elastic.co/guide/en/ecs/current/ecs-base.html) instead of dropping them. + +## Configuration + +```yaml +# config/packages/ecs_logging.yaml +ecs_logging: + processor: + auto_label: + enabled: true + mode: bundle # 'bundle' (default), 'full', or 'custom' + fields: [] # extra field names - only used when mode is 'custom' + move_to_labels: false # move non-ECS fields to labels instead of dropping + include_extra: false # also process Monolog extra array + non_scalar_strategy: skip # 'skip' (default) or 'json' + + #handlers: ['ecs'] + #channels: ['app'] +``` + +## Modes + +The `mode` option defines which context keys are **kept as-is** (the ECS whitelist). Everything else is dropped or moved to `labels`. + +| Mode | Whitelist | +|---|---| +| `bundle` (default) | Fields used by this bundle's processors (`error`, `host`, `http`, `service`, `trace`, `transaction`, `url`, `user`, …) | +| `full` | All ECS top-level field sets (8.x and 9.x) | +| `custom` | Only the keys listed in `fields` | + +Bundle-internal keys (`tracing`, `span`) are always protected regardless of mode. + +```yaml +auto_label: + enabled: true + mode: bundle # recommended starting point +``` + +```yaml +auto_label: + enabled: true + mode: full # maximum ECS coverage, no custom fields pass through +``` + +```yaml +auto_label: + enabled: true + mode: custom + fields: ['error', 'user', 'http', 'trace', 'my_custom_ecs_field'] +``` + +## Move to labels (`move_to_labels`) + +By default, non-ECS fields are **dropped silently**. Enable `move_to_labels` to preserve them under `labels` instead: + +```yaml +auto_label: + enabled: true + mode: bundle + move_to_labels: true +``` + +Without the processor (non-ECS fields at root level): + +```json +{ + "route": "_wdt", + "route_parameters": { "_route": "_wdt", "_controller": "...", "token": "..." }, + "request_uri": "...", + "method": "GET" +} +``` + +With `move_to_labels: true` and `non_scalar_strategy: skip`: + +```json +{ + "labels": { + "route": "_wdt", + "request_uri": "...", + "method": "GET" + } +} +``` + +`route_parameters` is an array (non-scalar) and is dropped. Use `non_scalar_strategy: json` to convert it instead. + +## Non-scalar values (`non_scalar_strategy`) + +ECS requires `labels` values to be scalar (string, bool, number). This option controls what happens to non-scalar values when they would be moved to labels (has no effect when `move_to_labels` is `false`). + +| Value | Behaviour | +|---|---| +| `skip` (default) | Non-scalar fields are removed silently | +| `json` | Non-scalar fields are JSON-encoded into `labels` as a string. Falls back to `skip` on encoding failure. | + +With `move_to_labels: true` and `non_scalar_strategy: json`: + +```json +{ + "labels": { + "route": "_wdt", + "route_parameters": "{\"_route\":\"_wdt\",\"_controller\":\"...\",\"token\":\"...\"}", + "request_uri": "...", + "method": "GET" + } +} +``` + +## Monolog extra fields (`include_extra`) + +Monolog's `extra` array (populated by processors like `ProcessIdProcessor`, `UidProcessor`, `HostnameProcessor`) is serialised to the ECS root by the formatter, which can pollute the namespace. Enable `include_extra` to also process unknown `extra` keys (drop or move to `labels` depending on `move_to_labels`): + +```yaml +auto_label: + enabled: true + mode: bundle + move_to_labels: true + include_extra: true +``` + +Known ECS keys in `extra` are left in place. + +## Label key collision + +If a key appears both in `context['labels']` (explicitly set by the application) and as a non-ECS context field, the explicit `labels` value takes priority. diff --git a/docs/processors/error.md b/docs/processors/error.md new file mode 100644 index 0000000..3d7a534 --- /dev/null +++ b/docs/processors/error.md @@ -0,0 +1,56 @@ +# ErrorProcessor + +Converts a `\Throwable` in the log context to [ECS `error.*`](https://www.elastic.co/guide/en/ecs/current/ecs-error.html) fields. + +## Configuration + +```yaml +# config/packages/ecs_logging.yaml +ecs_logging: + processor: + error: + enabled: true + field_name: 'error' # context key to read from (default: 'error') + map_exception_key: false # also process context['exception'] → error.* + + #handlers: ['ecs'] + #channels: ['app'] +``` + +## Basic usage + +Without the processor: + +```php +use Elastic\Types\Error as EcsError; + +try { + // ... +} catch (\Throwable $e) { + $logger->error('Something failed', ['error' => new EcsError($e)]); +} +``` + +With the processor: + +```php +try { + // ... +} catch (\Throwable $e) { + $logger->error('Something failed', ['error' => $e]); +} +``` + +## Symfony exceptions (`map_exception_key`) + +Symfony's internal log calls pass exceptions under the `exception` key (not `error`). Enable `map_exception_key` to automatically process `context['exception']` as `error.*`, without any code change: + +```yaml +ecs_logging: + processor: + error: + enabled: true + map_exception_key: true +``` + +This covers framework logs from `HttpKernel`, security, form, etc. diff --git a/docs/processors/host.md b/docs/processors/host.md new file mode 100644 index 0000000..c4be70e --- /dev/null +++ b/docs/processors/host.md @@ -0,0 +1,35 @@ +# HostProcessor + +Injects static [ECS `host.*`](https://www.elastic.co/guide/en/ecs/current/ecs-host.html) fields into every log record. Values are resolved once at container build time and cached for the lifetime of the process. + +## Configuration + +```yaml +# config/packages/ecs_logging.yaml +ecs_logging: + processor: + host: + enabled: true + name: ~ # auto-detected via gethostname() if null + ip: [] # explicit IPs; takes precedence over resolve_ip + + # ⚠️ resolve_ip triggers a blocking DNS call (gethostbyname()) at container build time. + # In environments where DNS resolution is slow (cold containers, restricted networks), + # this can delay startup by several seconds. Prefer providing ip explicitly: + # ip: ['%env(string:HOST_IP)%'] + resolve_ip: false + + architecture: ~ # auto-detected via php_uname('m') if null + + #handlers: ['ecs'] + #channels: ['app'] +``` + +## Fields + +| ECS field | Default behaviour | +|---|---| +| `host.name` | `gethostname()` | +| `host.ip` | Not set unless `ip` is provided or `resolve_ip: true` | +| `host.architecture` | `php_uname('m')` (e.g. `x86_64`, `aarch64`) | + diff --git a/docs/processors/http-request.md b/docs/processors/http-request.md new file mode 100644 index 0000000..ede0c3c --- /dev/null +++ b/docs/processors/http-request.md @@ -0,0 +1,47 @@ +# HttpRequestProcessor + +Injects [ECS `http.*`](https://www.elastic.co/guide/en/ecs/current/ecs-http.html), [`url.*`](https://www.elastic.co/guide/en/ecs/current/ecs-url.html), and optionally `client.ip` from the current HTTP request into every log record. + +> No active request (e.g. in a Symfony command) means the processor has no effect. + +## Configuration + +```yaml +# config/packages/ecs_logging.yaml +ecs_logging: + processor: + http_request: + enabled: true + + # ⚠️ query parameters frequently carry sensitive data (password reset tokens, OAuth codes, + # API keys, session identifiers) - disabled by default + include_full_url: false + + # ⚠️ client IP is a PII value; resolved via Symfony trusted proxies (X-Forwarded-For + # handled correctly) - disabled by default + include_client_ip: false + + # ⚠️ the Referer header may carry sensitive data (tokens, OAuth codes, session IDs, + # personal search terms from external sites) - disabled by default + include_referrer: false + + #handlers: ['ecs'] + #channels: ['app'] +``` + +## Fields always logged + +| ECS field | Source | +|---|---| +| `http.request.method` | HTTP method (`GET`, `POST`…) | +| `http.request.mime_type` | `Content-Type` header, if present | +| `http.request.bytes` | `Content-Length` header, if present | +| `http.version` | Protocol version (`1.1`, `2`) | +| `url.path` | Request path ⚠️ see security note | +| `url.scheme` | `http` or `https` | +| `url.domain` | Host (`example.com`) | +| `url.port` | Port, if non-standard (80/443 are omitted) | + +> **Note - `url.path`** +> The request path can contain personal identifiers embedded in the route (e.g. `/api/users/john@example.com/`, `/reset-password/TOKEN`). +> Consider normalising routes before logging (log `/api/users/{id}` rather than `/api/users/42`). \ No newline at end of file diff --git a/docs/processors/service.md b/docs/processors/service.md new file mode 100644 index 0000000..71d82eb --- /dev/null +++ b/docs/processors/service.md @@ -0,0 +1,55 @@ +# ServiceProcessor + +Injects static [ECS `service.*`](https://www.elastic.co/guide/en/ecs/current/ecs-service.html) metadata into every log record. Values are defined in config and injected at container build time. + +## Configuration + +```yaml +# config/packages/ecs_logging.yaml +ecs_logging: + processor: + service: + enabled: true + name: 'my-app' + version: '%env(string:APP_VERSION)%' + type: 'web' + id: ~ + ephemeral_id: ~ + node_name: ~ + state: ~ + address: ~ + + #handlers: ['ecs'] + #channels: ['app'] +``` + +| Option | ECS field | Description | +|---|---|---| +| `name` | `service.name` | Name of the service | +| `version` | `service.version` | Version of the service | +| `type` | `service.type` | Type of service (`web`, `db`…) | +| `id` | `service.id` | Unique service identifier | +| `ephemeral_id` | `service.ephemeral_id` | Ephemeral ID, regenerated on restart | +| `node_name` | `service.node.name` | Node or instance name | +| `state` | `service.state` | Current state of the service | +| `address` | `service.address` | Service address | + +## Usage + +Without the processor, you must build and pass the object manually: + +```php +use Elastic\Types\Service; + +$service = new Service(); +$service->setName('my-app'); +$service->setVersion('1.0.0'); + +$logger->info('message', ['service' => $service]); +``` + +With the processor enabled, every log record receives the service fields automatically: + +```php +$logger->info('message'); +``` diff --git a/docs/processors/tracing.md b/docs/processors/tracing.md new file mode 100644 index 0000000..e93212a --- /dev/null +++ b/docs/processors/tracing.md @@ -0,0 +1,51 @@ +# TracingProcessor + +Converts a tracing array in the log context to ECS `trace.id`, `transaction.id`, and `span.id`. + +## Configuration + +```yaml +# config/packages/ecs_logging.yaml +ecs_logging: + processor: + tracing: + enabled: true + field_name: 'tracing' # context key to read from (default: 'tracing') + + #handlers: ['ecs'] + #channels: ['app'] +``` + +## Usage + +Without the processor: + +```php +use Elastic\Types\Tracing; + +$logger->info('message', [ + 'tracing' => new Tracing($traceId, $transactionId), +]); +``` + +With the processor: + +```php +$logger->info('message', [ + 'tracing' => [ + 'trace_id' => $traceId, // required → ECS trace.id + 'transaction_id' => $transactionId, // optional → ECS transaction.id + 'span_id' => $spanId, // optional → ECS span.id + ], +]); +``` + +## ECS output + +| Input key | ECS field | Format | +|---|---|---| +| `trace_id` | `trace.id` | 32-char hex string | +| `transaction_id` | `transaction.id` | 16-char hex string | +| `span_id` | `span.id` | 16-char hex string | + +`trace_id` is required. `transaction_id` and `span_id` are optional. diff --git a/docs/processors/user.md b/docs/processors/user.md new file mode 100644 index 0000000..00f766c --- /dev/null +++ b/docs/processors/user.md @@ -0,0 +1,87 @@ +# UserProcessor + +Injects the currently authenticated user as [ECS `user.*`](https://www.elastic.co/guide/en/ecs/current/ecs-user.html) fields via a user provider. + +## Configuration + +```yaml +# config/packages/ecs_logging.yaml +ecs_logging: + processor: + user: + enabled: true + # ⚠️ PII: user.name is populated from getUserIdentifier() (typically an email address). + # This appears in every authenticated log record. Use a custom provider to log a + # non-sensitive identifier (e.g. a numeric ID) instead. + # No authenticated user (e.g. in a Symfony command) means the processor has no effect. + domain: ~ # ECS user.domain (e.g. 'ldap', 'ad') - optional + provider: ~ # Service ID of a custom EcsUserProviderInterface - optional + + #handlers: ['ecs'] + #channels: ['app'] +``` + +## Default provider + +The built-in `EcsUserProvider` reads the current Symfony Security token and maps `getUserIdentifier()` to `user.name`. It requires `symfony/security-core`. + +For richer user data (e.g. `user.id`, `user.email`, `user.roles`), implement a custom provider. + +## Custom user provider + +Create a class implementing `EcsUserProviderInterface`: + +```php +// src/Security/CustomEcsUserProvider.php +namespace App\Security; + +use Aubes\EcsLoggingBundle\Security\EcsUserProviderInterface; +use Elastic\Types\User; + +class CustomEcsUserProvider implements EcsUserProviderInterface +{ + public function __construct(private readonly MyUserRepository $users) {} + + public function getUser(): ?User + { + $appUser = $this->users->getCurrentUser(); + if ($appUser === null) { + return null; + } + + $ecsUser = new User(); + $ecsUser->setId((string) $appUser->getId()); + $ecsUser->setName($appUser->getUsername()); + $ecsUser->setEmail($appUser->getEmail()); + + return $ecsUser; + } + + public function getDomain(): ?string + { + return 'my-app'; + } +} +``` + +Register it as a service (if `autoconfigure: true` is enabled, tagging is automatic): + +```yaml +# config/services.yaml +services: + App\Security\CustomEcsUserProvider: ~ +``` + +Then reference it in the bundle config: + +```yaml +# config/packages/ecs_logging.yaml +ecs_logging: + processor: + user: + enabled: true + # ⚠️ FrankenPHP worker mode: if your provider caches state between calls, implement + # Symfony\Contracts\Service\ResetInterface so Symfony clears it between requests. + # The built-in EcsUserProvider already implements it. + provider: 'App\Security\CustomEcsUserProvider' +``` diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..5ac3f0b --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +includes: + - vendor/phpstan/phpstan-symfony/extension.neon + - vendor/phpstan/phpstan-phpunit/extension.neon + +parameters: + level: 8 + paths: + - src + - tests diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..2d12254 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,25 @@ + + + + + tests + + + + + + src + + + + + + + + + diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 8ddabd6..0000000 --- a/psalm.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/src/DependencyInjection/Compiler/UserProviderPass.php b/src/DependencyInjection/Compiler/UserProviderPass.php new file mode 100644 index 0000000..f7b2af2 --- /dev/null +++ b/src/DependencyInjection/Compiler/UserProviderPass.php @@ -0,0 +1,40 @@ +hasDefinition('.ecs_logging.processor.user')) { + return; + } + + $definition = $container->getDefinition('.ecs_logging.processor.user'); + $providerArg = $definition->getArgument('$provider'); + + if (!$providerArg instanceof Reference) { + return; + } + + $providerId = (string) $providerArg; + + if (!$container->hasDefinition($providerId) && !$container->hasAlias($providerId)) { + throw new InvalidArgumentException(\sprintf('The service "%s" configured for "ecs_logging.processor.user.provider" does not exist in the container.', $providerId)); + } + + $class = $container->findDefinition($providerId)->getClass(); + + if ($class === null || !\is_a($class, EcsUserProviderInterface::class, true)) { + throw new InvalidArgumentException(\sprintf('The service "%s" configured for "ecs_logging.processor.user.provider" must implement "%s".', $providerId, EcsUserProviderInterface::class)); + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php deleted file mode 100644 index f993133..0000000 --- a/src/DependencyInjection/Configuration.php +++ /dev/null @@ -1,121 +0,0 @@ -getRootNode(); - - $rootNode - ->children() - ->arrayNode('monolog') - ->addDefaultsIfNotSet() - ->children() - ->arrayNode('channels') - ->info('Default logging channel list the processors should be pushed to') - ->scalarPrototype()->end() - ->end() - ->arrayNode('handlers') - ->info('Default logging handler list the processors should be pushed to') - ->scalarPrototype()->end() - ->end() - ->end() - ->end() - ->arrayNode('processor') - ->children() - ->arrayNode('service') - ->canBeEnabled() - ->children() - ->scalarNode('name')->end() - ->scalarNode('address')->end() - ->scalarNode('version')->end() - ->scalarNode('ephemeral_id')->end() - ->scalarNode('id')->end() - ->scalarNode('node_name')->end() - ->scalarNode('state')->end() - ->scalarNode('type')->end() - ->end() - ->append($this->addHandlersNode()) - ->append($this->addChannelsNode()) - ->end() - ->arrayNode('error') - ->canBeEnabled() - ->children() - ->scalarNode('field_name')->defaultValue('error')->end() - ->end() - ->append($this->addHandlersNode()) - ->append($this->addChannelsNode()) - ->end() - ->arrayNode('tracing') - ->canBeEnabled() - ->children() - ->scalarNode('field_name')->defaultValue('tracing')->end() - ->end() - ->append($this->addHandlersNode()) - ->append($this->addChannelsNode()) - ->end() - ->arrayNode('user') - ->canBeEnabled() - ->children() - ->scalarNode('domain')->defaultNull()->end() - ->scalarNode('provider')->defaultNull()->end() - ->end() - ->append($this->addHandlersNode()) - ->append($this->addChannelsNode()) - ->end() - ->arrayNode('auto_label') - ->canBeEnabled() - ->children() - ->arrayNode('fields') - ->scalarPrototype()->end() - ->end() - ->end() - ->append($this->addHandlersNode()) - ->append($this->addChannelsNode()) - ->end() - ->end() - ->end() - ->end(); - - return $treeBuilder; - } - - /** - * @psalm-suppress UndefinedMethod - */ - private function addHandlersNode(): NodeDefinition - { - $treeBuilder = new TreeBuilder('handlers'); - - return $treeBuilder->getRootNode() - ->info('Logging handler list the processor should be pushed to') - ->scalarPrototype()->end() - ; - } - - /** - * @psalm-suppress UndefinedMethod - */ - private function addChannelsNode(): NodeDefinition - { - $treeBuilder = new TreeBuilder('channels'); - - return $treeBuilder->getRootNode() - ->info('Logging channel list the processor should be pushed to') - ->scalarPrototype()->end() - ; - } -} diff --git a/src/DependencyInjection/EcsLoggingExtension.php b/src/DependencyInjection/EcsLoggingExtension.php deleted file mode 100644 index 2140bb9..0000000 --- a/src/DependencyInjection/EcsLoggingExtension.php +++ /dev/null @@ -1,183 +0,0 @@ -processConfiguration($configuration, $configs); - - $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); - $loader->load('services.yaml'); - - if (isset($config['processor'])) { - $this->configureAutoLabelProcessor($config, $container); - $this->configureErrorProcessor($config, $container); - $this->configureServiceProcessor($config, $container); - $this->configureTracingProcessor($config, $container); - $this->configureUserProcessor($config, $container); - } - } - - private function configureAutoLabelProcessor(array $config, ContainerBuilder $container): void - { - if (!isset($config['processor']['auto_label']) || !$config['processor']['auto_label']['enabled']) { - return; - } - - $processorConfig = $config['processor']['auto_label']; - - $processor = new Definition(AutoLabelProcessor::class); - $processor->setArgument('$fields', $processorConfig['fields']); - - $this->configureMonologProcessor($config, $processorConfig, $processor, -10); - - $container->setDefinition('.ecs_logging.processor.auto_label', $processor); - } - - private function configureErrorProcessor(array $config, ContainerBuilder $container): void - { - if (!isset($config['processor']['error']) || !$config['processor']['error']['enabled']) { - return; - } - - $processorConfig = $config['processor']['error']; - - $processor = new Definition(ErrorProcessor::class); - $processor->setArgument('$fieldName', $processorConfig['field_name']); - - $this->configureMonologProcessor($config, $processorConfig, $processor); - - $container->setDefinition('.ecs_logging.processor.error', $processor); - } - - private function configureServiceProcessor(array $config, ContainerBuilder $container): void - { - if (!isset($config['processor']['service']) || !$config['processor']['service']['enabled']) { - return; - } - - $processorConfig = $config['processor']['service']; - - $processor = new Definition(ServiceProcessor::class); - - $this->configureMonologProcessor($config, $processorConfig, $processor); - - $service = new Definition(Service::class); - - if (isset($processorConfig['name'])) { - $service->addMethodCall('setName', [$processorConfig['name']]); - } - - if (isset($processorConfig['address'])) { - $service->addMethodCall('setAddress', [$processorConfig['address']]); - } - - if (isset($processorConfig['version'])) { - $service->addMethodCall('setVersion', [$processorConfig['version']]); - } - - if (isset($processorConfig['ephemeral_id'])) { - $service->addMethodCall('setEphemeralId', [$processorConfig['ephemeral_id']]); - } - - if (isset($processorConfig['id'])) { - $service->addMethodCall('setId', [$processorConfig['id']]); - } - - if (isset($processorConfig['node_name'])) { - $service->addMethodCall('setNodeName', [$processorConfig['node_name']]); - } - - if (isset($processorConfig['state'])) { - $service->addMethodCall('setState', [$processorConfig['state']]); - } - - if (isset($processorConfig['type'])) { - $service->addMethodCall('setType', [$processorConfig['type']]); - } - - $processor->setArgument('$service', $service); - - $container->setDefinition('.ecs_logging.processor.service', $processor); - } - - private function configureTracingProcessor(array $config, ContainerBuilder $container): void - { - if (!isset($config['processor']['tracing']) || !$config['processor']['tracing']['enabled']) { - return; - } - - $processorConfig = $config['processor']['tracing']; - - $processor = new Definition(TracingProcessor::class); - $processor->setArgument('$fieldName', $processorConfig['field_name']); - - $this->configureMonologProcessor($config, $processorConfig, $processor); - - $container->setDefinition('.ecs_logging.processor.tracing', $processor); - } - - private function configureUserProcessor(array $config, ContainerBuilder $container): void - { - if (!isset($config['processor']['user']) || !$config['processor']['user']['enabled']) { - return; - } - - $processorConfig = $config['processor']['user']; - - if (($processorConfig['provider'] ?? null) === null) { - $provider = new Definition(EcsUserProvider::class); - $provider->setAutowired(true); - } else { - $provider = new Reference($processorConfig['provider']); - } - - $processor = new Definition(UserProcessor::class); - $processor->setArgument('$provider', $provider); - $processor->setArgument('$domain', $processorConfig['domain']); - - $this->configureMonologProcessor($config, $processorConfig, $processor); - - $container->setDefinition('.ecs_logging.processor.user', $processor); - } - - private function configureMonologProcessor(array $config, array $configOverride, Definition $processor, int $priority = 0): void - { - $channels = !empty($configOverride['channels']) ? $configOverride['channels'] : $config['monolog']['channels']; - foreach ($channels as $channel) { - $processor->addTag('monolog.processor', ['channel' => $channel, 'priority' => $priority]); - } - - $handlers = !empty($configOverride['handlers']) ? $configOverride['handlers'] : $config['monolog']['handlers']; - foreach ($handlers as $handler) { - $processor->addTag('monolog.processor', ['handler' => $handler, 'priority' => $priority]); - } - } -} diff --git a/src/DependencyInjection/ProcessorConfigurationBuilder.php b/src/DependencyInjection/ProcessorConfigurationBuilder.php new file mode 100644 index 0000000..2e81cd5 --- /dev/null +++ b/src/DependencyInjection/ProcessorConfigurationBuilder.php @@ -0,0 +1,188 @@ +getRootNode(); + $node + ->canBeEnabled() + ->info('Stamps service.* ECS fields on every log record (static values set at boot time).') + ->children() + ->scalarNode('name')->info('service.name — logical name of the service (e.g. "my-api").')->end() + ->scalarNode('address')->info('service.address — address at which the service can be reached (e.g. "https://api.example.com").')->end() + ->scalarNode('version')->info('service.version — version of the service (e.g. "1.0.0").')->end() + ->scalarNode('ephemeral_id')->info('service.ephemeral_id — ephemeral ID of the service instance; changes on restart.')->end() + ->scalarNode('id')->info('service.id — unique identifier of the service instance (e.g. container ID).')->end() + ->scalarNode('node_name')->info('service.node.name — name of the node running this service instance.')->end() + ->scalarNode('state')->info('service.state — current lifecycle state (e.g. "started", "deployed").')->end() + ->scalarNode('type')->info('service.type — type of service (e.g. "web", "worker", "console").')->end() + ->end() + ->append($this->addHandlersNode()) + ->append($this->addChannelsNode()); + + return $node; + } + + public function addErrorProcessorNode(): ArrayNodeDefinition + { + $node = (new TreeBuilder('error'))->getRootNode(); + $node + ->canBeEnabled() + ->children() + ->scalarNode('field_name')->defaultValue('error')->info('Context key read by the processor (e.g. $logger->error("msg", ["error" => $e])).')->end() + ->booleanNode('map_exception_key') + ->defaultFalse() + ->info('Also process context[\'exception\'] (Symfony convention) and map it to error.*') + ->end() + + ->end() + ->append($this->addHandlersNode()) + ->append($this->addChannelsNode()); + + return $node; + } + + public function addTracingProcessorNode(): ArrayNodeDefinition + { + $node = (new TreeBuilder('tracing'))->getRootNode(); + $node + ->canBeEnabled() + ->children() + ->scalarNode('field_name')->defaultValue('tracing')->info('Context key read by the processor (e.g. $logger->info("msg", ["tracing" => ["trace_id" => "…"]])).')->end() + ->end() + ->append($this->addHandlersNode()) + ->append($this->addChannelsNode()); + + return $node; + } + + public function addUserProcessorNode(): ArrayNodeDefinition + { + $node = (new TreeBuilder('user'))->getRootNode(); + $node + ->canBeEnabled() + ->info('Injects the authenticated user as ECS user.* fields. WARNING: user.name is populated from getUserIdentifier(), which is typically a PII value (e.g. email address). Ensure your data-retention and privacy policies cover this field before enabling.') + ->children() + ->scalarNode('domain')->defaultNull()->info('Default user.domain stamped on every log record (e.g. "in_memory", "database"). Can be overridden at runtime by the provider\'s getDomain().')->end() + ->scalarNode('provider')->defaultNull()->info('Service ID of a custom EcsUserProviderInterface implementation. Defaults to the built-in provider backed by Symfony Security.')->end() + ->end() + ->append($this->addHandlersNode()) + ->append($this->addChannelsNode()); + + return $node; + } + + public function addAutoLabelProcessorNode(): ArrayNodeDefinition + { + $node = (new TreeBuilder('auto_label'))->getRootNode(); + $node + ->canBeEnabled() + ->children() + ->enumNode('mode') + ->values(['bundle', 'full', 'custom']) + ->defaultValue('bundle') + ->info('ECS field whitelist: "bundle" (fields covered by this bundle), "full" (all ECS field sets), "custom" (only fields listed in "fields").') + ->end() + ->arrayNode('fields') + ->info('Extra ECS field names to whitelist when mode is "custom". Ignored for other modes.') + ->scalarPrototype()->end() + ->end() + ->booleanNode('move_to_labels') + ->defaultFalse() + ->info('Move non-ECS context fields into labels instead of dropping them.') + ->end() + ->booleanNode('include_extra') + ->defaultFalse() + ->info('Also process non-ECS keys from Monolog extra (e.g. from ProcessIdProcessor, HostnameProcessor).') + ->end() + ->enumNode('non_scalar_strategy') + ->values(['skip', 'json']) + ->defaultValue('skip') + ->info('Strategy for non-ECS context values that are not scalar. "skip" removes them silently; "json" converts them via json_encode (falls back to skip on failure).') + ->end() + ->end() + ->append($this->addHandlersNode()) + ->append($this->addChannelsNode()); + + return $node; + } + + public function addHostProcessorNode(): ArrayNodeDefinition + { + $node = (new TreeBuilder('host'))->getRootNode(); + $node + ->canBeEnabled() + ->children() + ->scalarNode('name') + ->defaultNull() + ->info('host.name — auto-detected via gethostname() if null') + ->end() + ->arrayNode('ip') + ->info('host.ip — auto-detected via gethostbyname() if empty and resolve_ip is true') + ->scalarPrototype()->end() + ->end() + ->booleanNode('resolve_ip') + ->defaultFalse() + ->info('Resolve host.ip via gethostbyname() when ip is not provided. WARNING: this is a blocking DNS call at container warm-up time.') + ->end() + ->scalarNode('architecture') + ->defaultNull() + ->info('host.architecture — auto-detected via php_uname(\'m\') if null') + ->end() + ->end() + ->append($this->addHandlersNode()) + ->append($this->addChannelsNode()); + + return $node; + } + + public function addHttpRequestProcessorNode(): ArrayNodeDefinition + { + $node = (new TreeBuilder('http_request'))->getRootNode(); + $node + ->canBeEnabled() + ->children() + ->booleanNode('include_full_url') + ->defaultFalse() + ->info('Log url.full and url.query. WARNING: may expose sensitive data (tokens, API keys) present in query parameters.') + ->end() + ->booleanNode('include_client_ip') + ->defaultFalse() + ->info('Log client.ip from the request. Uses Symfony trusted proxies to resolve the real IP behind load balancers.') + ->end() + ->booleanNode('include_referrer') + ->defaultFalse() + ->info('Log http.request.referrer. WARNING: the Referer header may contain external URLs with sensitive data (tokens, session identifiers).') + ->end() + ->end() + ->append($this->addHandlersNode()) + ->append($this->addChannelsNode()); + + return $node; + } + + public function addHandlersNode(): NodeDefinition + { + return (new TreeBuilder('handlers'))->getRootNode() + ->info('Logging handler list the processor should be pushed to') + ->scalarPrototype()->end() + ; + } + + public function addChannelsNode(): NodeDefinition + { + return (new TreeBuilder('channels'))->getRootNode() + ->info('Logging channel list the processor should be pushed to') + ->scalarPrototype()->end() + ; + } +} diff --git a/src/DependencyInjection/ProcessorLoader.php b/src/DependencyInjection/ProcessorLoader.php new file mode 100644 index 0000000..b4eaee6 --- /dev/null +++ b/src/DependencyInjection/ProcessorLoader.php @@ -0,0 +1,227 @@ + $config */ + public function registerAutoLabelProcessor(array $config, ContainerBuilder $builder): void + { + if (!isset($config['processor']['auto_label']) || !$config['processor']['auto_label']['enabled']) { + return; + } + + $processorConfig = $config['processor']['auto_label']; + + $fields = match ($processorConfig['mode']) { + AutoLabelProcessor::MODE_BUNDLE => AutoLabelProcessor::FIELDS_BUNDLE, + AutoLabelProcessor::MODE_FULL => AutoLabelProcessor::FIELDS_ALL, + AutoLabelProcessor::MODE_CUSTOM => $processorConfig['fields'], + default => throw new \LogicException(\sprintf('Unexpected auto_label mode "%s".', $processorConfig['mode'])), + }; + + $processor = new Definition(AutoLabelProcessor::class); + $processor->setArgument('$fields', $fields); + $processor->setArgument('$moveToLabels', $processorConfig['move_to_labels']); + $processor->setArgument('$nonScalarStrategy', $processorConfig['non_scalar_strategy']); + $processor->setArgument('$includeExtra', $processorConfig['include_extra']); + + $this->configureMonologProcessor($config, $processorConfig, $processor, -10); + + $builder->setDefinition('.ecs_logging.processor.auto_label', $processor); + } + + /** @param array $config */ + public function registerErrorProcessor(array $config, ContainerBuilder $builder): void + { + if (!isset($config['processor']['error']) || !$config['processor']['error']['enabled']) { + return; + } + + $processorConfig = $config['processor']['error']; + + $processor = new Definition(ErrorProcessor::class); + $processor->setArgument('$fieldName', $processorConfig['field_name']); + $processor->setArgument('$mapExceptionKey', $processorConfig['map_exception_key']); + + $this->configureMonologProcessor($config, $processorConfig, $processor); + + $builder->setDefinition('.ecs_logging.processor.error', $processor); + } + + /** @param array $config */ + public function registerHostProcessor(array $config, ContainerBuilder $builder): void + { + if (!isset($config['processor']['host']) || !$config['processor']['host']['enabled']) { + return; + } + + $processorConfig = $config['processor']['host']; + + $processor = new Definition(HostProcessor::class); + $processor->setArgument('$name', $processorConfig['name']); + $processor->setArgument('$ip', $processorConfig['ip']); + $processor->setArgument('$resolveIp', $processorConfig['resolve_ip']); + $processor->setArgument('$architecture', $processorConfig['architecture']); + + $this->configureMonologProcessor($config, $processorConfig, $processor); + + $builder->setDefinition('.ecs_logging.processor.host', $processor); + } + + /** @param array $config */ + public function registerHttpRequestProcessor(array $config, ContainerBuilder $builder): void + { + if (!isset($config['processor']['http_request']) || !$config['processor']['http_request']['enabled']) { + return; + } + + $processorConfig = $config['processor']['http_request']; + + $processor = new Definition(HttpRequestProcessor::class); + $processor->setArgument('$requestStack', new Reference('request_stack')); + $processor->setArgument('$includeFullUrl', $processorConfig['include_full_url']); + $processor->setArgument('$includeClientIp', $processorConfig['include_client_ip']); + $processor->setArgument('$includeReferrer', $processorConfig['include_referrer']); + + $this->configureMonologProcessor($config, $processorConfig, $processor); + + $builder->setDefinition('.ecs_logging.processor.http_request', $processor); + } + + /** @param array $config */ + public function registerServiceProcessor(array $config, ContainerBuilder $builder): void + { + if (!isset($config['processor']['service']) || !$config['processor']['service']['enabled']) { + return; + } + + $processorConfig = $config['processor']['service']; + + $processor = new Definition(ServiceProcessor::class); + $processor->setArgument('$service', $this->buildServiceDefinition($processorConfig)); + + $this->configureMonologProcessor($config, $processorConfig, $processor); + + $builder->setDefinition('.ecs_logging.processor.service', $processor); + } + + /** @param array $config */ + public function registerTracingProcessor(array $config, ContainerBuilder $builder): void + { + if (!isset($config['processor']['tracing']) || !$config['processor']['tracing']['enabled']) { + return; + } + + $processorConfig = $config['processor']['tracing']; + + $processor = new Definition(TracingProcessor::class); + $processor->setArgument('$fieldName', $processorConfig['field_name']); + + $this->configureMonologProcessor($config, $processorConfig, $processor); + + $builder->setDefinition('.ecs_logging.processor.tracing', $processor); + } + + /** @param array $config */ + public function registerUserProcessor(array $config, ContainerBuilder $builder): void + { + if (!isset($config['processor']['user']) || !$config['processor']['user']['enabled']) { + return; + } + + $processorConfig = $config['processor']['user']; + + $processor = new Definition(UserProcessor::class); + $processor->setArgument('$provider', $this->resolveUserProvider($processorConfig)); + $processor->setArgument('$domain', $processorConfig['domain']); + + $this->configureMonologProcessor($config, $processorConfig, $processor); + + $builder->setDefinition('.ecs_logging.processor.user', $processor); + } + + /** @param array $processorConfig */ + private function buildServiceDefinition(array $processorConfig): Definition + { + $service = new Definition(Service::class); + + $setters = [ + 'name' => 'setName', + 'address' => 'setAddress', + 'version' => 'setVersion', + 'ephemeral_id' => 'setEphemeralId', + 'id' => 'setId', + 'node_name' => 'setNodeName', + 'state' => 'setState', + 'type' => 'setType', + ]; + + foreach ($setters as $key => $method) { + if (isset($processorConfig[$key])) { + $service->addMethodCall($method, [$processorConfig[$key]]); + } + } + + return $service; + } + + /** @param array $processorConfig */ + private function resolveUserProvider(array $processorConfig): Definition|Reference + { + if (($processorConfig['provider'] ?? null) !== null) { + return new Reference($processorConfig['provider']); + } + + if (!\interface_exists(\Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface::class)) { + throw new \LogicException('The ecs_logging user processor requires symfony/security-core. Install it or configure a custom provider via ecs_logging.processor.user.provider.'); + } + + $provider = new Definition(EcsUserProvider::class); + $provider->setAutowired(true); + + return $provider; + } + + /** + * @param array $config + * @param array $configOverride + */ + private function configureMonologProcessor(array $config, array $configOverride, Definition $processor, int $priority = 0): void + { + $channels = !empty($configOverride['channels']) ? $configOverride['channels'] : $config['monolog']['channels']; + $handlers = !empty($configOverride['handlers']) ? $configOverride['handlers'] : $config['monolog']['handlers']; + + if (!empty($channels) && !empty($handlers)) { + throw new InvalidConfigurationException('ecs_logging: a processor cannot target both channels and handlers simultaneously. Configure one or the other.'); + } + + if (empty($channels) && empty($handlers)) { + throw new InvalidConfigurationException('ecs_logging: a processor is enabled but has no channel or handler configured. It will never be invoked.'); + } + + foreach ($channels as $channel) { + $processor->addTag('monolog.processor', ['channel' => $channel, 'priority' => $priority]); + } + + foreach ($handlers as $handler) { + $processor->addTag('monolog.processor', ['handler' => $handler, 'priority' => $priority]); + } + } +} diff --git a/src/EcsLoggingBundle.php b/src/EcsLoggingBundle.php index e2705c7..ad20467 100644 --- a/src/EcsLoggingBundle.php +++ b/src/EcsLoggingBundle.php @@ -4,11 +4,107 @@ namespace Aubes\EcsLoggingBundle; -use Symfony\Component\HttpKernel\Bundle\Bundle; +use Aubes\EcsLoggingBundle\DependencyInjection\Compiler\UserProviderPass; +use Aubes\EcsLoggingBundle\DependencyInjection\ProcessorConfigurationBuilder; +use Aubes\EcsLoggingBundle\DependencyInjection\ProcessorLoader; +use Aubes\EcsLoggingBundle\Formatter\EcsFormatter; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; -/** - * @unused - */ -final class EcsLoggingBundle extends Bundle +final class EcsLoggingBundle extends AbstractBundle { + public function configure(DefinitionConfigurator $definition): void + { + /** @var ArrayNodeDefinition $rootNode */ + $rootNode = $definition->rootNode(); + $this->buildRootNode($rootNode); + } + + private function buildRootNode(ArrayNodeDefinition $rootNode): void + { + $config = new ProcessorConfigurationBuilder(); + + $rootNode + ->children() + ->scalarNode('ecs_version') + ->defaultValue('9.3.0') + ->info('ECS version declared in the ecs.version field of every log record. Override to match your Elastic Stack version (e.g. "8.11.0").') + ->validate() + ->ifTrue(static fn (mixed $v): bool => !\is_string($v) || !\preg_match('/^\d+\.\d+\.\d+$/', $v)) + ->thenInvalid('ecs_version must be a valid semver string (e.g. "9.3.0"), got %s.') + ->end() + ->end() + ->arrayNode('tags') + ->info('Static tags added to every log record (ECS tags field). Values must be non-empty strings.') + ->validate() + ->ifTrue(static function (mixed $tags): bool { + foreach ($tags as $tag) { + if (!\is_string($tag) || $tag === '') { + return true; + } + } + + return false; + }) + ->thenInvalid('ecs_logging.tags must contain only non-empty strings.') + ->end() + ->scalarPrototype()->end() + ->end() + ->arrayNode('monolog') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('channels') + ->info('Default logging channel list the processors should be pushed to') + ->scalarPrototype()->end() + ->end() + ->arrayNode('handlers') + ->info('Default logging handler list the processors should be pushed to') + ->scalarPrototype()->end() + ->end() + ->end() + ->end() + ->arrayNode('processor') + ->children() + ->append($config->addServiceProcessorNode()) + ->append($config->addErrorProcessorNode()) + ->append($config->addTracingProcessorNode()) + ->append($config->addUserProcessorNode()) + ->append($config->addAutoLabelProcessorNode()) + ->append($config->addHostProcessorNode()) + ->append($config->addHttpRequestProcessorNode()) + ->end() + ->end() + ->end() + ; + } + + /** + * @param array $config + */ + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $builder->register('monolog.formatter.ecs', EcsFormatter::class); + + $builder->getDefinition('monolog.formatter.ecs') + ->setArgument('$ecsVersion', $config['ecs_version']) + ->setArgument('$tags', $config['tags']); + + $loader = new ProcessorLoader(); + $loader->registerAutoLabelProcessor($config, $builder); + $loader->registerErrorProcessor($config, $builder); + $loader->registerHostProcessor($config, $builder); + $loader->registerHttpRequestProcessor($config, $builder); + $loader->registerServiceProcessor($config, $builder); + $loader->registerTracingProcessor($config, $builder); + $loader->registerUserProcessor($config, $builder); + } + + public function build(ContainerBuilder $container): void + { + parent::build($container); + $container->addCompilerPass(new UserProviderPass()); + } } diff --git a/src/Formatter/EcsFormatter.php b/src/Formatter/EcsFormatter.php new file mode 100644 index 0000000..92d8566 --- /dev/null +++ b/src/Formatter/EcsFormatter.php @@ -0,0 +1,45 @@ + $tags */ + public function __construct(private readonly string $ecsVersion = '9.3.0', array $tags = []) + { + parent::__construct($tags); + } + + public function format(LogRecord $record): string + { + $json = parent::format($record); + + try { + $data = \json_decode(\rtrim($json, "\n"), true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + // Safety net: parent::format() always returns a valid JSON object. + // This branch is unreachable in practice but guards against upstream changes. + return $json; + } + + if (isset($data['log.level'])) { + $data['log']['level'] = \strtolower($data['log.level']); + unset($data['log.level']); + } + + $data['ecs.version'] = $this->ecsVersion; + + return $this->toJson($data) . "\n"; + } +} diff --git a/src/Logger/AbstractProcessor.php b/src/Logger/AbstractProcessor.php index 78f68b3..f2fc23f 100644 --- a/src/Logger/AbstractProcessor.php +++ b/src/Logger/AbstractProcessor.php @@ -8,15 +8,20 @@ abstract class AbstractProcessor { - public function __construct(protected readonly string $fieldName) - { + public function __construct( + protected readonly string $fieldName, + private readonly string $targetField, + ) { } - abstract public function transformValue(mixed $value): mixed; + abstract protected function transformValue(mixed $value): mixed; - abstract public function support(LogRecord $record): bool; + abstract protected function support(LogRecord $record): bool; - abstract public function getTargetField(): string; + final protected function getTargetField(): string + { + return $this->targetField; + } public function __invoke(LogRecord $record): LogRecord { @@ -25,7 +30,7 @@ public function __invoke(LogRecord $record): LogRecord } $context = $record->context; - $targetField = $this->getTargetField(); + $targetField = $this->targetField; $context[$targetField] = $this->transformValue($record->context[$this->fieldName]); if ($this->fieldName !== $targetField) { diff --git a/src/Logger/AutoLabelProcessor.php b/src/Logger/AutoLabelProcessor.php index 3583648..28a8000 100644 --- a/src/Logger/AutoLabelProcessor.php +++ b/src/Logger/AutoLabelProcessor.php @@ -25,19 +25,24 @@ final class AutoLabelProcessor public const FIELD_DNS = 'dns'; public const FIELD_ECS = 'ecs'; public const FIELD_ELF = 'elf'; + public const FIELD_ENTITY = 'entity'; public const FIELD_EMAIL = 'email'; public const FIELD_ERROR = 'error'; public const FIELD_EVENT = 'event'; public const FIELD_FAAS = 'faas'; + public const FIELD_GEN_AI = 'gen_ai'; public const FIELD_FILE = 'file'; public const FIELD_GEO = 'geo'; public const FIELD_GROUP = 'group'; public const FIELD_HASH = 'hash'; public const FIELD_HOST = 'host'; public const FIELD_HTTP = 'http'; + /** + * Not a top-level ECS field set — `interface` is a sub-object of `observer.ingress/egress`. + * Kept for BC but excluded from FIELDS_ALL. + */ public const FIELD_INTERFACE = 'interface'; public const FIELD_LOG = 'log'; - public const FIELD_MATCHO = 'matcho'; public const FIELD_NETWORK = 'network'; public const FIELD_OBSERVER = 'observer'; public const FIELD_ORCHESTRATOR = 'orchestrator'; @@ -57,32 +62,53 @@ final class AutoLabelProcessor public const FIELD_TLS = 'tls'; public const FIELD_SPAN = 'span'; public const FIELD_TRACE = 'trace'; + /** + * Not an ECS field set — bundle-internal transport key used by TracingProcessor. + * ElasticCommonSchemaFormatter hardcodes context['tracing'] to detect Elastic\Types\Tracing objects. + * Automatically excluded from auto-labeling via FIELDS_INTERNAL — users do not need to whitelist it. + */ public const FIELD_TRACING = 'tracing'; public const FIELD_TRANSACTION = 'transaction'; public const FIELD_URL = 'url'; public const FIELD_USER = 'user'; public const FIELD_USER_AGENT = 'user_agent'; + /** + * Not a top-level ECS field set — `vlan` is a sub-object of `network.vlan`. + * Kept for BC but excluded from FIELDS_ALL. + */ public const FIELD_VLAN = 'vlan'; public const FIELD_VULNERABILITY = 'vulnerability'; public const FIELD_X509 = 'x509'; - public const FIELDS_MINIMAL = [ - self::FIELD_LOG, - self::FIELD_MESSAGE, - self::FIELD_SERVICE, - self::FIELD_TIMESTAMP, + /** + * Fields injected by bundle processors that must never be moved to labels, regardless of the user's field list. + * AutoLabelProcessor always merges this list into its whitelist automatically. + * + * - FIELD_TRACING: non-ECS transport key required by ElasticCommonSchemaFormatter to serialize Tracing objects. + * - FIELD_SPAN: injected by TracingProcessor when span_id is provided. + */ + public const FIELDS_INTERNAL = [ + self::FIELD_TRACING, + self::FIELD_SPAN, ]; + public const MODE_BUNDLE = 'bundle'; + public const MODE_FULL = 'full'; + public const MODE_CUSTOM = 'custom'; + public const FIELDS_BUNDLE = [ + self::FIELD_CLIENT, self::FIELD_ERROR, + self::FIELD_HOST, + self::FIELD_HTTP, self::FIELD_LABELS, self::FIELD_LOG, self::FIELD_MESSAGE, self::FIELD_SERVICE, self::FIELD_TIMESTAMP, self::FIELD_TRACE, - self::FIELD_TRACING, self::FIELD_TRANSACTION, + self::FIELD_URL, self::FIELD_USER, ]; @@ -104,24 +130,23 @@ final class AutoLabelProcessor self::FIELD_DNS, self::FIELD_ECS, self::FIELD_ELF, + self::FIELD_ENTITY, self::FIELD_EMAIL, self::FIELD_ERROR, self::FIELD_EVENT, self::FIELD_FAAS, self::FIELD_FILE, + self::FIELD_GEN_AI, self::FIELD_GEO, self::FIELD_GROUP, self::FIELD_HASH, self::FIELD_HOST, self::FIELD_HTTP, - self::FIELD_INTERFACE, self::FIELD_LOG, - self::FIELD_MATCHO, self::FIELD_NETWORK, self::FIELD_OBSERVER, self::FIELD_ORCHESTRATOR, self::FIELD_ORGANIZATION, - self::FIELD_OS, self::FIELD_PACKAGE, self::FIELD_PE, self::FIELD_PROCESS, @@ -136,49 +161,86 @@ final class AutoLabelProcessor self::FIELD_TLS, self::FIELD_SPAN, self::FIELD_TRACE, - self::FIELD_TRACING, self::FIELD_TRANSACTION, self::FIELD_URL, self::FIELD_USER, self::FIELD_USER_AGENT, - self::FIELD_VLAN, self::FIELD_VULNERABILITY, self::FIELD_X509, ]; - private readonly array $ecsFields; + public const STRATEGY_SKIP = 'skip'; + public const STRATEGY_JSON = 'json'; - public function __construct(array $fields) - { - $this->ecsFields = \array_flip($fields); + /** @var array */ + private readonly array $ecsFields; + private readonly bool $encodeAsJson; + + /** @param list $fields */ + public function __construct( + array $fields, + private readonly bool $moveToLabels = false, + string $nonScalarStrategy = self::STRATEGY_SKIP, + private readonly bool $includeExtra = false, + ) { + $this->ecsFields = \array_flip(\array_merge($fields, self::FIELDS_INTERNAL)); + $this->encodeAsJson = $nonScalarStrategy === self::STRATEGY_JSON; } public function __invoke(LogRecord $record): LogRecord { $context = $record->context; - $nonEcsFields = []; + $extra = $record->extra; - foreach ($context as $contextName => $contextValue) { - if (!isset($this->ecsFields[$contextName])) { - $nonEcsFields[$contextName] = $contextValue; - } - } + $nonEcsContext = \array_diff_key($context, $this->ecsFields); + $nonEcsExtra = $this->includeExtra ? \array_diff_key($extra, $this->ecsFields) : []; - if (empty($nonEcsFields)) { + if (empty($nonEcsContext) && empty($nonEcsExtra)) { return $record; } - foreach (\array_keys($nonEcsFields) as $contextName) { - unset($context[$contextName]); + $context = \array_diff_key($context, $nonEcsContext); + $extra = \array_diff_key($extra, $nonEcsExtra); + + if ($this->moveToLabels) { + $labels = \array_merge( + $this->toScalarLabels($nonEcsContext), + $this->toScalarLabels($nonEcsExtra), + ); + if (!empty($labels)) { + $existingLabels = $context['labels'] ?? []; + if (!\is_array($existingLabels)) { + $existingLabels = []; + } + $context['labels'] = \array_merge($labels, $existingLabels); + } } - $existingLabels = $context['labels'] ?? []; - if (!\is_array($existingLabels)) { - throw new \InvalidArgumentException(\sprintf('The "labels" context field must be an array, "%s" given.', \get_debug_type($existingLabels))); - } + return $record->with(context: $context, extra: $extra); + } + + /** + * @param array $fields + * + * @return array + */ + private function toScalarLabels(array $fields): array + { + $labels = []; - $context['labels'] = \array_merge($existingLabels, $nonEcsFields); + foreach ($fields as $name => $value) { + if (\is_scalar($value)) { + $labels[$name] = $value; + continue; + } + if ($this->encodeAsJson) { + $encoded = \json_encode($value); + if ($encoded !== false) { + $labels[$name] = $encoded; + } + } + } - return $record->with(context: $context); + return $labels; } } diff --git a/src/Logger/ErrorProcessor.php b/src/Logger/ErrorProcessor.php index e7f86c0..689bf83 100644 --- a/src/Logger/ErrorProcessor.php +++ b/src/Logger/ErrorProcessor.php @@ -9,22 +9,37 @@ final class ErrorProcessor extends AbstractProcessor { - public function getTargetField(): string + public function __construct(string $fieldName, private readonly bool $mapExceptionKey = false) { - return 'error'; + parent::__construct($fieldName, 'error'); } - public function support(LogRecord $record): bool + protected function support(LogRecord $record): bool { - return isset($record->context[$this->fieldName]) && !$record->context[$this->fieldName] instanceof Error; + return isset($record->context[$this->fieldName]) && $record->context[$this->fieldName] instanceof \Throwable; } - public function transformValue(mixed $value): Error + protected function transformValue(mixed $value): Error { - if (!$value instanceof \Throwable) { - throw new \InvalidArgumentException($this->fieldName . ' must be an instance of Throwable'); + return new Error($value); + } + + public function __invoke(LogRecord $record): LogRecord + { + $record = parent::__invoke($record); + + if (!$this->mapExceptionKey || isset($record->context[$this->getTargetField()])) { + return $record; } - return new Error($value); + if (!isset($record->context['exception']) || !$record->context['exception'] instanceof \Throwable) { + return $record; + } + + $context = $record->context; + $context[$this->getTargetField()] = new Error($context['exception']); + unset($context['exception']); + + return $record->with(context: $context); } } diff --git a/src/Logger/HostProcessor.php b/src/Logger/HostProcessor.php new file mode 100644 index 0000000..da9ff69 --- /dev/null +++ b/src/Logger/HostProcessor.php @@ -0,0 +1,56 @@ + */ + private readonly array $host; + + /** @param string[] $ip */ + public function __construct( + ?string $name = null, + array $ip = [], + bool $resolveIp = false, + ?string $architecture = null, + ) { + $hostname = \gethostname(); + $resolvedName = $name ?? ($hostname !== false ? $hostname : null); + + $host = []; + + if ($resolvedName !== null) { + $host['name'] = $resolvedName; + } + + if (!empty($ip)) { + $host['ip'] = $ip; + } elseif ($resolveIp && $resolvedName !== null) { + $resolved = \gethostbyname($resolvedName); + + if ($resolved !== $resolvedName) { + $host['ip'] = [$resolved]; + } + } + + $host['architecture'] = $architecture ?? (\php_uname('m') ?: null); + + $this->host = \array_filter($host, static fn (mixed $val): bool => $val !== null); + } + + public function __invoke(LogRecord $record): LogRecord + { + if (isset($record->context['host'])) { + return $record; + } + + $context = $record->context; + $context['host'] = $this->host; + + return $record->with(context: $context); + } +} diff --git a/src/Logger/HttpRequestProcessor.php b/src/Logger/HttpRequestProcessor.php new file mode 100644 index 0000000..0715d3e --- /dev/null +++ b/src/Logger/HttpRequestProcessor.php @@ -0,0 +1,170 @@ + */ + private ?\WeakReference $cachedRequest = null; + /** @var null|array */ + private ?array $cachedHttpContext = null; + /** @var null|array */ + private ?array $cachedUrlContext = null; + /** @var null|array|false */ + private false|array|null $cachedClientContext = false; + + public function __construct( + private readonly RequestStack $requestStack, + private readonly bool $includeFullUrl = false, + private readonly bool $includeClientIp = false, + private readonly bool $includeReferrer = false, + ) { + } + + public function reset(): void + { + $this->cachedRequest = null; + $this->cachedHttpContext = null; + $this->cachedUrlContext = null; + $this->cachedClientContext = false; + } + + public function __invoke(LogRecord $record): LogRecord + { + $request = $this->requestStack->getCurrentRequest(); + + if ($request === null) { + return $record; + } + + if ($this->cachedRequest?->get() !== $request) { + $this->cachedRequest = \WeakReference::create($request); + $this->cachedHttpContext = null; + $this->cachedUrlContext = null; + $this->cachedClientContext = false; + } + + $context = $record->context; + + if (!isset($context['http'])) { + $this->cachedHttpContext ??= $this->buildHttpContext($request); + $context['http'] = $this->cachedHttpContext; + } + + if (!isset($context['url'])) { + $this->cachedUrlContext ??= $this->buildUrlContext($request); + $context['url'] = $this->cachedUrlContext; + } + + if ($this->includeClientIp && !isset($context['client'])) { + if ($this->cachedClientContext === false) { + $this->cachedClientContext = $this->buildClientContext($request); + } + + if ($this->cachedClientContext !== null) { + $context['client'] = $this->cachedClientContext; + } + } + + return $record->with(context: $context); + } + + /** @return array */ + private function buildHttpContext(Request $request): array + { + $http = [ + 'request' => [ + 'method' => $request->getMethod(), + ], + ]; + + $mimeType = $request->headers->get('Content-Type'); + if ($mimeType !== null) { + $http['request']['mime_type'] = $this->sanitizeString($mimeType, 512); + } + + $contentLength = $request->headers->get('Content-Length'); + if ($contentLength !== null && (int) $contentLength >= 0) { + $http['request']['bytes'] = (int) $contentLength; + } + + if ($this->includeReferrer) { + $referrer = $request->headers->get('Referer'); + if ($referrer !== null) { + $http['request']['referrer'] = $this->sanitizeString($referrer, 512); + } + } + + // Only the regex capture group ($matches[1], e.g. "1.1") is stored — never the raw + // SERVER_PROTOCOL value, which can be attacker-influenced in some CGI/proxy configurations. + $serverProtocol = $request->server->get('SERVER_PROTOCOL'); + if ($serverProtocol !== null && \preg_match('/^HTTP\/([\d.]+)$/', (string) $serverProtocol, $matches)) { + $http['version'] = $matches[1]; + } + + return $http; + } + + /** @return array */ + private function buildUrlContext(Request $request): array + { + $scheme = $request->getScheme(); + $url = [ + 'path' => $request->getPathInfo(), + 'scheme' => $scheme, + 'domain' => $request->getHost(), + ]; + + if ($this->includeFullUrl) { + $url['full'] = $this->sanitizeString($request->getUri(), 2048); + + $queryString = $request->getQueryString(); + if ($queryString !== null) { + $url['query'] = $this->sanitizeString($queryString, 2048); + } + } + + $port = $this->resolvePort($request, $scheme); + if ($port !== null) { + $url['port'] = $port; + } + + return $url; + } + + private function resolvePort(Request $request, string $scheme): ?int + { + $port = $request->getPort() !== null ? (int) $request->getPort() : null; + $isStandardPort = ($port === 80 && $scheme === 'http') || ($port === 443 && $scheme === 'https'); + + return ($port !== null && $port > 0 && !$isStandardPort) ? $port : null; + } + + /** + * Strips ASCII control characters and enforces a maximum length. + * Prevents log injection via attacker-controlled header or URL values. + */ + private function sanitizeString(string $value, int $maxLength): string + { + return \substr(\preg_replace('/[\x00-\x1f\x7f]+/', '', $value) ?? '', 0, $maxLength); + } + + /** @return null|array */ + private function buildClientContext(Request $request): ?array + { + $clientIp = $request->getClientIp(); + + if ($clientIp === null) { + return null; + } + + return ['ip' => $clientIp]; + } +} diff --git a/src/Logger/TracingProcessor.php b/src/Logger/TracingProcessor.php index 174f705..685008f 100644 --- a/src/Logger/TracingProcessor.php +++ b/src/Logger/TracingProcessor.php @@ -7,24 +7,31 @@ use Elastic\Types\Tracing; use Monolog\LogRecord; -final class TracingProcessor extends AbstractProcessor +final class TracingProcessor { - public function getTargetField(): string + public function __construct(private readonly string $fieldName) { - return 'tracing'; } - public function support(LogRecord $record): bool + public function __invoke(LogRecord $record): LogRecord { - return isset($record->context[$this->fieldName]) && !$record->context[$this->fieldName] instanceof Tracing; - } + $value = $record->context[$this->fieldName] ?? null; - public function transformValue(mixed $value): Tracing - { - if (!isset($value['trace_id'])) { - throw new \InvalidArgumentException('trace_id is required when ' . $this->fieldName . ' is provided'); + if (!\is_array($value) || !isset($value['trace_id'])) { + return $record; + } + + $context = $record->context; + $context['tracing'] = new Tracing((string) $value['trace_id'], $value['transaction_id'] ?? null); + + if ($this->fieldName !== 'tracing') { + unset($context[$this->fieldName]); + } + + if (isset($value['span_id']) && !isset($context['span'])) { + $context['span'] = ['id' => (string) $value['span_id']]; } - return new Tracing((string) $value['trace_id'], $value['transaction_id'] ?? null); + return $record->with(context: $context); } } diff --git a/src/Logger/UserProcessor.php b/src/Logger/UserProcessor.php index c7fdf06..032c66b 100644 --- a/src/Logger/UserProcessor.php +++ b/src/Logger/UserProcessor.php @@ -5,17 +5,28 @@ namespace Aubes\EcsLoggingBundle\Logger; use Aubes\EcsLoggingBundle\Security\EcsUserProviderInterface; +use Elastic\Types\User; use Monolog\LogRecord; +use Symfony\Contracts\Service\ResetInterface; -final class UserProcessor +final class UserProcessor implements ResetInterface { + private ?User $lastUser = null; + private ?string $lastDomain = null; + public function __construct( private readonly EcsUserProviderInterface $provider, private readonly ?string $domain = null, ) { } - public function support(LogRecord $record): bool + public function reset(): void + { + $this->lastUser = null; + $this->lastDomain = null; + } + + private function support(LogRecord $record): bool { return !isset($record->context['user']); } @@ -32,9 +43,13 @@ public function __invoke(LogRecord $record): LogRecord return $record; } - $domain = $this->provider->getDomain() ?? $this->domain; - if ($domain !== null) { - $ecsUser->setDomain($domain); + if ($ecsUser !== $this->lastUser) { + $this->lastUser = $ecsUser; + $this->lastDomain = $this->provider->getDomain() ?? $this->domain; + + if ($this->lastDomain !== null) { + $ecsUser->setDomain($this->lastDomain); + } } $context = $record->context; diff --git a/src/Security/EcsUserProvider.php b/src/Security/EcsUserProvider.php index cb831c6..dbed227 100644 --- a/src/Security/EcsUserProvider.php +++ b/src/Security/EcsUserProvider.php @@ -6,28 +6,51 @@ use Elastic\Types\User; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Contracts\Service\ResetInterface; -final class EcsUserProvider implements EcsUserProviderInterface +final class EcsUserProvider implements EcsUserProviderInterface, ResetInterface { + private ?User $cachedUser = null; + /** @var null|\WeakReference */ + private ?\WeakReference $cachedToken = null; + public function __construct(private readonly TokenStorageInterface $tokenStorage) { } - /** - * @psalm-suppress InternalMethod - */ public function getUser(): ?User { - $user = $this->tokenStorage->getToken()?->getUser(); + $token = $this->tokenStorage->getToken(); - if ($user !== null) { - $ecsUser = new User(); - $ecsUser->setId($user->getUserIdentifier()); + if ($token === null) { + return null; + } - return $ecsUser; + if ($this->cachedToken?->get() === $token) { + return $this->cachedUser; } - return null; + $this->cachedToken = \WeakReference::create($token); + + $user = $token->getUser(); + if ($user === null) { + $this->cachedUser = null; + + return null; + } + + $ecsUser = new User(); + $ecsUser->setName($user->getUserIdentifier()); + $this->cachedUser = $ecsUser; + + return $ecsUser; + } + + public function reset(): void + { + $this->cachedUser = null; + $this->cachedToken = null; } public function getDomain(): ?string diff --git a/src/Security/EcsUserProviderInterface.php b/src/Security/EcsUserProviderInterface.php index 07520c3..01ed2e1 100644 --- a/src/Security/EcsUserProviderInterface.php +++ b/src/Security/EcsUserProviderInterface.php @@ -8,7 +8,17 @@ interface EcsUserProviderInterface { + /** + * Returns the ECS User object for the current security context, or null if unauthenticated. + * + * WARNING: UserProcessor may call setDomain() on the returned object. Implementations must + * therefore return a fresh User instance on each call, or implement ResetInterface to clear + * any cached instance between requests (required in FrankenPHP worker mode). + */ public function getUser(): ?User; + /** + * Returns the domain to set on the User object, or null to use the processor's configured default. + */ public function getDomain(): ?string; } diff --git a/tests/Formatter/EcsFormatterTest.php b/tests/Formatter/EcsFormatterTest.php new file mode 100644 index 0000000..d82cace --- /dev/null +++ b/tests/Formatter/EcsFormatterTest.php @@ -0,0 +1,117 @@ +format($this->buildRecord(Level::Info)), true); + + $this->assertSame('info', $data['log']['level']); + } + + public function testDotNotationLogLevelKeyIsRemoved(): void + { + $formatter = new EcsFormatter(); + $data = \json_decode($formatter->format($this->buildRecord(Level::Info)), true); + + $this->assertArrayNotHasKey('log.level', $data); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('levelProvider')] + public function testAllLevelsAreLowercase(Level $level, string $expected): void + { + $formatter = new EcsFormatter(); + $data = \json_decode($formatter->format($this->buildRecord($level)), true); + + $this->assertSame($expected, $data['log']['level']); + } + + /** @return array */ + public static function levelProvider(): array + { + return [ + 'debug' => [Level::Debug, 'debug'], + 'info' => [Level::Info, 'info'], + 'notice' => [Level::Notice, 'notice'], + 'warning' => [Level::Warning, 'warning'], + 'error' => [Level::Error, 'error'], + 'critical' => [Level::Critical, 'critical'], + 'alert' => [Level::Alert, 'alert'], + 'emergency' => [Level::Emergency, 'emergency'], + ]; + } + + public function testOutputIsValidJson(): void + { + $formatter = new EcsFormatter(); + $output = $formatter->format($this->buildRecord()); + + $this->assertNotNull(\json_decode($output, true)); + $this->assertStringEndsWith("\n", $output); + } + + public function testEcsVersionDefaultIs9x(): void + { + $formatter = new EcsFormatter(); + $data = \json_decode($formatter->format($this->buildRecord()), true); + + $this->assertSame('9.3.0', $data['ecs.version']); + } + + public function testEcsVersionIsOverridable(): void + { + $formatter = new EcsFormatter('8.11.0'); + $data = \json_decode($formatter->format($this->buildRecord()), true); + + $this->assertSame('8.11.0', $data['ecs.version']); + } + + public function testTagsAreAbsentByDefault(): void + { + $formatter = new EcsFormatter(); + $data = \json_decode($formatter->format($this->buildRecord()), true); + + $this->assertArrayNotHasKey('tags', $data); + } + + public function testTagsAreAddedToOutput(): void + { + $formatter = new EcsFormatter('9.3.0', ['env:prod', 'region:eu-west-1']); + $data = \json_decode($formatter->format($this->buildRecord()), true); + + $this->assertSame(['env:prod', 'region:eu-west-1'], $data['tags']); + } + + public function testOtherFieldsArePreserved(): void + { + $formatter = new EcsFormatter(); + $data = \json_decode($formatter->format($this->buildRecord()), true); + + $this->assertArrayHasKey('@timestamp', $data); + $this->assertArrayHasKey('message', $data); + $this->assertArrayHasKey('ecs.version', $data); + $this->assertArrayHasKey('log', $data); + $this->assertArrayHasKey('logger', $data['log']); + $this->assertArrayHasKey('level', $data['log']); + } +} diff --git a/tests/Logger/AutoLabelProcessorTest.php b/tests/Logger/AutoLabelProcessorTest.php index a78e84d..7181555 100644 --- a/tests/Logger/AutoLabelProcessorTest.php +++ b/tests/Logger/AutoLabelProcessorTest.php @@ -11,6 +11,7 @@ class AutoLabelProcessorTest extends TestCase { + /** @param array $context */ private function createRecord(array $context): LogRecord { return new LogRecord( @@ -22,21 +23,37 @@ private function createRecord(array $context): LogRecord ); } - public function testNonEcsFieldsAreMovedToLabels(): void + /** + * @param array $context + * @param array $extra + */ + private function createRecordWithExtra(array $context, array $extra): LogRecord + { + return new LogRecord( + new \DateTimeImmutable(), + 'channel', + Level::Info, + 'message', + $context, + $extra, + ); + } + + // --- Default behavior: drop --- + + public function testNonEcsFieldsAreDroppedByDefault(): void { $processor = new AutoLabelProcessor([]); $record = $this->createRecord(['foo' => 'bar', 'baz' => 'qux']); $record = $processor($record); - $this->assertArrayHasKey('labels', $record->context); - $this->assertSame('bar', $record->context['labels']['foo']); - $this->assertSame('qux', $record->context['labels']['baz']); $this->assertArrayNotHasKey('foo', $record->context); $this->assertArrayNotHasKey('baz', $record->context); + $this->assertArrayNotHasKey('labels', $record->context); } - public function testEcsFieldsAreNotMoved(): void + public function testEcsFieldsAreKeptAndNonEcsFieldsAreDropped(): void { $processor = new AutoLabelProcessor(['service', 'error']); @@ -45,9 +62,8 @@ public function testEcsFieldsAreNotMoved(): void $this->assertArrayHasKey('service', $record->context); $this->assertArrayHasKey('error', $record->context); - $this->assertArrayHasKey('labels', $record->context); - $this->assertSame('value', $record->context['labels']['custom']); $this->assertArrayNotHasKey('custom', $record->context); + $this->assertArrayNotHasKey('labels', $record->context); } public function testEmptyContextIsUnchanged(): void @@ -72,27 +88,25 @@ public function testAllContextFieldsAreEcsFields(): void $this->assertArrayNotHasKey('labels', $record->context); } - public function testFieldsMinimalConstant(): void + // --- Move-to-labels behavior --- + + public function testNonEcsFieldsAreMovedToLabelsWhenEnabled(): void { - $processor = new AutoLabelProcessor(AutoLabelProcessor::FIELDS_MINIMAL); + $processor = new AutoLabelProcessor([], moveToLabels: true); - $record = $this->createRecord([ - 'message' => 'text', - 'service' => 'svc', - 'custom_field' => 'moved', - ]); + $record = $this->createRecord(['foo' => 'bar', 'baz' => 'qux']); $record = $processor($record); - $this->assertArrayHasKey('message', $record->context); - $this->assertArrayHasKey('service', $record->context); $this->assertArrayHasKey('labels', $record->context); - $this->assertSame('moved', $record->context['labels']['custom_field']); - $this->assertArrayNotHasKey('custom_field', $record->context); + $this->assertSame('bar', $record->context['labels']['foo']); + $this->assertSame('qux', $record->context['labels']['baz']); + $this->assertArrayNotHasKey('foo', $record->context); + $this->assertArrayNotHasKey('baz', $record->context); } public function testFieldsBundleConstant(): void { - $processor = new AutoLabelProcessor(AutoLabelProcessor::FIELDS_BUNDLE); + $processor = new AutoLabelProcessor(AutoLabelProcessor::FIELDS_BUNDLE, moveToLabels: true); $record = $this->createRecord([ 'error' => 'some-error', @@ -107,22 +121,21 @@ public function testFieldsBundleConstant(): void $this->assertArrayNotHasKey('unexpected', $record->context); } - public function testInvalidLabelsThrowsException(): void + public function testInvalidLabelsIsOverwritten(): void { - // 'labels' is declared as an ECS field (stays in context), but holds a non-array value - $processor = new AutoLabelProcessor(['labels']); + // 'labels' holds a non-array value (already invalid ECS) — must be silently overwritten + $processor = new AutoLabelProcessor(['labels'], moveToLabels: true); $record = $this->createRecord(['labels' => 'not-an-array', 'foo' => 'bar']); + $record = $processor($record); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The "labels" context field must be an array, "string" given.'); - - $processor($record); + $this->assertIsArray($record->context['labels']); + $this->assertSame('bar', $record->context['labels']['foo']); } public function testExistingLabelsAreMerged(): void { - $processor = new AutoLabelProcessor(['labels']); + $processor = new AutoLabelProcessor(['labels'], moveToLabels: true); $record = $this->createRecord([ 'labels' => ['existing' => 'value'], @@ -134,4 +147,168 @@ public function testExistingLabelsAreMerged(): void $this->assertSame('value', $record->context['labels']['existing']); $this->assertSame('new_value', $record->context['labels']['new_field']); } + + public function testExistingLabelsWinOnKeyCollision(): void + { + $processor = new AutoLabelProcessor(['labels'], moveToLabels: true); + + $record = $this->createRecord([ + 'labels' => ['foo' => 'explicit'], + 'foo' => 'auto-moved', + ]); + $record = $processor($record); + + // The explicitly set label must not be overwritten by the auto-moved field. + $this->assertSame('explicit', $record->context['labels']['foo']); + } + + // --- include_extra --- + + public function testIncludeExtraIsDisabledByDefault(): void + { + $processor = new AutoLabelProcessor([]); + + $record = $this->createRecordWithExtra([], ['process_id' => 42]); + $record = $processor($record); + + $this->assertArrayNotHasKey('labels', $record->context); + $this->assertArrayHasKey('process_id', $record->extra); + } + + public function testIncludeExtraDropsNonEcsExtraKeysByDefault(): void + { + $processor = new AutoLabelProcessor([], includeExtra: true); + + $record = $this->createRecordWithExtra([], ['process_id' => 42, 'memory_usage' => '8 MB']); + $record = $processor($record); + + $this->assertArrayNotHasKey('labels', $record->context); + $this->assertArrayNotHasKey('process_id', $record->extra); + $this->assertArrayNotHasKey('memory_usage', $record->extra); + } + + public function testIncludeExtraMovesNonEcsExtraKeysToLabels(): void + { + $processor = new AutoLabelProcessor([], moveToLabels: true, includeExtra: true); + + $record = $this->createRecordWithExtra([], ['process_id' => 42, 'memory_usage' => '8 MB']); + $record = $processor($record); + + $this->assertSame(42, $record->context['labels']['process_id']); + $this->assertSame('8 MB', $record->context['labels']['memory_usage']); + $this->assertArrayNotHasKey('process_id', $record->extra); + $this->assertArrayNotHasKey('memory_usage', $record->extra); + } + + public function testIncludeExtraPreservesEcsExtraKeys(): void + { + $processor = new AutoLabelProcessor(['process'], moveToLabels: true, includeExtra: true); + + $record = $this->createRecordWithExtra([], ['process' => ['pid' => 42], 'uid' => 'abc']); + $record = $processor($record); + + $this->assertArrayHasKey('process', $record->extra); + $this->assertSame('abc', $record->context['labels']['uid']); + $this->assertArrayNotHasKey('uid', $record->extra); + } + + public function testIncludeExtraMergesWithExistingContextLabels(): void + { + $processor = new AutoLabelProcessor(['labels'], moveToLabels: true, includeExtra: true); + + $record = $this->createRecordWithExtra( + ['labels' => ['existing' => 'value']], + ['extra_key' => 'extra_value'], + ); + $record = $processor($record); + + $this->assertSame('value', $record->context['labels']['existing']); + $this->assertSame('extra_value', $record->context['labels']['extra_key']); + } + + public function testIncludeExtraWithEmptyExtraIsUnchanged(): void + { + $processor = new AutoLabelProcessor([], includeExtra: true); + + $record = $this->createRecordWithExtra([], []); + $record = $processor($record); + + $this->assertSame([], $record->context); + $this->assertSame([], $record->extra); + } + + // --- non_scalar_strategy --- + + public function testSkipStrategyIsDefault(): void + { + $processor = new AutoLabelProcessor([]); + + $record = $this->createRecord(['foo' => ['nested' => 'array']]); + $record = $processor($record); + + $this->assertArrayNotHasKey('foo', $record->context); + $this->assertArrayNotHasKey('labels', $record->context); + } + + public function testSkipStrategyRemovesNonScalarContextFields(): void + { + $processor = new AutoLabelProcessor([], moveToLabels: true, nonScalarStrategy: AutoLabelProcessor::STRATEGY_SKIP); + + $record = $this->createRecord(['obj' => new \stdClass(), 'scalar' => 'ok']); + $record = $processor($record); + + // non-scalar field is removed from context and not added to labels + $this->assertArrayNotHasKey('obj', $record->context); + $this->assertArrayNotHasKey('obj', $record->context['labels'] ?? []); + // scalar non-ECS field is moved to labels + $this->assertSame('ok', $record->context['labels']['scalar']); + } + + public function testJsonStrategyConvertsNonScalarToString(): void + { + $processor = new AutoLabelProcessor([], moveToLabels: true, nonScalarStrategy: AutoLabelProcessor::STRATEGY_JSON); + + $record = $this->createRecord(['meta' => ['key' => 'value'], 'scalar' => 'ok']); + $record = $processor($record); + + $this->assertArrayNotHasKey('meta', $record->context); + $this->assertSame('{"key":"value"}', $record->context['labels']['meta']); + $this->assertSame('ok', $record->context['labels']['scalar']); + } + + public function testJsonStrategyFallsBackToSkipOnEncodingFailure(): void + { + $processor = new AutoLabelProcessor([], moveToLabels: true, nonScalarStrategy: AutoLabelProcessor::STRATEGY_JSON); + + // Object with circular reference cannot be json_encoded + $obj = new \stdClass(); + $obj->self = $obj; + $record = $this->createRecord(['bad' => $obj]); + $record = $processor($record); + + $this->assertArrayNotHasKey('bad', $record->context); + $this->assertArrayNotHasKey('labels', $record->context); + } + + public function testSkipStrategyRemovesNonScalarExtraFields(): void + { + $processor = new AutoLabelProcessor([], includeExtra: true, nonScalarStrategy: AutoLabelProcessor::STRATEGY_SKIP); + + $record = $this->createRecordWithExtra([], ['obj' => new \stdClass()]); + $record = $processor($record); + + $this->assertArrayNotHasKey('obj', $record->extra); + $this->assertArrayNotHasKey('labels', $record->context); + } + + public function testJsonStrategyConvertsNonScalarExtraToString(): void + { + $processor = new AutoLabelProcessor([], moveToLabels: true, includeExtra: true, nonScalarStrategy: AutoLabelProcessor::STRATEGY_JSON); + + $record = $this->createRecordWithExtra([], ['meta' => ['k' => 'v']]); + $record = $processor($record); + + $this->assertArrayNotHasKey('meta', $record->extra); + $this->assertSame('{"k":"v"}', $record->context['labels']['meta']); + } } diff --git a/tests/Logger/ErrorProcessorTest.php b/tests/Logger/ErrorProcessorTest.php index ba60381..7a90721 100644 --- a/tests/Logger/ErrorProcessorTest.php +++ b/tests/Logger/ErrorProcessorTest.php @@ -12,7 +12,7 @@ class ErrorProcessorTest extends TestCase { - public function testWithErrorProcessor() + public function testWithErrorProcessor(): void { $processor = new ErrorProcessor('error'); @@ -32,7 +32,7 @@ public function testWithErrorProcessor() $this->assertInstanceOf(Error::class, $record->context['error']); } - public function testWithErrorRenameProcessor() + public function testWithErrorRenameProcessor(): void { $processor = new ErrorProcessor('error_custom'); @@ -53,7 +53,7 @@ public function testWithErrorRenameProcessor() $this->assertInstanceOf(Error::class, $record->context['error']); } - public function testWithoutErrorProcessor() + public function testWithoutErrorProcessor(): void { $processor = new ErrorProcessor('error'); @@ -71,7 +71,7 @@ public function testWithoutErrorProcessor() $this->assertArrayNotHasKey('error', $record->context); } - public function testWithNonThrowableErrorProcessor() + public function testWithNonThrowableErrorProcessor(): void { $processor = new ErrorProcessor('error'); @@ -85,16 +85,12 @@ public function testWithNonThrowableErrorProcessor() ] ); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('error must be an instance of Throwable'); - $record = $processor($record); - $this->assertArrayHasKey('context', $record); - $this->assertArrayNotHasKey('error', $record->context); + $this->assertSame('Not Throwable', $record->context['error']); } - public function testWithAlreadyTransformedErrorProcessor() + public function testWithAlreadyTransformedErrorProcessor(): void { $processor = new ErrorProcessor('error'); @@ -115,4 +111,67 @@ public function testWithAlreadyTransformedErrorProcessor() $this->assertArrayHasKey('error', $record->context); $this->assertSame($ecsError, $record->context['error']); } + + public function testMapExceptionKeyProcessesExceptionContext(): void + { + $processor = new ErrorProcessor('error', mapExceptionKey: true); + + $record = new LogRecord( + new \DateTimeImmutable(), + 'channel', + Level::Info, + 'message', + [ + 'exception' => new \Exception('from symfony'), + ] + ); + + $record = $processor($record); + + $this->assertArrayHasKey('error', $record->context); + $this->assertInstanceOf(Error::class, $record->context['error']); + $this->assertArrayNotHasKey('exception', $record->context); + } + + public function testMapExceptionKeyIsSkippedWhenTargetAlreadySet(): void + { + $processor = new ErrorProcessor('error', mapExceptionKey: true); + + $record = new LogRecord( + new \DateTimeImmutable(), + 'channel', + Level::Info, + 'message', + [ + 'error' => new \RuntimeException('primary'), + 'exception' => new \Exception('should be ignored'), + ] + ); + + $record = $processor($record); + + $this->assertArrayHasKey('error', $record->context); + $this->assertInstanceOf(Error::class, $record->context['error']); + $this->assertArrayHasKey('exception', $record->context); + } + + public function testMapExceptionKeyIsSkippedWhenNotThrowable(): void + { + $processor = new ErrorProcessor('error', mapExceptionKey: true); + + $record = new LogRecord( + new \DateTimeImmutable(), + 'channel', + Level::Info, + 'message', + [ + 'exception' => 'not a throwable', + ] + ); + + $record = $processor($record); + + $this->assertArrayNotHasKey('error', $record->context); + $this->assertArrayHasKey('exception', $record->context); + } } diff --git a/tests/Logger/ServiceProcessorTest.php b/tests/Logger/ServiceProcessorTest.php index 7079685..f195ba4 100644 --- a/tests/Logger/ServiceProcessorTest.php +++ b/tests/Logger/ServiceProcessorTest.php @@ -9,12 +9,10 @@ use Monolog\Level; use Monolog\LogRecord; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; class ServiceProcessorTest extends TestCase { - use ProphecyTrait; - + /** @param array $context */ private function createRecord(array $context): LogRecord { return new LogRecord( diff --git a/tests/Logger/TracingProcessorTest.php b/tests/Logger/TracingProcessorTest.php index 0414626..bdced51 100644 --- a/tests/Logger/TracingProcessorTest.php +++ b/tests/Logger/TracingProcessorTest.php @@ -90,12 +90,10 @@ public function testWithTracingWithoutTraceIdProcessor(): void ] ); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('trace_id is required when tracing is provided'); - $record = $processor($record); - $this->assertArrayNotHasKey('tracing', $record->context); + $this->assertSame([], $record->context['tracing']); + $this->assertArrayNotHasKey('span', $record->context); } public function testWithTracingWithoutTransactionIdProcessor(): void @@ -120,6 +118,74 @@ public function testWithTracingWithoutTransactionIdProcessor(): void $this->assertInstanceOf(Tracing::class, $record->context['tracing']); } + public function testWithSpanId(): void + { + $processor = new TracingProcessor('tracing'); + + $record = new LogRecord( + new \DateTimeImmutable(), + 'channel', + Level::Info, + 'message', + [ + 'tracing' => [ + 'trace_id' => 'abc123', + 'transaction_id' => 'txn456', + 'span_id' => 'span789', + ], + ] + ); + + $record = $processor($record); + + $this->assertArrayHasKey('span', $record->context); + $this->assertSame('span789', $record->context['span']['id']); + } + + public function testWithoutSpanIdNoSpanContext(): void + { + $processor = new TracingProcessor('tracing'); + + $record = new LogRecord( + new \DateTimeImmutable(), + 'channel', + Level::Info, + 'message', + [ + 'tracing' => [ + 'trace_id' => 'abc123', + ], + ] + ); + + $record = $processor($record); + + $this->assertArrayNotHasKey('span', $record->context); + } + + public function testSpanNotOverwrittenIfAlreadyPresent(): void + { + $processor = new TracingProcessor('tracing'); + + $record = new LogRecord( + new \DateTimeImmutable(), + 'channel', + Level::Info, + 'message', + [ + 'tracing' => [ + 'trace_id' => 'abc123', + 'span_id' => 'new-span', + ], + 'span' => ['id' => 'existing-span'], + ] + ); + + $record = $processor($record); + + $this->assertSame('existing-span', $record->context['span']['id']); + } + public function testWithAlreadyTransformedTracingProcessor(): void { $processor = new TracingProcessor('tracing'); diff --git a/tests/Logger/UserProcessorTest.php b/tests/Logger/UserProcessorTest.php index 002a49a..ea7d5d4 100644 --- a/tests/Logger/UserProcessorTest.php +++ b/tests/Logger/UserProcessorTest.php @@ -10,31 +10,29 @@ use Monolog\Level; use Monolog\LogRecord; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; class UserProcessorTest extends TestCase { - use ProphecyTrait; - - public function testWithUserProcessor(): void + /** @param array $context */ + private function makeRecord(array $context = []): LogRecord { - $user = $this->prophesize(User::class); + return new LogRecord(new \DateTimeImmutable(), 'channel', Level::Info, 'message', $context); + } - $provider = $this->prophesize(EcsUserProviderInterface::class); - $provider->getDomain()->willReturn('in_memory'); - $provider->getUser()->willReturn($user->reveal()); + private function makeProvider(?User $user, ?string $domain = null): EcsUserProviderInterface + { + $provider = $this->createStub(EcsUserProviderInterface::class); + $provider->method('getUser')->willReturn($user); + $provider->method('getDomain')->willReturn($domain); - $processor = new UserProcessor($provider->reveal(), 'unknown'); + return $provider; + } - $record = new LogRecord( - new \DateTimeImmutable(), - 'channel', - Level::Info, - 'message', - [] - ); + public function testWithUserProcessor(): void + { + $processor = new UserProcessor($this->makeProvider(new User(), 'in_memory'), 'unknown'); - $record = $processor($record); + $record = $processor($this->makeRecord()); $this->assertArrayHasKey('user', $record->context); $this->assertInstanceOf(User::class, $record->context['user']); @@ -42,119 +40,75 @@ public function testWithUserProcessor(): void public function testWithoutUserProcessor(): void { - $provider = $this->prophesize(EcsUserProviderInterface::class); - $provider->getDomain()->willReturn('in_memory'); - $provider->getUser()->willReturn(null); - - $processor = new UserProcessor($provider->reveal(), 'unknown'); + $processor = new UserProcessor($this->makeProvider(null, 'in_memory'), 'unknown'); - $record = new LogRecord( - new \DateTimeImmutable(), - 'channel', - Level::Info, - 'message', - [] - ); - - $record = $processor($record); + $record = $processor($this->makeRecord()); $this->assertArrayNotHasKey('user', $record->context); } public function testSkipsWhenUserAlreadyInContext(): void { - $provider = $this->prophesize(EcsUserProviderInterface::class); - - $processor = new UserProcessor($provider->reveal(), 'unknown'); + $processor = new UserProcessor($this->createStub(EcsUserProviderInterface::class), 'unknown'); - $record = new LogRecord( - new \DateTimeImmutable(), - 'channel', - Level::Info, - 'message', - [ - 'user' => [ - 'id' => 'User Id', - ], - ] - ); + $record = $processor($this->makeRecord(['user' => ['id' => 'User Id']])); - $record = $processor($record); - - $this->assertArrayHasKey('user', $record->context); - $this->assertArrayHasKey('id', $record->context['user']); $this->assertSame('User Id', $record->context['user']['id']); } public function testDomainFromProviderTakesPriority(): void { - $user = new User(); - - $provider = $this->prophesize(EcsUserProviderInterface::class); - $provider->getDomain()->willReturn('provider_domain'); - $provider->getUser()->willReturn($user); - - $processor = new UserProcessor($provider->reveal(), 'constructor_domain'); + $processor = new UserProcessor($this->makeProvider(new User(), 'provider_domain'), 'constructor_domain'); - $record = new LogRecord( - new \DateTimeImmutable(), - 'channel', - Level::Info, - 'message', - [] - ); + $record = $processor($this->makeRecord()); - $record = $processor($record); - - $this->assertArrayHasKey('user', $record->context); $this->assertSame('provider_domain', $record->context['user']->jsonSerialize()['user']['domain']); } public function testDomainFallbackToConstructor(): void { - $user = new User(); + $processor = new UserProcessor($this->makeProvider(new User(), null), 'constructor_domain'); - $provider = $this->prophesize(EcsUserProviderInterface::class); - $provider->getDomain()->willReturn(null); - $provider->getUser()->willReturn($user); + $record = $processor($this->makeRecord()); - $processor = new UserProcessor($provider->reveal(), 'constructor_domain'); + $this->assertSame('constructor_domain', $record->context['user']->jsonSerialize()['user']['domain']); + } - $record = new LogRecord( - new \DateTimeImmutable(), - 'channel', - Level::Info, - 'message', - [] - ); + public function testNoDomainWhenBothAreNull(): void + { + $processor = new UserProcessor($this->makeProvider(new User(), null), null); - $record = $processor($record); + $record = $processor($this->makeRecord()); - $this->assertArrayHasKey('user', $record->context); - $this->assertSame('constructor_domain', $record->context['user']->jsonSerialize()['user']['domain']); + $this->assertArrayNotHasKey('domain', $record->context['user']->jsonSerialize()['user'] ?? []); } - public function testNoDomainWhenBothAreNull(): void + public function testResetClearsDomainCache(): void { $user = new User(); + $domainCallCount = 0; - $provider = $this->prophesize(EcsUserProviderInterface::class); - $provider->getDomain()->willReturn(null); - $provider->getUser()->willReturn($user); + $provider = $this->createStub(EcsUserProviderInterface::class); + $provider->method('getUser')->willReturn($user); + $provider->method('getDomain')->willReturnCallback(static function () use (&$domainCallCount): ?string { + ++$domainCallCount; - $processor = new UserProcessor($provider->reveal(), null); + return null; + }); - $record = new LogRecord( - new \DateTimeImmutable(), - 'channel', - Level::Info, - 'message', - [] - ); + $processor = new UserProcessor($provider); - $record = $processor($record); + $processor($this->makeRecord()); + $this->assertSame(1, $domainCallCount); // getDomain resolved on first invocation - $this->assertArrayHasKey('user', $record->context); - $this->assertArrayNotHasKey('domain', $record->context['user']->jsonSerialize()['user'] ?? []); + $processor($this->makeRecord()); + /* @phpstan-ignore method.alreadyNarrowedType (cache hit, getDomain not re-called) */ + $this->assertSame(1, $domainCallCount); + + $processor->reset(); + + $processor($this->makeRecord()); + /* @phpstan-ignore method.impossibleType (cache cleared, getDomain resolved again) */ + $this->assertSame(2, $domainCallCount); } } diff --git a/tests/Security/EcsUserProviderTest.php b/tests/Security/EcsUserProviderTest.php index 9ec9e7e..9b41838 100644 --- a/tests/Security/EcsUserProviderTest.php +++ b/tests/Security/EcsUserProviderTest.php @@ -7,62 +7,117 @@ use Aubes\EcsLoggingBundle\Security\EcsUserProvider; use Elastic\Types\User; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\UserInterface; class EcsUserProviderTest extends TestCase { - use ProphecyTrait; - public function testGetUserReturnsEcsUserWhenAuthenticated(): void { - $symfonyUser = $this->prophesize(UserInterface::class); - $symfonyUser->getUserIdentifier()->willReturn('user@example.com'); + $symfonyUser = $this->createStub(UserInterface::class); + $symfonyUser->method('getUserIdentifier')->willReturn('user@example.com'); - $token = $this->prophesize(TokenInterface::class); - $token->getUser()->willReturn($symfonyUser->reveal()); + $token = $this->createStub(TokenInterface::class); + $token->method('getUser')->willReturn($symfonyUser); - $tokenStorage = $this->prophesize(TokenStorageInterface::class); - $tokenStorage->getToken()->willReturn($token->reveal()); + $tokenStorage = $this->createStub(TokenStorageInterface::class); + $tokenStorage->method('getToken')->willReturn($token); - $provider = new EcsUserProvider($tokenStorage->reveal()); - $user = $provider->getUser(); + $user = (new EcsUserProvider($tokenStorage))->getUser(); $this->assertInstanceOf(User::class, $user); - $this->assertSame('user@example.com', $user->jsonSerialize()['user']['id']); + $this->assertSame('user@example.com', $user->jsonSerialize()['user']['name']); } public function testGetUserReturnsNullWhenNoToken(): void { - $tokenStorage = $this->prophesize(TokenStorageInterface::class); - $tokenStorage->getToken()->willReturn(null); - - $provider = new EcsUserProvider($tokenStorage->reveal()); + $tokenStorage = $this->createStub(TokenStorageInterface::class); + $tokenStorage->method('getToken')->willReturn(null); - $this->assertNull($provider->getUser()); + $this->assertNull((new EcsUserProvider($tokenStorage))->getUser()); } public function testGetUserReturnsNullWhenTokenHasNoUser(): void { - $token = $this->prophesize(TokenInterface::class); - $token->getUser()->willReturn(null); + $token = $this->createStub(TokenInterface::class); + $token->method('getUser')->willReturn(null); - $tokenStorage = $this->prophesize(TokenStorageInterface::class); - $tokenStorage->getToken()->willReturn($token->reveal()); + $tokenStorage = $this->createStub(TokenStorageInterface::class); + $tokenStorage->method('getToken')->willReturn($token); - $provider = new EcsUserProvider($tokenStorage->reveal()); - - $this->assertNull($provider->getUser()); + $this->assertNull((new EcsUserProvider($tokenStorage))->getUser()); } public function testGetDomainReturnsNull(): void { - $tokenStorage = $this->prophesize(TokenStorageInterface::class); + $this->assertNull((new EcsUserProvider($this->createStub(TokenStorageInterface::class)))->getDomain()); + } + + public function testGetUserCachesResultForSameToken(): void + { + $symfonyUser = $this->createMock(UserInterface::class); + $symfonyUser->expects($this->once())->method('getUserIdentifier')->willReturn('user@example.com'); + + $token = $this->createStub(TokenInterface::class); + $token->method('getUser')->willReturn($symfonyUser); + + $tokenStorage = $this->createStub(TokenStorageInterface::class); + $tokenStorage->method('getToken')->willReturn($token); + + $provider = new EcsUserProvider($tokenStorage); + + $user1 = $provider->getUser(); + $user2 = $provider->getUser(); + + $this->assertSame($user1, $user2); + } + + public function testGetUserInvalidatesCacheWhenTokenChanges(): void + { + $makeUser = function (string $id): UserInterface { + $stub = $this->createStub(UserInterface::class); + $stub->method('getUserIdentifier')->willReturn($id); + + return $stub; + }; + + $token1 = $this->createStub(TokenInterface::class); + $token1->method('getUser')->willReturn($makeUser('user1@example.com')); + + $token2 = $this->createStub(TokenInterface::class); + $token2->method('getUser')->willReturn($makeUser('user2@example.com')); + + $tokenStorage = $this->createStub(TokenStorageInterface::class); + $tokenStorage->method('getToken')->willReturnOnConsecutiveCalls($token1, $token2); + + $provider = new EcsUserProvider($tokenStorage); + + $user1 = $provider->getUser(); + $user2 = $provider->getUser(); + + $this->assertNotSame($user1, $user2); + $this->assertSame('user1@example.com', $user1?->jsonSerialize()['user']['name']); + $this->assertSame('user2@example.com', $user2?->jsonSerialize()['user']['name']); + } + + public function testResetClearsCache(): void + { + $symfonyUser = $this->createStub(UserInterface::class); + $symfonyUser->method('getUserIdentifier')->willReturn('user@example.com'); + + $token = $this->createStub(TokenInterface::class); + $token->method('getUser')->willReturn($symfonyUser); + + $tokenStorage = $this->createStub(TokenStorageInterface::class); + $tokenStorage->method('getToken')->willReturn($token); + + $provider = new EcsUserProvider($tokenStorage); + $userBefore = $provider->getUser(); - $provider = new EcsUserProvider($tokenStorage->reveal()); + $provider->reset(); + $userAfter = $provider->getUser(); - $this->assertNull($provider->getDomain()); + $this->assertNotSame($userBefore, $userAfter); } }