Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ ecs_logging:
## Documentation

- [Configuration reference](docs/configuration-reference.md)
- [Logging a Symfony app in ECS format](docs/symfony-logs.md)
- [Advanced example](docs/advanced-example.md)
- Processors
- [ServiceProcessor](docs/processors/service.md)
- [ErrorProcessor](docs/processors/error.md)
Expand Down
162 changes: 162 additions & 0 deletions docs/advanced-example.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Advanced example

This example shows a Symfony application logging an error during a payment process, with every processor enabled (service, error, user, host, tracing, http_request, auto_label) and OpenTelemetry auto-instrumentation for distributed tracing.

## Prerequisites

- **Symfony 6.4+** with Monolog
- **`symfony/security-bundle`** installed (for `UserProcessor`)
- **[`open-telemetry/opentelemetry-auto-psr3`](https://github.com/opentelemetry-php/contrib-auto-psr3)** installed with `OTEL_PHP_PSR3_MODE=inject`, which injects flat `trace_id`/`span_id` keys into Monolog context. Typically paired with **[`open-telemetry/opentelemetry-auto-symfony`](https://github.com/opentelemetry-php/contrib-auto-symfony)**, which creates the spans. The `TracingProcessor` in `opentelemetry` mode reads these keys and maps them to ECS fields.

## Configuration

### `config/packages/monolog.yaml`

```yaml
monolog:
handlers:
app:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: info
channels: ["app"]
formatter: 'monolog.formatter.ecs'
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: warning
channels: ["!event", "!app"]
formatter: 'monolog.formatter.ecs'
```

### `config/packages/ecs_logging.yaml`

```yaml
ecs_logging:
monolog:
handlers: ['app', 'main']

tags: ['env:prod', 'region:eu-west-1']

processor:
service:
enabled: true
name: 'my-app'
version: '%env(string:APP_VERSION)%'
type: 'payments'

error:
enabled: true
map_exception_key: true # also process context['exception']

user:
enabled: true

host:
enabled: true

tracing:
enabled: true
mode: 'opentelemetry'

http_request:
enabled: true
include_client_ip: true

auto_label:
enabled: true
mode: 'bundle'
move_to_labels: true
include_extra: true # process Monolog extras too
non_scalar_strategy: json # encode arrays/objects instead of dropping
```

## Triggering a log

```php
// src/Service/PaymentService.php
namespace App\Service;

use Psr\Log\LoggerInterface;

class PaymentService
{
public function __construct(private readonly LoggerInterface $logger) {}

public function process(string $orderId): void
{
try {
// ... payment processing logic
throw new \RuntimeException('Card declined by issuer');
} catch (\Throwable $e) {
$this->logger->error('Payment failed', [
'error' => $e,
'order_id' => 'ORD-9876',
]);

throw $e;
}
}
}
```

## Generated log

```json
{
"@timestamp": "2026-03-20T14:32:01.000000+00:00",
"message": "Payment failed",
"ecs.version": "9.3.0",
"log": {
"logger": "app",
"level": "error"
},
"service": {
"name": "my-app",
"version": "1.5.2",
"type": "payments"
},
"error": {
"type": "RuntimeException",
"message": "Card declined by issuer",
"code": 0,
"stack_trace": "RuntimeException: Card declined by issuer in /app/src/Service/PaymentService.php:17\nStack trace:\n#0 /app/src/Controller/CheckoutController.php(34): App\\Service\\PaymentService->process('ORD-9876')\n#1 ..."
},
"trace": {
"id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
},
"transaction": {
"id": "f6e5d4c3b2a1f6e5"
},
"span": {
"id": "9f8e7d6c5b4a3210"
},
"user": {
"name": "john.doe"
},
"host": {
"name": "web-01.example.com",
"ip": ["203.0.113.10"],
"architecture": "x86_64"
},
"url": {
"path": "/api/checkout",
"scheme": "https",
"domain": "example.com"
},
"http": {
"request": {
"method": "POST",
"mime_type": "application/json"
},
"version": "2"
},
"client": {
"ip": "198.51.100.42"
},
"labels": {
"order_id": "ORD-9876"
},
"tags": ["env:prod", "region:eu-west-1"]
}
```
2 changes: 1 addition & 1 deletion docs/processors/auto-label.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The `mode` option defines which context keys are **kept as-is** (the ECS whiteli
| `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.
Always-protected keys (`tracing`, `span`) are preserved regardless of mode.

```yaml
auto_label:
Expand Down
2 changes: 1 addition & 1 deletion docs/processors/tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ ECS output:
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`
- [`open-telemetry/opentelemetry-auto-psr3`](https://github.com/opentelemetry-php/contrib-auto-psr3) with `OTEL_PHP_PSR3_MODE=inject` (typically paired with [`open-telemetry/opentelemetry-auto-symfony`](https://github.com/opentelemetry-php/contrib-auto-symfony), which creates the spans)
- Any OpenTelemetry setup that injects flat tracing keys into Monolog context

### Configuration
Expand Down
58 changes: 58 additions & 0 deletions docs/symfony-logs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Logging a Symfony app in ECS format

Starting point for capturing Symfony's framework logs (routing, security, Doctrine, messenger, uncaught exceptions…) in ECS format without dropping information from the Monolog context.

This is a working baseline, not a finished recipe: adapt handler paths, log levels, channel filters and enabled processors to your own routing / retention / privacy constraints.

## Configuration

### `config/packages/monolog.yaml`

```yaml
monolog:
handlers:
ecs:
type: stream
path: "%kernel.logs_dir%/ecs.log"
level: info
formatter: 'monolog.formatter.ecs'
```

### `config/packages/ecs_logging.yaml`

```yaml
ecs_logging:
monolog:
handlers: ['ecs']

processor:
error:
enabled: true
map_exception_key: true # capture context['exception'] from ErrorListener

auto_label:
enabled: true
mode: 'bundle'
move_to_labels: true # keep non-ECS keys in labels instead of dropping
include_extra: true # process Monolog extras too
non_scalar_strategy: json # encode arrays/objects as JSON strings
```

Add `service`, `host`, `http_request`, `user`, `tracing`… as needed - see the [advanced example](advanced-example.md).

## How context is preserved

| Source | Destination |
|---|---|
| `context['exception']` (framework) or `context['error']` (manual) | `error.type` / `error.message` / `error.code` / `error.stack_trace` |
| Scalar context keys (`route`, `firewall_name`, `message_id`…) | `labels.{key}` |
| Array / object context values | `labels.{key}` as JSON string |
| Monolog `extra` | `labels.{key}` |
| Native ECS keys in context (`user`, `http`, `url`…) | promoted to top-level |

## Trade-offs

- **Message keeps its placeholders** (`Matched route "{route}"`) - the value goes to `labels.route`. You lose the ready-to-read string, you gain a queryable field.
- **Raw `\Throwable` is replaced** by `error.*` fields. Downstream processors that expected the original object won't find it. Full trace remains in `error.stack_trace`.
- **JSON-encoded labels are opaque in Kibana** - with `non_scalar_strategy: json`, arrays/objects are preserved as strings, but not as structured fields. Prefer flat scalar context keys when you need to filter on them.
- **`error.code` is emitted as integer** (inherited from `elastic/ecs-logging-php`). ECS types it as `keyword`; Elasticsearch usually coerces on ingest.
Loading