Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Framework\FeatureFlags;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Represents a feature flag configuration
*/
final readonly class FeatureFlag
{
/**
* @param array<string, mixed> $conditions
*/
public function __construct(
public string $name,
public FeatureFlagStatus $status,
public ?string $description = null,
public array $conditions = [],
public ?Timestamp $enabledAt = null,
public ?Timestamp $expiresAt = null
) {
}
public function isEnabled(): bool
{
// Check if flag is explicitly disabled
if ($this->status->isDisabled()) {
return false;
}
// Check expiration
if ($this->expiresAt !== null && Timestamp::now()->toFloat() > $this->expiresAt->toFloat()) {
return false;
}
// If unconditionally enabled
if ($this->status->isEnabled()) {
return true;
}
// For conditional flags, we'll need context to evaluate
return false;
}
public function isEnabledForContext(FeatureFlagContext $context): bool
{
// Check basic enabled status first
if (! $this->isEnabled() && ! $this->status->isConditional()) {
return false;
}
// If unconditionally enabled (and not expired)
if ($this->status->isEnabled()) {
return true;
}
// Evaluate conditions for conditional flags
if ($this->status->isConditional()) {
return $this->evaluateConditions($context);
}
return false;
}
private function evaluateConditions(FeatureFlagContext $context): bool
{
foreach ($this->conditions as $key => $expectedValue) {
$contextValue = $context->getValue($key);
if ($contextValue === null) {
return false; // Required context missing
}
// Handle array conditions (e.g., user_ids: [1, 2, 3])
if (is_array($expectedValue)) {
if (! in_array($contextValue, $expectedValue)) {
return false;
}
continue;
}
// Handle percentage rollout (e.g., percentage: 50)
if ($key === 'percentage' && is_numeric($expectedValue)) {
$hash = crc32($this->name . ':' . (string) $context->getUserId());
$userPercentage = abs($hash) % 100;
if ($userPercentage >= $expectedValue) {
return false;
}
continue;
}
// Exact match
if ($contextValue !== $expectedValue) {
return false;
}
}
return true;
}
public function withStatus(FeatureFlagStatus $status): self
{
return new self(
$this->name,
$status,
$this->description,
$this->conditions,
$this->enabledAt,
$this->expiresAt
);
}
public function withConditions(array $conditions): self
{
return new self(
$this->name,
$this->status,
$this->description,
$conditions,
$this->enabledAt,
$this->expiresAt
);
}
public function withExpiration(Timestamp $expiresAt): self
{
return new self(
$this->name,
$this->status,
$this->description,
$this->conditions,
$this->enabledAt,
$expiresAt
);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Framework\FeatureFlags;
use App\Framework\Http\IpAddress;
use App\Framework\UserAgent\UserAgent;
/**
* Context for evaluating feature flags
*/
final readonly class FeatureFlagContext
{
/**
* @param array<string, mixed> $data
*/
public function __construct(
private array $data = [],
private ?string $userId = null,
private ?string $environment = null,
private ?UserAgent $userAgent = null,
private ?IpAddress $ipAddress = null
) {
}
public function getValue(string $key): mixed
{
return match($key) {
'user_id' => $this->userId,
'environment' => $this->environment,
'user_agent' => $this->userAgent?->value,
'ip_address' => $this->ipAddress?->value,
default => $this->data[$key] ?? null,
};
}
public function getUserId(): ?string
{
return $this->userId;
}
public function getEnvironment(): ?string
{
return $this->environment;
}
public function getUserAgent(): ?UserAgent
{
return $this->userAgent;
}
public function getIpAddress(): ?IpAddress
{
return $this->ipAddress;
}
public function withUserId(string $userId): self
{
return new self(
$this->data,
$userId,
$this->environment,
$this->userAgent,
$this->ipAddress
);
}
public function withEnvironment(string $environment): self
{
return new self(
$this->data,
$this->userId,
$environment,
$this->userAgent,
$this->ipAddress
);
}
public function withUserAgent(UserAgent $userAgent): self
{
return new self(
$this->data,
$this->userId,
$this->environment,
$userAgent,
$this->ipAddress
);
}
public function withIpAddress(IpAddress $ipAddress): self
{
return new self(
$this->data,
$this->userId,
$this->environment,
$this->userAgent,
$ipAddress
);
}
public function withData(array $data): self
{
return new self(
array_merge($this->data, $data),
$this->userId,
$this->environment,
$this->userAgent,
$this->ipAddress
);
}
public function with(string $key, mixed $value): self
{
return $this->withData([$key => $value]);
}
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Framework\FeatureFlags;
/**
* Main interface for feature flag operations
*/
final readonly class FeatureFlagManager
{
public function __construct(
private FeatureFlagRepository $repository,
private ?FeatureFlagContext $defaultContext = null
) {
}
/**
* Check if a feature is enabled
*/
public function isEnabled(string $flagName, ?FeatureFlagContext $context = null): bool
{
$flag = $this->repository->find($flagName);
if ($flag === null) {
return false;
}
$context = $context ?? $this->defaultContext ?? new FeatureFlagContext();
return $flag->isEnabledForContext($context);
}
/**
* Check if a feature is disabled
*/
public function isDisabled(string $flagName, ?FeatureFlagContext $context = null): bool
{
return ! $this->isEnabled($flagName, $context);
}
/**
* Get a feature flag by name
*/
public function getFlag(string $flagName): ?FeatureFlag
{
return $this->repository->find($flagName);
}
/**
* Get all feature flags
* @return FeatureFlag[]
*/
public function getAllFlags(): array
{
return $this->repository->findAll();
}
/**
* Enable a feature flag
*/
public function enable(string $flagName, ?string $description = null): void
{
$flag = $this->repository->find($flagName);
if ($flag === null) {
$flag = new FeatureFlag(
name: $flagName,
status: FeatureFlagStatus::ENABLED,
description: $description,
enabledAt: \App\Framework\Core\ValueObjects\Timestamp::now()
);
} else {
$flag = $flag->withStatus(FeatureFlagStatus::ENABLED);
}
$this->repository->save($flag);
}
/**
* Disable a feature flag
*/
public function disable(string $flagName): void
{
$flag = $this->repository->find($flagName);
if ($flag === null) {
$flag = new FeatureFlag(
name: $flagName,
status: FeatureFlagStatus::DISABLED
);
} else {
$flag = $flag->withStatus(FeatureFlagStatus::DISABLED);
}
$this->repository->save($flag);
}
/**
* Set conditional feature flag
*/
public function setConditional(
string $flagName,
array $conditions,
?string $description = null
): void {
$flag = $this->repository->find($flagName);
if ($flag === null) {
$flag = new FeatureFlag(
name: $flagName,
status: FeatureFlagStatus::CONDITIONAL,
description: $description,
conditions: $conditions
);
} else {
$flag = $flag->withStatus(FeatureFlagStatus::CONDITIONAL)
->withConditions($conditions);
}
$this->repository->save($flag);
}
/**
* Set percentage rollout for a feature
*/
public function setPercentageRollout(
string $flagName,
int $percentage,
?string $description = null
): void {
if ($percentage < 0 || $percentage > 100) {
throw new \InvalidArgumentException('Percentage must be between 0 and 100');
}
$this->setConditional($flagName, ['percentage' => $percentage], $description);
}
/**
* Set user-specific feature flag
*/
public function setForUsers(
string $flagName,
array $userIds,
?string $description = null
): void {
$this->setConditional($flagName, ['user_id' => $userIds], $description);
}
/**
* Set environment-specific feature flag
*/
public function setForEnvironment(
string $flagName,
string $environment,
?string $description = null
): void {
$this->setConditional($flagName, ['environment' => $environment], $description);
}
/**
* Set expiration for a feature flag
*/
public function setExpiration(
string $flagName,
\App\Framework\Core\ValueObjects\Timestamp $expiresAt
): void {
$flag = $this->repository->find($flagName);
if ($flag === null) {
throw new \InvalidArgumentException("Feature flag '{$flagName}' not found");
}
$flag = $flag->withExpiration($expiresAt);
$this->repository->save($flag);
}
/**
* Delete a feature flag
*/
public function deleteFlag(string $flagName): void
{
$this->repository->delete($flagName);
}
/**
* Check if a feature flag exists
*/
public function exists(string $flagName): bool
{
return $this->repository->exists($flagName);
}
/**
* Get feature flag status summary
*/
public function getStatusSummary(): array
{
$flags = $this->getAllFlags();
$summary = [
'total' => count($flags),
'enabled' => 0,
'disabled' => 0,
'conditional' => 0,
'expired' => 0,
];
$now = \App\Framework\Core\ValueObjects\Timestamp::now();
foreach ($flags as $flag) {
if ($flag->expiresAt !== null && $now->toFloat() > $flag->expiresAt->toFloat()) {
$summary['expired']++;
continue;
}
switch ($flag->status) {
case FeatureFlagStatus::ENABLED:
$summary['enabled']++;
break;
case FeatureFlagStatus::DISABLED:
$summary['disabled']++;
break;
case FeatureFlagStatus::CONDITIONAL:
$summary['conditional']++;
break;
}
}
return $summary;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\FeatureFlags;
/**
* Repository interface for feature flags
*/
interface FeatureFlagRepository
{
/**
* Get a feature flag by name
*/
public function find(string $name): ?FeatureFlag;
/**
* Get all feature flags
* @return FeatureFlag[]
*/
public function findAll(): array;
/**
* Save a feature flag
*/
public function save(FeatureFlag $flag): void;
/**
* Delete a feature flag
*/
public function delete(string $name): void;
/**
* Check if a feature flag exists
*/
public function exists(string $name): bool;
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\FeatureFlags;
/**
* Status of a feature flag
*/
enum FeatureFlagStatus: string
{
case ENABLED = 'enabled';
case DISABLED = 'disabled';
case CONDITIONAL = 'conditional';
public function isEnabled(): bool
{
return $this === self::ENABLED;
}
public function isDisabled(): bool
{
return $this === self::DISABLED;
}
public function isConditional(): bool
{
return $this === self::CONDITIONAL;
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Framework\FeatureFlags\Storage;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\FeatureFlags\FeatureFlag;
use App\Framework\FeatureFlags\FeatureFlagRepository;
/**
* Cached feature flag repository for better performance
*/
final readonly class CacheFeatureFlagRepository implements FeatureFlagRepository
{
private string $cachePrefix;
private Duration $cacheTtl;
public function __construct(
private FeatureFlagRepository $repository,
private Cache $cache,
string $cachePrefix = 'feature_flags',
?Duration $cacheTtl = null
) {
$this->cachePrefix = $cachePrefix;
$this->cacheTtl = $cacheTtl ?? Duration::fromMinutes(15);
}
public function find(string $name): ?FeatureFlag
{
$cacheKey = $this->getCacheKey($name);
// Try cache first
$cached = $this->cache->get($cacheKey);
if ($cached !== null) {
return $cached instanceof FeatureFlag ? $cached : null;
}
// Load from repository
$flag = $this->repository->find($name);
// Cache the result (including null results)
if ($flag !== null) {
$this->cache->set(CacheItem::forSet($cacheKey, $flag, $this->cacheTtl));
} else {
// Cache negative results for shorter time
$this->cache->set(CacheItem::forSet($cacheKey, 'NOT_FOUND', Duration::fromMinutes(5)));
}
return $flag;
}
public function findAll(): array
{
$cacheKey = $this->getCacheKey('all');
// Try cache first
$cached = $this->cache->get($cacheKey);
if (is_array($cached)) {
return $cached;
}
// Load from repository
$flags = $this->repository->findAll();
// Cache all flags
$this->cache->set(CacheItem::forSet($cacheKey, $flags, $this->cacheTtl));
return $flags;
}
public function save(FeatureFlag $flag): void
{
// Save to repository first
$this->repository->save($flag);
// Invalidate cache
$this->invalidateCache($flag->name);
}
public function delete(string $name): void
{
// Delete from repository first
$this->repository->delete($name);
// Invalidate cache
$this->invalidateCache($name);
}
public function exists(string $name): bool
{
// Use find() method which benefits from caching
return $this->find($name) !== null;
}
private function getCacheKey(string $key): CacheKey
{
return CacheKey::fromString($this->cachePrefix . ':' . $key);
}
private function invalidateCache(string $flagName): void
{
// Invalidate specific flag cache
$this->cache->forget($this->getCacheKey($flagName));
// Invalidate all flags cache
$this->cache->forget($this->getCacheKey('all'));
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Framework\FeatureFlags\Storage;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\FeatureFlags\FeatureFlag;
use App\Framework\FeatureFlags\FeatureFlagRepository;
use App\Framework\FeatureFlags\FeatureFlagStatus;
use App\Framework\Filesystem\FilesystemManager;
use App\Framework\Filesystem\Serializers\JsonSerializer;
/**
* File-based feature flag storage using Framework's FilesystemManager
*/
final readonly class FileFeatureFlagRepository implements FeatureFlagRepository
{
public function __construct(
private FilesystemManager $filesystem,
private string $filePath = 'feature_flags.json',
private string $storageName = 'default',
private JsonSerializer $serializer = new JsonSerializer()
) {
}
public function find(string $name): ?FeatureFlag
{
$flags = $this->loadFlags();
return $flags[$name] ?? null;
}
public function findAll(): array
{
return array_values($this->loadFlags());
}
public function save(FeatureFlag $flag): void
{
$flags = $this->loadFlags();
$flags[$flag->name] = $flag;
$this->saveFlags($flags);
}
public function delete(string $name): void
{
$flags = $this->loadFlags();
unset($flags[$name]);
$this->saveFlags($flags);
}
public function exists(string $name): bool
{
$flags = $this->loadFlags();
return isset($flags[$name]);
}
/**
* @return array<string, FeatureFlag>
*/
private function loadFlags(): array
{
$storage = $this->filesystem->storage($this->storageName);
if (! $storage->exists($this->filePath)) {
return [];
}
try {
$content = $storage->get($this->filePath);
$data = $this->serializer->deserialize($content);
if (! is_array($data)) {
return [];
}
$flags = [];
foreach ($data as $flagData) {
if (! is_array($flagData) || ! isset($flagData['name'])) {
continue;
}
$flag = $this->arrayToFeatureFlag($flagData);
if ($flag !== null) {
$flags[$flag->name] = $flag;
}
}
return $flags;
} catch (\Throwable) {
return [];
}
}
/**
* @param array<string, FeatureFlag> $flags
*/
private function saveFlags(array $flags): void
{
$data = [];
foreach ($flags as $flag) {
$data[] = $this->featureFlagToArray($flag);
}
$content = $this->serializer->serialize($data);
$storage = $this->filesystem->storage($this->storageName);
$storage->put($this->filePath, $content);
}
private function featureFlagToArray(FeatureFlag $flag): array
{
return [
'name' => $flag->name,
'status' => $flag->status->value,
'description' => $flag->description,
'conditions' => $flag->conditions,
'enabled_at' => $flag->enabledAt?->toIso8601(),
'expires_at' => $flag->expiresAt?->toIso8601(),
];
}
private function arrayToFeatureFlag(array $data): ?FeatureFlag
{
try {
$status = FeatureFlagStatus::from($data['status'] ?? 'disabled');
$enabledAt = isset($data['enabled_at']) && $data['enabled_at'] !== null
? Timestamp::fromFloat(strtotime($data['enabled_at']))
: null;
$expiresAt = isset($data['expires_at']) && $data['expires_at'] !== null
? Timestamp::fromFloat(strtotime($data['expires_at']))
: null;
return new FeatureFlag(
name: $data['name'],
status: $status,
description: $data['description'] ?? null,
conditions: $data['conditions'] ?? [],
enabledAt: $enabledAt,
expiresAt: $expiresAt
);
} catch (\Throwable) {
return null;
}
}
}