feat: Add TeleCart Pulse heartbeat telemetry

This commit is contained in:
2025-12-03 23:03:16 +03:00
parent 772efce242
commit b60c77e453
11 changed files with 202 additions and 5 deletions

View File

@@ -0,0 +1,38 @@
## TeleCart Pulse Heartbeat Telemetry
### Цель
Раз в час отправлять телеметрию (heartbeat) на TeleCart Pulse, чтобы фиксировать состояние магазина и версии окружения без участия пользователя.
### Backend (`module/oc_telegram_shop/upload/oc_telegram_shop`)
- `framework/TeleCartPulse/TeleCartPulseService.php`
- Новый метод `handleHeartbeat()` собирает данные: домен (через `Utils::getCurrentDomain()`), username бота (через `TelegramService::getMe()`), версии PHP, модуля (из `composer.json`), OpenCart (`VERSION` и `VERSION_CORE`), текущий UTC timestamp.
- Последний успешный пинг кешируется (ключ `telecart_pulse_heartbeat`, TTL 1 час) через существующий `CacheInterface`.
- Подпись heartbeat выполняется через отдельный `PayloadSigner`, который использует секрет `pulse.heartbeat_secret`/`PULSE_HEARTBEAT_SECRET`. Логируются предупреждения при ошибках кеша/бота/подписи.
- Отправка идет на эндпоинт `heartbeat` с таймаутом 2 секунды и заголовком `X-TELECART-VERSION`, взятым из `composer.json`.
- `framework/TeleCartPulse/TeleCartPulseServiceProvider.php`
- Регистрирует основной `PayloadSigner` (по `pulse.api_key`) и отдельный heartbeat signer (по `pulse.heartbeat_secret` или `PULSE_HEARTBEAT_SECRET`), инжектит `LoggerInterface`.
- `src/Handlers/TelemetryHandler.php` + `src/routes.php`
- Добавлен маршрут `heartbeat`, который вызывает `handleHeartbeat()` и возвращает `{ status: "ok" }`. Логгер пишет warning при проблемах.
### Frontend (`frontend/spa`)
- `src/utils/ftch.js`: новая функция `heartbeat()` вызывает `api_action=heartbeat`.
- `src/stores/Pulse.js`: добавлен action `heartbeat`, использующий новую API-функцию и логирующий результат.
- `src/main.js`: после `pulse.ingest(...)` вызывается `pulse.heartbeat()` без блокировки цепочки.
### Конфигурация / ENV
- `PULSE_API_HOST` — базовый URL TeleCart Pulse (используется и для events, и для heartbeat).
- `PULSE_TIMEOUT` — общий таймаут HTTP (для heartbeat принудительно 2 секунды).
- `PULSE_HEARTBEAT_SECRET` (или `pulse.heartbeat_secret` в настройках) — общий секрет для подписания heartbeat. Обязателен, иначе heartbeat не будет отправляться.
- `pulse.api_key` — прежний API ключ, используется только для event-инджеста.
### Поведение
1. Frontend (SPA) вызывает `heartbeat` при инициализации приложения (fire-and-forget).
2. Backend проверяет кеш. Если часа еще не прошло, `handleHeartbeat()` возвращает без запросов.
3. При необходимости собираются данные, подписываются через heartbeat signer и отправляются POST-запросом на `/heartbeat`.
4. Любые сбои (bot info, подпись, HTTP) логируются как warning, чтобы не тревожить пользователей.
### TODO / Возможные улучшения
- При необходимости вынести heartbeat запуск в крон/CLI, чтобы не зависеть от фронтенда.
- Добавить метрики успешности heartbeat в админку.

View File

@@ -62,6 +62,9 @@ settings.load()
.then(() => pulse.initFromStartParams())
.then(() => pulse.catchTelegramCustomerFromInitData())
.then(() => pulse.ingest(TC_PULSE_EVENTS.WEBAPP_OPEN))
.then(() => {
pulse.heartbeat();
})
.then(() => {
(async () => {
try {

View File

@@ -1,5 +1,5 @@
import {defineStore} from "pinia";
import {ingest, saveTelegramCustomer} from "@/utils/ftch.js";
import {ingest, saveTelegramCustomer, heartbeat} from "@/utils/ftch.js";
import {toRaw} from "vue";
import {deserializeStartParams} from "@/helpers.js";
@@ -46,5 +46,11 @@ export const usePulseStore = defineStore('pulse', {
.catch(() => console.warn('[Pulse] Failed to save Telegram customer data:', error));
}
},
heartbeat() {
heartbeat()
.then(() => console.debug('[Pulse] Heartbeat sent'))
.catch(err => console.warn('[Pulse] Heartbeat failed:', err));
}
},
});

View File

@@ -127,4 +127,8 @@ export async function ingest(data) {
return await ftchPost('ingest', data);
}
export async function heartbeat() {
return await ftch('heartbeat');
}
export default ftch;

View File

@@ -1,2 +1,3 @@
APP_DEBUG=true
PULSE_API_HOST=http://host.docker.internal:8086/api/
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb

View File

@@ -1,2 +1,3 @@
APP_DEBUG=false
PULSE_API_HOST=http://host.docker.internal:8086/api/
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb

View File

@@ -1,2 +1,3 @@
APP_DEBUG=false
PULSE_API_HOST=https://pulse.telecart.pro/api/
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb

View File

@@ -6,25 +6,41 @@ use Carbon\Carbon;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Support\Utils;
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Psr\Log\LoggerInterface;
use Throwable;
class TeleCartPulseService
{
private TelegramInitDataDecoder $initDataDecoder;
private PayloadSigner $payloadSigner;
private TelegramService $telegramService;
private CacheInterface $cache;
private LoggerInterface $logger;
private ?string $apiKey;
private ?PayloadSigner $heartbeatPayloadSigner;
private ?string $moduleVersion = null;
public function __construct(
TelegramInitDataDecoder $initDataDecoder,
PayloadSigner $payloadSigner,
?string $apiKey = null
TelegramService $telegramService,
CacheInterface $cache,
LoggerInterface $logger,
?string $apiKey = null,
?PayloadSigner $heartbeatPayloadSigner = null
) {
$this->initDataDecoder = $initDataDecoder;
$this->payloadSigner = $payloadSigner;
$this->telegramService = $telegramService;
$this->cache = $cache;
$this->logger = $logger;
$this->apiKey = $apiKey;
$this->heartbeatPayloadSigner = $heartbeatPayloadSigner;
}
/**
@@ -68,6 +84,65 @@ class TeleCartPulseService
}
}
/**
* @throws GuzzleException
*/
public function handleHeartbeat(): void
{
if ($this->cache->get('telecart_pulse_heartbeat')) {
return;
}
try {
$this->cache->set('telecart_pulse_heartbeat', time(), 3600);
$me = $this->telegramService->getMe();
} catch (Throwable $e) {
$this->logger->warning(
'TeleCart Pulse heartbeat prerequisites failed: ' . $e->getMessage(),
['exception' => $e]
);
return;
}
$botName = $me['username'] ?? 'unknown';
$moduleVersion = $this->getModuleVersion();
$payload = [
'event' => 'HEARTBEAT',
'meta' => [
'domain' => Utils::getCurrentDomain(),
'bot_name' => $botName,
'php_version' => PHP_VERSION,
'module_version' => $moduleVersion,
'opencart_version' => defined('VERSION') ? VERSION : 'unknown',
'opencart_version_core' => defined('VERSION_CORE') ? VERSION_CORE : 'unknown',
],
'timestamp' => Carbon::now('UTC')->toJSON(),
];
if (! $this->heartbeatPayloadSigner) {
return;
}
try {
$signature = $this->heartbeatPayloadSigner->sign($payload);
} catch (PayloadSignException $exception) {
$this->logger->warning(
'TeleCart Pulse heartbeat signing failed: ' . $exception->getMessage(),
['exception' => $exception]
);
return;
}
$dataToSend = [
'payload' => $payload,
'signature' => $signature,
];
$this->pushHeartbeat($dataToSend);
}
/**
* @throws PayloadSignException
* @throws GuzzleException
@@ -109,13 +184,53 @@ class TeleCartPulseService
'timeout' => env('PULSE_TIMEOUT', 5.0),
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'X-TELECART-VERSION' => '2.0.0',
'X-TELECART-VERSION' => $this->getModuleVersion(),
],
]);
$client->post('events', compact('json'));
}
/**
* @throws GuzzleException
*/
private function pushHeartbeat(array $json): void
{
$baseUri = rtrim(env('PULSE_API_HOST', 'http://localhost'), '/') . '/';
$client = new Client([
'base_uri' => $baseUri,
'timeout' => env('PULSE_TIMEOUT', 2.0),
'headers' => [
'X-TELECART-VERSION' => $this->getModuleVersion(),
],
]);
$client->post('heartbeat', compact('json'));
}
private function getModuleVersion(): string
{
if ($this->moduleVersion !== null) {
return $this->moduleVersion;
}
$moduleVersion = 'unknown';
$composerPath = __DIR__ . '/../../composer.json';
if (file_exists($composerPath)) {
$composerRaw = @file_get_contents($composerPath);
if ($composerRaw !== false) {
$composer = json_decode($composerRaw, true);
if (is_array($composer) && isset($composer['version'])) {
$moduleVersion = (string)$composer['version'];
}
}
}
return $this->moduleVersion = $moduleVersion;
}
private function handleOrderCreated(array $data, array $deserialized): void
{
if (isset($deserialized['campaign_id'], $deserialized['tracking_id'])) {

View File

@@ -2,9 +2,12 @@
namespace Openguru\OpenCartFramework\TeleCartPulse;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Psr\Log\LoggerInterface;
class TeleCartPulseServiceProvider extends ServiceProvider
{
@@ -17,10 +20,17 @@ class TeleCartPulseServiceProvider extends ServiceProvider
});
$this->container->singleton(TeleCartPulseService::class, function (Container $app) {
$heartbeatSecret = $app->getConfigValue('pulse.heartbeat_secret') ?? env('PULSE_HEARTBEAT_SECRET');
$heartbeatSigner = $heartbeatSecret ? new PayloadSigner($heartbeatSecret) : null;
return new TeleCartPulseService(
$app->get(TelegramInitDataDecoder::class),
$app->get(PayloadSigner::class),
$app->get(TelegramService::class),
$app->get(CacheInterface::class),
$app->get(LoggerInterface::class),
$app->getConfigValue('pulse.api_key'),
$heartbeatSigner,
);
});
}

View File

@@ -9,14 +9,20 @@ use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\TeleCartPulse\PulseIngestException;
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseService;
use Psr\Log\LoggerInterface;
use Throwable;
class TelemetryHandler
{
private TeleCartPulseService $teleCartPulseService;
private LoggerInterface $logger;
public function __construct(TeleCartPulseService $teleCartPulseService)
{
public function __construct(
TeleCartPulseService $teleCartPulseService,
LoggerInterface $logger
) {
$this->teleCartPulseService = $teleCartPulseService;
$this->logger = $logger;
}
/**
@@ -28,4 +34,15 @@ class TelemetryHandler
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
public function heartbeat(): JsonResponse
{
try {
$this->teleCartPulseService->handleHeartbeat();
} catch (Throwable $e) {
$this->logger->warning('TeleCart Pulse Heartbeat failed: ' . $e->getMessage(), ['exception' => $e]);
}
return new JsonResponse(['status' => 'ok']);
}
}

View File

@@ -24,6 +24,7 @@ return [
'getForm' => [FormsHandler::class, 'getForm'],
'health' => [HealthCheckHandler::class, 'handle'],
'ingest' => [TelemetryHandler::class, 'ingest'],
'heartbeat' => [TelemetryHandler::class, 'heartbeat'],
'manifest' => [SettingsHandler::class, 'manifest'],
'processBlock' => [BlocksHandler::class, 'processBlock'],
'product_show' => [ProductsHandler::class, 'show'],