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.initFromStartParams())
|
||||||
.then(() => pulse.catchTelegramCustomerFromInitData())
|
.then(() => pulse.catchTelegramCustomerFromInitData())
|
||||||
.then(() => pulse.ingest(TC_PULSE_EVENTS.WEBAPP_OPEN))
|
.then(() => pulse.ingest(TC_PULSE_EVENTS.WEBAPP_OPEN))
|
||||||
|
.then(() => {
|
||||||
|
pulse.heartbeat();
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {defineStore} from "pinia";
|
import {defineStore} from "pinia";
|
||||||
import {ingest, saveTelegramCustomer} from "@/utils/ftch.js";
|
import {ingest, saveTelegramCustomer, heartbeat} from "@/utils/ftch.js";
|
||||||
import {toRaw} from "vue";
|
import {toRaw} from "vue";
|
||||||
import {deserializeStartParams} from "@/helpers.js";
|
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));
|
.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);
|
return await ftchPost('ingest', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function heartbeat() {
|
||||||
|
return await ftch('heartbeat');
|
||||||
|
}
|
||||||
|
|
||||||
export default ftch;
|
export default ftch;
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
PULSE_API_HOST=http://host.docker.internal:8086/api/
|
PULSE_API_HOST=http://host.docker.internal:8086/api/
|
||||||
|
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
PULSE_API_HOST=http://host.docker.internal:8086/api/
|
PULSE_API_HOST=http://host.docker.internal:8086/api/
|
||||||
|
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
PULSE_API_HOST=https://pulse.telecart.pro/api/
|
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\Client;
|
||||||
use GuzzleHttp\Exception\ClientException;
|
use GuzzleHttp\Exception\ClientException;
|
||||||
use GuzzleHttp\Exception\GuzzleException;
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||||
use Openguru\OpenCartFramework\Support\Arr;
|
use Openguru\OpenCartFramework\Support\Arr;
|
||||||
use Openguru\OpenCartFramework\Support\Utils;
|
use Openguru\OpenCartFramework\Support\Utils;
|
||||||
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
|
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
|
||||||
|
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class TeleCartPulseService
|
class TeleCartPulseService
|
||||||
{
|
{
|
||||||
private TelegramInitDataDecoder $initDataDecoder;
|
private TelegramInitDataDecoder $initDataDecoder;
|
||||||
private PayloadSigner $payloadSigner;
|
private PayloadSigner $payloadSigner;
|
||||||
|
private TelegramService $telegramService;
|
||||||
|
private CacheInterface $cache;
|
||||||
|
private LoggerInterface $logger;
|
||||||
private ?string $apiKey;
|
private ?string $apiKey;
|
||||||
|
private ?PayloadSigner $heartbeatPayloadSigner;
|
||||||
|
private ?string $moduleVersion = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
TelegramInitDataDecoder $initDataDecoder,
|
TelegramInitDataDecoder $initDataDecoder,
|
||||||
PayloadSigner $payloadSigner,
|
PayloadSigner $payloadSigner,
|
||||||
?string $apiKey = null
|
TelegramService $telegramService,
|
||||||
|
CacheInterface $cache,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
?string $apiKey = null,
|
||||||
|
?PayloadSigner $heartbeatPayloadSigner = null
|
||||||
) {
|
) {
|
||||||
$this->initDataDecoder = $initDataDecoder;
|
$this->initDataDecoder = $initDataDecoder;
|
||||||
$this->payloadSigner = $payloadSigner;
|
$this->payloadSigner = $payloadSigner;
|
||||||
|
$this->telegramService = $telegramService;
|
||||||
|
$this->cache = $cache;
|
||||||
|
$this->logger = $logger;
|
||||||
$this->apiKey = $apiKey;
|
$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 PayloadSignException
|
||||||
* @throws GuzzleException
|
* @throws GuzzleException
|
||||||
@@ -109,13 +184,53 @@ class TeleCartPulseService
|
|||||||
'timeout' => env('PULSE_TIMEOUT', 5.0),
|
'timeout' => env('PULSE_TIMEOUT', 5.0),
|
||||||
'headers' => [
|
'headers' => [
|
||||||
'Authorization' => 'Bearer ' . $this->apiKey,
|
'Authorization' => 'Bearer ' . $this->apiKey,
|
||||||
'X-TELECART-VERSION' => '2.0.0',
|
'X-TELECART-VERSION' => $this->getModuleVersion(),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$client->post('events', compact('json'));
|
$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
|
private function handleOrderCreated(array $data, array $deserialized): void
|
||||||
{
|
{
|
||||||
if (isset($deserialized['campaign_id'], $deserialized['tracking_id'])) {
|
if (isset($deserialized['campaign_id'], $deserialized['tracking_id'])) {
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
namespace Openguru\OpenCartFramework\TeleCartPulse;
|
namespace Openguru\OpenCartFramework\TeleCartPulse;
|
||||||
|
|
||||||
|
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||||
use Openguru\OpenCartFramework\Container\Container;
|
use Openguru\OpenCartFramework\Container\Container;
|
||||||
use Openguru\OpenCartFramework\Container\ServiceProvider;
|
use Openguru\OpenCartFramework\Container\ServiceProvider;
|
||||||
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
|
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
|
||||||
|
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
class TeleCartPulseServiceProvider extends ServiceProvider
|
class TeleCartPulseServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -17,10 +20,17 @@ class TeleCartPulseServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
|
|
||||||
$this->container->singleton(TeleCartPulseService::class, function (Container $app) {
|
$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(
|
return new TeleCartPulseService(
|
||||||
$app->get(TelegramInitDataDecoder::class),
|
$app->get(TelegramInitDataDecoder::class),
|
||||||
$app->get(PayloadSigner::class),
|
$app->get(PayloadSigner::class),
|
||||||
|
$app->get(TelegramService::class),
|
||||||
|
$app->get(CacheInterface::class),
|
||||||
|
$app->get(LoggerInterface::class),
|
||||||
$app->getConfigValue('pulse.api_key'),
|
$app->getConfigValue('pulse.api_key'),
|
||||||
|
$heartbeatSigner,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,20 @@ use Openguru\OpenCartFramework\Http\Request;
|
|||||||
use Openguru\OpenCartFramework\Http\Response;
|
use Openguru\OpenCartFramework\Http\Response;
|
||||||
use Openguru\OpenCartFramework\TeleCartPulse\PulseIngestException;
|
use Openguru\OpenCartFramework\TeleCartPulse\PulseIngestException;
|
||||||
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseService;
|
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class TelemetryHandler
|
class TelemetryHandler
|
||||||
{
|
{
|
||||||
private TeleCartPulseService $teleCartPulseService;
|
private TeleCartPulseService $teleCartPulseService;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
public function __construct(TeleCartPulseService $teleCartPulseService)
|
public function __construct(
|
||||||
{
|
TeleCartPulseService $teleCartPulseService,
|
||||||
|
LoggerInterface $logger
|
||||||
|
) {
|
||||||
$this->teleCartPulseService = $teleCartPulseService;
|
$this->teleCartPulseService = $teleCartPulseService;
|
||||||
|
$this->logger = $logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,4 +34,15 @@ class TelemetryHandler
|
|||||||
|
|
||||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
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'],
|
'getForm' => [FormsHandler::class, 'getForm'],
|
||||||
'health' => [HealthCheckHandler::class, 'handle'],
|
'health' => [HealthCheckHandler::class, 'handle'],
|
||||||
'ingest' => [TelemetryHandler::class, 'ingest'],
|
'ingest' => [TelemetryHandler::class, 'ingest'],
|
||||||
|
'heartbeat' => [TelemetryHandler::class, 'heartbeat'],
|
||||||
'manifest' => [SettingsHandler::class, 'manifest'],
|
'manifest' => [SettingsHandler::class, 'manifest'],
|
||||||
'processBlock' => [BlocksHandler::class, 'processBlock'],
|
'processBlock' => [BlocksHandler::class, 'processBlock'],
|
||||||
'product_show' => [ProductsHandler::class, 'show'],
|
'product_show' => [ProductsHandler::class, 'show'],
|
||||||
|
|||||||
Reference in New Issue
Block a user