Skip to content

feat: extended http-api for providing all module variables as json …#3743

Draft
JohannesRegneri wants to merge 5 commits intobitfocus:mainfrom
JohannesRegneri:http-api
Draft

feat: extended http-api for providing all module variables as json …#3743
JohannesRegneri wants to merge 5 commits intobitfocus:mainfrom
JohannesRegneri:http-api

Conversation

@JohannesRegneri
Copy link

@JohannesRegneri JohannesRegneri commented Nov 1, 2025

…and prometheus metrics #2424

Summary by CodeRabbit

  • New Features

    • Added GET endpoints to retrieve variable definitions and values in JSON format for custom, expression, and connection variables
    • Added GET endpoints to expose variables as Prometheus metrics with automatic metric formatting and labeling
  • Documentation

    • Updated HTTP API documentation with new variable endpoint details and Prometheus metric specifications

✏️ Tip: You can customize this high-level summary in your review settings.

@CLAassistant
Copy link

CLAassistant commented Nov 1, 2025

CLA assistant check
All committers have signed the CLA.

Copy link
Contributor

@peternewman peternewman left a comment

Choose a reason for hiding this comment

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

Exciting, a few opinion points as an API consumer/trying to think of the future a bit...

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

// Prometheus metrics
this.#apiRouter.get('/variables/:label/metrics', this.#variablesGetMetrics)
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd argue this should be Prometheus so other formats have a sensible namespace too (or maybe they should be further nested under metrics:

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

E.g. if someone wanted to implement InfluxDB line protocol ( https://docs.influxdata.com/influxdb3/cloud-serverless/reference/syntax/line-protocol/ ) they could do it at:
/variables/:label/influx

this.#apiRouter.post('/surfaces/rescan', this.#surfacesRescan)

// JSON
this.#apiRouter.get('/appinfo/json', this.#appInfoGetJson)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be app-info to match custom-variable above?

Suggested change
this.#apiRouter.get('/appinfo/json', this.#appInfoGetJson)
this.#apiRouter.get('/app-info/json', this.#appInfoGetJson)

}

if (Object.keys(result).length == 0) {
res.status(404).json({
Copy link
Contributor

Choose a reason for hiding this comment

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

Should no variables be a 404? I guess the real question is can we differentiate between a module with no variables and an invalid name for a module? If we can, I'd say it shouldn't be a 404 (and maybe it's just an empty response). Especially given module variables can be dynamic.

Copy link
Contributor

Choose a reason for hiding this comment

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

The existing does return a 404, but only for a single variable, I'd argue here the status should be representing the module now (and whether it exists):

if (result === undefined) {
res.status(404).send('Not found')

@@ -320,6 +320,14 @@ export class ServiceHttpApi {
// surfaces
this.#apiRouter.post('/surfaces/rescan', this.#surfacesRescan)
Copy link
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?

? this.#serviceApi.getCustomVariableValue(variableName)
: this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)

if (value !== undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Aren't undefined ones also valuable? I think some of my module ones are set to undefined (rather than the empty string) when they've got no data? I don't know if there's a recommended Companion policy there?

Copy link
Contributor

Choose a reason for hiding this comment

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

I see the existing code does it for a single variable:

if (result === undefined) {
res.status(404).send('Not found')

Although maybe the distinction there is you know you've tried and it's either undefined or you've used the wrong name, here's it's just randomly missing (as you can't tell you've tried it).

: this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)

if (value !== undefined) {
result[variableName] = {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't know the right answer here, but in the Prometheus endpoint, you've included the connection name too.

If I've got multiple connections, talking to the same type of device (e.g. 3 Hyperdecks from the original issue), without the "namespace" being embedded in the returned JSON, I've got to do some processing before I can store them (or store by "namespace"), whereas I could just chuck all the Prometheus datapoints in a file and they're still unique. Although I appreciate it's more data being transmitted that's duplicated if I'm using them separately.

Copy link
Member

Choose a reason for hiding this comment

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

I think that in general it is reasonable to not include the connection prefix, as that will make writing generic processing/parsing code simpler without having to make that parsing aware of the connection label.
In your case of working with multiple, it sounds reasonable to have to do a little bit of namespacing in your app, you may want to use different labels/ids too.

Prometheus is different, as it is expected they will be put into a global store, where it is likely that other connections/data will exist, so they need to be scoped. (although maybe that could be added by the scraper rule, so maybe not necessary to be builtin?)

Copy link
Contributor

Choose a reason for hiding this comment

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

Fair enough, could we compromise on at least including the connection prefix once in a "header/request" type space, then consumers don't need to track the request/response pair to know what bit of data it was. Then even Companion's Generic HTTP module could process it! 😆

Prometheus is different, as it is expected they will be put into a global store, where it is likely that other connections/data will exist, so they need to be scoped. (although maybe that could be added by the scraper rule, so maybe not necessary to be builtin?)

I don't know enough about Prometheus, is the store per host being monitored, or global to the Prometheus instance? Obviously if it's the latter you've still got to add context as soon as you're checking two different Companion instances with the same type of connection configured on both (unless you've already made the connection names globally unique there).

Copy link
Member

Choose a reason for hiding this comment

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

I think I'm agreeing with you on this endpoint not including connection label. Prometheus wont understand json though, so is a parallel discussion


Its a global store. As an example, if I query my prometheus with node_boot_time_seconds{}, I get back:

node_boot_time_seconds{instance="10.0.0.1:9100", job="node-a"} = 1763253353.944076
node_boot_time_seconds{instance="10.0.0.2:9100", job="node-b"} = 1763252538
node_boot_time_seconds{instance="10.0.0.3:9100", job="node-c"} = 1763237998
node_boot_time_seconds{instance="10.0.0.4:9100", job="node-d"} = 1762635123.979674
node_boot_time_seconds{instance="10.0.0.5:9100", job="node-e"} = 1762635247

I believe that in this case, both the instance and job are being injected by the scraping and are not in the source. But the source file is also able to specify other fields, eg node_cpu_seconds_total{} returns values looking like: node_cpu_seconds_total{cpu="0", instance="10.0.0.1:9100", job="node-a", mode="idle"} = 0

But the bit that worries me is a module producing a metric called simply ip pollutes the global store quite a bit, so they want to have better names in some way. Those two metrics I showed are from the node exporter (linux system), general convention appears to be to at least prefix it with companion_, but even that will still make quite a mush. Maybe the best thing would be companion_connection_${moduleType}_variable_${variableName} as that is probabyl closest to the typical structure.
That would avoid embedding the label in the name, while also allowing the metrics to have accurate descriptions (if we ignore that different versions of a module could vary)

}

/**
* @returns Seconds since Jan 01 1970. (UTC)
Copy link
Contributor

Choose a reason for hiding this comment

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

Out of interest, what's the thinking behind this rather than say ISO format?

* Provides Prometheus metrics for module or custom variables
*
*/
#variablesGetMetrics = (req: Express.Request, res: Express.Response): void => {
Copy link
Member

Choose a reason for hiding this comment

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

I'm not keen on the structure of these metrics. As an occasional prometheus user, I would find these hard to work with.

Them being called something like custom_myvar_string will make them very hard to find when in a prometheus with other data. At the very least that should be prefixed with companion_.

But also I want to question if they should be called simply companion_variable, with the connection label and variable name being supplied as attributes/properties/whatever they are called.
My reason for this is that if you want to do any graphing based on a pattern (perhaps list them all in a table or just all with the name starting abc), that will be very hard to do with them as separate metrics, but if they are the same metric with different properties, then they can be easily queried together while still being easy to target down to the ones you want.

Copy link
Contributor

Choose a reason for hiding this comment

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

Again I don't know enough about Prometheus, but thinking of something I'm aiming to do with a different monitoring solution is extracting health info like temperature from connection variables (across multiple devices of the same type) and graphing that. I assume a use case like that would still work with @Julusian suggestion?

Would you want to add the connection type too to the attributes, so you can graph all the FooBar 5000 temperatures together (without needing to know how each connection is labelled)?

Copy link
Member

Choose a reason for hiding this comment

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

I think my answer #3743 (comment) covers some of this.
I wrote that before reading this, but yeah I think my proposal on naming will achieve what you want as then:

  • the variables will be grouped as metrics based on what they are (ie, how the module defines them), with the connection label/id/whatever being available as a label on the metric.
  • These labels on metrics are commonly used to filter, or you can query and ignore the label.
  • There is no guarantee that a temperature variable will be at all consistent between module types. some will be C some F, some strings, some numbers, so having them grouped by module will both force and allow you to handle that explicitly

For example, a snippet from a grafana graph I have:
image
That first query returns ~10 sensors of data, the second query matches one or two). The device label is being used to know how to group values within the metric

.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')

result += `# HELP ${connectionLabel}:${variableNamePrint} ${labelPrint}\n`
Copy link
Member

Choose a reason for hiding this comment

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

Personally, I would be tempted to use https://github.com/siimon/prom-client to build the response of this request, to avoid needing to manually handle this encoding and syntax structure.
I don't know if it can do what we need here though, I have only used it for an application wide endpoint.

message: 'App information retrieved successfully',
timestamp: getTimestampUnix(),
data: {
appinfo: this.#serviceApi.appInfo,
Copy link
Member

Choose a reason for hiding this comment

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

I don't like this.
This appInfo object is an internal object that has no guarantees of being at all consistent between versions.
A majority of its contents I can't see as being of any value to a caller.

I am not against exposing some of this data, but I think it needs to be done in a more controlled fashion, with only the useful bits being exposed

* Provides JSON for connection status
*
*/
#connectionGetJson = (_req: Express.Request, res: Express.Response): void => {
Copy link
Member

Choose a reason for hiding this comment

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

I don't like how this method works.
Searching for variables like this feels rather brittle and messy.

It would be much cleaner to expose direct access to some methods/events/classes needed to drive this.

I can say that some of this will for sure break a bit with the surface module work which is ongoing, as they are 'instances' too, so will appear in some of these variables

@coderabbitai
Copy link

coderabbitai bot commented Jan 13, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

New HTTP API endpoints expose variable data in JSON and Prometheus formats. A new method retrieves expression variable definitions through the ControlsController. The prom-client dependency is added for Prometheus metrics support. Documentation is updated across user guide and WebUI settings.

Changes

Cohort / File(s) Summary
Expression Variable Definitions
companion/lib/Controls/Controller.ts, companion/lib/Service/ServiceApi.ts
Added getExpressionVariableDefinitions() method to ControlsController that returns a dictionary mapping expression variable names to their definitions. ServiceApi delegates to this method to expose the functionality.
HTTP API Endpoints & Prometheus Support
companion/lib/Service/HttpApi.ts, companion/package.json
Implemented GET /variables/:label/json and /variables/:label/prometheus endpoints to serve variable definitions and values. Private handlers process custom, expression, and connection variables with appropriate error handling. Added prom-client (^15.1.3) dependency.
Documentation
docs/user-guide/5_remote-control/http-remote-control.md, webui/src/UserConfig/Sections/HttpProtocol.tsx
Added documentation for new JSON and Prometheus variable endpoints with Content-Type and path specifications. Removed stray formatting and updated examples in WebUI settings.

Poem

Variables now flow in metrics bright ✨
Prometheus gauges catch the light,
JSON endpoints, clean and neat—
Your monitoring suite's complete! 📊

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: extending the HTTP API to expose module variables as JSON and Prometheus metrics, which aligns with the commit message and file changes across multiple endpoints and formats.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@JohannesRegneri
Copy link
Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Jan 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
companion/lib/Service/HttpApi.ts (2)

688-694: Redundant branches — both do the same thing.

The isExpressionVariable case (line 691) and the else case (line 693) are identical. This looks like a copy-paste oversight. You could simplify this to just an if/else for custom vs. everything else.

✨ Suggested simplification
-			if (isCustomVariable) {
-				value = this.#serviceApi.getCustomVariableValue(variableName)
-			} else if (isExpressionVariable) {
-				value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
-			} else {
-				value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
-			}
+			if (isCustomVariable) {
+				value = this.#serviceApi.getCustomVariableValue(variableName)
+			} else {
+				value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
+			}

743-749: Same redundant branches here too.

Same simplification opportunity as in the JSON handler — the isExpressionVariable and else branches are identical.

✨ Suggested simplification
-			if (isCustomVariable) {
-				value = this.#serviceApi.getCustomVariableValue(variableName)
-			} else if (isExpressionVariable) {
-				value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
-			} else {
-				value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
-			}
+			if (isCustomVariable) {
+				value = this.#serviceApi.getCustomVariableValue(variableName)
+			} else {
+				value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
+			}
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f37378f and 14758cd.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (6)
  • companion/lib/Controls/Controller.ts
  • companion/lib/Service/HttpApi.ts
  • companion/lib/Service/ServiceApi.ts
  • companion/package.json
  • docs/user-guide/5_remote-control/http-remote-control.md
  • webui/src/UserConfig/Sections/HttpProtocol.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-01-11T09:42:06.804Z
Learnt from: arikorn
Repo: bitfocus/companion PR: 3849
File: webui/src/Layout/Header.tsx:231-271
Timestamp: 2026-01-11T09:42:06.804Z
Learning: In CoreUI React (coreui/react), prefer using the as prop on CDropdownItem instead of the component prop to render as a different element/component. For TanStack Router integration, use as={Link} (and provide to="/path") to avoid wrapping with CNavLink, e.g. <CDropdownItem as={Link} to="/path">...</CDropdownItem>. This improves compatibility with routing libraries and keeps rendering consistent across layouts. Apply this pattern to TSX files in the webui directory where CoreUI Dropdowns are used.

Applied to files:

  • webui/src/UserConfig/Sections/HttpProtocol.tsx
🧬 Code graph analysis (2)
companion/lib/Service/ServiceApi.ts (1)
shared-lib/lib/Model/Variables.ts (1)
  • ModuleVariableDefinitions (12-12)
companion/lib/Service/HttpApi.ts (1)
companion/lib/Registry.ts (1)
  • Registry (81-484)
🔇 Additional comments (9)
docs/user-guide/5_remote-control/http-remote-control.md (1)

71-103: Great documentation for the new variable endpoints!

The documentation is clear, well-structured, and follows the existing format nicely. The Content-Type specifications and Prometheus metric prefix details are particularly helpful for users integrating with these endpoints. Thanks for keeping the docs in sync with the code changes!

companion/lib/Service/ServiceApi.ts (1)

153-159: LGTM!

The delegation pattern is clean and consistent with the other variable-related methods in this class (like getConnectionVariableDefinitions and getCustomVariableDefinitions). The JSDoc comment follows the established style. Nice work keeping the API surface consistent!

companion/lib/Controls/Controller.ts (1)

403-421: Nice work on this clean implementation!

The method is well-structured and follows the existing patterns nicely. The defensive check for a truthy variableName on line 412 is a great touch, and the iteration pattern is consistent with similar methods in the class.

The type compatibility concern checks out—the return type Record<string, { name: string; description: string }> perfectly matches ModuleVariableDefinitions (which is defined as Record<string, VariableDefinition> where VariableDefinition is { name: string; description: string }), so the types are fully compatible with the delegating method in ServiceApi.

companion/package.json (1)

83-83: Nice addition of prom-client for Prometheus metrics support!

This is the standard library for Prometheus metrics in Node.js applications – great choice! You're using the latest version (15.1.3) with no known security vulnerabilities, so this is a solid, safe addition.

companion/lib/Service/HttpApi.ts (5)

324-328: LGTM! Routes look well-placed.

The new endpoints follow the existing naming conventions and are correctly positioned before the catch-all 404 handler. 👍


696-701: Consider whether name: connectionLabel is the intended value.

Setting name to the connection label for every variable seems a bit unusual. Did you perhaps mean to use the variable's own label (like obj.label) here instead? If connectionLabel is intentional, maybe a clearer property name like connection would help avoid confusion since you already have connection: connectionLabel at the top level of the response.


774-797: Nice implementation of the info-metric pattern for non-numeric values!

Using a gauge with value 1 and the actual value as a label is the standard Prometheus way to expose string data. The _info suffix convention is spot-on. 🎯


800-810: Good async error handling.

Properly handling the promise from register.metrics() with both success and error paths. The error logging and 500 response are appropriate.

One tiny thought: if you wanted to be extra defensive, you could wrap the entire loop (lines 741-798) in a try-catch as well, in case new Gauge() throws unexpectedly due to the collision issue mentioned above. But this is optional — what you have is solid!


10-10: Nice choice with prom-client!

Great pick using a well-established and actively maintained library for Prometheus metrics. The import is focused and pulls only what's needed for your implementation. Thanks for putting thought into the library selection!

Comment on lines 751 to 757
// 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

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.

Comment on lines 162 to 186
<li>
Get all custom variables as Prometheus metrics
<br />
Method: <code>GET</code>
<br />
Path: <code>/api/variables/custom/prometheus</code>
</li>
<li>
Get all expression variables as Prometheus metrics
<br />
Method: <code>GET</code>
<br />
Path: <code>/api/variables/expression/prometheus</code>
</li>
<li>
Get all module/connection variables as Prometheus metrics
<br />
Method: <code>GET</code>
<br />
Path: <code>/api/variables/</code>&lt;Connection label&gt;<code>/prometheus</code>
</li>
Press page 1 row 0 column 2
<br />
POST <code>/api/location/1/0/2/press</code>
</p>

</ul>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Small structural issue and a consistency suggestion

A couple of things I noticed:

  1. Structural issue: Lines 183-185 ("Press page 1 row 0 column 2...") appear to be an example that ended up outside of a <li> element and inside the <ul>. This looks like it might be leftover from the previous structure.

  2. Consistency: The JSON endpoints include Content-Type: <code>application/json</code>, but the Prometheus endpoints don't mention their content type (text/plain; version=0.0.4). The markdown docs include this info – might be nice to have it here too for consistency!

Suggested fix for the structural issue
 				<li>
 					Get all module/connection variables as Prometheus metrics
 					<br />
 					Method: <code>GET</code>
 					<br />
 					Path: <code>/api/variables/</code>&lt;Connection label&gt;<code>/prometheus</code>
 				</li>
-				Press page 1 row 0 column 2
-				<br />
-				POST <code>/api/location/1/0/2/press</code>
 			</ul>
+			<p>
+				<strong>Examples:</strong>
+			</p>
+			<p>
+				Press page 1 row 0 column 2
+				<br />
+				POST <code>/api/location/1/0/2/press</code>
+			</p>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants