Deploy an OpenTelemetry Collector on Azure Container Apps with Application Insights

OTel-Collector-Azure-App

A minimal, scalable, working OTLP ingestion endpoint on Azure, using only three resources.

Your services emit OpenTelemetry. You want that telemetry in Application Insights, without every application carrying a connection string and without operating Jaeger, Tempo, or Prometheus alongside it. This walks through the full deployment: Log Analytics Workspace, workspace-based Application Insights, an OpenTelemetry Collector as a Container App β€” with the Azure CLI commands for each step and a test trace at the end.

What we deploy

ComponentPurpose
Log Analytics WorkspaceStorage backend for all telemetry
Application Insights (workspace-based)Ingestion endpoint + trace/dependency UI
Container AppOTel Collector, receives OTLP and exports to App Insights
Container App SecretHolds the collector config

Why this is enough

  • The collector is stateless. It receives, batches, forwards. No volumes, no database, no leader election. A single scalable container image with one config file.
  • Application Insights already is the backend. Ingestion, storage, retention, and the trace UI come with the workspace.
  • Workspace-based App Insights means one storage layer. Traces, metrics, and logs land in the same Log Analytics Workspace and are queryable with a single KQL query.
  • The config is a secret, not an image layer. Changing the pipeline means updating a secret and restarting a revision β€” no rebuild, no registry push.
  • Container Apps scales on HTTP load without a cluster to maintain.

1. Log Analytics Workspace and Application Insights

For a reproducible setup we use the Azure CLI in bash throughout: Set the variables once:

RG="rg-observability"
LOCATION="germanywestcentral"
WORKSPACE="law-observability"
APPINSIGHTS="appi-observability"

Resource group and workspace:

az group create \
  --name "$RG" \
  --location "$LOCATION"

az monitor log-analytics workspace create \
  --resource-group "$RG" \
  --workspace-name "$WORKSPACE" \
  --location "$LOCATION" \
  --retention-time 30

Application Insights, bound to the workspace:

WORKSPACE_ID=$(az monitor log-analytics workspace show \
  --resource-group "$RG" \
  --workspace-name "$WORKSPACE" \
  --query id -o tsv)

az monitor app-insights component create \
  --resource-group "$RG" \
  --app "$APPINSIGHTS" \
  --location "$LOCATION" \
  --workspace "$WORKSPACE_ID" \
  --application-type web

--workspace is what makes this workspace-based. Without it you get a classic instance with its own separate storage.

Read the connection string β€” the collector needs it:

CONNECTION_STRING=$(az monitor app-insights component show \
  --resource-group "$RG" \
  --app "$APPINSIGHTS" \
  --query connectionString -o tsv)

echo "$CONNECTION_STRING"

Output looks like this:

InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://germanywestcentral-1.in.applicationinsights.azure.com/;LiveEndpoint=...

The ingestion endpoint is region-bound. Never copy a connection string between regions. Save this.


2. Container Apps Environment and Collector App

The app is deployed first with the image's default config. The real config follows in step 4 as a secret.

ENVIRONMENT="cae-observability"
APP="ca-otel-collector"
IMAGE="otel/opentelemetry-collector-contrib:latest"

Install the extension and register the provider (once per subscription):

az extension add --name containerapp --upgrade
az provider register --namespace Microsoft.App

Environment, wired to the same workspace so container logs land there too:

WORKSPACE_CUSTOMER_ID=$(az monitor log-analytics workspace show \
  --resource-group "$RG" \
  --workspace-name "$WORKSPACE" \
  --query customerId -o tsv)

WORKSPACE_KEY=$(az monitor log-analytics workspace get-shared-keys \
  --resource-group "$RG" \
  --workspace-name "$WORKSPACE" \
  --query primarySharedKey -o tsv)

az containerapp env create \
  --resource-group "$RG" \
  --name "$ENVIRONMENT" \
  --location "$LOCATION" \
  --logs-workspace-id "$WORKSPACE_CUSTOMER_ID" \
  --logs-workspace-key "$WORKSPACE_KEY"

The collector app:

az containerapp create \
  --resource-group "$RG" \
  --name "$APP" \
  --environment "$ENVIRONMENT" \
  --image "$IMAGE" \
  --target-port 4318 \
  --ingress internal \
  --transport http \
  --min-replicas 1 \
  --max-replicas 3 \
  --cpu 0.5 \
  --memory 1.0Gi

Key choices:

FlagWhy
--target-port 4318OTLP/HTTP. Container Apps exposes exactly one port.
--ingress internalReachable only inside the environment/VNet. Use external only with IP restrictions in place.
--min-replicas 1Prevents scale-to-zero. A cold collector drops the spans that wake it.

On gRPC (4317): Container Apps ingress exposes a single port, so you get either OTLP/HTTP or OTLP/gRPC per app β€” not both. HTTP is the safer default; it works through APIM and any proxy. If you need gRPC, set --transport http2 and --target-port 4317, or deploy a second app.

Check the FQDN β€” that's your OTLP endpoint:

az containerapp show \
  --resource-group "$RG" \
  --name "$APP" \
  --query properties.configuration.ingress.fqdn -o tsv

Senders point at https://<fqdn>/v1/traces, /v1/metrics, /v1/logs.


3. The collector config

collector-config.yaml

receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 10s
    send_batch_size: 1024
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 20

exporters:
  azuremonitor:
    connection_string: ${env:APPLICATIONINSIGHTS_CONNECTION_STRING}

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [azuremonitor]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [azuremonitor]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [azuremonitor]

Save this as collector-config.yaml

Section by section

SectionWhat it does
receiversWhere telemetry comes in. 0.0.0.0 is required β€” binding to localhost makes the container unreachable.
processorsTransform between receive and export. Order matters.
exportersWhere telemetry goes out.
serviceWires the three together. A component not referenced here is not loaded.

Processor order

Processors run top to bottom as listed in the pipeline, not as declared above.

  1. memory_limiter first β€” it can only protect the collector if it sees data before anything buffers it.
  2. batch last β€” always the final processor.

Details worth knowing

  • memory_limiter applies backpressure when the container approaches its memory limit. Without it, a traffic spike OOM-kills the container and you lose everything in flight. Mandatory in production.
  • ${env:...} resolves at startup. The connection string stays out of the config and out of any image layer.
  • Four top-level keys only: receivers, processors, exporters, service. Each appears exactly once. A duplicate key gives you mapping key already defined.
  • No debug exporter here. It writes every item to stdout. See Activate debug mode at the end.

4. Store the config and connection string as a secret

The config never contains the connection string. It references an environment variable, which we fill from a second secret in the next step.

${env:APPLICATIONINSIGHTS_CONNECTION_STRING} is resolved by the collector at startup. The config stays identical across dev, test, and production β€” only the env variable changes.

Choose one: Portal

Multiline YAML through the shell is where this step usually breaks: quoting, escaping, newlines collapsing into one line. Use the portal.

  1. Container App β†’ Settings β†’ Secrets
  2. Add, name collector-config, paste the YAML from step 3
  3. Add, name appinsights-connection-string, paste the connection string from step 1 (In Terminal echo $CONNECTION_STRING)
  4. Save

Pasting into a textarea keeps the YAML intact. No escaping.

Choose one: CLI

az containerapp secret set \
  --resource-group "$RG" \
  --name "$APP" \
  --secrets \
    "collector-config=$(cat collector-config.yaml)" \
    "appinsights-connection-string=$CONNECTION_STRING"

The double quotes around $(cat ...) preserve the newlines. Without them the YAML becomes one line and the collector won't start.

az containerapp secret list \
  --resource-group "$RG" \
  --name "$APP" \
  -o table

Notes

  • Names must be lowercase alphanumeric with dashes. collector-config works, collectorConfig does not.
  • Values are write-only. secret list returns names only, the portal hides values after saving. Keep collector-config.yaml in Git β€” the secret is a deployment artifact, not the source of truth.
  • Secrets are encrypted at rest and scoped to the app.

No Key Vault here. It adds a resource, a managed identity, and an RBAC assignment β€” three moving parts for two values that are already encrypted and app-scoped. Worth it once multiple apps share secrets or you need enforced rotation. Not for this.


5. Mount the config and set the env variable

Two different mechanisms, one for each secret:

SecretMechanismWhy
collector-configVolume mount β†’ fileThe collector reads config from a file path
appinsights-connection-stringEnvironment variableReferenced as ${env:...} inside the config

Full app definition

Secret volumes aren't exposed as CLI flags, so this step goes through an inline YAML document.

az containerapp update \
  --resource-group "$RG" \
  --name "$APP" \
  --yaml <(cat <<EOF
properties:
  template:
    containers:
      - name: otel-collector
        image: otel/opentelemetry-collector-contrib:latest
        args: ["--config=/etc/collector/config.yaml"]
        env:
          - name: APPLICATIONINSIGHTS_CONNECTION_STRING
            secretRef: appinsights-connection-string
        volumeMounts:
          - volumeName: config-volume
            mountPath: /etc/collector
    volumes:
      - name: config-volume
        storageType: Secret
        secrets:
          - secretRef: collector-config
            path: config.yaml
EOF
)

How it fits together

Secret "collector-config"              β†’ file  /etc/collector/config.yaml
Secret "appinsights-connection-string" β†’ env   APPLICATIONINSIGHTS_CONNECTION_STRING
                                                        ↓
                              config.yaml: ${env:APPLICATIONINSIGHTS_CONNECTION_STRING}

The collector reads the file, resolves the variable at startup, and never has the connection string written down anywhere.

Details that matter

  • path: config.yaml sets the filename inside the mount. Without it the file is named after the secret (collector-config) and --config won't find it.
  • args overrides the image default. The contrib image looks for /etc/otelcol-contrib/config.yaml. Mounting to a separate path and passing --config explicitly avoids shadowing the image's own directory.
  • secretRef in env injects the value without exposing it in the revision spec.
  • Every update creates a new revision. Secret changes alone don't restart running containers β€” you need a revision restart:
az containerapp revision restart \
  --resource-group "$RG" \
  --name "$APP" \
  --revision $(az containerapp revision list \
    --resource-group "$RG" --name "$APP" \
    --query "[0].name" -o tsv)

Verify

az containerapp logs show \
  --resource-group "$RG" \
  --name "$APP" \
  --follow

A healthy start ends with Everything is ready. Begin running and processing data.

Common failures:

Log lineCause
no such file or directorypath: missing in the volume definition
mapping key already definedDuplicate top-level key in the YAML
references exporter "x" which is not configuredPipeline references an undeclared component
expanding ${env:...}, variable not setEnv variable name doesn't match the config

6. Send a test trace from CLI

Reachability first: with --ingress internal the endpoint only resolves inside the Container Apps environment. For a quick test from the portal cli, switch it to external temporarily:

az containerapp ingress update \
  --resource-group "$RG" --name "$APP" --type external

FQDN=$(az containerapp show \
  --resource-group "$RG" --name "$APP" \
  --query properties.configuration.ingress.fqdn -o tsv)

echo "$FQDN"

Switch it back to internal when you're done.

The script

TRACE_ID=$(openssl rand -hex 16)
SPAN_ID=$(openssl rand -hex 8)
NOW=$(($(date +%s) * 1000000000))
END=$((NOW + 250000000))

curl -i -X POST "https://$FQDN/v1/traces" \
  -H "Content-Type: application/json" \
  -d '{
  "resourceSpans": [{
    "resource": {
      "attributes": [{
        "key": "service.name",
        "value": { "stringValue": "cli-test" }
      }]
    },
    "scopeSpans": [{
      "spans": [{
        "traceId": "'"$TRACE_ID"'",
        "spanId": "'"$SPAN_ID"'",
        "name": "manual-test-span",
        "kind": 2,
        "startTimeUnixNano": "'"$NOW"'",
        "endTimeUnixNano": "'"$END"'"
      }]
    }]
  }]
}'

HTTP/1.1 200 OK with body {} means accepted.

Payload notes

FieldRequirement
traceId32 hex characters. Wrong length is rejected.
spanId16 hex characters.
*TimeUnixNanoNanoseconds, as a string. JSON numbers overflow at this magnitude.
kind2 = SERVER, appears as a request in Application Insights.
service.nameBecomes cloud_RoleName. Without it the span is hard to find.

Verify in Application Insights Logs

Ingestion takes 1–3 minutes:

requests
| where cloud_RoleName == "cli-test"
| order by timestamp desc

Or Application Insights β†’ Transaction search.

If nothing arrives

CheckHow
Did the collector accept it?az containerapp logs show --follow in a second Cloud Shell tab during the call
Is the connection string set?Look for azuremonitor errors in the collector logs
Right region?Ingestion endpoint must match the App Insights region
Waited long enough?Give it 5 minutes before concluding it failed

Close the endpoint again

Once the span shows up, put the collector back behind internal ingress:

az containerapp ingress update \
  --resource-group "$RG" --name "$APP" --type internal

Verify it's no longer public:

az containerapp show \
  --resource-group "$RG" --name "$APP" \
  --query properties.configuration.ingress.external -o tsv

false is what you want. An OTLP receiver on a public endpoint accepts telemetry from anyone who finds it β€” that's both a billing problem and a data integrity problem. Don't leave it open or at least restrict its IP range.

7. Troubleshooting: debug exporter

When telemetry doesn't arrive and you need to know whether it even reaches the collector, add the debug exporter and put it next to azuremonitor in the pipeline you're investigating:

Updating the secret does not restart running containers. Restart the revision afterwards β€” see step 5.

exporters:
  debug:
    verbosity: detailed      # basic | normal | detailed
    sampling_initial: 5      # first 5 records per second are printed
    sampling_thereafter: 200 # after that, every 200th
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [azuremonitor, debug]