Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 20 additions & 0 deletions companion/lib/Controls/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,26 @@ export class ControlsController {
return variables
}

/**
* Get all expression variable definitions
* @returns Object with variable names as keys and their definitions
*/
getExpressionVariableDefinitions(): Record<string, { name: string; description: string }> {
const definitions: Record<string, { name: string; description: string }> = {}

for (const variable of this.getAllExpressionVariables()) {
const name = variable.options.variableName
if (name) {
definitions[name] = {
name,
description: variable.options.description,
}
}
}

return definitions
}

getExpressionVariableByName(name: string): ControlExpressionVariable | undefined {
if (!name) return undefined

Expand Down
168 changes: 168 additions & 0 deletions companion/lib/Service/HttpApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ControlLocation } from '@companion-app/shared/Model/Common.js'
import LogController from '../Log/Controller.js'
import type { DataUserConfig } from '../Data/UserConfig.js'
import type { ServiceApi } from './ServiceApi.js'
import { Registry, Gauge } from 'prom-client'

const HTTP_API_SURFACE_ID = 'http'

Expand Down Expand Up @@ -320,6 +321,12 @@ export class ServiceHttpApi {
// surfaces
this.#apiRouter.post('/surfaces/rescan', this.#surfacesRescan)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding reporting of surface state (e.g. offline) would also be really nice, but maybe that's a separate issue/PR?


// JSON endpoints
this.#apiRouter.get('/variables/:label/json', this.#variablesGetJson)

// Prometheus metrics
this.#apiRouter.get('/variables/:label/prometheus', this.#variablesGetPrometheus)

// Finally, default all unhandled to 404
this.#apiRouter.use((_req, res) => {
res.status(404).send('')
Expand Down Expand Up @@ -641,4 +648,165 @@ export class ServiceHttpApi {
}
}
}

/**
* Provides JSON for module or custom variables
*/
#variablesGetJson = (req: Express.Request, res: Express.Response): void => {
const connectionLabel = req.params.label
this.logger.debug(`Got HTTP /api/variables/${connectionLabel}/json`)

// Determine variable type and fetch definitions
const isCustomVariable = connectionLabel === 'custom'
const isExpressionVariable = connectionLabel === 'expression'

let variableDefinitions: Record<string, any>

if (isCustomVariable) {
variableDefinitions = this.#serviceApi.getCustomVariableDefinitions()
} else if (isExpressionVariable) {
variableDefinitions = this.#serviceApi.getExpressionVariableDefinitions()
} else {
variableDefinitions = this.#serviceApi.getConnectionVariableDefinitions(connectionLabel)
}

// Check if connection/module exists by checking if we got any definitions
if (!variableDefinitions || Object.keys(variableDefinitions).length === 0) {
if (!isCustomVariable && !isExpressionVariable) {
res.status(404).json({
error: 'Connection not found',
connection: connectionLabel,
})
return
}
}

const result: Record<string, any> = {}

for (const [variableName, obj] of Object.entries(variableDefinitions)) {
let value: any
if (isCustomVariable) {
value = this.#serviceApi.getCustomVariableValue(variableName)
} else if (isExpressionVariable) {
value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
} else {
value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
}

result[variableName] = {
value,
name: connectionLabel,
...obj,
}
}

res.json({
connection: connectionLabel,
variables: result,
})
}

/**
* Provides Prometheus metrics for module or custom variables
*/
#variablesGetPrometheus = (req: Express.Request, res: Express.Response): void => {
const connectionLabel = req.params.label
this.logger.debug(`Got HTTP /api/variables/${connectionLabel}/prometheus`)

// Determine variable type and fetch definitions
const isCustomVariable = connectionLabel === 'custom'
const isExpressionVariable = connectionLabel === 'expression'

let variableDefinitions: Record<string, any>

if (isCustomVariable) {
variableDefinitions = this.#serviceApi.getCustomVariableDefinitions()
} else if (isExpressionVariable) {
variableDefinitions = this.#serviceApi.getExpressionVariableDefinitions()
} else {
variableDefinitions = this.#serviceApi.getConnectionVariableDefinitions(connectionLabel)
}

// Check if connection exists
if (!variableDefinitions || Object.keys(variableDefinitions).length === 0) {
if (!isCustomVariable && !isExpressionVariable) {
res.status(404).send('# Connection not found\n')
return
}
}

// Create a new registry for this request
const register = new Registry()

for (const [variableName, obj] of Object.entries(variableDefinitions)) {
let value: any
if (isCustomVariable) {
value = this.#serviceApi.getCustomVariableValue(variableName)
} else if (isExpressionVariable) {
value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
} else {
value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
}

// Create sanitized metric name
const sanitizedVariableName = variableName.replace(/[^a-zA-Z0-9_]/g, '_')
const metricName = isCustomVariable
? `companion_custom_variable_${sanitizedVariableName}`
: isExpressionVariable
? `companion_expression_variable_${sanitizedVariableName}`
: `companion_connection_variable_${sanitizedVariableName}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor heads-up: metric name collisions are theoretically possible.

The sanitization variableName.replace(/[^a-zA-Z0-9_]/g, '_') could map different variable names to the same metric name (e.g., foo-bar and foo.bar both become foo_bar). This would cause prom-client to throw an error about duplicate metric registration.

In practice this is probably rare, but if you want to be extra safe, you could catch and log metric registration errors within the loop rather than failing the whole request.


const help = isCustomVariable
? obj.description || variableName
: isExpressionVariable
? obj.description || variableName
: obj.label || variableName

// Create labels object
const labels: Record<string, string> = {
variable_name: variableName,
}

if (!isCustomVariable && !isExpressionVariable) {
labels.connection_label = connectionLabel
}

// Handle different value types
if (typeof value === 'number') {
// Numeric values as gauges
const gauge = new Gauge({
name: metricName,
help: help,
labelNames: Object.keys(labels),
registers: [register],
})
gauge.set(labels, value)
} else {
// String and other values as info metrics (gauge with value 1)
const stringLabels = {
...labels,
value: value !== null && value !== undefined ? String(value) : '',
}
const gauge = new Gauge({
name: `${metricName}_info`,
help: help,
labelNames: Object.keys(stringLabels),
registers: [register],
})
gauge.set(stringLabels, 1)
}
}

// Return metrics in Prometheus format
res.header('Content-Type', register.contentType)
register.metrics().then(
(metrics) => {
res.send(metrics)
},
(err) => {
this.logger.error(`Error generating Prometheus metrics: ${err}`)
res.status(500).send('# Error generating metrics\n')
}
)
}
}
8 changes: 8 additions & 0 deletions companion/lib/Service/ServiceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ export class ServiceApi extends EventEmitter<ServiceApiEvents> {
return this.#variablesController.custom.getDefinitions()
}

/**
* Get all expression variable definitions
* @returns Object with variable names as keys and their definitions
*/
getExpressionVariableDefinitions(): ModuleVariableDefinitions {
return this.#controlController.getExpressionVariableDefinitions()
}

async triggerRescanForSurfaces(): Promise<void> {
await this.#surfaceController.triggerRefreshDevices()
}
Expand Down
Loading