herdwatch-oss/monolog-ecs-formatter
Composer 安装命令:
composer require herdwatch-oss/monolog-ecs-formatter
包简介
Monolog formatter and Symfony bundle that promotes typed ECS value objects (metrics, labels, text, tags, service, user, tracing, error) to top-level ECS-aligned JSON fields for clean Elasticsearch mapping, with a default copy mode that also keeps the legacy Monolog top-level keys (channel, level_nam
README 文档
README
A Monolog formatter and Symfony bundle that promotes typed ECS value objects from a log's context to top-level ECS-aligned JSON fields, for clean Elasticsearch mapping.
You log structured data as small typed objects — Metrics, Labels, Text, Tags, Service, User, Tracing, EcsError, or any of your own — and the formatter lifts them to the correct ECS location with key validation, per-namespace caps, and a never-drop guarantee. The JSON type of each value is fixed by the object's method signatures, so there is no guesswork or formatter-side coercion.
use Herdwatch\MonologEcsFormatter\Ecs\{Metrics, Labels, Tags, Service, EcsError}; $log->info('Order processed', [ Metrics::create()->total('orders_total', 1200)->gauge('latency_ms', 12.5)->flag('is_retry', false), Labels::create()->add('tenant', 'acme')->add('env', 'prod'), Tags::of('billing', 'reconciliation'), ]);
{
"@timestamp": "2026-06-21T09:14:02.481139+00:00",
"log.level": "info",
"message": "Order processed",
"ecs.version": "8.11.0",
"log": {"logger": "app"},
"event": {"kind": "event", "module": "symfony", "dataset": "symfony.logs", "created": "2026-06-21T09:14:02.481139+00:00", "severity": 200},
"metric": {"orders_total": 1200, "latency_ms": 12.5, "is_retry": false},
"labels": {"tenant": "acme", "env": "prod"},
"tags": ["billing", "reconciliation"]
}
Installation
composer require herdwatch-oss/monolog-ecs-formatter
Register the bundle in config/bundles.php:
Herdwatch\MonologEcsFormatter\MonologEcsFormatterBundle::class => ['all' => true],
Configuration
Create config/packages/monolog_ecs_formatter.yaml:
monolog_ecs_formatter: mode: copy # "copy" (default) or "move" — see Modes below service_name: my-service # optional; enables the EcsIdentityProcessor when set service_version: '%env(APP_VERSION)%' # optional; service.version on every record (requires service_name) service_environment: '%env(APP_ENV)%' # optional; service.environment on every record (requires service_name) ecs_version: '8.11.0' # optional; value advertised in ecs.version (defaults to the ECS schema this formatter targets)
Passing fields
Fields are detected by instanceof, not by array key — so the key is irrelevant. Pass them positionally (cleanest) or under any key; both work, and ordinary context entries flow through untouched:
$log->info('User authenticated', [ new Service('billing-svc', version: '1.4.0', environment: 'prod'), new User(id: 42, email: 'farmer@example.com'), new Tracing($traceId, $transactionId), 'request_id' => $requestId, // plain context — stays under "context", not promoted ]);
Multiple bags of the same kind merge. EcsField objects are detected in both context and extra (context wins on a conflict) — this is how the identity processor's injected Service object gets promoted. Anything that is not an EcsField is left alone: non-field context goes under a leftover context object, and extra is emitted verbatim.
Recommended usage
Pass field objects positionally, without a key. The key is ignored for routing anyway, so a key is redundant and misleading; positional also reads cleanly and lets multiple fields merge:
// ✅ Recommended — positional $log->error('Sync failed', [ new EcsError($e), new Service('billing-svc'), Labels::create()->add('tenant', 'acme'), ]); // ⚠️ Works, but the key is redundant (it is NOT used to route the field) $log->error('Sync failed', ['error' => new EcsError($e)]);
Reserve string keys for the two cases where the key genuinely matters:
- the
exceptionconvention —['exception' => $e]with a raw\Throwable, which the formatter auto-promotes toerror.*; - ordinary scalar context you want to keep or interpolate —
['request_id' => $id].
Governed namespaces (bags)
| Bag | ECS field | Value typing | Cap |
|---|---|---|---|
Metrics |
metric.* |
count()/total() → int, gauge() → float, flag() → bool |
8 keys |
Labels |
labels.* |
scalars coerced to string | 8 keys |
Text |
text.* |
strings | 2 keys |
Tags |
tags |
de-duplicated keyword strings | 8 |
Keys must match /^[a-z][a-z0-9]*(_[a-z][a-z0-9]*){0,2}$/ (lower snake_case, ≤ 3 segments).
Nothing is ever dropped. A key that fails validation or exceeds the cap is demoted into the leftover context with a dotted key — e.g. Labels::create()->add('Bad Key', 'x') ends up as "context": {"labels.Bad Key": "x"}. This is identical in both modes; the mode only governs the legacy base keys (see Modes).
Building a bag from an array
When you already hold an array (config-driven logging, generic middleware), use the fromArray() factories — values still go through the bag's typing/validation:
Metrics::fromArray(['rows_total' => 1200, 'avg_ms' => 8.3]); // non-numeric values are ignored Labels::fromArray($keyValuePairs); Tags::fromArray($list);
Identity fields
| Object | ECS fields |
|---|---|
Service |
service.name, service.language (default php), and optional version, environment, node.name |
User |
user.id, name, email, domain, full_name, hash (null fields omitted) |
Tracing |
trace.id, transaction.id — for logs ↔ APM correlation |
EcsError |
error.type (class), message, stack_trace (includes throw-site file:line + previous-exception chain); error.code only when non-zero |
$log->error('Sync failed', [new EcsError($exception)]);
Standard ECS context fields
Bundled value objects for common runtime / request / host fields, so each service doesn't hand-roll them. All take named constructor arguments and omit null fields.
| Object | ECS fields |
|---|---|
Http |
http.request.{method,body.bytes,mime_type}, http.response.{status_code,body.bytes,mime_type} — request/response nest, so separate Http objects deep-merge |
Process |
process.pid, process.command_line, process.name |
Client |
client.ip, client.port |
UserAgent |
user_agent.original, user_agent.version, user_agent.device.name |
Host |
host.name, host.ip |
Event |
event.action, event.start, event.duration (nanoseconds), event.outcome (the EventOutcome enum: success/failure/unknown) — merged additively onto the base event object; it cannot override event.kind/dataset/etc. |
Url |
url.full, url.scheme, url.domain, url.port, url.path, url.query, url.fragment — pass parts by name, or use Url::parse($url) to split a URL string |
$log->info('Inbound request', [ new Http(statusCode: 200, method: 'POST'), new Client(ip: $request->getClientIp()), Url::parse((string) $request->getUri()), new Event(action: 'api.request', start: $startedAt), ]);
Urlrecords the URL faithfully —url.full/url.querykeep whatever you pass, including any embedded credentials or query tokens/PII. Redaction is the application's job: sanitise the URL before logging it, or strip sensitive fields in a Monolog processor.
Project-specific fields
Any class implementing EcsField is detected automatically — no registration, no formatter change. EcsField extends JsonSerializable; use SerializesToEcs to satisfy it (it maps jsonSerialize() to toEcs()):
use Herdwatch\MonologEcsFormatter\Ecs\EcsField; use Herdwatch\MonologEcsFormatter\Ecs\SerializesToEcs; final class FarmContext implements EcsField { use SerializesToEcs; public function __construct(private string $herdId, private string $region) {} public function toEcs(): array { return ['farm' => ['herd_id' => $this->herdId, 'region' => $this->region]]; } } $log->info('Herd sync complete', [new FarmContext($herd->id(), $herd->region())]); // → { …, "farm": {"herd_id": "…", "region": "…"} }
Rules that keep this safe:
- A fragment targeting a governed namespace (
metric/labels/text/tags) is validated and capped like any bag, whatever produced it. - Unknown namespaces and new top-level fields pass through.
- A fragment can never overwrite the base skeleton (
@timestamp,log.level,message,ecs.version); contributions tolog/event(e.g.log.origin) are merged additively, with the base winning conflicts.
Serialisation under other handlers. Because
EcsFieldisJsonSerializable, the same object also serialises to its ECS data under a non-ECS formatter/handler (a JSON handler emits the bare fragment; a line-based one wraps it under the class name) instead of an empty{}. That path is rawtoEcs()— the validation, caps and never-drop demotion above are applied only byEcsFieldsFormatter, which pulls the objects out before normalising. If a non-ECS handler needs the governed, promoted form, point it atEcsFieldsFormattertoo.
To apply a custom field to every record, inject it from a Monolog processor (the pattern the bundled identity processor uses).
ECS base fields emitted on every record
| Field | Value |
|---|---|
@timestamp |
record datetime, ISO-8601 with microseconds |
log.level |
lowercased level name (dotted top-level key, per the ecs-logging spec) |
message |
log message |
ecs.version |
configurable; defaults to 8.11.0 (the ECS schema this formatter's fields conform to — bump it if you emit fields from a newer ECS version) |
log.logger |
channel name |
event.kind / module / dataset |
event / symfony / symfony.logs |
event.created / severity |
record datetime / Monolog level integer |
Exceptions
A \Throwable at context['exception'] (the Monolog convention) is promoted to error.* by the formatter automatically — no processor or configuration required. So existing $log->error($msg, ['exception' => $e]) call sites get error.{type,message,code,stack_trace} for free. The consumed exception is then removed from the leftover context (in both modes — it now lives, typed, under error.*). An explicit new EcsError($e) always takes precedence.
Service identity processor (service.*)
When service_name is configured, EcsIdentityProcessor is registered as a global Monolog processor and injects a Service object into every record, which the formatter promotes to service.*. Omitting service_name disables it. The processor does not handle exceptions — that is the formatter's job (see above).
The optional service_version and service_environment keys ride along on that injected Service, so every record carries service.version and service.environment (bind them to %env(APP_VERSION)% / %env(APP_ENV)% to track deploys per environment). Both require service_name — setting either without it is a configuration error. A Service set explicitly at the call site still wins (the processor only fills the gap).
Modes
The mode controls one thing only: whether the legacy Monolog top-level keys (channel, level_name, level, datetime) are emitted alongside the ECS fields. Promotion of ECS fields and the never-drop handling of un-promotable entries are identical in both modes.
copy (default)
ECS fields are promoted to top-level and the legacy top-level keys (channel, level_name, level, datetime) are kept. This is the default because the bundle is normally installed into an existing application: dashboards still querying the old keys keep working while you migrate them to the ECS fields (log.logger, log.level, event.severity, @timestamp).
move
The clean ECS-only shape: ECS fields are promoted to top-level, everything else stays under context/extra, and no legacy top-level keys are emitted. Switch to move once nothing depends on the legacy keys — the duplicated base keys in copy cost storage and can muddy your Elasticsearch mapping.
Wiring the formatter in monolog.yaml
monolog: handlers: my_handler: type: stream path: "php://stderr" formatter: Herdwatch\MonologEcsFormatter\Formatter\EcsFieldsFormatter
For service-type handlers wired in services.yaml:
MyApp\Monolog\Handler\MyStreamHandler: calls: - [setFormatter, ['@Herdwatch\MonologEcsFormatter\Formatter\EcsFieldsFormatter']]
Test command
In dev/test environments the bundle registers a console command that emits sample records covering every field type, the governance rules, and a custom EcsField:
bin/console monolog-ecs:test
License
Released under the MIT License. Copyright © Herdwatch.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 0
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-24