Will Hallam
ServiceNow Employee

DISCLAIMER: the directions and code featured in this article come with no support or warranty, explicit or implied.  Caveat Emptor!

 

Service Observability in ServiceNow ships with built-in integrations for vendors like Dynatrace, Datadog, and AppDynamics. But what if your metrics live in some other tool?  The custom vendor framework lets you build your own integration from scratch. 

 

This article walks through a complete, working example: discovering Kubernetes pods from a Mimir instance and rendering their container metrics on Service Observability dashboards. 

 

Prerequisites

Before I began, here's what I had in place:

  • A Grafana Mimir instance accessible from a MID Server
  • kube-state-metrics deployed in a Kubernetes cluster (for pod label discovery)
  • An HTTP Connection record in ServiceNow pointing to the Mimir endpoint
  • A credential record associated with the HTTP Connection (in my case, a placeholder — more on this below)

Getting Started

I referred to this support Knowledge Base article to get started with my custom integration: https://support.servicenow.com/kb?sys_kb_id=3466b14f472dfe10f64de825126d4311&id=kb_article_view

 

Following the instructions in the document attached to the KB, I created a new Observability Vendor record for Grafana Mimir.

WillHallam_0-1770904190552.png

Then I created a Data Source record, which established the linkage between the Observability Vendor and my HTTP(S) connection record.

WillHallam_1-1770904938603.png

Next I created a simple Data Mapping, which was focused on one particular service in my Kubernetes cluster.  It specified a filter for Kubernetes labels which would pull back pods running the "frontend" service.

WillHallam_2-1770905118580.png

 

Most of the heavy lifting started after that, with creating the Related Entities and Chart Data scripts.

 

The Code

(Full script code is at the bottom of this article)

 

NOTE: Creating these scripts for a tool requires detailed knowledge of its API and data schema.  I was able to accelerate this in my case with an AI tool -- after uploading things like the Mimir API spec and the pertinent Service Observability documentation, the AI was able to create working scripts after a few iterations.

 

The integration has two halves, matching the two scripts that every custom observability vendor requires:

 

Related Entities script — Queries Mimir's /api/v1/series endpoint to discover Kubernetes pods matching the Data Mapping's label filter (e.g., app=frontend). Returns them as RelatedEntity objects so Service Observability can associate them with an application service.

 

Chart Data script — Takes PromQL queries with template variables like ${ENTITIES} and executes them against Mimir's /api/v1/query_range endpoint. Transforms the Prometheus-format response into the TimeseriesEntry format that Service Observability dashboards expect.

 

Both scripts share common infrastructure: reading the base URL and MID Server from the HTTP Connection record, automatic credential detection and application, and optional multi-tenant header support.

 

Configure the Dashboard

You will need to create at least one dashboard for your custom observability vendor.  Here's how I did it.

Navigate to the Service Observability PA Dashboards table (sn_sow_svcobs_pa_dashboards) and create a new record:

  • Certified: True
  • Category: service
  • Observability Vendor (observability_vendor column): the sys_id of your vendor record 
    • NOTE: newer updates will have this as a reference field, in which case you can select your custom vendor by name
  • PA Dashboard: Filter with Name = "Overview" and Description contains "Generic"

WillHallam_3-1770907113727.png

 

This creates a stub dashboard, which can be duplicated and customized with specific queries into the Mimir metric backend.

 

In Service Operations Workspace, navigate to one of the services which is tied to the new observability vendor via the data mapping you created above.  On the Observability tab, you will see a blank dashboard.

WillHallam_4-1770907315407.png

 

From there you can follow the link to documentation on customizing dashboard templates (https://www.servicenow.com/docs/r/it-operations-management/service-observability/customize-service-o...)

 

Once you have a customized dashboard, that is what you will see by default for any service with a matching Data Mapping record which uses your custom tool integration.

WillHallam_6-1770907653739.png

NOTE: this functionality is based on standard Platform Analytics dashboards, so you are free to work directly within PA to create dashboards and then associate them with corresponding Service Observability vendor and category combinations.  Standard "configuration vs. customization" guidance applies.

Gotchas and Lessons Learned

These took some time to figure out, so I'm highlighting them here to save you the trouble.

The HTTP Connection must have a credential. Even if your tool doesn't require authentication, the framework expects a credential record on the HTTP Connection. Without one, you'll get errors before your scripts even execute. If you don't need real auth, create a Basic Auth credential with a random placeholder value.  My example code detects an invalid credential and makes the API calls without authentication.

 

Entity type must match an available dashboard. This was the most confusing issue. The Related Entities script can successfully discover pods and return them as entity_type: 'kubernetes-pod', but the Observability tab will hang because there's no kubernetes-pod dashboard. The fix is to return entities with entity_type: 'service' and associate your vendor with the generic Overview dashboard. The entities are still pods under the hood — the entity_type just controls which dashboard renders.

 

Mimir's series endpoint requires POST with form-encoded body. The /api/v1/series endpoint accepts both GET and POST, but using GET with query parameters caused Invalid query errors from ServiceNow's REST client (likely due to bracket characters in match[]). Switching to POST with Content-Type: application/x-www-form-urlencoded resolved it.

 

Timestamps must be in seconds, not milliseconds. Mimir's Prometheus-compatible API expects Unix timestamps in seconds. ServiceNow's template variables pass timestamps in milliseconds. The Chart Data script auto-detects and converts by checking whether the value exceeds 9999999999 (a 10-digit second-precision timestamp won't hit this until the year 2286).

 

The require() import in the Chart Data template is scope dependent.  My Observability Vendor record was in the Global scope, so the Chart Data script threw an exception on that boilerplate "require" directive.  The solution is to create the Observability Vendor record in the service-observability-app scope, or remove the require() entirely and implement template variable replacement directly.

The Mimir API Endpoints

The integration uses two Mimir endpoints, both Prometheus-compatible:

Endpoint Method Purpose
/prometheus/api/v1/series POST Entity discovery — find series matching a label selector
/prometheus/api/v1/query_range POST Chart data — execute a PromQL range query

Mimir's own authentication is based on the X-Scope-OrgID header for multi-tenant deployments. The scripts support this via the tenantId configuration. HTTP Basic Auth and Bearer tokens are handled separately for infrastructure-level authentication (reverse proxy, API gateway, etc.).

Complete Script: Related Entities

This script queries kube_pod_labels to discover pods matching a label filter, then returns them as RelatedEntity objects. Key design decisions are annotated in the comments.

/*
 * Grafana Mimir Related Entities Script
 * ServiceNow Service Observability Custom Integration
 * 
 * This script queries Grafana Mimir to discover Kubernetes pods and other entities
 * based on label criteria, returning them as RelatedEntity objects for Service Observability.
 * 
 * The output must be an array of objects matching the RelatedEntity type:
 * {
 *   entity_type: string,
 *   name: string,
 *   query_timeseries_metadata: {
 *     entity_id: string,
 *     datasource_type: string, // sys_id of Observability Vendor record
 *     datasource_id: string
 *   },
 *   matching_ci_metadata: { [key: string]: string, ... }
 * }
 * 
 * Supported Service Observability entity types:
 * ['service', 'apache-server', 'nginx-server', 'host', 'mysql', 'postgresql',
 *  'oracle-db', 'microsoft-sql', 'kubernetes-cluster', 'kubernetes-node',
 *  'kubernetes-pod', 'endpoint', 'rds', 'lambda', 'api_gateway_http',
 *  'api_gateway_rest', 'elb']
 *
 * IMPORTANT: entity_type MUST match a dashboard that exists for the vendor.
 *    There is currently no 'kubernetes-pod' dashboard available, so even though we
 *    query for pod entities, we return them with entity_type = 'service' to ensure
 *    the generic Overview dashboard renders correctly.
 *
 * Authentication:
 *    The script automatically detects and uses credentials associated with the
 *    HTTP Connection record. Supported credential types:
 *    - Basic Auth (basic_auth_credentials): username + password
 *    - API Key (api_key_credentials): api_key as Bearer token
 *    - Basic Auth with no username but valid password: treated as Bearer token
 *    If no credential is found, requests are sent without authentication.
 */

var vendorEntities = Class.create();
vendorEntities.prototype = {
    initialize: function(dataSourceId, httpConnectionId) {
        this.dataSourceId = dataSourceId;
        this.httpConnectionId = httpConnectionId;
        
        this.baseUrl = this.getHttpConnectionBaseUrl(httpConnectionId);
        this.credential = this.getHttpConnectionCredential(httpConnectionId);
        
        // Grafana Mimir configuration
        this.prometheusHttpPrefix = '/prometheus';
        this.tenantId = '';  // Set for multi-tenant Mimir deployments
        
        // IMPORTANT: Replace with the sys_id of your Observability Vendor record
        this.vendorSysId = '499c78ce97babe10e94b3b671153afa6';
    },

    getHttpConnectionBaseUrl: function(httpConnectionId) {
        var gr = new GlideRecord('http_connection');
        if (gr.get(httpConnectionId)) {
            var url = gr.getValue('connection_url') || '';
            return url.replace(/\/+$/, '');
        }
        gs.error('Mimir Related Entities: Could not find HTTP Connection with sys_id: ' + httpConnectionId);
        return '';
    },

    getMidServerName: function(httpConnectionId) {
        var gr = new GlideRecord('http_connection');
        if (gr.get(httpConnectionId)) {
            var midServerRef = gr.getValue('mid_server');
            if (midServerRef) {
                var midGr = new GlideRecord('ecc_agent');
                if (midGr.get(midServerRef)) {
                    return midGr.getValue('name');
                }
            }
        }
        return '';
    },

    getHttpConnectionCredential: function(httpConnectionId) {
        var result = { type: 'none' };
        
        var gr = new GlideRecord('http_connection');
        if (!gr.get(httpConnectionId)) {
            return result;
        }
        
        var credentialRef = gr.getValue('credential');
        if (!credentialRef) {
            gs.info('Mimir Related Entities: No credential configured on HTTP Connection');
            return result;
        }
        
        gs.info('Mimir Related Entities: Found credential reference: ' + credentialRef);
        
        // Try basic_auth_credentials first (extends discovery_credentials)
        var basicGr = new GlideRecord('basic_auth_credentials');
        if (basicGr.get(credentialRef)) {
            var username = basicGr.getValue('user_name') || '';
            var password = basicGr.password.getDecryptedValue() || '';
            
            if (username && password) {
                gs.info('Mimir Related Entities: Using Basic Auth credential (user: ' + username + ')');
                return { type: 'basic', username: username, password: password };
            }
            if (password) {
                gs.info('Mimir Related Entities: Credential has password but no username, using as Bearer token');
                return { type: 'bearer', token: password };
            }
        }
        
        // Try api_key_credentials (extends discovery_credentials)
        var apiKeyGr = new GlideRecord('api_key_credentials');
        if (apiKeyGr.get(credentialRef)) {
            var apiKey = apiKeyGr.api_key.getDecryptedValue() || '';
            if (apiKey) {
                gs.info('Mimir Related Entities: Using API Key credential');
                return { type: 'bearer', token: apiKey };
            }
        }
        
        gs.info('Mimir Related Entities: Credential record found but no usable password/token, proceeding without auth');
        return result;
    },

    applyCredential: function(request) {
        if (!this.credential || this.credential.type === 'none') {
            return;
        }
        
        if (this.credential.type === 'basic') {
            request.setBasicAuth(this.credential.username, this.credential.password);
        } else if (this.credential.type === 'bearer') {
            request.setRequestHeader('Authorization', 'Bearer ' + this.credential.token);
        }
    },

    // DO NOT CHANGE THE FUNCTION SIGNATURE — Required by framework
    queryEntities: function(queries) {
        var results = [];
        var self = this;

        if (!this.baseUrl) {
            gs.error('Mimir Related Entities: No base URL configured. Check HTTP Connection.');
            return results;
        }

        queries.forEach(function(query) {
            var filter = query.filter;
            var entityTypes = query.entityTypes || [];

            entityTypes.forEach(function(entityType) {
                var vendorEntityTypes = self.serviceObsEntityTypeToVendorEntityType(entityType);
                
                vendorEntityTypes.forEach(function(vendorType) {
                    var selector = self.buildSelector(vendorType, filter, entityType);
                    var entities = self.queryMimirSeries(selector, entityType);
                    
                    entities.forEach(function(entity) {
                        results.push(entity);
                    });
                });
            });
        });

        return results;
    },

    buildSelector: function(vendorEntityType, filter, entityType) {
        var selectors = [];

        if (vendorEntityType.job) {
            selectors.push('job="' + vendorEntityType.job + '"');
        }

        if (filter && filter.key && filter.value) {
            var op = this.mapOperator(filter.operator);
            var labelKey = filter.key;
            
            // For Kubernetes entities, kube-state-metrics exposes labels as label_<name>
            if (this.isKubernetesEntityType(entityType) && vendorEntityType.useLabelMetric) {
                if (labelKey.indexOf('label_') !== 0 && 
                    labelKey.indexOf('namespace') !== 0 &&
                    labelKey.indexOf('pod') !== 0 &&
                    labelKey.indexOf('node') !== 0) {
                    labelKey = 'label_' + labelKey;
                }
            }
            
            selectors.push(labelKey + op + '"' + filter.value + '"');
        }

        return selectors.join(',');
    },

    isKubernetesEntityType: function(entityType) {
        return entityType === 'kubernetes-pod';
    },

    mapOperator: function(operator) {
        var operatorMap = {
            'equals': '=',
            'not_equals': '!=',
            'regex_match': '=~',
            'regex_not_match': '!~',
            'contains': '=~'
        };
        return operatorMap[operator] || '=';
    },

    queryMimirSeries: function(selector, entityType) {
        var results = [];
        var self = this;

        var metricName = this.getMetricForEntityType(entityType);
        
        var fullSelector;
        if (selector) {
            fullSelector = metricName + '{' + selector + '}';
        } else {
            fullSelector = metricName + '{}';
        }

        var endpoint = this.baseUrl + this.prometheusHttpPrefix + '/api/v1/series';
        
        // Time range: last 1 hour
        var now = Math.floor(Date.now() / 1000);
        var oneHourAgo = now - 3600;

        // POST with form-encoded body (GET causes issues with bracket characters)
        var body = 'match[]=' + encodeURIComponent(fullSelector);
        body += '&start=' + oneHourAgo;
        body += '&end=' + now;

        gs.info('Mimir Related Entities: Querying for entityType=' + entityType);
        gs.info('Mimir Related Entities: Selector=' + fullSelector);

        var request = new sn_ws.RESTMessageV2();
        request.setHttpMethod('POST');
        request.setEndpoint(endpoint);
        request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
        request.setRequestBody(body);
        
        // Route through MID Server (required for cloud instances)
        var midServer = this.getMidServerName(this.httpConnectionId);
        if (midServer) {
            request.setMIDServer(midServer);
        }
        
        this.applyCredential(request);
        
        if (this.tenantId) {
            request.setRequestHeader('X-Scope-OrgID', this.tenantId);
        }

        try {
            var responseBody = this.queryAPI(request);
            var response = JSON.parse(responseBody);

            if (response.status === 'success' && response.data) {
                gs.info('Mimir Related Entities: Found ' + response.data.length + ' series');
                var seenEntities = {};
                
                response.data.forEach(function(series) {
                    var entity = self.vendorEntityToRelatedEntity(series, entityType);
                    if (entity && !seenEntities[entity.name]) {
                        seenEntities[entity.name] = true;
                        results.push(entity);
                    }
                });
                
                gs.info('Mimir Related Entities: ' + results.length + ' unique entities');
            }
        } catch (err) {
            gs.error('Mimir Related Entities: Error querying series - ' + err.message);
        }

        return results;
    },

    getMetricForEntityType: function(entityType) {
        if (entityType === 'kubernetes-pod') {
            return 'kube_pod_labels';
        }
        return null;
    },

    // NOTE: entity_type is hardcoded to 'service' because there is no
    // kubernetes-pod dashboard. Using 'kubernetes-pod' causes the dashboard
    // to hang with an indefinite loading spinner.
    vendorEntityToRelatedEntity: function(vendorEntity, entityType) {
        var name = this.extractEntityName(vendorEntity, entityType);
        if (!name) {
            return null;
        }

        var entityId = this.buildEntityId(vendorEntity, entityType);
        
        return {
            entity_type: 'service',
            name: name,
            query_timeseries_metadata: {
                entity_id: entityId,
                datasource_type: this.vendorSysId,
                datasource_id: this.dataSourceId
            },
            matching_ci_metadata: this.extractCIMetadata(vendorEntity, entityType)
        };
    },

    extractEntityName: function(labels, entityType) {
        if (entityType === 'kubernetes-pod') {
            return labels.pod || labels.pod_name || null;
        }
        return null;
    },

    buildEntityId: function(labels, entityType) {
        if (entityType === 'kubernetes-pod') {
            return labels.pod || labels.pod_name || '';
        }
        
        var idParts = [];
        var keyLabels = ['namespace', 'pod', 'node', 'instance', 'job', 'cluster'];
        keyLabels.forEach(function(label) {
            if (labels[label]) {
                idParts.push(label + '=' + labels[label]);
            }
        });
        
        return entityType + ':' + idParts.join(',');
    },

    extractCIMetadata: function(labels, entityType) {
        var metadata = {};
        
        var directFields = [
            'namespace', 'pod', 'node', 'cluster', 'container',
            'service', 'deployment', 'statefulset', 'daemonset',
            'instance', 'job', 'hostname', 'uid'
        ];

        directFields.forEach(function(field) {
            if (labels[field]) {
                metadata[field] = labels[field];
            }
        });

        // kube-state-metrics exposes K8s labels as label_<name>
        Object.keys(labels).forEach(function(key) {
            if (key.indexOf('label_') === 0) {
                metadata[key] = labels[key];
                metadata[key.substring(6)] = labels[key]; // Also store without prefix
            }
        });

        Object.keys(labels).forEach(function(key) {
            if (key.indexOf('kubernetes_io') !== -1 || key.indexOf('app_') === 0) {
                metadata[key] = labels[key];
            }
        });

        return metadata;
    },

    serviceObsEntityTypeToVendorEntityType: function(entityType) {
        var mappings = {
            'kubernetes-pod': [
                { metric: 'kube_pod_labels', job: 'kube-state-metrics', useLabelMetric: true }
            ]
        };

        return mappings[entityType] || [];
    },

    queryAPI: function(request) {
        try {
            var response = request.execute();
            var status = response.getStatusCode();
            var responseBody = response.getBody();

            if (status >= 400) {
                throw new Error('API Error (' + status + '): ' + responseBody);
            }

            return responseBody;
        } catch (err) {
            throw new Error('API Error: ' + (err.message || err.toString()));
        }
    },

    type: 'vendorEntities'
};

// DO NOT CHANGE ANYTHING BELOW THIS LINE — Required for execution
var object = new vendorEntities(datasourceID, httpConnectionID);
var result = object.queryEntities(queries);
result;

Complete Script: Chart Data

This script handles PromQL range queries with template variable substitution. Note the require() import from the standard template is intentionally removed — it's not available at runtime.

/*
 * Grafana Mimir Chart Data Script
 * ServiceNow Service Observability Custom Integration
 * 
 * This script queries Grafana Mimir for time series metrics data
 * to populate charts in Service Observability dashboards.
 * 
 * NOTE: The require() import from the template is removed because it is not
 * available in the ServiceNow scripting environment (causes "require is not defined"
 * error). Template variable replacement is implemented directly in this script.
 * 
 * The output must be an array of objects matching the TimeseriesEntry type:
 * [
 *   {
 *     metric_name: string,
 *     series: [
 *       {
 *         timestamp: number,  // Unix epoch time in milliseconds
 *         value: number
 *       }
 *     ],
 *     chart_options: {
 *       unit?: string,
 *       dimensions?: { [key: string]: string },
 *       [key: string]: any
 *     }
 *   }
 * ]
 * 
 * Template Variables available:
 * - ${START} - Start timestamp
 * - ${END} - End timestamp
 * - ${ENTITIES} - Array of entity identifiers
 * - Custom variables as defined in data mappings
 *
 * Authentication:
 *    The script automatically detects and uses credentials associated with the
 *    HTTP Connection record. Supported credential types:
 *    - Basic Auth (basic_auth_credentials): username + password
 *    - API Key (api_key_credentials): api_key as Bearer token
 *    - Basic Auth with no username but valid password: treated as Bearer token
 *    If no credential is found, requests are sent without authentication.
 */

var vendorChartData = Class.create();
vendorChartData.prototype = {
    initialize: function(dataSourceId, httpConnectionId) {
        this.dataSourceId = dataSourceId;
        this.httpConnectionId = httpConnectionId;
        
        this.baseUrl = this.getHttpConnectionBaseUrl(httpConnectionId);
        this.credential = this.getHttpConnectionCredential(httpConnectionId);
        
        // Grafana Mimir configuration
        this.prometheusHttpPrefix = '/prometheus';
        this.tenantId = '';  // Set for multi-tenant Mimir deployments
        this.defaultStep = 60;
    },

    getHttpConnectionBaseUrl: function(httpConnectionId) {
        var gr = new GlideRecord('http_connection');
        if (gr.get(httpConnectionId)) {
            var url = gr.getValue('connection_url') || '';
            return url.replace(/\/+$/, '');
        }
        gs.error('Mimir Chart Data: Could not find HTTP Connection with sys_id: ' + httpConnectionId);
        return '';
    },

    getMidServerName: function(httpConnectionId) {
        var gr = new GlideRecord('http_connection');
        if (gr.get(httpConnectionId)) {
            var midServerRef = gr.getValue('mid_server');
            if (midServerRef) {
                var midGr = new GlideRecord('ecc_agent');
                if (midGr.get(midServerRef)) {
                    return midGr.getValue('name');
                }
            }
        }
        return '';
    },

    getHttpConnectionCredential: function(httpConnectionId) {
        var result = { type: 'none' };
        
        var gr = new GlideRecord('http_connection');
        if (!gr.get(httpConnectionId)) {
            return result;
        }
        
        var credentialRef = gr.getValue('credential');
        if (!credentialRef) {
            return result;
        }
        
        var basicGr = new GlideRecord('basic_auth_credentials');
        if (basicGr.get(credentialRef)) {
            var username = basicGr.getValue('user_name') || '';
            var password = basicGr.password.getDecryptedValue() || '';
            
            if (username && password) {
                return { type: 'basic', username: username, password: password };
            }
            if (password) {
                return { type: 'bearer', token: password };
            }
        }
        
        var apiKeyGr = new GlideRecord('api_key_credentials');
        if (apiKeyGr.get(credentialRef)) {
            var apiKey = apiKeyGr.api_key.getDecryptedValue() || '';
            if (apiKey) {
                return { type: 'bearer', token: apiKey };
            }
        }
        
        return result;
    },

    applyCredential: function(request) {
        if (!this.credential || this.credential.type === 'none') {
            return;
        }
        
        if (this.credential.type === 'basic') {
            request.setBasicAuth(this.credential.username, this.credential.password);
        } else if (this.credential.type === 'bearer') {
            request.setRequestHeader('Authorization', 'Bearer ' + this.credential.token);
        }
    },

    fullQuery: function(queries, templateVariables) {
        var results = [];
        var self = this;

        if (!this.baseUrl) {
            gs.error('Mimir Chart Data: No base URL configured. Check HTTP Connection.');
            return results;
        }

        queries.forEach(function(queryObj) {
            var query = queryObj.query;
            var convertedQuery = self.convertQueryTemplateVariables(query, templateVariables);
            
            var timeseriesData = self.executeRangeQuery(
                convertedQuery,
                templateVariables.start,
                templateVariables.end
            );
            
            timeseriesData.forEach(function(ts) {
                results.push(ts);
            });
        });

        return results;
    },

    convertQueryTemplateVariables: function(query, variables) {
        var result = query;
        
        for (var key in variables) {
            if (variables.hasOwnProperty(key)) {
                var value = variables[key];
                var pattern = new RegExp('\\$\\{' + key + '\\}', 'gi');
                
                var replacement;
                if (Array.isArray(value)) {
                    // For ENTITIES, create a regex alternation for PromQL =~ matching
                    replacement = value.join('|');
                } else {
                    replacement = String(value);
                }
                
                result = result.replace(pattern, replacement);
            }
        }
        
        return result;
    },

    executeRangeQuery: function(query, start, end) {
        var results = [];
        var self = this;

        // Convert milliseconds to seconds if needed
        var startSec = start > 9999999999 ? Math.floor(start / 1000) : start;
        var endSec = end > 9999999999 ? Math.floor(end / 1000) : end;

        var step = this.calculateStep(startSec, endSec);
        var fullUrl = this.baseUrl + this.prometheusHttpPrefix + '/api/v1/query_range';

        var request = new sn_ws.RESTMessageV2();
        request.setHttpMethod('POST');
        request.setEndpoint(fullUrl);
        request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
        
        var midServer = this.getMidServerName(this.httpConnectionId);
        if (midServer) {
            request.setMIDServer(midServer);
        }
        
        this.applyCredential(request);
        
        if (this.tenantId) {
            request.setRequestHeader('X-Scope-OrgID', this.tenantId);
        }

        var body = 'query=' + encodeURIComponent(query);
        body += '&start=' + startSec;
        body += '&end=' + endSec;
        body += '&step=' + step;
        
        request.setRequestBody(body);

        try {
            var responseBody = this.queryAPI(request);
            var response = JSON.parse(responseBody);

            if (response.status === 'success' && response.data) {
                results = this.transformRangeQueryResponse(response.data, query);
            }
        } catch (err) {
            gs.error('Mimir Chart Data: Error executing range query - ' + err.message);
        }

        return results;
    },

    calculateStep: function(start, end) {
        var duration = end - start;
        var targetPoints = 300;
        var step = Math.ceil(duration / targetPoints);
        
        step = Math.max(15, Math.min(step, 3600));
        
        var niceIntervals = [15, 30, 60, 120, 300, 600, 900, 1800, 3600];
        for (var i = 0; i < niceIntervals.length; i++) {
            if (step <= niceIntervals[i]) {
                return niceIntervals[i];
            }
        }
        
        return this.defaultStep;
    },

    transformRangeQueryResponse: function(data, originalQuery) {
        var results = [];
        var self = this;

        if (data.resultType === 'matrix' && data.result) {
            data.result.forEach(function(series) {
                var metricName = self.buildMetricName(series.metric);
                var dimensions = self.extractDimensions(series.metric);
                var unit = self.inferUnit(series.metric, originalQuery);

                var timeseriesEntry = {
                    metric_name: metricName,
                    series: [],
                    chart_options: { unit: unit, dimensions: dimensions }
                };

                if (series.values) {
                    series.values.forEach(function(point) {
                        var timestamp = point[0] * 1000; // Convert to milliseconds
                        var value = parseFloat(point[1]);
                        
                        if (isNaN(value) || !isFinite(value)) {
                            value = null;
                        }
                        
                        timeseriesEntry.series.push({
                            timestamp: timestamp,
                            value: value
                        });
                    });
                }

                results.push(timeseriesEntry);
            });
        } else if (data.resultType === 'vector' && data.result) {
            data.result.forEach(function(series) {
                var metricName = self.buildMetricName(series.metric);
                var dimensions = self.extractDimensions(series.metric);

                var timestamp = series.value[0] * 1000;
                var value = parseFloat(series.value[1]);
                
                if (isNaN(value) || !isFinite(value)) {
                    value = null;
                }

                results.push({
                    metric_name: metricName,
                    series: [{ timestamp: timestamp, value: value }],
                    chart_options: { dimensions: dimensions }
                });
            });
        }

        return results;
    },

    buildMetricName: function(metric) {
        if (!metric) return 'unknown';

        var name = metric.__name__ || 'value';
        var identifiers = [];
        ['pod', 'node', 'instance', 'container', 'job'].forEach(function(label) {
            if (metric[label]) identifiers.push(metric[label]);
        });

        if (identifiers.length > 0) {
            name += ' {' + identifiers.join(', ') + '}';
        }
        return name;
    },

    extractDimensions: function(metric) {
        var dimensions = {};
        if (!metric) return dimensions;

        Object.keys(metric).forEach(function(key) {
            if (key !== '__name__') dimensions[key] = metric[key];
        });
        return dimensions;
    },

    inferUnit: function(metric, query) {
        var metricName = metric ? metric.__name__ || '' : '';
        var queryLower = (metricName + ' ' + query).toLowerCase();

        if (queryLower.indexOf('_bytes') !== -1) return 'bytes';
        if (queryLower.indexOf('_seconds') !== -1 || queryLower.indexOf('duration') !== -1) return 'seconds';
        if (queryLower.indexOf('_percent') !== -1 || queryLower.indexOf('ratio') !== -1) return 'percent';
        if (queryLower.indexOf('cpu') !== -1 && queryLower.indexOf('rate') !== -1) return 'cores';
        if (queryLower.indexOf('memory') !== -1 && queryLower.indexOf('bytes') === -1) return 'bytes';
        if (queryLower.indexOf('_total') !== -1) return 'count';
        if (queryLower.indexOf('requests') !== -1 || queryLower.indexOf('_count') !== -1) return 'requests';
        return '';
    },

    queryAPI: function(request) {
        try {
            var response = request.execute();
            var status = response.getStatusCode();
            var responseBody = response.getBody();

            if (status >= 400) {
                throw new Error('API Error (' + status + '): ' + responseBody);
            }

            return responseBody;
        } catch (err) {
            throw new Error('API Error: ' + (err.message || err.toString()));
        }
    },

    type: 'vendorChartData'
};

// DO NOT CHANGE ANYTHING BELOW THIS LINE — Required for execution
var object = new vendorChartData(datasourceID, httpConnectionID);
var result = object.fullQuery(queries, templateVariables);
result;

Example PromQL Queries for Charts

Once the integration is working, you can add charts to your dashboard using PromQL queries. The ${ENTITIES} variable is replaced with a pipe-delimited list of pod names at runtime, suitable for PromQL regex matching.

Memory utilization (working set bytes):

container_memory_working_set_bytes{pod=~"${ENTITIES}", container!="POD", container!=""}

CPU usage rate:

sum(rate(container_cpu_usage_seconds_total{pod=~"${ENTITIES}", container!="POD", container!=""}[5m])) by (pod)

Memory limit ratio (percent of limit used):

container_memory_working_set_bytes{pod=~"${ENTITIES}", container!="POD", container!=""}
  / on(pod, namespace, container)
kube_pod_container_resource_limits{pod=~"${ENTITIES}", resource="memory"}

Network receive bytes rate:

sum(rate(container_network_receive_bytes_total{pod=~"${ENTITIES}"}[5m])) by (pod)

Debugging

Both scripts log extensively with the prefixes Mimir Related Entities: and Mimir Chart Data:. Search the system logs for these prefixes to trace the full request lifecycle: credential detection, MID Server routing, endpoint URLs, PromQL selectors, response status, and entity counts.

If the Observability tab hangs with a loading spinner after entities are found, check the entity_type and dashboard configuration first — that was the root cause in my case.

Adapting This for Other Prometheus-Compatible Backends

The scripts are built against Mimir's API, but since Mimir implements the Prometheus HTTP API, they should work with minimal changes against Prometheus itself, Thanos, Cortex, or Victoria Metrics. The main things you'd need to adjust:

  • prometheusHttpPrefix — Mimir defaults to /prometheus; vanilla Prometheus uses no prefix
  • tenantId and X-Scope-OrgID — Mimir-specific; other backends may use different multi-tenancy headers or none at all
  • Credential type — depends on your infrastructure's auth layer

The core patterns — form-encoded POST to /api/v1/series and /api/v1/query_range, timestamp handling, and the RelatedEntity/TimeseriesEntry output formats — are the same regardless of which Prometheus-compatible backend you're targeting.


 

Version history
Last update:
2 weeks ago
Updated by:
Contributors