herdwatch-oss/monolog-ecs-formatter
Monolog ECS Formatter outputs Elastic Common Schema compliant JSON from PHP and Laravel logs, making them easier to ship to Elasticsearch, Kibana, and other ECS-aware observability tools. A lightweight drop-in formatter for structured, searchable application logging.
Architecture fit. Unchanged: the package sits at the Monolog formatter layer — below Laravel's LogManager, above the handler/transport — and Laravel's formatter + formatter_with keys on single, daily, or stack leaves still make this a config-only drop-in with no application code changes. The Symfony bundle half remains inert in Laravel (no extra.laravel provider), so the deliverable is still "one formatter class plus a config array."
What v0.2.1 changes is the shape of that deliverable. Adding a Network value object and a NetworkDirection closed-set enum moves the package from pure serialization toward a small typed payload API you are expected to construct and hand to the formatter. That is a modest expansion of the surface you must evaluate, fork, and keep current — but it is directionally meaningful: the package is accumulating modelling opinions, not just formatting them. Treat it as "a formatter plus a thin ECS vocabulary layer," and scope your review of future releases accordingly.
Integration feasibility. Still low-to-moderate, and v0.2.1 adds no new class of work — it adds one more field to the enrichment checklist. The formatter still does not enrich; populating service.*, trace.id, user.id, event.dataset, and now network.direction remains your responsibility via request-scoped Log::withContext and an Octane-safe lifecycle. network.direction is only worth emitting where the record actually has a network dimension: inbound from request middleware, outbound from an HTTP-client logging wrapper. Setting it on general application logs is noise. Budget unchanged at 2–5 dev-days for a vertical slice (add half a day if you standardize outbound HTTP-client telemetry), 2–3 weeks end to end.
Technical risk. Prior risks 1–7 stand. v0.2.1 adjusts two and adds three:
FormatterInterface signature against your pinned Monolog 3 LogRecord and cover with a contract test.@timestamp correctness. Unchanged. Confirm derivation from DateTimeImmutable under Octane and an overridden app timezone.labels/tags — and now directly relevant to the change: if you run your index template with dynamic: strict, the new network.direction field will be rejected until the template is updated. A patch-level package bump can therefore produce silent log gaps. Template-before-package is now a hard ordering rule, not a nicety.network.direction should not be added to the copy-mode duplicated-key set; copy mode covers only the legacy keys you are migrating off (channel, level_name, level, datetime).NetworkDirection raises on an unexpected value (a proxy-supplied direction, a typo, a string from context), that exception surfaces inside the request that was only trying to log. Confirm the failure behaviour and keep the WhatFailureGroupHandler + fallback channel guard.NetworkDirection is pinned to ECS 8.11 while the formatter may still emit an older or absent ecs.version. A document declaring one version and using another is worse than one declaring none — consumers cannot trust field semantics.0.2.1), do not float ^0.2, and re-review on every bump.Key questions. Carried forward: Monolog 3 LogRecord signature and PHP range; what log.level receives for critical/alert/emergency; timezone-correct @timestamp; per-field vs. all-or-nothing copy mode; exception normalization into error.*; whether ecs.version is emitted; commit cadence and test coverage.
New for v0.2.1:
Network passed in — first-class formatter argument, or serialized from context/extra? Does the formatter recognize it automatically?NetworkDirection throw, coerce, or drop the field? Where in the write path does that happen?ecs.version to 8.11, or only the enum's vocabulary?Network immutable, and does it carry more than direction (any source.*/destination.*/url.*)?network.direction and any sibling fields documented with an explicit mapping (keyword, expected low cardinality)?source.ip/destination.ip? That moves the package into PII territory and needs a retention decision before adoption.Stack fit. Unchanged: Laravel 10/11, PHP 8.1+, Monolog 3, a dedicated ecs channel composed into a stack alongside the legacy channel during cutover, NDJSON to stdout → collector → Elasticsearch. On OpenSearch, verify the ECS 8.11 field subset you actually use rather than assuming compatibility.
Migration path. Copy mode remains the bridge, not the destination, and the template still lands before the first document. Insert the new field into the sequence:
ecs channel via formatter + formatter_with; ship to a non-production index only.@timestamp, log.level, service.*, trace.id, labels (flattened), tags (keyword), and now network.direction (keyword) plus ILM. If you adopted dynamic: strict, this step gates the upgrade.stack → [daily legacy, ecs] for N weeks; reconcile with a count-and-field-sample diff job.Compatibility. Pin exactly (0.2.1), pin Monolog, run composer audit, and confirm no symfony/* or psr/log collisions. Skip registering the Symfony bundle. Keep mirroring the repo into your VCS or private Packagist under a branch alias — with a single small upstream, an archive or delete must not be able to break your build. Add network.direction to your field-convention doc so outbound HTTP telemetry is instrumented once, centrally, rather than ad hoc per client wrapper.
Sequencing. Week 1: spike plus contract tests covering one record per level, an exception record, a non-UTF8 context record, and a record carrying Network. Week 2: index template (including network.direction), ILM, dual-write in staging. Weeks 3–4: production dual-write and dashboard port. Week 5+: retire copy mode. Do not begin with production traffic; the failure mode throws inside the request path.
Maintenance. Still effectively fork-and-own, with a slightly better signal: one external contributor and an active version line means upstream is alive enough to matter and volatile enough to require attention. Budget 1–2 days per quarter for upstream review — unchanged — and treat every 0.x bump as a re-review, not a patch. Keep a thin internal wrapper (subclass or small factory) so the escape hatch to a homegrown JsonFormatter extension stays a one-file change. That wrapper should also shield your call sites from Network/NetworkDirection API churn, which is now a live concern.
Support. No issue history, no community corpus, no vendor. Your runbook stays yours: "logs malformed," "logs missing," "documents rejected." Add the known-good sample document to CI as a schema contract test and regenerate it with each package bump — v0.2.1 is exactly the kind of release that silently changes the wire format.
Scaling. Unchanged: per-record cost is one json_encode plus normalization, dominated by I/O. Copy-mode index growth and labels cardinality remain the two real pressures. network.direction is a closed-set low-cardinality keyword — negligible index cost, no cardinality concern, provided it is mapped as keyword and never fed arbitrary user strings. Buffered handler with flush on terminate; explicit flush interval on queue workers where terminate is not guaranteed.
Failure modes.
WhatFailureGroupHandler plus a fallback channel.dynamic: strict template not updated for network.direction → rejected documents, silent log gaps, found during an incident.labels or tags → rejected documents, silent gaps.@timestamp → out-of-order indices, ILM rollover surprises, broken correlation.ecs.version declaring a version the document does not conform to → consumer trust erosion.user.* and — if the network model grows to addresses — source.*/destination.*. Set retention on those fields explicitly rather than inheriting it from index ILM.Ramp-up. Unchanged for an individual developer: config plus one page of convention docs, now including a line on when to set network.direction. Moderate for the team, because the hard work is the index template, cross-service field-naming conventions, and dashboard migration. One engineer ~2 days to be productive with the package, ~2–3 weeks to land the platform slice end to end.
How can I help you explore Laravel packages today?