# Statistics: event collection and evaluation Reference for how statistics are collected, what is stored, and how to read or extend it. Describes the behaviour as implemented — not a plan. Evaluation screens for these metrics do not exist yet; this is what they will be built on. The governing rule, from which the rest follows: > **Columns are for what you `GROUP BY`. The payload is for what you read back.** > > A value that will end up in a `GROUP BY` needs its own indexed column. Everything else — > old and new values, reasons, scopes, flags — lives in the JSON `payload`. That split is > what lets a new metric ship without a migration, and it is the only rule that has to hold > as the table grows. Two corollaries that surprise people: - **Rows are never updated or deleted.** A statistics event is a record of something that happened, not a view of current state. If the answer can be recomputed from live data at any time, it does not belong here — see *What does not belong* below. - **Nothing here is a foreign key.** `assignment_id` and friends are plain integers, so statistics outlive the rows they describe. Applications are hard deleted; their history is not. --- ## Why the table exists Three of the four metrics could not be answered from live data: - `Assignment::$jobProfile` holds only the current value. Every edit destroyed the previous one permanently. - Applications are hard deleted (no soft delete), so any count over them silently shrank over time. - Call-offs persisted, but not *when* they happened — `updatedAt` is overwritten by any later edit — nor at what scope. The `log` table is not an alternative. It is a monolog dump (`App\Logging\DatabaseHandler`) with free-form `context`/`extra`, excluded from migrations by `schema_filter: ~^(?!log)~` in `config/packages/doctrine.yaml`. It is an audit trail for humans to read, not a schema to aggregate over. --- ## The table `statistics_event`, created by `migrations/Version20260826000000.php`, mapped by `src/Entity/StatisticsEvent.php`. | Column | Type | Purpose | |--------|------|---------| | `id` | int, PK | | | `name` | varchar(64) | the metric, `enumType: StatisticsEventName` | | `occurred_at` | datetime | when it happened | | `actor_id` | int, null | `User::getId()` of whoever acted | | `actor_label` | varchar(180), null | their `getUserIdentifier()` | | `actor_role` | varchar(32), null | `enumType: StatisticsActorRole` | | `assignment_id` | int, null | dimension | | `disposition_id` | int, null | dimension | | `application_id` | int, null | dimension | | `teamer_id` | int, null | dimension | | `destination_id` | int, null | dimension | | `job_profile_id` | int, null | dimension | | `hotel_code` | varchar(8), null | **normalized** code, frozen at write time | | `payload` | json | everything metric-specific | Indexed on `(name, occurred_at)`, `(name, hotel_code)`, `(assignment_id)`, `(teamer_id)`. ### Two traps in the columns **`hotel_code` is normalized here and raw in `feedback`.** This table stores `ALB`; `feedback.hotel_code` stores `SERALB` and normalizes at query time. Same column name, opposite conventions — comparing or joining them directly returns nothing, silently. The `SER`-stripping rule lives in `Destination::getHotelCodeNormalized()` (`src/Entity/Destination.php`), and is reimplemented in DQL in `DispositionRepository` and in raw SQL in `FeedbackRepository`. Reuse the entity method in PHP; do not add a fourth copy. **Dimensions are frozen at write time, deliberately.** A later correction to a destination's hotel code does not rewrite past periods. This is the same pattern `Feedback::fromAssignment()` (`src/Entity/Feedback.php`) already uses. --- ## The four metrics Defined in `src/Enum/StatisticsEventName.php`. Adding a case here plus one collector that records it is the entire cost of a new metric. | Name | Recorded when | Payload | |------|---------------|---------| | `assignment.job_profile_changed` | the job profile column changes on any write path | `from`, `to` (each `{id, name}` or null) | | `assignment.called_off` | an assignment's status becomes `called_off` | `dispositions_affected`, `previous_status` | | `disposition.called_off` | a disposition's status becomes `called_off` | `called_off_by`, `scope`, `reason`, `previous_status` | | `application.created` | an application is persisted, by any path | — | Which dimensions each one fills: | Metric | assignment | destination | job_profile | hotel_code | teamer | disposition | application | |--------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| | `assignment.job_profile_changed` | ✓ | ✓ | ✓ | ✓ | — | — | — | | `assignment.called_off` | ✓ | ✓ | ✓ | ✓ | — | — | — | | `disposition.called_off` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | | `application.created` | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ### Call-offs: three different "who" and "how far" This is the part most likely to be misread when building a chart. - **`actor_role`** — who *clicked*, from the security token. Always `admin` for a call-off; only administrative users can perform one. - **`payload.called_off_by`** — whose *decision* it was, `teamer` or `office`, as declared by an admin on the call-off form (`src/Form/DispositionCallOffType.php`). There is no teamer-initiated call-off route; a teamer's decision is recorded on their behalf. - **`payload.scope`** — how far it *reached*, `assignment` or `disposition` (`src/Enum/CallOffScope.php`). The office can cancel one placement just as a whole trip can fall away, so `called_off_by` does not imply this. Cancelling a trip cascades onto every disposition on it in one flush, so **one office decision produces one `assignment.called_off` row plus N `disposition.called_off` rows**. Counting the disposition rows therefore counts *affected placements*, not decisions. To count decisions: ```sql -- decisions SELECT COUNT(*) FROM statistics_event WHERE name = 'assignment.called_off'; SELECT COUNT(*) FROM statistics_event WHERE name = 'disposition.called_off' AND JSON_UNQUOTE(JSON_EXTRACT(payload,'$.scope')) = 'disposition'; -- affected placements SELECT COUNT(*) FROM statistics_event WHERE name = 'disposition.called_off'; ``` `assignment.called_off` carries `dispositions_affected` so a cancelled trip's cost reads off a single row. It is frequently `0` — a trip cancelled before anyone was staffed is still a decision worth counting. --- ## How events are recorded One mechanism, writing through `App\Service\Statistics\StatisticsRecorder`. **Doctrine changesets** — `src/EventListener/StatisticsChangeSetListener.php`, on `onFlush` and `postFlush`. Used for state changes. `onFlush` is the only place the *previous* value still exists, and it catches every write path — edit form, duplication, imports, console — rather than the one route someone remembered to hook. Recording happens in `postFlush`, after commit, so a rolled-back flush leaves no phantom statistics: the batch belongs to one flush, is cleared at the start of the next, and goes away with the service reset that a worker does between messages. The listener itself knows nothing about any metric. It owns only the timing, and delegates to **collectors** — implementations of `App\Service\Statistics\Collector\StatisticsCollectorInterface`, autoconfigured by interface so a new one needs no wiring and cannot be silently forgotten. A collector receives the whole flush (`StatisticsFlush`), not one entity at a time. That is deliberate: `CallOffCollector` has to see which *assignments* are becoming `called_off` before it can tell a cascaded disposition from a teamer dropping out, and it counts the cascade to fill `dispositions_affected`. Neither is expressible from a single entity's changeset, and that distinction exists only inside the flush. **Group collectors by concern, not by metric.** Both call-off metrics live in one collector because they can only be told apart together; a job profile change shares nothing with them and lives in its own. Order between collectors is irrelevant and nothing may depend on it. Collectors see **insertions** as well as updates, so a metric about something being created is a collector too. `ApplicationCreatedCollector` reads `insertionsOf(Application::class)` rather than subscribing to `ApplicationCreatedEvent`, which is what an earlier version did. Reading the flush counts every path that persists an application; subscribing counted only the paths that remember to dispatch, and would have silently missed an admin applying on someone's behalf, an import, or a fixture. A newly inserted entity has no id during `onFlush`. That is fine — Doctrine assigns it during the commit, and recording happens afterwards in `postFlush`. > **There is deliberately only one mechanism.** If a future metric is not a persisted state > change at all — an email sent, a login, a document downloaded — call `StatisticsRecorder` > directly from wherever that happens. Do not reintroduce a second listener layer for it. **Writes go through raw DBAL**, not the ORM, so recording never touches the UnitOfWork — a listener running inside a flush would otherwise trigger a nested flush. Failures are caught and reported to the audit logger: statistics are a side effect and must never break the business action that produced them. --- ## Reading the data `src/Repository/StatisticsEventRepository.php`. ```php public function countGroupedBy( StatisticsEventName $name, string $dimension, // whitelisted ?\DateTimeImmutable $dateFrom = null, ?\DateTimeImmutable $dateTo = null, StatisticsDateBasis $dateBasis = StatisticsDateBasis::SEASON, ): array ``` `getCountGroupedByQuery()` returns the same query unexecuted, which is how its semantics are pinned in tests without a database. ### A period means the season, not when the row was written `StatisticsDateBasis::SEASON` (the default) filters on the assignment's dates, falling back to the destination's — the translation of `Assignment::getEffectivePeriod()` into DQL, which lives in `src/Repository/Filter/SeasonPeriodFilter.php` and is shared with `DispositionRepository`. This matches every other statistics screen and `StatisticsFilterType`. Change what a season means there, once, not per screen. `StatisticsDateBasis::OCCURRENCE` filters `occurred_at` instead. Reach for it deliberately, for genuine time series such as call-offs per month. The two routinely disagree, because an application for the coming winter arrives months before the season it belongs to. Picking the wrong one does not fail — it quietly answers a different question from the screen next to it. `SEASON` reaches the assignment through an arbitrary join and therefore excludes events with no `assignment_id`. All four metrics set it. ### Crossing with live data The plain-integer dimension columns cost nothing in queryability — Doctrine's arbitrary join works: ``` SELECT destination.hotel AS hotel, COUNT(event.id) AS applications FROM App\Entity\StatisticsEvent event JOIN App\Entity\Assignment assignment WITH assignment.id = event.assignmentId JOIN assignment.destination destination WHERE event.name = :name GROUP BY hotel ``` Group by `hotel_code`, though, not by the hotel *name*: they are not one-to-one. One normalized code can span several destinations with different names. --- ## Backfilling `bin/console app:statistics:backfill` seeds what current data still contains. Idempotent — it skips subjects already recorded — and supports `--dry-run`. It is never run automatically: nothing in `CronCommand`, the scheduler, or any deploy script invokes it. It writes only to `statistics_event`, so it is safe to run against production at any time. | Metric | Backfillable | `occurred_at` from | |--------|--------------|--------------------| | `application.created` | yes | `createdAt` — exact | | `disposition.called_off` | yes | `updatedAt` — **approximate** | | `assignment.called_off` | yes | `updatedAt` — **approximate** | | `assignment.job_profile_changed` | **no** | history does not exist | Every seeded row carries `"backfilled": true`; the approximate ones also carry `"approximate_date": true`. So the choice is per chart, never table-wide: ```sql WHERE JSON_EXTRACT(payload,'$.backfilled') IS NULL -- organically recorded only ``` Three limits on seeded rows, all of which matter when charting: - **Dates from `updatedAt` are a proxy**, since any later edit overwrites it. Exclude those rows from time series; keep them for totals. - **`actor_role` is `system`.** The command runs on the CLI with no security token, so the original actor is not recoverable. - **Frozen dimensions are frozen at *backfill* time**, not at the time of the event, and `scope` is left unset because it cannot be established retroactively. --- ## Extending it The cost depends entirely on whether the new metric needs a new *dimension* or just a new *name*. Almost all need only a name. **No migration — a new kind of event.** Add a case to `StatisticsEventName`, then one `StatisticsCollectorInterface` implementation that records it. Metric-specific detail goes in `payload`. `StatisticsChangeSetListener` is never edited. This covers most requests — application rejections, contract upload delays, feedback submitted, any status transition, anything created or deleted. Reuse an existing collector when the new metric is part of a concern already covered — especially when it needs to see the same entities in the same flush. Write a new one when it is genuinely independent. **One migration — a new dimension to slice by.** Only when you need to group by something no column carries (a season, a country, a fee band). Add the column and an index, fill it in `StatisticsRecorder`, and accept `NULL` on older rows. Resist when the dimension is already reachable through a join. **Migration plus backfill — history for something not yet recorded.** Extend `StatisticsBackfillCommand` if current data still holds the answer. If it has been overwritten, as with job profiles, the metric simply starts the day it ships. --- ## What does not belong here **Anything recomputable from live data that cannot be lost.** Feedback statistics (`src/Controller/Administrative/Statistics/`) are the worked example: `Feedback` already freezes its own dimensions at creation, is never deleted, and its aggregations average a per-question `ratings` array. It is already an append-only record; copying it into `statistics_event` would duplicate every row and lose the structure the charts need. This table counts occurrences — it has no shape for averaging structured arrays. **Current state.** "How many applications are open right now" is a question for the `application` table. This table answers "how many were ever received", which is a different and equally valid question — just not the same one. **An audit trail for humans.** That is the `log` table and `/admin/log`. --- ## Where things live | Concern | Path | |---------|------| | Entity | `src/Entity/StatisticsEvent.php` | | Repository | `src/Repository/StatisticsEventRepository.php` | | Enums | `src/Enum/Statistics*.php` | | Write path | `src/Service/Statistics/StatisticsRecorder.php` | | Flush orchestration | `src/EventListener/StatisticsChangeSetListener.php` | | Collectors (one per concern) | `src/Service/Statistics/Collector/` | | Flush context passed to collectors | `src/Service/Statistics/StatisticsFlush.php` | | What a period means, shared with the disposition statistics | `src/Repository/Filter/SeasonPeriodFilter.php` | | Backfill | `src/Command/StatisticsBackfillCommand.php` | | Migration | `migrations/Version20260826000000.php` | | Tests | `tests/EventListener/`, `tests/Repository/`, `tests/Service/Statistics/` | Existing statistics screens, for the filter/handler pattern a future evaluation UI should reuse: `src/Controller/Administrative/Statistics/`, `src/Service/Common/StatisticsFilterHandler.php`, `src/Model/StatisticsFilterDto.php`, `src/Form/StatisticsFilterType.php`.