observability-instrumentation
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 othe
SKILL.mdplugins/php-backend-sdlc/skills/observability-instrumentation/SKILL.md
GitHubBusiness Metrics Instrumentation
Profile keys consumed
capabilities.observability_emfproject.nameframework.namearchitecture.source_rootarchitecture.shared_contextarchitecture.bounded_contextsmake.testsmake.ciquality.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
// GOOD: add a new metric by creating a new class
final readonly class OrdersPlacedMetric extends EndpointOperationBusinessMetric { ... }
// BAD: modify the existing emitter to handle a new metric type
Why event subscribers (not handler injection)
// BAD: metrics in the command handler (violates SRP)
final class CreateOrderHandler
{
public function __construct(
private OrderRepository $repository,
private BusinessMetricsEmitterInterface $metrics // Wrong!
) {}
}
// GOOD: metrics in a dedicated event subscriber
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)
// <source_root>/<SharedContext>/Application/Observability/Metric/BusinessMetric.php
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)
// <source_root>/<SharedContext>/Application/Observability/Emitter/BusinessMetricsEmitterInterface.php
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.
// <source_root>/<Context>/Application/Metric/OrdersPlacedMetric.php
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|int $value = 1
) {
parent::__construct($value, MetricUnit::COUNT);
}
public function name(): string
{
return 'OrdersPlaced';
}
public function dimensions(): MetricDimensionsInterface
{
return new OrdersPlacedMetricDimensions(
dimensionsFactory: $this->dimensionsFactory,
paymentMethod: $this->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
// <source_root>/<Context>/Application/EventSubscriber/OrderPlacedMetricsSubscriber.php
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()));
}
/**
* @return array<class-string>
*/
public function subscribedTo(): array
{
return [OrderPlacedEvent::class];
}
}
Step 3: For multiple metrics — use MetricCollection
// Emit multiple metrics together (factories injected via constructor)
$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
-- Total endpoint invocations by resource
SELECT SUM(EndpointInvocations)
FROM "<ProjectName>/BusinessMetrics"
GROUP BY Endpoint
-- Orders placed over time
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
BusinessMetricto 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, notOrderPlaced) - 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, $metric->value());
self::assertSame('Order', $metric->dimensions()->values()->get('Endpoint'));
self::assertSame('create', $metric->dimensions()->values()->get('Operation'));
}
// Or use the assertion helper
$metricsSpy->assertEmittedWithDimensions(
'OrdersPlaced',
new MetricDimension('Endpoint', 'Order'),
new MetricDimension('Operation', 'create')
);
}
}
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.cipasses at the profile's protectedquality.*thresholds (raise-only — fix code, never lower thresholds, never add suppression annotations, never editdeptrac.yaml)
SOLID compliance checklist
- SRP: each metric class defines exactly one metric type
- SRP: event subscriber only emits metrics (no business logic)
- OCP: new metrics added via new classes (no emitter changes)
- OCP: new event subscribers added without changing existing code
- LSP: all metrics properly extend the
BusinessMetricbase class - ISP:
MetricDimensionsInterfacestays minimal (onlyvalues()) - DIP: handlers depend on the event bus interface, not concrete
metrics; subscribers depend on
BusinessMetricsEmitterInterface, not the EMF/backend implementation
Type safety checklist
- NO arrays for metric configuration — use typed classes
- NO arrays for metric collections — use
MetricCollection - All dimensions via
MetricDimensionsInterfaceimplementations - Arrays allowed only at infrastructure boundaries (JSON serialization, PSR-3 log context)
- The
MetricUnitenum used for all units