26 Mar 2026
02:44 PM
- last edited on
27 Mar 2026
09:50 AM
by
MaciejNeumann
Title: Propagating host tags to extension entities via Workflow — looking for feedback/suggestions
Hey all — we've put together a workflow to propagate patch-group tags from HOST entities down to their extension entities (WMI, SQL, IIS, HyperV, etc.) and wanted to share the approach and get some feedback before we scale it out.
The goal is to make sure extension entities are included in our maintenance windows during patching — without the tag on the extension entity, they keep alerting even when the host they run on is in a maintenance window. Propagating the tag automatically keeps everything in sync without manual tagging.
How it works
A JS action uses queryExecutionClient to query dt.system.data_objects to dynamically discover all extension entity types, then loops over each type running this DQL:
fetch `dt.entity.{entityType}`
| fieldsAdd runs_on, tags
| filter isNotNull(runs_on[`dt.entity.host`])
| fieldsAdd hostId = runs_on[`dt.entity.host`]
| lookup [fetch dt.entity.host | fieldsAdd tags], sourceField: hostId, lookupField: id
| expand tag = lookup.tags
| filter startsWith(tag, "patch-group:")
| fieldsAdd patchGroupValue = replaceString(tag, "patch-group:", "")
| fields entity.name, id, entity.type, hostId, tags, patchGroupValue
Entity types that don't have a runs_on field throw a FIELD_DOES_NOT_EXIST error and get skipped. The JS action validates existing tags and only passes entities that actually need updating to a follow-up HTTP action that calls POST /api/v2/tags.
A few things we're curious about
dt.system.data_objects a stable/supported table to use as an entity type catalog, or should we be pulling this differently?runs_on[dt.entity.host] relationship rather than querying each type and catching the error?Happy to share more detail if useful — just looking for any gotchas or better approaches before we push this to production. Thanks!
Full JS action code
import { queryExecutionClient } from '@dynatrace-sdk/client-query';
import { execution } from '@dynatrace-sdk/automation-utils';
export default async function ({ execution_id }) {
const ex = await execution(execution_id);
// Configuration — update this value to retarget the workflow
const SOURCE_TAG_PREFIX = 'patch-group'; // Tag prefix to look for on the host and write to extension entities
// Step 1: Get all extension entity types from the Dynatrace data object catalog
const typeResponse = await queryExecutionClient.queryExecute({
body: {
query: `fetch dt.system.data_objects
| filter contains(name, "dt.entity.") and contains(name, ":")
| fieldsAdd entityType = replaceString(name, "dt.entity.", "")
| fields name, entityType`,
requestTimeoutMilliseconds: 60000,
fetchTimeoutSeconds: 60
}
});
const types = typeResponse.result.records ?? [];
const output = [];
const skipped = [];
let totalFound = 0;
let totalAlreadyTagged = 0;
// Step 2: For each entity type, query extension entities that run on a host
// and have a patch-group tag on the host. Types without a runs_on relationship
// will throw a DQL error and be skipped gracefully.
for (const t of types) {
const entityType = t.entityType;
try {
const entityResponse = await queryExecutionClient.queryExecute({
body: {
query: `fetch \`dt.entity.${entityType}\`
| fieldsAdd runs_on, tags
| filter isNotNull(runs_on[\`dt.entity.host\`])
| fieldsAdd hostId = runs_on[\`dt.entity.host\`]
| lookup [fetch dt.entity.host | fieldsAdd tags], sourceField: hostId, lookupField: id
| expand tag = lookup.tags
| filter startsWith(tag, "${SOURCE_TAG_PREFIX}:")
| fieldsAdd patchGroupValue = replaceString(tag, "${SOURCE_TAG_PREFIX}:", "")
| fields entity.name, id, entity.type, hostId, tags, patchGroupValue`,
requestTimeoutMilliseconds: 60000,
fetchTimeoutSeconds: 60
}
});
const records = entityResponse.result?.records ?? [];
totalFound += records.length;
// Step 3: Validate each entity — only add to output if tag is missing or incorrect
for (const r of records) {
const existingTags = Array.isArray(r.tags) ? r.tags : [];
const expectedTag = `${SOURCE_TAG_PREFIX}:${r.patchGroupValue}`;
const alreadyTagged = existingTags.some(t => t === expectedTag);
if (alreadyTagged) {
totalAlreadyTagged++;
} else {
output.push({
entityId: r.id,
entityName: r['entity.name'],
entityType: r['entity.type'],
tagKey: SOURCE_TAG_PREFIX,
tagValue: r.patchGroupValue,
entitySelector: `entityId("${r.id}")`
});
}
}
} catch (e) {
// Entity type does not have a runs_on field — skip silently
skipped.push(entityType);
}
}
// Step 4: Detect and exclude entities where the host has duplicate patch-group tags
const entityIdCounts = output.reduce((acc, r) => {
acc[r.entityId] = (acc[r.entityId] || 0) + 1;
return acc;
}, {});
const duplicateEntities = [...new Set(
output.filter(r => entityIdCounts[r.entityId] > 1).map(r => r.entityId)
)];
const cleanOutput = output.filter(r => entityIdCounts[r.entityId] === 1);
console.log(`Types processed: ${types.length}, Skipped (no runs_on field): ${skipped.length}`);
console.log(`Entities found: ${totalFound}, Already tagged: ${totalAlreadyTagged}, Needs update: ${cleanOutput.length}`);
if (duplicateEntities.length > 0) {
console.log(`WARNING: ${duplicateEntities.length} entities skipped — host has duplicate patch-group tags`);
duplicateEntities.forEach(id => console.log(` Duplicate: ${id}`));
}
// Log each entity queued for tag write
cleanOutput.forEach(r => console.log(`Queued: ${r.entityName} (${r.entityId}) [${r.entityType}] → ${r.tagKey}:${r.tagValue}`));
return { total: cleanOutput.length, duplicates: duplicateEntities.length, records: cleanOutput };
}
Featured Posts