Per-backend Terraform provider patterns, resource names, auth variables, and gotchas for grafana / datadog / newrelic / dash0. Use when generating observability-backend Terraform.
Install
npx skillscat add radra23/otel-as-code-plugin/terraform-patterns Install via the SkillsCat registry.
Terraform Patterns for Observability Backends
Module Shape (all backends)
Every generated module has exactly three files:
main.tf— all resources + provider blockvariables.tf— all input variablesoutputs.tf— key output values (dashboard URL, monitor IDs, SLO IDs)
Header comment required in every main.tf:
# Generated by otel-as-code v0.1.0 on YYYY-MM-DD.
# Re-run /otel-backend <vendor> to regenerate.
# Drift detection: v2 roadmap.Run terraform fmt and terraform validate immediately after writing the files.
Emit the exact commands the user should run next (init, plan, apply) as a post-generation note.
Service name → resource identifiers (sanitize, but ONLY in identifier positions)
service.name is frequently an npm-scoped package name like @myorg/web (from package.json#name,
confidence 0.97) — @ and / are invalid in most resource identifiers. terraform validate
does NOT catch this (the invalid string is inside an embedded YAML / a uid the provider accepts
as an opaque string), so it only surfaces at apply against a live account. Derive a sanitized
slug and use it in identifier positions:
locals {
service_slug = trim(replace(lower(var.service_name), "/[^a-z0-9]+/", "_"), "_")
}- Sanitize (use
local.service_slug) wherever the name becomes an identifier: a Grafana
dashboarduid/ rule-groupname, a Prometheus/Dash0alert:name, a Kubernetesmetadata.name, any slug. Pick the separator the target format allows:_for Prometheus alert
names and Grafana uids (both permit_);-for a DNS-1123metadata.name(which forbids_) — the two constraints have no common separator, so choose per field, don't assume one slug
fits all. - Do NOT sanitize the value used in a query/filter (
{service_name="..."},{job="..."},WHERE service.name = '...') — it must equal the emittedservice.name, so it
keeps the rawvar.service_name; a slug there silently matches nothing. - Do NOT sanitize a display title (
name = "High error rate — ${var.service_name}") —
free text, the real name reads better.
Grafana Cloud (grafana/grafana ~> 4.0)
Authentication variables
variable "grafana_url" {
description = "Grafana Cloud URL (e.g. https://yourorg.grafana.net)"
type = string
}
variable "grafana_service_account_token" {
description = "Service account token with Editor role"
type = string
sensitive = true
}Required resources
grafana_folder— create a folder to scope all generated resourcesgrafana_dashboard— usesconfig_jsonwith a JSON-encoded dashboard modelgrafana_rule_group— unified alerting (NOT the deprecatedgrafana_alert_notification)grafana_slo— Grafana Cloud SLOs (requiresgrafana_sloresource, available on Cloud plans)
Key gotchas
grafana_folder.uidis auto-generated on create; reference it viagrafana_folder.<resource_label>.uid(e.g.grafana_folder.otel_folder.uid)- Dashboard
config_jsonmust be valid Grafana JSON; usejsonencode()to construct it safely grafana_rule_grouprequires afolder_uid(from the folder resource) andinterval_seconds- SLO
queryblock uses PromQL expressions; preferrate()overirate()for stability - Pin
~> 4.0(current major); the 2.x → 3.x → 4.x jumps each changed resource schemas — don't copy older-major patterns - Filter by
job, NOTservice_name. On a default OTLP → Prometheus pipeline,service.nameis mapped to thejoblabel (andservice.instance.id→instance); resource attributes are NOT added as metric labels — they live only on the separatetarget_infoseries (otlp.promote_resource_attributesdefaults to[]). So{service_name="..."}returns NO DATA — a silently-empty panel/alert. Use{job="<name>"}; whenservice.namespaceis set,jobbecomes<namespace>/<name>, so filter{job="<namespace>/<name>"}. (Only emit{service_name="..."}if the user's Prometheus receiver setsotlp.promote_resource_attributes: [service.name].)
OTel-specific dashboard panels to generate
- Request rate:
rate(http_server_request_duration_seconds_count{job="..."}[5m]) - Error rate:
rate(http_server_request_duration_seconds_count{job="...",http_response_status_code=~"5.."}[5m]) - P99 latency:
histogram_quantile(0.99, rate(http_server_request_duration_seconds_bucket{job="..."}[5m]))
Business-attribute panels (from confirmed businessAttrs)
Grafana panels query Prometheus, so a business attribute is only visible as a metric (or a
metric label). Sanitize the attribute name to a Prometheus metric name (dots/dashes →
underscores) as <M>, and scope every query to the service by job — exactly as the generic
panels do (service.name → job; <namespace>/<name> when a namespace is set). Emit the caveat
as the panel description so an empty panel is self-explanatory.
kind: counter→sum(rate(<M>_total{job="<name>"}[5m]))(OTLP→Prometheus appends_totalto
counters). Caveat: requires the app to emit a counter named<M>.kind: gauge→avg(<M>{job="<name>"})(oravg_over_time(<M>{job="<name>"}[5m])) — plot the
value directly. Do NOT wrap a gauge inrate()/sum(rate()):rate()is defined only for
counters, and summing a ratio likeconversion_rateis meaningless.kind: dimension→sum by (<label>) (rate(http_server_request_duration_seconds_count{job="<name>"}[5m])).
Caveat: the breakdown needs<label>to be a metric label. If the attribute is recorded as a
per-request metric data-point attribute, it already is one; if it is only a resource
attribute, it is NOT a label on a default OTLP→Prometheus pipeline (see thejob-label gotcha
above) unless the producer promotes it (otlp.promote_resource_attributes).
Datadog (DataDog/datadog ~> 4.0)
Authentication variables
variable "datadog_api_key" {
description = "Datadog API key"
type = string
sensitive = true
}
variable "datadog_app_key" {
description = "Datadog application key"
type = string
sensitive = true
}
variable "datadog_site" {
description = "Datadog site (e.g. datadoghq.com, datadoghq.eu)"
type = string
default = "datadoghq.com"
}Required resources
datadog_dashboard— usewidgetblocks (not JSON string)datadog_monitor— type"metric alert"for threshold-based;"query alert"for formula-baseddatadog_service_level_objective— type"metric"for metric-based SLOs
Key gotchas
- Both
api_keyANDapp_keyare required; setting only one will silently fail datadog_monitor.typemust be one of:"metric alert","service check","event alert","query alert","composite","log alert","rum alert","trace-analytics alert"- SLO
timeframemust be exactly"7d","30d", or"90d"— no other values accepted - Monitor tags must include the service tag:
"service:<service_name>" - Dashboard
layout_typeis either"ordered"or"free"
OTel-specific monitor queries
Datadog APM trace metrics are named trace.<operation_name>.{hits,errors,duration}. For an
OTel HTTP server span, Datadog's operation-name logic v2 (default on OTel Collector>= v0.126.0 / Datadog Agent >= v7.65) assigns the operation name http.server.request —
NOT http.request. Use the trace.http.server.request stem:
- Request rate:
sum(last_5m):sum:trace.http.server.request.hits{service:<name>}.as_rate() - Error rate:
sum(last_5m):sum:trace.http.server.request.errors{service:<name>}.as_rate() / sum:trace.http.server.request.hits{service:<name>}.as_rate() > 0.05 - P99 latency:
avg(last_5m):p99:trace.http.server.request{service:<name>} > 500000000
Two gotchas baked into those queries:
- Percentiles need the bare distribution metric.
trace.<op>.durationis a COUNT (total time) and does NOT supportp50/p95/p99. Query the suffix-less distribution metrictrace.http.server.requestfor percentile aggregations. - Durations are in nanoseconds.
500000000= 500 ms. Do not write> 500or> 0.5. - The operation name is pipeline-dependent. On pre-v2 collectors, or where a
transformprocessor setsoperation.name/ legacyspan_name_as_resource_nameis configured, the stem differs. Emit a comment telling the user to confirm theirs in Datadog under APM > Metrics (Metrics Explorer, searchtrace.) before trusting the monitors — a monitor on a metric that never populates silently never fires.
Business-attribute panels (from confirmed businessAttrs)
kind: counter→sum:<name>{service:<name>}.as_rate(). Caveat: requires the counter reported
to Datadog;.as_rate()is documented for StatsD/DogStatsD rate/count metrics — for an
OTLP-ingested counter, have the user confirm it populates, or drop.as_rate()for a plainsum:<name>{service:<name>}.kind: gauge→avg:<name>{service:<name>}— the value as-is, no.as_rate().kind: dimension→ group the request metric by the tag:sum:trace.http.server.request.hits{service:<name>} by {<tag>}.as_rate(). Caveat: the tag must
be present on the spans/metrics (an OTel span attribute promoted to a Datadog tag).
New Relic (newrelic/newrelic ~> 3.0)
Authentication variables
variable "newrelic_account_id" {
description = "New Relic account ID"
type = number
}
variable "newrelic_api_key" {
description = "New Relic User API key (NRAK-...)"
type = string
sensitive = true
}
variable "newrelic_region" {
description = "New Relic region: US or EU"
type = string
default = "US"
}Required resources
newrelic_one_dashboard— pages with widget blocks; usewidget_lineandwidget_tablenewrelic_nrql_alert_condition— NRQL-based alerts;type = "static"for thresholdnewrelic_service_level— SLOs;eventsblock usesvalid_events/good_events(and optionallybad_events) NRQL query blocks — NOTvalid/good
Key gotchas
account_idhas no provider-level default and must be set per resource — but WHERE differs by
resource. It is a top-level argument onnewrelic_one_dashboard,newrelic_alert_policy, andnewrelic_nrql_alert_condition. Onnewrelic_service_levelthere is no top-levelaccount_id— it goes inside theeventsblock (a top-levelaccount_idthere failsterraform validatewith "An argument named account_id is not expected here"). Verified againstnewrelic/newrelicv3.x viaterraform providers schema -json; re-check if you bump the pin.newrelic_nrql_alert_conditionrequires analert_policy_id; always createnewrelic_alert_policyfirst- Service level
goodquery denominator must return a rate between 0 and 1 — divide by total count - NRQL uses
FROM Spanfor OTel trace data; attribute names follow OTel semconv directly - Dashboard widgets require
account_idinside thenrql_queryblock, not just on the resource - Alert-condition NRQL must NOT contain
SINCE/UNTIL/TIMESERIES/COMPARE WITH— New Relic rejects them innewrelic_nrql_alert_condition(the condition's own aggregation window drives timing). Those clauses are dashboard-only.
OTel-specific NRQL queries
Dashboard widget NRQL (a time window is expected — SINCE / TIMESERIES are fine here):
- Request rate:
SELECT rate(count(*), 1 MINUTE) FROM Span WHERE service.name = '<name>' SINCE 5 MINUTES AGO - Error rate:
SELECT filter(count(*), WHERE otel.status_code = 'ERROR') / count(*) FROM Span WHERE service.name = '<name>' SINCE 5 MINUTES AGO - P99 latency:
SELECT percentile(duration.ms, 99) FROM Span WHERE service.name = '<name>' SINCE 5 MINUTES AGO
Alert-condition NRQL (newrelic_nrql_alert_condition.nrql.query — NO SINCE/TIMESERIES):
- Error rate:
SELECT filter(count(*), WHERE otel.status_code = 'ERROR') / count(*) FROM Span WHERE service.name = '<name>' - P99 latency:
SELECT percentile(duration.ms, 99) FROM Span WHERE service.name = '<name>'
Business-attribute widgets (New Relic can query spans/metrics directly — the strongest of the four
for this). Backtick-quote dotted attribute names:
kind: counter→SELECT sum(`<name>`) FROM Metric WHERE service.name = '<name>' TIMESERIES
(a reported OTel counter; userate(sum(`<name>`), 1 minute)for a per-minute rate).kind: gauge→SELECT average(`<name>`) FROM Metric WHERE service.name = '<name>' TIMESERIES
(latest(...)for a level) — neversum()a ratio/level.kind: dimension→SELECT count(*) FROM Span WHERE service.name = '<name>' FACET `<name>` SINCE 5 MINUTES AGO— a native breakdown of traffic by the business dimension (no metric-label caveat: the attribute is on the span).
Dash0 (dash0hq/dash0)
Authentication variables
variable "dash0_auth_token" {
description = "Dash0 API auth token (must start with \"auth_\" or \"dash0_at_\"). Requires management/write API access, NOT an ingestion-only scope (this module creates dashboards/check rules) — a 403 on every resource means check the token's permission scope. Create one under Organization Settings > Auth Tokens."
type = string
sensitive = true
default = ""
}
variable "dash0_url" {
description = "Dash0 API endpoint (region-specific)"
type = string
default = "https://api.us-west-2.aws.dash0.com"
}
variable "dash0_dataset" {
description = "Dash0 dataset identifier (not display name). A data-partitioning concept, UNRELATED to the shared `environment` variable — never default this to \"production\" (not a dataset Dash0 provisions; produces a 403 that reads like a permissions failure but isn't). A fresh org's actual default dataset is named \"default\"."
type = string
default = "default"
}Provider version
Use the latest available version from the Terraform registry. Check https://registry.terraform.io/providers/dash0hq/dash0/latest for the current version and add a version constraint to required_providers. Do not omit the version constraint.
Key gotchas
check_rule_yamlmust be a fullPrometheusRuledocument — never a flat alert body. It
needsapiVersion: monitoring.coreos.com/v1,kind: PrometheusRule,metadata.name, andspec.groupswrapping the rule; Dash0 currently supports exactly one group containing exactly
one rule percheck_rule_yaml. A flatalert: / expr: / for: / ...document with nogroups
wrapper fails live apply witherror converting check rule YAML to Dash0 format: currently only one group is supported(confirmed via a live apply — the golden shipped with exactly this flat,
broken shape until this was caught). Shape (mirror this, one rule per resource):apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule metadata: name: <dns-1123-safe-slug>-<alert-purpose> spec: groups: - name: Alerting rules: - alert: <AlertName> expr: <promql> for: 5m labels: { severity: critical } annotations: { summary: "..." }metadata.namefollows Kubernetes object-naming convention (DNS-1123: lowercase
alphanumeric +-, no underscores) — a separate constraint from thealert:field (a free-text
Prometheus label value, where underscores are fine and were NOT the cause of the failure above).
Sanitizeservice.nameinto a hyphen-based slug formetadata.namespecifically (the same@scope/pkgproblem as the "Service name → resource identifiers" rule above, but with-as
the separator here since DNS-1123 forbids_) — do not reuse an underscore-based slug for it.dash0_datasetmust never default to"production". See the variable description above —
confirmed live: a nonexistent dataset produces a 403 that is easily misdiagnosed as a
token-permission problem (#102 → #103). Default it to"default"(a fresh org's actual
out-of-the-box dataset) and say so explicitly in the description, so a later regeneration does
not reintroduce the mistake by pattern-matching onto the sharedenvironmentvariable's"production"default — datasets and deployment environments are unrelated Dash0 concepts.- A
dash0.com/folder-pathannotation, if you add one, MUST start with a leading/. Dash0's
live API rejects an omitted leading slash withdash0 api error: folder path must start with '/' (status: 400)(confirmed via a live apply, #104) —"otel-as-code"fails,"/otel-as-code"succeeds. This is a fixed literal in the generated template, not
account-specific, so it reproduces identically for every user if it regresses. - The auth token needs management/write scope, not ingestion-only. See the
dash0_auth_token
description above — confirmed live: an insufficiently-scoped token produces a 403 on every
resource (#102). Dash0's own docs distinguish ingestion-scoped tokens (send telemetry) from
management-scoped ones (manage dashboards/check rules via the API this module uses); verify the
exact current UI label for the broader scope against Dash0's own docs/account rather than
asserting one here, since it was not independently confirmed.
Notes for implementors
Dash0 is an OTel-native backend; their Terraform provider models resources around OTel data
directly. Before writing the terraform-gen subagent's Dash0 template, verify current resource
names at https://registry.terraform.io/providers/dash0hq/dash0/latest/docs.
As of mid-2026, the provider supports dashboards and monitoring rules. Use the registry docs
as the authoritative source for resource names and required fields — do not rely on this skill
alone for Dash0-specific field names.
Key principle: Dash0 uses OTel attribute names natively in query expressions, so no translation
layer is needed between OTel semconv and the monitoring query syntax.
Service filter: because Dash0 is OTel-native, its queries CAN filter by service directly —service.name in the Query Builder, or service_name (dots→underscores) in PromQL panels. This
is unlike a vanilla OTLP→Prometheus pipeline, where service.name is only the job label and{service_name="..."} returns no data (see the Grafana gotcha above). So the Dash0 golden keeps{service_name="..."} intentionally — verify against a live Dash0 instance, as the exact PromQL
label spelling depends on the panel/query surface.
Business-attribute panels (from confirmed businessAttrs)
Dash0 panels are PromQL, but its OTel-native pipeline makes business attributes far more likely to
be queryable than a vanilla Prometheus setup (it filters by service_name directly). Sanitize the
attribute name to a PromQL metric/label (dots→underscores) as <M>.
kind: counter→sum(rate(<M>_total{service_name="<name>"}[5m])). Caveat: requires the counter
to be emitted.kind: gauge→avg(<M>{service_name="<name>"})— the value as-is, not rated.kind: dimension→sum by (<M>) (rate(http_server_request_duration_seconds_count{service_name="<name>"}[5m]))
— a breakdown by the business attribute, which Dash0's OTel-native ingestion keeps queryable as a
label where a vanilla Prometheus pipeline would not. Verify the label spelling against a live
instance.