Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"extends": "eslint:recommended",
"env": {
"node": true
"node": true,
"es6": true
},
"parserOptions": {
"ecmaVersion": 2015
"ecmaVersion": 2017
},
"rules": {
// Possible errors
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions lib/db/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 48 additions & 0 deletions lib/units/api/controllers/metrics.js
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 11 additions & 1 deletion lib/units/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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()
}
Expand Down
21 changes: 21 additions & 0 deletions lib/units/api/swagger/api_v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
148 changes: 148 additions & 0 deletions lib/util/metrics-collector.js
Original file line number Diff line number Diff line change
@@ -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
Loading