diff --git a/src/Contract/DashboardApiInterface.php b/src/Contract/DashboardApiInterface.php new file mode 100644 index 0000000..47223da --- /dev/null +++ b/src/Contract/DashboardApiInterface.php @@ -0,0 +1,26 @@ + + */ + public function getExcludedEntityClasses(): array; +} diff --git a/src/Contract/ExceptionExclusionProviderInterface.php b/src/Contract/ExceptionExclusionProviderInterface.php new file mode 100644 index 0000000..7256bf0 --- /dev/null +++ b/src/Contract/ExceptionExclusionProviderInterface.php @@ -0,0 +1,16 @@ +> + */ + public function getExcludedExceptionClasses(): array; +} diff --git a/src/Contract/ItemDefinitionProviderInterface.php b/src/Contract/ItemDefinitionProviderInterface.php new file mode 100644 index 0000000..67decd5 --- /dev/null +++ b/src/Contract/ItemDefinitionProviderInterface.php @@ -0,0 +1,20 @@ + + */ + public function getExcludedRoutes(): array; +} diff --git a/src/Contract/ZabbixClientWithApiKeyInterface.php b/src/Contract/ZabbixClientWithApiKeyInterface.php new file mode 100644 index 0000000..1e11ff4 --- /dev/null +++ b/src/Contract/ZabbixClientWithApiKeyInterface.php @@ -0,0 +1,12 @@ +children() ->scalarNode('base_uri') ->isRequired() - ->cannotBeEmpty() - ->info('Zabbix API endpoint URL, e.g. https://zabbix.example.com/api_jsonrpc.php') + ->defaultValue('%env(ZABBIX_API_URL)%') + ->info('Base URI for Zabbix API') ->end() ->scalarNode('api_token') ->defaultNull() @@ -26,15 +26,32 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->scalarNode('username') ->defaultNull() - ->info('Zabbix username for login-based authentication') + ->info('Username for Zabbix authentication') ->end() ->scalarNode('password') ->defaultNull() ->info('Zabbix password for login-based authentication') + ->defaultNull() ->end() ->integerNode('auth_ttl') ->defaultValue(3600) - ->min(60) + ->info('Authentication TTL in seconds') + ->end() + ->scalarNode('app_name') + ->defaultValue('%env(APP_NAME)%') + ->info('Application name for monitoring') + ->end() + ->scalarNode('host_group') + ->defaultValue('Application Servers') + ->info('Default Zabbix host group') + ->end() + ->scalarNode('dashboard_config_path') + ->defaultValue('%kernel.project_dir%/config/zabbix/dashboards') + ->info('Path to dashboard configuration files') + ->end() + ->booleanNode('setup_enabled') + ->defaultFalse() + ->info('Enable Zabbix setup commands') ->info('Authentication token cache TTL in seconds (minimum 60)') ->end() ->end(); diff --git a/src/DependencyInjection/ZabbixApiExtension.php b/src/DependencyInjection/ZabbixApiExtension.php index bf6bc99..084c55a 100644 --- a/src/DependencyInjection/ZabbixApiExtension.php +++ b/src/DependencyInjection/ZabbixApiExtension.php @@ -13,9 +13,6 @@ final class ZabbixApiExtension extends Extension { public function load(array $configs, ContainerBuilder $container): void { - $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - $loader->load('services.yaml'); - $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); @@ -24,10 +21,12 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('zabbix_api.username', $config['username']); $container->setParameter('zabbix_api.password', $config['password']); $container->setParameter('zabbix_api.auth_ttl', $config['auth_ttl']); - } + $container->setParameter('zabbix_api.app_name', $config['app_name']); + $container->setParameter('zabbix_api.host_group', $config['host_group']); + $container->setParameter('zabbix_api.dashboard_config_path', $config['dashboard_config_path']); + $container->setParameter('zabbix_api.setup_enabled', $config['setup_enabled']); - public function getConfiguration(array $config, ContainerBuilder $container): ?Configuration - { - return new Configuration(); + $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); + $loader->load('services.yaml'); } } diff --git a/src/Message/EnsureZabbixSetupMessage.php b/src/Message/EnsureZabbixSetupMessage.php new file mode 100644 index 0000000..c91a83a --- /dev/null +++ b/src/Message/EnsureZabbixSetupMessage.php @@ -0,0 +1,15 @@ +setup->ensureAll(); + } +} diff --git a/src/Messenger/Handler/PushEventHandler.php b/src/Messenger/Handler/PushEventHandler.php new file mode 100644 index 0000000..79712f9 --- /dev/null +++ b/src/Messenger/Handler/PushEventHandler.php @@ -0,0 +1,28 @@ +sender->pushEvent( + key: $message->key, + payload: $message->payload, + clock: $message->clock ?? time(), + correlationId: $message->correlationId, + ); + } +} diff --git a/src/Messenger/Handler/PushMetricHandler.php b/src/Messenger/Handler/PushMetricHandler.php new file mode 100644 index 0000000..d78ebdd --- /dev/null +++ b/src/Messenger/Handler/PushMetricHandler.php @@ -0,0 +1,28 @@ +sender->pushNumeric( + key: $message->key, + value: $message->value, + clock: $message->clock ?? time(), + tags: $message->tags, + correlationId: $message->correlationId, + ); + } +} diff --git a/src/Provisioning/DashboardApi.php b/src/Provisioning/DashboardApi.php new file mode 100644 index 0000000..e4c4128 --- /dev/null +++ b/src/Provisioning/DashboardApi.php @@ -0,0 +1,275 @@ +clientFactory->create(); + + $result = $client->call(ZabbixAction::DASHBOARD_GET, [ + 'filter' => ['name' => $applicationName], + 'selectPages' => 'extend', + 'output' => ['dashboardid', 'name'], + ]); + + if (!\is_array($result) || \count($result) === 0) { + return null; + } + + foreach ($result as $dashboardData) { + if (!\is_array($dashboardData)) { + continue; + } + + $dashboard = $this->parseDashboard($dashboardData); + if ($dashboard->managedKey->equals($key)) { + return $dashboard; + } + } + + return null; + } + + public function create(DashboardSpec $spec): DashboardId + { + $client = $this->clientFactory->create(); + + $widgets = $this->prepareWidgets($spec); + + $result = $client->call(ZabbixAction::DASHBOARD_CREATE, [ + 'name' => $spec->name, + 'pages' => [ + [ + 'widgets' => $widgets, + ], + ], + ]); + + Assert::isArray($result); + Assert::keyExists($result, 'dashboardids'); + Assert::isArray($result['dashboardids']); + Assert::notEmpty($result['dashboardids']); + + $dashboardId = $result['dashboardids'][0]; + Assert::string($dashboardId); + + $this->logger->info('Zabbix dashboard created', [ + 'dashboardId' => $dashboardId, + 'name' => $spec->name, + 'managedKey' => $spec->managedKey->value, + ]); + + return DashboardId::fromString($dashboardId); + } + + public function update(DashboardId $id, DashboardSpec $spec): void + { + $client = $this->clientFactory->create(); + + $widgets = $this->prepareWidgets($spec); + + $result = $client->call(ZabbixAction::DASHBOARD_UPDATE, [ + 'dashboardid' => $id->value, + 'name' => $spec->name, + 'pages' => [ + [ + 'widgets' => $widgets, + ], + ], + ]); + + Assert::isArray($result); + Assert::keyExists($result, 'dashboardids'); + + $this->logger->info('Zabbix dashboard updated', [ + 'dashboardId' => $id->value, + 'name' => $spec->name, + 'managedKey' => $spec->managedKey->value, + ]); + } + + public function get(DashboardId $id): ZabbixDashboard + { + $client = $this->clientFactory->create(); + + $result = $client->call(ZabbixAction::DASHBOARD_GET, [ + 'dashboardids' => [$id->value], + 'selectPages' => 'extend', + 'output' => ['dashboardid', 'name'], + ]); + + Assert::isArray($result); + Assert::count($result, 1); + + return $this->parseDashboard($result[0]); + } + + public function findHostById(string $hostId): ?HostInfo + { + $client = $this->clientFactory->create(); + + $result = $client->call(ZabbixAction::HOST_GET, [ + 'hostids' => [$hostId], + 'output' => ['hostid', 'host', 'name'], + ]); + + if (!\is_array($result) || \count($result) === 0) { + return null; + } + + return HostInfo::fromArray($result[0]); + } + + public function findHostByName(string $hostName): ?HostInfo + { + $client = $this->clientFactory->create(); + + $result = $client->call(ZabbixAction::HOST_GET, [ + 'filter' => ['host' => $hostName], + 'output' => ['hostid', 'host'], + ]); + + if (!\is_array($result) || \count($result) === 0) { + return null; + } + + return HostInfo::fromArray($result[0]); + } + + private function parseDashboard(array $data): ZabbixDashboard + { + $widgets = $this->extractWidgetsFromPages($data); + $managedKey = $this->extractManagedKey($widgets); + $hash = $this->extractHash($widgets); + + return new ZabbixDashboard( + dashboardId: DashboardId::fromString($data['dashboardid']), + name: $data['name'], + managedKey: $managedKey, + hash: $hash, + widgets: $widgets, + ); + } + + private function extractWidgetsFromPages(array $dashboardData): array + { + $widgets = []; + + foreach ($dashboardData['pages'] ?? [] as $page) { + if (!\is_array($page)) { + continue; + } + + foreach ($page['widgets'] ?? [] as $widget) { + if (\is_array($widget)) { + $widgets[] = $widget; + } + } + } + + return $widgets; + } + + private function extractManagedKey(array $widgets): ManagedKey + { + foreach ($widgets as $widget) { + if (!\is_array($widget)) { + continue; + } + + if (($widget['type'] ?? '') === 'text' && str_contains((string) ($widget['name'] ?? ''), 'Managed')) { + foreach ($widget['fields'] ?? [] as $field) { + if (!\is_array($field)) { + continue; + } + + if (($field['type'] ?? '0') === '1' && str_contains((string) ($field['value'] ?? ''), 'Key:')) { + preg_match('/Key:\s*(\S+)/', (string) $field['value'], $matches); + if (isset($matches[1])) { + return ManagedKey::fromString($matches[1]); + } + } + } + } + } + + throw new RuntimeException('Managed key not found in dashboard'); + } + + private function extractHash(array $widgets): DefinitionHash + { + foreach ($widgets as $widget) { + if (!\is_array($widget)) { + continue; + } + + if (($widget['type'] ?? '') === 'text' && str_contains((string) ($widget['name'] ?? ''), 'Managed')) { + foreach ($widget['fields'] ?? [] as $field) { + if (!\is_array($field)) { + continue; + } + + if (($field['type'] ?? '1') === '1' && str_contains((string) ($field['value'] ?? ''), 'Hash:')) { + preg_match('/Hash:\s*(\S+)/', (string) $field['value'], $matches); + if (isset($matches[1])) { + return DefinitionHash::fromString($matches[1]); + } + } + } + } + } + + throw new RuntimeException('Hash not found in dashboard'); + } + + private function prepareWidgets(DashboardSpec $spec): array + { + $widgets = $spec->widgets; + + $managedMarkerWidget = [ + 'type' => 'text', + 'name' => 'Managed', + 'x' => 0, + 'y' => 0, + 'width' => 12, + 'height' => 1, + 'fields' => [ + [ + 'type' => 1, + 'value' => \sprintf( + "ManagedBy: ZabbixSetup\nKey: %s\nHash: %s\nUpdatedAt: %s", + $spec->managedKey->value, + $spec->hash->value, + date('Y-m-d H:i:s'), + ), + ], + ], + ]; + + array_unshift($widgets, $managedMarkerWidget); + + return $widgets; + } +} diff --git a/src/Provisioning/DashboardProvisioner.php b/src/Provisioning/DashboardProvisioner.php new file mode 100644 index 0000000..32d2d37 --- /dev/null +++ b/src/Provisioning/DashboardProvisioner.php @@ -0,0 +1,156 @@ +logger->info('Starting dashboard provisioning', [ + 'hostIdentifier' => $hostIdentifier, + 'dashboardName' => $dashboardName, + 'dryRun' => $dryRun, + ]); + + $host = $this->resolveHost($hostIdentifier); + + $managedKey = ManagedKey::fromComponents( + $this->naming->getEnvLabel(), + $this->naming->getEnvLabel(), + $host->hostId, + ); + + $definition = $this->definitionLoader->load($dashboardName); + + $hash = $this->specHasher->hash($definition); + + $title = $this->specRenderer->renderTitleTemplate($definition['title_template'], $host); + $widgets = $this->specRenderer->renderWidgets($definition['widgets'], $host); + + $spec = new DashboardSpec( + name: $title, + managedKey: $managedKey, + hash: $hash, + widgets: $widgets, + ); + + $existingDashboard = $this->dashboardApi->findByManagedKey($managedKey, $title); + + if ($existingDashboard === null) { + return $this->createDashboard($spec, $dryRun); + } + + return $this->updateDashboardIfNeeded($existingDashboard, $spec, $dryRun); + } + + private function resolveHost(string $hostIdentifier): HostInfo + { + $host = $this->dashboardApi->findHostById($hostIdentifier); + + if ($host !== null) { + return $host; + } + + $host = $this->dashboardApi->findHostByName($hostIdentifier); + + if ($host !== null) { + return $host; + } + + throw new RuntimeException(\sprintf('Host not found: %s', $hostIdentifier)); + } + + private function createDashboard(DashboardSpec $spec, bool $dryRun): ProvisioningResult + { + if ($dryRun) { + $this->logger->info('Dry run: Would create dashboard', [ + 'name' => $spec->name, + 'managedKey' => $spec->managedKey->value, + 'hash' => $spec->hash->value, + ]); + + return new ProvisioningResult( + status: ProvisioningStatus::CREATED, + dashboardId: null, + message: \sprintf('Dry run: Would create dashboard "%s"', $spec->name), + ); + } + + $dashboardId = $this->dashboardApi->create($spec); + + $this->logger->info('Dashboard created successfully', [ + 'dashboardId' => $dashboardId->value, + 'name' => $spec->name, + 'managedKey' => $spec->managedKey->value, + ]); + + return ProvisioningResult::created($dashboardId); + } + + private function updateDashboardIfNeeded(ZabbixDashboard $existingDashboard, DashboardSpec $spec, bool $dryRun): ProvisioningResult + { + if ($existingDashboard->hash->equals($spec->hash)) { + $this->logger->info('Dashboard unchanged', [ + 'dashboardId' => $existingDashboard->dashboardId->value, + 'name' => $existingDashboard->name, + 'hash' => $existingDashboard->hash->value, + ]); + + return ProvisioningResult::unchanged($existingDashboard->dashboardId); + } + + if ($dryRun) { + $this->logger->info('Dry run: Would update dashboard', [ + 'dashboardId' => $existingDashboard->dashboardId->value, + 'name' => $spec->name, + 'oldHash' => $existingDashboard->hash->value, + 'newHash' => $spec->hash->value, + ]); + + return new ProvisioningResult( + status: ProvisioningStatus::UPDATED, + dashboardId: $existingDashboard->dashboardId, + message: \sprintf('Dry run: Would update dashboard "%s"', $spec->name), + ); + } + + $this->dashboardApi->update($existingDashboard->dashboardId, $spec); + + $this->logger->info('Dashboard updated successfully', [ + 'dashboardId' => $existingDashboard->dashboardId->value, + 'name' => $spec->name, + 'oldHash' => $existingDashboard->hash->value, + 'newHash' => $spec->hash->value, + ]); + + return ProvisioningResult::updated($existingDashboard->dashboardId); + } +} diff --git a/src/Provisioning/DefinitionLoader.php b/src/Provisioning/DefinitionLoader.php new file mode 100644 index 0000000..2aae595 --- /dev/null +++ b/src/Provisioning/DefinitionLoader.php @@ -0,0 +1,41 @@ +configPath, $name); + + Assert::fileExists($filePath, \sprintf('Dashboard definition file not found: %s', $filePath)); + + $data = Yaml::parseFile($filePath); + + Assert::isArray($data); + Assert::keyExists($data, 'title_template'); + Assert::keyExists($data, 'widgets'); + + return $data; + } + + public function exists(string $name): bool + { + $filePath = \sprintf('%s/%s.yaml', $this->configPath, $name); + + return file_exists($filePath); + } +} diff --git a/src/Provisioning/Dto/DashboardSpec.php b/src/Provisioning/Dto/DashboardSpec.php new file mode 100644 index 0000000..df345b5 --- /dev/null +++ b/src/Provisioning/Dto/DashboardSpec.php @@ -0,0 +1,39 @@ + $this->name, + 'managed_key' => $this->managedKey->value, + 'hash' => $this->hash->value, + 'widgets' => $this->widgets, + ]; + } +} diff --git a/src/Provisioning/Dto/HostInfo.php b/src/Provisioning/Dto/HostInfo.php new file mode 100644 index 0000000..f7389c4 --- /dev/null +++ b/src/Provisioning/Dto/HostInfo.php @@ -0,0 +1,24 @@ +value), + ); + } + + public static function updated(DashboardId $dashboardId): self + { + return new self( + status: ProvisioningStatus::UPDATED, + dashboardId: $dashboardId, + message: \sprintf('Dashboard updated with ID %s', $dashboardId->value), + ); + } + + public static function unchanged(DashboardId $dashboardId): self + { + return new self( + status: ProvisioningStatus::UNCHANGED, + dashboardId: $dashboardId, + message: \sprintf('Dashboard unchanged with ID %s', $dashboardId->value), + ); + } + + public function isCreated(): bool + { + return $this->status === ProvisioningStatus::CREATED; + } + + public function isUpdated(): bool + { + return $this->status === ProvisioningStatus::UPDATED; + } + + public function isUnchanged(): bool + { + return $this->status === ProvisioningStatus::UNCHANGED; + } +} diff --git a/src/Provisioning/Dto/ZabbixDashboard.php b/src/Provisioning/Dto/ZabbixDashboard.php new file mode 100644 index 0000000..b8f7fba --- /dev/null +++ b/src/Provisioning/Dto/ZabbixDashboard.php @@ -0,0 +1,21 @@ +canonicalize($definition); + + return DefinitionHash::fromData($canonical); + } + + private function canonicalize(array $data): array + { + $result = []; + + foreach ($data as $key => $value) { + if (\is_array($value)) { + $result[$key] = $this->canonicalize($value); + } elseif (\is_string($value)) { + $result[$key] = $this->normalizeString($value); + } else { + $result[$key] = $value; + } + } + + ksort($result); + + return $result; + } + + private function normalizeString(string $value): string + { + return trim($value); + } +} diff --git a/src/Provisioning/SpecRenderer.php b/src/Provisioning/SpecRenderer.php new file mode 100644 index 0000000..b6a7a47 --- /dev/null +++ b/src/Provisioning/SpecRenderer.php @@ -0,0 +1,55 @@ + $host->name, + '{{ host.id }}' => $host->hostId, + ]; + + return str_replace( + array_keys($replacements), + array_values($replacements), + $template, + ); + } + + public function renderWidgets(array $widgets, HostInfo $host): array + { + $replacements = [ + '{{ host.id }}' => $host->hostId, + '{{ host.name }}' => $host->name, + ]; + + return $this->renderArray($widgets, $replacements); + } + + private function renderArray(array $data, array $replacements): array + { + $result = []; + + foreach ($data as $key => $value) { + if (\is_array($value)) { + $result[$key] = $this->renderArray($value, $replacements); + } elseif (\is_string($value)) { + $result[$key] = str_replace( + array_keys($replacements), + array_values($replacements), + $value, + ); + } else { + $result[$key] = $value; + } + } + + return $result; + } +} diff --git a/src/Provisioning/ValueObject/DashboardId.php b/src/Provisioning/ValueObject/DashboardId.php new file mode 100644 index 0000000..0895e46 --- /dev/null +++ b/src/Provisioning/ValueObject/DashboardId.php @@ -0,0 +1,28 @@ +value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} diff --git a/src/Provisioning/ValueObject/DefinitionHash.php b/src/Provisioning/ValueObject/DefinitionHash.php new file mode 100644 index 0000000..e60daa1 --- /dev/null +++ b/src/Provisioning/ValueObject/DefinitionHash.php @@ -0,0 +1,28 @@ +value === $other->value; + } +} diff --git a/src/Provisioning/ValueObject/ManagedKey.php b/src/Provisioning/ValueObject/ManagedKey.php new file mode 100644 index 0000000..33e13b0 --- /dev/null +++ b/src/Provisioning/ValueObject/ManagedKey.php @@ -0,0 +1,28 @@ +value === $other->value; + } +} diff --git a/src/Provisioning/ZabbixClientFactory.php b/src/Provisioning/ZabbixClientFactory.php new file mode 100644 index 0000000..2b12095 --- /dev/null +++ b/src/Provisioning/ZabbixClientFactory.php @@ -0,0 +1,20 @@ +client; + } +} diff --git a/src/Resources/config/packages/framework.yaml b/src/Resources/config/packages/framework.yaml new file mode 100644 index 0000000..fe6d3a8 --- /dev/null +++ b/src/Resources/config/packages/framework.yaml @@ -0,0 +1,9 @@ + +framework: + http_client: + scoped_clients: + zabbix.http_client: + base_uri: '%env(ZABBIX_API_URL)%' + timeout: 30 + headers: + Content-Type: 'application/json-rpc' diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index f0e6be5..a7661df 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -4,6 +4,17 @@ services: autowire: true autoconfigure: true + BytesCommerce\ZabbixApi\: + resource: '../../*' + exclude: + - '../../DependencyInjection/' + - '../../Resources/' + - '../../Actions/Dto/' + - '../../Provisioning/Dto/' + - '../../Provisioning/ValueObject/' + - '../../Message/' + - '../../Enums/' + zabbix_api.http_client: class: Symfony\Component\HttpClient\ScopingHttpClient factory: ['Symfony\Component\HttpClient\ScopingHttpClient', 'forBaseUri'] @@ -26,3 +37,35 @@ services: BytesCommerce\ZabbixApi\ActionServiceInterface: class: BytesCommerce\ZabbixApi\ActionService + + BytesCommerce\ZabbixApi\Contract\ZabbixNamingProviderInterface: + class: BytesCommerce\ZabbixApi\Service\ZabbixNaming + + BytesCommerce\ZabbixApi\Contract\ZabbixClientWithApiKeyInterface: + class: BytesCommerce\ZabbixApi\Service\ZabbixClientWithApiKey + arguments: + $httpClient: '@zabbix_api.http_client' + $cache: '@cache.app' + + BytesCommerce\ZabbixApi\Contract\ZabbixClientWrapperInterface: + class: BytesCommerce\ZabbixApi\Service\ZabbixClientWrapper + + BytesCommerce\ZabbixApi\Contract\ItemDefinitionProviderInterface: + class: BytesCommerce\ZabbixApi\Service\ZabbixItemRegistry + arguments: + $cache: '@cache.app' + + BytesCommerce\ZabbixApi\Contract\ZabbixSenderInterface: + class: BytesCommerce\ZabbixApi\Setup\ZabbixSender + + BytesCommerce\ZabbixApi\Contract\ZabbixSetupInterface: + class: BytesCommerce\ZabbixApi\Setup\ZabbixSetup + + BytesCommerce\ZabbixApi\Contract\DashboardApiInterface: + class: BytesCommerce\ZabbixApi\Provisioning\DashboardApi + + BytesCommerce\ZabbixApi\Contract\DefinitionLoaderInterface: + class: BytesCommerce\ZabbixApi\Provisioning\DefinitionLoader + + BytesCommerce\ZabbixApi\Contract\DashboardProvisionerInterface: + class: BytesCommerce\ZabbixApi\Provisioning\DashboardProvisioner diff --git a/src/Service/ZabbixClientWithApiKey.php b/src/Service/ZabbixClientWithApiKey.php new file mode 100644 index 0000000..504368e --- /dev/null +++ b/src/Service/ZabbixClientWithApiKey.php @@ -0,0 +1,195 @@ +executeApiCall($action, $params, null); + } + + $authToken = $this->getAuthToken(); + + try { + return $this->executeApiCall($action, $params, $authToken); + } catch (ZabbixApiException $e) { + if ($this->isAuthFailure($e)) { + $this->logger->info('Authentication failure detected, retrying with fresh token', [ + 'error' => $e->getMessage(), + 'errorData' => $e->getErrorData(), + 'code' => $e->getErrorCode(), + ]); + + $this->cache->delete(self::CACHE_KEY); + $authToken = $this->performLogin(); + + return $this->executeApiCall($action, $params, $authToken); + } + + throw $e; + } + } + + private function getAuthToken(): ?string + { + $token = $this->cache->get(self::CACHE_KEY, function (ItemInterface $item): ?string { + $item->expiresAfter(null); + + if ($this->apiToken !== null && $this->apiToken !== '') { + return $this->apiToken; + } + + if ($this->username === null || $this->password === null) { + return null; + } + + return $this->performLogin(); + }); + + return $token === '' ? null : $token; + } + + private function performLogin(): string + { + if ($this->apiToken !== null && $this->apiToken !== '') { + return $this->apiToken; + } + + if (!$this->username || !$this->password) { + throw new ZabbixApiException( + 'Username and password must be configured for authentication', + -1, + ); + } + + $this->logger->debug('Performing Zabbix login', ['username' => $this->username]); + + $result = $this->executeApiCall( + ZabbixAction::USER_LOGIN, + ['username' => $this->username, 'password' => $this->password], + null, + ); + + if (!\is_string($result)) { + throw new ZabbixApiException('Invalid login response: expected string token', -1); + } + + $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) use ($result): string { + $item->expiresAfter($this->authTtl); + + return $result; + }); + + $this->logger->info('Zabbix authentication successful', [ + 'username' => $this->username, + 'ttl' => $this->authTtl, + ]); + + return $result; + } + + private function executeApiCall(ZabbixAction $action, array $params, ?string $authToken): mixed + { + $requestBody = [ + 'jsonrpc' => '2.0', + 'method' => $action->value, + 'params' => $params, + 'id' => ++$this->requestId, + ]; + + $headers = [ + 'Content-Type' => 'application/json-rpc', + ]; + + if ($authToken !== null) { + $headers['Authorization'] = \sprintf('Bearer %s', $authToken); + } + + $this->logger->debug('Zabbix API call', [ + 'method' => $action->value, + 'id' => $requestBody['id'], + 'authenticated' => $authToken !== null, + ]); + + try { + $response = $this->httpClient->request('POST', '', [ + 'json' => $requestBody, + 'headers' => $headers, + ]); + + $data = $response->toArray(); + + if (isset($data['error']) && \is_array($data['error'])) { + $error = ResponseValidator::ensureErrorStructure($data['error']); + + throw new ZabbixApiException( + $error['message'], + $error['code'], + $error['data'], + ); + } + + return $data['result'] ?? null; + } catch (Throwable $e) { + $this->logger->error('Zabbix API call failed', [ + 'method' => $action->value, + 'error' => $e->getMessage(), + ]); + + if ($e instanceof ZabbixApiException) { + throw $e; + } + + throw new ZabbixApiException('HTTP request failed: ' . $e->getMessage(), -1, null, $e); + } + } + + private function isAuthFailure(ZabbixApiException $exception): bool + { + return array_any(self::AUTH_ERROR_MESSAGES, static fn ($message) => str_contains($exception->getMessage(), $message)); + } +} diff --git a/src/Service/ZabbixClientWrapper.php b/src/Service/ZabbixClientWrapper.php new file mode 100644 index 0000000..302673a --- /dev/null +++ b/src/Service/ZabbixClientWrapper.php @@ -0,0 +1,41 @@ +logger->debug('Zabbix API call', ['method' => $action->value, 'params' => $params]); + + try { + return $this->client->call($action, $params); + } catch (Throwable $e) { + $this->logger->error('Zabbix API call failed', [ + 'method' => $action->value, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + public function getClient(): ZabbixClientWithApiKeyInterface + { + return $this->client; + } +} diff --git a/src/Service/ZabbixItemRegistry.php b/src/Service/ZabbixItemRegistry.php new file mode 100644 index 0000000..db6b12f --- /dev/null +++ b/src/Service/ZabbixItemRegistry.php @@ -0,0 +1,131 @@ +cache->getItem(self::CACHE_KEY_HOST_ID); + $hostId = $item->get(); + + return $hostId !== null ? (string) $hostId : null; + } + + public function setHostId(string $hostId): void + { + $item = $this->cache->getItem(self::CACHE_KEY_HOST_ID); + $item->set($hostId); + $this->cache->save($item); + } + + public function getItemIdForKey(string $key): ?string + { + $item = $this->cache->getItem(self::CACHE_KEY_ITEM_IDS); + $itemIds = $item->get(); + if (!\is_array($itemIds)) { + return null; + } + + return $itemIds[$key] ?? null; + } + + public function setItemId(string $key, string $itemId): void + { + $item = $this->cache->getItem(self::CACHE_KEY_ITEM_IDS); + $itemIds = $item->get(); + if (!\is_array($itemIds)) { + $itemIds = []; + } + $itemIds[$key] = $itemId; + $item->set($itemIds); + $this->cache->save($item); + } + + public function getAllItemDefinitions(): array + { + return [ + 'tx.duration_ms' => [ + 'name' => 'Transaction Duration (ms)', + 'type' => 2, + 'value_type' => 0, + 'history' => '7d', + ], + 'tx.http_status' => [ + 'name' => 'HTTP Status Code', + 'type' => 2, + 'value_type' => 3, + 'history' => '7d', + ], + 'auth.login.success' => [ + 'name' => 'Login Success Count', + 'type' => 2, + 'value_type' => 3, + 'history' => '7d', + ], + 'auth.login.failure' => [ + 'name' => 'Login Failure Count', + 'type' => 2, + 'value_type' => 3, + 'history' => '7d', + ], + 'auth.login.success_event' => [ + 'name' => 'Login Success Event', + 'type' => 2, + 'value_type' => 4, + 'history' => '7d', + ], + 'auth.login.failure_event' => [ + 'name' => 'Login Failure Event', + 'type' => 2, + 'value_type' => 4, + 'history' => '7d', + ], + 'entity.persist.success' => [ + 'name' => 'Entity Persist Count', + 'type' => 2, + 'value_type' => 3, + 'history' => '7d', + ], + 'entity.update.success' => [ + 'name' => 'Entity Update Count', + 'type' => 2, + 'value_type' => 3, + 'history' => '7d', + ], + 'entity.remove.success' => [ + 'name' => 'Entity Remove Count', + 'type' => 2, + 'value_type' => 3, + 'history' => '7d', + ], + 'error.exception' => [ + 'name' => 'Exception Event', + 'type' => 2, + 'value_type' => 4, + 'history' => '7d', + ], + ]; + } + + public function getFullItemKey(string $suffix): string + { + return $this->naming->getItemKey($suffix); + } +} diff --git a/src/Service/ZabbixNaming.php b/src/Service/ZabbixNaming.php new file mode 100644 index 0000000..10e9014 --- /dev/null +++ b/src/Service/ZabbixNaming.php @@ -0,0 +1,62 @@ +appEnv); + } + + public function getAppName(): string + { + return \sprintf('%s [%s] %s', self::DASHBOARD_PREFIX, $this->getEnvLabel(), $this->appName); + } + + public function getHostGroup(): string + { + return $this->hostGroup; + } + + public function getDashboardPrefix(): string + { + return \sprintf('%s Dashboard', self::DASHBOARD_PREFIX); + } + + public function getHostName(): string + { + return $this->getAppName(); + } + + public function getDashboardName(): string + { + return \sprintf('%s — %s', $this->getDashboardPrefix(), $this->getAppName()); + } + + public function getItemKey(string $suffix): string + { + return \sprintf('symfony.%s', $suffix); + } + + public function getCleanHostName(): string + { + return strtolower($this->slugger->slug($this->getHostName())->toString()); + } +} diff --git a/src/Setup/ZabbixSender.php b/src/Setup/ZabbixSender.php new file mode 100644 index 0000000..0ad5309 --- /dev/null +++ b/src/Setup/ZabbixSender.php @@ -0,0 +1,90 @@ +setup->ensureFast(); + + $itemId = $this->registry->getItemIdForKey($key); + if ($itemId === null) { + $this->logger->warning('Zabbix item not found', ['key' => $key]); + + return; + } + + try { + $this->client->call(ZabbixAction::HISTORY_PUSH, [ + [ + 'itemid' => $itemId, + 'clock' => $clock, + 'value' => (string) $value, + ], + ]); + } catch (Throwable $e) { + $this->logger->error('Failed to push metric to Zabbix', [ + 'key' => $key, + 'value' => $value, + 'error' => $e->getMessage(), + 'correlationId' => $correlationId, + ]); + } + } + + public function pushEvent(string $key, array $payload, int $clock, string $correlationId): void + { + $this->setup->ensureFast(); + + $itemId = $this->registry->getItemIdForKey($key); + if ($itemId === null) { + $this->logger->warning('Zabbix item not found', ['key' => $key]); + + return; + } + + try { + $jsonPayload = json_encode($payload, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + + $this->client->call(ZabbixAction::HISTORY_PUSH, [ + [ + 'itemid' => $itemId, + 'clock' => $clock, + 'value' => $jsonPayload, + ], + ]); + } catch (JsonException $e) { + $this->logger->error('Failed to encode event payload', [ + 'key' => $key, + 'error' => $e->getMessage(), + 'correlationId' => $correlationId, + ]); + } catch (Throwable $e) { + $this->logger->error('Failed to push event to Zabbix', [ + 'key' => $key, + 'error' => $e->getMessage(), + 'correlationId' => $correlationId, + ]); + } + } +} diff --git a/src/Setup/ZabbixSetup.php b/src/Setup/ZabbixSetup.php new file mode 100644 index 0000000..48e326c --- /dev/null +++ b/src/Setup/ZabbixSetup.php @@ -0,0 +1,167 @@ +setupEnabled) { + return; + } + + $hostId = $this->registry->getHostId(); + if ($hostId !== null) { + return; + } + + $this->ensureAll(); + } + + public function ensureAll(): void + { + if (!$this->setupEnabled) { + return; + } + + $hostId = $this->ensureHost(); + $this->registry->setHostId($hostId); + + $this->ensureItems($hostId); + } + + public function ensureHost(): string + { + $hostName = $this->naming->getHostName(); + $cleanName = $this->naming->getCleanHostName(); + + $result = $this->client->call(ZabbixAction::HOST_GET, [ + 'filter' => ['name' => $hostName, 'host' => $cleanName], + 'output' => ['hostid'], + ]); + + if (\count($result) > 0 && !empty($result[0]['hostid'])) { + return $result[0]['hostid']; + } + + $groupId = $this->ensureHostGroup(); + + $result = $this->client->call(ZabbixAction::HOST_CREATE, [ + 'host' => $cleanName, + 'name' => $hostName, + 'groups' => [['groupid' => $groupId]], + 'interfaces' => [ + [ + 'type' => 1, + 'main' => 1, + 'useip' => 1, + 'ip' => '127.0.0.1', + 'dns' => '', + 'port' => '10050', + ], + ], + 'tags' => [ + ['tag' => 'class', 'value' => 'software'], + ['tag' => 'subclass', 'value' => 'web-application'], + ['tag' => 'framework', 'value' => 'symfony'], + ], + ]); + + $hostId = $result['hostids'][0]; + $this->logger->info('Zabbix host created', ['host' => $hostName, 'hostid' => $hostId]); + + return $hostId; + } + + private function ensureHostGroup(): string + { + $hostGroup = $this->naming->getHostGroup(); + + $result = $this->client->call(ZabbixAction::HOSTGROUP_GET, [ + 'filter' => ['name' => $hostGroup], + 'output' => ['groupid'], + ]); + + if (\count($result) > 0 && !empty($result[0]['groupid'])) { + return $result[0]['groupid']; + } + + $result = $this->client->call(ZabbixAction::HOSTGROUP_CREATE, [ + 'name' => $hostGroup, + ]); + + Assert::keyExists($result, 'groupids', 'Failed to create Zabbix host group'); + $groupId = $result['groupids'][0]; + $this->logger->info('Zabbix host group created', ['group' => $hostGroup, 'groupid' => $groupId]); + + return $groupId; + } + + private function ensureItems(string $hostId): void + { + $itemDefinitions = $this->registry->getAllItemDefinitions(); + + foreach ($itemDefinitions as $suffix => $definition) { + $key = $this->registry->getFullItemKey($suffix); + + $result = $this->client->call(ZabbixAction::ITEM_GET, [ + 'hostids' => $hostId, + 'filter' => ['key_' => $key], + 'output' => ['itemid'], + ]); + + if (\count($result) > 0 && !empty($result[0]['itemid'])) { + $itemId = $result[0]['itemid']; + $this->registry->setItemId($key, $itemId); + + continue; + } + + try { + $result = $this->client->call(ZabbixAction::ITEM_CREATE, [ + 'name' => $definition['name'], + 'key_' => $key, + 'hostid' => $hostId, + 'type' => $definition['type'], + 'value_type' => $definition['value_type'], + 'history' => $definition['history'], + ]); + } catch (ZabbixApiException $e) { + $this->logger->error('Failed to create Zabbix item', [ + 'key' => $key, + 'error' => $e->getMessage(), + 'errorData' => $e->getErrorData(), + ]); + + continue; + } + + Assert::keyExists($result, 'itemids', \sprintf('Failed to create Zabbix item, expected key "itemids" in response, got %s', implode(',', array_keys($result)))); + $itemId = $result['itemids'][0]; + $this->registry->setItemId($key, $itemId); + $this->logger->info('Zabbix item created', ['key' => $key, 'itemid' => $itemId]); + } + } +} diff --git a/src/Subscriber/DoctrineEntitySubscriber.php b/src/Subscriber/DoctrineEntitySubscriber.php new file mode 100644 index 0000000..67fd168 --- /dev/null +++ b/src/Subscriber/DoctrineEntitySubscriber.php @@ -0,0 +1,91 @@ + */ + private array $excludedEntityClasses; + + /** + * @param iterable $exclusionProviders + */ + public function __construct( + private readonly MessageBusInterface $bus, + private readonly ZabbixNamingProviderInterface $naming, + #[Autowire('%kernel.environment%')] + private readonly string $appEnv, + #[AutowireIterator('zabbix.entity_exclusion_provider')] + iterable $exclusionProviders = [], + ) { + $excluded = []; + foreach ($exclusionProviders as $provider) { + foreach ($provider->getExcludedEntityClasses() as $class) { + $excluded[] = $class; + } + } + $this->excludedEntityClasses = array_unique($excluded); + } + + public function postPersist(PostPersistEventArgs $event): void + { + $this->dispatch($event->getObject(), 'insert'); + } + + public function postUpdate(PostUpdateEventArgs $event): void + { + $this->dispatch($event->getObject(), 'update'); + } + + public function postRemove(PostRemoveEventArgs $event): void + { + $this->dispatch($event->getObject(), 'delete'); + } + + private function dispatch(object $entity, string $operation): void + { + if ($this->isExcluded($entity)) { + return; + } + + $entityClass = str_replace('\\', '.', $entity::class); + + $this->bus->dispatch(new PushMetricMessage( + key: $this->naming->getItemKey('doctrine.entity_change'), + value: 1, + tags: [ + 'env' => $this->appEnv, + 'entity' => $entityClass, + 'operation' => $operation, + ], + )); + } + + private function isExcluded(object $entity): bool + { + foreach ($this->excludedEntityClasses as $excludedClass) { + if ($entity instanceof $excludedClass) { + return true; + } + } + + return false; + } +} diff --git a/src/Subscriber/ExceptionSubscriber.php b/src/Subscriber/ExceptionSubscriber.php new file mode 100644 index 0000000..eb47a54 --- /dev/null +++ b/src/Subscriber/ExceptionSubscriber.php @@ -0,0 +1,86 @@ +> */ + private array $excludedExceptionClasses; + + /** + * @param iterable $exclusionProviders + */ + public function __construct( + private readonly MessageBusInterface $bus, + private readonly ZabbixNamingProviderInterface $naming, + #[Autowire('%kernel.environment%')] + private readonly string $appEnv, + #[AutowireIterator('zabbix.exception_exclusion_provider')] + iterable $exclusionProviders = [], + ) { + $excluded = []; + foreach ($exclusionProviders as $provider) { + foreach ($provider->getExcludedExceptionClasses() as $class) { + $excluded[] = $class; + } + } + $this->excludedExceptionClasses = array_unique($excluded); + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::EXCEPTION => ['onException', -100], + ]; + } + + public function onException(ExceptionEvent $event): void + { + $req = $event->getRequest(); + $cid = (string) $req->attributes->get('_mon_cid', ''); + $route = (string) ($req->attributes->get('_route') ?? 'unknown'); + + $exception = $event->getThrowable(); + + if ($this->isExcluded($exception)) { + return; + } + + $this->bus->dispatch(new PushEventMessage( + key: $this->naming->getItemKey('error.exception'), + payload: [ + 'class' => $exception::class, + 'message' => mb_substr($exception->getMessage(), 0, 500), + 'code' => $exception->getCode(), + 'route' => $route, + 'correlationId' => $cid, + 'env' => $this->appEnv, + ], + correlationId: $cid, + )); + } + + private function isExcluded(Throwable $exception): bool + { + foreach ($this->excludedExceptionClasses as $excludedClass) { + if ($exception instanceof $excludedClass) { + return true; + } + } + + return false; + } +} diff --git a/src/Subscriber/RequestTransactionSubscriber.php b/src/Subscriber/RequestTransactionSubscriber.php new file mode 100644 index 0000000..bd53831 --- /dev/null +++ b/src/Subscriber/RequestTransactionSubscriber.php @@ -0,0 +1,94 @@ + */ + private array $excludedRoutes; + + /** + * @param iterable $exclusionProviders + */ + public function __construct( + private readonly MessageBusInterface $bus, + private readonly ZabbixNamingProviderInterface $naming, + #[Autowire('%kernel.environment%')] + private readonly string $appEnv, + #[AutowireIterator('zabbix.route_exclusion_provider')] + iterable $exclusionProviders = [], + ) { + $excluded = []; + foreach ($exclusionProviders as $provider) { + foreach ($provider->getExcludedRoutes() as $route) { + $excluded[$route] = true; + } + } + $this->excludedRoutes = $excluded; + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => ['onRequest', 10_000], + KernelEvents::TERMINATE => ['onTerminate', -10_000], + ]; + } + + public function onRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $req = $event->getRequest(); + $req->attributes->set('_mon_start', hrtime(true)); + $req->attributes->set('_mon_cid', Uuid::v7()->toRfc4122()); + } + + public function onTerminate(TerminateEvent $event): void + { + $req = $event->getRequest(); + $start = $req->attributes->get('_mon_start'); + if (!\is_int($start)) { + return; + } + + $cid = (string) $req->attributes->get('_mon_cid', ''); + $durationMs = (hrtime(true) - $start) / 1_000_000; + + $route = (string) ($req->attributes->get('_route') ?? 'unknown'); + if (isset($this->excludedRoutes[$route])) { + return; + } + + $status = $event->getResponse()->getStatusCode(); + $this->bus->dispatch(new PushMetricMessage( + key: $this->naming->getItemKey('tx.duration_ms'), + value: (float) $durationMs, + tags: ['env' => $this->appEnv, 'route' => $route, 'method' => $req->getMethod(), 'status' => $status], + correlationId: $cid, + )); + + $this->bus->dispatch(new PushMetricMessage( + key: $this->naming->getItemKey('tx.http_status'), + value: (int) $status, + tags: ['env' => $this->appEnv, 'route' => $route, 'method' => $req->getMethod()], + correlationId: $cid, + )); + } +} diff --git a/src/Subscriber/SecurityAuthSubscriber.php b/src/Subscriber/SecurityAuthSubscriber.php new file mode 100644 index 0000000..1f2c100 --- /dev/null +++ b/src/Subscriber/SecurityAuthSubscriber.php @@ -0,0 +1,122 @@ + 'onSuccess', + LoginFailureEvent::class => 'onFailure', + ]; + } + + public function onSuccess(LoginSuccessEvent $event): void + { + $user = $event->getUser(); + $userId = $this->extractUserId($user); + + $this->bus->dispatch(new PushMetricMessage( + key: $this->naming->getItemKey('auth.login.success'), + value: 1, + tags: ['env' => $this->appEnv], + )); + + $this->bus->dispatch(new PushEventMessage( + key: $this->naming->getItemKey('auth.login.success_event'), + payload: ['userId' => $userId], + )); + } + + public function onFailure(LoginFailureEvent $event): void + { + $ex = $event->getException(); + $userIdentifier = $this->extractUserIdentifier($ex, $event->getPassport()?->getUser()); + + $this->bus->dispatch(new PushMetricMessage( + key: $this->naming->getItemKey('auth.login.failure'), + value: 1, + tags: ['env' => $this->appEnv], + )); + + $this->bus->dispatch(new PushEventMessage( + key: $this->naming->getItemKey('auth.login.failure_event'), + payload: [ + 'userIdentifier' => $userIdentifier, + 'exception' => $ex::class, + 'message' => mb_substr($ex->getMessage(), 0, 300), + ], + )); + } + + private function extractUserId(mixed $user): string + { + if (!\is_object($user)) { + return (string) $user; + } + + if (method_exists($user, 'getId')) { + $id = $user->getId(); + if (\is_scalar($id)) { + return (string) $id; + } + } + + if (method_exists($user, 'getUserIdentifier')) { + return (string) $user->getUserIdentifier(); + } + + return 'unknown'; + } + + private function extractUserIdentifier(AuthenticationException $exception, mixed $user): string + { + if (\is_object($user)) { + if (method_exists($user, 'getUserIdentifier')) { + return (string) $user->getUserIdentifier(); + } + + if (method_exists($user, 'getId')) { + $id = $user->getId(); + if (\is_scalar($id)) { + return (string) $id; + } + } + + return 'unknown'; + } + + if (\is_string($user)) { + return $user; + } + + $token = $exception->getToken(); + if ($token instanceof TokenInterface) { + return $token->getUserIdentifier(); + } + + return 'unknown'; + } +}