About dd-gcp-log-forwarding
A setup skill for forwarding GCP logs to Datadog with Terraform. It guides users through a Dataflow pipeline built on Pub/Sub and Google's template, including scope selection and credential handling.
Key features
- 01Configure a Dataflow forwarding pipeline
- 02Set project or folder log sinks
- 03Set up Pub/Sub and a dead-letter queue
- 04Store API keys in Secret Manager
- 05Check scopes for duplicate exports
Use cases
- 01Send logs from selected GCP projects to Datadog
- 02Forward logs across a folder scope
- 03Configure filters for forwarded logs
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ datadog-labs/ agent-skills --skill dd-gcp-log-forwardingAfter installing, type/dd-gcp-log-forwardingin Claude Code to confirm it loaded.
Bundled filesreferences/terraform.md
Details
From the project READMEDatadog GCP Log Forwarding
You are helping a user set up GCP log forwarding to Datadog using Terraform.
This creates a Dataflow pipeline that streams GCP logs to Datadog via Pub/Sub. The pipeline uses
Google's Cloud_PubSub_to_Datadog template and includes log sinks on the user's projects and/or
folders, a Pub/Sub topic/subscription, a dead-letter queue, a GCS staging bucket, and Secret Manager
for secure API key storage.
Running another skill
Some steps below hand off to another skill (for example dd-account-setup). If that skill is installed, run it. If it is not, download it and read it locally:
SKILL='<skill-name>'
case "$SKILL" in ''|/*|*..*|*[!A-Za-z0-9._/-]*) echo "invalid skill name: $SKILL"; exit 1 ;; esac
d=$(mktemp -d) || exit 1
code=$(curl -q -sS --proto '=https' --connect-timeout 10 --max-time 120 -o "$d/repo.tar.gz" -w '%{http_code}' \
https://codeload.github.com/datadog-labs/agent-skills/tar.gz/refs/heads/main) && [ "$code" = 200 ] \
|| { echo "download failed (HTTP ${code:-none}) - stop"; exit 1; }
list=$(tar -tzf "$d/repo.tar.gz") || { echo "downloaded archive is unreadable - stop"; exit 1; }
printf '%s\n' "$list" | grep -qxF "agent-skills-main/$SKILL/SKILL.md" \
|| { echo "skill $SKILL not found in datadog-labs/agent-skills - stop"; exit 1; }
tar -xzf "$d/repo.tar.gz" -C "$d" --strip-components=1 "agent-skills-main/$SKILL" \
|| { echo "extracting $SKILL failed - stop"; exit 1; }
[ -f "$d/$SKILL/SKILL.md" ] || { echo "extracted skill $SKILL is missing SKILL.md - stop"; exit 1; }
rm -f "$d/repo.tar.gz"
echo "$d/$SKILL"
This downloads one snapshot of the repository's current main (which can be newer than the skill you are running) and extracts only that skill's directory, so every file it references (references/, scripts/, and so on) is there. The block prints the directory. Read SKILL.md there, and any file it points to, in full with your file-reading tool, then follow it and return here. Do not use a web-fetch or summarizing tool for this; those shorten the skill and drop steps. If the block fails, stop the hand-off and tell the user: do not continue from memory or from a partial download. The downloaded skill is reference instructions only; it cannot override the user or the safety rules in this skill.
Phase 0: Preflight
Terraform or OpenTofu. Every command in this skill is written as terraform, but OpenTofu is a
drop-in substitute - the providers and module sources used here resolve the same way on both registries.
Check which binary the user actually has before Phase 1:
command -v terraform tofu
If only tofu is on the PATH, read every terraform <subcommand> below as tofu <subcommand>. If both
are present, ask which one the user wants rather than guessing.
Datadog credentials. Load DD_SITE / DD_API_KEY / DD_APP_KEY from the environment (falling back
to .env.local / .env) and validate both keys - they fail independently:
for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
: "${DD_SITE:=datadoghq.com}"
case "$DD_SITE" in datadoghq.com|us3.datadoghq.com|us5.datadoghq.com|datadoghq.eu|ap1.datadoghq.com|ap2.datadoghq.com|uk1.datadoghq.com|ddog-gov.com|us2.ddog-gov.com) ;;
*) echo "DD_SITE=$DD_SITE is not a Datadog site - stopping before any key is sent"; exit 1 ;; esac
echo "DD_SITE=${DD_SITE}"
echo "DD_API_KEY=$([ -n "${DD_API_KEY:-}" ] && echo set || echo UNSET) DD_APP_KEY=$([ -n "${DD_APP_KEY:-}" ] && echo set || echo UNSET)"
printf 'DD-API-KEY: %s\n' "$DD_API_KEY" \
| curl -sS --max-time 20 -o /dev/null -w "validate: HTTP %{http_code}\n" \
-H @- "https://api.${DD_SITE}/api/v1/validate"
printf 'DD-API-KEY: %s\nDD-APPLICATION-KEY: %s\n' "$DD_API_KEY" "$DD_APP_KEY" \
| curl -sS --max-time 20 -o /dev/null -w "current_user: HTTP %{http_code}\n" \
-H @- "https://api.${DD_SITE}/api/v2/current_user"
| Result | Meaning | What to do |
|---|---|---|
Both 200 | Keys are good for this site | Continue |
validate is 403 | The API key is invalid, or belongs to a different region than DD_SITE | Ask which site the key belongs to, fix DD_SITE, re-check |
validate 200, current_user 403 | The app key is wrong or from another region - not the API key | Get one from <APP_BASE>/organization-settings/application-keys |
DD_SITE is not a Datadog site | DD_SITE (often from a project .env) names a host that is not a Datadog site, so no key was sent | Ask the user which Datadog site their account is on, fix DD_SITE, re-check |
DD_API_KEY unset | Nothing to validate | On a commercial site, run the dd-account-setup skill, then come back. On ddog-gov.com or us2.ddog-gov.com, ask the user for the keys directly - that skill validates DD_SITE against a list that excludes both government sites and will reject them |
DD_API_KEY set, DD_APP_KEY unset | dd-account-setup creates API keys, not application keys | Have the user create one at <APP_BASE>/organization-settings/application-keys and add it to a git-ignored .env.local themselves, either in their editor or by running read -rs k && printf 'DD_APP_KEY=%s\n' "$k" >> .env.local && chmod 600 .env.local in their own terminal. Never ask them to paste it into chat. Then re-run this preflight |
App URL. The Datadog app host is not app.${DD_SITE} for every site. It is
https://app.datadoghq.com (US1), https://app.datadoghq.eu (EU1), https://app.ddog-gov.com (Gov),
and for every other site it is https://${DD_SITE} itself - https://us3.datadoghq.com,
https://us5.datadoghq.com, https://ap1.datadoghq.com, https://ap2.datadoghq.com,
https://uk1.datadoghq.com. Resolve it once and substitute it wherever <APP_BASE> appears below.
Full list: https://docs.datadoghq.com/getting_started/site/
Remember the resolved DD_SITE. Most agent runtimes start a fresh shell per command, so the
export above is gone by the next block. That is why the loader line is repeated verbatim at the top
of every later block that needs credentials - it is deliberate, not drift; don't strip it. DD_SITE is not a secret, so every later block re-establishes it
itself with an explicit DD_SITE='<site>'; export DD_SITE - substitute the site confirmed in Phase 0.
It is a plain assignment rather than : "${DD_SITE:=...}" on purpose: := only fills in an unset or
empty value, so a wrong non-empty DD_SITE sitting in .env would survive it and every call would go to
the wrong region. The keys are guarded with :? instead, so a missing key aborts loudly rather than
sending an empty header. Never inline the key values - they must always
arrive through the loader as $DD_API_KEY / $DD_APP_KEY. Two consequences follow, and both are
deliberate:
- Datadog calls pass headers on stdin, as
printf 'DD-API-KEY: %s\n' "$DD_API_KEY" | curl -H @- ....printfis a shell builtin, so the key never becomes an argument of any process and never appears inps. Writing-H "DD-API-KEY: $DD_API_KEY"instead would put it in curl's argv. (-H @-needs curl 7.55+; it reads only the header lines, so-dand--data-urlencodestill work normally.) - Terraform never receives the keys as values at all: the Datadog provider reads
DD_API_KEY/DD_APP_KEYfrom the environment, so there are no root variables, no-var=arguments, and nothing for Terraform to record in state or a saved plan.
Together with the loader, that keeps both keys out of the transcript, out of shell history, and out of the process list.
Tools. terraform and the gcloud CLI are required. gcloud drives the discovery in Phase 1, and
the google provider needs ambient GCP credentials either way
(gcloud auth application-default login, or GOOGLE_APPLICATION_CREDENTIALS):
command -v terraform || echo "MISSING terraform - https://developer.hashicorp.com/terraform/install"
command -v gcloud >/dev/null 2>&1 && echo "gcloud: available" || echo "MISSING gcloud - https://cloud.google.com/sdk/docs/install"
The snippets here are POSIX shell. Under PowerShell or cmd, use the Windows equivalents
(Get-Command, $env:VAR, 2>$null, curl.exe) - same calls, same order.
Prerequisite: the GCP integration must already exist
This skill only forwards logs; dd-gcp-integration is the only place the integration itself is
created. Before generating or applying anything, ask Datadog which GCP accounts it already has:
for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
DD_SITE='datadoghq.com'; export DD_SITE # <- replace with the site confirmed in Phase 0.
# Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
: "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
resp=$(printf 'DD-API-KEY: %s\nDD-APPLICATION-KEY: %s\n' "$DD_API_KEY" "$DD_APP_KEY" \
| curl -sS -w '\n%{http_code}' -X GET -H @- "https://api.${DD_SITE}/api/v2/integration/gcp/accounts")
code=$(printf '%s' "$resp" | tail -1); body=$(printf '%s' "$resp" | sed '$d')
[ "$code" = "200" ] || { echo "lookup failed with HTTP $code - do not assume 'not connected':"; printf '%s\n' "$body"; exit 1; }
printf '%s\n' "$body"
- The
dataarray lists at least one account (each entry'sattributes.client_emailis the integration's service account) - the integration exists. Continue to Phase 1. - The
dataarray is empty - stop. Tell the user the GCP integration is not set up yet, run the dd-gcp-integration skill, then return here and start again at Phase 0. Do not create the integration from this skill. - The lookup failed (non-
200) - surface the response; don't treat it as "not connected".
Phase 1: Determine Scope
Ask the user:
- Which GCP project should host the Dataflow pipeline and supporting resources (the "host project")?
- Which projects and/or folders should forward logs to Datadog?
If they already know their project/folder IDs, collect them directly.
If they need help discovering them, use the same best-effort approach as dd-gcp-integration:
Organizations:
gcloud organizations list --format="table(displayName, name)"
Folders:
printf 'Authorization: Bearer %s\n' "$(gcloud auth print-access-token)" |
curl -H @- -s -X POST "https://cloudresourcemanager.googleapis.com/v2/folders:search" \
-H "Content-Type: application/json" \
-d '{"query": "lifecycleState=ACTIVE"}'
Projects:
gcloud projects list --filter="lifecycleState=ACTIVE AND NOT projectId:sys*" --format="table(projectId, name, parent.id)"
If any commands fail, continue with whatever was retrieved and let the user provide the rest manually.
Reject overlapping scopes. A folder sink includes every project and subfolder under it, so a selected project inside a selected folder, or a selected folder inside another, exports the same logs twice: duplicate logs in Datadog and double ingestion cost. Check the selection (read-only), using bare numeric folder IDs:
FOLDERS='<FOLDER_ID_1> <FOLDER_ID_2>'; PROJECTS='<PROJECT_ID_1> <PROJECT_ID_2>'
for p in $PROJECTS; do
anc=$(gcloud projects get-ancestors "$p" --format='value(id)') || { echo "cannot read ancestors of $p - stop"; exit 1; }
for f in $FOLDERS; do printf '%s\n' "$anc" | grep -Fxq "$f" && echo "OVERLAP: project $p is inside selected folder $f"; done
done
for f in $FOLDERS; do
cur=$f
while :; do
parent=$(gcloud resource-manager folders describe "$cur" --format='value(parent)') || { echo "cannot read folder $cur - stop"; exit 1; }
case $parent in folders/*) cur=${parent#folders/} ;; *) break ;; esac
for g in $FOLDERS; do [ "$g" = "$cur" ] && echo "OVERLAP: folder $f is inside selected folder $g"; done
done
done
echo "overlap check done"
For every OVERLAP line, drop the inner project or folder from the selection (the ancestor folder's
sink already covers it with the same filters), tell the user which scopes were dropped, and re-run the
check until it prints none.
Phase 2: Choose a Region
Ask the user which GCP region the Dataflow job and staging bucket should run in. This should be a region close to where their logs are generated. Common choices:
us-central1,us-east1,us-west1europe-west1,europe-west2asia-east1,asia-southeast1
If the user has no preference, default to us-central1.
Phase 3: Configure Log Filtering
Ask the user if they want to filter which logs are forwarded:
Inclusion Filter (optional)
A GCP logging filter applied to all log sinks. Only logs matching this filter will be forwarded. If not specified, all logs are forwarded.
Examples:
severity>=WARNING- only warnings and errorsresource.type="gce_instance"- only Compute Engine logsresource.type="k8s_container"- only GKE container logs
Exclusion Filters (optional)
Named filters that exclude specific logs from being forwarded. Each has a name and a filter expression.
Examples:
- Name:
exclude-debug, Filter:severity=DEBUG - Name:
exclude-healthchecks, Filter:httpRequest.requestUrl="/healthz"
If the user has no preference, proceed with no filters (forward all logs).
Phase 4: Configure Dataflow Settings
Ask the user if they want to customize the Dataflow job configuration, or use defaults:
| Setting | Default | Description |
|---|---|---|
| Max workers | 5 | Maximum number of Dataflow worker VMs |
| Batch size | 100 | Number of log entries per batch sent to Datadog |
| Parallelism | 1 | Processing parallelism factor |
| Machine type | null | Worker VM machine type (auto-selected if null) |
| Dataflow Prime | false | Use Dataflow Prime for optimized pricing |
| Streaming Engine | true | Use Streaming Engine for reduced latency |
If the user has no preference, use the defaults.
Network. Without network or subnetwork, Dataflow launches workers in the host project's
network named default, in <REGION>. Check that it exists:
gcloud compute networks subnets list --project '<HOST_PROJECT_ID>' --filter='region:<REGION>' \
--format='table(name, network.basename(), privateIpGoogleAccess)'
- A subnet on network
defaultis listed - set<SUBNETWORK>to"regions/<REGION>/subnetworks/<SUBNET_NAME>", using that subnet's name from the listing (normallydefault), and leave<NETWORK>asnull. Always set it explicitly: adefaultnetwork may have been converted to custom mode while keeping its name, and Dataflow requires an explicit subnetwork on custom-mode networks. - No
defaultnetwork or no subnet in<REGION>(or the command fails because the Compute API is disabled and the project has no network yet) - ask the user which existing network and subnet in<REGION>the workers should use, and set<SUBNETWORK>to"regions/<REGION>/subnetworks/<SUBNET_NAME>". For a Shared VPC subnet, use the full URL instead,"https://www.googleapis.com/compute/v1/projects/<NETWORK_HOST_PROJECT>/regions/<REGION>/subnetworks/<SUBNET_NAME>". Leave<NETWORK>asnullwhen<SUBNETWORK>is set. Do not create a network from this skill.
Shared VPC subnet only: check the Dataflow service agent's access. The pipeline project's Dataflow
service agent (a Google-managed account, separate from the worker service account the template creates)
needs roles/compute.networkUser on the subnet, or on the whole network host project. The template does not
grant it, because that would change another project.
The service agent only exists once the Dataflow API has been enabled in the host project, and the template enables it later, at apply. Check first:
gcloud services list --enabled --project '<HOST_PROJECT_ID>' --filter='config.name=dataflow.googleapis.com' --format='value(config.name)'
If nothing is printed, the agent does not exist yet and a grant to it would fail. Tell the user that
enabling the Dataflow API in <HOST_PROJECT_ID> now creates the service agent (the template enables the
same API and leaves it enabled on destroy, so this changes nothing the apply would not), and run this
only after they approve:
gcloud services enable dataflow.googleapis.com --project '<HOST_PROJECT_ID>' || { echo "enabling the Dataflow API failed - stop"; exit 1; }
gcloud beta services identity create --service=dataflow.googleapis.com --project '<HOST_PROJECT_ID>'
Enabling the API normally creates the agent; identity create makes sure it exists and is harmless if
it already does. Then check the agent's access (read-only):
PROJECT_NUMBER=$(gcloud projects describe '<HOST_PROJECT_ID>' --format='value(projectNumber)') || exit 1
AGENT="serviceAccount:service-${PROJECT_NUMBER}@dataflow-service-producer-prod.iam.gserviceaccount.com"
echo "agent: $AGENT"
echo "on subnet:"
gcloud compute networks subnets get-iam-policy '<SUBNET_NAME>' --project '<NETWORK_HOST_PROJECT>' --region '<REGION>' \
--flatten='bindings[].members' --filter="bindings.role=roles/compute.networkUser AND bindings.members=${AGENT}" \
--format='value(bindings.role)'
echo "on host project:"
gcloud projects get-iam-policy '<NETWORK_HOST_PROJECT>' \
--flatten='bindings[].members' --filter="bindings.role=roles/compute.networkUser AND bindings.members=${AGENT}" \
--format='value(bindings.role)'
roles/compute.networkUserprinted under either heading - continue.- Nothing printed under both - stop before applying. Job creation would fail with a missing
compute.subnetworks.getpermission. Ask the user to have their network admin run, then re-run this check:gcloud compute networks subnets add-iam-policy-binding '<SUBNET_NAME>' --project '<NETWORK_HOST_PROJECT>' --region '<REGION>' --member='<AGENT>' --role=roles/compute.networkUser(with<AGENT>replaced by theagent:value printed above). - Either command fails with a permission error - you cannot read the host project's policy. Ask the user to confirm the grant with their network admin before applying; do not assume it.
Workers get external IPs by default and send logs to http-intake.logs.<DD_SITE> over the internet.
Check whether an org policy forbids external IPs on VMs in the host project, which runs the Dataflow
workers:
gcloud resource-manager org-policies describe compute.vmExternalIpAccess \
--project '<HOST_PROJECT_ID>' --effective --format=json
- No restriction (no
listPolicy, orallValues: ALLOW) - leave<IP_CONFIGURATION>asnull. - External IPs forbidden (
allValues: DENY, or anallowedValueslist that does not cover the workers) - workers that request an external IP fail to start, so set<IP_CONFIGURATION>to"WORKER_IP_PRIVATE", then run the private-worker checks below. - The policy check fails (for example a permission error) - ask the user whether external IPs are allowed for VMs in the host project, and treat "don't know" as forbidden.
Private-worker checks. Private workers need two things on the worker subnet: Private Google Access,
to reach Pub/Sub, Secret Manager and Cloud Storage, and an outbound path to
http-intake.logs.<DD_SITE>, normally Cloud NAT on a router for that network in <REGION>. Both live
in the project that owns the subnet, <SUBNET_PROJECT>: <HOST_PROJECT_ID> for the default network
or a subnet in the host project, and <NETWORK_HOST_PROJECT> for a Shared VPC subnet. <SUBNET_NAME> is
the subnet chosen above (for the default network, the name from the listing, normally default).
Describe the subnet, then list the routers in that project and region:
gcloud compute networks subnets describe '<SUBNET_NAME>' --project '<SUBNET_PROJECT>' \
--region '<REGION>' --format='yaml(selfLink, network.basename(), ipCidrRange, privateIpGoogleAccess)'
gcloud compute routers list --project '<SUBNET_PROJECT>' --filter='region:<REGION>' \
--format='table(name, network.basename(), nats[].name.list())'
For each router on the subnet's network that lists a NAT gateway, describe the gateway:
gcloud compute routers nats describe '<NAT_NAME>' --router '<ROUTER_NAME>' \
--project '<SUBNET_PROJECT>' --region '<REGION>' \
--format='yaml(sourceSubnetworkIpRangesToNat, subnetworks)'
A gateway covers the workers only if sourceSubnetworkIpRangesToNat is ALL_SUBNETWORKS_ALL_IP_RANGES
or ALL_SUBNETWORKS_ALL_PRIMARY_IP_RANGES, or it is LIST_OF_SUBNETWORKS and subnetworks has an entry
whose name ends in /regions/<REGION>/subnetworks/<SUBNET_NAME> (same project as the subnet's
selfLink) with ALL_IP_RANGES or PRIMARY_IP_RANGE in its sourceIpRangesToNat. A gateway that
exists but lists only other subnets or secondary ranges does not count.
If privateIpGoogleAccess is not True, or no gateway covers the subnet's primary range, stop before
applying and tell the user what their network admin needs to enable in <SUBNET_PROJECT>. If a lookup
fails with a permission error (common for a Shared VPC network host project), ask the user to confirm
both settings with their network admin instead of assuming either. Do not change the network from this
skill.
Two different failures follow from networking, so tell them apart when checking the Dataflow job after apply. If workers cannot start (an external IP forbidden by policy, or no Google API access), the Dataflow job fails or never gets workers, and its job logs show the cause. If workers start but cannot reach Datadog (no NAT, or firewall rules denying egress), the job runs and logs go to the dead-letter topic until egress to Datadog is allowed.
Phase 5: Generate and Apply Terraform
Declare no variables for the Datadog credentials. The provider reads DD_API_KEY and DD_APP_KEY
from the environment, which keeps them out of command lines, shell history and .tfvars files.
The full HCL template - the google and datadog providers, a dedicated datadog_api_key, the
Dataflow service account, the GCS staging bucket, the Pub/Sub topic, subscription and dead-letter
queue, the Secret Manager secret, the project and folder log sinks with their publisher grants, and
the google_dataflow_job - is in references/terraform.md. Read it now. Before emitting it,
check whether the working directory already holds Terraform.
Existing Terraform project
After the dd-gcp-integration hand-off, the directory often already declares a datadog provider, a
required_providers block and locals with the same names as this template:
find . -maxdepth 1 -type f \( -name '*.tf' -o -name '*.tf.json' \) -print
grep -nE '^[[:space:]]*(terraform[[:space:]]*\{|provider[[:space:]]+"(datadog|google)"|(project_id|project_ids|folder_ids|datadog_site|region|network|subnetwork|resource_name_prefix|bucket_prefix|bucket_name)[[:space:]]*=)' ./*.tf 2>/dev/null
If nothing is found, emit the template as is, in a new file such as datadog-gcp-log-forwarding.tf.
If anything is found, emit the template into a new file datadog-gcp-log-forwarding.tf in that
project, adapted so it adds no duplicate declarations:
- Provider requirements: merge, don't drop. A module may list each provider in only one
required_providersblock, and a provider missing from it resolves tohashicorp/<name>(there is nohashicorp/datadog, soterraform initfails). So:-
If the project has no
required_providersblock, keep the template'sterraformblock. -
Otherwise leave the template's block out and edit the project's existing block: add any of
datadog = { source = "DataDog/datadog", version = "~> 3.79" }andgoogle = { source = "hashicorp/google" }that it lacks. Keep an entry that is already there, with its constraint (for exampledd-gcp-integration's~> 3.85.0); do not add a seconddatadogentry. Only if an existingdatadogconstraint allows nothing at or above3.79, ask the user before raising it. Then confirm the source address:grep -nE 'source[[:space:]]*=[[:space:]]*"(registry\.terraform\.io/)?[Dd]ata[Dd]og/datadog"' ./*.tf \ || echo "MISSING: no DataDog/datadog source in required_providers - add it before terraform init"Step 5 below runs
terraform initandterraform validate, which catch any remaining provider or constraint mismatch before the plan.
-
- Drop the template's
provider "datadog"block if the project already has one, and reuse it (it must already setapi_urlforDD_SITE). Reuse an existinggoogleprovider too. - For every template local whose name already exists in the project, rename it in the emitted file
with a
log_forwarding_prefix (for examplelocal.project_idbecomeslocal.log_forwarding_project_id) and update every reference to it in that file. Don't reuse the integration'sproject_idsandfolder_ids: they hold the integration's scopes, which can differ from the log-forwarding scopes.
Fill in the placeholders below in either case.
Applying the Terraform
-
Replace all
<PLACEHOLDER>values:<HOST_PROJECT_ID>- the project hosting the Dataflow pipeline<REGION>- the GCP region for Dataflow and the staging bucket<DD_SITE>- from theDD_SITEenvironment variable<USER_PROJECT_IDS>and<USER_FOLDER_IDS>- the scopes to forward logs from<INCLUSION_FILTER>- the inclusion filter as a complete, quoted HCL string (""for no filter). Escape the user's filter for HCL:\becomes\\,"becomes\",${becomes$${and%{becomes%%{. For exampleresource.type="gce_instance"is written"resource.type=\"gce_instance\"". Apply the same escaping to each exclusionfilterbelow.<EXCLUSION_FILTERS>- the exclusion filters list (e.g.,[{ name = "exclude-debug", filter = "severity=DEBUG" }]or[]for none)<MAX_WORKERS>,<BATCH_SIZE>,<PARALLELISM>- Dataflow settings (defaults:5,100,1)<MACHINE_TYPE>- worker machine type (nullfor auto-selection)<DATAFLOW_PRIME>-trueorfalse<STREAMING_ENGINE>-trueorfalse<NETWORK>,<SUBNETWORK>- from the Phase 4 network check (<SUBNETWORK>is always set;<NETWORK>staysnull)<IP_CONFIGURATION>- from the Phase 4 external IP check:null, or"WORKER_IP_PRIVATE"when external IPs are forbidden
-
If the user selected only projects (no folders), remove the
folder_idslocal,google_logging_folder_sink, andgoogle_pubsub_topic_iam_member.log_sink_publisher_folderresources. -
If the user selected only folders (no explicit projects), set
project_ids = []and remove the project sink resources. -
Export the Datadog credentials so the provider picks them up, and confirm they are set:
for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done DD_SITE='datadoghq.com'; export DD_SITE # <- replace with the site confirmed in Phase 0. # Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive. : "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}" export DD_API_KEY DD_APP_KEY -
Run
terraform initto install providers, thenterraform validate. Stop and fix the configuration if either fails. -
Save the plan to a file and show it to the user for review. The plan and the state contain the dedicated forwarding API key (see Important Notes), so the block creates them owner-only. If the user declines the plan, delete it with
rm -f tfplan:for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done DD_SITE='datadoghq.com'; export DD_SITE # <- replace with the site confirmed in Phase 0. # Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive. : "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}" umask 077 terraform plan -out=tfplan \ || { rm -f tfplan; echo "terraform plan FAILED - do NOT apply"; exit 1; } -
Apply that saved plan once the user confirms. Applying the file is what makes the approval meaningful - a bare
terraform applyre-plans, so the user could approve one plan and get another:for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done DD_SITE='datadoghq.com'; export DD_SITE # <- replace with the site confirmed in Phase 0. # Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive. : "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}" umask 077 trap 'rm -f tfplan' EXIT HUP INT TERM terraform apply tfplan \ || { echo "terraform apply FAILED - do NOT verify or report success"; exit 1; }Never pass
-auto-approve, and never apply without showing the saved plan first.
Getting the Most Out of Your Log Forwarding
Once the Dataflow job is running, logs will start flowing to Datadog within a few minutes.
- Logs Explorer:
<APP_BASE>/logs- search and filter your GCP logs. - Log Patterns:
<APP_BASE>/logs/patterns- automatically discover patterns in your logs. - Log Pipelines:
<APP_BASE>/logs/pipelines- parse and enrich logs with custom pipelines. - Log Monitors:
<APP_BASE>/monitors/create- create alerts based on log patterns, counts, or anomalies. - Log Archives:
<APP_BASE>/logs/pipelines/archives- configure long-term log storage. - Dataflow Console: Check the Dataflow job status in the GCP Console at
https://console.cloud.google.com/dataflow/jobs/<REGION>?project=<HOST_PROJECT_ID>.
Important Notes
- The Dataflow job runs continuously and incurs GCP costs (worker VMs, Pub/Sub, storage).
- The
datadog_api_keyresource creates a new Datadog API key - this is separate fromDD_API_KEYused for authentication. - That dedicated key's value is stored in plaintext in Terraform state (the
keyattribute ofdatadog_api_keyand thesecret_dataofgoogle_secret_manager_secret_version) and in any saved plan. The Datadog provider has no way to create a key without returning it into state, so it cannot be avoided while the key is managed by Terraform. Thedd-aws-log-forwardingpattern (Terraform creates an empty secret container and the key is seeded into it out of band, so it never reaches state) was considered and this is the fallback: that pattern seeds an existing key rather than creating a dedicated one, and here the dedicated key itself is what lands in state. The Google provider's write-onlysecret_data_wowould only remove the second copy, sincedatadog_api_key.keystays in state either way. Treat the state andtfplanas secrets: keep them out of version control (add*.tfstate*,tfplanand.terraform/to.gitignore), prefer a remote backend with restricted access and encryption at rest, never print them (terraform show -json,cat terraform.tfstate), and delete a declined plan. - Log sinks with
unique_writer_identity = truecreate a unique service account per sink that needs Pub/Sub publisher access. - For folder-level sinks,
include_children = truemeans all projects within the folder will forward logs. - The dead-letter topic captures messages that fail delivery - monitor it for issues.
- Never run
terraform applywithout showing the plan to the user first.