rasuvaeff / yii3-ab-testing-clickhouse
ClickHouse exposure and conversion trackers for Yii3 A/B testing
Package info
github.com/rasuvaeff/yii3-ab-testing-clickhouse
pkg:composer/rasuvaeff/yii3-ab-testing-clickhouse
Requires
- php: 8.3 - 8.5
- psr/log: ^3.0
- rasuvaeff/clickhouse-toolkit: ^1.6
- rasuvaeff/yii3-ab-testing: ^2.0
- simpod/clickhouse-client: ^0.8
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
- 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
This package is auto-updated.
Last update: 2026-08-01 17:07:28 UTC
README
ClickHouse exposure and conversion trackers for Yii3 A/B testing. Implements the
ExposureTracker and ConversionTracker interfaces from rasuvaeff/yii3-ab-testing,
buffering events in memory and writing them to ClickHouse in batches.
Using an AI coding assistant? llms.txt contains a compact API reference you can ingest in your prompt context. 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.
Requirements
- PHP 8.3+
rasuvaeff/yii3-ab-testing^1.2rasuvaeff/clickhouse-toolkit^1.6- a PSR-18 HTTP client (for example
guzzlehttp/guzzle) for the ClickHouse connection
Installation
composer require rasuvaeff/yii3-ab-testing-clickhouse
Upgrading from 1.x? See UPGRADE.md.
With Yii3 config-plugin this package binds ExposureTracker, ConversionTracker
and ClickHouseTrackingFlushMiddleware automatically. Do not bind the tracker
interfaces from another adapter at the same time or yiisoft/config reports a
Duplicate key error. To send events to several sinks, compose them with the
core CompositeExposureTracker / CompositeConversionTracker.
The DI factory pulls a Rasuvaeff\ClickHouseToolkit\ClickHouseClientFactory
and Psr\Log\LoggerInterface from the container. It uses the factory to build
the batch writers and sends delivery warnings to the logger. Bind both in your
application (Yii applications normally already provide the logger):
use Rasuvaeff\ClickHouseToolkit\ClickHouseClientFactory; use Rasuvaeff\ClickHouseToolkit\ClickHouseConfig; return [ ClickHouseClientFactory::class => static fn (): ClickHouseClientFactory => new ClickHouseClientFactory( new ClickHouseConfig(host: getenv('CLICKHOUSE_HOST') ?: '127.0.0.1', /* ... */), ), ];
Database schema
DDL for the two event tables ships under migrations/ as ClickHouse *.sql
files, applied by the toolkit's ClickHouseMigrationRunner. The table names are
{{exposures_table}} / {{conversions_table}} placeholders, resolved by the
runner before the file is hashed and executed:
use Rasuvaeff\ClickHouseToolkit\ClickHouseMigrationRunner; (new ClickHouseMigrationRunner( client: $clickHouseClient, migrationsPath: __DIR__ . '/vendor/rasuvaeff/yii3-ab-testing-clickhouse/migrations', placeholders: [ 'exposures_table' => 'ab_exposures', 'conversions_table' => 'ab_conversions', ], ))->run();
Wired through rasuvaeff/yii3-clickhouse-toolkit (v1.1+), the same values come
from params. The name is used twice — by the writer and by the migration — so
define it once:
// config/common/params.php $exposures = 'ab_exposures'; $conversions = 'ab_conversions'; return [ 'rasuvaeff/yii3-ab-testing-clickhouse' => [ 'exposuresTable' => $exposures, 'conversionsTable' => $conversions, ], 'rasuvaeff/yii3-clickhouse-toolkit' => [ 'migrationPlaceholders' => [ 'exposures_table' => $exposures, 'conversions_table' => $conversions, ], ], ];
Two params blocks rather than one because yiisoft/config allows exactly one
vendor package to define a given params key: this package cannot contribute to
the toolkit's migrationPlaceholders without a Duplicate key error.
Before v1.1 the params renamed only the writer while the shipped DDL always
created ab_exposures / ab_conversions — configuring them produced a writer
inserting into a table nothing had created.
Renaming after the first apply. The runner's checksum covers the resolved
SQL, so changing a name once the migration has been applied is reported as a
divergence instead of silently creating a second table. Create the new table
yourself (the DDL is in migrations/), or drop the file's row from the
_migrations table, then re-run.
| Table | Columns |
|---|---|
ab_exposures |
experiment, variant, subject_id, is_forced, is_fallback, is_sticky, environment, ts |
ab_conversions |
experiment, variant, subject_id, goal, is_forced, is_fallback, is_sticky, environment, ts |
Both are MergeTree partitioned by toYYYYMM(ts); ts defaults to now().
Retention
Opt-in TTL templates for schema v1 live in retention/. They are deliberately
not migrations: installing or upgrading the package must never start deleting
analytics data. Resolve {{exposures_table}} / {{conversions_table}} and a
positive integer {{retention_days}}, review the SQL, then apply it through
your deployment process. The matching disable_*_ttl.sql templates remove the
policy without dropping existing rows. Changing the number of days is an
ordinary MODIFY TTL operation; ClickHouse applies it asynchronously.
Usage
use Rasuvaeff\ClickHouseToolkit\ClickHouseBatchWriter; use Rasuvaeff\Yii3AbTesting\AbTesting; use Rasuvaeff\Yii3AbTestingClickHouse\ClickHouseConversionTracker; use Rasuvaeff\Yii3AbTestingClickHouse\ClickHouseExposureTracker; $exposure = new ClickHouseExposureTracker( writer: new ClickHouseBatchWriter($client, 'ab_exposures', ClickHouseExposureTracker::COLUMNS), logger: $logger, ); $conversion = new ClickHouseConversionTracker( writer: new ClickHouseBatchWriter($client, 'ab_conversions', ClickHouseConversionTracker::COLUMNS), logger: $logger, ); $ab = new AbTesting( provider: $provider, strategy: $strategy, exposureTracker: $exposure, conversionTracker: $conversion, ); $assignment = $ab->assign(experiment: 'checkout-button', subjectId: (string) $userId); $ab->trackExposure($assignment); // buffered, not sent yet $ab->trackConversion($assignment, goal: 'purchase');
Request-end flushing
Rows are appended to an in-memory buffer. Reaching autoFlushSize (1000 by
default) makes trackExposure() or trackConversion() attempt one batched
network write; otherwise writing happens on flush(). Direct ClickHouse
tracking is therefore a best-effort sink, not a durable queue. The package ships
ClickHouseTrackingFlushMiddleware for the recommended request-end flush:
use Rasuvaeff\Yii3AbTestingClickHouse\ClickHouseTrackingFlushMiddleware; return [ // It must wrap every middleware/handler that can track an event. ClickHouseTrackingFlushMiddleware::class, ];
The middleware wraps the downstream handler in try/finally, flushes both
trackers after the request, and swallows/logs flush failures so analytics never
breaks the user response. Register it outside (before, in pipelines whose first
entry is outermost) all middleware and application code that can track events.
Failed auto-flushes keep their events for retry and emit
Failed to auto-flush ClickHouse A/B testing tracker. To bound worker memory,
the buffer is capped at 10 * autoFlushSize; dropping the oldest events emits
Dropped ClickHouse A/B testing events after repeated flush failures with a
droppedEvents count. Their structured event values are flush_failed and
dropped. Monitor both warnings. Events still buffered when the process exits
are lost.
For metrics and traces, implement TrackingObserverInterface and bind it in DI.
It reports buffered, written, flushFailed, and dropped with the tracker
kind and event counts. Keep labels low-cardinality and never throw from an
observer. The default NullTrackingObserver has no overhead beyond the method
call. The PSR-3 warnings remain available independently.
The buffering code writes through an internal TrackingBatchSinkInterface.
Trackers accept an optional secondarySink for controlled dual-write rollout;
the package DI does not configure one and schema v2 is not shipped or enabled.
If a secondary write fails after the primary succeeded, the retained batch is
retried as a whole, so every target must tolerate at-least-once delivery.
If you do not use a PSR-15 pipeline, call flush() yourself once at request end
or from register_shutdown_function().
API reference
| Class | Description |
|---|---|
ClickHouseExposureTracker |
Buffers exposures; flush() batch-writes to ab_exposures |
ClickHouseConversionTracker |
Buffers conversions (with goal); flush() batch-writes to ab_conversions |
ClickHouseTrackingFlushMiddleware |
PSR-15 middleware that flushes both trackers safely at request end |
TrackingObserverInterface |
Lifecycle metrics/tracing signals for buffered, written, failed, and dropped events |
AnalyticsSchemaV2 |
Column contract producers check against, pinned to the shipped DDL |
SchemaMigrations |
Applies the shipped .sql without hardcoding a vendor path |
Security
- Connection credentials travel via the toolkit's
ClickHouseClientFactory(headers / config from env), never in URLs. The toolkit validates table and column identifiers and uses parameterized inserts. subject_idis stored verbatim and may be personally identifiable. Apply TTL / partition retention per your privacy policy.- Auto-flush and middleware flush failures are swallowed by design. Monitor the delivery and dropped-event warnings if analytics delivery matters operationally; use the outbox adapter when durable delivery is required.
Examples
See examples/ for a runnable script (no server required — uses an in-memory writer).
Development
composer build # full gate: validate + normalize + cs + psalm + test composer cs:fix # auto-fix code style composer psalm # static analysis composer test # run unit tests (integration tests skipped without CLICKHOUSE_HOST) vendor/bin/testo --suite=Integration # requires CLICKHOUSE_HOST; runs live in CI
License
BSD-3-Clause. See LICENSE.md.