From 8815fc7346f54928063985ed289f373ac7872817 Mon Sep 17 00:00:00 2001 From: aubes <3941035+aubes@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:26:59 +0200 Subject: [PATCH] feat: Add CorrelationIdProcessor and TracingProcessor OpenTelemetry mode --- CHANGELOG.md | 11 ++- README.md | 8 ++- composer.json | 2 +- docs/configuration-reference.md | 19 +++++ docs/processors/auto-label.md | 2 + docs/processors/correlation-id.md | 72 +++++++++++++++++++ docs/processors/error.md | 15 ++++ docs/processors/host.md | 13 ++++ docs/processors/http-request.md | 24 ++++++- docs/processors/service.md | 13 ++++ docs/processors/tracing.md | 69 ++++++++++++++---- docs/processors/user.md | 12 ++++ .../ProcessorConfigurationBuilder.php | 30 +++++++- src/DependencyInjection/ProcessorLoader.php | 20 ++++++ src/EcsLoggingBundle.php | 2 + src/Logger/CorrelationIdProcessor.php | 55 ++++++++++++++ src/Logger/TracingProcessor.php | 41 ++++++++++- 17 files changed, 386 insertions(+), 22 deletions(-) create mode 100644 docs/processors/correlation-id.md create mode 100644 src/Logger/CorrelationIdProcessor.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ef35202..ca7c25a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.1.0] + +### Added + +- **`CorrelationIdProcessor`** — new processor that reads a correlation ID from Monolog `extra` and writes it to `labels.correlation_id` or `trace.id`. Works with any library that populates `extra` (e.g. `aubes/correlation-bundle`). The source key is removed from `extra` after processing. +- **`TracingProcessor`** — new `opentelemetry` mode that reads flat `trace_id`/`span_id`/`trace_flags` keys injected by the OpenTelemetry Monolog processor and maps them to ECS fields. The flat keys are cleaned up from context automatically. + ## [3.0.1] ### Fixed @@ -89,7 +96,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 +[Unreleased]: https://github.com/aubes/ecs-logging-bundle/compare/v3.1.0...HEAD +[3.1.0]: https://github.com/aubes/ecs-logging-bundle/compare/v3.0.1...v3.1.0 +[3.0.1]: https://github.com/aubes/ecs-logging-bundle/compare/v3.0.0...v3.0.1 [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 diff --git a/README.md b/README.md index 6572f49..645d454 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ Built on top of [elastic/ecs-logging](https://github.com/elastic/ecs-logging-php |---|---| | **`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` | +| **`ErrorProcessor`** | Converts a `\Throwable` in context to ECS `error.*` fields. `map_exception_key` also catches Symfony's native exceptions | +| **`TracingProcessor`** | Maps tracing data to ECS `trace.id`, `transaction.id`, `span.id` (supports manual arrays and OpenTelemetry flat keys) | +| **`CorrelationIdProcessor`** | Maps a correlation ID from Monolog `extra` to ECS `labels.correlation_id` or `trace.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 | @@ -121,7 +122,8 @@ ecs_logging: - [ServiceProcessor](docs/processors/service.md) - [ErrorProcessor](docs/processors/error.md) - [TracingProcessor](docs/processors/tracing.md) - - [UserProcessor](docs/processors/user.md) — includes custom provider + - [CorrelationIdProcessor](docs/processors/correlation-id.md) + - [UserProcessor](docs/processors/user.md) - [AutoLabelProcessor](docs/processors/auto-label.md) - [HttpRequestProcessor](docs/processors/http-request.md) - [HostProcessor](docs/processors/host.md) diff --git a/composer.json b/composer.json index 0e3131c..57aa849 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "aubes/ecs-logging-bundle", "type": "symfony-bundle", "description": "Symfony bundle providing the Ecs log format", - "keywords": ["symfony", "bundle", "ecs", "elastic", "elastic-common-schema", "monolog", "logging"], + "keywords": ["symfony", "symfony-bundle", "ecs", "elastic", "elastic-common-schema", "monolog", "logging"], "license": "MIT", "authors": [ { diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 6649cdc..d178c03 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -47,6 +47,11 @@ ecs_logging: channels: [] tracing: enabled: false + + # "default" reads a nested array from context[field_name]. "opentelemetry" reads flat trace_id/span_id keys injected by the OTel Monolog processor (field_name is ignored). + mode: default # One of "default"; "opentelemetry" + + # Context key read by the processor in "default" mode. Ignored in "opentelemetry" mode. field_name: tracing # Logging handler list the processor should be pushed to @@ -105,6 +110,20 @@ ecs_logging: # Logging handler list the processor should be pushed to handlers: [] + # Logging channel list the processor should be pushed to + channels: [] + correlation_id: + enabled: false + + # Key to read from Monolog extra (must match the library that populates extra). + field_name: correlation_id + + # Where to write the correlation ID: "labels" writes to labels.correlation_id, "trace" writes to trace.id. + target: labels # One of "labels"; "trace" + + # Logging handler list the processor should be pushed to + handlers: [] + # Logging channel list the processor should be pushed to channels: [] http_request: diff --git a/docs/processors/auto-label.md b/docs/processors/auto-label.md index b126ed9..58fa9c4 100644 --- a/docs/processors/auto-label.md +++ b/docs/processors/auto-label.md @@ -1,5 +1,7 @@ # AutoLabelProcessor +Keep your custom context fields without polluting the ECS namespace. Non-ECS keys are moved to `labels` or dropped automatically. + 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 diff --git a/docs/processors/correlation-id.md b/docs/processors/correlation-id.md new file mode 100644 index 0000000..8e88fb4 --- /dev/null +++ b/docs/processors/correlation-id.md @@ -0,0 +1,72 @@ +# CorrelationIdProcessor + +Lightweight request correlation without the full OpenTelemetry stack. Your correlation ID flows into ECS logs automatically. + +Reads a correlation ID from Monolog `extra` and writes it to an ECS-compliant field in the log context. Works with any library that populates `extra` with a correlation/request ID. + +## Correlation ID vs Tracing OpenTelemetry Mode + +If your application already uses OpenTelemetry for distributed tracing, you probably don't need this processor: the [Tracing processor with open opentelemetry mode](tracing.md#opentelemetry-mode) maps `trace_id` and `span_id` to ECS fields automatically. + +## Configuration + +```yaml +# config/packages/ecs_logging.yaml +ecs_logging: + processor: + correlation_id: + enabled: true + + # Key to read from Monolog extra (default: "correlation_id"). + # Must match the field name configured in the library that populates extra. + field_name: correlation_id + + # Where to write the correlation ID: + # "labels" (default) -> labels.correlation_id + # "trace" -> trace.id + target: labels + + #handlers: ['ecs'] + #channels: ['app'] +``` + +## Target options + +| Value | ECS field | When to use | +|---|---|---| +| `labels` (default) | `labels.correlation_id` | When the correlation ID is a business/request-scoping value distinct from distributed tracing | +| `trace` | `trace.id` | When the correlation ID serves as the trace ID (no separate tracing system). ECS expects a 32-character lowercase hex string | + +ECS output with `target: labels`: + +```json +{ + "labels": { + "correlation_id": "abc-123" + } +} +``` + +ECS output with `target: trace`: + +```json +{ + "trace": { + "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" + } +} +``` + +The source key is removed from `extra` to prevent it from appearing at root level in the ECS output. If the field is missing, empty, or not a string, the record is left unchanged. Existing values in the target field are never overwritten. + +## Custom field name + +Any Monolog processor that writes a string to `extra` can be used. Configure `field_name` to match: + +```yaml +ecs_logging: + processor: + correlation_id: + enabled: true + field_name: request_id # reads from extra.request_id +``` diff --git a/docs/processors/error.md b/docs/processors/error.md index 3d7a534..8237073 100644 --- a/docs/processors/error.md +++ b/docs/processors/error.md @@ -1,5 +1,7 @@ # ErrorProcessor +Just pass a `\Throwable` in context, the processor handles the rest. With `map_exception_key`, Symfony's own exceptions (HttpKernel, security, form...) are also converted to ECS automatically. + Converts a `\Throwable` in the log context to [ECS `error.*`](https://www.elastic.co/guide/en/ecs/current/ecs-error.html) fields. ## Configuration @@ -41,6 +43,19 @@ try { } ``` +ECS output: + +```json +{ + "error": { + "type": "RuntimeException", + "message": "Something failed", + "code": 0, + "stack_trace": "RuntimeException: Something failed in /app/src/Service.php:42\n..." + } +} +``` + ## 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: diff --git a/docs/processors/host.md b/docs/processors/host.md index c4be70e..58f653a 100644 --- a/docs/processors/host.md +++ b/docs/processors/host.md @@ -1,5 +1,7 @@ # HostProcessor +Enrich every log record with host metadata (name, IP, architecture). + 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 @@ -33,3 +35,14 @@ ecs_logging: | `host.ip` | Not set unless `ip` is provided or `resolve_ip: true` | | `host.architecture` | `php_uname('m')` (e.g. `x86_64`, `aarch64`) | +ECS output: + +```json +{ + "host": { + "name": "example.com", + "architecture": "x86_64" + } +} +``` + diff --git a/docs/processors/http-request.md b/docs/processors/http-request.md index ede0c3c..6601eaa 100644 --- a/docs/processors/http-request.md +++ b/docs/processors/http-request.md @@ -1,5 +1,7 @@ # HttpRequestProcessor +Every log record automatically includes HTTP method, URL and protocol version from the current request. + 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. @@ -43,5 +45,23 @@ ecs_logging: | `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 +> The request path can contain personal identifiers embedded in the route. + +ECS output: + +```json +{ + "http": { + "request": { + "method": "POST", + "mime_type": "application/json" + }, + "version": "1.1" + }, + "url": { + "path": "/api/checkout", + "scheme": "https", + "domain": "example.com" + } +} +``` \ No newline at end of file diff --git a/docs/processors/service.md b/docs/processors/service.md index 71d82eb..032b33e 100644 --- a/docs/processors/service.md +++ b/docs/processors/service.md @@ -1,5 +1,7 @@ # ServiceProcessor +Configure once, every log record is automatically enriched with your service name, version and type. + 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 @@ -53,3 +55,14 @@ With the processor enabled, every log record receives the service fields automat ```php $logger->info('message'); ``` + +ECS output: + +```json +{ + "service": { + "name": "my-app", + "version": "1.0.0" + } +} +``` diff --git a/docs/processors/tracing.md b/docs/processors/tracing.md index e93212a..9059114 100644 --- a/docs/processors/tracing.md +++ b/docs/processors/tracing.md @@ -1,6 +1,8 @@ # TracingProcessor -Converts a tracing array in the log context to ECS `trace.id`, `transaction.id`, and `span.id`. +Maps your tracing data to ECS fields automatically, whether you use OpenTelemetry or pass tracing IDs manually. + +Converts tracing data in the log context to ECS `trace.id`, `transaction.id`, and `span.id`. ## Configuration @@ -10,13 +12,18 @@ ecs_logging: processor: tracing: enabled: true - field_name: 'tracing' # context key to read from (default: 'tracing') + mode: 'default' # 'default' or 'opentelemetry' + field_name: 'tracing' # context key to read from (default mode only) #handlers: ['ecs'] #channels: ['app'] ``` -## Usage +## Default mode + +In `default` mode, the processor reads a nested array from `context[field_name]` and maps it to ECS tracing fields. + +### Usage Without the processor: @@ -33,19 +40,57 @@ 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 + 'trace_id' => $traceId, // required + 'transaction_id' => $transactionId, // optional + 'span_id' => $spanId, // optional ], ]); ``` -## ECS output +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 | +```json +{ + "trace": { "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" }, + "transaction": { "id": "f6e5d4c3b2a1f6e5" }, + "span": { "id": "f6e5d4c3b2a1f6e5" } +} +``` `trace_id` is required. `transaction_id` and `span_id` are optional. + +## OpenTelemetry mode + +In `opentelemetry` mode, the processor reads flat `trace_id`, `span_id`, and `trace_flags` keys from the log context (injected by the OpenTelemetry Monolog handler) and maps them to ECS fields. The `field_name` option is ignored. + +This mode is designed to work with: +- [`open-telemetry/opentelemetry-auto-symfony`](https://github.com/opentelemetry-php/contrib-auto-symfony) with `OTEL_PHP_PSR3_MODE=inject` +- Any OpenTelemetry setup that injects flat tracing keys into Monolog context + +### Configuration + +```yaml +ecs_logging: + processor: + tracing: + enabled: true + mode: 'opentelemetry' +``` + +No additional dependency is required: the processor reads from keys already present in the log context. The flat OTel keys (`trace_id`, `span_id`, `trace_flags`) are cleaned up automatically. + +### ECS output + +| Context key | ECS field | +|---|---| +| `trace_id` | `trace.id` | +| `span_id` | `transaction.id`, `span.id` | +| `trace_flags` | removed (not an ECS field) | + +```json +{ + "trace": { "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" }, + "transaction": { "id": "f6e5d4c3b2a1f6e5" }, + "span": { "id": "f6e5d4c3b2a1f6e5" } +} +``` diff --git a/docs/processors/user.md b/docs/processors/user.md index 00f766c..9aedd4b 100644 --- a/docs/processors/user.md +++ b/docs/processors/user.md @@ -1,5 +1,7 @@ # UserProcessor +Know who triggered every log record. The authenticated user is injected automatically. + 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 @@ -85,3 +87,13 @@ ecs_logging: # The built-in EcsUserProvider already implements it. provider: 'App\Security\CustomEcsUserProvider' ``` + +ECS output: + +```json +{ + "user": { + "name": "john.doe" + } +} +``` diff --git a/src/DependencyInjection/ProcessorConfigurationBuilder.php b/src/DependencyInjection/ProcessorConfigurationBuilder.php index 2e81cd5..b1064e3 100644 --- a/src/DependencyInjection/ProcessorConfigurationBuilder.php +++ b/src/DependencyInjection/ProcessorConfigurationBuilder.php @@ -57,7 +57,12 @@ public function addTracingProcessorNode(): ArrayNodeDefinition $node ->canBeEnabled() ->children() - ->scalarNode('field_name')->defaultValue('tracing')->info('Context key read by the processor (e.g. $logger->info("msg", ["tracing" => ["trace_id" => "…"]])).')->end() + ->enumNode('mode') + ->values(['default', 'opentelemetry']) + ->defaultValue('default') + ->info('"default" reads a nested array from context[field_name]. "opentelemetry" reads flat trace_id/span_id keys injected by the OTel Monolog processor (field_name is ignored).') + ->end() + ->scalarNode('field_name')->defaultValue('tracing')->info('Context key read by the processor in "default" mode. Ignored in "opentelemetry" mode.')->end() ->end() ->append($this->addHandlersNode()) ->append($this->addChannelsNode()); @@ -170,6 +175,29 @@ public function addHttpRequestProcessorNode(): ArrayNodeDefinition return $node; } + public function addCorrelationIdProcessorNode(): ArrayNodeDefinition + { + $node = (new TreeBuilder('correlation_id'))->getRootNode(); + $node + ->canBeEnabled() + ->info('Injects a correlation ID from Monolog extra into the log context (works with any library that populates extra).') + ->children() + ->scalarNode('field_name') + ->defaultValue('correlation_id') + ->info('Key to read from Monolog extra (e.g. "correlation_id"). Must match the field name configured in the library that populates extra.') + ->end() + ->enumNode('target') + ->values(['labels', 'trace']) + ->defaultValue('labels') + ->info('Where to write the correlation ID: "labels" writes to labels.correlation_id, "trace" writes to trace.id. WARNING: ECS expects trace.id to be a 32-character hex string; ensure your correlation ID generator produces this format when using "trace".') + ->end() + ->end() + ->append($this->addHandlersNode()) + ->append($this->addChannelsNode()); + + return $node; + } + public function addHandlersNode(): NodeDefinition { return (new TreeBuilder('handlers'))->getRootNode() diff --git a/src/DependencyInjection/ProcessorLoader.php b/src/DependencyInjection/ProcessorLoader.php index b3e73be..7c9a2e8 100644 --- a/src/DependencyInjection/ProcessorLoader.php +++ b/src/DependencyInjection/ProcessorLoader.php @@ -5,6 +5,7 @@ namespace Aubes\EcsLoggingBundle\DependencyInjection; use Aubes\EcsLoggingBundle\Logger\AutoLabelProcessor; +use Aubes\EcsLoggingBundle\Logger\CorrelationIdProcessor; use Aubes\EcsLoggingBundle\Logger\ErrorProcessor; use Aubes\EcsLoggingBundle\Logger\HostProcessor; use Aubes\EcsLoggingBundle\Logger\HttpRequestProcessor; @@ -133,6 +134,7 @@ public function registerTracingProcessor(array $config, ContainerBuilder $builde $processor = new Definition(TracingProcessor::class); $processor->setArgument('$fieldName', $processorConfig['field_name']); + $processor->setArgument('$mode', $processorConfig['mode']); $this->configureMonologProcessor($config, $processorConfig, $processor); @@ -199,6 +201,24 @@ private function resolveUserProvider(array $processorConfig): Definition|Referen return $provider; } + /** @param array $config */ + public function registerCorrelationIdProcessor(array $config, ContainerBuilder $builder): void + { + if (!isset($config['processor']['correlation_id']) || !$config['processor']['correlation_id']['enabled']) { + return; + } + + $processorConfig = $config['processor']['correlation_id']; + + $processor = new Definition(CorrelationIdProcessor::class); + $processor->setArgument('$fieldName', $processorConfig['field_name']); + $processor->setArgument('$target', $processorConfig['target']); + + $this->configureMonologProcessor($config, $processorConfig, $processor); + + $builder->setDefinition('.ecs_logging.processor.correlation_id', $processor); + } + /** * @param array $config * @param array $configOverride diff --git a/src/EcsLoggingBundle.php b/src/EcsLoggingBundle.php index ad20467..766dcad 100644 --- a/src/EcsLoggingBundle.php +++ b/src/EcsLoggingBundle.php @@ -75,6 +75,7 @@ private function buildRootNode(ArrayNodeDefinition $rootNode): void ->append($config->addAutoLabelProcessorNode()) ->append($config->addHostProcessorNode()) ->append($config->addHttpRequestProcessorNode()) + ->append($config->addCorrelationIdProcessorNode()) ->end() ->end() ->end() @@ -100,6 +101,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $loader->registerServiceProcessor($config, $builder); $loader->registerTracingProcessor($config, $builder); $loader->registerUserProcessor($config, $builder); + $loader->registerCorrelationIdProcessor($config, $builder); } public function build(ContainerBuilder $container): void diff --git a/src/Logger/CorrelationIdProcessor.php b/src/Logger/CorrelationIdProcessor.php new file mode 100644 index 0000000..35fa021 --- /dev/null +++ b/src/Logger/CorrelationIdProcessor.php @@ -0,0 +1,55 @@ +extra[$this->fieldName] ?? null; + + if (!\is_string($correlationId) || $correlationId === '') { + return $record; + } + + $context = $record->context; + $extra = $record->extra; + + if ($this->target === self::TARGET_TRACE) { + if (!isset($context['trace']['id'])) { + $context['trace'] = \array_merge($context['trace'] ?? [], ['id' => $correlationId]); + } + } else { + if (!isset($context['labels']['correlation_id'])) { + $labels = $context['labels'] ?? []; + + if (!\is_array($labels)) { + $labels = []; + } + + $labels['correlation_id'] = $correlationId; + $context['labels'] = $labels; + } + } + + unset($extra[$this->fieldName]); + + return $record->with(context: $context, extra: $extra); + } +} diff --git a/src/Logger/TracingProcessor.php b/src/Logger/TracingProcessor.php index 685008f..f28fae0 100644 --- a/src/Logger/TracingProcessor.php +++ b/src/Logger/TracingProcessor.php @@ -9,11 +9,24 @@ final class TracingProcessor { - public function __construct(private readonly string $fieldName) - { + public const MODE_DEFAULT = 'default'; + public const MODE_OPENTELEMETRY = 'opentelemetry'; + + public function __construct( + private readonly string $fieldName = 'tracing', + private readonly string $mode = self::MODE_DEFAULT, + ) { } public function __invoke(LogRecord $record): LogRecord + { + return match ($this->mode) { + self::MODE_OPENTELEMETRY => $this->processOpenTelemetry($record), + default => $this->processDefault($record), + }; + } + + private function processDefault(LogRecord $record): LogRecord { $value = $record->context[$this->fieldName] ?? null; @@ -34,4 +47,28 @@ public function __invoke(LogRecord $record): LogRecord return $record->with(context: $context); } + + private function processOpenTelemetry(LogRecord $record): LogRecord + { + $traceId = $record->context['trace_id'] ?? null; + + if (!\is_string($traceId) || $traceId === '') { + return $record; + } + + $context = $record->context; + + if (!isset($context['tracing'])) { + $spanId = $context['span_id'] ?? null; + $context['tracing'] = new Tracing($traceId, \is_string($spanId) ? $spanId : null); + } + + if (!isset($context['span']) && isset($context['span_id']) && \is_string($context['span_id'])) { + $context['span'] = ['id' => $context['span_id']]; + } + + unset($context['trace_id'], $context['span_id'], $context['trace_flags']); + + return $record->with(context: $context); + } }