> **dd-azure-log-forwarding** — skill 45 of 69 in [datadog-labs/agent-skills](https://skillsdocs.com/datadog-labs/agent-skills).
>
> Book (all skills, one file): https://skillsdocs.com/datadog-labs/agent-skills.md
> Machine manifest: https://skillsdocs.com/datadog-labs/agent-skills/.well-known/agent-skills/index.json
> Install the book: `npx skills add datadog-labs/agent-skills`
> Upstream: https://github.com/datadog-labs/agent-skills/blob/main/dd-azure-log-forwarding/SKILL.md @ `main`
> Raw bytes, no header: https://raw.githubusercontent.com/datadog-labs/agent-skills/main/dd-azure-log-forwarding/SKILL.md
> Base for relative paths: https://raw.githubusercontent.com/datadog-labs/agent-skills/main/dd-azure-log-forwarding/
> Licence: MIT — https://spdx.org/licenses/MIT.html
>
> Content © its authors, served unmodified. Takedown: https://github.com/DreambaseAI/skillsdocs/issues/new?labels=takedown&title=Takedown+request

<!-- Verbatim upstream SKILL.md follows, YAML frontmatter included. -->

---
name: dd-azure-log-forwarding
description: Set up automated Azure log forwarding to Datadog by running Datadog's official Log Forwarding Orchestrator (LFO) install script through the Azure CLI - deploys the managed control plane that auto-discovers resources across the monitored subscriptions and provisions and scales the log forwarders itself, with no per-resource diagnostic settings. Use when the user wants to forward Azure logs to Datadog, send Azure resource logs to Datadog, or set up Azure log forwarding. Does not generate Terraform and does not create the Azure integration or set up metrics collection. Requires the Datadog Azure integration; run `dd-azure-integration` first if it is missing.
metadata:
  version: "1.0.1"
  author: datadog-labs
  repository: https://github.com/datadog-labs/agent-skills
  tags: datadog,azure,logs,log-forwarding,lfo,cloud
  alwaysApply: "false"
  tools: az,python3
---

# Datadog Azure Log Forwarding

You are helping a user set up Azure log forwarding to Datadog using Datadog's **Automated Azure Log
Forwarding** product (the Log Forwarding Orchestrator, "LFO").

The LFO is a managed, container-based control plane that auto-discovers resources across the
monitored subscriptions and forwards their logs to Datadog - it provisions and scales the forwarders
for you, with no per-resource diagnostic-setting wiring.

**This skill does not generate Terraform** - unlike the AWS and GCP log-forwarding skills. The LFO is
an evolving managed product whose Function App code is deployed by Datadog's own installer (not
expressible as static resources), so the correct tool is Datadog's official install script run via
the Azure CLI, not a hand-maintained Terraform reimplementation that would drift the moment the LFO
changes. The script is public, in `DataDog/integrations-management` (`azure/logging_install`); Phase 3
runs a pinned release of it, verified by checksum.

## 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:

```bash
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

**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:

```bash
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 to Phase 1 |
| `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 |

**Installer site support.** Valid keys are not enough: the installer's `--datadog-site` only accepts
`datadoghq.com`, `datadoghq.eu`, `us3.datadoghq.com`, `us5.datadoghq.com`, `ap1.datadoghq.com`,
`ap2.datadoghq.com`, `uk1.datadoghq.com` and `ddog-gov.com`. If `DD_SITE` is anything else (for example
`us2.ddog-gov.com`), stop here and tell the user this installer does not support their site yet; don't
collect the deployment scope.

**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`.

**Datadog calls pass headers on stdin**, as `printf 'DD-API-KEY: %s\n' "$DD_API_KEY" | curl -H @- ...`.
`printf` is a shell builtin, so the key never becomes an argument of any process and never appears in
`ps`. 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 `-d` and `--data-urlencode` still work normally.)

Together with the loader, that keeps both keys out of the transcript and out of shell history.

**Tools.** The `az` CLI, `python3` and `bash` are all required:

```bash
command -v bash >/dev/null 2>&1 && echo "bash: available" || echo "MISSING bash - the Phase 3 block needs it for pipefail"
command -v az >/dev/null 2>&1 && echo "az: available" || echo "MISSING az - https://learn.microsoft.com/cli/azure/install-azure-cli"
command -v python3 >/dev/null 2>&1 && echo "python3: available" || echo "MISSING python3"
```

**Run the snippets here with bash.** Most are POSIX shell, but the Phase 3 installer block uses
`set -o pipefail`, which `/bin/sh` implementations such as `dash` reject, and it checks for bash before
running. On Windows, use bash from WSL or Git Bash.

## Phase 1: Prerequisites

1. **Azure CLI authenticated.** Confirm with `az account show`. If it fails, have the user run
   `az login`.
2. **Python 3 available** (the installer is a Python zipapp):
   ```bash
   command -v python3
   ```
   If missing, ask the user to install Python 3.
3. **Permissions.** The installer creates resource groups, a custom role, and role assignments in
   the control-plane subscription *and* resource groups and role assignments in every monitored
   subscription. In the **control-plane subscription** the user needs **Owner**, or **Contributor** plus
   **User Access Administrator**: the installer runs `az role definition create` for its custom role,
   which needs `Microsoft.Authorization/roleDefinitions/write`, and **Role Based Access Control
   Administrator** does not grant that (it only covers role assignments). In each **monitored
   subscription**, **Owner**, or **Contributor** plus **Role Based Access Control Administrator** or
   **User Access Administrator**, is enough. Check this before running the installer, because a missing
   permission can fail the install after control-plane resources are already deployed. If the script
   later fails with authorization errors, point them to their Azure administrator - don't assume their
   environment is broken.
4. **The Datadog Azure integration already exists.** This skill installs log forwarding only;
   `dd-azure-integration` is the only place the integration itself is created. Before running the
   installer, confirm the tenant is registered with Datadog. Get the tenant ID:

   ```bash
   az account show --query "tenantId" -o tsv
   ```

   Then ask Datadog which Azure tenants it already has:

   ```bash
   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/v1/integration/azure")
   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"
   ```

   - **An entry's `tenant_name` matches the tenant ID** - the integration exists. Record the tenant ID
     as `TENANT_ID` and continue to Phase 2. This check covers **this tenant only**: every subscription
     selected in Phase 2 must belong to it. To forward logs from another tenant, run this prerequisite
     check for that tenant (`az login --tenant <id>`) and do a separate installation there.
   - **No entry matches** (or the list is empty) - stop. Tell the user the Azure integration is not set
     up yet, run the **dd-azure-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 2: Determine Scope

Collect:
- **Control plane region** - the Azure region to host the control plane (e.g. `eastus`, `westus2`).
- **Control plane subscription** - the subscription ID where the control plane is deployed. Default
  to the current subscription from `az account show --query id -o tsv`. It must be in `TENANT_ID`.
- **Control plane resource group** - a name for the control-plane resource group. Default to
  `datadog-log-forwarding`. Record this as `CONTROL_PLANE_RG`.
- **Monitored subscriptions** - the subscription IDs to forward logs from. By default, forward from
  **all** subscriptions the user can access **in the tenant validated in Phase 1**. List them so the
  user can confirm or narrow (substitute the `TENANT_ID` from Phase 1):
  ```bash
  az account list --query "[?tenantId=='<TENANT_ID>'].{name:name, id:id}" -o table
  ```
  Pass the chosen IDs as a comma-separated list.

Then confirm that the control-plane subscription and every monitored subscription belong to that
tenant, so none of them bypasses the Phase 1 integration check:

```bash
TENANT_ID='<TENANT_ID>'
for sub in '<CONTROL_PLANE_SUBSCRIPTION_ID>' '<SUB_ID_1>' '<SUB_ID_2>'; do
  t=$(az account show --subscription "$sub" --query tenantId -o tsv) || { echo "cannot read subscription $sub - stop"; exit 1; }
  [ "$t" = "$TENANT_ID" ] && echo "ok: $sub" || { echo "subscription $sub is in tenant $t, not $TENANT_ID - its integration was not checked; stop"; exit 1; }
done
```

If any subscription fails, drop it from this installation (or handle its tenant separately, as described
in Phase 1) before continuing.

Optional (only if the user asks):
- **Resource tag filters** - forward only from resources matching tags, e.g. `datadog:true,env:prod`.
- **PII scrubber rules** - a YAML block of redaction rules applied to forwarded logs.

If the user has no preference on tags/PII, a **new** installation uses none (all logs forwarded
unfiltered). An **existing** installation keeps its current values, which the next step reads.

### Check for an existing installation

Re-running the installer against an existing installation **updates** it, and the update is
destructive in two ways. Settings are replaced, not merged: leaving out `--resource-tag-filters` or
`--pii-scrubber-rules` sends an empty value, which clears the tag filters and PII scrubber rules already
in place. And every subscription the installation monitors today that is missing from the new list
loses its role assignments, and the installer deletes the resource group named like the control-plane
resource group in that subscription. So read the current state before confirming, and always run this
check before planning any change to an existing installation - including a request to stop forwarding
from one subscription. Its result decides what may follow; in particular, if the subscription to drop
hosts the control plane, the result is a stop (see below), not a reinstall. This block is
read-only on Azure. It writes the current tag filters and PII rules to two local files, which Phase 3
passes back to the installer, and never prints the API key. It also checks where an existing
installation sends logs: the installer's update path keeps the stored Datadog site and API key and
ignores `--datadog-site` / `--datadog-api-key`, so newly added subscriptions would forward to the old
destination. Substitute the site confirmed in Phase 0, the control-plane subscription and the
comma-separated monitored subscriptions:

```bash
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.
: "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
export DD_API_KEY
python3 - '<CONTROL_PLANE_SUBSCRIPTION_ID>' '<SUB_ID_1,SUB_ID_2,...>' <<'EOF'
import json, os, subprocess, sys

def az(*args):
    try:
        out = subprocess.run(["az", *args, "-o", "json"], check=True, capture_output=True, text=True).stdout
    except subprocess.CalledProcessError as err:
        sys.exit(f"az {args[0]} {args[1]} failed: {err.stderr.strip()}")
    return json.loads(out or "null") or []

control_sub, new_subs = sys.argv[1], [s.strip() for s in sys.argv[2].split(",") if s.strip()]
found = []
for sub in dict.fromkeys([control_sub, *new_subs]):
    for rtype in ("Microsoft.App/jobs", "Microsoft.Web/sites"):
        for res in az("resource", "list", "--subscription", sub, "--resource-type", rtype,
                      "--query", "[?starts_with(name, 'resources-task-')].{name:name, rg:resourceGroup, location:location}"):
            found.append((sub, res["rg"], res["location"], res["name"].rsplit("-", 1)[-1], rtype))

def env(sub, rg, name, rtype):
    if rtype == "Microsoft.App/jobs":
        items = az("containerapp", "job", "show", "--subscription", sub, "-g", rg, "-n", name,
                   "--query", "properties.template.containers[].env[]")
    else:
        items = az("functionapp", "config", "appsettings", "list", "--subscription", sub, "-g", rg, "-n", name)
    return {item["name"]: item.get("value") or "" for item in items}

if not found:
    tags, pii = "", ""
    print("No existing installation: this is a fresh install.")
elif len(found) > 1:
    sys.exit("MULTIPLE existing installations found - the installer refuses to run; remove the extras first.")
else:
    sub, rg, location, cp_id, rtype = found[0]
    resources_env = env(sub, rg, "resources-task-" + cp_id, rtype)
    scaling_env = env(sub, rg, "scaling-task-" + cp_id, rtype)
    raw = resources_env.get("MONITORED_SUBSCRIPTIONS", "")
    try:
        current = json.loads(raw) if raw else []
    except json.JSONDecodeError:
        current = [s.strip() for s in raw.split(",") if s.strip()]
    tags, pii = resources_env.get("RESOURCE_TAG_FILTERS", ""), scaling_env.get("PII_SCRUBBER_RULES", "")
    print("EXISTING installation - this run will UPDATE it.")
    print(f"control plane: subscription {sub}, resource group {rg}, region {location}")
    print("currently monitored:", ", ".join(current) or "none")
    case_only = [s for s in new_subs if s not in current and s.lower() in (c.lower() for c in current)]
    if case_only:
        sys.exit("STOP: these IDs differ from the monitored ones only by letter case: " + ", ".join(case_only)
                 + ". The installer compares IDs case-sensitively and would treat each as both added and removed "
                 "(revoking its permissions and deleting its forwarder resource group). Use the exact spelling "
                 "shown under 'currently monitored'.")
    existing_site = resources_env.get("DD_SITE") or "datadoghq.com"
    existing_key = resources_env.get("DD_API_KEY", "")
    if existing_key.startswith("@Microsoft.KeyVault"):
        existing_key = ""
    print("existing Datadog site:", existing_site)
    if existing_site != os.environ["DD_SITE"]:
        sys.exit(f"STOP: the existing installation forwards to {existing_site}, not {os.environ['DD_SITE']}. "
                 "The installer's update does not change the destination; moving it is a separate migration.")
    print("existing API key:", "not readable" if not existing_key
          else "matches DD_API_KEY" if existing_key == os.environ["DD_API_KEY"] else "DIFFERS from DD_API_KEY")
    print("ADDED by this run:", ", ".join(s for s in new_subs if s not in current) or "none")
    removed = [s for s in current if s not in new_subs]
    print(f"REMOVED by this run (permissions revoked, resource group '{rg}' deleted there):",
          ", ".join(removed) or "none")
    if sub.lower() in (s.lower() for s in removed):
        sys.exit(f"STOP: the removal set includes the control-plane subscription {sub}. The installer "
                 f"would delete resource group '{rg}' there, which is the control plane itself.")
    print("current resource tag filters:", tags or "none")
    print("current PII scrubber rules:", "\n" + pii if pii else "none")
with open("lfo-resource-tag-filters.txt", "w") as f:
    f.write(tags)
with open("lfo-pii-scrubber-rules.yaml", "w") as f:
    f.write(pii)
EOF
```

- **Fresh install** - continue. Both files are empty unless the user asked for filters above; if so,
  write the agreed tag filters to `lfo-resource-tag-filters.txt` and the PII YAML to
  `lfo-pii-scrubber-rules.yaml`.
- **`existing API key: DIFFERS from DD_API_KEY`** - stop. The update keeps the stored key, so new
  subscriptions would forward with it, possibly to another Datadog organization. Tell the user; changing
  the destination is a separate migration (uninstall and reinstall), not an installer update.
- **`existing API key: not readable`** (Container App Job installs store it as a secret reference) -
  the destination org cannot be confirmed from Azure. Run the Phase 4 log search for the **currently
  monitored** subscriptions now: `forwarder:lfo` logs arriving in this org confirm the destination. If
  none arrive, stop and ask the user to confirm in Datadog which organization this installation
  forwards to before approving the update; do not proceed on an unverified destination.
- **`STOP: these IDs differ ... only by letter case`** - rewrite the monitored list with the exact
  spelling printed under `currently monitored` and re-run this check.
- **Existing installation** (site matches, key matches or destination confirmed) - the installer only updates in place when the control-plane subscription,
  resource group and region match the ones printed, so use those in Phase 3. The two files now hold the
  current tag filters and PII rules; leave them as they are unless the user explicitly asks to change
  them, and then edit the files. Never empty them to "use the defaults" - that removes the user's
  existing filtering and PII redaction. If anything shows under `REMOVED`, make sure the user wants
  those subscriptions dropped.
- **`STOP: the removal set includes the control-plane subscription`** - stop, and tell the user the resource
  group the check printed in that line, since that is what would be deleted. The installer deletes the
  resource group named like the control plane in every removed subscription, and in the control-plane
  subscription that resource group is the control plane, so removing it would break forwarding for
  every remaining subscription. It cannot be removed through an installer update, even with the user's
  approval. Keep it in the monitored list and stop here: write no installer command, delete nothing by
  hand, and do not move the control plane. Offer a resource tag filter (all subscriptions stay monitored;
  only resources matching the filter forward) as the way to cut its logs, and ask which tags to use.
  Uninstalling the whole installation is the only other option; mention it, but never draft it unless
  the user separately chooses it - it stops forwarding for every subscription.
- **`MULTIPLE` or an `az` failure** - stop and surface the message.

## Phase 3: Run the Installer

**Confirm before deploying.** The installer creates billable infrastructure and grants permissions
across subscriptions, so show the user exactly what will change and wait for an explicit "yes" before
running it:

- Control plane: region, subscription and resource group (`CONTROL_PLANE_RG`), holding the task
  Function Apps and the scheduled Container App deployer.
- Monitored subscriptions: every ID that will be passed to `--monitored-subscriptions`.
- Permissions: a custom role and role assignments for the control plane in the control-plane
  subscription and in every monitored subscription, plus forwarder resource groups created in them.
- Filters: the resource tag filters and PII scrubber rules from the two files, or "none - all logs
  forwarded". On an update, say whether they are unchanged or what changes.
- On an update: the subscriptions being added, and every subscription being **removed** - its role
  assignments are revoked and its resource group with the control-plane name is deleted.
- Cost: the control plane and the forwarders it provisions run continuously and incur Azure cost
  (compute, storage).

Do not run the block below until the user approves this summary.

Download the script and run it with the gathered values. Reference `$DD_API_KEY` directly rather
than pasting the literal key, so it stays out of shell history. The installer has no environment
variable or file option for the key: `--datadog-api-key` is a required argument, and the installer
itself passes the key on to the `az` commands it runs. So the key is visible in the local process list
(`ps`) to other users of this machine while the installer runs. Run it on a machine you trust, and
tell the user about this exposure before running it. The installer can also echo those `az` commands
in its error messages, including the Datadog key and the storage account key it passes to
`az storage container create --account-key`. So the block below pipes all installer output through a
filter that masks the Datadog key, `--account-key` / `--sas-token` / `--connection-string` values, and
`AccountKey=`, `SharedAccessSignature=` and `sig=` values before they reach the transcript, and still
exits with the installer's status. It needs bash (for `pipefail`):

```bash
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)}"
case $DD_SITE in
  datadoghq.com|datadoghq.eu|us3.datadoghq.com|us5.datadoghq.com|ap1.datadoghq.com|ap2.datadoghq.com|uk1.datadoghq.com|ddog-gov.com) ;;
  *) echo "the installer does not support DD_SITE=$DD_SITE - stop"; exit 1 ;;
esac
[ -f lfo-resource-tag-filters.txt ] && [ -f lfo-pii-scrubber-rules.yaml ] \
  || { echo "run the existing-installation check in Phase 2 first"; exit 1; }
# Pinned to release azure-logging_install-v0.1.19 (commit bb854e5) and its SHA-256, so a later push to
# main cannot change what runs with the API key. To upgrade, update both values from a newer release.
INSTALLER_COMMIT=bb854e540eeae6b1f90fb9aef4273e72f37053ec
INSTALLER_SHA256=e0bbe311123caa879be24a7bfe6e76c90984324c8af50e526d91449dc4ad09b5
installer_dir=$(mktemp -d)
curl -fsS --proto '=https' -o "$installer_dir/azure_logging_install.pyz" \
  "https://raw.githubusercontent.com/DataDog/integrations-management/${INSTALLER_COMMIT}/azure/logging_install/dist/azure_logging_install.pyz" \
  || { echo "installer download FAILED - do not run anything"; exit 1; }
python3 -c 'import hashlib, sys; sys.exit(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest() != sys.argv[2])' \
  "$installer_dir/azure_logging_install.pyz" "$INSTALLER_SHA256" \
  || { echo "installer checksum MISMATCH - do not run it"; exit 1; }

[ -n "${BASH_VERSION:-}" ] || { echo "run this block with bash"; exit 1; }
set -o pipefail
python3 "$installer_dir/azure_logging_install.pyz" \
  --control-plane-region "<CONTROL_PLANE_REGION>" \
  --control-plane-subscription "<CONTROL_PLANE_SUBSCRIPTION_ID>" \
  --control-plane-resource-group "<CONTROL_PLANE_RG>" \
  --monitored-subscriptions "<SUB_ID_1,SUB_ID_2,...>" \
  --resource-tag-filters "$(cat lfo-resource-tag-filters.txt)" \
  --pii-scrubber-rules "$(cat lfo-pii-scrubber-rules.yaml)" \
  --datadog-api-key "$DD_API_KEY" \
  --datadog-site "$DD_SITE" 2>&1 \
  | DD_API_KEY="$DD_API_KEY" python3 -c 'import os, re, sys
key = os.environ["DD_API_KEY"]
flags = re.compile(r"(--(?:account-key|sas-token|connection-string)[\x27\"]?[=, ]+)(\x27[^\x27]*\x27|\"[^\"]*\"|\S+)")
pairs = re.compile(r"((?:AccountKey|SharedAccessSignature|sig)=)[^;&\s\x27\"]+", re.I)
for line in sys.stdin:
    line = pairs.sub(r"\1***REDACTED***", flags.sub(r"\1***REDACTED***", line.replace(key, "***REDACTED***")))
    sys.stdout.write(line); sys.stdout.flush()' \
  || { echo "installer FAILED - surface the (redacted) error above; do not verify or report success"; exit 1; }
```

The tag filters and PII rules always come from the two files written in Phase 2, so an update keeps
the current values unless the user changed the files.

The script validates the API key against the chosen site, deploys the control plane (the task
Function Apps plus the scheduled Container App deployer), grants the required permissions across the
monitored subscriptions, and triggers an initial discovery run. It runs for several minutes - let it
complete and surface any (redacted) error it prints rather than retrying blindly. Re-running the script against
the same control plane **updates** the existing install rather than creating a duplicate.

## Phase 4: Verify

The control plane discovers resources and stands up forwarders on a schedule, so the first logs can
take 10-15 minutes to appear. After a few minutes, check for logs sent by the LFO forwarders, **one
subscription at a time**. Every LFO forwarder tags its logs `forwarder:lfo` and `subscription_id:<id>`,
so each search excludes other Azure log sources and other subscriptions. List every monitored
subscription:

```bash
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)}"
sleep 10
for sub in '<SUB_ID_1>' '<SUB_ID_2>'; do
  query=$(python3 -c 'import json, sys; print(json.dumps({"filter": {"query": "forwarder:lfo subscription_id:" + sys.argv[1], "from": "now-15m", "to": "now"}, "page": {"limit": 1}}))' "$sub")
  resp=$(printf 'DD-API-KEY: %s\nDD-APPLICATION-KEY: %s\n' "$DD_API_KEY" "$DD_APP_KEY" |
  curl -H @- -sS -w '\n%{http_code}' -X POST "https://api.${DD_SITE}/api/v2/logs/events/search" \
    -H "Content-Type: application/json" -d "$query") \
    || { echo "request failed (network or TLS) - this is not 'no logs yet'"; exit 1; }
  code=$(printf '%s' "$resp" | tail -1); body=$(printf '%s' "$resp" | sed '$d')
  [ "$code" = "200" ] || { echo "log search failed with HTTP $code - an API or auth error, not 'no logs yet':"; printf '%s\n' "$body"; exit 1; }
  printf '%s' "$body" | python3 -c 'import json, sys; print(sys.argv[1] + ": " + ("logs received" if json.load(sys.stdin).get("data") else "no logs yet"))' "$sub"
done
```

Report the result per subscription. Those with `logs received` are forwarding; tell the user the
others are not verified yet - the forwarders are still being provisioned (the deployer runs on a
schedule) and logs should appear shortly, so re-run the block later rather than reporting them as
working. A failed request or a non-`200` is an error to surface, not a provisioning delay. If an LFO
already forwarded these subscriptions before this run (the installer reported an update), events here
may come from that earlier install and do not prove this run changed anything.

Provide links (construct them as `<APP_BASE>/...`, using the App URL resolved in Phase 0):

- **Logs Explorer**: `<APP_BASE>/logs` - search and filter your Azure logs.
- **Log Pipelines**: `<APP_BASE>/logs/pipelines` - parse and enrich logs.
- **Log Monitors**: `<APP_BASE>/monitors/create` - alert on log patterns, counts, or anomalies.

## Important Notes

- The control plane (Function Apps + a Container App deployer) and the forwarders it provisions run
  continuously in the user's subscriptions and incur Azure cost (compute, storage).
- The API key is expanded from the `$DD_API_KEY` env var loaded in Phase 0 into the installer's
  `--datadog-api-key` argument, so it stays out of shell history and isn't written to any local file
  or Terraform state. It is on the command line of the installer, and of the `az` commands the
  installer runs, for as long as they run: the installer has no environment variable or file option.
  The LFO stores it server-side in Azure (app settings / container secrets) as part of the product.
  Installer output is filtered so the key does not reach the transcript if an `az` command fails.
- Forwarders are created automatically per monitored resource group by the control plane; you don't
  configure diagnostic settings by hand.
- To uninstall, delete the control-plane resource group and the per-subscription forwarder resource
  groups. See `DataDog/integrations-management` (`azure/logging_install`) for teardown details.
- This skill installs log forwarding only; it does not set up the metrics integration (that's
  `dd-azure-integration`, the prerequisite).
