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, distributed tracing (a Spring/OTel-shaped Tracer port, an OpenTelemetry adapter, W3C
traceparent over HTTP, CQRS and EDA), trace-aware structured logging, and histogram buckets on
timers. 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, and the default.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 — or as a histogram with cumulative_bucket{le}lines when its name has buckets infirefly.observability.metrics.distribution.*, see Histograms).CacheMeterRegistry— the cross-process implementation of both ports, bound instead whenfirefly.observability.metrics.storenames a cache store. See Surviving the request below.- 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.
SimpleMeterRegistry keeps every meter in process memory. That is correct for a long-lived worker (Octane,
RoadRunner) and wrong for PHP's usual deployment: under PHP-FPM each request is a fresh process, so by the time a
scrape reaches /actuator/metrics or /actuator/prometheus, the only meters in memory are the ones that scrape's
own request recorded. The endpoints were effectively empty in production — and the numbers they did show were a
single request's, which is worse than empty, because it reads as data.
Naming a cache store swaps in CacheMeterRegistry, which writes through to that store:
increment()andrecord()use the store's atomic increment, so concurrent workers cannot lose writes on a driver that supports it (redis, memcached, apc, dynamodb). Durations accumulate in integer microseconds, becauseincrement()is integer-only and a float read-modify-write would drop samples under concurrency.setGauge()is a plainput(): a gauge is a snapshot, so last-writer-wins is the correct semantic.meters()rehydratesCounter/Timer/Gaugefrom a single index of every meter identity ever written, so it costs one read rather than a key scan — which not every cache driver supports.- Tag order never splits a meter in two; identities sort their tags.
The whole registry is three keys and one nested block in config/firefly.php, printed here at the defaults the
reference ships — store empty is the in-process SimpleMeterRegistry, ttl in seconds with 0 meaning no
expiry, and distribution.buckets empty keeping every timer a summary:
'observability' => [
'metrics' => [
// …
'enabled' => true,
// …
'store' => env('FIREFLY_METRICS_STORE', ''),
// …
'ttl' => (int) env('FIREFLY_METRICS_TTL', 0),
// …
'distribution' => [
// …
'buckets' => [],
],
],It is opt-in rather than the default on purpose: a metrics registry that silently starts writing to whatever
cache an application happens to have configured is a surprise, and on the array driver it would be no better
than memory anyway.
The documented boundary: the factory methods (counter()/timer()/gauge()) still hand back the in-process
meters, and mutating one of those directly stays process-local. Everything the framework itself records goes
through the MetricsRecorder methods, which are the durable path.
DistributionStatisticConfig (Micrometer's name) reads firefly.observability.metrics.distribution.buckets
(upper bounds in seconds, applied to every timer) and distribution.per-meter.<name> (a list for one meter
name; an empty list turns that meter back into a summary). Both registries ask it for the buckets when a
timer is created — bounds are keyed by meter name, never by tag set, because a Prometheus family has one
layout — and CacheMeterRegistry increments one <id>:le:<bound> counter per bucket a sample falls into, so
a cross-process scrape rebuilds the histogram exactly. PrometheusTextFormat then emits:
# TYPE http_server_requests_seconds histogram
http_server_requests_seconds_bucket{method="GET",uri="/orders/{id}",le="0.05"} 12
http_server_requests_seconds_bucket{method="GET",uri="/orders/{id}",le="0.5"} 40
http_server_requests_seconds_bucket{method="GET",uri="/orders/{id}",le="+Inf"} 41
http_server_requests_seconds_count{method="GET",uri="/orders/{id}"} 41
http_server_requests_seconds_sum{method="GET",uri="/orders/{id}"} 9.87
_count and _sum are the same two lines the summary has, so rate(x_sum[5m]) / rate(x_count[5m]) keeps
working the day buckets are switched on; histogram_quantile() becomes possible. Off by default: turning a
family's # TYPE from summary to histogram on upgrade would change a running scrape without being asked.
The Prometheus client default list — [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] — is the one
to start from.
/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+TraceContextLogProcessor— pushed onto each configured log channel's real Monolog logger (see Logging): every record getscorrelation_id,request_idand, with tracing on,trace_id/span_idinextra.TracingFilter— a#[Component]WebFilterat#[Order(-110)], the outermost discovered filter, starting a SERVER span per request when tracing is on (see Tracing).
Auto-instrumentation covers the seams the framework owns. #[Timed], #[Counted] and #[Observed] —
Micrometer's three, ported — cover the method an application owns, on any stereotyped bean, with no recorder
injected and no try/finally written by hand:
#[Service]
class OrderService
{
#[Timed('orders.place', extraTags: ['tier' => 'gold'])]
#[Counted('orders.place.calls')]
public function place(Basket $basket): Order { /* … */ }
#[Timed('orders.import', longTask: true)]
public function importAll(string $path): int { /* … */ }
#[Observed('orders.quote', contextualName: 'quote an order')]
public function quote(Basket $basket): Money { /* … */ }
}| Attribute | What it records | Tags |
|---|---|---|
#[Timed(value, extraTags, longTask)] |
a timer around the call, and the same timer on a throw | class (short name), method, exception (short name, none on success), plus extraTags |
#[Counted(value, extraTags, recordFailuresOnly)] |
one counter increment per invocation | class, method, result = success|failure, exception, plus extraTags |
#[Observed(name, contextualName, lowCardinalityKeyValues)] |
one span and one timer under one name — Micrometer's Observation API in one attribute | class, method, exception on the timer; lowCardinalityKeyValues on both |
value/name empty falls back to the configured default name below. contextualName is the span's name
when the waterfall wants a sentence and the query language wants a meter name. Both targets are allowed, as
Micrometer allows them: a class-level attribute applies to every public instance method the class exposes
and a method-level one of the same kind replaces it for that method — the replacement rule
MethodSecurityScanner applies to #[PreAuthorize].
longTask: true is Micrometer's LongTaskTimer, reduced to the half this registry can honestly publish: a
set-gauge <meter>.active, carrying the timer's own tags, holding the number of invocations this process
has in flight — a depth, not a flag, so a re-entered or recursive long task reads 2 rather than dropping to
0 when the inner call returns. Sampling the duration of a task that has not finished needs a meter type
MeterRegistry does not have, and across processes a gauge is last-writer-wins (see
Known-latent) — read <meter>.active as "this meter has work in flight somewhere".
The advice is the OUTERMOST link of the proxy chain — order 50, ahead of method security's 100 and the transaction's 1000:
#[Timed] ( #[PreAuthorize] ( resilience ( #[Transactional] ( method ) ) ) )
Three consequences, and each one is why the number is 50. A timer measures what the caller waited for:
the expression evaluation and role-hierarchy walk of a refusal, and the BEGIN/COMMIT of a transactional
method, are both time a client would measure from outside. A refusal is still counted, as a failure —
inside security the AccessDeniedException would be thrown before this link ran and the meter would never
see the call, so a permissions misconfiguration would read as falling traffic and a flat error rate, which is
the shape of a silent outage. And a deadlock thrown by COMMIT is attributed to orders.place with
exception=QueryException rather than disappearing after the body has already returned.
This is a deliberate divergence from Micrometer, not parity with it: TimedAspect, CountedAspect and
ObservedAspect are unordered @Aspects, so Spring AOP runs all three innermost — inside Spring Security's
interceptors and level with the transaction advisor — and a @Timed method whose @PreAuthorize denies is,
in Spring, today, neither timed nor counted. The chain the number produces is asserted on the compiled plan
by packages/cli's cached-boot fixture, so the ordering is a test rather than a paragraph.
Telemetry never changes the call. Every recorder and tracer touch goes through its own best-effort guard
(HttpExchangeFilter::record()'s, for the same reason plus one: most of them run in a finally, where a
throw would discard the exception already on its way to the caller). A lost sample is the correct price for
a telemetry failure; a changed return value or a swapped exception never is.
Compiled, not reflected. ObservabilityMethodScanner is the one reflection site this adds, run at
firefly:cache; the rows are baked into the generated proxy as ObservabilityMethodDescriptor::fromArray([…])
literals. It refuses — loudly, where a person can read the message — anything that would compile and then
be honoured by nothing: an attribute on a class nothing post-processes, on a final class or a final method
the class itself declares, written explicitly on a static or a __-prefixed method, #[Timed(percentiles:)]
and #[Timed(description:)] (see Known-latent), and a #[Timed] and #[Counted] on one
method spelling out the same
meter name — a Prometheus name has exactly one type, so the registry would record the first and refuse the
second for the life of the process. #[Timed] and #[Observed] may share a name, both being timers.
| Key | Type | Default | Meaning |
|---|---|---|---|
firefly.observability.method.enabled |
bool | true |
Enforce #[Timed]/#[Counted]/#[Observed]. Off makes them inert — the proxy runs a pass-through link in place of this one — never half-applied. Read live on every call as well as at wrap time. |
firefly.observability.method.timed.name |
string | method.timed |
Meter name for a #[Timed] that names none. |
firefly.observability.method.counted.name |
string | method.counted |
Meter name for a #[Counted] that names none. |
firefly.observability.method.observed.name |
string | method.observed |
Meter and span name for an #[Observed] that names none. |
The advice is compiled either way, so a cached application and a dev application agree on the shape of every
proxy; only the link's behaviour differs. The whole feature is also inert while the metrics master gate
(firefly.observability.metrics.enabled) is off — there is no registry to record into.
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).
Moved to its own page: Tracing — the port, W3C propagation over HTTP/CQRS/EDA, the OpenTelemetry
adapter, and every firefly.observability.tracing.* key.
/actuator/httpexchanges serves the last N requests this application answered, newest first, and
/actuator/process serves the live process numbers (pid, uptime, PHP version/SAPI, memory, OPcache) beside the
request counter the same recorder already keeps. Neither is in the secure-by-default exposure list
(health,info), so reaching either over HTTP means naming it in
firefly.management.endpoints.web.exposure.include.
Recording is done by HttpExchangeFilter — a #[Component] WebFilter discovered by web's
FilterChainRegistrar, #[Order(-100)], #[Lazy], gated on its own
firefly.observability.httpexchanges.enabled rather than on the metrics flag. Each row carries
timestamp/method/uri/status/durationMs/correlationId and, when tracing is on, traceId (the SERVER
span's W3C trace id; omitted otherwise); uri is the route template where a route
matched, and otherwise the raw path with the query string dropped, capped at 256 characters. No request or
response body is ever retained, and there is no flag to enable one. Headers are off by default; switching
include-headers on adds a requestHeaders object whose credential-bearing entries (authorization, cookie,
proxy-authorization, anything matching password|secret|token|key|credential|passwd|authenticate) are replaced
with ****** by HeaderMasker.
HttpExchangeRecorder is a port with the same two implementations, and the same reason for them, as
MeterRegistry: InMemoryHttpExchangeRecorder (default, correct only on a long-lived worker) and
CacheHttpExchangeRecorder. Under PHP-FPM the in-memory buffer is not merely stale but always empty — each
request is a fresh process, and the request rendering the endpoint has not been recorded yet because the filter
records on the way out. The payload therefore reports storage (memory or cache:<store>), processLocal,
recording, capacity, recorded (monotonic, so recorded - count is what the ring has evicted) and count,
so an empty list can be told apart from a broken one. ?limit=N trims the list; a malformed limit is ignored
rather than answered with a 400.
| 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.observability.metrics.store |
'' |
Names a cache store. Empty (or no cache binding) → the in-process SimpleMeterRegistry; a store name → CacheMeterRegistry over cache()->store($name), keyed under firefly:metrics:. |
firefly.observability.metrics.ttl |
0 |
Expiry in seconds for each cache-backed meter. 0 or less means no expiry. Only consulted when store is set. |
firefly.observability.metrics.distribution.buckets |
[] |
Histogram upper bounds in seconds for every timer. Empty = summaries. |
firefly.observability.metrics.distribution.per-meter |
[] |
meter name => list overrides; an empty list makes that meter a summary. |
firefly.observability.method.* |
see Method attributes | The #[Timed]/#[Counted]/#[Observed] gate and the three fallback meter names. |
firefly.observability.tracing.* |
see Tracing | The tracing master gate, exporter, sampler, OTLP and per-instrumentation switches. |
firefly.logging.structured.* |
see Logging | The structured log format and the channels it applies to. |
firefly.observability.httpexchanges.enabled |
true |
Gates HttpExchangeFilter — i.e. whether anything is recorded. Compared as the literal string true by #[ConditionalOnProperty], so 1/on/yes count as OFF. The endpoints stay mounted either way and report "recording": false. Independent of the metrics gate. |
firefly.observability.httpexchanges.capacity |
100 |
Ring size, clamped to [1, 10000]. |
firefly.observability.httpexchanges.store |
'' |
Names a cache store. Empty (or no cache binding) → the process-local InMemoryHttpExchangeRecorder; a store name → CacheHttpExchangeRecorder over cache()->store($name), keyed under firefly:httpexchanges:, which is what makes the buffer non-empty under PHP-FPM. |
firefly.observability.httpexchanges.ttl |
0 |
Expiry in seconds for each cache-backed row. 0 or less means no expiry. Only consulted when store is set. |
firefly.observability.httpexchanges.include-headers |
false |
Adds masked request headers to each row. Bodies are never recorded, with or without this. |
firefly.observability.httpexchanges.exclude |
[<management base path>, <management base path>/*] |
Glob patterns whose requests are not recorded. The default keeps a polling dashboard from evicting real traffic from its own ring; setting it replaces the default rather than adding to it. |
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, OpenTelemetry adapter, W3C traceparent over HTTP/CQRS/EDA — see Tracing |
| Structured logs | a formatter per channel by hand | firefly.logging.structured.format = json/ecs/logstash — see Logging |
- Percentiles / client-side quantiles — timers offer fixed buckets (what Prometheus aggregates across
processes); there is no sliding-window percentile summary.
#[Timed(percentiles:)]is therefore REFUSED at scan time rather than accepted and honoured by nothing, with a message namingfirefly.observability.metrics.distribution.per-meter— the histogram buckets a percentile is actually computed from here. - A meter carries no description.
PrometheusTextFormatsynthesises every# HELPline from the sanitised family name and the family's type (# HELP orders_place orders_place (timer)), and there is no seam from a meter to a help text:MetricsRecorder::record()takes a name, tags and a duration, and a description is family-level metadata a sample-level port cannot carry.#[Timed(description:)]is therefore REFUSED at scan time, on the same rule aspercentilesabove — the message says where the# HELPline comes from and suggests a docblock on the method instead. Micrometer's@Timed(description=)has no equivalent here until the registry itself grows per-family metadata. - OTLP metrics push — spans export over OTLP; metrics are pull-only (
/actuator/prometheus). - Multiprocess aggregation is opt-in, and partial.
firefly.observability.metrics.storegives counters, timers and set-gauges cross-process totals through the cache (see Surviving the request); without it,SimpleMeterRegistryexposes only the calling process's own meters. Two limits remain even with a store: thecounter()/timer()/gauge()factory objects stay process-local, and a pull-based gauge registered byMeterBindingsPassis sampled in whichever process serves the scrape (which is the correct semantic forphp_memory_peak_bytes, and the only possible one for a live circuit-breaker read). - 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.