Skip to main content

Templating & Best Practices

Most action parameters in Nudgebee are not just static strings — they accept templates. A template is a small expression like {{ alert.labels.namespace }} that pulls a value from the alert (or from an earlier action's output) at the moment the alert fires.

This is what lets one alert configuration handle hundreds of real alerts without manual editing. You write the action once, with placeholders, and Nudgebee fills them in for each event.

Nudgebee uses gonja — a Go implementation of Jinja2 — so the syntax will be familiar to anyone who has used Ansible or Jinja-based tools.


The basics

You can use templates in any action parameter — text, textarea, lists, or objects. Nudgebee serialises the whole parameter blob, runs gonja over it, and parses the result back, so a template inside any field works the same way.

SyntaxWhat it does
{{ value }}Substitutes a value from the context.
{{ value | filter }}Pipes the value through a filter (e.g. | upper, | length).
{% if cond %} ... {% endif %}Conditionals (rarely needed inside parameters; usually you use the if: field on the action instead).
{% for x in list %} ... {% endfor %}Loops (rarely needed; usually you use the for_each: field on the action).

Anything not wrapped in {{ }} or {% %} is treated as literal text. So host {{ alert.labels.host }} becomes host db-prod-01.


What's in scope

Inside a template, these top-level variables are available:

VariableWhat's in it
alertAn object with labels, annotations, and name. Note: this is all that's exposed about the event itself — fields like subject_name, subject_namespace, aggregation_key, started_at are not in scope. Use the equivalent label (Prometheus convention puts them in labels — labels.namespace, labels.pod, etc.).
labelsAlias for alert.labels.
annotationsAlias for alert.annotations.
outputsResults of previous actions in the same playbook. Keyed by <action_name>_<index> (e.g. logs_enricher_0); if you set a title on the action, the title is also added as an alias key. See What's in outputs[key] below.
extracted_labelsValues pulled out of previous actions via their regex_extractors / label_extractors. Same key convention as outputs. See What's in extracted_labels[key] below.
itemAvailable only inside an action that has for_each: set — the current iteration value.

Maps and arrays — how to access them

Three of the variables are maps (key → value) and one is a nested map containing arrays. This determines whether you use dot-notation, bracket-notation, or indexing.

VariableUnderlying typeAccess patterns
alert.labels, labelsmap[string]stringlabels.namespace, labels.pod, labels['kubernetes.io/hostname'] (bracket form is required for keys with dots, dashes, or spaces).
alert.annotations, annotationsmap[string]stringSame as labels. annotations.summary, annotations['runbook_url'].
outputsmap[string]any keyed by <action_name>_<index> (and by title if set)outputs.logs_enricher_0, outputs['logs_enricher_0'], outputs['Pod logs'] (when titled).
extracted_labelsmap[string]map[string]any (a map of maps)extracted_labels['logs_0'].service, extracted_labels['logs_0']._series — bracket form on the outer key is recommended because the keys often contain underscores or look numeric.
extracted_labels[key]._series[]map[string]any (array of objects)Index with [0] (extracted_labels.logs_0._series[0].service), iterate with for_each: "{{ extracted_labels.logs_0._series }}", or filter with | pluck('service').
outputs[key].rows (Table response)[][]any (array of arrays — one per row)outputs.foo.rows[0][2] for the cell at row 0, column 2. Iterate with for_each: "{{ outputs.foo.rows }}".
outputs[key].headers (Table response)[]stringoutputs.foo.headers[0], or | join(', ').
item (inside for_each)Whatever element type the array holdsIf for_each is _series, then item is {label: value} and you reach into it with item.service or item['service'].

Dot vs bracket notation:

{{ labels.namespace }}              # works for simple keys
{{ labels['namespace'] }} # equivalent
{{ labels['kubernetes.io/host'] }} # required when key contains . / - or spaces
{{ outputs['Pod details'].data }} # required when key has spaces (titled actions)

Iterating in templates:

You almost never need an explicit {% for %} loop — use the action's for_each: field instead, which gives each iteration its own template render with item bound. But when you do need to inline a loop, gonja iterates maps directly (no .items() like in Python Jinja):

{% for k, v in labels %}
- {{ k }}: {{ v }}
{% endfor %}

What labels are actually on your alert?

Labels are integration-specific. There's no fixed schema — Prometheus alerts carry whatever the rule author put in the labels: block, Datadog pushes its tags, CloudWatch pushes its dimensions, and so on. To find out which labels a specific alert source is producing, fire the alert once and open the resulting event in Troubleshooting — the evidence panel shows the full label set. Common Prometheus K8s labels: alertname, namespace, pod, container, node, instance, severity, job. Common CloudWatch labels: alarmname, region, dbinstanceidentifier (or instanceid, loadbalancername, etc., depending on the resource type).

What's in alert

Just three fields, taken directly from the event:

FieldTypeSource
alert.namestringThe alert name resolved at ingest — nb_alert_name label, falling back to alertname label, falling back to the event's aggregation_key.
alert.labelsmap[string]stringAll labels on the event. For Prometheus alerts, this is the alert's labels: block. For external alerts (Datadog, CloudWatch, …), the integration normalises its own metadata into labels.
alert.annotationsmap[string]stringAll annotations on the event. For Prometheus alerts this includes description, summary, runbook, etc.

There is no alert.subject_name, alert.namespace, alert.severity, alert.aggregation_key, etc. Reach for those via labels — labels.namespace, labels.severity, and so on.

What's in outputs[key]

outputs[key] is whatever object the action returned. For Nudgebee server-side actions (most actions whose source is nudgebeeproxy_db_query, cloud_cli, notification_channel_*, the proxy_* family, etc.), the shape depends on the response type the action chose:

Response typeShape (visible fields)
Table (e.g. proxy_db_query, many enrichers)outputs.foo.rows (array of row arrays), outputs.foo.headers (array of column names), outputs.foo.additional_info, outputs.foo.insight, outputs.foo.labels.
JSON (e.g. proxy_http_request, cloud_cli, ssh)outputs.foo.data (string — JSON-serialised payload, not a parsed object), outputs.foo.format, outputs.foo.additional_info, outputs.foo.insight, outputs.foo.metadata, outputs.foo.labels.
Markdownoutputs.foo.text (string), outputs.foo.additional_info, outputs.foo.insight.
Fileoutputs.foo.type, outputs.foo.filename, outputs.foo.data (string), outputs.foo.additional_info, outputs.foo.insight, outputs.foo.labels.

For agent-side actions (source prometheuspod_enricher, logs_enricher, node_disk_analyzer, …), the response is whatever the in-cluster agent returns over relay. The shape can be richer (e.g. pod_enricher returns a structured data object you can navigate with outputs.pod_enricher_0.data.containers[0].restarts) but is action-specific. Two practical implications:

  • For JSON-format server-side actions, outputs.foo.data is a string of JSON. Either pipe it through | markdown (the Nudgebee filter, which JSON-escapes safely) or accept that you'll be doing string-level reasoning.
  • The markdown filter is the safest way to embed a previous action's result in another action's text-shaped parameter (Slack messages, ticket bodies). It handles all four response types and JSON-escapes the result.

What's in extracted_labels[key]

extracted_labels[key] is a map[string]any populated from the action's label-extraction:

KeyWhen it's thereWhat's in it
Custom keys (e.g. service, task_id)When the action's regex_extractors or label_extractors produce valuesThe extracted value(s). Single values for non-iterating actions; usually arrays after extraction.
_seriesSet by some actions (notably logs) when the extractor produced multiple rowsArray of {label1: value, label2: value, …} objects, one per matched row. Use this with for_each.
_all_extractedSet on the base key after a for_each action completesAggregated map: {labelKey: [values across all iterations]}. Useful when one for_each loop produces values you want to fan out again in a later step.

Example shapes:

extracted_labels['logs_0'] = {
"_series": [
{"service": "billing"},
{"service": "checkout"},
{"service": "auth"}
],
"service": ["billing", "checkout", "auth"]
}

# After a for_each on logs_0._series produced extra extractions per iteration:
extracted_labels['signoz_logs_enricher_6'] = {
"_all_extracted": {
"task_id": ["t-101", "t-102", "t-103"],
"user_id": ["u-77", "u-77", "u-92"]
}
}

A first example

Suppose every alert from your Prometheus rules carries a namespace label and a pod label.

You attach a kubectl_command_executor action and want it to run for the right pod:

kubectl describe pod {{ alert.labels.pod }} -n {{ alert.labels.namespace }}

When KubePodCrashLooping fires for checkout-api-7d in production, this becomes:

kubectl describe pod checkout-api-7d -n production

No manual editing. Same configuration, different alerts.


Filters you'll actually use

Filters transform a value. Pipe them with |.

Standard Jinja filters

FilterExampleResult
default{{ labels.region | default('us-east-1') }}Falls back if the label is missing.
lower / upper{{ labels.severity | upper }}Case-fold.
length{{ outputs.foo.rows | length }}Number of items in an array (or characters in a string).
first / last{{ list | last }}First / last element.
join{{ list | join(', ') }}Concatenate list items.
tojson{{ obj | tojson }}Serialise to JSON (useful when an action expects a JSON-shaped param).
slice{{ list | slice('1:') }}Drop the first item.
selectattr{{ items | selectattr('status', 'eq', 'failed') }}Filter list by attribute.
map(attribute=…){{ items | map(attribute='name') }}Pluck a field (also see pluck below).

Nudgebee-specific filters

In addition to the standard Jinja filters above, Nudgebee registers a handful of custom filters that come up often in alert investigation. They're available in any action parameter.

FilterSignatureWhat it does
split(sep='-')string | split(sep='-')[]stringSplits a string by the separator. The sep kwarg defaults to '-'.
pluck(field)[]object | pluck('field')[]anyExtracts a field from each object in an array. Drops items missing the field.
top(n)[]any | top(n)[]anyFirst n items of an array.
gt(threshold) / gte(threshold) / lt(threshold) / lte(threshold)number | gt(80)"true" or "false"Numeric comparison that returns the lowercase string "true" or "false" — designed for the action's if: field.
markdown<action response> | markdownstringConverts a previous action's response into a Markdown string. Tables become pipe-delimited Markdown tables, JSON responses are inlined as JSON, Markdown responses pass through, File responses inline their data. The result is JSON-escaped so it's safe to embed in another action's text parameter.

split — examples

{{ "production-air-worker" | split(sep='-') | last }}        → worker
{{ "production-air-worker" | split(sep='-') | first }} → production
{{ "production-air-worker" | split(sep='-') | slice('1:') | join('-') }} → air-worker
{{ "key:value:data" | split(sep=':') }} → ['key', 'value', 'data']

pluck — extract a field across many rows

If extracted_labels.logs_0._series is [{service: "billing"}, {service: "checkout"}, {service: "auth"}]:

{{ extracted_labels.logs_0._series | pluck('service') }}
→ ["billing", "checkout", "auth"]

(Often the same data is available pre-extracted at extracted_labels.logs_0.service. Use pluck when you need to lift a field that wasn't promoted to a top-level extracted label.)

top — limit an array

{{ extracted_labels.logs_0._series | top(5) }}
{{ outputs.proxy_db_query_0.rows | top(10) }}

gt / gte / lt / lte — numeric thresholds for if:

if: "{{ outputs.api_traces_enricher_0.metadata.error_rate | gt(0.05) }}"   # only run when error rate > 5%
if: "{{ outputs['Pod details'].data.containers[0].restarts | gte(5) }}"

These are designed for the if: field. The reason a custom filter exists at all: gonja renders Go bools as "True" / "False" (capitalized). The if: check is case-insensitive so "True" does work — but gt and friends give you a clean lowercase "true" / "false" that's consistent with the rest of the YAML.

markdown — embed a prior action's output as text

The most useful filter for chaining. When you want to forward a previous action's evidence card into a Slack message, ticket body, or LLM prompt, | markdown does the right thing for any of the four response types:

# Send a Slack message that contains the table from a previous proxy_db_query action
notification_channel_message:
platform: slack
channel_id: "{{ labels.slack_channel | default('C0123456') }}"
incident_id: "{{ alert.labels.incident_id }}"
text: |
DB high CPU detected.

Running queries at the time of the alert:
{{ outputs['Running queries'] | markdown }}

The output is a JSON-escaped string, so it's safe to drop into any text-shaped parameter without manually quoting newlines or special characters.


Common patterns

These are the templates we see in real customer playbooks. Copy and adapt.

Pull a label off the alert

{{ alert.labels.namespace }}
{{ alert.labels.pod }}
{{ labels.dbinstanceidentifier }}

alert.labels and labels are aliases — use whichever reads better.

Provide a fallback when a label is missing

{{ labels.region | default('us-east-1') }}

Useful for alerts coming from external sources where labels aren't always set.

Reference an earlier action's output

outputs is keyed by <action_name>_<index>, where the index is the action's position in the playbook (zero-based). So if pod_enricher is the first action, its output is at outputs.pod_enricher_0:

{{ outputs.pod_enricher_0.data.containers[0].restarts }}

If you set a title on the action (recommended), the same output is also available under that title — outputs['Pod details'].data.containers[0].restarts. Use whichever reads better.

If you find yourself chaining outputs across many actions, that's usually a signal the work belongs in a workflow rather than an alert playbook — workflows have a visual outputs picker and proper data-flow plumbing.

Loop over values extracted from logs

A common pattern: a logs action with a regex extractor pulls out distinct service names from error logs, and the next action runs once per service.

Action 1 — logs — extracts service from each log line:

regex_extractors:
- pattern: "service=(\\S+)"
label_name: service

Action 2 — kubectl_command_executor — runs once per service:

for_each: "{{ extracted_labels['logs_0']['_series'] }}"
for_each_limit: 5
command: "kubectl describe deploy {{ item.service }} -n production"

(The key logs_0 follows from action 1's name being logs and its index being 0. If you titled the first action "Error logs", you could write extracted_labels['Error logs']['_series'] instead.)

Inside the loop, item is the current row from the extractor.

Skip an expensive action unless something is true

Title the first action "Pod details" and reference it from a later action's if: field:

if: "{{ outputs['Pod details'].data.containers[0].restarts | gt(5) }}"

This pod_profiler action only runs when the pod has restarted more than 5 times.

For more complex conditions, use a full Jinja expression. The result must render to the literal string "true" (case-insensitive) for the action to run — anything else will skip it:

if: "{{ outputs['Pod details'].data.containers | selectattr('status.container_statuses') | map(attribute='status.container_statuses') | map('selectattr', 'state.waiting.reason', 'in', ['CrashLoopBackOff', 'ImagePullBackOff']) | list | length > 0 }}"

Pass a JSON object as a parameter

When an action expects an object (like dimensions on cloud_metrics), you can build it inline:

dimensions:
- Name: DBInstanceIdentifier
Value: "{{ alert.labels.dbinstanceidentifier }}"

Or, when the value is already a JSON-shaped string, use | tojson to preserve types:

labels: "{{ outputs.extractor.data | tojson }}"

Nudgebee post-processes the rendered template and parses any string that looks like JSON back into the right type, so you usually don't need to think about this.


Best practices

A short list of things that pay off in production.

1. Lean on labels, not hardcoded values

If two alerts differ only by namespace, write one action that uses {{ alert.labels.namespace }} and let it cover both. Maintaining one configuration is cheaper than maintaining two slightly different ones.

2. Always provide defaults for optional labels

Alerts forwarded from external sources don't always carry the labels you expect. {{ labels.region | default('us-east-1') }} is much better than a template that explodes when the label is missing.

3. Name your actions clearly

The default output key is <action_name>_<index> — fine for a one-action playbook, awkward when you have three prometheus_enricher actions in a row (prometheus_enricher_0, prometheus_enricher_1, …). Set a short, stable title on the action; the title becomes the evidence card heading and an alias key in outputs / extracted_labels, so {{ outputs['Pod details'] }} is a lot easier to read than {{ outputs.pod_enricher_0 }}.

4. Use if: on the action, not {% if %} inside parameters

If you want to skip an action entirely under some condition, set the action's if: field. Don't try to no-op the parameters with {% if %}{% endif %} — that just produces empty params and a confusing run.

# Good
if: "{{ outputs.cpu.value | gt(80) }}"
command: "kubectl top pods -n {{ alert.labels.namespace }}"

# Avoid
command: "{% if outputs.cpu.value > 80 %}kubectl top pods -n {{ alert.labels.namespace }}{% endif %}"

5. Cap your loops

for_each: makes it easy to fan out, but a runaway loop attaches a flood of evidence cards. Set for_each_limit to a sensible number (default is 10) and use top(n) if your input list might be huge.

6. Keep secrets out of templates

Don't put credentials, tokens, or DB passwords directly into action parameters. Use the integration / secret reference instead — proxy datasources, Kubernetes secrets, integration credentials. Templates are stored in plain text on the alert.

7. Verify by triggering the alert once

There is no in-editor template preview. The fastest way to confirm a template works is to let the alert fire once (or trigger it from the source — Prometheus, Datadog, etc.) and inspect the resulting evidence cards on the event in Troubleshooting. A typo in {{ alert.labels.podd }} will surface there as a missing value or a runtime error on the action.


Troubleshooting

SymptomLikely cause
unable to render template at runtimeBad syntax in one of the action's parameters. Open the alert, find the action with the error message in its evidence card, and fix the offending template.
Value looks like <no value> or empty in the evidence cardThe label or output you referenced doesn't exist on this event. Add a | default(...).
Action expected an object / array but got a stringA template returned a JSON-shaped string. Pipe it through | tojson (or rely on auto-parse — Nudgebee converts string-encoded JSON back to the typed value before the action runs).
for_each ran zero timesThe expression didn't resolve to an array. Check the rendered value — extracted_labels is keyed by <action_name>_<index> (e.g. logs_0) and most extractors put rows under _series.
if: is being skipped when you expected it to runThe rendered template must equal the string "true" (case-insensitive — "True", "TRUE" all work) or boolean true. Anything else skips the action: "false", "False", the empty string, "True " with a trailing space, "yes", "1", etc. Use the gt / lt / gte / lte filters to get a clean lowercase "true" / "false" you don't have to second-guess.

See Also