diff --git a/ProcessMaker/Cache/Monitoring/CacheMetricsDecorator.php b/ProcessMaker/Cache/Monitoring/CacheMetricsDecorator.php index f0c54aa7cc..198f5ff35e 100644 --- a/ProcessMaker/Cache/Monitoring/CacheMetricsDecorator.php +++ b/ProcessMaker/Cache/Monitoring/CacheMetricsDecorator.php @@ -4,6 +4,7 @@ use ProcessMaker\Cache\CacheInterface; use ProcessMaker\Cache\Monitoring\CacheMetricsInterface; +use ProcessMaker\Contracts\PrometheusMetricInterface; /** * Decorator class that adds metrics tracking about cache operations @@ -64,11 +65,18 @@ public function get(string $key, mixed $default = null): mixed $endTime = microtime(true); $duration = $endTime - $startTime; + // Get extra labels for metrics + $labels = []; + if ($value instanceof PrometheusMetricInterface) { + $labels['label'] = $value->getPrometheusMetricLabel(); + } else { + $labels['label'] = $key; + } // Record metrics based on key existence, not value comparison if ($exists) { - $this->metrics->recordHit($key, $duration); + $this->metrics->recordHit($key, $duration, $labels); } else { - $this->metrics->recordMiss($key, $duration); + $this->metrics->recordMiss($key, $duration, $labels); } return $value; @@ -88,10 +96,18 @@ public function set(string $key, mixed $value, null|int|\DateInterval $ttl = nul { $result = $this->cache->set($key, $value, $ttl); + // Get extra labels for metrics + $labels = []; + if ($value instanceof PrometheusMetricInterface) { + $labels['label'] = $value->getPrometheusMetricLabel(); + } else { + $labels['label'] = $key; + } + if ($result) { // Calculate approximate size in bytes $size = $this->calculateSize($value); - $this->metrics->recordWrite($key, $size); + $this->metrics->recordWrite($key, $size, $labels); } return $result; diff --git a/ProcessMaker/Cache/Monitoring/CacheMetricsInterface.php b/ProcessMaker/Cache/Monitoring/CacheMetricsInterface.php index 421ca457cb..030e8162ea 100644 --- a/ProcessMaker/Cache/Monitoring/CacheMetricsInterface.php +++ b/ProcessMaker/Cache/Monitoring/CacheMetricsInterface.php @@ -12,8 +12,9 @@ interface CacheMetricsInterface * * @param string $key Cache key that was accessed * @param float $microtime Time taken for the operation in microseconds + * @param array $labels Additional labels to attach to the metric */ - public function recordHit(string $key, $microtime): void; + public function recordHit(string $key, $microtime, array $labels = []): void; /** * Record a cache miss event @@ -21,7 +22,7 @@ public function recordHit(string $key, $microtime): void; * @param string $key Cache key that was accessed * @param float $microtime Time taken for the operation in microseconds */ - public function recordMiss(string $key, $microtime): void; + public function recordMiss(string $key, $microtime, array $labels = []): void; /** * Record a cache write operation @@ -29,5 +30,5 @@ public function recordMiss(string $key, $microtime): void; * @param string $key Cache key that was written * @param int $size Size of the cached data in bytes */ - public function recordWrite(string $key, int $size): void; + public function recordWrite(string $key, int $size, array $labels = []): void; } diff --git a/ProcessMaker/Cache/Monitoring/PrometheusMetricsManager.php b/ProcessMaker/Cache/Monitoring/PrometheusMetricsManager.php index 97033a120a..c063ba0810 100644 --- a/ProcessMaker/Cache/Monitoring/PrometheusMetricsManager.php +++ b/ProcessMaker/Cache/Monitoring/PrometheusMetricsManager.php @@ -35,21 +35,22 @@ public function __construct(string $namespace = 'cache') * @param string $key Cache key * @param float $microtime Time taken in microseconds */ - public function recordHit(string $key, $microtime): void + public function recordHit(string $key, $microtime, array $labels = []): void { $sanitizedKey = $this->sanitizeKey($key); + $labelKeys = array_keys($labels); $this->metrics->counter( 'cache_hits_total', 'Total number of cache hits', - ['cache_key'] - )->inc(['cache_key' => $sanitizedKey]); + ['cache_key', ...$labelKeys] + )->inc(['cache_key' => $sanitizedKey, ...$labels]); // record the last write timestamp $this->metrics->gauge( 'cache_last_write_timestamp', 'Last write timestamp', - ['cache_key'] - )->set($microtime, ['cache_key' => $sanitizedKey]); + ['cache_key', ...$labelKeys] + )->set($microtime, ['cache_key' => $sanitizedKey, ...$labels]); } /** @@ -58,22 +59,23 @@ public function recordHit(string $key, $microtime): void * @param string $key Cache key * @param float $microtime Time taken in microseconds */ - public function recordMiss(string $key, $microtime): void + public function recordMiss(string $key, $microtime, array $labels = []): void { $sanitizedKey = $this->sanitizeKey($key); + $labelKeys = array_keys($labels); $this->metrics->counter( 'cache_misses_total', 'Total number of cache misses', - ['cache_key'] - )->inc(['cache_key' => $sanitizedKey]); + ['cache_key', ...$labelKeys] + )->inc(['cache_key' => $sanitizedKey, ...$labels]); // record the last write timestamp $this->metrics->gauge( 'cache_last_write_timestamp', 'Last write timestamp', - ['cache_key'] - )->set($microtime, ['cache_key' => $sanitizedKey]); + ['cache_key', ...$labelKeys] + )->set($microtime, ['cache_key' => $sanitizedKey, ...$labels]); } /** @@ -82,15 +84,16 @@ public function recordMiss(string $key, $microtime): void * @param string $key Cache key * @param int $size Size in bytes */ - public function recordWrite(string $key, int $size): void + public function recordWrite(string $key, int $size, array $labels = []): void { $sanitizedKey = $this->sanitizeKey($key); + $labelKeys = array_keys($labels); $this->metrics->gauge( 'cache_memory_bytes', 'Memory usage in bytes', - ['cache_key'] - )->set($size, ['cache_key' => $sanitizedKey]); + ['cache_key', ...$labelKeys] + )->set($size, ['cache_key' => $sanitizedKey, ...$labels]); } /** diff --git a/ProcessMaker/Cache/Monitoring/RedisMetricsManager.php b/ProcessMaker/Cache/Monitoring/RedisMetricsManager.php index 942bc9d4a4..343f0bf0ae 100644 --- a/ProcessMaker/Cache/Monitoring/RedisMetricsManager.php +++ b/ProcessMaker/Cache/Monitoring/RedisMetricsManager.php @@ -26,7 +26,7 @@ class RedisMetricsManager implements CacheMetricsInterface * @param string $key Cache key * @param float $microtime Time taken in microseconds */ - public function recordHit(string $key, $microtime): void + public function recordHit(string $key, $microtime, array $labels = []): void { $baseKey = self::METRICS_PREFIX . $key; Redis::pipeline(function ($pipe) use ($baseKey, $microtime) { @@ -42,7 +42,7 @@ public function recordHit(string $key, $microtime): void * @param string $key Cache key * @param float $microtime Time taken in microseconds */ - public function recordMiss(string $key, $microtime): void + public function recordMiss(string $key, $microtime, array $labels = []): void { $baseKey = self::METRICS_PREFIX . $key; Redis::pipeline(function ($pipe) use ($baseKey, $microtime) { @@ -58,7 +58,7 @@ public function recordMiss(string $key, $microtime): void * @param string $key Cache key * @param int $size Size in bytes */ - public function recordWrite(string $key, int $size): void + public function recordWrite(string $key, int $size, array $labels = []): void { $baseKey = self::METRICS_PREFIX . $key; Redis::pipeline(function ($pipe) use ($baseKey, $size) { diff --git a/ProcessMaker/Cache/Screens/ScreenCache.php b/ProcessMaker/Cache/Screens/ScreenCache.php new file mode 100644 index 0000000000..ba408916d3 --- /dev/null +++ b/ProcessMaker/Cache/Screens/ScreenCache.php @@ -0,0 +1,30 @@ +label = $screen->getPrometheusMetricLabel(); + + return $self; + } + + /** + * Returns a legible or friendly name for Prometheus metrics. + * + * @return string + */ + public function getPrometheusMetricLabel(): string + { + return $this->label; + } +} diff --git a/ProcessMaker/Contracts/PrometheusMetricInterface.php b/ProcessMaker/Contracts/PrometheusMetricInterface.php new file mode 100644 index 0000000000..dc366e2644 --- /dev/null +++ b/ProcessMaker/Contracts/PrometheusMetricInterface.php @@ -0,0 +1,13 @@ +updateData($token, $data); $this->engine->runToNextState(); $element->complete($token); + + Metrics::counter( + 'activity_completed_total', + 'Total number of activities completed', + [ + 'activity_id', + 'activity_name', + 'process_id', + 'request_id', + ] + )->inc( + [ + 'activity_id' => $element->getId(), + 'activity_name' => $element->getName(), + 'process_id' => $this->definitionsId, + 'request_id' => $this->instanceId, + ] + ); } } diff --git a/ProcessMaker/Models/Screen.php b/ProcessMaker/Models/Screen.php index 1182ab16be..4073546922 100644 --- a/ProcessMaker/Models/Screen.php +++ b/ProcessMaker/Models/Screen.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use ProcessMaker\Assets\ScreensInScreen; +use ProcessMaker\Contracts\PrometheusMetricInterface; use ProcessMaker\Contracts\ScreenInterface; use ProcessMaker\Events\TranslationChanged; use ProcessMaker\Traits\Exportable; @@ -63,7 +64,7 @@ * @OA\Property(property="url", type="string"), * ) */ -class Screen extends ProcessMakerModel implements ScreenInterface +class Screen extends ProcessMakerModel implements ScreenInterface, PrometheusMetricInterface { use SerializeToIso8601; use HideSystemResources; @@ -283,4 +284,14 @@ public function scopeFilter($query, $filterStr) return $query; } + + /** + * Return the label to be used in grafana reports + * + * @return string + */ + public function getPrometheusMetricLabel(): string + { + return 'screen.' . $this->id; + } } diff --git a/ProcessMaker/Models/ScreenVersion.php b/ProcessMaker/Models/ScreenVersion.php index 47867cd637..ead25ce27b 100644 --- a/ProcessMaker/Models/ScreenVersion.php +++ b/ProcessMaker/Models/ScreenVersion.php @@ -3,12 +3,13 @@ namespace ProcessMaker\Models; use Illuminate\Database\Eloquent\Builder; +use ProcessMaker\Contracts\PrometheusMetricInterface; use ProcessMaker\Contracts\ScreenInterface; use ProcessMaker\Events\TranslationChanged; use ProcessMaker\Traits\HasCategories; use ProcessMaker\Traits\HasScreenFields; -class ScreenVersion extends ProcessMakerModel implements ScreenInterface +class ScreenVersion extends ProcessMakerModel implements ScreenInterface, PrometheusMetricInterface { use HasCategories; use HasScreenFields; @@ -77,4 +78,9 @@ public function scopePublished(Builder $query) { return $query->where('draft', false); } + + public function getPrometheusMetricLabel(): string + { + return 'screen.' . $this->screen_id; + } } diff --git a/ProcessMaker/Models/Setting.php b/ProcessMaker/Models/Setting.php index c023379b25..7d1b48ff94 100644 --- a/ProcessMaker/Models/Setting.php +++ b/ProcessMaker/Models/Setting.php @@ -8,6 +8,7 @@ use Illuminate\Validation\Rule; use Log; use ProcessMaker\Cache\Settings\SettingCacheFactory; +use ProcessMaker\Contracts\PrometheusMetricInterface; use ProcessMaker\Traits\ExtendedPMQL; use ProcessMaker\Traits\SerializeToIso8601; use Spatie\MediaLibrary\HasMedia; @@ -48,7 +49,7 @@ * }, * ) */ -class Setting extends ProcessMakerModel implements HasMedia +class Setting extends ProcessMakerModel implements HasMedia, PrometheusMetricInterface { use ExtendedPMQL; use InteractsWithMedia; @@ -497,4 +498,14 @@ public static function updateAllSettingsGroupId() } }); } + + /** + * Get the label used in grafana reports + * + * @return string + */ + public function getPrometheusMetricLabel(): string + { + return 'settings.' . $this->key; + } } diff --git a/ProcessMaker/Services/MetricsService.php b/ProcessMaker/Services/MetricsService.php index a44efaef2e..1adc166d5c 100644 --- a/ProcessMaker/Services/MetricsService.php +++ b/ProcessMaker/Services/MetricsService.php @@ -32,7 +32,7 @@ class MetricsService * * @param mixed $adapter The storage adapter to use (e.g., Redis). */ - public function __construct($adapter = null) + public function __construct(private $adapter = null) { $this->namespace = config('app.prometheus_namespace', 'app'); try { @@ -49,6 +49,16 @@ public function __construct($adapter = null) } } + /** + * Get the collection registry. + * + * @return CollectorRegistry The collection registry instance. + */ + public function getCollectionRegistry(): CollectorRegistry + { + return $this->collectionRegistry; + } + /** * Registers or retrieves a counter metric. * diff --git a/ProcessMaker/Traits/HasScreenFields.php b/ProcessMaker/Traits/HasScreenFields.php index 909cc13ddd..52c17f6cac 100644 --- a/ProcessMaker/Traits/HasScreenFields.php +++ b/ProcessMaker/Traits/HasScreenFields.php @@ -4,6 +4,7 @@ use Illuminate\Support\Arr; use Log; +use ProcessMaker\Cache\Screens\ScreenCache; use ProcessMaker\Cache\Screens\ScreenCacheFactory; use ProcessMaker\Models\Column; use ProcessMaker\Models\Screen; @@ -60,14 +61,15 @@ private function loadScreenFields() $parsedFields = $screenCache->get($key); if (!$parsedFields || collect($parsedFields)->isEmpty()) { - $this->parsedFields = collect([]); + $this->parsedFields = ScreenCache::makeFrom($this, []); if ($this->config) { $this->walkArray($this->config); } + $this->parsedFields = ScreenCache::makeFrom($this, $this->parsedFields); $screenCache->set($key, $this->parsedFields); } else { - $this->parsedFields = collect($parsedFields); + $this->parsedFields = ScreenCache::makeFrom($this, $parsedFields); } } diff --git a/tests/Feature/Metrics/TaskMetricsTest.php b/tests/Feature/Metrics/TaskMetricsTest.php new file mode 100644 index 0000000000..b133316a7d --- /dev/null +++ b/tests/Feature/Metrics/TaskMetricsTest.php @@ -0,0 +1,53 @@ +create([ + 'is_administrator' => true, + ]); + + $bpmnFile = 'tests/Fixtures/single_task_with_screen.bpmn'; + $process = $this->createProcessFromBPMN($bpmnFile, [ + 'user_id' => $user->id, + ]); + + $this->be($user); + + $startEvent = $process->getDefinitions()->getStartEvent('node_1'); + $request = WorkflowManager::triggerStartEvent($process, $startEvent, []); + + $formTask = $request->tokens()->where('element_id', 'node_2')->firstOrFail(); + + // Complete the task + WorkflowManager::completeTask($process, $request, $formTask, ['someValue' => 123]); + + // Verify that the metric was stored + $this->assertMetricWasStored('activity_completed_total', [ + 'activity_id' => 'node_2', + 'activity_name' => 'Form Task', + 'process_id' => $process->id, + 'request_id' => $request->id, + ]); + } + + private function assertMetricWasStored(string $name, array $labels) + { + $adapter = Metrics::getCollectionRegistry(); + $ns = config('app.prometheus_namespace', 'app'); + $metric = $adapter->getCounter($ns, $name); + + $this->assertInstanceOf(Counter::class, $metric); + $this->assertEquals($metric->getLabelNames(), array_keys($labels)); + } +} diff --git a/tests/unit/ProcessMaker/Cache/Monitoring/CacheMetricsDecoratorTest.php b/tests/unit/ProcessMaker/Cache/Monitoring/CacheMetricsDecoratorTest.php index 718137a0ed..372c971167 100644 --- a/tests/unit/ProcessMaker/Cache/Monitoring/CacheMetricsDecoratorTest.php +++ b/tests/unit/ProcessMaker/Cache/Monitoring/CacheMetricsDecoratorTest.php @@ -7,6 +7,7 @@ use ProcessMaker\Cache\Monitoring\CacheMetricsDecorator; use ProcessMaker\Cache\Monitoring\CacheMetricsInterface; use ProcessMaker\Cache\Screens\ScreenCacheInterface; +use ProcessMaker\Contracts\PrometheusMetricInterface; use Tests\TestCase; class CacheMetricsDecoratorTest extends TestCase @@ -331,4 +332,64 @@ protected function tearDown(): void Mockery::close(); parent::tearDown(); } + + public function testGetWithPrometheusMetricLabel() + { + $mockMetric = Mockery::mock(PrometheusMetricInterface::class); + $mockMetric->shouldReceive('getPrometheusMetricLabel') + ->once() + ->andReturn('prometheus_label'); + + // Setup expectations for cache hit + $this->cache->shouldReceive('has') + ->once() + ->with($this->testKey) + ->andReturn(true); + + $this->cache->shouldReceive('get') + ->once() + ->with($this->testKey, null) + ->andReturn($mockMetric); + + $this->metrics->shouldReceive('recordHit') + ->once() + ->withArgs(function ($key, $time, $labels) { + return $key === $this->testKey && is_float($time) && $labels['label'] === 'prometheus_label'; + }); + + // Execute and verify + $result = $this->decorator->get($this->testKey); + $this->assertEquals($mockMetric, $result); + } + + public function testSetWithPrometheusMetricLabel() + { + $mockMetric = new MockMetric(); + + $ttl = 3600; + + // Setup expectations + $this->cache->shouldReceive('set') + ->once() + ->with($this->testKey, $mockMetric, $ttl) + ->andReturn(true); + + $this->metrics->shouldReceive('recordWrite') + ->once() + ->withArgs(function ($key, $size, $labels) { + return $key === $this->testKey && is_int($size) && $size > 0 && $labels['label'] === 'prometheus_label'; + }); + + // Execute and verify + $result = $this->decorator->set($this->testKey, $mockMetric, $ttl); + $this->assertTrue($result); + } } + +class MockMetric implements PrometheusMetricInterface +{ + public function getPrometheusMetricLabel(): string + { + return 'prometheus_label'; + } +} \ No newline at end of file