feat: Add TeleCart Pulse heartbeat telemetry
This commit is contained in:
38
.cursor/features/telecart-pulse-heartbeat.md
Normal file
38
.cursor/features/telecart-pulse-heartbeat.md
Normal 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 в админку.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
APP_DEBUG=true
|
||||
PULSE_API_HOST=http://host.docker.internal:8086/api/
|
||||
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
APP_DEBUG=false
|
||||
PULSE_API_HOST=http://host.docker.internal:8086/api/
|
||||
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
APP_DEBUG=false
|
||||
PULSE_API_HOST=https://pulse.telecart.pro/api/
|
||||
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user