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:
268
src/Framework/Telemetry/Adapters/PerformanceCollectorAdapter.php
Normal file
268
src/Framework/Telemetry/Adapters/PerformanceCollectorAdapter.php
Normal file
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry\Adapters;
|
||||
|
||||
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
|
||||
use App\Framework\Performance\PerformanceCategory;
|
||||
use App\Framework\Performance\PerformanceMetric;
|
||||
use App\Framework\Telemetry\UnifiedTelemetryService;
|
||||
|
||||
/**
|
||||
* Adapts the UnifiedTelemetryService to the PerformanceCollectorInterface
|
||||
* This allows the telemetry system to be used as a drop-in replacement for the existing performance collector
|
||||
*/
|
||||
final class PerformanceCollectorAdapter implements PerformanceCollectorInterface
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{start: float, category: PerformanceCategory, context: array<string, mixed>}> Active timings
|
||||
*/
|
||||
private array $timings = [];
|
||||
|
||||
/**
|
||||
* @var array<string, PerformanceMetric> Collected metrics
|
||||
*/
|
||||
private array $metrics = [];
|
||||
|
||||
/**
|
||||
* @var float Start time of the request
|
||||
*/
|
||||
private float $requestStartTime;
|
||||
|
||||
/**
|
||||
* @var int Start memory usage of the request
|
||||
*/
|
||||
private int $requestStartMemory;
|
||||
|
||||
/**
|
||||
* @var bool Whether performance tracking is enabled
|
||||
*/
|
||||
private bool $enabled = true;
|
||||
|
||||
/**
|
||||
* @param UnifiedTelemetryService $telemetryService Telemetry service to adapt
|
||||
* @param PerformanceCollectorInterface $originalCollector Original collector for fallback
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UnifiedTelemetryService $telemetryService,
|
||||
private readonly PerformanceCollectorInterface $originalCollector
|
||||
) {
|
||||
$this->requestStartTime = $originalCollector->getTotalRequestTime();
|
||||
$this->requestStartMemory = $originalCollector->getTotalRequestMemory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start timing an operation
|
||||
*/
|
||||
public function startTiming(string $key, PerformanceCategory $category, array $context = []): void
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Record in original collector
|
||||
$this->originalCollector->startTiming($key, $category, $context);
|
||||
|
||||
// Record in telemetry service
|
||||
$this->telemetryService->startOperation(
|
||||
$key,
|
||||
$category->value,
|
||||
$context
|
||||
);
|
||||
|
||||
// Record locally for endTiming
|
||||
$this->timings[$key] = [
|
||||
'start' => microtime(true),
|
||||
'category' => $category,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* End timing an operation
|
||||
*/
|
||||
public function endTiming(string $key): void
|
||||
{
|
||||
if (! $this->enabled || ! isset($this->timings[$key])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// End in original collector
|
||||
$this->originalCollector->endTiming($key);
|
||||
|
||||
// End in telemetry service
|
||||
$this->telemetryService->endOperation($key);
|
||||
|
||||
// Calculate duration and record as metric
|
||||
$duration = (microtime(true) - $this->timings[$key]['start']) * 1000;
|
||||
$this->recordMetric(
|
||||
"{$key}_duration",
|
||||
$this->timings[$key]['category'],
|
||||
$duration,
|
||||
array_merge($this->timings[$key]['context'], ['unit' => 'ms'])
|
||||
);
|
||||
|
||||
// Remove from active timings
|
||||
unset($this->timings[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure a callable's execution time and memory usage
|
||||
*/
|
||||
public function measure(string $key, PerformanceCategory $category, callable $callback, array $context = []): mixed
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
return $callback();
|
||||
}
|
||||
|
||||
// Use telemetry service's trace method
|
||||
return $this->telemetryService->trace(
|
||||
$key,
|
||||
$category->value,
|
||||
$callback,
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a metric value
|
||||
*/
|
||||
public function recordMetric(string $key, PerformanceCategory $category, float $value, array $context = []): void
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Record in original collector
|
||||
$this->originalCollector->recordMetric($key, $category, $value, $context);
|
||||
|
||||
// Record in telemetry service
|
||||
$unit = $context['unit'] ?? '';
|
||||
$this->telemetryService->recordMetric(
|
||||
$key,
|
||||
$value,
|
||||
$unit,
|
||||
array_merge($context, ['category' => $category->value])
|
||||
);
|
||||
|
||||
// Store locally
|
||||
$this->metrics[$key] = PerformanceMetric::create($key, $category, $context)
|
||||
->withValue($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment a counter
|
||||
*/
|
||||
public function increment(string $key, PerformanceCategory $category, int $amount = 1, array $context = []): void
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment in original collector
|
||||
$this->originalCollector->increment($key, $category, $amount, $context);
|
||||
|
||||
// Get current value or default to 0
|
||||
$currentValue = 0;
|
||||
if (isset($this->metrics[$key])) {
|
||||
$currentValue = $this->metrics[$key]->getValue();
|
||||
}
|
||||
|
||||
// Record new value
|
||||
$this->recordMetric(
|
||||
$key,
|
||||
$category,
|
||||
$currentValue + $amount,
|
||||
array_merge($context, ['type' => 'counter'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metrics, optionally filtered by category
|
||||
*
|
||||
* @return array<string, PerformanceMetric>
|
||||
*/
|
||||
public function getMetrics(?PerformanceCategory $category = null): array
|
||||
{
|
||||
// Get metrics from original collector
|
||||
$originalMetrics = $this->originalCollector->getMetrics($category);
|
||||
|
||||
// Filter local metrics by category if needed
|
||||
if ($category !== null) {
|
||||
$filteredMetrics = [];
|
||||
foreach ($this->metrics as $key => $metric) {
|
||||
if ($metric->getCategory() === $category) {
|
||||
$filteredMetrics[$key] = $metric;
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge($originalMetrics, $filteredMetrics);
|
||||
}
|
||||
|
||||
return array_merge($originalMetrics, $this->metrics);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific metric by key
|
||||
*/
|
||||
public function getMetric(string $key): ?PerformanceMetric
|
||||
{
|
||||
if (isset($this->metrics[$key])) {
|
||||
return $this->metrics[$key];
|
||||
}
|
||||
|
||||
return $this->originalCollector->getMetric($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total request time in milliseconds
|
||||
*/
|
||||
public function getTotalRequestTime(): float
|
||||
{
|
||||
return (microtime(true) - $this->requestStartTime) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total request memory usage in bytes
|
||||
*/
|
||||
public function getTotalRequestMemory(): int
|
||||
{
|
||||
return memory_get_usage() - $this->requestStartMemory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get peak memory usage in bytes
|
||||
*/
|
||||
public function getPeakMemory(): int
|
||||
{
|
||||
return memory_get_peak_usage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all collected metrics
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->metrics = [];
|
||||
$this->timings = [];
|
||||
$this->originalCollector->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if performance tracking is enabled
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable performance tracking
|
||||
*/
|
||||
public function setEnabled(bool $enabled): void
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
$this->originalCollector->setEnabled($enabled);
|
||||
}
|
||||
}
|
||||
95
src/Framework/Telemetry/Config/ExporterConfig.php
Normal file
95
src/Framework/Telemetry/Config/ExporterConfig.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry\Config;
|
||||
|
||||
/**
|
||||
* Configuration for a telemetry exporter
|
||||
*/
|
||||
final class ExporterConfig
|
||||
{
|
||||
/**
|
||||
* @param string $endpoint Endpoint URL for the exporter
|
||||
* @param string $protocol Protocol to use (http, file, etc.)
|
||||
* @param array<string, mixed> $options Additional options for the exporter
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $endpoint,
|
||||
private readonly string $protocol = 'http',
|
||||
private readonly array $options = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get endpoint URL
|
||||
*/
|
||||
public function getEndpoint(): string
|
||||
{
|
||||
return $this->endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol
|
||||
*/
|
||||
public function getProtocol(): string
|
||||
{
|
||||
return $this->protocol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all options
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getOptions(): array
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific option
|
||||
*/
|
||||
public function getOption(string $name, mixed $default = null): mixed
|
||||
{
|
||||
return $this->options[$name] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an option exists
|
||||
*/
|
||||
public function hasOption(string $name): bool
|
||||
{
|
||||
return isset($this->options[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an HTTP exporter
|
||||
*/
|
||||
public function isHttpExporter(): bool
|
||||
{
|
||||
return $this->protocol === 'http' || $this->protocol === 'https';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a file exporter
|
||||
*/
|
||||
public function isFileExporter(): bool
|
||||
{
|
||||
return $this->protocol === 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with additional options
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function withOptions(array $options): self
|
||||
{
|
||||
return new self(
|
||||
$this->endpoint,
|
||||
$this->protocol,
|
||||
array_merge($this->options, $options)
|
||||
);
|
||||
}
|
||||
}
|
||||
148
src/Framework/Telemetry/Config/TelemetryConfig.php
Normal file
148
src/Framework/Telemetry/Config/TelemetryConfig.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry\Config;
|
||||
|
||||
/**
|
||||
* Configuration for the Telemetry system
|
||||
*/
|
||||
final class TelemetryConfig
|
||||
{
|
||||
/**
|
||||
* @param string $serviceName Name of the service
|
||||
* @param string $serviceVersion Version of the service
|
||||
* @param string $environment Environment (dev, test, prod)
|
||||
* @param bool $enabled Whether telemetry is enabled
|
||||
* @param float $samplingRatio Sampling ratio (0.0-1.0)
|
||||
* @param array<string, ExporterConfig> $exporters Configured exporters
|
||||
* @param array<string, mixed> $resourceAttributes Additional resource attributes
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $serviceName,
|
||||
private readonly string $serviceVersion,
|
||||
private readonly string $environment,
|
||||
private readonly bool $enabled = true,
|
||||
private readonly float $samplingRatio = 1.0,
|
||||
private readonly array $exporters = [],
|
||||
private readonly array $resourceAttributes = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create config from array
|
||||
*/
|
||||
public static function fromArray(array $config): self
|
||||
{
|
||||
$exporters = [];
|
||||
foreach ($config['exporters'] ?? [] as $name => $exporterConfig) {
|
||||
if (isset($exporterConfig['enabled']) && $exporterConfig['enabled']) {
|
||||
$exporters[$name] = new ExporterConfig(
|
||||
$exporterConfig['endpoint'] ?? '',
|
||||
$exporterConfig['protocol'] ?? 'http',
|
||||
$exporterConfig['options'] ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
$config['service_name'] ?? 'app',
|
||||
$config['service_version'] ?? '1.0.0',
|
||||
$config['environment'] ?? 'dev',
|
||||
$config['enabled'] ?? true,
|
||||
$config['sampling_ratio'] ?? 1.0,
|
||||
$exporters,
|
||||
$config['resource_attributes'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service name
|
||||
*/
|
||||
public function getServiceName(): string
|
||||
{
|
||||
return $this->serviceName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service version
|
||||
*/
|
||||
public function getServiceVersion(): string
|
||||
{
|
||||
return $this->serviceVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment
|
||||
*/
|
||||
public function getEnvironment(): string
|
||||
{
|
||||
return $this->environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sampling ratio
|
||||
*/
|
||||
public function getSamplingRatio(): float
|
||||
{
|
||||
return $this->samplingRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exporters
|
||||
*
|
||||
* @return array<string, ExporterConfig>
|
||||
*/
|
||||
public function getExporters(): array
|
||||
{
|
||||
return $this->exporters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource attributes
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getResourceAttributes(): array
|
||||
{
|
||||
return $this->resourceAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if telemetry is enabled
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all resource attributes including standard ones
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getAllResourceAttributes(): array
|
||||
{
|
||||
return array_merge([
|
||||
'service.name' => $this->serviceName,
|
||||
'service.version' => $this->serviceVersion,
|
||||
'deployment.environment' => $this->environment,
|
||||
], $this->resourceAttributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an exporter is configured
|
||||
*/
|
||||
public function hasExporter(string $name): bool
|
||||
{
|
||||
return isset($this->exporters[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an exporter configuration
|
||||
*/
|
||||
public function getExporter(string $name): ?ExporterConfig
|
||||
{
|
||||
return $this->exporters[$name] ?? null;
|
||||
}
|
||||
}
|
||||
226
src/Framework/Telemetry/Exporters/FileExporter.php
Normal file
226
src/Framework/Telemetry/Exporters/FileExporter.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry\Exporters;
|
||||
|
||||
use App\Framework\Telemetry\Config\ExporterConfig;
|
||||
use App\Framework\Telemetry\ValueObjects\Event;
|
||||
use App\Framework\Telemetry\ValueObjects\Metric;
|
||||
use App\Framework\Telemetry\ValueObjects\Operation;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Exports telemetry data to log files
|
||||
*/
|
||||
final class FileExporter implements TelemetryExporterInterface
|
||||
{
|
||||
/**
|
||||
* @var resource|null File handle for operations log
|
||||
*/
|
||||
private $operationsHandle = null;
|
||||
|
||||
/**
|
||||
* @var resource|null File handle for metrics log
|
||||
*/
|
||||
private $metricsHandle = null;
|
||||
|
||||
/**
|
||||
* @var resource|null File handle for events log
|
||||
*/
|
||||
private $eventsHandle = null;
|
||||
|
||||
/**
|
||||
* @param string $directory Directory where log files will be stored
|
||||
* @param bool $prettyPrint Whether to pretty-print JSON output
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $directory,
|
||||
private readonly bool $prettyPrint = false
|
||||
) {
|
||||
$this->ensureDirectoryExists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from exporter config
|
||||
*/
|
||||
public static function fromConfig(ExporterConfig $config): self
|
||||
{
|
||||
$directory = $config->getEndpoint();
|
||||
if (! $directory) {
|
||||
$directory = sys_get_temp_dir() . '/telemetry';
|
||||
}
|
||||
|
||||
$prettyPrint = $config->getOption('pretty_print', false);
|
||||
|
||||
return new self($directory, $prettyPrint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an operation
|
||||
*/
|
||||
public function exportOperation(Operation $operation): void
|
||||
{
|
||||
if (! $operation->isCompleted()) {
|
||||
return; // Only export completed operations
|
||||
}
|
||||
|
||||
$handle = $this->getOperationsHandle();
|
||||
$data = $operation->toArray();
|
||||
$this->writeToFile($handle, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a metric
|
||||
*/
|
||||
public function exportMetric(Metric $metric): void
|
||||
{
|
||||
$handle = $this->getMetricsHandle();
|
||||
$data = $metric->toArray();
|
||||
$this->writeToFile($handle, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an event
|
||||
*/
|
||||
public function exportEvent(Event $event): void
|
||||
{
|
||||
$handle = $this->getEventsHandle();
|
||||
$data = $event->toArray();
|
||||
$this->writeToFile($handle, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any buffered telemetry data
|
||||
*/
|
||||
public function flush(): void
|
||||
{
|
||||
if ($this->operationsHandle) {
|
||||
fflush($this->operationsHandle);
|
||||
}
|
||||
|
||||
if ($this->metricsHandle) {
|
||||
fflush($this->metricsHandle);
|
||||
}
|
||||
|
||||
if ($this->eventsHandle) {
|
||||
fflush($this->eventsHandle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all file handles
|
||||
*/
|
||||
public function close(): void
|
||||
{
|
||||
if ($this->operationsHandle) {
|
||||
fclose($this->operationsHandle);
|
||||
$this->operationsHandle = null;
|
||||
}
|
||||
|
||||
if ($this->metricsHandle) {
|
||||
fclose($this->metricsHandle);
|
||||
$this->metricsHandle = null;
|
||||
}
|
||||
|
||||
if ($this->eventsHandle) {
|
||||
fclose($this->eventsHandle);
|
||||
$this->eventsHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the log directory exists
|
||||
*/
|
||||
private function ensureDirectoryExists(): void
|
||||
{
|
||||
if (! is_dir($this->directory) && ! mkdir($this->directory, 0755, true)) {
|
||||
throw new RuntimeException("Failed to create directory: {$this->directory}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file handle for operations log
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
private function getOperationsHandle()
|
||||
{
|
||||
if ($this->operationsHandle === null) {
|
||||
$date = new DateTimeImmutable()->format('Y-m-d');
|
||||
$path = "{$this->directory}/operations-{$date}.jsonl";
|
||||
$this->operationsHandle = fopen($path, 'a');
|
||||
|
||||
if ($this->operationsHandle === false) {
|
||||
throw new RuntimeException("Failed to open operations log file: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
return $this->operationsHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file handle for metrics log
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
private function getMetricsHandle()
|
||||
{
|
||||
if ($this->metricsHandle === null) {
|
||||
$date = new DateTimeImmutable()->format('Y-m-d');
|
||||
$path = "{$this->directory}/metrics-{$date}.jsonl";
|
||||
$this->metricsHandle = fopen($path, 'a');
|
||||
|
||||
if ($this->metricsHandle === false) {
|
||||
throw new RuntimeException("Failed to open metrics log file: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
return $this->metricsHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file handle for events log
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
private function getEventsHandle()
|
||||
{
|
||||
if ($this->eventsHandle === null) {
|
||||
$date = new DateTimeImmutable()->format('Y-m-d');
|
||||
$path = "{$this->directory}/events-{$date}.jsonl";
|
||||
$this->eventsHandle = fopen($path, 'a');
|
||||
|
||||
if ($this->eventsHandle === false) {
|
||||
throw new RuntimeException("Failed to open events log file: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
return $this->eventsHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write data to a file
|
||||
*
|
||||
* @param resource $handle File handle
|
||||
* @param array<string, mixed> $data Data to write
|
||||
*/
|
||||
private function writeToFile($handle, array $data): void
|
||||
{
|
||||
$flags = $this->prettyPrint ? JSON_PRETTY_PRINT : 0;
|
||||
$json = json_encode($data, $flags) . PHP_EOL;
|
||||
|
||||
if (fwrite($handle, $json) === false) {
|
||||
throw new RuntimeException("Failed to write to log file");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructor to ensure files are closed
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
$this->close();
|
||||
}
|
||||
}
|
||||
271
src/Framework/Telemetry/Exporters/PrometheusExporter.php
Normal file
271
src/Framework/Telemetry/Exporters/PrometheusExporter.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry\Exporters;
|
||||
|
||||
use App\Framework\Telemetry\Config\ExporterConfig;
|
||||
use App\Framework\Telemetry\ValueObjects\Event;
|
||||
use App\Framework\Telemetry\ValueObjects\Metric;
|
||||
use App\Framework\Telemetry\ValueObjects\Operation;
|
||||
|
||||
/**
|
||||
* Exports telemetry data in Prometheus format
|
||||
*/
|
||||
final class PrometheusExporter implements TelemetryExporterInterface
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{type: string, help: string, values: array<string, float>}>
|
||||
*/
|
||||
private array $metrics = [];
|
||||
|
||||
/**
|
||||
* @param string $endpoint Endpoint for the Prometheus metrics (not used directly)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $endpoint = ''
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from exporter config
|
||||
*/
|
||||
public static function fromConfig(ExporterConfig $config): self
|
||||
{
|
||||
return new self($config->getEndpoint());
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an operation
|
||||
*/
|
||||
public function exportOperation(Operation $operation): void
|
||||
{
|
||||
if (! $operation->isCompleted()) {
|
||||
return; // Only export completed operations
|
||||
}
|
||||
|
||||
// Record operation duration as histogram
|
||||
$duration = $operation->getDuration() ?? 0;
|
||||
$this->recordHistogram(
|
||||
name: 'operation_duration_milliseconds',
|
||||
value: $duration,
|
||||
labels: [
|
||||
'name' => $operation->name,
|
||||
'type' => $operation->type,
|
||||
'status' => $operation->status,
|
||||
],
|
||||
help: 'Duration of operations in milliseconds'
|
||||
);
|
||||
|
||||
// Record operation count
|
||||
$this->incrementCounter(
|
||||
name: 'operations_total',
|
||||
labels: [
|
||||
'name' => $operation->name,
|
||||
'type' => $operation->type,
|
||||
'status' => $operation->status,
|
||||
],
|
||||
help: 'Total number of operations'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a metric
|
||||
*/
|
||||
public function exportMetric(Metric $metric): void
|
||||
{
|
||||
$name = $metric->getPrometheusName();
|
||||
$type = $metric->getPrometheusType();
|
||||
$help = $metric->getPrometheusHelp();
|
||||
$labels = $this->formatLabels($metric->attributes);
|
||||
|
||||
switch ($type) {
|
||||
case 'counter':
|
||||
$this->recordCounter($name, $metric->value, $labels, $help);
|
||||
|
||||
break;
|
||||
case 'gauge':
|
||||
$this->recordGauge($name, $metric->value, $labels, $help);
|
||||
|
||||
break;
|
||||
case 'histogram':
|
||||
$this->recordHistogram($name, $metric->value, $labels, $help);
|
||||
|
||||
break;
|
||||
default:
|
||||
$this->recordGauge($name, $metric->value, $labels, $help);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an event
|
||||
*/
|
||||
public function exportEvent(Event $event): void
|
||||
{
|
||||
// Events are exported as counters with the event name as a label
|
||||
$this->incrementCounter(
|
||||
name: "events_{$event->severity}_total",
|
||||
labels: array_merge(
|
||||
['event_name' => $event->name],
|
||||
$this->extractScalarAttributes($event->attributes)
|
||||
),
|
||||
help: "Total number of {$event->severity} events"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any buffered telemetry data
|
||||
*/
|
||||
public function flush(): void
|
||||
{
|
||||
// No buffering in this implementation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the metrics output in Prometheus format
|
||||
*/
|
||||
public function getMetricsOutput(): string
|
||||
{
|
||||
$output = [];
|
||||
|
||||
foreach ($this->metrics as $name => $metric) {
|
||||
$output[] = "# HELP {$name} {$metric['help']}";
|
||||
$output[] = "# TYPE {$name} {$metric['type']}";
|
||||
|
||||
foreach ($metric['values'] as $labels => $value) {
|
||||
$output[] = "{$name}{$labels} {$value}";
|
||||
}
|
||||
|
||||
// Add an empty line between metrics
|
||||
$output[] = "";
|
||||
}
|
||||
|
||||
return implode("\n", $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a counter metric
|
||||
*
|
||||
* @param string $name Metric name
|
||||
* @param float $value Metric value
|
||||
* @param array<string, mixed> $labels Metric labels
|
||||
* @param string $help Help text for the metric
|
||||
*/
|
||||
public function recordCounter(string $name, float $value, array $labels = [], string $help = ''): void
|
||||
{
|
||||
$this->ensureMetricExists($name, 'counter', $help);
|
||||
$labelString = $this->formatLabels($labels);
|
||||
$this->metrics[$name]['values'][$labelString] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment a counter metric
|
||||
*
|
||||
* @param string $name Metric name
|
||||
* @param array<string, mixed> $labels Metric labels
|
||||
* @param string $help Help text for the metric
|
||||
* @param float $increment Amount to increment by (default: 1)
|
||||
*/
|
||||
public function incrementCounter(string $name, array $labels = [], string $help = '', float $increment = 1.0): void
|
||||
{
|
||||
$this->ensureMetricExists($name, 'counter', $help);
|
||||
$labelString = $this->formatLabels($labels);
|
||||
|
||||
if (! isset($this->metrics[$name]['values'][$labelString])) {
|
||||
$this->metrics[$name]['values'][$labelString] = 0;
|
||||
}
|
||||
|
||||
$this->metrics[$name]['values'][$labelString] += $increment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a gauge metric
|
||||
*
|
||||
* @param string $name Metric name
|
||||
* @param float $value Metric value
|
||||
* @param array<string, mixed> $labels Metric labels
|
||||
* @param string $help Help text for the metric
|
||||
*/
|
||||
public function recordGauge(string $name, float $value, array $labels = [], string $help = ''): void
|
||||
{
|
||||
$this->ensureMetricExists($name, 'gauge', $help);
|
||||
$labelString = $this->formatLabels($labels);
|
||||
$this->metrics[$name]['values'][$labelString] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a histogram metric
|
||||
*
|
||||
* @param string $name Metric name
|
||||
* @param float $value Metric value
|
||||
* @param array<string, mixed> $labels Metric labels
|
||||
* @param string $help Help text for the metric
|
||||
*/
|
||||
public function recordHistogram(string $name, float $value, array $labels = [], string $help = ''): void
|
||||
{
|
||||
// For simplicity, we're just recording the value as a gauge
|
||||
// In a real implementation, we would maintain buckets for histograms
|
||||
$this->ensureMetricExists($name, 'histogram', $help);
|
||||
$labelString = $this->formatLabels($labels);
|
||||
$this->metrics[$name]['values'][$labelString] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a metric exists in the metrics array
|
||||
*/
|
||||
private function ensureMetricExists(string $name, string $type, string $help): void
|
||||
{
|
||||
if (! isset($this->metrics[$name])) {
|
||||
$this->metrics[$name] = [
|
||||
'type' => $type,
|
||||
'help' => $help ?: $name,
|
||||
'values' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format labels as a Prometheus label string
|
||||
*
|
||||
* @param array<string, mixed> $labels
|
||||
*/
|
||||
private function formatLabels(array $labels): string
|
||||
{
|
||||
if (empty($labels)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$formattedLabels = [];
|
||||
foreach ($labels as $key => $value) {
|
||||
// Skip non-scalar values
|
||||
if (is_scalar($value)) {
|
||||
$escapedValue = str_replace(['\\', '"', "\n"], ['\\\\', '\\"', '\\n'], (string)$value);
|
||||
$formattedLabels[] = "{$key}=\"{$escapedValue}\"";
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($formattedLabels)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '{' . implode(',', $formattedLabels) . '}';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract scalar attributes from an array
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, scalar>
|
||||
*/
|
||||
private function extractScalarAttributes(array $attributes): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($attributes as $key => $value) {
|
||||
if (is_scalar($value)) {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry\Exporters;
|
||||
|
||||
use App\Framework\Telemetry\ValueObjects\Event;
|
||||
use App\Framework\Telemetry\ValueObjects\Metric;
|
||||
use App\Framework\Telemetry\ValueObjects\Operation;
|
||||
|
||||
/**
|
||||
* Interface for telemetry exporters
|
||||
*/
|
||||
interface TelemetryExporterInterface
|
||||
{
|
||||
/**
|
||||
* Export an operation
|
||||
*
|
||||
* @param Operation $operation The operation to export
|
||||
*/
|
||||
public function exportOperation(Operation $operation): void;
|
||||
|
||||
/**
|
||||
* Export a metric
|
||||
*
|
||||
* @param Metric $metric The metric to export
|
||||
*/
|
||||
public function exportMetric(Metric $metric): void;
|
||||
|
||||
/**
|
||||
* Export an event
|
||||
*
|
||||
* @param Event $event The event to export
|
||||
*/
|
||||
public function exportEvent(Event $event): void;
|
||||
|
||||
/**
|
||||
* Flush any buffered telemetry data
|
||||
*/
|
||||
public function flush(): void;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry\Middleware;
|
||||
|
||||
use App\Framework\Database\Middleware\QueryContext;
|
||||
use App\Framework\Database\Middleware\QueryMiddleware;
|
||||
use App\Framework\Telemetry\UnifiedTelemetryService;
|
||||
|
||||
/**
|
||||
* Database middleware for telemetry query tracing
|
||||
*/
|
||||
final readonly class TelemetryDatabaseMiddleware implements QueryMiddleware
|
||||
{
|
||||
/**
|
||||
* @param UnifiedTelemetryService $telemetryService Telemetry service
|
||||
*/
|
||||
public function __construct(
|
||||
private UnifiedTelemetryService $telemetryService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a query operation through the middleware
|
||||
*/
|
||||
public function process(QueryContext $context, callable $next): mixed
|
||||
{
|
||||
// Skip if telemetry is disabled
|
||||
if (! $this->telemetryService->isEnabled()) {
|
||||
return $next();
|
||||
}
|
||||
|
||||
$query = $context->sql;
|
||||
$bindings = $context->parameters;
|
||||
$queryType = $this->getQueryType($query);
|
||||
|
||||
// Create attributes for the operation
|
||||
$attributes = [
|
||||
'db.system' => $this->getDatabaseSystem($context),
|
||||
'db.type' => 'sql',
|
||||
'db.operation' => $queryType,
|
||||
'db.statement' => $this->sanitizeQuery($query),
|
||||
'db.sql.table' => $this->extractTableName($query, $queryType),
|
||||
'db.user' => $context->connection->getConfig()->getUsername() ?? 'unknown',
|
||||
'net.peer.name' => $context->connection->getConfig()->getHost() ?? 'unknown',
|
||||
'net.peer.port' => $context->connection->getConfig()->getPort() ?? 0,
|
||||
'db.name' => $context->connection->getConfig()->getDatabase() ?? 'unknown',
|
||||
'db.parameters_count' => count($bindings),
|
||||
];
|
||||
|
||||
// Create an operation name for the query
|
||||
$operationName = "db.{$queryType}";
|
||||
|
||||
// Trace the query execution
|
||||
return $this->telemetryService->trace(
|
||||
$operationName,
|
||||
'database',
|
||||
function () use ($next) {
|
||||
return $next();
|
||||
},
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the priority of the middleware
|
||||
*/
|
||||
public function getPriority(): int
|
||||
{
|
||||
return 90; // Run after performance middleware but before most other middleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the query type from the SQL query
|
||||
*/
|
||||
private function getQueryType(string $query): string
|
||||
{
|
||||
$query = trim(strtoupper($query));
|
||||
|
||||
if (str_starts_with($query, 'SELECT')) {
|
||||
return 'select';
|
||||
}
|
||||
if (str_starts_with($query, 'INSERT')) {
|
||||
return 'insert';
|
||||
}
|
||||
if (str_starts_with($query, 'UPDATE')) {
|
||||
return 'update';
|
||||
}
|
||||
if (str_starts_with($query, 'DELETE')) {
|
||||
return 'delete';
|
||||
}
|
||||
if (str_starts_with($query, 'CREATE')) {
|
||||
return 'create';
|
||||
}
|
||||
if (str_starts_with($query, 'DROP')) {
|
||||
return 'drop';
|
||||
}
|
||||
if (str_starts_with($query, 'ALTER')) {
|
||||
return 'alter';
|
||||
}
|
||||
if (str_starts_with($query, 'SHOW')) {
|
||||
return 'show';
|
||||
}
|
||||
if (str_starts_with($query, 'DESCRIBE') || str_starts_with($query, 'DESC')) {
|
||||
return 'describe';
|
||||
}
|
||||
if (str_starts_with($query, 'BEGIN') || str_starts_with($query, 'START')) {
|
||||
return 'transaction_begin';
|
||||
}
|
||||
if (str_starts_with($query, 'COMMIT')) {
|
||||
return 'transaction_commit';
|
||||
}
|
||||
if (str_starts_with($query, 'ROLLBACK')) {
|
||||
return 'transaction_rollback';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a query to remove sensitive data
|
||||
*/
|
||||
private function sanitizeQuery(string $query): string
|
||||
{
|
||||
// Remove potential sensitive data from queries for logging
|
||||
$query = preg_replace('/\b\d{4}[-\s]\d{4}[-\s]\d{4}[-\s]\d{4}\b/', '****-****-****-****', $query);
|
||||
$query = preg_replace('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', '***@***.***', $query);
|
||||
|
||||
// Replace password values
|
||||
$query = preg_replace('/password\s*=\s*[\'"][^\'"]*[\'"]/i', 'password=\'***\'', $query);
|
||||
$query = preg_replace('/password\s*=\s*[^\s,;)]+/i', 'password=***', $query);
|
||||
|
||||
// Truncate very long queries
|
||||
if (strlen($query) > 1000) {
|
||||
$query = substr($query, 0, 1000) . '...';
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the table name from a query
|
||||
*/
|
||||
private function extractTableName(string $query, string $queryType): string
|
||||
{
|
||||
$query = trim($query);
|
||||
|
||||
// Simple regex-based extraction - in a real implementation, this would be more robust
|
||||
$patterns = [
|
||||
'select' => '/FROM\s+`?([a-zA-Z0-9_]+)`?/i',
|
||||
'insert' => '/INSERT\s+INTO\s+`?([a-zA-Z0-9_]+)`?/i',
|
||||
'update' => '/UPDATE\s+`?([a-zA-Z0-9_]+)`?/i',
|
||||
'delete' => '/DELETE\s+FROM\s+`?([a-zA-Z0-9_]+)`?/i',
|
||||
'create' => '/CREATE\s+TABLE\s+`?([a-zA-Z0-9_]+)`?/i',
|
||||
'drop' => '/DROP\s+TABLE\s+`?([a-zA-Z0-9_]+)`?/i',
|
||||
'alter' => '/ALTER\s+TABLE\s+`?([a-zA-Z0-9_]+)`?/i',
|
||||
];
|
||||
|
||||
if (isset($patterns[$queryType])) {
|
||||
if (preg_match($patterns[$queryType], $query, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database system from the connection
|
||||
*/
|
||||
private function getDatabaseSystem(QueryContext $context): string
|
||||
{
|
||||
$driver = $context->connection->getConfig()->getDriver() ?? '';
|
||||
|
||||
return match ($driver) {
|
||||
'mysql', 'mysqli' => 'mysql',
|
||||
'pgsql' => 'postgresql',
|
||||
'sqlite' => 'sqlite',
|
||||
'sqlsrv' => 'mssql',
|
||||
'oci' => 'oracle',
|
||||
default => $driver ?: 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
226
src/Framework/Telemetry/Middleware/TelemetryHttpMiddleware.php
Normal file
226
src/Framework/Telemetry/Middleware/TelemetryHttpMiddleware.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry\Middleware;
|
||||
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Http\MiddlewarePriorityAttribute;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Telemetry\OperationHandle;
|
||||
use App\Framework\Telemetry\UnifiedTelemetryService;
|
||||
|
||||
/**
|
||||
* Middleware for tracing HTTP requests
|
||||
*/
|
||||
#[MiddlewarePriorityAttribute(MiddlewarePriority::VERY_EARLY)]
|
||||
final readonly class TelemetryHttpMiddleware implements HttpMiddleware
|
||||
{
|
||||
/**
|
||||
* @param UnifiedTelemetryService $telemetryService Telemetry service
|
||||
*/
|
||||
public function __construct(
|
||||
private UnifiedTelemetryService $telemetryService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the middleware context
|
||||
*/
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
if (! $this->telemetryService->isEnabled()) {
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
$request = $context->request;
|
||||
|
||||
// Start operation for the request
|
||||
$operation = $this->telemetryService->startOperation(
|
||||
$this->getSpanName($request),
|
||||
'http',
|
||||
$this->createRequestAttributes($request)
|
||||
);
|
||||
|
||||
try {
|
||||
// Process the request through the middleware chain
|
||||
$resultContext = $next($context);
|
||||
|
||||
// If we have a response, add response attributes
|
||||
if ($resultContext->hasResponse()) {
|
||||
$response = $resultContext->response;
|
||||
$this->addResponseAttributes($operation, $response);
|
||||
$this->setSpanStatus($operation, $response);
|
||||
}
|
||||
|
||||
return $resultContext;
|
||||
} catch (\Throwable $e) {
|
||||
// Mark operation as failed
|
||||
$operation->fail($e->getMessage());
|
||||
|
||||
// Re-throw the exception
|
||||
throw $e;
|
||||
} finally {
|
||||
// End the operation if it hasn't been ended yet
|
||||
if ($operation->getId() !== 'disabled' && $operation->getId() !== 'error') {
|
||||
$operation->end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the span name for the request
|
||||
*/
|
||||
private function getSpanName($request): string
|
||||
{
|
||||
$method = $request->method;
|
||||
$path = $request->uri->getPath();
|
||||
|
||||
// Use a generic name if the path is empty
|
||||
if (empty($path)) {
|
||||
$path = '/';
|
||||
}
|
||||
|
||||
return "{$method} {$path}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create attributes for the request
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function createRequestAttributes($request): array
|
||||
{
|
||||
$attributes = [
|
||||
'http.method' => $request->method,
|
||||
'http.url' => (string)$request->uri,
|
||||
'http.host' => $request->uri->getHost(),
|
||||
'http.scheme' => $request->uri->getScheme() ?: 'http',
|
||||
'http.target' => $request->uri->getPath(),
|
||||
];
|
||||
|
||||
// Add query parameters if present
|
||||
$query = $request->uri->getQuery();
|
||||
if (! empty($query)) {
|
||||
$attributes['http.query'] = $this->sanitizeQuery($query);
|
||||
}
|
||||
|
||||
// Add content length if present
|
||||
$contentLength = $request->headers->get('Content-Length');
|
||||
if (! empty($contentLength)) {
|
||||
$attributes['http.request_content_length'] = (int)$contentLength;
|
||||
}
|
||||
|
||||
// Add user agent if present
|
||||
$userAgent = $request->headers->get('User-Agent');
|
||||
if (! empty($userAgent)) {
|
||||
$attributes['http.user_agent'] = $userAgent;
|
||||
}
|
||||
|
||||
// Add client IP if available
|
||||
$clientIp = $this->getClientIp($request);
|
||||
if ($clientIp !== null) {
|
||||
$attributes['http.client_ip'] = $clientIp;
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add response attributes to the operation
|
||||
*/
|
||||
private function addResponseAttributes(OperationHandle $operation, $response): void
|
||||
{
|
||||
$operation->addAttribute('http.status_code', $response->statusCode);
|
||||
|
||||
// Add content length if present
|
||||
$contentLength = $response->headers->get('Content-Length');
|
||||
if (! empty($contentLength)) {
|
||||
$operation->addAttribute('http.response_content_length', (int)$contentLength);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the span status based on the response
|
||||
*/
|
||||
private function setSpanStatus(OperationHandle $operation, $response): void
|
||||
{
|
||||
$statusCode = $response->statusCode;
|
||||
|
||||
if ($statusCode >= 400) {
|
||||
$operation->end('error', "HTTP {$statusCode}");
|
||||
} else {
|
||||
$operation->end('success');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize query string to remove sensitive information
|
||||
*/
|
||||
private function sanitizeQuery(string $query): string
|
||||
{
|
||||
parse_str($query, $params);
|
||||
|
||||
// List of sensitive parameter names
|
||||
$sensitiveParams = ['password', 'token', 'api_key', 'apikey', 'secret', 'credential'];
|
||||
|
||||
// Redact sensitive parameters
|
||||
foreach ($params as $key => $value) {
|
||||
if ($this->isSensitiveParameter($key, $sensitiveParams)) {
|
||||
$params[$key] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
|
||||
return http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a parameter name is sensitive
|
||||
*
|
||||
* @param string $paramName Parameter name
|
||||
* @param array<string> $sensitiveParams List of sensitive parameter names
|
||||
*/
|
||||
private function isSensitiveParameter(string $paramName, array $sensitiveParams): bool
|
||||
{
|
||||
$paramName = strtolower($paramName);
|
||||
|
||||
// Check for exact matches
|
||||
if (in_array($paramName, $sensitiveParams, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for partial matches
|
||||
foreach ($sensitiveParams as $sensitiveParam) {
|
||||
if (str_contains($paramName, $sensitiveParam)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client IP address from the request
|
||||
*/
|
||||
private function getClientIp($request): ?string
|
||||
{
|
||||
// Check X-Forwarded-For header
|
||||
$forwardedFor = $request->headers->get('X-Forwarded-For');
|
||||
if (! empty($forwardedFor)) {
|
||||
$ips = explode(',', $forwardedFor);
|
||||
|
||||
return trim($ips[0]);
|
||||
}
|
||||
|
||||
// Check REMOTE_ADDR server parameter
|
||||
$serverParams = $request->serverParams;
|
||||
if (isset($serverParams['REMOTE_ADDR'])) {
|
||||
return $serverParams['REMOTE_ADDR'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
77
src/Framework/Telemetry/OperationHandle.php
Normal file
77
src/Framework/Telemetry/OperationHandle.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry;
|
||||
|
||||
/**
|
||||
* Handle for an active operation, providing a fluent API
|
||||
*/
|
||||
final class OperationHandle
|
||||
{
|
||||
/**
|
||||
* @param string $operationId ID of the operation
|
||||
* @param UnifiedTelemetryService $telemetryService Telemetry service that created this handle
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $operationId,
|
||||
private readonly UnifiedTelemetryService $telemetryService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the operation ID
|
||||
*/
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->operationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an attribute to the operation
|
||||
*/
|
||||
public function addAttribute(string $key, mixed $value): self
|
||||
{
|
||||
$this->telemetryService->addOperationAttribute($this->operationId, $key, $value);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* End the operation with the given status and error message
|
||||
*/
|
||||
public function end(string $status = 'success', ?string $errorMessage = null): self
|
||||
{
|
||||
$this->telemetryService->endOperation($this->operationId, $status, $errorMessage);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the operation as failed with the given error message
|
||||
*/
|
||||
public function fail(string $errorMessage): self
|
||||
{
|
||||
return $this->end('error', $errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a metric within the context of this operation
|
||||
*/
|
||||
public function recordMetric(string $name, float $value, string $unit = '', array $attributes = []): self
|
||||
{
|
||||
$this->telemetryService->recordMetric($name, $value, $unit, $attributes);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an event within the context of this operation
|
||||
*/
|
||||
public function recordEvent(string $name, array $attributes = [], string $severity = 'info'): self
|
||||
{
|
||||
$this->telemetryService->recordEvent($name, $attributes, $severity);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
192
src/Framework/Telemetry/README.md
Normal file
192
src/Framework/Telemetry/README.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Telemetry Module
|
||||
|
||||
The Telemetry module provides a unified system for collecting, processing, and exporting telemetry data from the application. It supports tracing operations, recording metrics, and capturing events.
|
||||
|
||||
## Core Components
|
||||
|
||||
### UnifiedTelemetryService
|
||||
|
||||
The main entry point for the telemetry system. It provides methods for:
|
||||
|
||||
- Starting and ending operations
|
||||
- Recording metrics
|
||||
- Recording events
|
||||
- Tracing function execution
|
||||
|
||||
```php
|
||||
// Get the telemetry service from the container
|
||||
$telemetry = $container->get(UnifiedTelemetryService::class);
|
||||
|
||||
// Start an operation
|
||||
$operation = $telemetry->startOperation('process_order', 'business', [
|
||||
'order_id' => $orderId,
|
||||
'customer_id' => $customerId
|
||||
]);
|
||||
|
||||
try {
|
||||
// Perform the operation
|
||||
$result = processOrder($orderId);
|
||||
|
||||
// Record a metric
|
||||
$telemetry->recordMetric('order_value', $result->getTotalValue(), 'EUR');
|
||||
|
||||
// Record an event
|
||||
$telemetry->recordEvent('order_processed', [
|
||||
'order_id' => $orderId,
|
||||
'status' => 'success'
|
||||
]);
|
||||
|
||||
// End the operation
|
||||
$operation->end('success');
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
// Mark the operation as failed
|
||||
$operation->fail($e->getMessage());
|
||||
|
||||
// Re-throw the exception
|
||||
throw $e;
|
||||
}
|
||||
```
|
||||
|
||||
### Trace Method
|
||||
|
||||
For simpler cases, you can use the `trace` method to automatically handle operation lifecycle:
|
||||
|
||||
```php
|
||||
$result = $telemetry->trace(
|
||||
'process_order',
|
||||
'business',
|
||||
function() use ($orderId) {
|
||||
return processOrder($orderId);
|
||||
},
|
||||
[
|
||||
'order_id' => $orderId,
|
||||
'customer_id' => $customerId
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
### Operation
|
||||
|
||||
Represents a discrete operation or span of work in the application. Operations can be nested to create a trace.
|
||||
|
||||
```php
|
||||
$operation = $telemetry->startOperation('name', 'type', ['attribute' => 'value']);
|
||||
$operation->addAttribute('key', 'value');
|
||||
$operation->end('success');
|
||||
```
|
||||
|
||||
### Metric
|
||||
|
||||
Represents a measurable value. Metrics can be counters, gauges, or histograms.
|
||||
|
||||
```php
|
||||
$telemetry->recordMetric('name', 42.0, 'unit', ['attribute' => 'value']);
|
||||
```
|
||||
|
||||
### Event
|
||||
|
||||
Represents a discrete occurrence. Events can have different severity levels.
|
||||
|
||||
```php
|
||||
$telemetry->recordEvent('name', ['attribute' => 'value'], 'info');
|
||||
```
|
||||
|
||||
## Exporters
|
||||
|
||||
Telemetry data can be exported to various backends using exporters.
|
||||
|
||||
### FileExporter
|
||||
|
||||
Exports telemetry data to log files in JSON Lines format.
|
||||
|
||||
```php
|
||||
$exporter = new FileExporter('/path/to/logs');
|
||||
$telemetry->addExporter($exporter);
|
||||
```
|
||||
|
||||
### PrometheusExporter
|
||||
|
||||
Exports metrics in Prometheus format for scraping.
|
||||
|
||||
```php
|
||||
$exporter = new PrometheusExporter();
|
||||
$telemetry->addExporter($exporter);
|
||||
|
||||
// In your metrics endpoint:
|
||||
$metricsOutput = $exporter->getMetricsOutput();
|
||||
return new Response($metricsOutput, 200, ['Content-Type' => 'text/plain']);
|
||||
```
|
||||
|
||||
## Integration Components
|
||||
|
||||
### TelemetryHttpMiddleware
|
||||
|
||||
Middleware for tracing HTTP requests.
|
||||
|
||||
```php
|
||||
// Register in your middleware stack
|
||||
$middlewareManager->add(TelemetryHttpMiddleware::class);
|
||||
```
|
||||
|
||||
### TelemetryDatabaseMiddleware
|
||||
|
||||
Middleware for tracing database queries.
|
||||
|
||||
```php
|
||||
// Register in your database middleware pipeline
|
||||
$pipeline->add(new TelemetryDatabaseMiddleware($telemetryService));
|
||||
```
|
||||
|
||||
### Cache Telemetry
|
||||
|
||||
Cache telemetry is available through the MetricsDecoratedCache which provides comprehensive cache operation tracking and performance monitoring. For custom telemetry needs, implement a custom cache decorator using the current batched Cache interface.
|
||||
|
||||
### PerformanceCollectorAdapter
|
||||
|
||||
Adapter that implements `PerformanceCollectorInterface` and forwards calls to the telemetry service.
|
||||
|
||||
```php
|
||||
// Register in your container
|
||||
$container->singleton(PerformanceCollectorInterface::class, function ($c) {
|
||||
return new PerformanceCollectorAdapter(
|
||||
$c->get(UnifiedTelemetryService::class),
|
||||
$c->get(EnhancedPerformanceCollector::class)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The telemetry system can be configured using the `TelemetryConfig` class:
|
||||
|
||||
```php
|
||||
$config = new TelemetryConfig(
|
||||
serviceName: 'my-service',
|
||||
serviceVersion: '1.0.0',
|
||||
environment: 'production',
|
||||
enabled: true,
|
||||
samplingRatio: 0.1, // Sample 10% of operations
|
||||
exporters: [
|
||||
'file' => new ExporterConfig('/path/to/logs', 'file'),
|
||||
'prometheus' => new ExporterConfig('http://localhost:9090', 'http')
|
||||
],
|
||||
resourceAttributes: [
|
||||
'deployment.environment' => 'production',
|
||||
'service.instance.id' => 'instance-1'
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use meaningful operation names**: Choose descriptive names that indicate what the operation does.
|
||||
2. **Add relevant attributes**: Include attributes that help identify the context of the operation.
|
||||
3. **Use appropriate operation types**: Use consistent types like 'http', 'database', 'cache', 'business', etc.
|
||||
4. **Handle errors properly**: Always mark operations as failed when exceptions occur.
|
||||
5. **Use the trace method for simple cases**: For simple operations, use the `trace` method to automatically handle the operation lifecycle.
|
||||
6. **Be mindful of sensitive data**: Avoid including sensitive data in attributes or event details.
|
||||
7. **Use sampling in production**: In high-volume production environments, use sampling to reduce the amount of telemetry data collected.
|
||||
139
src/Framework/Telemetry/TelemetryContext.php
Normal file
139
src/Framework/Telemetry/TelemetryContext.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry;
|
||||
|
||||
use App\Framework\Telemetry\ValueObjects\Operation;
|
||||
|
||||
/**
|
||||
* Manages the telemetry context, including active operations and their relationships
|
||||
*/
|
||||
final class TelemetryContext
|
||||
{
|
||||
/**
|
||||
* @var array<string, Operation> Map of operation IDs to Operation objects
|
||||
*/
|
||||
private array $operations = [];
|
||||
|
||||
/**
|
||||
* @var string|null ID of the current operation
|
||||
*/
|
||||
private ?string $currentOperationId = null;
|
||||
|
||||
/**
|
||||
* Get the ID of the current operation
|
||||
*/
|
||||
public function getCurrentOperationId(): ?string
|
||||
{
|
||||
return $this->currentOperationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current operation
|
||||
*/
|
||||
public function getCurrentOperation(): ?Operation
|
||||
{
|
||||
if ($this->currentOperationId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->operations[$this->currentOperationId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current operation
|
||||
*/
|
||||
public function setCurrentOperation(Operation $operation): void
|
||||
{
|
||||
$this->operations[$operation->id] = $operation;
|
||||
$this->currentOperationId = $operation->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an operation by ID
|
||||
*/
|
||||
public function getOperation(string $id): ?Operation
|
||||
{
|
||||
return $this->operations[$id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an operation from the context
|
||||
*/
|
||||
public function removeOperation(string $id): void
|
||||
{
|
||||
unset($this->operations[$id]);
|
||||
|
||||
// If this was the current operation, clear the current operation ID
|
||||
if ($this->currentOperationId === $id) {
|
||||
$this->currentOperationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the parent operation as the current operation
|
||||
*/
|
||||
public function restoreParentOperation(?string $parentId): void
|
||||
{
|
||||
if ($parentId !== null && isset($this->operations[$parentId])) {
|
||||
$this->currentOperationId = $parentId;
|
||||
} else {
|
||||
$this->currentOperationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active operations
|
||||
*
|
||||
* @return array<string, Operation>
|
||||
*/
|
||||
public function getActiveOperations(): array
|
||||
{
|
||||
return array_filter($this->operations, fn (Operation $op) => ! $op->isCompleted());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all operations
|
||||
*
|
||||
* @return array<string, Operation>
|
||||
*/
|
||||
public function getAllOperations(): array
|
||||
{
|
||||
return $this->operations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all operations
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->operations = [];
|
||||
$this->currentOperationId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the operation stack as a string for debugging
|
||||
*/
|
||||
public function getOperationStackAsString(): string
|
||||
{
|
||||
if ($this->currentOperationId === null) {
|
||||
return 'No active operations';
|
||||
}
|
||||
|
||||
$stack = [];
|
||||
$currentId = $this->currentOperationId;
|
||||
|
||||
while ($currentId !== null) {
|
||||
$operation = $this->operations[$currentId] ?? null;
|
||||
if ($operation === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
$stack[] = "{$operation->name} ({$operation->type})";
|
||||
$currentId = $operation->parentId;
|
||||
}
|
||||
|
||||
return implode(' > ', array_reverse($stack));
|
||||
}
|
||||
}
|
||||
426
src/Framework/Telemetry/UnifiedTelemetryService.php
Normal file
426
src/Framework/Telemetry/UnifiedTelemetryService.php
Normal file
@@ -0,0 +1,426 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry;
|
||||
|
||||
use App\Framework\Attributes\Singleton;
|
||||
use App\Framework\CircuitBreaker\CircuitBreaker;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
|
||||
use App\Framework\Performance\PerformanceCategory;
|
||||
use App\Framework\Telemetry\Config\TelemetryConfig;
|
||||
use App\Framework\Telemetry\Exporters\TelemetryExporterInterface;
|
||||
use App\Framework\Telemetry\ValueObjects\Event;
|
||||
use App\Framework\Telemetry\ValueObjects\Metric;
|
||||
use App\Framework\Telemetry\ValueObjects\Operation;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Unified telemetry service that provides a central point for collecting and exporting telemetry data
|
||||
*/
|
||||
#[Singleton]
|
||||
final class UnifiedTelemetryService
|
||||
{
|
||||
/**
|
||||
* @var array<TelemetryExporterInterface> Exporters for telemetry data
|
||||
*/
|
||||
private array $exporters = [];
|
||||
|
||||
/**
|
||||
* @var TelemetryContext Context for tracking operations
|
||||
*/
|
||||
private TelemetryContext $context;
|
||||
|
||||
/**
|
||||
* @param PerformanceCollectorInterface $performanceCollector Performance collector for timing operations
|
||||
* @param CircuitBreaker $circuitBreaker Circuit breaker for resilience
|
||||
* @param Logger $logger Logger for error reporting
|
||||
* @param Clock $clock Clock for timing operations
|
||||
* @param TelemetryConfig $config Configuration for telemetry
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly PerformanceCollectorInterface $performanceCollector,
|
||||
private readonly CircuitBreaker $circuitBreaker,
|
||||
private readonly Logger $logger,
|
||||
private readonly Clock $clock,
|
||||
private readonly TelemetryConfig $config
|
||||
) {
|
||||
$this->context = new TelemetryContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an exporter for telemetry data
|
||||
*/
|
||||
public function addExporter(TelemetryExporterInterface $exporter): self
|
||||
{
|
||||
$this->exporters[] = $exporter;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new operation
|
||||
*
|
||||
* @param string $name Name of the operation
|
||||
* @param string $type Type of operation (e.g., http, database, custom)
|
||||
* @param array<string, mixed> $attributes Additional attributes for the operation
|
||||
*/
|
||||
public function startOperation(string $name, string $type, array $attributes = []): OperationHandle
|
||||
{
|
||||
if (! $this->config->isEnabled()) {
|
||||
// Return a dummy handle if telemetry is disabled
|
||||
return new OperationHandle('disabled', $this);
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate a unique ID for the operation
|
||||
$operationId = $this->generateId();
|
||||
$parentId = $this->context->getCurrentOperationId();
|
||||
|
||||
// Create the operation
|
||||
$operation = new Operation(
|
||||
id: $operationId,
|
||||
parentId: $parentId,
|
||||
name: $name,
|
||||
type: $type,
|
||||
startTime: $this->clock->now(),
|
||||
attributes: $attributes
|
||||
);
|
||||
|
||||
// Store the operation in the context
|
||||
$this->context->setCurrentOperation($operation);
|
||||
|
||||
// Start timing the operation
|
||||
$this->performanceCollector->startTiming(
|
||||
$operationId,
|
||||
$this->mapTypeToCategory($type),
|
||||
$attributes
|
||||
);
|
||||
|
||||
// Return a handle for the operation
|
||||
return new OperationHandle($operationId, $this);
|
||||
} catch (Throwable $e) {
|
||||
// Log the error but don't throw - telemetry should not break the application
|
||||
$this->logger->error("Failed to start operation: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
'operation_name' => $name,
|
||||
'operation_type' => $type,
|
||||
]);
|
||||
|
||||
// Return a dummy handle
|
||||
return new OperationHandle('error', $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End an operation
|
||||
*
|
||||
* @param string $operationId ID of the operation to end
|
||||
* @param string $status Status of the operation (success, error)
|
||||
* @param string|null $errorMessage Error message if the operation failed
|
||||
*/
|
||||
public function endOperation(string $operationId, ?string $status = null, ?string $errorMessage = null): void
|
||||
{
|
||||
if (! $this->config->isEnabled() || $operationId === 'disabled' || $operationId === 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the operation from the context
|
||||
$operation = $this->context->getOperation($operationId);
|
||||
if ($operation === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// End timing the operation
|
||||
$this->performanceCollector->endTiming($operationId);
|
||||
|
||||
// Update the operation
|
||||
$operation->endTime = $this->clock->now();
|
||||
$operation->status = $status ?? 'success';
|
||||
$operation->errorMessage = $errorMessage;
|
||||
|
||||
// Export the operation
|
||||
$this->exportOperation($operation);
|
||||
|
||||
// Remove the operation from the context
|
||||
$this->context->removeOperation($operationId);
|
||||
|
||||
// Restore the parent operation as the current operation
|
||||
$this->context->restoreParentOperation($operation->parentId);
|
||||
} catch (Throwable $e) {
|
||||
// Log the error but don't throw - telemetry should not break the application
|
||||
$this->logger->error("Failed to end operation: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
'operation_id' => $operationId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an attribute to an operation
|
||||
*
|
||||
* @param string $operationId ID of the operation
|
||||
* @param string $key Attribute key
|
||||
* @param mixed $value Attribute value
|
||||
*/
|
||||
public function addOperationAttribute(string $operationId, string $key, mixed $value): void
|
||||
{
|
||||
if (! $this->config->isEnabled() || $operationId === 'disabled' || $operationId === 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$operation = $this->context->getOperation($operationId);
|
||||
if ($operation !== null) {
|
||||
$operation->addAttribute($key, $value);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Log the error but don't throw - telemetry should not break the application
|
||||
$this->logger->error("Failed to add operation attribute: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
'operation_id' => $operationId,
|
||||
'key' => $key,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a metric
|
||||
*
|
||||
* @param string $name Name of the metric
|
||||
* @param float $value Value of the metric
|
||||
* @param string $unit Unit of measurement (e.g., ms, bytes, count)
|
||||
* @param array<string, mixed> $attributes Additional attributes for the metric
|
||||
*/
|
||||
public function recordMetric(string $name, float $value, string $unit = '', array $attributes = []): void
|
||||
{
|
||||
if (! $this->config->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Add the current operation ID to the attributes
|
||||
$attributes = array_merge(
|
||||
$attributes,
|
||||
['operation_id' => $this->context->getCurrentOperationId()]
|
||||
);
|
||||
|
||||
// Create the metric
|
||||
$metric = new Metric(
|
||||
name: $name,
|
||||
value: $value,
|
||||
unit: $unit,
|
||||
type: 'gauge',
|
||||
timestamp: $this->clock->now(),
|
||||
attributes: $attributes
|
||||
);
|
||||
|
||||
// Export the metric
|
||||
$this->exportMetric($metric);
|
||||
} catch (Throwable $e) {
|
||||
// Log the error but don't throw - telemetry should not break the application
|
||||
$this->logger->error("Failed to record metric: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
'metric_name' => $name,
|
||||
'metric_value' => $value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an event
|
||||
*
|
||||
* @param string $name Name of the event
|
||||
* @param array<string, mixed> $attributes Additional attributes for the event
|
||||
* @param string $severity Severity of the event (info, warning, error)
|
||||
*/
|
||||
public function recordEvent(string $name, array $attributes = [], string $severity = 'info'): void
|
||||
{
|
||||
if (! $this->config->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Add the current operation ID to the attributes
|
||||
$attributes = array_merge(
|
||||
$attributes,
|
||||
['operation_id' => $this->context->getCurrentOperationId()]
|
||||
);
|
||||
|
||||
// Create the event
|
||||
$event = new Event(
|
||||
name: $name,
|
||||
timestamp: $this->clock->now(),
|
||||
severity: $severity,
|
||||
attributes: $attributes
|
||||
);
|
||||
|
||||
// Export the event
|
||||
$this->exportEvent($event);
|
||||
} catch (Throwable $e) {
|
||||
// Log the error but don't throw - telemetry should not break the application
|
||||
$this->logger->error("Failed to record event: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
'event_name' => $name,
|
||||
'event_severity' => $severity,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function within the context of an operation
|
||||
*
|
||||
* @template T
|
||||
* @param string $name Name of the operation
|
||||
* @param string $type Type of operation
|
||||
* @param callable(): T $callback Function to execute
|
||||
* @param array<string, mixed> $attributes Additional attributes for the operation
|
||||
* @return T Result of the callback
|
||||
*/
|
||||
public function trace(string $name, string $type, callable $callback, array $attributes = []): mixed
|
||||
{
|
||||
$operation = $this->startOperation($name, $type, $attributes);
|
||||
|
||||
try {
|
||||
$result = $callback();
|
||||
$operation->end('success');
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
$operation->fail($e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current operation stack as a string for debugging
|
||||
*/
|
||||
public function getOperationStack(): string
|
||||
{
|
||||
return $this->context->getOperationStackAsString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an operation to all exporters
|
||||
*/
|
||||
private function exportOperation(Operation $operation): void
|
||||
{
|
||||
foreach ($this->exporters as $exporter) {
|
||||
try {
|
||||
$exporter->exportOperation($operation);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error("Failed to export operation: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
'operation_id' => $operation->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a metric to all exporters
|
||||
*/
|
||||
private function exportMetric(Metric $metric): void
|
||||
{
|
||||
foreach ($this->exporters as $exporter) {
|
||||
try {
|
||||
$exporter->exportMetric($metric);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error("Failed to export metric: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
'metric_name' => $metric->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an event to all exporters
|
||||
*/
|
||||
private function exportEvent(Event $event): void
|
||||
{
|
||||
foreach ($this->exporters as $exporter) {
|
||||
try {
|
||||
$exporter->exportEvent($event);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error("Failed to export event: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
'event_name' => $event->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for an operation
|
||||
*/
|
||||
private function generateId(): string
|
||||
{
|
||||
return bin2hex(random_bytes(8));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map operation type to performance category
|
||||
*/
|
||||
private function mapTypeToCategory(string $type): PerformanceCategory
|
||||
{
|
||||
return match($type) {
|
||||
'http' => PerformanceCategory::API,
|
||||
'database' => PerformanceCategory::DATABASE,
|
||||
'cache' => PerformanceCategory::CACHE,
|
||||
'view' => PerformanceCategory::VIEW,
|
||||
'system' => PerformanceCategory::SYSTEM,
|
||||
'template' => PerformanceCategory::TEMPLATE,
|
||||
'routing' => PerformanceCategory::ROUTING,
|
||||
'controller' => PerformanceCategory::CONTROLLER,
|
||||
'filesystem' => PerformanceCategory::FILESYSTEM,
|
||||
'security' => PerformanceCategory::SECURITY,
|
||||
'benchmark' => PerformanceCategory::BENCHMARK,
|
||||
default => PerformanceCategory::CUSTOM
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all exporters
|
||||
*/
|
||||
public function flush(): void
|
||||
{
|
||||
foreach ($this->exporters as $exporter) {
|
||||
try {
|
||||
$exporter->flush();
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error("Failed to flush exporter: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if telemetry is enabled
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->config->isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the telemetry configuration
|
||||
*/
|
||||
public function getConfig(): TelemetryConfig
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructor to ensure exporters are flushed
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
$this->flush();
|
||||
}
|
||||
}
|
||||
117
src/Framework/Telemetry/ValueObjects/Event.php
Normal file
117
src/Framework/Telemetry/ValueObjects/Event.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry\ValueObjects;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Represents a telemetry event (a discrete occurrence)
|
||||
*/
|
||||
final class Event
|
||||
{
|
||||
/**
|
||||
* @param string $name Name of the event
|
||||
* @param DateTimeImmutable $timestamp Time when the event occurred
|
||||
* @param string $severity Severity level of the event (info, warning, error)
|
||||
* @param array<string, mixed> $attributes Additional attributes for the event
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly DateTimeImmutable $timestamp = new DateTimeImmutable(),
|
||||
public readonly string $severity = 'info',
|
||||
public readonly array $attributes = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the event to an array
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'timestamp' => $this->timestamp->format('Y-m-d\TH:i:s.uP'),
|
||||
'severity' => $this->severity,
|
||||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with additional attributes
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function withAttributes(array $attributes): self
|
||||
{
|
||||
return new self(
|
||||
$this->name,
|
||||
$this->timestamp,
|
||||
$this->severity,
|
||||
array_merge($this->attributes, $attributes)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an info event
|
||||
*/
|
||||
public static function info(string $name, array $attributes = []): self
|
||||
{
|
||||
return new self(
|
||||
$name,
|
||||
new DateTimeImmutable(),
|
||||
'info',
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a warning event
|
||||
*/
|
||||
public static function warning(string $name, array $attributes = []): self
|
||||
{
|
||||
return new self(
|
||||
$name,
|
||||
new DateTimeImmutable(),
|
||||
'warning',
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error event
|
||||
*/
|
||||
public static function error(string $name, array $attributes = []): self
|
||||
{
|
||||
return new self(
|
||||
$name,
|
||||
new DateTimeImmutable(),
|
||||
'error',
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the formatted name for metrics
|
||||
*/
|
||||
public function getMetricName(): string
|
||||
{
|
||||
return "event_{$this->severity}_total";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attributes with event name added
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getMetricAttributes(): array
|
||||
{
|
||||
return array_merge(
|
||||
$this->attributes,
|
||||
['event_name' => $this->name]
|
||||
);
|
||||
}
|
||||
}
|
||||
167
src/Framework/Telemetry/ValueObjects/Metric.php
Normal file
167
src/Framework/Telemetry/ValueObjects/Metric.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry\ValueObjects;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Represents a telemetry metric (a measurable value)
|
||||
*/
|
||||
final class Metric
|
||||
{
|
||||
/**
|
||||
* @param string $name Name of the metric
|
||||
* @param float $value Value of the metric
|
||||
* @param string $unit Unit of measurement (e.g., ms, bytes, count)
|
||||
* @param string $type Type of metric (counter, gauge, histogram)
|
||||
* @param DateTimeImmutable $timestamp Time when the metric was recorded
|
||||
* @param array<string, mixed> $attributes Additional attributes for the metric
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly float $value,
|
||||
public readonly string $unit = '',
|
||||
public readonly string $type = 'gauge',
|
||||
public readonly DateTimeImmutable $timestamp = new DateTimeImmutable(),
|
||||
public readonly array $attributes = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the metric to an array
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'value' => $this->value,
|
||||
'unit' => $this->unit,
|
||||
'type' => $this->type,
|
||||
'timestamp' => $this->timestamp->format('Y-m-d\TH:i:s.uP'),
|
||||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with additional attributes
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function withAttributes(array $attributes): self
|
||||
{
|
||||
return new self(
|
||||
$this->name,
|
||||
$this->value,
|
||||
$this->unit,
|
||||
$this->type,
|
||||
$this->timestamp,
|
||||
array_merge($this->attributes, $attributes)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a counter metric
|
||||
*/
|
||||
public static function counter(string $name, float $value, array $attributes = []): self
|
||||
{
|
||||
return new self(
|
||||
$name,
|
||||
$value,
|
||||
'count',
|
||||
'counter',
|
||||
new DateTimeImmutable(),
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a gauge metric
|
||||
*/
|
||||
public static function gauge(string $name, float $value, string $unit = '', array $attributes = []): self
|
||||
{
|
||||
return new self(
|
||||
$name,
|
||||
$value,
|
||||
$unit,
|
||||
'gauge',
|
||||
new DateTimeImmutable(),
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a histogram metric
|
||||
*/
|
||||
public static function histogram(string $name, float $value, string $unit = '', array $attributes = []): self
|
||||
{
|
||||
return new self(
|
||||
$name,
|
||||
$value,
|
||||
$unit,
|
||||
'histogram',
|
||||
new DateTimeImmutable(),
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the formatted name for Prometheus
|
||||
*/
|
||||
public function getPrometheusName(): string
|
||||
{
|
||||
// Convert to Prometheus naming convention (lowercase with underscores)
|
||||
return strtolower(str_replace(['.', '-', ' '], '_', $this->name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Prometheus metric type
|
||||
*/
|
||||
public function getPrometheusType(): string
|
||||
{
|
||||
return match($this->type) {
|
||||
'counter' => 'counter',
|
||||
'gauge' => 'gauge',
|
||||
'histogram' => 'histogram',
|
||||
default => 'untyped'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Prometheus help text
|
||||
*/
|
||||
public function getPrometheusHelp(): string
|
||||
{
|
||||
$unitText = $this->unit ? " in {$this->unit}" : '';
|
||||
|
||||
return "{$this->name}{$unitText}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the attributes as Prometheus labels
|
||||
*/
|
||||
public function getPrometheusLabels(): string
|
||||
{
|
||||
if (empty($this->attributes)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$labels = [];
|
||||
foreach ($this->attributes as $key => $value) {
|
||||
// Skip non-scalar values
|
||||
if (is_scalar($value)) {
|
||||
$escapedValue = str_replace(['\\', '"', "\n"], ['\\\\', '\\"', '\\n'], (string)$value);
|
||||
$labels[] = "{$key}=\"{$escapedValue}\"";
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($labels)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '{' . implode(',', $labels) . '}';
|
||||
}
|
||||
}
|
||||
117
src/Framework/Telemetry/ValueObjects/Operation.php
Normal file
117
src/Framework/Telemetry/ValueObjects/Operation.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Telemetry\ValueObjects;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Represents a telemetry operation (similar to a span in OpenTelemetry)
|
||||
*/
|
||||
final class Operation
|
||||
{
|
||||
public ?DateTimeImmutable $endTime = null;
|
||||
|
||||
public string $status = 'pending';
|
||||
|
||||
public ?string $errorMessage = null;
|
||||
|
||||
/**
|
||||
* @param string $id Unique identifier for the operation
|
||||
* @param string|null $parentId Parent operation ID if this is a child operation
|
||||
* @param string $name Name of the operation
|
||||
* @param string $type Type of operation (e.g., http, database, custom)
|
||||
* @param DateTimeImmutable $startTime Start time of the operation
|
||||
* @param array<string, mixed> $attributes Additional attributes for the operation
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly ?string $parentId,
|
||||
public readonly string $name,
|
||||
public readonly string $type,
|
||||
public readonly DateTimeImmutable $startTime,
|
||||
public array $attributes = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the duration of the operation in milliseconds
|
||||
*/
|
||||
public function getDuration(): ?float
|
||||
{
|
||||
if ($this->endTime === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ($this->endTime->getTimestamp() - $this->startTime->getTimestamp()) * 1000 +
|
||||
($this->endTime->format('u') - $this->startTime->format('u')) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the operation to an array
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'parent_id' => $this->parentId,
|
||||
'name' => $this->name,
|
||||
'type' => $this->type,
|
||||
'start_time' => $this->startTime->format('Y-m-d\TH:i:s.uP'),
|
||||
'end_time' => $this->endTime?->format('Y-m-d\TH:i:s.uP'),
|
||||
'duration_ms' => $this->getDuration(),
|
||||
'status' => $this->status,
|
||||
'error_message' => $this->errorMessage,
|
||||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an attribute to the operation
|
||||
*/
|
||||
public function addAttribute(string $key, mixed $value): self
|
||||
{
|
||||
$this->attributes[$key] = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* End the operation with the given status and error message
|
||||
*/
|
||||
public function end(string $status = 'success', ?string $errorMessage = null): self
|
||||
{
|
||||
$this->endTime = new DateTimeImmutable();
|
||||
$this->status = $status;
|
||||
$this->errorMessage = $errorMessage;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the operation as failed with the given error message
|
||||
*/
|
||||
public function fail(string $errorMessage): self
|
||||
{
|
||||
return $this->end('error', $errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the operation is completed
|
||||
*/
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->endTime !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the operation was successful
|
||||
*/
|
||||
public function isSuccessful(): bool
|
||||
{
|
||||
return $this->isCompleted() && $this->status === 'success';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user