04 Aug 2026 10:15 PM - edited 05 Aug 2026 01:14 AM
Infrastructure as Code for Dynatrace Workflows — a field guide from the trenches
Dynatrace Automation Workflows are powerful — but clicking through the UI to build and maintain them doesn't scale. Once you have more than a handful of workflows, you want:
This guide walks through the full cycle: using dtctl (the Dynatrace CLI) together with GitHub Copilot to author, deploy, and version-control Dynatrace Workflows as YAML files.
Verify your context is working:
dtctl get workflows | head -5
Copilot generates YAML → dtctl apply → test in DT UI → git commit → PR
Simple. Let's walk through each step.
Before writing from scratch, export a working workflow to understand the schema:
# List all workflows dtctl get workflows # Export a specific workflow to YAML dtctl get workflow <workflow-id> -o yaml
The exported YAML is the ground truth for the API schema. Use it as a starting point and ask Copilot to adapt it to your use case. Any workflows already living in your repo are equally good templates.
When prompting Copilot, be specific about three things:
Here are the three trigger types with correct YAML structures — including some non-obvious gotchas I hit along the way.
trigger:
eventTrigger:
filterQuery: >-
event.kind == "DAVIS_PROBLEM" AND event.status == "ACTIVE"
AND matchesValue(event.status_transition, {"CREATED", "UPDATED", "REOPENED"})
AND matchesPhrase(affected_entity_names, "MY_HOST")
isActive: true
triggerConfiguration:
type: davis-problem
value:
analysisReady: false
categories:
custom: true
customFilter: 'matchesPhrase(affected_entity_names, "MY_HOST")'
entityTags: {}
entityTagsMatch: "all"
onProblemClose: false
problemOpenDuration: null
severityThreshold: null
triggerOn: null
triggerOnUpdateFields: []
uniqueExpression: '{{ event()["event.id"] }}-{{ "open" if event()["event.status"] == "ACTIVE" else "resolved" }}-{{ event()["dt.davis.last_reopen_timestamp"] }}'trigger:
eventTrigger:
filterQuery: >-
event.type == "PGI_CRASHED_INFO"
AND matchesPhrase(event.description, "MyProcess")
AND dt.entity.host == "HOST-XXXXXXXXXXXXXXXX"
isActive: true
triggerConfiguration:
type: davis-event
value:
entityTags: {}
entityTagsMatch: "all"
customFilter: 'event.type == "PGI_CRASHED_INFO" AND matchesPhrase(event.description, "MyProcess")'
uniqueExpression: '{{ event()["event.id"] }}-{{ event()["event.start"] }}'Gotcha: For davis-event, the value object only accepts entityTags, entityTagsMatch, and customFilter. Fields like eventName, problemState, and eventTypes are not valid and will cause a 400 error. Copilot sometimes generates these — always validate with dtctl apply --debug.
trigger:
schedule:
filterParameters: {}
inputs: {}
isActive: true
nextExecution: null
rule: null
timezone: America/Toronto
trigger:
type: cron
cron: "5 20 * * 0" # Sunday 20:05 local timeGotcha: The cron expression goes in the nested trigger.cron field — not in rule. The rule field must be null or a valid UUID. Setting it to a cron string causes a 400 error.
Here's a full, deployable workflow that ties everything together. It listens for a process crash event, suppresses the alert during a known maintenance window, then creates a ServiceNow incident via a shared workflow.
title: HealthCheck - MyService - Process Crash Alert - MYSERVER01
isDeployed: true
description: >
Creates a ServiceNow incident when MyService.exe crashes on MYSERVER01.
Suppresses alerting during the Sunday 20:00-20:05 scheduled maintenance window.
actor: <your-actor-id>
owner: <your-owner-id>
ownerType: USER
isPrivate: false
schemaVersion: 4
trigger:
eventTrigger:
filterQuery: >-
event.type == "PGI_CRASHED_INFO"
AND matchesPhrase(event.description, "MyService")
AND dt.entity.host == "HOST-XXXXXXXXXXXXXXXX"
isActive: true
triggerConfiguration:
type: davis-event
value:
entityTags: {}
entityTagsMatch: "all"
customFilter: 'event.type == "PGI_CRASHED_INFO" AND matchesPhrase(event.description, "MyService") AND dt.entity.host == "HOST-XXXXXXXXXXXXXXXX"'
uniqueExpression: '{{ event()["event.id"] }}-{{ event()["event.start"] }}'
type: STANDARD
hourlyExecutionLimit: 1000
tasks:
check_maintenance_window:
action: dynatrace.automations:run-javascript
description: >
Returns inMaintenanceWindow=true if current time falls inside the Sunday
20:00-20:05 scheduled restart window. Handles DST by checking both UTC offsets.
input:
script: |
export default async function () {
const now = new Date();
const utcDay = now.getUTCDay();
const utcHour = now.getUTCHours();
const utcMin = now.getUTCMinutes();
const inSummerWindow = utcDay === 1 && utcHour === 0 && utcMin < 5;
const inWinterWindow = utcDay === 1 && utcHour === 1 && utcMin < 5;
const inMaintenanceWindow = inSummerWindow || inWinterWindow;
console.log(`UTC day=${utcDay} hour=${utcHour} min=${utcMin} | inMaintenanceWindow=${inMaintenanceWindow}`);
return { inMaintenanceWindow };
}
name: check_maintenance_window
position:
x: 0
"y": 1
predecessors: []
prepare_snow_payload:
action: dynatrace.automations:run-javascript
active: true
conditions:
custom: '{{ result("check_maintenance_window").inMaintenanceWindow == false }}'
else: STOP
states:
check_maintenance_window: OK
description: >
Builds the ServiceNow incident payload. Only runs when the crash occurs
outside the scheduled maintenance window.
input:
script: |
import { execution } from '@dynatrace-sdk/automation-utils';
export default async function ({ execution_id }) {
const ex = await execution(execution_id);
const ev = ex.params.event || {};
const payload = {
serviceNowPayload: {
cmdb_ci_input: "MYSERVER01",
assignment_group: "My Team Name",
u_impacted_system: "My Application",
caller_id: "dynatrace",
category: "Software",
subcategory: "Application",
contact_type: "Alert",
correlation_id: `DT_${ev["event.id"] || ""}`,
short_description: "MyService crashed on MYSERVER01",
description: [
`Dynatrace detected a process crash on MYSERVER01.`,
`Process : MyService.exe`,
`Time : ${ev["event.start"] || new Date().toISOString()}`,
`Event ID : ${ev["event.id"] || ""}`,
`The service may have restarted automatically.`
].join("\n"),
priority: "3",
state: "1",
environment: "Production",
environment_type: "PRD",
options: { search_cmdb_ci_by_fqdn: "N" }
},
original_event: ev
};
return payload;
}
name: prepare_snow_payload
position:
x: 0
"y": 2
predecessors:
- check_maintenance_window
create_snow_incident:
action: dynatrace.automations:run-workflow
active: true
conditions:
states:
prepare_snow_payload: OK
description: Calls the shared ServiceNow Incident Creation Workflow.
input:
workflowId: <your-snow-workflow-id>
workflowInput: '{{ result("prepare_snow_payload") }}'
name: create_snow_incident
position:
x: 0
"y": 3
predecessors:
- prepare_snow_payloadThree tasks, one conditional gate:
Save this as HealthCheck-MyService-ProcessCrash-MYSERVER01.yaml and deploy with dtctl apply -f.
cd ~/projects/dynatrace-workflows # Create a new workflow (auto-generates an ID) dtctl apply -f MyWorkflow.yaml # Stamp the generated ID back into the file for future updates dtctl apply -f MyWorkflow.yaml --write-id # Update an existing workflow (file must already contain the id field) dtctl apply -f MyWorkflow.yaml
The output tells you whether the workflow was created or updated. Use --write-id on first deploy so the file becomes self-contained for all future updates.
When dtctl apply returns a 400, run with --debug to see the full API error body:
dtctl apply -f MyWorkflow.yaml --debug 2>&1 | grep -A 10 '"error"'
The response includes a details object that pinpoints the exact invalid field:
{
"trigger": {
"eventTrigger": {
"triggerConfiguration": [
"davis-event -> value -> eventName: Extra inputs are not permitted"
]
}
}
}This tells you exactly which field to remove or fix. Much faster than guessing.
Workflows with event triggers can't be tested by injecting synthetic events via the Events API — PGI_CRASHED_INFO and similar types are OneAgent-generated. Use the manual run feature in the Dynatrace UI instead:
{
"event.type": "PGI_CRASHED_INFO",
"event.description": "Process MyService.exe has crashed",
"event.start": "2026-01-01T13:00:00.000Z",
"event.id": "test-event-001",
"dt.entity.host": "HOST-XXXXXXXXXXXXXXXX"
}Note: Manual test runs do not create Dynatrace problems or inject real events. They only execute the workflow tasks with the provided mock context.
cd ~/projects/my-dynatrace-repo git add dynatrace-workflows/ git commit -m "Add/update workflow: <description>" # Use a feature branch — main typically requires PR + CI checks git checkout -b feature/my-workflow-change git push -u origin feature/my-workflow-change
Then open a PR. Treat workflow YAML the same as application code — review it, scan it, merge it.
A pattern that works well at scale is a shared integration workflow that handles ServiceNow incident creation, with individual alert workflows delegating to it. This avoids duplicating SNOW logic across dozens of workflows and gives you a single place to update the integration.
const ev = execution.params.event ?? {};
const payload = {
serviceNowPayload: {
cmdb_ci_input: "MY-HOSTNAME",
assignment_group: "My Team Name",
u_impacted_system: "My Application",
caller_id: "dynatrace",
category: "Software",
subcategory: "Application",
contact_type: "Alert",
correlation_id: `DT_${ev["event.id"]}`,
short_description: "Alert: process crash detected on MY-HOSTNAME",
description: `Event ID: ${ev["event.id"]}\nHost: ${ev["dt.entity.host"]}`,
priority: "2",
state: "1",
environment: "Production",
environment_type: "PRD",
options: {
search_cmdb_ci_by_fqdn: "N"
}
},
original_event: ev
};
return payload;create_snow_incident:
action: dynatrace.automations:run-workflow
input:
workflowId: <your-snow-workflow-id>
workflowInput: '{{ result("prepare_snow_payload") }}'Issue Cause Fix
| 400 on davis-event trigger | Invalid fields in value (e.g. eventName, problemState) | Only use entityTags, entityTagsMatch, customFilter |
| 400 on schedule trigger | Cron string in rule field | Put cron in trigger.cron, set rule: null |
| Push rejected to main | Branch protection / CI required | Use feature branch + PR |
| PGI_CRASHED_INFO not triggering | INFO-level event, not a Davis Problem | Use davis-event trigger type, not davis-problem |
| contains() not allowed in filterQuery | filterQuery uses a DQL matcher subset, not full DQL | Use matchesPhrase() instead |
# List all workflows dtctl get workflows # Export a workflow to YAML dtctl get workflow <id> -o yaml > my-workflow.yaml # Deploy / update dtctl apply -f my-workflow.yaml # Deploy and stamp ID into file dtctl apply -f my-workflow.yaml --write-id # Debug a failed deploy dtctl apply -f my-workflow.yaml --debug 2>&1 | grep -A 20 '"error"' # View execution history dtctl get workflow-executions --workflow <id> # Get task result from a specific execution dtctl get wfe-task-result <execution-id> <task-name>
The dtctl + Copilot combination makes managing Dynatrace Workflows as code genuinely practical. Copilot handles the boilerplate YAML and JavaScript task logic; dtctl apply --debug gives you precise, field-level error feedback when the API rejects something; and Git gives you the full audit trail.
The main things to remember:
Happy automating. If you've hit other gotchas or have workflow patterns worth sharing, drop them in the comments.
Featured Posts