rasuvaeff / yii3-ab-testing-outbox
Outbox producer for Yii3 A/B testing exposure and conversion events
Package info
github.com/rasuvaeff/yii3-ab-testing-outbox
pkg:composer/rasuvaeff/yii3-ab-testing-outbox
Requires
- php: 8.3 - 8.5
- ext-json: *
- psr/clock: ^1.0
- rasuvaeff/yii3-ab-testing: ^2.0
- rasuvaeff/yii3-outbox: ^1.2
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- guzzlehttp/guzzle: ^7.9
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- rasuvaeff/clickhouse-toolkit: ^1.6
- rasuvaeff/yii3-ab-testing-clickhouse: ^2.0
- rasuvaeff/yii3-outbox-clickhouse: ^1.2
- rasuvaeff/yii3-outbox-db: ^2.0
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
- yiisoft/cache: ^3.2
- yiisoft/db: ^2.0
- yiisoft/db-migration: ^2.0
- yiisoft/db-sqlite: ^2.0
- yiisoft/test-support: ^3.1
Suggests
- rasuvaeff/yii3-ab-testing-clickhouse: Required by AbTestingClickHouseRoutes, which takes its tables and column order from that package; unnecessary if you route messages somewhere other than ClickHouse
This package is auto-updated.
Last update: 2026-08-01 17:20:33 UTC
README
Records rasuvaeff/yii3-ab-testing
exposure and conversion events into rasuvaeff/yii3-outbox
as durable messages. The request path stays fast and survives analytics outages;
a worker exports the outbox asynchronously (e.g. with yii3-outbox-clickhouse).
Using an AI coding assistant? llms.txt has a compact API reference you can use. Projects using the llm/skills Composer plugin also get this package's agent skill synced into
.agents/skills/automatically on install.
Assembling a combination? The family's integration matrix lives in the core:
vendor/rasuvaeff/yii3-ab-testing/docs/integration.md.
Direct sink vs durable pipeline
| Direct | Durable (this package) | |
|---|---|---|
| Package | yii3-ab-testing-clickhouse |
yii3-ab-testing-outbox + yii3-outbox(-db) + yii3-outbox-clickhouse |
| Batching | per request | large, cross-request |
| Survives ClickHouse outage | no | yes |
| Setup | minimal | worker + outbox storage |
Requirements
- PHP 8.3+
rasuvaeff/yii3-ab-testing^1.2rasuvaeff/yii3-outbox^1.0psr/clock^1.0
Installation
composer require rasuvaeff/yii3-ab-testing-outbox
For the complete durable ClickHouse path, also install the DB storage and exporter used by the tested pipeline:
composer require rasuvaeff/yii3-outbox-db rasuvaeff/yii3-outbox-clickhouse
Usage
use Rasuvaeff\Yii3AbTesting\AbTesting; use Rasuvaeff\Yii3AbTestingOutbox\OutboxConversionTracker; use Rasuvaeff\Yii3AbTestingOutbox\OutboxExposureTracker; use Rasuvaeff\Yii3Outbox\Outbox; $outbox = new Outbox(storage: $storage, clock: $clock); // storage from yii3-outbox-db $exposureTracker = new OutboxExposureTracker($outbox); $conversionTracker = new OutboxConversionTracker($outbox); $assignment = $abTesting->assign(experiment: 'checkout', subjectId: $userId); // The facade mints the event and calls the tracker; its identity travels // with it, so a retry of the same domain event stays one row. $exposure = $ab->trackExposure($assignment); // later, on the goal: $ab->trackConversion($assignment, goal: 'purchase', exposure: $exposure);
Payload
ab.exposure / ab.conversion messages carry the canonical schema v2 row,
produced by the core's CanonicalEventSerializer — not assembled here. That is
deliberate: in v1 each delivery path built its own array, they dropped different
fields, and nothing noticed until the data was already wrong.
{"v":2,"event_id":"0198f2c1-4d3a-7c9e-8b21-6f4a2d9e0c17","occurred_at":"2026-08-01 10:00:00.123","experiment":"checkout","variant":"green","subject_id":"user-1","decision_reason":"assigned","assignment_source":"computed","experiment_revision":"db:7","environment":"production","dimensions":"{\"country\":\"RU\"}"}
Conversions add goal and exposure_event_id.
| Field | Note |
|---|---|
v |
schema generation; transport meta, never written to ClickHouse |
event_id |
minted by the core, the deduplication key of the whole pipeline |
occurred_at |
event time, not export time — the partition key derives from it |
decision_reason |
assigned, forced, fallback_disabled, fallback_targeting_mismatch; only assigned counts as participation |
assignment_source |
computed or store (served from a sticky store) |
dimensions |
a JSON string, not a nested object: the exporter rejects non-scalar payload fields |
Analytics dimensions are filtered by the core's AllowListAnalyticsContextPolicy,
configured on the AbTesting facade. It moved there in 2.0 so that every
delivery path applies one allow-list; configured here it filtered the durable
path only.
Payload consumers must branch on v and accept every version advertised during
a rollout — see UPGRADE.md for draining v1 messages queued before
the upgrade.
AbTestingOutboxEventType fixes the two message types (ab.exposure,
ab.conversion); AbTestingOutboxPayload is what the factory hands to
Outbox::record() — the type, the JSON payload and the aggregate id.
Identity and retries
The default PseudonymousAggregateIdStrategy emits stable HMAC-SHA-256 ids such
as exposure:<digest> and never copies raw subject_id into the top-level
outbox column. Inject an application secret to resist offline guessing, or
implement AggregateIdStrategyInterface for a domain-specific grouping policy.
The default config-plugin factory is configured through application params:
'rasuvaeff/yii3-ab-testing-outbox' => [ 'aggregateIdSecret' => $_ENV['AB_AGGREGATE_SECRET'], ],
Analytics dimensions are no longer configured here — the allow-list lives on the
AbTesting facade, so it applies to every delivery path rather than this one.
The event id comes from the event itself: the core mints it, the tracker writes
it into the payload and passes it to Outbox::record(id: ...). The two must
agree, because the exporter may fill the ClickHouse event_id column from
either, and a mismatch splits one event into two rows that never deduplicate.
ClickHouse routing
This package ships a compatible v1 ClickHouse schema in migrations/ and a
matching AbTestingClickHouseRoutes::map(), which takes its tables and column
order from yii3-ab-testing-clickhouse's AnalyticsSchemaV2. Its tables are
ab_outbox_exposures and ab_outbox_conversions, deliberately distinct from
the incompatible direct-sink tables. Two transport-meta columns lead each row:
event_id (filled by the exporter from the message id, for
ReplacingMergeTree dedup) and event_at (event time from the payload).
Apply the DDL with the same names passed to the route map:
use Rasuvaeff\ClickHouseToolkit\ClickHouseMigrationRunner; use Rasuvaeff\Yii3AbTestingOutbox\AbTestingClickHouseRoutes; (new ClickHouseMigrationRunner( client: $clickHouseClient, migrationsPath: __DIR__ . '/vendor/rasuvaeff/yii3-ab-testing-outbox/migrations', placeholders: [ 'outbox_exposures_table' => AbTestingClickHouseRoutes::EXPOSURES_TABLE, 'outbox_conversions_table' => AbTestingClickHouseRoutes::CONVERSIONS_TABLE, ], ))->run(); $router = new MapClickHouseMessageRouter(routes: AbTestingClickHouseRoutes::map());
The tables use ReplacingMergeTree ORDER BY event_id; at-least-once retries of
the same outbox message therefore collapse during ClickHouse merges. They also
store ingested_at separately from the payload's event_at.
Before 1.2.4 the route defaults were ab_exposures / ab_conversions, but this
package supplied no matching DDL and those names collided with the direct sink's
different schema. Existing applications that created their own compatible
tables under the old names can retain them by passing both names explicitly to
map(). Never route outbox rows into the direct package's tables.
Yii3 DI
config/di.php binds ExposureTracker and ConversionTracker. Bind each from a
single source — installing this next to another tracker backend that also
binds them triggers a yiisoft/config Duplicate key error. To use several
sinks at once, compose them in your app config:
use Rasuvaeff\Yii3AbTesting\CompositeExposureTracker; use Rasuvaeff\Yii3AbTesting\ExposureTracker; use Rasuvaeff\Yii3AbTestingOutbox\OutboxExposureTracker; return [ ExposureTracker::class => static fn (Outbox $outbox, LoggerInterface $log): ExposureTracker => new CompositeExposureTracker(new OutboxExposureTracker($outbox), new LoggerExposureTracker($log)), ];
Security
subject_idremains verbatim in the analytics JSON payload and may be PII. Pseudonymous aggregate ids only reduce its footprint in top-level outbox metadata; they do not anonymize the event. Pseudonymize beforeAssignmentwhen the analytics payload itself must not contain the original identifier.- Always inject a private aggregate-id secret in production. The empty-secret default is deterministic and prevents accidental raw-value disclosure, but does not resist dictionary attacks on predictable subject ids.
- Context attributes are denied by default. Use a short allow-list, avoid secrets and high-cardinality values, and redact before the payload reaches outbox storage.
- Payloads are JSON strings written through the outbox;
goal/experimentare trusted analytics dimensions from your application.
Examples
See examples/.
Development
make build make test make test-coverage make mutation vendor/bin/testo --suite=Integration # requires CLICKHOUSE_HOST; runs live in CI
See AGENTS.md for the monorepo-root Docker invocation (path repo).
License
BSD-3-Clause. See LICENSE.md.