From 0c2130aee5075ca11a93518e243364130f88814a Mon Sep 17 00:00:00 2001 From: pratap360 Date: Sat, 25 Oct 2025 16:09:24 +0530 Subject: [PATCH 1/3] Add Cost Optimization Analyzer and Scheduled Job --- Specialized Areas/Cost Optimization/README.md | 39 +++ .../Cost Optimization/cost_analyzer.js | 238 ++++++++++++++++++ .../Cost Optimization/scheduled_job.js | 24 ++ 3 files changed, 301 insertions(+) create mode 100644 Specialized Areas/Cost Optimization/README.md create mode 100644 Specialized Areas/Cost Optimization/cost_analyzer.js create mode 100644 Specialized Areas/Cost Optimization/scheduled_job.js diff --git a/Specialized Areas/Cost Optimization/README.md b/Specialized Areas/Cost Optimization/README.md new file mode 100644 index 0000000000..0ded5f8d01 --- /dev/null +++ b/Specialized Areas/Cost Optimization/README.md @@ -0,0 +1,39 @@ +# ServiceNow Instance Cost Optimization Analyzer + +## Description +Analyzes ServiceNow instance usage to identify cost optimization opportunities including unused licenses, redundant integrations, and oversized tables. + +## Use Case +- Identify unused user licenses for cost savings +- Find redundant or duplicate integrations +- Locate oversized tables affecting performance and storage costs +- Generate cost optimization reports for management + +## Features +- **License Analysis**: Finds inactive users with expensive licenses +- **Integration Audit**: Identifies duplicate or unused REST/SOAP integrations +- **Storage Analysis**: Locates tables consuming excessive storage +- **Cost Reporting**: Generates actionable cost-saving recommendations + +## Implementation + +### 1. Script Include (cost_analyzer.js) +Create a Script Include named `CostOptimizationAnalyzer` + +### 2. Scheduled Job (scheduled_job.js) +Run weekly analysis and generate reports + +### 3. System Properties +- `cost.analyzer.license.threshold` = 90 (days of inactivity) +- `cost.analyzer.table.size.threshold` = 1000000 (records) +- `cost.analyzer.integration.threshold` = 30 (days unused) + +## Output +Returns JSON object with: +```json +{ + "unusedLicenses": [{"user": "john.doe", "license": "itil", "lastLogin": "2024-01-15"}], + "redundantIntegrations": [{"name": "Duplicate LDAP", "type": "REST", "lastUsed": "2024-02-01"}], + "oversizedTables": [{"table": "sys_audit", "recordCount": 5000000, "sizeGB": 12.5}], + "totalPotentialSavings": "$15,000/month" +} diff --git a/Specialized Areas/Cost Optimization/cost_analyzer.js b/Specialized Areas/Cost Optimization/cost_analyzer.js new file mode 100644 index 0000000000..287a2801dd --- /dev/null +++ b/Specialized Areas/Cost Optimization/cost_analyzer.js @@ -0,0 +1,238 @@ + +var CostOptimizationAnalyzer = Class.create(); +CostOptimizationAnalyzer.prototype = { + + analyze: function() { + try { + var results = { + unusedLicenses: this.findUnusedLicenses(), + redundantIntegrations: this.findRedundantIntegrations(), + oversizedTables: this.findOversizedTables(), + analysisDate: new GlideDateTime().getDisplayValue(), + totalPotentialSavings: 0 + }; + + results.totalPotentialSavings = this.calculatePotentialSavings(results); + this.logResults(results); + return results; + + } catch (e) { + gs.error('Cost Analyzer Error: ' + e.message); + return null; + } + }, + + findUnusedLicenses: function() { + var unusedLicenses = []; + var threshold = gs.getProperty('cost.analyzer.license.threshold', '90'); + var cutoffDate = gs.daysAgoStart(parseInt(threshold)); + + var userGr = new GlideRecord('sys_user'); + userGr.addQuery('active', true); + userGr.addQuery('last_login_time', '<', cutoffDate); + userGr.addNotNullQuery('last_login_time'); + userGr.query(); + + while (userGr.next()) { + var roles = this.getExpensiveRoles(userGr.sys_id.toString()); + if (roles.length > 0) { + unusedLicenses.push({ + user: userGr.user_name.toString(), + name: userGr.name.toString(), + lastLogin: userGr.last_login_time.getDisplayValue(), + expensiveRoles: roles, + estimatedMonthlyCost: roles.length * 100 // Estimate $100 per role + }); + } + } + + return unusedLicenses; + }, + + getExpensiveRoles: function(userId) { + var expensiveRoles = ['itil', 'itil_admin', 'admin', 'security_admin', 'asset']; + var userRoles = []; + + var roleGr = new GlideRecord('sys_user_has_role'); + roleGr.addQuery('user', userId); + roleGr.query(); + + while (roleGr.next()) { + var roleName = roleGr.role.name.toString(); + if (expensiveRoles.indexOf(roleName) !== -1) { + userRoles.push(roleName); + } + } + + return userRoles; + }, + + findRedundantIntegrations: function() { + var redundantIntegrations = []; + var threshold = gs.getProperty('cost.analyzer.integration.threshold', '30'); + var cutoffDate = gs.daysAgoStart(parseInt(threshold)); + + // Check REST Messages + var restGr = new GlideRecord('sys_rest_message'); + restGr.query(); + + while (restGr.next()) { + var lastUsed = this.getIntegrationLastUsed(restGr.sys_id.toString(), 'rest'); + if (lastUsed && lastUsed < cutoffDate) { + redundantIntegrations.push({ + name: restGr.name.toString(), + type: 'REST', + endpoint: restGr.endpoint.toString(), + lastUsed: lastUsed, + status: 'Potentially Unused' + }); + } + } + + // Check for duplicate endpoints + var duplicates = this.findDuplicateEndpoints(); + redundantIntegrations = redundantIntegrations.concat(duplicates); + + return redundantIntegrations; + }, + + getIntegrationLastUsed: function(integrationId, type) { + var logGr = new GlideRecord('syslog'); + logGr.addQuery('message', 'CONTAINS', integrationId); + logGr.orderByDesc('sys_created_on'); + logGr.setLimit(1); + logGr.query(); + + if (logGr.next()) { + return logGr.sys_created_on.getDisplayValue(); + } + return null; + }, + + findDuplicateEndpoints: function() { + var duplicates = []; + var endpoints = {}; + + var restGr = new GlideRecord('sys_rest_message'); + restGr.query(); + + while (restGr.next()) { + var endpoint = restGr.endpoint.toString(); + if (endpoints[endpoint]) { + duplicates.push({ + name: restGr.name.toString(), + type: 'REST', + endpoint: endpoint, + status: 'Duplicate Endpoint', + duplicateOf: endpoints[endpoint] + }); + } else { + endpoints[endpoint] = restGr.name.toString(); + } + } + + return duplicates; + }, + + findOversizedTables: function() { + var oversizedTables = []; + var threshold = gs.getProperty('cost.analyzer.table.size.threshold', '1000000'); + + var tableGr = new GlideRecord('sys_db_object'); + tableGr.addQuery('name', 'STARTSWITH', 'u_'); // Custom tables + tableGr.query(); + + while (tableGr.next()) { + var tableName = tableGr.name.toString(); + var recordCount = this.getTableRecordCount(tableName); + + if (recordCount > parseInt(threshold)) { + var sizeInfo = this.estimateTableSize(tableName, recordCount); + oversizedTables.push({ + table: tableName, + recordCount: recordCount, + estimatedSizeGB: sizeInfo.sizeGB, + recommendation: sizeInfo.recommendation + }); + } + } + + // Check system tables that commonly grow large + var systemTables = ['sys_audit', 'sys_email', 'syslog', 'sys_attachment']; + systemTables.forEach(function(tableName) { + var recordCount = this.getTableRecordCount(tableName); + if (recordCount > parseInt(threshold)) { + var sizeInfo = this.estimateTableSize(tableName, recordCount); + oversizedTables.push({ + table: tableName, + recordCount: recordCount, + estimatedSizeGB: sizeInfo.sizeGB, + recommendation: sizeInfo.recommendation + }); + } + }.bind(this)); + + return oversizedTables; + }, + + getTableRecordCount: function(tableName) { + try { + var countGr = new GlideAggregate(tableName); + countGr.addAggregate('COUNT'); + countGr.query(); + + if (countGr.next()) { + return parseInt(countGr.getAggregate('COUNT')); + } + } catch (e) { + gs.debug('Cannot count records for table: ' + tableName); + } + return 0; + }, + + estimateTableSize: function(tableName, recordCount) { + var avgRecordSize = 2; // KB per record (estimate) + var sizeGB = (recordCount * avgRecordSize) / (1024 * 1024); + + var recommendation = 'Consider archiving old records'; + if (tableName === 'sys_audit') { + recommendation = 'Configure audit retention policy'; + } else if (tableName === 'sys_email') { + recommendation = 'Clean up old email records'; + } else if (tableName === 'syslog') { + recommendation = 'Reduce log retention period'; + } + + return { + sizeGB: Math.round(sizeGB * 100) / 100, + recommendation: recommendation + }; + }, + + calculatePotentialSavings: function(results) { + var totalSavings = 0; + + // License savings + results.unusedLicenses.forEach(function(license) { + totalSavings += license.estimatedMonthlyCost || 0; + }); + + // Storage savings (estimate $10 per GB per month) + results.oversizedTables.forEach(function(table) { + totalSavings += (table.estimatedSizeGB * 10); + }); + + return '$' + totalSavings.toLocaleString() + '/month'; + }, + + logResults: function(results) { + gs.info('=== Cost Optimization Analysis Results ==='); + gs.info('Unused Licenses Found: ' + results.unusedLicenses.length); + gs.info('Redundant Integrations: ' + results.redundantIntegrations.length); + gs.info('Oversized Tables: ' + results.oversizedTables.length); + gs.info('Potential Monthly Savings: ' + results.totalPotentialSavings); + gs.info('========================================'); + }, + + type: 'CostOptimizationAnalyzer' +}; diff --git a/Specialized Areas/Cost Optimization/scheduled_job.js b/Specialized Areas/Cost Optimization/scheduled_job.js new file mode 100644 index 0000000000..ee7c6662a2 --- /dev/null +++ b/Specialized Areas/Cost Optimization/scheduled_job.js @@ -0,0 +1,24 @@ +// Scheduled Job Script - Run Weekly +// Name: Weekly Cost Optimization Analysis + +try { + var analyzer = new CostOptimizationAnalyzer(); + var results = analyzer.analyze(); + + if (results) { + // Store results in a custom table or send email report + gs.info('Cost optimization analysis completed successfully'); + + var emailBody = 'Cost Optimization Report:\n\n'; + emailBody += 'Unused Licenses: ' + results.unusedLicenses.length + '\n'; + emailBody += 'Redundant Integrations: ' + results.redundantIntegrations.length + '\n'; + emailBody += 'Oversized Tables: ' + results.oversizedTables.length + '\n'; + emailBody += 'Potential Savings: ' + results.totalPotentialSavings + '\n'; + + // below line will send to email + gs.eventQueue('cost.optimization.report', null, emailBody); + } + +} catch (e) { + gs.error('Scheduled cost analysis failed: ' + e.message); +} From 37a5043623c8ba839bc98a4680e01a12da4d4a77 Mon Sep 17 00:00:00 2001 From: pratap360 Date: Sat, 25 Oct 2025 16:19:19 +0530 Subject: [PATCH 2/3] Add Cost Optimization Analyzer and Scheduled Job scripts --- .../Cost Optimization/{ => Instance Cost Analyzer}/README.md | 0 .../{ => Instance Cost Analyzer}/cost_analyzer.js | 0 .../{ => Instance Cost Analyzer}/scheduled_job.js | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename Specialized Areas/Cost Optimization/{ => Instance Cost Analyzer}/README.md (100%) rename Specialized Areas/Cost Optimization/{ => Instance Cost Analyzer}/cost_analyzer.js (100%) rename Specialized Areas/Cost Optimization/{ => Instance Cost Analyzer}/scheduled_job.js (100%) diff --git a/Specialized Areas/Cost Optimization/README.md b/Specialized Areas/Cost Optimization/Instance Cost Analyzer/README.md similarity index 100% rename from Specialized Areas/Cost Optimization/README.md rename to Specialized Areas/Cost Optimization/Instance Cost Analyzer/README.md diff --git a/Specialized Areas/Cost Optimization/cost_analyzer.js b/Specialized Areas/Cost Optimization/Instance Cost Analyzer/cost_analyzer.js similarity index 100% rename from Specialized Areas/Cost Optimization/cost_analyzer.js rename to Specialized Areas/Cost Optimization/Instance Cost Analyzer/cost_analyzer.js diff --git a/Specialized Areas/Cost Optimization/scheduled_job.js b/Specialized Areas/Cost Optimization/Instance Cost Analyzer/scheduled_job.js similarity index 100% rename from Specialized Areas/Cost Optimization/scheduled_job.js rename to Specialized Areas/Cost Optimization/Instance Cost Analyzer/scheduled_job.js From 2aee83ef931fe245488915ecc8a9f523439895d8 Mon Sep 17 00:00:00 2001 From: pratap360 Date: Sun, 26 Oct 2025 23:54:32 +0530 Subject: [PATCH 3/3] Enhance Cost Optimization Analyzer to identify unused IntegrationHub spokes and SOAP services, and improve endpoint duplication checks --- .../Instance Cost Analyzer/cost_analyzer.js | 145 +++++++++++++++--- 1 file changed, 120 insertions(+), 25 deletions(-) diff --git a/Specialized Areas/Cost Optimization/Instance Cost Analyzer/cost_analyzer.js b/Specialized Areas/Cost Optimization/Instance Cost Analyzer/cost_analyzer.js index 287a2801dd..430988d47b 100644 --- a/Specialized Areas/Cost Optimization/Instance Cost Analyzer/cost_analyzer.js +++ b/Specialized Areas/Cost Optimization/Instance Cost Analyzer/cost_analyzer.js @@ -72,33 +72,112 @@ CostOptimizationAnalyzer.prototype = { var threshold = gs.getProperty('cost.analyzer.integration.threshold', '30'); var cutoffDate = gs.daysAgoStart(parseInt(threshold)); - // Check REST Messages - var restGr = new GlideRecord('sys_rest_message'); - restGr.query(); + // Check IntegrationHub Spoke usage + var unusedSpokes = this.findUnusedSpokes(cutoffDate); + redundantIntegrations = redundantIntegrations.concat(unusedSpokes); - while (restGr.next()) { - var lastUsed = this.getIntegrationLastUsed(restGr.sys_id.toString(), 'rest'); - if (lastUsed && lastUsed < cutoffDate) { - redundantIntegrations.push({ - name: restGr.name.toString(), - type: 'REST', - endpoint: restGr.endpoint.toString(), - lastUsed: lastUsed, + // Check SOAP Web Services + var unusedSoap = this.findUnusedSoapServices(cutoffDate); + redundantIntegrations = redundantIntegrations.concat(unusedSoap); + + // Check for duplicate endpoints (still valuable) + var duplicates = this.findDuplicateEndpoints(); + redundantIntegrations = redundantIntegrations.concat(duplicates); + + return redundantIntegrations; + }, + + findUnusedSpokes: function(cutoffDate) { + var unusedSpokes = []; + + // Get all installed spokes + var spokeGr = new GlideRecord('sys_app'); + spokeGr.addQuery('source', 'sn_app_store'); + spokeGr.addQuery('name', 'CONTAINS', 'spoke'); + spokeGr.query(); + + while (spokeGr.next()) { + var spokeId = spokeGr.sys_id.toString(); + var spokeName = spokeGr.name.toString(); + + // Check usage in ua_ih_usage table + var usageGr = new GlideRecord('ua_ih_usage'); + usageGr.addQuery('spoke', spokeId); + usageGr.addQuery('sys_created_on', '>=', cutoffDate); + usageGr.setLimit(1); + usageGr.query(); + + if (!usageGr.hasNext()) { + // No recent usage found + var lastUsage = this.getLastSpokeUsage(spokeId); + unusedSpokes.push({ + name: spokeName, + type: 'IntegrationHub Spoke', + spokeId: spokeId, + lastUsed: lastUsage, status: 'Potentially Unused' }); } } - // Check for duplicate endpoints - var duplicates = this.findDuplicateEndpoints(); - redundantIntegrations = redundantIntegrations.concat(duplicates); + return unusedSpokes; + }, + + getLastSpokeUsage: function(spokeId) { + var usageGr = new GlideRecord('ua_ih_usage'); + usageGr.addQuery('spoke', spokeId); + usageGr.orderByDesc('sys_created_on'); + usageGr.setLimit(1); + usageGr.query(); - return redundantIntegrations; + if (usageGr.next()) { + return usageGr.sys_created_on.getDisplayValue(); + } + return 'Never used'; + }, + + findUnusedSoapServices: function(cutoffDate) { + var unusedSoap = []; + + var soapGr = new GlideRecord('sys_web_service'); + soapGr.query(); + + while (soapGr.next()) { + var soapId = soapGr.sys_id.toString(); + var soapName = soapGr.name.toString(); + + // Check if SOAP service has been used recently + var usageCount = this.getSoapUsageCount(soapId, cutoffDate); + if (usageCount === 0) { + unusedSoap.push({ + name: soapName, + type: 'SOAP Web Service', + endpoint: soapGr.endpoint.toString(), + lastUsed: this.getLastSoapUsage(soapId), + status: 'Potentially Unused' + }); + } + } + + return unusedSoap; + }, + + getSoapUsageCount: function(soapId, cutoffDate) { + var usageGr = new GlideAggregate('sys_soap_log'); + usageGr.addQuery('web_service', soapId); + usageGr.addQuery('sys_created_on', '>=', cutoffDate); + usageGr.addAggregate('COUNT'); + usageGr.query(); + + if (usageGr.next()) { + return parseInt(usageGr.getAggregate('COUNT')); + } + return 0; }, - getIntegrationLastUsed: function(integrationId, type) { - var logGr = new GlideRecord('syslog'); - logGr.addQuery('message', 'CONTAINS', integrationId); + getLastSoapUsage: function(soapId) { + var logGr = new GlideRecord('sys_soap_log'); + logGr.addQuery('web_service', soapId); logGr.orderByDesc('sys_created_on'); logGr.setLimit(1); logGr.query(); @@ -106,7 +185,7 @@ CostOptimizationAnalyzer.prototype = { if (logGr.next()) { return logGr.sys_created_on.getDisplayValue(); } - return null; + return 'Never used'; }, findDuplicateEndpoints: function() { @@ -147,7 +226,7 @@ CostOptimizationAnalyzer.prototype = { var recordCount = this.getTableRecordCount(tableName); if (recordCount > parseInt(threshold)) { - var sizeInfo = this.estimateTableSize(tableName, recordCount); + var sizeInfo = this.getActualTableSize(tableName, recordCount); oversizedTables.push({ table: tableName, recordCount: recordCount, @@ -162,7 +241,7 @@ CostOptimizationAnalyzer.prototype = { systemTables.forEach(function(tableName) { var recordCount = this.getTableRecordCount(tableName); if (recordCount > parseInt(threshold)) { - var sizeInfo = this.estimateTableSize(tableName, recordCount); + var sizeInfo = this.getActualTableSize(tableName, recordCount); oversizedTables.push({ table: tableName, recordCount: recordCount, @@ -190,9 +269,25 @@ CostOptimizationAnalyzer.prototype = { return 0; }, - estimateTableSize: function(tableName, recordCount) { - var avgRecordSize = 2; // KB per record (estimate) - var sizeGB = (recordCount * avgRecordSize) / (1024 * 1024); + getActualTableSize: function(tableName, recordCount) { + var sizeGB = 0; + + // Get actual table size from sys_physical_table_stats + var statsGr = new GlideRecord('sys_physical_table_stats'); + statsGr.addQuery('table_name', tableName); + statsGr.orderByDesc('sys_created_on'); + statsGr.setLimit(1); + statsGr.query(); + + if (statsGr.next()) { + // Convert bytes to GB + var sizeBytes = parseInt(statsGr.size_bytes || 0); + sizeGB = sizeBytes / (1024 * 1024 * 1024); + } else { + // Fallback to estimate if stats not available + var avgRecordSize = 2; // KB per record (estimate) + sizeGB = (recordCount * avgRecordSize) / (1024 * 1024); + } var recommendation = 'Consider archiving old records'; if (tableName === 'sys_audit') { @@ -235,4 +330,4 @@ CostOptimizationAnalyzer.prototype = { }, type: 'CostOptimizationAnalyzer' -}; +}