Привет, хабровчане! На связи Алиса — тимлид в e-commerce агентстве KISLOROD. Мы ежедневно имеем дело с большими каталогами, сложной коммерцией и 1С, которая дышит в затылок. И однажды мы решили сделать невозможное: подружить Битрикс с Laravel...
В первой части мы доказали, что Laravel и Битрикс могут жить вместе, как кот и пылесос — с уважением к личному пространству. Во второй — выносим бизнес-логику, не ломая 1С. Рассказываю, как устроить единый вход без шаринга сессий, ускорить каталог с OpenSearch, внедрить outbox-публикации и навести порядок в наблюдаемости.
Шаг А. База: один вход, два бэкенда
Пользователю все равно, сколько у вас бэкендов и какой там API Gateway. Он просто хочет залогиниться один раз — и спокойно работать. Без дополнительных форм, без двойной авторизации, без подвисаний между фронтом и админкой. Идея простая: вход единый, контур общий, работа — бесшовная.
Чтобы это работало, мы делаем Laravel центром аутентификации. Он выпускает токен по JWT с подписью RS256 и кладет его в защищенную HttpOnly-cookie btjwt на общий домен .example.ru. После этого весь остальной бэкенд просто проверяет подпись токена по публичному ключу, доступному на /.well-known/jwks.json. Нет необходимости делиться секретами между сервисами — у каждого есть ключ, Laravel остается единственным источником доверия.
Такой подход дает устойчивость к сбоям: если токен невалиден — Laravel просто отклоняет запрос. Если cookie пропала — Битрикс продолжает работать с обычными сессиями. Даже если JWKS временно недоступен — используем кэш или отпускаем пользователя как анонима. При этом вся логика обернута аккуратно: ничего не ломается, UX не портится.
Предвкушая ехидные комментарии, сразу отвечу, зачем RS256, а не HMAC. Потому что секрет остается у Laravel, а проверка работает по открытому ключу. Безопаснее, проще и не требует лишних договоренностей.
Контракты простые, прозрачные и пригодные для автогенерации клиентского SDK:
Метод | URL | Назначение |
POST | /auth/login | Логин пользователя, выдача btjwt |
POST | /auth/logout | Отзыв токена и завершение сессии |
GET | /auth/me | Проверка текущего пользователя |
GET | /.well-known/jwks.json | Публичные ключи для проверки JWT |
С маршрутами разобрались — тут все лаконично. Но чтобы система не обернулась дырой в безопасности или лотереей с куками, важно держать в голове базовые параметры и договоренности. Эта таблица с тем, что должно быть задано и проверено:
Параметр | Значение |
Cookie | btjwt, HttpOnly, Secure, SameSite=Lax, TTL 10–15 минут, домен .example.ru |
Алгоритм | RS256, с ротацией ключей по kid, несколько ключей одновременно в JWKS |
JWT claims | iss, aud, sub, email, name, roles, iat, exp, jti, amr |
JWKS | JSON Web Key Set на /.well-known/jwks.json, кэшируется, проверяется по kid |
Параметры мы подобрали не от балды, а чтобы сбалансировать безопасность и удобство:
10–15 минут жизни токена — хватает, чтобы не плодить фантомные сессии, но и не мучить пользователя постоянными логинами. Если юзер активен — обновим.
Безопасность как в аптеке: HttpOnly, Secure, SameSite=Lax, cookie на поддомен .example.ru, проверка iss, aud, exp, kid.
Ключи можно ротировать через kid. Поддерживается сразу несколько для безболезненного перехода и обновлений.
Скрытый текст
Подготовка ключа и конфиг
Генерация ключа:
# приватный ключ openssl genrsa -out storage/jwt/jwt_rsa.pem 4096 # публичный ключ openssl rsa -in storage/jwt/jwt_rsa.pem -pubout -out storage/jwt/jwt_rsa.pub chmod 600 storage/jwt/jwt_rsa.*
.env в Laravel:
APP_URL=https://api.example.ru APP_KEY_ID=kid-1 JWT_COOKIE_DOMAIN=.example.ru JWT_TTL_MIN=15 Nginx на edge: # пробрасываем X-Request-Id и не трогаем HttpOnly-cookie add_header X-Request-Id $request_id; location /api/ { proxy_pass http://laravel_upstream; proxy_set_header Host $host; proxy_set_header X-Request-Id $request_id; }
Контроллер JWKS
<?php namespace App\Http\Controllers; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; final class JwksController extends Controller { public function show(): JsonResponse { $pem = file_get_contents(storage_path('jwt/jwt_rsa.pub')); $details = openssl_pkey_get_details(openssl_pkey_get_public($pem)); $n = rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '='); $e = rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '='); return response()->json([ 'keys' => [[ 'kty' => 'RSA', 'alg' => 'RS256', 'use' => 'sig', 'kid' => config('app.key_id', env('APP_KEY_ID', 'kid-1')), 'n' => $n, 'e' => $e, ]], ], Response::HTTP_OK, ['Content-Type' => 'application/json']); } } Маршрут: // routes/web.php use App\Http\Controllers\JwksController; Route::get('/.well-known/jwks.json', [JwksController::class, 'show']);
Контроллер авторизации
<?php namespace App\Http\Controllers; use Firebase\JWT\JWT; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; final class AuthController extends Controller { public function login(Request $request): JsonResponse { $credentials = $request->validate([ 'email' => ['required', 'email'], 'password' => ['required', 'string'], ]); if (! Auth::attempt($credentials)) { return response()->json(['ok' => false, 'error' => 'invalid'], 401); } $user = Auth::user(); $ttl = (int) env('JWT_TTL_MIN', 15); $payload = [ 'iss' => config('app.url'), 'aud' => config('app.url'), 'iat' => time(), 'exp' => time() + $ttl * 60, 'sub' => (string) $user->getAuthIdentifier(), 'email' => (string) $user->email, 'kid' => env('APP_KEY_ID', 'kid-1'), ]; $privateKey = file_get_contents(storage_path('jwt/jwt_rsa.pem')); $jwt = JWT::encode($payload, $privateKey, 'RS256', $payload['kid']); return response() ->json(['ok' => true]) ->cookie( 'btjwt', $jwt, $ttl, '/', env('JWT_COOKIE_DOMAIN', '.example.ru'), true, // secure true, // httpOnly false, // raw 'Lax' // SameSite ); } public function logout(): JsonResponse { return response() ->json(['ok' => true]) ->cookie( 'btjwt', '', -1, '/', env('JWT_COOKIE_DOMAIN', '.example.ru'), true, true, false, 'Lax' ); } public function me(Request $request): JsonResponse { return response()->json([ 'ok' => true, 'user' => $request->user(), ]); } } Маршруты: // routes/api.php use App\Http\Controllers\AuthController; Route::post('/auth/login', [AuthController::class, 'login']); Route::post('/auth/logout', [AuthController::class, 'logout']); Route::get('/auth/me', [AuthController::class, 'me'])->middleware('bitrix.jwt');
Проверка токена на Laravel-роутах
Сервис проверки по JWKS:
<?php namespace App\Auth; use Firebase\JWT\JWT; use Firebase\JWT\Key; use Illuminate\Support\Facades\Cache; use RuntimeException; final class JwtVerifier { public function verify(string $jwt): object { $pem = Cache::remember('jwks:pem', 3600, function (): string { $json = file_get_contents(config('app.url') . '/.well-known/jwks.json'); $jwks = json_decode($json, true, flags: \JSON_THROW_ON_ERROR); return $this->jwkToPem($jwks['keys'][0]); }); $payload = JWT::decode($jwt, new Key($pem, 'RS256')); // Дополнительные проверки if (($payload->iss ?? '') !== config('app.url')) { throw new RuntimeException('Bad issuer'); } if (($payload->aud ?? '') !== config('app.url')) { throw new RuntimeException('Bad audience'); } return $payload; } private function jwkToPem(array $jwk): string { $n = base64_decode(strtr($jwk['n'], '-_', '+/')); $e = base64_decode(strtr($jwk['e'], '-_', '+/')); $rsa = pack('Ca*a*', 0x30, $this->asn1Len( pack('Ca*a*', 0x02, $this->asn1Int($n), 0x02) . $this->asn1Int($e) )); $spki = pack( 'Ca*a*', 0x30, $this->asn1Len( pack('Ca*', 0x30, $this->asn1Len(pack('H*', '300d06092a864886f70d0101010500'))) . pack('Ca*a*', 0x03, $this->asn1Len("\0" . $rsa), "\0" . $rsa) ), '' ); return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64, "\n") . "-----END PUBLIC KEY-----\n"; } private function asn1Len(string $x): string { $l = strlen($x); if ($l < 128) { return chr($l) . $x; } $bin = ltrim(pack('N', $l), "\0"); return chr(0x80 | strlen($bin)) . $bin . $x; } private function asn1Int(string $x): string { $trim = ltrim($x, "\0"); if ($trim === '' || (ord($trim[0]) & 0x80)) { $trim = "\0" . $trim; } return chr(strlen($trim)) . $trim; } }
Middleware:
<?php namespace App\Http\Middleware; use App\Auth\JwtVerifier; use App\Models\User; use Closure; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; final class BitrixJwtMiddleware { public function __construct(private readonly JwtVerifier $verifier) { } /** * @param Closure(Request): Response $next */ public function handle(Request $request, Closure $next): Response|JsonResponse { $jwt = $request->cookie('btjwt') ?: $request->bearerToken(); if ($jwt === null) { return response()->json(['message' => 'Unauthenticated'], 401); } try { $payload = $this->verifier->verify($jwt); } catch (\Throwable) { return response()->json(['message' => 'Invalid token'], 401); } $user = User::firstOrCreate( ['bitrix_id' => (int) ($payload->sub ?? 0)], ['email' => isset($payload->email) ? (string) $payload->email : null] ); auth()->login($user); return $next($request); } }
Регистрация:
// app/Http/Kernel.php protected $routeMiddleware = [ // ... 'bitrix.jwt' => \App\Http\Middleware\BitrixJwtMiddleware::class, ];
Битрикс: авторизация по JWT
Класс-обвязка:
<?php namespace Project\Bitrix\Auth; use Bitrix\Main\Application; use Bitrix\Main\Web\HttpClient; use Firebase\JWT\JWT; use Firebase\JWT\Key; final class JwtSso { private const JWKS_CACHE_KEY = 'jwks-cache'; private const JWKS_TTL = 3600; public static function authorizeFromCookie(): void { global $USER; if ($USER->IsAuthorized()) { return; } $request = Application::getInstance()->getContext()->getRequest(); $jwt = (string) $request->getCookie('btjwt'); if ($jwt === '') { return; } try { $publicPem = self::fetchJwksPublicPem(); $payload = JWT::decode($jwt, new Key($publicPem, 'RS256')); // Дополнительные проверки $iss = (string) ($payload->iss ?? ''); $aud = (string) ($payload->aud ?? ''); if ($iss !== 'https://api.example.ru' || $aud !== 'https://api.example.ru') { return; } $userId = (int) ($payload->sub ?? 0); if ($userId > 0) { $USER->Authorize($userId, false, true); } } catch (\Throwable) { // игнорируем, Битрикс продолжит как аноним } } private static function fetchJwksPublicPem(): string { $cache = Application::getInstance()->getManagedCache(); if ($cache->read(self::JWKS_TTL, self::JWKS_CACHE_KEY)) { /** @var string $pem */ $pem = $cache->get(self::JWKS_CACHE_KEY); return $pem; } $http = new HttpClient(['socketTimeout' => 2, 'streamTimeout' => 2]); $json = (string) $http->get('https://api.example.ru/.well-known/jwks.json'); $jwks = json_decode($json, true, flags: \JSON_THROW_ON_ERROR); $pem = self::jwkToPem($jwks['keys'][0]); $cache->set(self::JWKS_CACHE_KEY, $pem); return $pem; } private static function jwkToPem(array $jwk): string { $n = base64_decode(strtr($jwk['n'], '-_', '+/')); $e = base64_decode(strtr($jwk['e'], '-_', '+/')); $rsa = pack('Ca*a*', 0x30, self::asn1Len( pack('Ca*a*', 0x02, self::asn1Int($n), 0x02) . self::asn1Int($e) )); $spki = pack( 'Ca*a*', 0x30, self::asn1Len( pack('Ca*', 0x30, self::asn1Len(pack('H*', '300d06092a864886f70d0101010500'))) . pack('Ca*a*', 0x03, self::asn1Len("\0" . $rsa), "\0" . $rsa) ), '' ); return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64, "\n") . "-----END PUBLIC KEY-----\n"; } private static function asn1Len(string $x): string { $l = strlen($x); if ($l < 128) { return chr($l) . $x; } $bin = ltrim(pack('N', $l), "\0"); return chr(0x80 | strlen($bin)) . $bin . $x; } private static function asn1Int(string $x): string { $trim = ltrim($x, "\0"); if ($trim === '' || (ord($trim[0]) & 0x80)) { $trim = "\0" . $trim; } return chr(strlen($trim)) . $trim; } }
Подключение хука:
<?php use Project\Bitrix\Auth\JwtSso; AddEventHandler('main', 'OnBeforeProlog', static function (): void { JwtSso::authorizeFromCookie(); });
Безопасность и углы
Cookie btjwt только
HttpOnly+Secure+SameSite=Lax, домен.example.ru.Короткий TTL и необязательное обновление токена по активности.
Ротация ключей: держим 2–3
kidв JWKS, новый приватный ключ включаем заранее.Проверка
issиaud, допустимое расхождение часов 30–60 секунд.Для SPA с кросс-доменом - белый список CORS и
credentials: include.
В общем, живем недолго, но безопасно, не усложняем, но и не халтурим.
Шаг Б. Outbox: когда «Сохранить» не значит «Подожди»

Интеграции в Битриксе часто выглядят как шутка с плохой развязкой. Нажимаешь «Сохранить», а дальше все висит, потому что в этот момент система синхронно ждет ответа от CRM, доставки или еще какого-нибудь внешнего сервиса, который сегодня не в духе. Один таймаут, и админка превращается в лотерею.
Outbox-паттерн спасает от этой боли. Вместо того, чтобы дергать интеграции «в лоб», мы сначала записываем событие в свою таблицу, а уже потом фоново публикуем его в очередь. Все в рамках одной транзакции. Контент-менеджер нажимает кнопку, данные сохраняются, можно выдыхать. Остальное система разрулит сама.
Что там происходит:
Событие | Что делает система | Что это дает |
Вы сохраняете товар, цену или заказ | Вместе с сохранением пишется запись в таблицу outbox | Все в одной транзакции без шансов потерять событие |
Событие включает: тип, объект, полезную нагрузку, время и статус | Все, что нужно, чтобы потом доставить и обработать | Можно отследить, повторно отправить или проигнорировать дубликат |
Фоновый аге��т просматривает outbox и публикует события в Redis Streams | Работает в фоне, не тормозит интерфейс | Даже если Redis «лежит», сохранение не ломается |
Если Redis не ответил или случился сбой | Событие остается в таблице, повторная попытка будет позже | Ретрай с умным бэк-офом, ничего не теряется |
Если все прошло успешно | Статус события меняется на published, мы логируем request_id | Есть прозрачный след: что, когда и куда ушло |
Если что-то не так | Уходит в Dead Letter Queue (DLQ) — специальную «карантинную» зону | Ничего не падает, все под контролем. |
Если раньше падение RabbitMQ превращало админку в болото, теперь все иначе. Сохранили и пошли дальше, а outbox сам разберется, когда и куда слать.
Что отправляем в стримы:
Тип события | Что произошло |
| Обновили товар |
| Изменилась цена |
| Обновились остатки |
| Новый заказ |
| Изменили существующий заказ |
Что получаем взамен:
админка сохраняет мгновенно, даже если CRM отвечает через раз;
все события доходят куда надо — с логами, ретраями и наблюдаемостью;
любую ситуацию можно отмотать по occurred_at и восстановить целиком.
ORM-модель:
Скрытый текст
<?php namespace Project\Bitrix\Outbox; use Bitrix\Main\Entity; use Bitrix\Main\Type\DateTime; final class OutboxEventTable extends Entity\DataManager { public static function getTableName(): string { return 'project_outbox_events'; } public static function getMap(): array { return [ (new Entity\IntegerField('ID')) ->configurePrimary(true) ->configureAutocomplete(true), (new Entity\StringField('EVENT_TYPE')) ->configureRequired(true), (new Entity\StringField('AGGREGATE_TYPE')) ->configureRequired(true), (new Entity\StringField('AGGREGATE_ID')) ->configureRequired(true), (new Entity\TextField('PAYLOAD_JSON')) ->configureRequired(true), (new Entity\DatetimeField('OCCURRED_AT')) ->configureRequired(true) ->configureDefaultValue(static fn() => new DateTime()), (new Entity\DatetimeField('AVAILABLE_AT')) ->configureRequired(true) ->configureDefaultValue(static fn() => new DateTime()), (new Entity\IntegerField('ATTEMPTS')) ->configureDefaultValue(0), (new Entity\StringField('STATUS')) ->configureDefaultValue('pending'), (new Entity\TextField('LAST_ERROR')), (new Entity\StringField('HASH')) ->addValidator(new Entity\Validator\Length(1, 64)) ->configureRequired(true), ]; } }
Примечание: в миграции/установщике модуля нужно будет добавить уникальный индекс на HASH, а также составной индекс STATUS+AVAILABLE_AT для выбора батчей.
Запись события из Битрикс в момент изменения
Пример: элемент каталога отредактирован - пишем событие catalog.element.updated.
<?php use Bitrix\Main\Web\Json; use Project\Bitrix\Outbox\OutboxEventTable; AddEventHandler('iblock', 'OnAfterIBlockElementUpdate', static function(array $arFields): void { if (! isset($arFields['ID'], $arFields['IBLOCK_ID'])) { return; } $aggregateType = 'catalog.element'; $aggregateId = (string) $arFields['ID']; $eventType = 'catalog.element.updated'; $payload = [ 'id' => (int) $arFields['ID'], 'iblock_id' => (int) $arFields['IBLOCK_ID'], 'changed' => array_keys((array) ($arFields['FIELDS'] ?? [])), 'ts' => time(), 'request_id' => \Bitrix\Main\Diag\Helper::getRequestId(), ]; $json = Json::encode($payload, JSON_UNESCAPED_UNICODE); $hash = hash('sha256', $eventType . '|' . $aggregateType . '|' . $aggregateId . '|' . $json); OutboxEventTable::add([ 'EVENT_TYPE' => $eventType, 'AGGREGATE_TYPE' => $aggregateType, 'AGGREGATE_ID' => $aggregateId, 'PAYLOAD_JSON' => $json, 'HASH' => $hash, // OCCURRED_AT/AVAILABLE_AT проставятся по умолчанию ]); });
Тот же приём работает для заказов, цен и остатков: OnSaleOrderSaved, изменения в catalog_price, синхронизация складов.
Публикация в шину: фоновый агент Битрикс
<?php namespace Project\Bitrix\Outbox; use Bitrix\Main\Application; use Bitrix\Main\Result; use Redis; final class OutboxPublisher { private const BATCH_SIZE = 200; private const STREAM_MAP = [ 'catalog.element.updated' => 'catalog.events', 'price.changed' => 'price.events', 'stock.changed' => 'stock.events', 'order.created' => 'order.events', 'order.updated' => 'order.events', ]; public static function run(): string { $connection = Application::getConnection(); $sql = " SELECT * FROM project_outbox_events WHERE STATUS = 'pending' AND AVAILABLE_AT <= NOW() ORDER BY ID ASC LIMIT " . self::BATCH_SIZE; $rows = $connection->query($sql)->fetchAll(); if ($rows === []) { return 'OutboxPublisher::run();'; } $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->setOption(Redis::OPT_READ_TIMEOUT, 1); foreach ($rows as $row) { $stream = self::STREAM_MAP[$row['EVENT_TYPE']] ?? null; if ($stream === null) { self::markFailed((int) $row['ID'], 'Unknown event type'); continue; } try { // XADD stream * fields $id = $redis->xAdd($stream, '*', [ 'event_type' => $row['EVENT_TYPE'], 'aggregate' => $row['AGGREGATE_TYPE'] . ':' . $row['AGGREGATE_ID'], 'payload_json' => $row['PAYLOAD_JSON'], 'hash' => $row['HASH'], 'occurred_at' => (string) $row['OCCURRED_AT'], ]); self::markPublished((int) $row['ID'], (string) $id); } catch (\Throwable $e) { self::markRetry((int) $row['ID'], (string) $e->getMessage()); } } return 'OutboxPublisher::run();'; } private static function markPublished(int $id, string $externalId): void { OutboxEventTable::update($id, [ 'STATUS' => 'published', 'ATTEMPTS' => new \Bitrix\Main\DB\SqlExpression('ATTEMPTS + 1'), 'LAST_ERROR' => null, ]); } private static function markRetry(int $id, string $error): void { // простой экспоненциальный бэкофф: +1, +2, +4, ... минут $attempts = 0; $row = OutboxEventTable::getByPrimary($id)->fetch(); if ($row) { $attempts = (int) $row['ATTEMPTS']; } $minutes = min(60, 2 ** max(0, $attempts)); $available = (new \Bitrix\Main\Type\DateTime())->add("+" . $minutes . " minutes"); OutboxEventTable::update($id, [ 'STATUS' => 'pending', 'ATTEMPTS' => $attempts + 1, 'LAST_ERROR' => $error, 'AVAILABLE_AT' => $available, ]); } private static function markFailed(int $id, string $error): void { OutboxEventTable::update($id, [ 'STATUS' => 'failed', 'LAST_ERROR' => $error, ]); } }
Агент регистрируется штатно, с периодом в 1 минуту. Даже если Redis недоступен, события остаются в outbox и публикуются позже.
Потребление событий в Laravel: группы, идемпотентность, DLQ
<?php namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\Cache; use Redis; final class ConsumeEvents extends Command { protected $signature = 'events:consume {group=svc} {--streams=catalog.events,price.events,stock.events,order.events}'; protected $description = 'Читает бизнес-события из Redis Streams и диспатчит обработчики'; public function handle(): int { $group = (string) $this->argument('group'); $streams = array_map('trim', explode(',', (string) $this->option('streams'))); $r = new Redis(); $r->connect('127.0.0.1', 6379); $r->setOption(Redis::OPT_READ_TIMEOUT, 1); // создаём группы, если их ещё нет foreach ($streams as $s) { try { $r->xGroup('CREATE', $s, $group, '0', true); } catch (\RedisException) { // группа уже есть } } $streamPairs = []; foreach ($streams as $s) { $streamPairs[$s] = '>'; } while (true) { $chunk = $r->xReadGroup($group, gethostname() ?: 'consumer', $streamPairs, 100, 2000); if (! $chunk) { continue; } foreach ($chunk as $stream => $messages) { foreach ($messages as $id => $fields) { try { $this->dispatch($stream, $fields); $r->xAck($stream, $group, [$id]); } catch (\Throwable $e) { // перекидываем в DLQ $r->xAdd($stream . '.dlq', '*', $fields + ['error' => $e->getMessage()]); $r->xAck($stream, $group, [$id]); } } } } } /** * @param array<string,string> $fields */ private function dispatch(string $stream, array $fields): void { $type = $fields['event_type'] ?? ''; $json = $fields['payload_json'] ?? '{}'; $hash = $fields['hash'] ?? ''; $aggKey = $fields['aggregate'] ?? ''; // простая идемпотентность на 24 часа $key = "idem:{$type}:{$aggKey}:{$hash}"; if (! Cache::add($key, 1, 86400)) { return; // уже обрабатывали } match ($type) { 'catalog.element.updated' => \App\Jobs\ReindexProduct::dispatch($json), 'price.changed' => \App\Jobs\RefreshPricing::dispatch($json), 'stock.changed' => \App\Jobs\RefreshInventory::dispatch($json), 'order.created', 'order.updated' => \App\Jobs\SyncOrder::dispatch($json), default => null, }; } }
Пример обработчика:
<?php namespace App\Jobs; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; final class ReindexProduct implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 5; public int $backoff = 10; public function __construct(private readonly string $payloadJson) { } public function handle(): void { $data = \json_decode($this->payloadJson, true, flags: \JSON_THROW_ON_ERROR); // 1) обновляем в��трины svc_catalog_* // 2) индексируем документ в OpenSearch // 3) инвалидируем кэши // ... доменная логика тут } }
Контракты и версионирование
Минимальный пример AsyncAPI-фрагмента в yaml-файле:
asyncapi: 2.6.0 info: title: Project Events version: 1.0.0 channels: catalog.events: subscribe: message: name: catalog.element.updated payload: type: object required: [id, iblock_id, changed, ts] properties: id: { type: integer } iblock_id: { type: integer } changed: { type: array, items: { type: string } } ts: { type: integer } request_id: { type: string }
Добавляем request_id везде - это связывает логи Битрикс, публишера и консьюмера.
В результате — спокойная админка, предсказуемый Битрикс и разработка без бесконечных «а у нас опять ничего не работает». Все работает.
Шаг В. Наблюдаемость: видеть, а не догадываться

Когда в проде что-то идет не так, первыми обычно узнает не спецы по мониторингу, а менеджеры. Клиенты пишут, звонят с вопросами, почему не работает и без конкретики, а нам — гадай, где именно все застопорилось.
Мы от этого устали, и теперь у нас все прозрачно: каждая заявка, каждый товар, каждое событие — под прицелом метрик, логов и трассировок.
Что внедрили:
Компонент | Что дает |
| Один идентификатор на весь путь — чтобы логи и трейсы были связаны |
OpenTelemetry | Визуализируем цепочки вызовов — от контроллера до OpenSearch и обратно |
Sentry | Ошибки в Laravel и Битриксе фиксируются с контекстом и пользователями |
Prometheus | Снимаем метрики: задержки, ошибки, глубину очередей, повторы |
Что отслеживаем:
разницу между временем события (
occurred_at) и его фактической обработкой;P95 latency — реальное ощущение скорости глазами пользователя;
долю ошибок 5xx — если больше 0,5%, включаем маяк;
нагрузку на очереди: как быстро обрабатываются, не растет ли DLQ;
повторяющиеся ошибки и ретраи — сигнал, что кто-то упорно ломится в закрытую дверь.
Скрытый текст
Фрагмент конфига nginx:
# в http{} или server{} блоке map $http_x_request_id $req_id { default $http_x_request_id; "" $request_id; # если пришёл без id - сгенерируем } server { # ... # для всех ответов add_header X-Request-Id $req_id; location /api/ { proxy_set_header X-Request-Id $req_id; proxy_pass http://laravel_upstream; } location / { proxy_set_header X-Request-Id $req_id; proxy_pass http://bitrix_upstream; } }
Битрикс: фиксация коррелятора и доступ из кода
<?php namespace Project\Obs; final class Correlation { private const HEADER = 'HTTP_X_REQUEST_ID'; private static ?string $id = null; public static function id(): string { if (self::$id !== null) { return self::$id; } $server = $_SERVER[self::HEADER] ?? ''; self::$id = $server !== '' ? $server : bin2hex(random_bytes(16)); // Отдадим назад тем же заголовком header('X-Request-Id: ' . self::$id); return self::$id; } }
Теперь при записи событий в outbox из предыдущего шага добавляем request_id:
$payload = [ // ... 'request_id' => \Project\Obs\Correlation::id(), ];
Laravel: middleware для корреляции
<?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; final class Correlate { public function handle(Request $request, Closure $next): Response { $id = $request->headers->get('X-Request-Id') ?: Str::uuid()->toString(); $request->headers->set('X-Request-Id', $id); // прокинем в лог-контекст \Log::withContext(['request_id' => $id]); /** @var Response $response */ $response = $next($request); $response->headers->set('X-Request-Id', $id); return $response; } }
Включаем в app/Http/Kernel.php в глобальные middleware.
Sentry: ошибки и перфоманс
Laravel
composer require sentry/sentry-laravel:^4.6 php artisan vendor:publish --provider="Sentry\Laravel\ServiceProvider"
Bitrix
composer require sentry/sentry:^4.0
config/sentry.php:
<?php return [ 'dsn' => env('SENTRY_LARAVEL_DSN'), 'traces_sample_rate' => 0.2, // 20 % запросов с перфоманс-трейсами 'profiles_sample_rate' => 0.0, 'before_send' => static function (\Sentry\Event $event) { // Положим X-Request-Id в event tag, если есть $rid = request()?->headers->get('X-Request-Id'); if ($rid) { $event->setTag('x_request_id', $rid); } return $event; }, ];
/local/php_interface/init.php:
<?php \Sentry\init([ 'dsn' => getenv('SENTRY_PHP_DSN'), 'traces_sample_rate' => 0.1, 'before_send' => static function (\Sentry\Event $event): \Sentry\Event { $rid = $_SERVER['HTTP_X_REQUEST_ID'] ?? null; if ($rid) { $event->setTag('x_request_id', $rid); } return $event; }, ]); // Пример отправки исключения try { // ... ваш код } catch (\Throwable $e) { \Sentry\captureException($e); }
OpenTelemetry: трассировка от HTTP до очередей
Laravel: базовая инициализация
composer require open-telemetry/opentelemetry-sdk:^1.0 open-telemetry/exporter-otlp:^1.0
Сервис-провайдер:
<?php namespace App\Providers; use Illuminate\Support\ServiceProvider; use OpenTelemetry\API\Globals; use OpenTelemetry\Context\Context; use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProviderFactory; final class TelemetryServiceProvider extends ServiceProvider { public function register(): void { $factory = new TracerProviderFactory(); $provider = $factory->create(); // читает OTEL_* из env Globals::registerTracerProvider($provider); // Можно добавить BatchSpanProcessor, если используете ручную сборку // $provider->addSpanProcessor(new BatchSpanProcessor($exporter)); } }
В .env:
OTEL_SERVICE_NAME=laravel-api OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 OTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=0.2
Оборачиваем критичные места спанами
Контроллер поиска:
use OpenTelemetry\API\Trace\SpanBuilderInterface; use OpenTelemetry\API\Trace\TracerInterface; use OpenTelemetry\API\Globals as Otel; final class CatalogController extends Controller { public function search(Request $request, SearchIndex $index): JsonResponse { $tracer = Otel::tracerProvider()->getTracer('catalog'); $span = $tracer->spanBuilder('catalog.search') ->setAttribute('http.request_id', $request->header('X-Request-Id')) ->setAttribute('query.q', (string) $request->get('q', '')) ->startSpan(); try { // ... ваш код поиска } finally { $span->end(); } // ... } }
Консьюмер Redis Streams из предыдущего шага можно расширить:
use OpenTelemetry\API\Globals as Otel; $tracer = Otel::tracerProvider()->getTracer('events-consumer'); $span = $tracer->spanBuilder('stream.consume') ->setAttribute('stream', $stream) ->setAttribute('event_type', $type) ->setAttribute('aggregate', $aggKey) ->startSpan(); try { // обработка } finally { $span->end(); }
Экспортируются спаны в OTLP Collector, дальше - Grafana Tempo/Jaeger на ваш выбор. Главное, чтобы по тегу http.request_id можно было склеить HTTP и worker.
Prometheus: простые метрики
Laravel: метрики HTTP и поиска
composer require promphp/prometheus_client_php:^2.10
Регистрируем репозиторий и экспортёр:
<?php namespace App\Providers; use Illuminate\Support\ServiceProvider; use Prometheus\CollectorRegistry; use Prometheus\RenderTextFormat; use Prometheus\Storage\InMemory; final class MetricsServiceProvider extends ServiceProvider { public function register(): void { $this->app->singleton(CollectorRegistry::class, function () { return new CollectorRegistry(new InMemory()); }); } }
Middleware для счётчика и гистограммы:
<?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Prometheus\CollectorRegistry; use Symfony\Component\HttpFoundation\Response; final class HttpMetrics { public function __construct(private readonly CollectorRegistry $registry) { } public function handle(Request $request, Closure $next): Response { $hist = $this->registry->getOrRegisterHistogram( 'app', 'http_server_duration_seconds', 'HTTP latency', ['route', 'method', 'code'], [0.05, 0.1, 0.2, 0.5, 1, 2] ); $counter = $this->registry->getOrRegisterCounter( 'app', 'http_requests_total', 'HTTP requests', ['route', 'method', 'code'] ); $start = \microtime(true); /** @var Response $res */ $res = $next($request); $dur = \microtime(true) - $start; $labels = [$request->route()?->getName() ?? 'unknown', $request->getMethod(), (string) $res->getStatusCode()]; $hist->observe($dur, $labels); $counter->inc($labels); return $res; } }
Эндпоинт /metrics:
<?php use Illuminate\Support\Facades\Route; use Prometheus\CollectorRegistry; use Prometheus\RenderTextFormat; Route::get('/metrics', function (CollectorRegistry $registry) { $renderer = new RenderTextFormat(); $result = $renderer->render($registry->getMetricFamilySamples()); return response($result, 200, ['Content-Type' => RenderTextFormat::MIME_TYPE]); });
Лаг очередей в консьюмере
В команде чтения Streams после парсинга сообщений:
$gauge = app(CollectorRegistry::class)->getOrRegisterGauge( 'app', 'stream_lag_seconds', 'Lag between occurred_at and consume time', ['stream'] ); $occurred = isset($fields['occurred_at']) ? strtotime($fields['occurred_at']) : null; if ($occurred) { $gauge->set(max(0, time() - $occurred), [$stream]); }
Политики и алерты
SLO для API: P95 150 мс на кэш-хит, 400 мс на мисс. Алерт - когда три 5-минутных окна подряд выше порога.
SLO для индексации: лаг P95 < 10 с. Алерт - если лаг > 30 с 10 минут.
Ошибки 5xx: доля > 0.5 % в течение 5 минут - алерт.
Очереди: длина DLQ > 0 - предупреждение, > N - критический.
Важная деталь - не собирать PII в события и спаны. Прогоняйте фильтры для скрытия e-mail и телефонов, оставляйте технические атрибуты: ids, статусы, размеры выборок, тайминги, коды ошибок.
Когда клиент приходит с проблемой, нам не нужно устраивать квест по логам или гадать, где завис товар. Мы сразу видим, какое событие не дошло, какой сервис сейчас тормозит и сколько времени займет все наверстать.
Наблюдаемость — это про возможность понять, что пошло не так, не теряя время на догадки. Когда в системе что-то ломается, важно не просто увидеть ошибку, а точно знать — где она, что ее вызвало и как быстро можно починить. С таким подходом легче и работать, и отвечать на вопросы, и просто быть уверенным в том, что ты идешь куда надо.
Продолжение следует...
