Session 04 — Refactoring for Platform Practices
Why this matters
The scripts already work. In this session, you will look at ways to make them easier to maintain and safer to run without changing what they report.
Import this first
Import the applicable refactor scaffold for the module you are reviewing using the shared import steps:
Each scaffold keeps the targeting rules and the online-node filter from the earlier exercises. Before opening a script, review the module settings and confirm that AppliesTo and the Active Discovery filter are correct. Then follow the script review notes below:
The review focuses on four kinds of shared LogicMonitor support: making HTTP requests, formatting output, writing debug messages, and remembering a short-lived token.
Open each comparison and use the Before refactor and After refactor controls to switch between the full scripts.
PropertySource
import groovy.json.JsonSlurper// Read connection details from the resource instead of hard-coding them.def apiHostname = hostProps.get("system.hostname", "").replaceAll('/$', '')def apiBaseUrl = "https://${apiHostname}/api/v1"def apiUser = hostProps.get("fabric.api.user", "")def apiPassword = hostProps.get("fabric.api.pass", "")if (!apiHostname || !apiUser || !apiPassword) return 1// Authenticate once, then use the bearer token for the controller request.def token = requestJson( "${apiBaseUrl}/auth/token", [Authorization: "Basic ${basicAuthHeader(apiUser, apiPassword)}"])def controller = requestJson( "${apiBaseUrl}/controller", [Authorization: "Bearer ${token.access_token}"])// Print resource properties in the format the Collector expects.println "system.categories=Training_Fabric"println "auto.fabric_site=${controller.site}"println "auto.fabric_version=${controller.version}"return 0// Helper methods keep the main PropertySource flow easy to follow.def basicAuthHeader(String user, String password) { return "${user}:${password}".bytes.encodeBase64().toString()}def requestJson(String endpoint, Map headers) { def connection = new URL(endpoint).openConnection() headers.each { key, value -> connection.setRequestProperty(key, value.toString()) } connection.setRequestProperty("Accept", "application/json") connection.connectTimeout = 10000 connection.readTimeout = 20000 if (connection.responseCode >= 400) throw new IllegalStateException("HTTP ${connection.responseCode} from ${endpoint}") return new JsonSlurper().parseText(connection.inputStream.text)}
import com.santaba.agent.groovy.utils.GroovyScriptHelper as GSHimport com.logicmonitor.mod.Snippetsimport groovy.json.JsonSlurper// REVIEW 1: Snippets replace repeated platform boilerplate with versioned helpers.def modLoader = GSH.getInstance(GroovySystem.version) .getScript("Snippets", Snippets.getLoader()) .withBinding(getBinding())def httpMod = modLoader.load("proto.http", "1.0.0")def emit = modLoader.load("lm.emit", "1.3.0")def cacheMod = modLoader.load("lm.cache", "0.3.1")def debugMod = modLoader.load("lm.debug", "2.0.0")def debug = falsedef lmDebug = debugMod.create(hostProps, debug, out)def cacheDebug = [LMDebugPrint: { message -> lmDebug.debug(message) }]def cache = cacheMod.cacheSnippetFactory(cacheDebug, "training-fabric")def http = httpMod.create(hostProps)// REVIEW 2: Resource inputs stay separate from the request and output flow.def hostname = hostProps.get("system.hostname", "").replaceAll('/$', '')def baseUrl = "https://${hostname}/api/v1"def user = hostProps.get("fabric.api.user", "")def pass = hostProps.get("fabric.api.pass", "")if (!hostname || !user || !pass) return 1// REVIEW 3: The main flow stays focused on the module contract.def token = getToken(cache, http, baseUrl, user, pass, lmDebug)if (!token) return 1def response = http.withHeaders( [Authorization: "Bearer ${token}", Accept: "application/json"]).GET("${baseUrl}/controller", 10000, 20000)if (response.responseCode == 401) { lmDebug.warn("Cached token was rejected; refreshing token") token = getToken(cache, http, baseUrl, user, pass, lmDebug, true) if (!token) return 1 response = http.withHeaders( [Authorization: "Bearer ${token}", Accept: "application/json"] ).GET("${baseUrl}/controller", 10000, 20000)}if (response.responseCode >= 400) { lmDebug.error("HTTP ${response.responseCode} from controller endpoint") return 1}def controller = new JsonSlurper().parseText(response.inputStream.text)emit.property("system.categories", "Training_Fabric")emit.property("auto.fabric_site", controller.site)emit.property("auto.fabric_version", controller.version)return 0// REVIEW 4: Cache the short-lived token, never the changing controller data.def getToken(cache, http, String baseUrl, String user, String pass, lmDebug, Boolean forceRefresh = false) { if (forceRefresh) cache.cacheRemove("accessToken") def token = cache.cacheGet("accessToken") if (token) return token // The password is used only to request a new token and is never logged. def basic = "${user}:${pass}".bytes.encodeBase64().toString() def response = http.withHeaders( [Authorization: "Basic ${basic}", Accept: "application/json"] ).GET("${baseUrl}/auth/token", 10000, 20000) if (response.responseCode >= 400) { lmDebug.error("Authentication failed with HTTP ${response.responseCode}") return null } def tokenResponse = new JsonSlurper().parseText(response.inputStream.text) token = tokenResponse.access_token def expiresIn = (tokenResponse.expires_in ?: 300) as Integer // Leave a safety margin so the token does not expire during collection. def cacheTtl = Math.max(1, expiresIn - 60) as Integer cache.cacheSet("accessToken", token, cacheTtl) lmDebug.debug("Fetched and cached a new training API token for ${cacheTtl}s") return token}
Controller collection
import groovy.json.JsonSlurper// Read connection details from the resource instead of hard-coding them.def apiHostname = hostProps.get("system.hostname", "").replaceAll('/$', '')def apiBaseUrl = "https://${apiHostname}/api/v1"def apiUser = hostProps.get("fabric.api.user", "")def apiPassword = hostProps.get("fabric.api.pass", "")if (!apiHostname || !apiUser || !apiPassword) return 1// Authenticate once, then collect the single controller response.def token = requestJson( "${apiBaseUrl}/auth/token", [Authorization: "Basic ${basicAuthHeader(apiUser, apiPassword)}"])def controller = requestJson( "${apiBaseUrl}/controller", [Authorization: "Bearer ${token.access_token}"])// These names must match the datapoints defined in the module JSON.println "controller_health=${controller.health}"println "node_count=${controller.node_count}"println "api_latency_ms=${controller.api_latency_ms}"return 0// Helper methods keep the main collection flow easy to follow.def basicAuthHeader(String user, String password) { return "${user}:${password}".bytes.encodeBase64().toString()}def requestJson(String endpoint, Map headers) { def connection = new URL(endpoint).openConnection() headers.each { key, value -> connection.setRequestProperty(key, value.toString()) } connection.setRequestProperty("Accept", "application/json") connection.connectTimeout = 10000 connection.readTimeout = 20000 if (connection.responseCode >= 400) throw new IllegalStateException("HTTP ${connection.responseCode} from ${endpoint}") return new JsonSlurper().parseText(connection.inputStream.text)}
import com.santaba.agent.groovy.utils.GroovyScriptHelper as GSHimport com.logicmonitor.mod.Snippetsimport groovy.json.JsonSlurper// REVIEW 1: Snippets replace repeated platform boilerplate with versioned helpers.def modLoader = GSH.getInstance(GroovySystem.version) .getScript("Snippets", Snippets.getLoader()) .withBinding(getBinding())def httpMod = modLoader.load("proto.http", "1.0.0")def emit = modLoader.load("lm.emit", "1.3.0")def cacheMod = modLoader.load("lm.cache", "0.3.1")def debugMod = modLoader.load("lm.debug", "2.0.0")def debug = falsedef lmDebug = debugMod.create(hostProps, debug, out)def cacheDebug = [LMDebugPrint: { message -> lmDebug.debug(message) }]def cache = cacheMod.cacheSnippetFactory(cacheDebug, "training-fabric")def http = httpMod.create(hostProps)// REVIEW 2: Resource inputs stay separate from the request and output flow.def hostname = hostProps.get("system.hostname", "").replaceAll('/$', '')def baseUrl = "https://${hostname}/api/v1"def user = hostProps.get("fabric.api.user", "")def pass = hostProps.get("fabric.api.pass", "")if (!hostname || !user || !pass) return 1// REVIEW 3: The main flow keeps fresh API data aligned with the existing datapoints.def token = getToken(cache, http, baseUrl, user, pass, lmDebug)if (!token) return 1def response = http.withHeaders( [Authorization: "Bearer ${token}", Accept: "application/json"]).GET("${baseUrl}/controller", 10000, 20000)if (response.responseCode == 401) { lmDebug.warn("Cached token was rejected; refreshing token") token = getToken(cache, http, baseUrl, user, pass, lmDebug, true) if (!token) return 1 response = http.withHeaders( [Authorization: "Bearer ${token}", Accept: "application/json"] ).GET("${baseUrl}/controller", 10000, 20000)}if (response.responseCode >= 400) { lmDebug.error("HTTP ${response.responseCode} from controller endpoint") return 1}def controller = new JsonSlurper().parseText(response.inputStream.text)// These names must remain aligned with the module datapoints and graph lines.emit.dp("controller_health", controller.health)emit.dp("node_count", controller.node_count)emit.dp("api_latency_ms", controller.api_latency_ms)return 0// REVIEW 4: Cache the short-lived token, never the changing controller data.def getToken(cache, http, String baseUrl, String user, String pass, lmDebug, Boolean forceRefresh = false) { if (forceRefresh) cache.cacheRemove("accessToken") def token = cache.cacheGet("accessToken") if (token) return token // The password is used only to request a new token and is never logged. def basic = "${user}:${pass}".bytes.encodeBase64().toString() def response = http.withHeaders( [Authorization: "Basic ${basic}", Accept: "application/json"] ).GET("${baseUrl}/auth/token", 10000, 20000) if (response.responseCode >= 400) { lmDebug.error("Authentication failed with HTTP ${response.responseCode}") return null } def tokenResponse = new JsonSlurper().parseText(response.inputStream.text) token = tokenResponse.access_token def expiresIn = (tokenResponse.expires_in ?: 300) as Integer // Leave a safety margin so the token does not expire during collection. def cacheTtl = Math.max(1, expiresIn - 60) as Integer cache.cacheSet("accessToken", token, cacheTtl) lmDebug.debug("Fetched and cached a new training API token for ${cacheTtl}s") return token}
Node Active Discovery
import groovy.json.JsonSlurper// Read connection details from the resource instead of hard-coding them.def apiHostname = hostProps.get("system.hostname", "").replaceAll('/$', '')def apiBaseUrl = "https://${apiHostname}/api/v1"def apiUser = hostProps.get("fabric.api.user", "")def apiPassword = hostProps.get("fabric.api.pass", "")if (!apiHostname || !apiUser || !apiPassword) return 1// Authenticate once, then ask the API which node instances exist.def token = requestJson( "${apiBaseUrl}/auth/token", [Authorization: "Basic ${basicAuthHeader(apiUser, apiPassword)}"])def nodes = requestJson( "${apiBaseUrl}/nodes", [Authorization: "Bearer ${token.access_token}"])nodes.each { node -> // The ID is identity; the name is presentation; role and site are context. // Status is emitted as an instance property so the module can filter offline nodes. println "${node.id}##${node.name}##${node.role} at ${node.site}####auto.role=${node.role}&auto.site=${node.site}&auto.status=${node.status}"}return 0// Helper methods keep the discovery flow easy to follow.def basicAuthHeader(String user, String password) { return "${user}:${password}".bytes.encodeBase64().toString()}def requestJson(String endpoint, Map headers) { def connection = new URL(endpoint).openConnection() headers.each { key, value -> connection.setRequestProperty(key, value.toString()) } connection.setRequestProperty("Accept", "application/json") connection.connectTimeout = 10000 connection.readTimeout = 20000 if (connection.responseCode >= 400) throw new IllegalStateException("HTTP ${connection.responseCode} from ${endpoint}") return new JsonSlurper().parseText(connection.inputStream.text)}
import com.santaba.agent.groovy.utils.GroovyScriptHelper as GSHimport com.logicmonitor.mod.Snippetsimport groovy.json.JsonSlurper// REVIEW 1: Snippets replace repeated platform boilerplate with versioned helpers.def modLoader = GSH.getInstance(GroovySystem.version) .getScript("Snippets", Snippets.getLoader()) .withBinding(getBinding())def httpMod = modLoader.load("proto.http", "1.0.0")def emit = modLoader.load("lm.emit", "1.3.0")def cacheMod = modLoader.load("lm.cache", "0.3.1")def debugMod = modLoader.load("lm.debug", "2.0.0")def debug = falsedef lmDebug = debugMod.create(hostProps, debug, out)def cacheDebug = [LMDebugPrint: { message -> lmDebug.debug(message) }]def cache = cacheMod.cacheSnippetFactory(cacheDebug, "training-fabric")def http = httpMod.create(hostProps)// REVIEW 2: Resource inputs stay separate from the request and discovery output.def hostname = hostProps.get("system.hostname", "").replaceAll('/$', '')def baseUrl = "https://${hostname}/api/v1"def user = hostProps.get("fabric.api.user", "")def pass = hostProps.get("fabric.api.pass", "")if (!hostname || !user || !pass) return 1// REVIEW 3: Discovery preserves identity while adding filterable instance properties.def token = getToken(cache, http, baseUrl, user, pass, lmDebug)if (!token) return 1def response = http.withHeaders( [Authorization: "Bearer ${token}", Accept: "application/json"]).GET("${baseUrl}/nodes", 10000, 20000)if (response.responseCode == 401) { lmDebug.warn("Cached token was rejected; refreshing token") token = getToken(cache, http, baseUrl, user, pass, lmDebug, true) if (!token) return 1 response = http.withHeaders( [Authorization: "Bearer ${token}", Accept: "application/json"] ).GET("${baseUrl}/nodes", 10000, 20000)}if (response.responseCode >= 400) return 1def nodes = new JsonSlurper().parseText(response.inputStream.text)nodes.each { node -> // REVIEW 5: The ID is identity; the name is presentation; role, site, and status are context. emit.instance(node.id, node.name, "${node.role} at ${node.site}", [ "auto.role": node.role, "auto.site": node.site, "auto.status": node.status ])}return 0// REVIEW 4: Cache the short-lived token, never the changing node list.def getToken(cache, http, String baseUrl, String user, String pass, lmDebug, Boolean forceRefresh = false) { if (forceRefresh) cache.cacheRemove("accessToken") def token = cache.cacheGet("accessToken") if (token) return token // The password is used only to request a new token and is never logged. def basic = "${user}:${pass}".bytes.encodeBase64().toString() def response = http.withHeaders( [Authorization: "Basic ${basic}", Accept: "application/json"] ).GET("${baseUrl}/auth/token", 10000, 20000) if (response.responseCode >= 400) return null def tokenResponse = new JsonSlurper().parseText(response.inputStream.text) token = tokenResponse.access_token def expiresIn = (tokenResponse.expires_in ?: 300) as Integer // Leave a safety margin so the token does not expire during discovery. def cacheTtl = Math.max(1, expiresIn - 60) as Integer cache.cacheSet("accessToken", token, cacheTtl) lmDebug.debug("Fetched and cached a new training API token for ${cacheTtl}s") return token}
Node collection
import groovy.json.JsonSlurper// Read connection details from the resource instead of hard-coding them.def apiHostname = hostProps.get("system.hostname", "").replaceAll('/$', '')def apiBaseUrl = "https://${apiHostname}/api/v1"def apiUser = hostProps.get("fabric.api.user", "")def apiPassword = hostProps.get("fabric.api.pass", "")if (!apiHostname || !apiUser || !apiPassword) return 1// Authenticate once before visiting each discovered instance.def token = requestJson( "${apiBaseUrl}/auth/token", [Authorization: "Basic ${basicAuthHeader(apiUser, apiPassword)}"])def bearer = [Authorization: "Bearer ${token.access_token}"]def collectionFailed = falsedatasourceinstanceProps.each { instance, props -> if (collectionFailed) return // The wildvalue links this collection request to one discovered instance. def wild = props.wildvalue try { def node = requestJson("${apiBaseUrl}/nodes/${wild}", bearer) println "${wild}.health=${node.health}" println "${wild}.cpu_percent=${node.cpu_percent}" println "${wild}.memory_percent=${node.memory_percent}" println "${wild}.interface_count=${node.interface_count}" println "${wild}.error_rate_percent=${node.error_rate_percent}" } catch (Exception ignored) { // Do not emit partial metrics when one instance request fails. collectionFailed = true }}return collectionFailed ? 1 : 0// Helper methods keep the multi-instance collection flow easy to follow.def basicAuthHeader(String user, String password) { return "${user}:${password}".bytes.encodeBase64().toString()}def requestJson(String endpoint, Map headers) { def connection = new URL(endpoint).openConnection() headers.each { key, value -> connection.setRequestProperty(key, value.toString()) } connection.setRequestProperty("Accept", "application/json") connection.connectTimeout = 10000 connection.readTimeout = 20000 if (connection.responseCode >= 400) throw new IllegalStateException("HTTP ${connection.responseCode} from ${endpoint}") return new JsonSlurper().parseText(connection.inputStream.text)}
import com.santaba.agent.groovy.utils.GroovyScriptHelper as GSHimport com.logicmonitor.mod.Snippetsimport groovy.json.JsonSlurper// REVIEW 1: Snippets replace repeated platform boilerplate with versioned helpers.def modLoader = GSH.getInstance(GroovySystem.version) .getScript("Snippets", Snippets.getLoader()) .withBinding(getBinding())def httpMod = modLoader.load("proto.http", "1.0.0")def emit = modLoader.load("lm.emit", "1.3.0")def cacheMod = modLoader.load("lm.cache", "0.3.1")def debugMod = modLoader.load("lm.debug", "2.0.0")def debug = falsedef lmDebug = debugMod.create(hostProps, debug, out)def cacheDebug = [LMDebugPrint: { message -> lmDebug.debug(message) }]def cache = cacheMod.cacheSnippetFactory(cacheDebug, "training-fabric")def http = httpMod.create(hostProps)// REVIEW 2: Resource inputs stay separate from the request and collection flow.def hostname = hostProps.get("system.hostname", "").replaceAll('/$', '')def baseUrl = "https://${hostname}/api/v1"def user = hostProps.get("fabric.api.user", "")def pass = hostProps.get("fabric.api.pass", "")if (!hostname || !user || !pass) return 1// REVIEW 3: One token supports the collection loop; the wildvalue preserves instance identity.def token = getToken(cache, http, baseUrl, user, pass, lmDebug)if (!token) return 1def headers = [Authorization: "Bearer ${token}", Accept: "application/json"]def collectionFailed = falsedatasourceinstanceProps.each { instance, props -> if (collectionFailed) return // The wildvalue links this request and every metric to one instance. def wild = props.wildvalue def response = http.withHeaders(headers).GET("${baseUrl}/nodes/${wild}", 10000, 20000) if (response.responseCode == 401) { lmDebug.warn("Cached token was rejected; refreshing token") token = getToken(cache, http, baseUrl, user, pass, lmDebug, true) if (!token) { collectionFailed = true return } headers = [Authorization: "Bearer ${token}", Accept: "application/json"] response = http.withHeaders(headers).GET("${baseUrl}/nodes/${wild}", 10000, 20000) } if (response.responseCode >= 400) { lmDebug.error("HTTP ${response.responseCode} for node ${wild}") collectionFailed = true return } def node = new JsonSlurper().parseText(response.inputStream.text) // REVIEW 5: These names must remain aligned with datapoints and final graph lines. emit.dp(wild, "health", node.health) emit.dp(wild, "cpu_percent", node.cpu_percent) emit.dp(wild, "memory_percent", node.memory_percent) emit.dp(wild, "interface_count", node.interface_count) emit.dp(wild, "error_rate_percent", node.error_rate_percent)}return collectionFailed ? 1 : 0// REVIEW 4: Cache the short-lived token, never the changing node metrics.def getToken(cache, http, String baseUrl, String user, String pass, lmDebug, Boolean forceRefresh = false) { if (forceRefresh) cache.cacheRemove("accessToken") def token = cache.cacheGet("accessToken") if (token) return token // The password is used only to request a new token and is never logged. def basic = "${user}:${pass}".bytes.encodeBase64().toString() def response = http.withHeaders( [Authorization: "Basic ${basic}", Accept: "application/json"] ).GET("${baseUrl}/auth/token", 10000, 20000) if (response.responseCode >= 400) { lmDebug.error("Authentication failed with HTTP ${response.responseCode}") return null } def tokenResponse = new JsonSlurper().parseText(response.inputStream.text) token = tokenResponse.access_token def expiresIn = (tokenResponse.expires_in ?: 300) as Integer // Leave a safety margin so the token does not expire during collection. def cacheTtl = Math.max(1, expiresIn - 60) as Integer cache.cacheSet("accessToken", token, cacheTtl) lmDebug.debug("Fetched and cached a new training API token for ${cacheTtl}s") return token}
What to do
- Start with the
REVIEW 1comments and identify the shared helpers being loaded. - Follow
REVIEW 2through the resource properties and API request setup. - At
REVIEW 3andREVIEW 4, discuss what is cached, how long it is kept, and what happens when it expires. - At
REVIEW 5in the node scripts, confirm that the same node IDs, properties, and metric names are still reported. - Import the refactored JSON and run the modules twice with debug enabled.
Check your result
- The shared helpers load successfully.
- The second run reuses the saved token.
- A rejected token is removed and requested again.
- The PropertySource still saves the same resource properties.
- Discovery still creates the same online node instances.
- The offline node remains excluded.
- Controller and node data is still requested fresh.
- The reported names and values remain unchanged.
Discuss
Which parts of the script are shared support, and which parts are specific to this module?
HTTP setup, output formatting, debug logging, and token caching are reusable support. The endpoint paths, response fields, targeting, and metrics reported are specific to the module.
Why is a short-lived token a good thing to save, while changing metric data is not?
A token can be reused until it approaches expiration, which avoids an extra authentication request on every poll. Controller and node metrics change over time, so they should be requested fresh for each collection.
Why should a cleanup change leave the reported names and values alone?
Datapoint names and instance IDs connect script output to stored metrics, alerts, and graphs. Changing them during a refactor can break those connections even if the script still runs.
Key idea
Use platform primitives to make scripts safer and easier to operate, while treating emitted monitoring data as a compatibility contract.
If you get stuck
Compare the matching student and reference scripts, then use the completed JSON files in Session 05 to restore a working module before continuing.