diff --git a/.eslintrc b/.eslintrc index 1f553887a0..f06f25db9c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,10 +1,11 @@ { "extends": "eslint:recommended", "env": { - "node": true + "node": true, + "es6": true }, "parserOptions": { - "ecmaVersion": 2015 + "ecmaVersion": 2017 }, "rules": { // Possible errors @@ -153,7 +154,7 @@ "no-path-concat": 2, // `2` is default "no-process-exit": 0, // `2` is default "no-restricted-modules": 0, // no default, optionally set `[2, "fs", "os"]` - "no-sync": 1, // `2` is default + "no-sync": 1 // `2` is default // eslint v2 //"keyword-spacing": 2 diff --git a/lib/db/api.js b/lib/db/api.js index 9a89df93b0..ca6c484e00 100644 --- a/lib/db/api.js +++ b/lib/db/api.js @@ -1661,4 +1661,56 @@ dbapi.loadAccessToken = function(id) { return db.run(r.table('accessTokens').get(id)) } +// Metrics-specific function that provides aggregate device statistics +// without exposing individual device data or bypassing access control +dbapi.getDeviceMetrics = function() { + return Promise.all([ + // Get total device count + db.run(r.table('devices').count()) + // Get device counts by status + , db.run(r.table('devices').group('status').count().ungroup()) + // Get provider count (line split to meet max length) + , db.run(r.table('devices').hasFields('provider') + .map(r.row('provider')('name')).distinct().count()) + ]) + .then(function(results) { + const totalCount = results[0] + const statusCounts = results[1] || [] + const providerCount = results[2] || 0 + + const stats = { + total: totalCount + , usable: 0 + , busy: 0 + , providers: providerCount + , byStatus: {} + } + + statusCounts.forEach(function(item) { + const status = item.group || 'unknown' + const count = item.reduction + stats.byStatus[status] = count + + if (status === 'available' || status === 'busy') { + stats.usable += count + } + if (status === 'busy') { + stats.busy += count + } + }) + + return stats + }) + .catch(function(error) { + log.error('Error getting device metrics:', error) + return { + total: 0 + , usable: 0 + , busy: 0 + , providers: 0 + , byStatus: {} + } + }) +} + module.exports = dbapi diff --git a/lib/units/api/controllers/metrics.js b/lib/units/api/controllers/metrics.js new file mode 100644 index 0000000000..50155d25d2 --- /dev/null +++ b/lib/units/api/controllers/metrics.js @@ -0,0 +1,48 @@ +/** + * Copyright © 2025 STF Metrics Controller - Licensed under the Apache license 2.0 + * + * Prometheus metrics endpoint controller + */ + +// Fix for Node.js versions where util.isError was removed +const util = require('util') +if (!util.isError) { + util.isError = function(e) { + return e && typeof e === 'object' && e instanceof Error + } +} + +const metrics = require('../../../util/metrics') +const logger = require('../../../util/logger') +const log = logger.createLogger('api:controllers:metrics') + +/** + * GET /metrics + * + * Returns Prometheus metrics in the expected format + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {void} + */ +function getMetrics(req, res) { + // Set the content type to plain text as expected by Prometheus + res.set('Content-Type', metrics.register.contentType) + + // Return the metrics (handle Promise from prom-client v15+) + metrics.register.metrics() + .then(metricsData => { + res.end(metricsData) + log.debug('Served Prometheus metrics') + }) + .catch(error => { + log.error('Error serving metrics:', error) + res.status(500).json({ + success: false + , description: 'Internal server error while fetching metrics' + }) + }) +} + +module.exports = { + getMetrics +} diff --git a/lib/units/api/index.js b/lib/units/api/index.js index 78357fef92..5f57c4a3c5 100644 --- a/lib/units/api/index.js +++ b/lib/units/api/index.js @@ -18,6 +18,7 @@ var zmqutil = require('../../util/zmqutil') var srv = require('../../util/srv') var lifecycle = require('../../util/lifecycle') var wireutil = require('../../wire/util') +var MetricsCollector = require('../../util/metrics-collector') module.exports = function(options) { var log = logger.createLogger('api') @@ -138,8 +139,17 @@ module.exports = function(options) { app.disable('x-powered-by') + // Initialize metrics collection + const metricsCollector = new MetricsCollector({ + interval: 30000 // Collect metrics every 30 seconds + }) + metricsCollector.start() + lifecycle.observe(function() { - [push, sub, pushdev, subdev].forEach(function(sock) { + // Stop metrics collection on shutdown + metricsCollector.stop() + + ;[push, sub, pushdev, subdev].forEach(function(sock) { try { sock.close() } diff --git a/lib/units/api/swagger/api_v1.yaml b/lib/units/api/swagger/api_v1.yaml index 7dc2b8b879..62d3dfad88 100644 --- a/lib/units/api/swagger/api_v1.yaml +++ b/lib/units/api/swagger/api_v1.yaml @@ -34,7 +34,28 @@ tags: description: Groups Operations - name: admin description: Privileged Operations + - name: metrics + description: Prometheus Metrics Operations paths: + /metrics: + x-swagger-router-controller: metrics + get: + summary: Get Prometheus metrics + description: Returns metrics in Prometheus format for monitoring STF system health and usage + operationId: getMetrics + tags: + - metrics + responses: + "200": + description: Prometheus metrics + schema: + type: string + default: + description: > + Unexpected Error: + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" /groups: x-swagger-router-controller: groups get: diff --git a/lib/util/metrics-collector.js b/lib/util/metrics-collector.js new file mode 100644 index 0000000000..4697ba4be3 --- /dev/null +++ b/lib/util/metrics-collector.js @@ -0,0 +1,148 @@ +/** + * Copyright © 2025 STF Metrics Collector - Licensed under the Apache license 2.0 + * + * Service for collecting STF metrics from database and external sources + */ + +const logger = require('./logger') +const dbapi = require('../db/api') +const metrics = require('./metrics') + +const log = logger.createLogger('metrics-collector') + +class MetricsCollector { + constructor(options = {}) { + this.interval = options.interval || 30000 // 30 seconds default + this.timer = null + this.isRunning = false + } + + start() { + if (!this.isRunning) { + log.info('Starting metrics collection with interval:', this.interval + 'ms') + this.isRunning = true + this.collectMetrics() // Collect immediately + this.timer = setInterval(() => this.collectMetrics(), this.interval) + } + } + + stop() { + if (this.isRunning) { + log.info('Stopping metrics collection') + this.isRunning = false + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + } + + async collectMetrics() { + try { + log.debug('Collecting metrics...') + + const [ + deviceData + , userData + , groupData + ] = await Promise.all([ + this.collectDeviceMetrics() + , this.collectUserMetrics() + , this.collectGroupMetrics() + ]) + + // Update the metrics + metrics.updateDeviceMetrics(deviceData) + metrics.updateUserMetrics(userData) + metrics.updateGroupMetrics(groupData) + + log.debug('Metrics collection completed') + } + catch (error) { + log.error('Error during metrics collection:', error) + } + } + + async collectDeviceMetrics() { + try { + // Get device statistics from database using secure aggregation function + // This avoids access control bypass by not exposing individual device data + const deviceStats = await dbapi.getDeviceMetrics() + return deviceStats + } + catch (error) { + log.error('Error collecting device metrics:', error) + return { + total: 0 + , usable: 0 + , busy: 0 + , providers: 0 + , byStatus: {} + } + } + } + + async collectUserMetrics() { + try { + // Get user statistics from database + const users = await dbapi.getUsers() + + return { + total: users.length + } + } + catch (error) { + log.error('Error collecting user metrics:', error) + return { + total: 0 + } + } + } + + async collectGroupMetrics() { + try { + // Get group statistics from database + const groups = await dbapi.getGroups() + + const groupStats = { + total: groups.length + , active: groups.filter(g => g.state === 'active').length + , ready: groups.filter(g => g.state === 'ready').length + , pending: groups.filter(g => g.state === 'pending').length + } + + return groupStats + } + catch (error) { + log.error('Error collecting group metrics:', error) + return { + total: 0 + , active: 0 + , ready: 0 + , pending: 0 + } + } + } + + // Method to collect quota metrics for a specific user + async collectUserQuotaMetrics(user) { + try { + // This would depend on how quotas are implemented in STF + // For now, return placeholder data + const quotaTypes = ['devices', 'duration'] + + quotaTypes.forEach(quotaType => { + // Example: Get quota usage from database + const consumed = 0 // Would be actual consumed amount + const allocated = 10 // Would be actual allocated amount + + metrics.updateUserQuota(user, quotaType, consumed, allocated) + }) + } + catch (error) { + log.error('Error collecting user quota metrics:', error) + } + } +} + +module.exports = MetricsCollector diff --git a/lib/util/metrics-hooks.js b/lib/util/metrics-hooks.js new file mode 100644 index 0000000000..f4a667fb7e --- /dev/null +++ b/lib/util/metrics-hooks.js @@ -0,0 +1,117 @@ +/** + * Copyright © 2025 STF Metrics Hooks - Licensed under the Apache license 2.0 + * + * Hooks to update metrics in real-time when entities change + */ + +const metrics = require('./metrics') +const logger = require('./logger') +const log = logger.createLogger('metrics-hooks') + +class MetricsHooks { + static onDeviceAdded() { + // Increment total devices counter + metrics.metrics.totalDevices.inc() + log.debug('Device added') + } + + static onDeviceRemoved() { + // Decrement total devices counter + metrics.metrics.totalDevices.dec() + log.debug('Device removed') + } + + static onDeviceStatusChanged(device) { + // Update device status metrics + try { + const previousStatus = device.previousStatus || 'offline' + let newStatus = 'offline' + if (device.present) { + newStatus = device.owner ? 'busy' : 'available' + } + // Update metrics if the status has changed + if (previousStatus !== newStatus) { + // Decrement the count for the previous status + metrics.metrics.devicesByStatus.dec({status: previousStatus}) + // Increment the count for the new status + metrics.metrics.devicesByStatus.inc({status: newStatus}) + log.debug('Device status changed:', device.serial, 'from:', + previousStatus, 'to:', newStatus) + } + } + catch (error) { + log.error('Error updating device status metrics:', error) + } + } + + static onUserAdded() { + // Increment total users counter + metrics.metrics.totalUsers.inc() + log.debug('User added') + } + + static onUserRemoved() { + // Decrement total users counter + metrics.metrics.totalUsers.dec() + log.debug('User removed') + } + + static onGroupAdded() { + // Increment total groups counter + metrics.metrics.totalGroups.inc() + log.debug('Group added') + } + + static onGroupRemoved() { + // Decrement total groups counter + metrics.metrics.totalGroups.dec() + log.debug('Group removed') + } + + static onGroupStatusChanged(group, oldStatus, newStatus) { + // Update group status metrics + try { + if (oldStatus !== newStatus) { + // Decrement old status + if (oldStatus === 'active') { + metrics.metrics.activeGroups.dec() + } + else if (oldStatus === 'ready') { + metrics.metrics.readyGroups.dec() + } + else if (oldStatus === 'pending') { + metrics.metrics.pendingGroups.dec() + } + + // Increment new status + if (newStatus === 'active') { + metrics.metrics.activeGroups.inc() + } + else if (newStatus === 'ready') { + metrics.metrics.readyGroups.inc() + } + else if (newStatus === 'pending') { + metrics.metrics.pendingGroups.inc() + } + + log.debug('Group status changed:', group.id, 'from:', oldStatus, 'to:', newStatus) + } + } + catch (error) { + log.error('Error updating group status metrics:', error) + } + } + + static updateUserQuota(userEmail, quotaType, consumed, allocated) { + // Update user quota metrics + try { + metrics.updateUserQuota(userEmail, quotaType, consumed, allocated) + log.debug('User quota updated:', userEmail, quotaType, consumed, '/', allocated) + } + catch (error) { + log.error('Error updating user quota metrics:', error) + } + } +} + +module.exports = MetricsHooks diff --git a/lib/util/metrics.js b/lib/util/metrics.js new file mode 100644 index 0000000000..bc9681173a --- /dev/null +++ b/lib/util/metrics.js @@ -0,0 +1,161 @@ +/** + * Copyright © 2025 STF Metrics Module - Licensed under the Apache license 2.0 + * + * Prometheus metrics collection for STF (Smartphone Test Farm) + */ + +const client = require('prom-client') +const logger = require('./logger') + +const log = logger.createLogger('metrics') + +// Create a Registry which registers the metrics +const register = new client.Registry() + +// Add a default label which is added to all metrics +register.setDefaultLabels({ + app: 'stf' +}) + +// Enable the collection of default metrics +client.collectDefaultMetrics({register}) + +// Define custom metrics +const metrics = { + // Device related metrics + totalDevices: new client.Gauge({ + name: 'stf_devices_total' + , help: 'Total number of devices in the system' + , registers: [register] + }) + + , totalProviders: new client.Gauge({ + name: 'stf_providers_total' + , help: 'Total number of device providers' + , registers: [register] + }) + + , usableDevices: new client.Gauge({ + name: 'stf_devices_usable' + , help: 'Number of devices available for use' + , registers: [register] + }) + + , busyDevices: new client.Gauge({ + name: 'stf_devices_busy' + , help: 'Number of devices currently in use' + , registers: [register] + }) + + // User related metrics + , totalUsers: new client.Gauge({ + name: 'stf_users_total' + , help: 'Total number of users in the system' + , registers: [register] + }) + + // Group related metrics + , totalGroups: new client.Gauge({ + name: 'stf_groups_total' + , help: 'Total number of groups in the system' + , registers: [register] + }) + + , activeGroups: new client.Gauge({ + name: 'stf_groups_active' + , help: 'Number of active groups' + , registers: [register] + }) + + , readyGroups: new client.Gauge({ + name: 'stf_groups_ready' + , help: 'Number of ready groups' + , registers: [register] + }) + + , pendingGroups: new client.Gauge({ + name: 'stf_groups_pending' + , help: 'Number of pending groups' + , registers: [register] + }) + + // Additional operational metrics + , devicesByStatus: new client.Gauge({ + name: 'stf_devices_by_status' + , help: 'Number of devices by status' + , labelNames: ['status'] + , registers: [register] + }) + + , userQuotaUsage: new client.Gauge({ + name: 'stf_user_quota_usage_percent' + , help: 'User quota usage percentage' + , labelNames: ['user', 'quota_type'] + , registers: [register] + }) +} + +// Helper functions to update metrics +function updateDeviceMetrics(deviceData) { + if (typeof deviceData.total === 'number') { + metrics.totalDevices.set(deviceData.total) + } + if (typeof deviceData.usable === 'number') { + metrics.usableDevices.set(deviceData.usable) + } + if (typeof deviceData.busy === 'number') { + metrics.busyDevices.set(deviceData.busy) + } + if (typeof deviceData.providers === 'number') { + metrics.totalProviders.set(deviceData.providers) + } + if (deviceData.byStatus) { + Object.keys(deviceData.byStatus).forEach(status => { + metrics.devicesByStatus.set({status}, deviceData.byStatus[status]) + }) + } + log.debug('Updated device metrics', deviceData) +} + +function updateUserMetrics(userData) { + if (typeof userData.total === 'number') { + metrics.totalUsers.set(userData.total) + } + log.debug('Updated user metrics', userData) +} + +function updateGroupMetrics(groupData) { + if (typeof groupData.total === 'number') { + metrics.totalGroups.set(groupData.total) + } + if (typeof groupData.active === 'number') { + metrics.activeGroups.set(groupData.active) + } + if (typeof groupData.ready === 'number') { + metrics.readyGroups.set(groupData.ready) + } + if (typeof groupData.pending === 'number') { + metrics.pendingGroups.set(groupData.pending) + } + log.debug('Updated group metrics', groupData) +} + +function updateUserQuota(user, quotaType, consumed, allocated) { + if (allocated > 0) { + const percentage = (consumed / allocated) * 100 + metrics.userQuotaUsage.set({user, quota_type: quotaType}, percentage) + } +} + +// Export the register and helper functions +module.exports = { + register + , metrics + , updateDeviceMetrics + , updateUserMetrics + , updateGroupMetrics + , updateUserQuota + + // Export the prom-client for advanced usage + , client +} diff --git a/package.json b/package.json index a0585fd160..430d926ef9 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "openid": "^2.0.1", "passport": "^0.6.0", "passport-oauth2": "^1.1.2", + "prom-client": "^15.1.3", "protobufjs": "^5.0.3", "proxy-addr": "^2.0.7", "pug": "^3.0.3", diff --git a/res/app/group-list/group-list-controller.js b/res/app/group-list/group-list-controller.js index a9fc7b4e6a..ab16bc5641 100644 --- a/res/app/group-list/group-list-controller.js +++ b/res/app/group-list/group-list-controller.js @@ -7,6 +7,7 @@ const _ = require('lodash') module.exports = function GroupListCtrl( $scope , $filter +, $window , GroupsService , UserService , UsersService @@ -64,6 +65,15 @@ module.exports = function GroupListCtrl( incrStateStats(oldGroup, -1) } } + // Send group metrics to backend if available (for metrics collection) + if (typeof $window !== 'undefined' && $window.stfMetrics) { + $window.stfMetrics.updateGroupMetrics({ + total: $scope.groups.length, + active: $scope.activeGroups, + ready: $scope.readyGroups, + pending: $scope.pendingGroups + }) + } } function updateGroupExtraProperties(group) {