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,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);
}
}

View 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)
);
}
}

View 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;
}
}

View 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();
}
}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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',
};
}
}

View 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;
}
}

View 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;
}
}

View 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.

View 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));
}
}

View 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();
}
}

View 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]
);
}
}

View 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) . '}';
}
}

View 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';
}
}