From 871271b565efacea5e709432d6aaa3c91ae7734e Mon Sep 17 00:00:00 2001
From: aubes <3941035+aubes@users.noreply.github.com>
Date: Wed, 25 Mar 2026 12:18:49 +0100
Subject: [PATCH] Miragate to v3
---
.github/workflows/php.yml | 32 +-
.gitignore | 1 +
.pmd-ruleset.xml | 35 --
CHANGELOG.md | 91 ++++
README.md | 462 +++---------------
UPGRADE.md | 140 ++++++
composer.json | 40 +-
config/services.yaml | 3 -
docs/configuration-reference.md | 127 +++++
docs/processors/auto-label.md | 128 +++++
docs/processors/error.md | 56 +++
docs/processors/host.md | 35 ++
docs/processors/http-request.md | 47 ++
docs/processors/service.md | 55 +++
docs/processors/tracing.md | 51 ++
docs/processors/user.md | 87 ++++
phpstan.neon | 9 +
phpunit.xml | 25 +
psalm.xml | 22 -
.../Compiler/UserProviderPass.php | 40 ++
src/DependencyInjection/Configuration.php | 121 -----
.../EcsLoggingExtension.php | 183 -------
.../ProcessorConfigurationBuilder.php | 188 +++++++
src/DependencyInjection/ProcessorLoader.php | 227 +++++++++
src/EcsLoggingBundle.php | 106 +++-
src/Formatter/EcsFormatter.php | 45 ++
src/Logger/AbstractProcessor.php | 17 +-
src/Logger/AutoLabelProcessor.php | 124 +++--
src/Logger/ErrorProcessor.php | 31 +-
src/Logger/HostProcessor.php | 56 +++
src/Logger/HttpRequestProcessor.php | 170 +++++++
src/Logger/TracingProcessor.php | 29 +-
src/Logger/UserProcessor.php | 25 +-
src/Security/EcsUserProvider.php | 43 +-
src/Security/EcsUserProviderInterface.php | 10 +
tests/Formatter/EcsFormatterTest.php | 117 +++++
tests/Logger/AutoLabelProcessorTest.php | 231 ++++++++-
tests/Logger/ErrorProcessorTest.php | 79 ++-
tests/Logger/ServiceProcessorTest.php | 4 +-
tests/Logger/TracingProcessorTest.php | 74 ++-
tests/Logger/UserProcessorTest.php | 146 ++----
tests/Security/EcsUserProviderTest.php | 109 ++++-
42 files changed, 2607 insertions(+), 1014 deletions(-)
delete mode 100644 .pmd-ruleset.xml
create mode 100644 CHANGELOG.md
create mode 100644 UPGRADE.md
delete mode 100644 config/services.yaml
create mode 100644 docs/configuration-reference.md
create mode 100644 docs/processors/auto-label.md
create mode 100644 docs/processors/error.md
create mode 100644 docs/processors/host.md
create mode 100644 docs/processors/http-request.md
create mode 100644 docs/processors/service.md
create mode 100644 docs/processors/tracing.md
create mode 100644 docs/processors/user.md
create mode 100644 phpstan.neon
create mode 100644 phpunit.xml
delete mode 100644 psalm.xml
create mode 100644 src/DependencyInjection/Compiler/UserProviderPass.php
delete mode 100644 src/DependencyInjection/Configuration.php
delete mode 100644 src/DependencyInjection/EcsLoggingExtension.php
create mode 100644 src/DependencyInjection/ProcessorConfigurationBuilder.php
create mode 100644 src/DependencyInjection/ProcessorLoader.php
create mode 100644 src/Formatter/EcsFormatter.php
create mode 100644 src/Logger/HostProcessor.php
create mode 100644 src/Logger/HttpRequestProcessor.php
create mode 100644 tests/Formatter/EcsFormatterTest.php
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

[](https://packagist.org/packages/aubes/ecs-logging-bundle)
[](https://packagist.org/packages/aubes/ecs-logging-bundle)
[](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);
}
}