| name | observability-instrumentation |
| description | Add type-safe business metrics for domain events to a PHP backend โ emitted as AWS EMF (Embedded Metric Format) when `capabilities.observability_emf` is true, or through a generic metrics emitter otherwise. Use when implementing new endpoints, adding command handlers or domain events, or instrumenting business events for dashboards and KPIs. |
Business Metrics Instrumentation
Profile keys consumed
capabilities.observability_emf
project.name
framework.name
architecture.source_root
architecture.shared_context
architecture.bounded_contexts
make.tests
make.ci
quality.infection_msi
All make invocations go through the profile's make target map. A null
value means the capability is absent: skip with a note, never improvise a
raw repo-specific command.
Capability gate
capabilities.observability_emf selects the emission backend; the
application-layer design is identical in both branches:
true โ emit metrics as AWS CloudWatch Embedded Metric Format
(EMF) structured logs. Apply every section of this skill, including
AWS EMF emission.
false โ apply the same typed-metric/event-subscriber
architecture, but implement the emitter against the project's metrics
backend (Prometheus, StatsD, OpenTelemetry, or a structured-log
counter). Skip the EMF format and CloudWatch sections; see
Generic metrics backends.
What this skill covers
- Business metrics โ domain events (users created, orders placed,
payments processed)
- Event subscribers โ metrics emitted via domain event subscribers
(never inside command handlers)
- Type-safe metrics โ concrete metric classes instead of arrays
- SOLID principles โ Single Responsibility (subscribers) +
Open/Closed (new metric classes)
What this skill does NOT cover
- Infrastructure metrics โ latency, error rates, RPS. Managed
runtimes and APM agents typically provide these out of the box; verify
what the deployment platform already emits before duplicating it.
- SLO/SLA metrics โ availability, response times (same reason)
- Distributed tracing โ use the platform's tracing integration
instead
When to use
- Implementing new API endpoints with business significance (see the
api-platform-crud skill)
- Adding domain events that should trigger metric emission
- Tracking domain events for analytics and business intelligence
- Building dashboards for business KPIs
Architecture overview
- Metric classes โ each metric type is a concrete class extending
BusinessMetric
- Event subscribers โ metrics are emitted via domain event
subscribers, not hardcoded in handlers
- Logging stack โ the emitter writes through the framework's
logging stack (
framework.name; e.g. a dedicated Monolog channel
with a custom formatter for Symfony)
- No arrays โ all metric configuration uses typed objects
- Collections โ multiple metrics use
MetricCollection, not arrays
Placement follows the layered architecture (paths below are relative to
architecture.source_root): the metric base classes and the emitter
interface live in the Application layer of
architecture.shared_context (when null, keep them in the bounded
context that first introduces metrics); the concrete emitter lives in
Infrastructure; per-context metrics and subscribers live in the owning
bounded context (one of architecture.bounded_contexts). Fix placement
to satisfy layer rules โ never edit deptrac.yaml or add suppression
annotations to force a fit.
SOLID principles in observability
Single Responsibility Principle (SRP)
Each class has ONE responsibility:
| Class | Responsibility |
|---|
OrdersPlacedMetric | Define metric name, value, dimensions |
OrderPlacedMetricsSubscriber | Listen to event, emit metric |
| Emitter implementation | Format and write metric output |
MetricCollection | Hold multiple metrics for batch emission |
Anti-pattern: metrics emitted directly in command handlers (violates
SRP โ a handler should only handle commands).
Open/Closed Principle (OCP)
- Open for extension: add new metrics via new classes
- Closed for modification: don't change existing metric/emitter code
final readonly class OrdersPlacedMetric extends EndpointOperationBusinessMetric { ... }
Why event subscribers (not handler injection)
final class CreateOrderHandler
{
public function __construct(
private OrderRepository $repository,
private BusinessMetricsEmitterInterface $metrics // Wrong!
) {}
}
final class OrderPlacedMetricsSubscriber implements DomainEventSubscriberInterface
{
public function __invoke(OrderPlacedEvent $event): void
{
$this->metricsEmitter->emit($this->metricFactory->create());
}
}
Benefits:
- Handler focuses on domain logic only
- Metrics emission is decoupled and testable
- Easy to add/remove metrics without touching business logic
- Multiple subscribers can react to the same event
Error handling belongs to the domain-event dispatch machinery (async
workers log failures and emit failure metrics automatically), so
subscribers stay clean and observability never breaks the main request
(AP from the CAP theorem).
Type-safe metric class hierarchy
BusinessMetric (abstract)
โโโ EndpointOperationBusinessMetric (abstract) - metrics with Endpoint/Operation dimensions
โ โโโ <Entity><Action>Metric concrete classes (one per business event)
โ โโโ EndpointInvocationsMetric
โโโ (other base classes for different dimension patterns)
MetricDimensionsInterface
โโโ EndpointOperationMetricDimensions - Endpoint + Operation
โโโ (custom dimensions for specific metrics)
MetricDimensions - typed collection of MetricDimension objects
MetricDimension - key/value pair
MetricUnit (enum)
โโโ COUNT, NONE, SECONDS, MILLISECONDS, BYTES, PERCENT
MetricCollection - typed collection implementing IteratorAggregate, Countable
Why no arrays?
| Arrays | Typed Classes |
|---|
| No type safety | Full type checking |
| No IDE autocomplete | IDE support |
| Runtime errors | Compile-time errors |
| Hard to refactor | Easy to refactor |
| No encapsulation | Validation in constructor |
Base class (Application layer of the shared context)
abstract readonly class BusinessMetric
{
public function __construct(
private float|int $value,
private MetricUnit $unit
) {}
abstract public function name(): string;
abstract public function dimensions(): MetricDimensionsInterface;
public function value(): float|int { return $this->value; }
public function unit(): MetricUnit { return $this->unit; }
}
Emitter interface (Application layer)
interface BusinessMetricsEmitterInterface
{
public function emit(BusinessMetric $metric): void;
public function emitCollection(MetricCollection $metrics): void;
}
The concrete emitter (EMF or otherwise) implements this interface in the
Infrastructure layer โ handlers and subscribers depend only on the
interface.
Creating new business metrics
Step 1: Create the metric class
Place it in the Application layer of the owning bounded context.
final readonly class OrdersPlacedMetricDimensions implements MetricDimensionsInterface
{
public function __construct(
private MetricDimensionsFactoryInterface $dimensionsFactory,
private string $paymentMethod
) {
}
public function values(): MetricDimensions
{
return $this->dimensionsFactory->endpointOperationWith(
'Order',
'create',
new MetricDimension('PaymentMethod', $this->paymentMethod)
);
}
}
final readonly class OrdersPlacedMetric extends BusinessMetric
{
public function __construct(
private MetricDimensionsFactoryInterface $dimensionsFactory,
private string $paymentMethod,
float| =
) {
::(, ::);
}
{
;
}
{
(
dimensionsFactory: ->dimensionsFactory,
paymentMethod: ->paymentMethod
);
}
}
Metrics that only need the standard Endpoint/Operation dimensions
can simply extend EndpointOperationBusinessMetric and return the
endpoint (entity/resource name) and operation (CRUD action) as
constants.
Step 2: Create the event subscriber
final readonly class OrderPlacedMetricsSubscriber implements DomainEventSubscriberInterface
{
public function __construct(
private BusinessMetricsEmitterInterface $metricsEmitter,
private OrdersPlacedMetricFactoryInterface $metricFactory
) {}
public function __invoke(OrderPlacedEvent $event): void
{
$this->metricsEmitter->emit($this->metricFactory->create($event->paymentMethod()));
}
public function subscribedTo(): array
{
return [OrderPlacedEvent::class];
}
}
Step 3: For multiple metrics โ use MetricCollection
$this->metricsEmitter->emitCollection(new MetricCollection(
$this->ordersPlacedMetricFactory->create($event->paymentMethod()),
$this->orderValueMetricFactory->create($event->totalAmount())
));
Reference layout from the canonical upstream profile:
src/Shared/Application/Observability/Metric/BusinessMetric.php # base class
src/Shared/Application/Observability/Metric/MetricUnit.php # unit enum
src/Shared/Application/Observability/Metric/MetricCollection.php # metrics collection
src/Shared/Application/Observability/Emitter/BusinessMetricsEmitterInterface.php
src/Shared/Infrastructure/Observability/AwsEmfBusinessMetricsEmitter.php # EMF implementation
src/Shared/Infrastructure/Observability/EmfLogFormatter.php # Monolog formatter
src/User/Application/Metric/UsersCreatedMetric.php # Endpoint=User, Operation=create
src/User/Application/EventSubscriber/UserCreatedMetricsSubscriber.php
config/packages/monolog.yaml # EMF channel configuration
AWS EMF emission (only when capabilities.observability_emf: true)
AWS Embedded Metric Format embeds custom metrics in structured log
events; CloudWatch automatically extracts metrics from EMF-formatted
logs written to stdout. Derive the namespace from project.name in
PascalCase: <ProjectName>/BusinessMetrics.
EMF log structure
{
"_aws": {
"Timestamp": 1702425600000,
"CloudWatchMetrics": [
{
"Namespace": "<ProjectName>/BusinessMetrics",
"Dimensions": [["Endpoint", "Operation"]],
"Metrics": [{ "Name": "OrdersPlaced", "Unit": "Count" }]
}
]
},
"Endpoint": "Order",
"Operation": "create",
"OrdersPlaced": 1
}
When this log is written via the dedicated logging channel with the EMF
formatter, CloudWatch automatically extracts OrdersPlaced as a metric,
associates it with the namespace, and applies the Endpoint and
Operation dimensions.
See the CloudWatch EMF specification.
Querying after deployment
SELECT SUM(EndpointInvocations)
FROM "<ProjectName>/BusinessMetrics"
GROUP BY Endpoint
SELECT SUM(OrdersPlaced)
FROM "<ProjectName>/BusinessMetrics"
WHERE Endpoint = 'Order'
Canonical upstream reference (managed AWS runtime provides the
infrastructure metrics):
Namespace: UserService/BusinessMetrics # project.name: user-service
Endpoint=User; Operations: create (registration), update (profile), request-password-reset
Infrastructure SLO/SLA metrics come from AWS AppRunner:
https://docs.aws.amazon.com/apprunner/latest/dg/monitor-cw.html
Generic metrics backends (when capabilities.observability_emf: false)
Keep the entire application-layer design โ typed metric classes,
dimensions, MetricCollection, event subscribers, and
BusinessMetricsEmitterInterface โ unchanged. Only the Infrastructure
implementation differs:
- Prometheus/OpenMetrics: map
BusinessMetric to counters/gauges;
dimensions become labels (the same cardinality rules apply).
- StatsD/OTLP: emit one measurement per metric with dimensions as
tags/attributes.
- No metrics backend: write structured log lines (one JSON object
per metric with name, value, unit, dimensions) through the framework's
logging stack so a backend can be attached later without code changes.
Do not bake backend specifics into metric classes or subscribers โ
swapping backends must touch only the emitter implementation and its
service wiring.
Dimension best practices
Recommended dimensions
| Dimension | Description | Cardinality |
|---|
Endpoint | API resource name | Low |
Operation | CRUD action | Very Low |
PaymentMethod | Payment type | Low |
UserType | User segment | Low |
Avoid high-cardinality dimensions
Don't use: user IDs, order IDs, session IDs, timestamps. These
create too many unique metric streams and inflate metrics-backend costs
(CloudWatch billing, Prometheus series explosion).
Metric naming conventions
Format: {Entity}{Action} in PascalCase.
| Good | Bad |
|---|
OrdersPlaced | orders.placed.count |
PaymentsProcessed | payment-processed |
- Use PascalCase for metric names
- Use plural nouns for counters (
OrdersPlaced, not OrderPlaced)
- Use past tense for completed actions
Testing business metrics
Use a spy in tests
final class OrderPlacedMetricsSubscriberTest extends TestCase
{
public function testEmitsMetricOnOrderPlaced(): void
{
$metricsSpy = new BusinessMetricsEmitterSpy();
$dimensionsFactory = new MetricDimensionsFactory();
$metricFactory = new OrdersPlacedMetricFactory($dimensionsFactory);
$subscriber = new OrderPlacedMetricsSubscriber($metricsSpy, $metricFactory);
($subscriber)(new OrderPlacedEvent($orderId, 'card'));
self::assertSame(1, $metricsSpy->count());
foreach ($metricsSpy->emitted() as $metric) {
self::assertSame('OrdersPlaced', $metric->name());
self::assertSame(1, ->());
::(, ->()->()->());
::(, ->()->()->());
}
->(
,
(, ),
(, )
);
}
}
Test service configuration
In the test service configuration (e.g. config/services_test.yaml for
Symfony), alias the emitter interface to the spy and mark the spy
public so tests can inspect it.
Run the suite via the target mapped by make.tests. Subscriber and
metric tests must assert names, values, and dimensions precisely โ
weak assertions leave escaped mutants, and the mutation score must stay
at or above quality.infection_msi (canonical default 100; raise-only,
never lower). For failure debugging see the
testing-workflow skill.
What NOT to track
When the deployment platform or an APM agent already provides
infrastructure metrics (always the case when
capabilities.observability_emf is true โ the managed AWS runtime
supplies SLO/SLA metrics), do not duplicate them.
Don't track: request latency, error rates, response times, HTTP
status codes, memory usage, CPU usage.
Do track: business events (orders placed, accounts created),
business values (order amounts, payment totals), domain-specific
actions (logins, uploads, exports).
Success criteria
- Each domain event that needs tracking has a corresponding metric
subscriber
- Metrics use typed classes (not arrays)
- Metrics are emitted via event subscribers (not hardcoded in handlers)
- Dimensions provide meaningful, low-cardinality segmentation
- Unit tests verify metric emission (names, values, dimensions)
- No infrastructure metrics duplicated from the platform
- The full local suite mapped by
make.ci passes at the profile's
protected quality.* thresholds (raise-only โ fix code, never lower
thresholds, never add suppression annotations, never edit
deptrac.yaml)
SOLID compliance checklist
Type safety checklist