firefly/observability is LaraFly's metrics core — the Micrometer/Prometheus-client analogue. It ships a first-party,
pure-PHP MeterRegistry (no ext-prometheus, no OpenTelemetry library), a Prometheus 0.0.4 text exposition and a
Micrometer-JSON /metrics endpoint (both mounted on firefly/actuator), HTTP auto-instrumentation, the real
CqrsMetrics recorder that drops into the M10 seam, a circuit-breaker gauge, process metrics, correlation-id log
enrichment, and a Tracer port (NoOpTracer shipped; the OpenTelemetry adapter lands at SP-7). Gated on one config
flag, secure-by-default-on, zero boot reflection.
MeterRegistry— the read-facing factory port:counter(name, tags),timer(name, tags),gauge(name, tags, callable $supplier),meters(): list<Meter>. Registration is idempotent pertype|name|sorted-tags— repeated calls with the same identity return the same instance.MetricsRecorder— the narrow write-facing port instrumentation actually depends on (increment(),record(),setGauge()), so callers never need the full registry.SimpleMeterRegistry— the shipped in-memory implementation of both ports.Counter(monotonic),Gauge(pull-based, backed by acallable(): floatsupplier — sampled at read time, not write time),Timer(count + total-seconds, exposed as a Prometheus summary — no histogram buckets/percentiles yet).- A metric name has exactly one type, globally: registering the same name under a different
MeterType(e.g.counter('foo')thengauge('foo', ...)) throwsInvalidArgumentException— Prometheus scopes one# TYPEline per name, so a silent type conflict would emit invalid exposition.
/actuator/prometheus(PrometheusEndpoint) — pure-PHP Prometheus text-exposition format 0.0.4. Names/labels are sanitised to the format's grammar; label values and HELP text are escaped; floats render vianumber_format()(neversprintf('%f')) so a comma-decimal locale (e.g.de_DE) can never leak an unscrapeable','into the output./actuator/metrics(+/actuator/metrics/{name}) (MetricsEndpoint) — the Micrometer-JSON shape: no sub-path →{"names": [...]}(sorted, unique); a metric name → its measurements (COUNT/TOTAL_TIME/VALUE) + available tags, or404if unknown.- Both endpoints are
#[Component]s offirefly/actuator'sActuatorEndpointcontract, reachable at{base-path}/{id}once actuator's exposure list includes them (seedocs/modules/actuator.md) — observability ships them, actuator mounts them.
MetricsFilter— a#[Component]WebFilter(discovered byfirefly/web'sFilterChainRegistrar, no web edit) timing every HTTP request ashttp_server_requests_seconds{method,uri,status,outcome,exception}.#[Order(-100)]makes it the outermost discovered filter, so it wraps the whole inner chain. Theuritag is the matched route's template (/orders/{id}), never the raw path (/orders/42) — bounded label cardinality. On a thrown request it recordsoutcome=SERVER_ERROR+ the exception's short class name, then rethrows (no swallow) soProblemDetailsRendererstill renders the error.MeterBindingsPass— aBootPassthat, once aMeterRegistryis bound, registers pull-based gauges:process_resident_memory_bytes/php_memory_peak_bytes, and oneresilience_circuit_breaker_state{name}gauge per configuredfirefly.resilience.circuit-breaker.*instance (closed=0/open=1/half_open=2, read live — a fresh scrape always reflects the breaker's current state, not a snapshot from boot time). It only touchesfirefly/resiliencewhen that package is actually installed (a softbound()guard at runtime, even though the Deptrac edge exists).CorrelationIdLogProcessor— pushed onto the default log channel's real Monolog logger (unwrapped viaIlluminate\Log\Logger::getLogger(), the same idiom Laravel's ownContextLogProcessoruses —LogManager'spushProcessoronly exists via__call()magic, whichmethod_exists()cannot see). Every log record gets the request's correlation id (LaravelContext) inextra, tying logs to the same id CQRS'sCorrelationContextstamps on commands/queries/events.
packages/cqrs/src/Metrics/CqrsMetrics.php was built in M10 as a deliberate extension point: CqrsAutoConfiguration
binds a NoOpCqrsMetrics behind #[ConditionalOnMissingBean(CqrsMetrics::class)], with a docblock promising a real
recorder would drop in later as a bean swap. firefly/observability is that drop-in:
MeterRegistryCqrsMetrics implements CqrsMetrics — records each command/query as a timer:
cqrs_commands_seconds{type, outcome}
cqrs_queries_seconds{type, outcome}
The precedence trick: ObservabilityAutoConfiguration is #[Configuration] #[Order(500)] — deliberately below
CqrsAutoConfiguration's #[Order(1000)] (the same mechanism firefly/security's SecurityAutoConfiguration uses
against the same seam). The incremental condition pass evaluates auto-configs low-#[Order]-first and registers
survivors before the next config is evaluated, so cqrsMetrics() registers first — Cqrs's own
#[ConditionalOnMissingBean(CqrsMetrics::class)] then sees a bean already bound and backs off. No code in
firefly/cqrs changes; the win is pure auto-configuration ordering.
Every observability bean/endpoint/filter gates on the same config property,
#[ConditionalOnProperty('firefly.observability.metrics.enabled', havingValue: 'true', matchIfMissing: true)] — the
meterRegistry() bean, cqrsMetrics(), PrometheusEndpoint, MetricsEndpoint, and MetricsFilter all read it.
This is deliberate, not incidental: a #[ConditionalOnBean(MeterRegistry::class)] on the endpoints/filter/
cqrsMetrics() would evaluate at condition-pass time before ObservabilityAutoConfiguration's own #[Order(500)]
registers MeterRegistry — wrongly dropping every downstream bean/component even when metrics are enabled, purely
because of evaluation order. Gating everything on the identical property instead makes survival order-independent:
flip the one flag, and the MeterRegistry bean, the CqrsMetrics winner, and every consumer either all survive or
all back off together. Disabled: no MeterRegistry, the M10 NoOpCqrsMetrics stays bound, and
/actuator/prometheus + /actuator/metrics are unreachable (404 — never registered, not merely unauthorized).
Tracer { trace(string $name, callable $callback): mixed } — a minimal span port. M12 ships only NoOpTracer
(runs the callback with no span); instrumentation is written against the interface today so an OpenTelemetry-backed
adapter can drop in at SP-7 with zero call-site changes — the same "port now, adapter later" shape as the CqrsMetrics
seam above.
| Key | Default | Meaning |
|---|---|---|
firefly.observability.metrics.enabled |
true |
Master gate. Binds MeterRegistry/MetricsRecorder/PrometheusTextFormat/the real CqrsMetrics, and survives on the endpoints + MetricsFilter. Disabled → NoOpMetricsRecorder, the M10 NoOpCqrsMetrics stays bound, no MeterRegistry, /prometheus+/metrics unmounted. |
firefly.resilience.circuit-breaker.* |
(unset) | Read by MeterBindingsPass (not owned by this package) — one named instance here gets one resilience_circuit_breaker_state{name} gauge. |
| Concern | Plain Laravel | LaraFly (firefly/observability) |
|---|---|---|
| Metrics | no first-party equivalent (typically a 3rd-party Prometheus package + ext-prometheus) |
first-party pure-PHP MeterRegistry + Prometheus 0.0.4 text, no extension |
| HTTP timing | manual middleware | MetricsFilter, auto-discovered, templated-URI tags |
| CQRS metrics | n/a (no first-party CQRS) | MeterRegistryCqrsMetrics — a config-only bean-precedence swap over the M10 NoOpCqrsMetrics |
| Correlation | Context alone |
CorrelationIdLogProcessor ties every log line to the same id CQRS stamps |
| Tracing | none first-party | Tracer port + NoOpTracer; OTel adapter deferred to SP-7 |
- OTLP push / an OpenTelemetry
Traceradapter — SP-7. Today'sNoOpTraceris a real, callable port with zero span overhead; nothing downstream needs to change when the adapter lands. - Histogram buckets / percentiles —
Timeronly exposes as a Prometheus summary (_count/_sum); nohistogram_quantile-friendly buckets yet. - Multiprocess aggregation —
SimpleMeterRegistryis a single-process, in-memory store; under PHP-FPM/Octane with multiple workers, each process/worker exposes only its own counters (no shared-memory or Redis aggregation layer, unlikeprometheus_client's APCu/Redis adapters). - A second Octane management-port listener — deferred alongside
firefly/actuator's own known-latent (no second management port; doesn't fit PHP-FPM). An SP-7 option for Octane deployments.