Skip to content

Commit 6c917c2

Browse files
authored
Added Cost Optimization Analyzer (#2481)
* Add Cost Optimization Analyzer and Scheduled Job * Add Cost Optimization Analyzer and Scheduled Job scripts * Enhance Cost Optimization Analyzer to identify unused IntegrationHub spokes and SOAP services, and improve endpoint duplication checks
1 parent 6e4da1e commit 6c917c2

File tree

3 files changed

+396
-0
lines changed

3 files changed

+396
-0
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# ServiceNow Instance Cost Optimization Analyzer
2+
3+
## Description
4+
Analyzes ServiceNow instance usage to identify cost optimization opportunities including unused licenses, redundant integrations, and oversized tables.
5+
6+
## Use Case
7+
- Identify unused user licenses for cost savings
8+
- Find redundant or duplicate integrations
9+
- Locate oversized tables affecting performance and storage costs
10+
- Generate cost optimization reports for management
11+
12+
## Features
13+
- **License Analysis**: Finds inactive users with expensive licenses
14+
- **Integration Audit**: Identifies duplicate or unused REST/SOAP integrations
15+
- **Storage Analysis**: Locates tables consuming excessive storage
16+
- **Cost Reporting**: Generates actionable cost-saving recommendations
17+
18+
## Implementation
19+
20+
### 1. Script Include (cost_analyzer.js)
21+
Create a Script Include named `CostOptimizationAnalyzer`
22+
23+
### 2. Scheduled Job (scheduled_job.js)
24+
Run weekly analysis and generate reports
25+
26+
### 3. System Properties
27+
- `cost.analyzer.license.threshold` = 90 (days of inactivity)
28+
- `cost.analyzer.table.size.threshold` = 1000000 (records)
29+
- `cost.analyzer.integration.threshold` = 30 (days unused)
30+
31+
## Output
32+
Returns JSON object with:
33+
```json
34+
{
35+
"unusedLicenses": [{"user": "john.doe", "license": "itil", "lastLogin": "2024-01-15"}],
36+
"redundantIntegrations": [{"name": "Duplicate LDAP", "type": "REST", "lastUsed": "2024-02-01"}],
37+
"oversizedTables": [{"table": "sys_audit", "recordCount": 5000000, "sizeGB": 12.5}],
38+
"totalPotentialSavings": "$15,000/month"
39+
}
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
2+
var CostOptimizationAnalyzer = Class.create();
3+
CostOptimizationAnalyzer.prototype = {
4+
5+
analyze: function() {
6+
try {
7+
var results = {
8+
unusedLicenses: this.findUnusedLicenses(),
9+
redundantIntegrations: this.findRedundantIntegrations(),
10+
oversizedTables: this.findOversizedTables(),
11+
analysisDate: new GlideDateTime().getDisplayValue(),
12+
totalPotentialSavings: 0
13+
};
14+
15+
results.totalPotentialSavings = this.calculatePotentialSavings(results);
16+
this.logResults(results);
17+
return results;
18+
19+
} catch (e) {
20+
gs.error('Cost Analyzer Error: ' + e.message);
21+
return null;
22+
}
23+
},
24+
25+
findUnusedLicenses: function() {
26+
var unusedLicenses = [];
27+
var threshold = gs.getProperty('cost.analyzer.license.threshold', '90');
28+
var cutoffDate = gs.daysAgoStart(parseInt(threshold));
29+
30+
var userGr = new GlideRecord('sys_user');
31+
userGr.addQuery('active', true);
32+
userGr.addQuery('last_login_time', '<', cutoffDate);
33+
userGr.addNotNullQuery('last_login_time');
34+
userGr.query();
35+
36+
while (userGr.next()) {
37+
var roles = this.getExpensiveRoles(userGr.sys_id.toString());
38+
if (roles.length > 0) {
39+
unusedLicenses.push({
40+
user: userGr.user_name.toString(),
41+
name: userGr.name.toString(),
42+
lastLogin: userGr.last_login_time.getDisplayValue(),
43+
expensiveRoles: roles,
44+
estimatedMonthlyCost: roles.length * 100 // Estimate $100 per role
45+
});
46+
}
47+
}
48+
49+
return unusedLicenses;
50+
},
51+
52+
getExpensiveRoles: function(userId) {
53+
var expensiveRoles = ['itil', 'itil_admin', 'admin', 'security_admin', 'asset'];
54+
var userRoles = [];
55+
56+
var roleGr = new GlideRecord('sys_user_has_role');
57+
roleGr.addQuery('user', userId);
58+
roleGr.query();
59+
60+
while (roleGr.next()) {
61+
var roleName = roleGr.role.name.toString();
62+
if (expensiveRoles.indexOf(roleName) !== -1) {
63+
userRoles.push(roleName);
64+
}
65+
}
66+
67+
return userRoles;
68+
},
69+
70+
findRedundantIntegrations: function() {
71+
var redundantIntegrations = [];
72+
var threshold = gs.getProperty('cost.analyzer.integration.threshold', '30');
73+
var cutoffDate = gs.daysAgoStart(parseInt(threshold));
74+
75+
// Check IntegrationHub Spoke usage
76+
var unusedSpokes = this.findUnusedSpokes(cutoffDate);
77+
redundantIntegrations = redundantIntegrations.concat(unusedSpokes);
78+
79+
// Check SOAP Web Services
80+
var unusedSoap = this.findUnusedSoapServices(cutoffDate);
81+
redundantIntegrations = redundantIntegrations.concat(unusedSoap);
82+
83+
// Check for duplicate endpoints (still valuable)
84+
var duplicates = this.findDuplicateEndpoints();
85+
redundantIntegrations = redundantIntegrations.concat(duplicates);
86+
87+
return redundantIntegrations;
88+
},
89+
90+
findUnusedSpokes: function(cutoffDate) {
91+
var unusedSpokes = [];
92+
93+
// Get all installed spokes
94+
var spokeGr = new GlideRecord('sys_app');
95+
spokeGr.addQuery('source', 'sn_app_store');
96+
spokeGr.addQuery('name', 'CONTAINS', 'spoke');
97+
spokeGr.query();
98+
99+
while (spokeGr.next()) {
100+
var spokeId = spokeGr.sys_id.toString();
101+
var spokeName = spokeGr.name.toString();
102+
103+
// Check usage in ua_ih_usage table
104+
var usageGr = new GlideRecord('ua_ih_usage');
105+
usageGr.addQuery('spoke', spokeId);
106+
usageGr.addQuery('sys_created_on', '>=', cutoffDate);
107+
usageGr.setLimit(1);
108+
usageGr.query();
109+
110+
if (!usageGr.hasNext()) {
111+
// No recent usage found
112+
var lastUsage = this.getLastSpokeUsage(spokeId);
113+
unusedSpokes.push({
114+
name: spokeName,
115+
type: 'IntegrationHub Spoke',
116+
spokeId: spokeId,
117+
lastUsed: lastUsage,
118+
status: 'Potentially Unused'
119+
});
120+
}
121+
}
122+
123+
return unusedSpokes;
124+
},
125+
126+
getLastSpokeUsage: function(spokeId) {
127+
var usageGr = new GlideRecord('ua_ih_usage');
128+
usageGr.addQuery('spoke', spokeId);
129+
usageGr.orderByDesc('sys_created_on');
130+
usageGr.setLimit(1);
131+
usageGr.query();
132+
133+
if (usageGr.next()) {
134+
return usageGr.sys_created_on.getDisplayValue();
135+
}
136+
return 'Never used';
137+
},
138+
139+
findUnusedSoapServices: function(cutoffDate) {
140+
var unusedSoap = [];
141+
142+
var soapGr = new GlideRecord('sys_web_service');
143+
soapGr.query();
144+
145+
while (soapGr.next()) {
146+
var soapId = soapGr.sys_id.toString();
147+
var soapName = soapGr.name.toString();
148+
149+
// Check if SOAP service has been used recently
150+
var usageCount = this.getSoapUsageCount(soapId, cutoffDate);
151+
if (usageCount === 0) {
152+
unusedSoap.push({
153+
name: soapName,
154+
type: 'SOAP Web Service',
155+
endpoint: soapGr.endpoint.toString(),
156+
lastUsed: this.getLastSoapUsage(soapId),
157+
status: 'Potentially Unused'
158+
});
159+
}
160+
}
161+
162+
return unusedSoap;
163+
},
164+
165+
getSoapUsageCount: function(soapId, cutoffDate) {
166+
var usageGr = new GlideAggregate('sys_soap_log');
167+
usageGr.addQuery('web_service', soapId);
168+
usageGr.addQuery('sys_created_on', '>=', cutoffDate);
169+
usageGr.addAggregate('COUNT');
170+
usageGr.query();
171+
172+
if (usageGr.next()) {
173+
return parseInt(usageGr.getAggregate('COUNT'));
174+
}
175+
return 0;
176+
},
177+
178+
getLastSoapUsage: function(soapId) {
179+
var logGr = new GlideRecord('sys_soap_log');
180+
logGr.addQuery('web_service', soapId);
181+
logGr.orderByDesc('sys_created_on');
182+
logGr.setLimit(1);
183+
logGr.query();
184+
185+
if (logGr.next()) {
186+
return logGr.sys_created_on.getDisplayValue();
187+
}
188+
return 'Never used';
189+
},
190+
191+
findDuplicateEndpoints: function() {
192+
var duplicates = [];
193+
var endpoints = {};
194+
195+
var restGr = new GlideRecord('sys_rest_message');
196+
restGr.query();
197+
198+
while (restGr.next()) {
199+
var endpoint = restGr.endpoint.toString();
200+
if (endpoints[endpoint]) {
201+
duplicates.push({
202+
name: restGr.name.toString(),
203+
type: 'REST',
204+
endpoint: endpoint,
205+
status: 'Duplicate Endpoint',
206+
duplicateOf: endpoints[endpoint]
207+
});
208+
} else {
209+
endpoints[endpoint] = restGr.name.toString();
210+
}
211+
}
212+
213+
return duplicates;
214+
},
215+
216+
findOversizedTables: function() {
217+
var oversizedTables = [];
218+
var threshold = gs.getProperty('cost.analyzer.table.size.threshold', '1000000');
219+
220+
var tableGr = new GlideRecord('sys_db_object');
221+
tableGr.addQuery('name', 'STARTSWITH', 'u_'); // Custom tables
222+
tableGr.query();
223+
224+
while (tableGr.next()) {
225+
var tableName = tableGr.name.toString();
226+
var recordCount = this.getTableRecordCount(tableName);
227+
228+
if (recordCount > parseInt(threshold)) {
229+
var sizeInfo = this.getActualTableSize(tableName, recordCount);
230+
oversizedTables.push({
231+
table: tableName,
232+
recordCount: recordCount,
233+
estimatedSizeGB: sizeInfo.sizeGB,
234+
recommendation: sizeInfo.recommendation
235+
});
236+
}
237+
}
238+
239+
// Check system tables that commonly grow large
240+
var systemTables = ['sys_audit', 'sys_email', 'syslog', 'sys_attachment'];
241+
systemTables.forEach(function(tableName) {
242+
var recordCount = this.getTableRecordCount(tableName);
243+
if (recordCount > parseInt(threshold)) {
244+
var sizeInfo = this.getActualTableSize(tableName, recordCount);
245+
oversizedTables.push({
246+
table: tableName,
247+
recordCount: recordCount,
248+
estimatedSizeGB: sizeInfo.sizeGB,
249+
recommendation: sizeInfo.recommendation
250+
});
251+
}
252+
}.bind(this));
253+
254+
return oversizedTables;
255+
},
256+
257+
getTableRecordCount: function(tableName) {
258+
try {
259+
var countGr = new GlideAggregate(tableName);
260+
countGr.addAggregate('COUNT');
261+
countGr.query();
262+
263+
if (countGr.next()) {
264+
return parseInt(countGr.getAggregate('COUNT'));
265+
}
266+
} catch (e) {
267+
gs.debug('Cannot count records for table: ' + tableName);
268+
}
269+
return 0;
270+
},
271+
272+
getActualTableSize: function(tableName, recordCount) {
273+
var sizeGB = 0;
274+
275+
// Get actual table size from sys_physical_table_stats
276+
var statsGr = new GlideRecord('sys_physical_table_stats');
277+
statsGr.addQuery('table_name', tableName);
278+
statsGr.orderByDesc('sys_created_on');
279+
statsGr.setLimit(1);
280+
statsGr.query();
281+
282+
if (statsGr.next()) {
283+
// Convert bytes to GB
284+
var sizeBytes = parseInt(statsGr.size_bytes || 0);
285+
sizeGB = sizeBytes / (1024 * 1024 * 1024);
286+
} else {
287+
// Fallback to estimate if stats not available
288+
var avgRecordSize = 2; // KB per record (estimate)
289+
sizeGB = (recordCount * avgRecordSize) / (1024 * 1024);
290+
}
291+
292+
var recommendation = 'Consider archiving old records';
293+
if (tableName === 'sys_audit') {
294+
recommendation = 'Configure audit retention policy';
295+
} else if (tableName === 'sys_email') {
296+
recommendation = 'Clean up old email records';
297+
} else if (tableName === 'syslog') {
298+
recommendation = 'Reduce log retention period';
299+
}
300+
301+
return {
302+
sizeGB: Math.round(sizeGB * 100) / 100,
303+
recommendation: recommendation
304+
};
305+
},
306+
307+
calculatePotentialSavings: function(results) {
308+
var totalSavings = 0;
309+
310+
// License savings
311+
results.unusedLicenses.forEach(function(license) {
312+
totalSavings += license.estimatedMonthlyCost || 0;
313+
});
314+
315+
// Storage savings (estimate $10 per GB per month)
316+
results.oversizedTables.forEach(function(table) {
317+
totalSavings += (table.estimatedSizeGB * 10);
318+
});
319+
320+
return '$' + totalSavings.toLocaleString() + '/month';
321+
},
322+
323+
logResults: function(results) {
324+
gs.info('=== Cost Optimization Analysis Results ===');
325+
gs.info('Unused Licenses Found: ' + results.unusedLicenses.length);
326+
gs.info('Redundant Integrations: ' + results.redundantIntegrations.length);
327+
gs.info('Oversized Tables: ' + results.oversizedTables.length);
328+
gs.info('Potential Monthly Savings: ' + results.totalPotentialSavings);
329+
gs.info('========================================');
330+
},
331+
332+
type: 'CostOptimizationAnalyzer'
333+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Scheduled Job Script - Run Weekly
2+
// Name: Weekly Cost Optimization Analysis
3+
4+
try {
5+
var analyzer = new CostOptimizationAnalyzer();
6+
var results = analyzer.analyze();
7+
8+
if (results) {
9+
// Store results in a custom table or send email report
10+
gs.info('Cost optimization analysis completed successfully');
11+
12+
var emailBody = 'Cost Optimization Report:\n\n';
13+
emailBody += 'Unused Licenses: ' + results.unusedLicenses.length + '\n';
14+
emailBody += 'Redundant Integrations: ' + results.redundantIntegrations.length + '\n';
15+
emailBody += 'Oversized Tables: ' + results.oversizedTables.length + '\n';
16+
emailBody += 'Potential Savings: ' + results.totalPotentialSavings + '\n';
17+
18+
// below line will send to email
19+
gs.eventQueue('cost.optimization.report', null, emailBody);
20+
}
21+
22+
} catch (e) {
23+
gs.error('Scheduled cost analysis failed: ' + e.message);
24+
}

0 commit comments

Comments
 (0)