'pk_…', 'serverKey' => getenv('VARLY_SERVER_KEY')]); * $decision = $varly->decide(''); * if ($decision['variant'] === 'b') { … } * * - The config is cached for 30 seconds (APCu when available, otherwise a file in the temp * folder), so most requests make no network call. Pausing a test in Varly reaches your * server within a minute. * - Assignment is the same as in the Varly snippet: the same visitor gets the same version. * - Without the _varly_id cookie (no consent yet, or a new visitor) everyone gets the original * and nothing is recorded. * - Exposures are sent once, at the end of the request, with a short timeout. Errors never * break your page: decide() always returns a decision. * * Keep the server key secret: put it in an environment variable or wp-config.php, never in a * page or JavaScript. */ namespace Varly; final class Client { const BUCKETS = 10000; const VISITOR_COOKIE = '_varly_id'; const BOT_UA = '/(varlyreview|bot|crawler|spider|crawl|slurp|bingpreview|facebookexternalhit|embedly|quora link preview|headless|phantom|puppeteer|playwright|lighthouse|pagespeed|gtmetrix|pingdom|uptime|monitor|curl|wget|python-requests|aiohttp|httpx|axios|go-http-client|java\/|okhttp|scrapy|semrush|ahrefs|screaming frog)/i'; /** @var array */ private $options; /** @var array|null */ private $config = null; /** @var array> */ private $queue = []; /** @var array */ private $seen = []; /** @var bool */ private $shutdownRegistered = false; /** * @param array $options siteKey, serverKey, endpoint (https://cdn.varly.ai), * cacheDir (sys_get_temp_dir()), timeout (seconds, 2), maxStale (seconds, 600), * onError (callable receiving a message). */ public function __construct(array $options) { if (!preg_match('/^pk_[A-Za-z0-9_]{4,64}$/', (string) ($options['siteKey'] ?? ''))) { throw new \InvalidArgumentException('Varly: siteKey must start with pk_'); } if (!preg_match('/^sk_srv_[A-Za-z0-9_-]{32,64}$/', (string) ($options['serverKey'] ?? ''))) { throw new \InvalidArgumentException('Varly: serverKey must be a server key (sk_srv_…)'); } $this->options = $options + [ 'endpoint' => 'https://cdn.varly.ai', 'cacheDir' => sys_get_temp_dir(), 'timeout' => 2, 'maxStale' => 600, 'onError' => null, ]; } // --- assignment (identical to @varly/core) -------------------------------------------- public static function fnv1a(string $input): int { $h = 0x811c9dc5; $n = strlen($input); for ($i = 0; $i < $n; $i++) { $h ^= ord($input[$i]); $h = ($h * 0x01000193) & 0xffffffff; } return $h; } public static function bucket(string $experimentId, string $visitorId): int { return self::fnv1a($experimentId . ':' . $visitorId) % self::BUCKETS; } /** JavaScript's Math.round for positive numbers. */ private static function round(float $x): int { return (int) floor($x + 0.5); } /** @param array $test */ public static function assign(array $test, string $visitorId): ?string { $variants = array_values(array_filter($test['variants'] ?? [], function ($v) { return ($v['weight'] ?? 0) > 0; })); if (count($variants) === 0) { return null; } if (count($variants) === 1) { return $variants[0]['key']; } $total = 0; foreach ($variants as $v) { $total += $v['weight']; } $bucket = self::bucket((string) $test['id'], $visitorId); $acc = 0; foreach ($variants as $v) { $acc += $v['weight']; if ($bucket < self::round(($acc / $total) * self::BUCKETS)) { return $v['key']; } } return $variants[count($variants) - 1]['key']; } public static function deviceFromUserAgent(string $ua): string { if (preg_match('/iPad|Tablet|PlayBook|Silk/i', $ua)) { return 'tablet'; } if (preg_match('/Mobi|Android|iPhone|iPod/i', $ua)) { return 'mobile'; } return 'desktop'; } public static function isVisitorId($id): bool { return is_string($id) && preg_match('/^[A-Za-z0-9_-]{8,64}$/', $id) === 1; } /** * @param array|null $targeting * @param array $ctx device, returning, channel */ public static function inTest(string $experimentId, ?array $targeting, string $visitorId, array $ctx): bool { $t = $targeting ?? []; $device = $ctx['device'] ?? null; if (!empty($t['devices']) && !($device !== null && in_array($device, $t['devices'], true))) { return false; } if (!empty($t['visitor'])) { if (!isset($ctx['returning']) || (($t['visitor'] === 'returning') !== (bool) $ctx['returning'])) { return false; } } $channel = $ctx['channel'] ?? null; if (!empty($t['channels']) && !($channel !== null && in_array($channel, $t['channels'], true))) { return false; } $share = isset($t['share']) && is_numeric($t['share']) ? (float) $t['share'] : 1.0; if (!($share > 0)) { return false; } return $share >= 1 || self::bucket($experimentId . ':traffic', $visitorId) < self::round($share * self::BUCKETS); } // --- decisions ------------------------------------------------------------------------ /** * Which version does this visitor get? Records the exposure unless $track is false. * * @param array $input visitorId, cookies ($_COOKIE), anonymousId, optOut, userAgent * ($_SERVER['HTTP_USER_AGENT']), device, returning, channel, page * @return array{experimentId: string, variant: string, inTest: bool, assigned: ?string, dryRun: bool, reason: string, privacy: bool} */ public function decide(string $experimentId, array $input = [], bool $track = true): array { $out = [ 'experimentId' => $experimentId, 'variant' => 'control', 'inTest' => false, 'assigned' => null, 'dryRun' => false, 'reason' => 'not_running', 'privacy' => false, ]; try { $config = $this->config(); $test = null; foreach ($config['tests'] ?? [] as $t) { if (($t['id'] ?? null) === $experimentId) { $test = $t; } } if ($test === null || count($test['variants'] ?? []) < 2) { return $out; } $who = $this->visitor($input, $config); if ($who === null) { return ['reason' => 'no_visitor_id'] + $out; } $visitor = $who['id']; $ua = array_key_exists('userAgent', $input) ? (string) $input['userAgent'] : (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''); if (trim($ua) === '' || preg_match(self::BOT_UA, $ua)) { return ['reason' => 'bot'] + $out; } $ctx = [ 'device' => $input['device'] ?? self::deviceFromUserAgent($ua), 'channel' => $input['channel'] ?? null, ]; if (array_key_exists('returning', $input)) { $ctx['returning'] = (bool) $input['returning']; } if (!self::inTest($experimentId, $test['targeting'] ?? null, $visitor, $ctx)) { return ['reason' => 'not_targeted'] + $out; } $assigned = self::assign($test, $visitor); if ($assigned === null) { return $out; } $dryRun = ($test['mode'] ?? 'dry_run') !== 'live'; $decision = [ 'experimentId' => $experimentId, 'variant' => $dryRun ? 'control' : $assigned, 'inTest' => true, 'assigned' => $assigned, 'dryRun' => $dryRun, 'reason' => 'in_test', 'privacy' => $who['privacy'], ]; if ($track) { $this->track($decision, $visitor, $ctx['device'], (string) ($input['page'] ?? '')); } return $decision; } catch (\Throwable $e) { $this->error($e->getMessage()); return $out; } } /** Record an exposure later, for example only when the changed part is really shown. */ public function track(array $decision, ?string $visitorId = null, ?string $device = null, string $page = ''): void { $visitor = $visitorId ?? ($this->visitor([], $this->config())['id'] ?? null); if (empty($decision['inTest']) || empty($decision['assigned']) || $visitor === null) { return; } $key = $decision['experimentId'] . ':' . $visitor; if (isset($this->seen[$key]) || $this->recentlySent($key)) { return; } $this->seen[$key] = true; $this->queue[] = array_filter([ 'experiment_id' => $decision['experimentId'], 'variant_key' => $decision['assigned'], 'visitor_id' => $visitor, 'ts' => (int) round(microtime(true) * 1000), 'device' => $device, 'page_key' => $page !== '' ? substr($page, 0, 300) : null, 'privacy_mode' => !empty($decision['privacy']) ? 1 : null, ], function ($v) { return $v !== null; }); if (!$this->shutdownRegistered) { $this->shutdownRegistered = true; register_shutdown_function([$this, 'flush']); } } /** A new visitor ID. Only set it as the _varly_id cookie after the visitor gave consent. */ public static function newVisitorId(): string { $b = random_bytes(16); $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); } /** Set the _varly_id cookie the way the snippet does (one year, whole site). */ public static function setVisitorCookie(string $id): void { if (!self::isVisitorId($id)) { return; } setcookie(self::VISITOR_COOKIE, $id, [ 'expires' => time() + 31536000, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'samesite' => 'Lax', ]); $_COOKIE[self::VISITOR_COOKIE] = $id; } /** Do Not Track or Global Privacy Control on this request. */ public static function optedOut(): bool { return ($_SERVER['HTTP_DNT'] ?? '') === '1' || ($_SERVER['HTTP_SEC_GPC'] ?? '') === '1'; } /** * Who is this visitor? With consent: the _varly_id cookie. Without consent, only when privacy * mode is on for the site: the anonymous ID your page sends along ('anonymousId', from * _varly_anon in local storage), never with Do Not Track or Global Privacy Control. * * @param array $input * @param array $config * @return array{id: string, privacy: bool}|null */ private function visitor(array $input, array $config): ?array { if (array_key_exists('visitorId', $input) && $input['visitorId'] !== null) { $id = self::isVisitorId($input['visitorId']) ? $input['visitorId'] : null; } else { $cookies = $input['cookies'] ?? $_COOKIE; $id = $cookies[self::VISITOR_COOKIE] ?? null; $id = self::isVisitorId($id) ? $id : null; } if ($id !== null) { return ['id' => $id, 'privacy' => false]; } $optOut = !empty($input['optOut']) || self::optedOut(); $anon = $input['anonymousId'] ?? null; if (($config['privacy'] ?? false) === true && !$optOut && self::isVisitorId($anon)) { return ['id' => $anon, 'privacy' => true]; } return null; } // --- config and network --------------------------------------------------------------- /** @return array */ public function config(): array { if ($this->config !== null) { return $this->config; } $cacheKey = 'varly_cfg_' . $this->options['siteKey']; $cached = $this->cacheGet($cacheKey); $age = $cached ? time() - (int) $cached['at'] : PHP_INT_MAX; if ($cached && $age < (int) ($cached['config']['refresh'] ?? 30)) { return $this->config = $cached['config']; } $res = $this->request('GET', '/v1/server-config', null, $cached['etag'] ?? null); if ($res !== null && $res['status'] === 304 && $cached) { $this->cacheSet($cacheKey, ['at' => time()] + $cached); return $this->config = $cached['config']; } if ($res !== null && $res['status'] === 200) { $config = json_decode($res['body'], true); if (is_array($config) && ($config['v'] ?? null) === 1) { $this->cacheSet($cacheKey, ['at' => time(), 'etag' => $res['etag'], 'config' => $config]); return $this->config = $config; } } if ($res !== null) { $this->error('server config: HTTP ' . $res['status']); } // Varly not reachable: keep the last config for a while, then everyone gets the original. if ($cached && $age < (int) $this->options['maxStale']) { return $this->config = $cached['config']; } return $this->config = ['v' => 1, 'tests' => []]; } /** Sends the exposures of this request. Called automatically at the end of the request. */ public function flush(): void { if (count($this->queue) === 0) { return; } $batch = array_splice($this->queue, 0, 500); $res = $this->request('POST', '/v1/exposures', json_encode(['exposures' => $batch])); if ($res === null || $res['status'] >= 400) { $this->error('exposures: ' . ($res === null ? 'no response' : 'HTTP ' . $res['status'])); return; } foreach ($batch as $e) { $this->markSent($e['experiment_id'] . ':' . $e['visitor_id']); } if (count($this->queue) > 0) { $this->flush(); } } /** @return array{status: int, body: string, etag: ?string}|null */ private function request(string $method, string $path, ?string $body, ?string $etag = null): ?array { if (!function_exists('curl_init')) { $this->error('the cURL extension is missing'); return null; } $url = rtrim((string) $this->options['endpoint'], '/') . $path . '?site=' . rawurlencode($this->options['siteKey']); $headers = ['Authorization: Bearer ' . $this->options['serverKey'], 'Accept: application/json']; if ($etag) { $headers[] = 'If-None-Match: ' . $etag; } if ($body !== null) { $headers[] = 'Content-Type: application/json'; } $responseEtag = null; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_CONNECTTIMEOUT => 1, CURLOPT_TIMEOUT => (int) $this->options['timeout'], CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$responseEtag) { if (stripos($line, 'etag:') === 0) { $responseEtag = trim(substr($line, 5)); } return strlen($line); }, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $result = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($result === false) { $this->error('request failed: ' . $error); return null; } return ['status' => $status, 'body' => (string) $result, 'etag' => $responseEtag]; } private function cacheGet(string $key): ?array { if (function_exists('apcu_fetch')) { $ok = false; $v = apcu_fetch($key, $ok); return $ok && is_array($v) ? $v : null; } $file = rtrim((string) $this->options['cacheDir'], '/') . '/' . $key . '.json'; $raw = @file_get_contents($file); $v = $raw === false ? null : json_decode($raw, true); return is_array($v) ? $v : null; } private function cacheSet(string $key, array $value): void { if (function_exists('apcu_store')) { apcu_store($key, $value, 3600); return; } $file = rtrim((string) $this->options['cacheDir'], '/') . '/' . $key . '.json'; @file_put_contents($file . '.tmp', json_encode($value), LOCK_EX); @rename($file . '.tmp', $file); } /** With APCu: each visitor and test is reported at most once per 30 minutes. */ private function recentlySent(string $key): bool { return function_exists('apcu_exists') && apcu_exists('varly_exp_' . md5($key)); } private function markSent(string $key): void { if (function_exists('apcu_store')) { apcu_store('varly_exp_' . md5($key), 1, 1800); } } private function error(string $message): void { $handler = $this->options['onError']; if (is_callable($handler)) { $handler($message); } else { error_log('[varly] ' . $message); } } }