# Azure Container Apps

> Send traces, logs, and metrics from Azure Container Apps to Last9 using the managed OpenTelemetry agent — with step-by-step setup for Node.js, Java, Next.js, and React.

Source: https://last9.io/docs/integrations/containers-and-k8s/azure-container-apps/

Use the built-in OpenTelemetry agent in Azure Container Apps (ACA) to route traces, logs, and metrics from your applications to Last9 — without running a separate collector.

ACA's managed OTel agent acts as a gRPC collector inside your environment. Once configured, it automatically injects the collector endpoint into every container app, so your applications only need the OTel SDK and a one-line startup change.

:::caution[Stdout logs do not flow automatically]
Adding a Last9 OTLP endpoint to ACA does **not** forward `console.log` or `System.out.println` output. ACA's managed OTel agent is a routing layer — it only forwards data that apps explicitly export via the OTel SDK over gRPC. It does not read container stdout/stderr.

This is [documented by Microsoft](https://learn.microsoft.com/en-us/azure/container-apps/opentelemetry-agents):

> _"Do I need to reference the OpenTelemetry SDK in my code? Yes. The SDK creates telemetry data, and the managed agent is only responsible to route data."_ > _"System data, such as system logs or Container Apps standard metrics, isn't available to be sent to the OpenTelemetry agent."_

**The fix is app instrumentation, not Last9 configuration.** See [Step 2](#step-2-instrument-your-app) below for each runtime.
:::

---

## Prerequisites

- Azure Container Apps environment (any region)
- Last9 account — [get OTLP credentials from the integrations page](https://app.last9.io/integrations?integration=OpenTelemetry)
- Azure CLI with `containerapp` extension installed

---

## Step 1: Configure the ACA Environment

This is a **one-time** setup per ACA environment. It tells the managed OTel agent where to forward telemetry.

Get your gRPC endpoint and credentials from [app.last9.io → Integrations → OpenTelemetry](https://app.last9.io/integrations?integration=OpenTelemetry). Use the **gRPC endpoint** (format: `host:port`, no `https://` prefix).

1. **Add Last9 as an OTLP destination**

   ```bash
   az containerapp env telemetry otlp add \
     --name <your-env-name> \
     --resource-group <your-resource-group> \
     --otlp-name last9 \
     --endpoint "<grpc-endpoint-from-last9>"  \
     --insecure false \
     --headers "Authorization={{ .Logs.AuthValue }}" \
     --enable-open-telemetry-traces true \
     --enable-open-telemetry-metrics true \
     --enable-open-telemetry-logs true
   ```

   :::note
   The `--endpoint` value must be the **gRPC** format from the Last9 integrations page — `host:port` with no `https://` prefix (e.g. `otlp-aps1.last9.io:443`). Using the HTTP URL here causes a silent connection failure.
   :::

   Three fields that commonly cause failures:

   | Field        | ❌ Wrong                         | ✅ Correct                     |
   | ------------ | -------------------------------- | ------------------------------ |
   | `--endpoint` | `https://otlp-aps1.last9.io:443` | `otlp-aps1.last9.io:443`       |
   | `--insecure` | `true` (the default)             | `false`                        |
   | `--headers`  | `username=X password=Y`          | `Authorization=Basic <base64>` |

2. **Verify the configuration**

   After the command runs, ACA automatically injects the following into every container app in the environment:

   ```
   OTEL_EXPORTER_OTLP_ENDPOINT=http://k8se-otel.k8se-apps.svc.cluster.local:4317
   OTEL_EXPORTER_OTLP_PROTOCOL=grpc
   OTEL_RESOURCE_ATTRIBUTES=<ACA container and environment metadata>
   ```

   :::caution
   Do **not** set `OTEL_EXPORTER_OTLP_ENDPOINT` or `OTEL_EXPORTER_OTLP_PROTOCOL` in your app's environment variables — ACA injects these automatically. Overriding them breaks the routing pipeline.
   :::

---

## Step 2: Instrument Your App

**Node.js**

1. **Install packages**

   ```bash
   npm install \
     @opentelemetry/api@1.9.0 \
     @opentelemetry/auto-instrumentations-node@0.59.0 \
     @opentelemetry/exporter-trace-otlp-grpc@0.201.1 \
     @opentelemetry/exporter-trace-otlp-http@0.201.1 \
     @opentelemetry/instrumentation@0.201.1 \
     @opentelemetry/resources@2.0.1 \
     @opentelemetry/sdk-node@0.201.1 \
     @opentelemetry/sdk-trace-base@2.0.1 \
     @opentelemetry/sdk-trace-node@2.0.1 \
     @opentelemetry/semantic-conventions@1.34.0
   ```

2. **Change the startup command in your Dockerfile**

   ```dockerfile
   CMD ["node", "--require", "@opentelemetry/auto-instrumentations-node/register", "server.js"]
   ```

   This single flag auto-instruments HTTP, database calls, and popular logging libraries (Winston, Pino, Bunyan) without code changes.

3. **Set environment variables on the container app**

   ```
   OTEL_SERVICE_NAME=your-service-name
   OTEL_TRACES_EXPORTER=otlp
   OTEL_METRICS_EXPORTER=otlp
   OTEL_LOGS_EXPORTER=otlp
   OTEL_TRACES_SAMPLER=always_on
   ```

   :::note
   Do not set `OTEL_EXPORTER_OTLP_ENDPOINT` or `OTEL_EXPORTER_OTLP_PROTOCOL` — ACA injects these.
   :::

**Logging library support:**

| Library       | Setup                                           | Severity in Last9         |
| ------------- | ----------------------------------------------- | ------------------------- |
| Winston ≥ 3.x | Zero changes — auto-bridged by `--require` flag | ✅ Full (INFO/WARN/ERROR) |
| Pino          | Zero changes — auto-bridged                     | ✅ Full                   |
| Bunyan        | Zero changes — auto-bridged                     | ✅ Full                   |
| `console.log` | Add console shim (see below)                    | ❌ Body only, no severity |

**`console.log` shim** — add at the very top of your entry file if you are not using a logging library:

```js
const { logs, SeverityNumber } = require("@opentelemetry/api-logs");

function _emit(body, severityNumber) {
  logs.getLogger("console-bridge").emit({ body, severityNumber });
}
const _log = console.log.bind(console),
  _info = console.info.bind(console),
  _warn = console.warn.bind(console),
  _error = console.error.bind(console);

console.log = (...a) => {
  _log(...a);
  _emit(a.map(String).join(" "), SeverityNumber.INFO);
};
console.info = (...a) => {
  _info(...a);
  _emit(a.map(String).join(" "), SeverityNumber.INFO);
};
console.warn = (...a) => {
  _warn(...a);
  _emit(a.map(String).join(" "), SeverityNumber.WARN);
};
console.error = (...a) => {
  _error(...a);
  _emit(a.map(String).join(" "), SeverityNumber.ERROR);
};
```

:::note
`console.log` shim forwards log body to Last9 but without a severity label. Use Winston, Pino, or Bunyan if severity filtering matters.
:::

**Java**

1. **Download the OTel Java agent**

   ```bash
   # Latest release — supports Java 8 and above
   curl -L https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar \
     -o opentelemetry-javaagent.jar

   # Or pin a specific version
   curl -L https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.30.0/opentelemetry-javaagent.jar \
     -o opentelemetry-javaagent.jar
   ```

2. **Add the agent to your Dockerfile**

   ```dockerfile
   CMD ["java", "-javaagent:/app/opentelemetry-javaagent.jar", "-jar", "app.jar"]
   ```

   Alternatively, use the `JAVA_TOOL_OPTIONS` environment variable on the container app — no Dockerfile change needed:

   ```
   JAVA_TOOL_OPTIONS=-javaagent:/app/opentelemetry-javaagent.jar
   ```

3. **Set environment variables on the container app**

   ```
   OTEL_SERVICE_NAME=your-service-name
   OTEL_TRACES_EXPORTER=otlp
   OTEL_METRICS_EXPORTER=otlp
   OTEL_LOGS_EXPORTER=otlp
   OTEL_TRACES_SAMPLER=always_on
   OTEL_RESOURCE_PROVIDERS_AZURE_ENABLED=true
   OTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES=*
   ```

   :::caution
   Do **not** set `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, or `OTEL_EXPORTER_OTLP_HEADERS` — ACA injects all three automatically.
   :::

**Logging library support:**

| Library              | Setup                                    | Severity in Last9         |
| -------------------- | ---------------------------------------- | ------------------------- |
| log4j 1.x / 2.x      | Zero changes — auto-bridged by javaagent | ✅ Full                   |
| Logback / SLF4J      | Zero changes — auto-bridged              | ✅ Full                   |
| java.util.logging    | Zero changes — auto-bridged              | ✅ Full                   |
| `System.out.println` | Stdout redirect in `main()` (see below)  | ❌ Body only, no severity |

**`System.out.println` redirect** — if your app uses plain stdout with no logging framework, replace with SLF4J (recommended), or add this redirect in `main()`:

:::note
`System.out.println` redirect forwards log body without a severity label. Use SLF4J/Logback for severity filtering.
:::

```java
private static void redirectStdout() {
    var otelLogger = GlobalOpenTelemetry.get()
        .getLogsBridge().loggerBuilder("stdout-bridge").build();
    System.setOut(otelPrintStream(System.out, Severity.INFO, otelLogger));
    System.setErr(otelPrintStream(System.err, Severity.ERROR, otelLogger));
}

private static PrintStream otelPrintStream(PrintStream delegate,
        Severity severity, io.opentelemetry.api.logs.Logger otelLogger) {
    return new PrintStream(delegate, true) {
        private void emit(String body) {
            otelLogger.logRecordBuilder().setBody(body).setSeverity(severity).emit();
        }
        @Override public void println(String x)  { super.println(x);  emit(x != null ? x : "null"); }
        @Override public void println(Object x)  { super.println(x);  emit(String.valueOf(x)); }
        @Override public void println(int x)     { super.println(x);  emit(String.valueOf(x)); }
        @Override public void println(long x)    { super.println(x);  emit(String.valueOf(x)); }
        @Override public void println(boolean x) { super.println(x);  emit(String.valueOf(x)); }
        @Override public void println()          { super.println();   emit(""); }
    };
}
```

**Next.js (SSR)**

Next.js SSR runs Node.js on the server. Use the Node.js OTel SDK via Next.js's built-in instrumentation hook.

1. **Install packages**

   Same as Node.js, plus:

   ```bash
   npm install @opentelemetry/sdk-logs@0.201.1 \
               @opentelemetry/exporter-logs-otlp-grpc@0.201.1 \
               @opentelemetry/sdk-metrics@2.0.1 \
               @opentelemetry/exporter-metrics-otlp-grpc@0.201.1
   ```

2. **Create `instrumentation.ts` in project root**

   ```ts
   export async function register() {
     if (process.env.NEXT_RUNTIME === "nodejs") {
       const { NodeSDK } = await import("@opentelemetry/sdk-node");
       const { getNodeAutoInstrumentations } = await import(
         "@opentelemetry/auto-instrumentations-node"
       );
       const { OTLPTraceExporter } = await import(
         "@opentelemetry/exporter-trace-otlp-grpc"
       );
       const { OTLPMetricExporter } = await import(
         "@opentelemetry/exporter-metrics-otlp-grpc"
       );
       const { OTLPLogExporter } = await import(
         "@opentelemetry/exporter-logs-otlp-grpc"
       );
       const { PeriodicExportingMetricReader } = await import(
         "@opentelemetry/sdk-metrics"
       );
       const { BatchLogRecordProcessor } = await import(
         "@opentelemetry/sdk-logs"
       );

       const sdk = new NodeSDK({
         instrumentations: [getNodeAutoInstrumentations()],
         traceExporter: new OTLPTraceExporter(),
         metricReader: new PeriodicExportingMetricReader({
           exporter: new OTLPMetricExporter(),
           exportIntervalMillis: 60_000,
         }),
         logRecordProcessor: new BatchLogRecordProcessor(new OTLPLogExporter()),
       });
       sdk.start();
     }
   }
   ```

   :::note
   ACA injects `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_PROTOCOL=grpc` automatically — no endpoint configuration is needed in the SDK setup.
   :::

3. **Enable the instrumentation hook**

   ```js
   // next.config.js
   module.exports = {
     experimental: {
       instrumentationHook: true, // Next.js 13.4–14.x; remove for Next.js 15+
     },
   };
   ```

4. **Set environment variables on the container app**

   ```
   OTEL_SERVICE_NAME=your-nextjs-app
   OTEL_TRACES_EXPORTER=otlp
   OTEL_METRICS_EXPORTER=otlp
   OTEL_LOGS_EXPORTER=otlp
   OTEL_TRACES_SAMPLER=always_on
   ```

Logging library support is the same as Node.js — Winston, Pino, and Bunyan are auto-bridged; `console.log` requires the shim.

**React SPA**

Browser apps cannot reach ACA's internal gRPC collector. Use the **Last9 RUM SDK** (`L9RUM`) instead of OTel browser packages.

Get your `baseUrl` and `clientToken` from [app.last9.io → Discover → Applications → Setup](https://app.last9.io/applications).

1. **Load the SDK in `index.html`**

   ```html
   <!-- Get the current SRI hash from app.last9.io → Discover → Applications → Setup -->
   <script
     src="https://cdn.last9.io/rum-sdk/builds/stable/v2/l9.umd.js"
     integrity="sha384-<hash-from-setup-page>"
     crossorigin="anonymous"
   ></script>
   ```

2. **Initialize in your app**

   ```jsx
   // React — App.js or App.tsx
   import { useEffect } from "react";

   function App() {
     useEffect(() => {
       L9RUM.init({
         baseUrl: "https://your-base-url",
         headers: { clientToken: "your-client-token" },
         resourceAttributes: {
           serviceName: "your-react-app",
           deploymentEnvironment: process.env.NODE_ENV,
           appVersion: "1.0.0",
         },
         errors: { console: true, global: true, network: true },
       });
     }, []);
     return <YourApp />;
   }
   ```

**What the RUM SDK captures automatically:**

| Signal                                                    | Captured                    |
| --------------------------------------------------------- | --------------------------- |
| Core Web Vitals (LCP, FID, CLS)                           | ✅                          |
| Page load timing                                          | ✅                          |
| JavaScript errors (`console.error`, unhandled exceptions) | ✅                          |
| Failed network requests (fetch, XHR)                      | ✅                          |
| User interactions                                         | ✅                          |
| Backend trace correlation (W3C traceparent)               | ✅                          |
| `console.log` (non-error)                                 | ❌ Log from backend instead |

---

## Verification

After deploying, wait 2–3 minutes and check Last9:

1. **[Services](https://app.last9.io/services)** — your `OTEL_SERVICE_NAME` should appear
2. **[Traces](https://app.last9.io/traces)** — filter by service name
3. **[Logs](https://app.last9.io/logs)** — filter by `service.name` attribute
4. **Metrics** — search `http.server.request.duration` (HTTP), `process.runtime.nodejs.*` (Node.js), `jvm.*` (Java)
5. **[Applications](https://app.last9.io/applications)** — for React SPA browser monitoring

### Smoke test

Validate the ACA environment config before deploying app changes:

```bash
START_NS=$(date +%s)000000000
END_NS=$(date +%s)100000000
curl -X POST https://otlp-aps1.last9.io:443/v1/traces \
  -H "Authorization: {{ .Logs.AuthValue }}" \
  -H "Content-Type: application/json" \
  -d "{\"resourceSpans\":[{\"resource\":{\"attributes\":[{\"key\":\"service.name\",\"value\":{\"stringValue\":\"smoke-test\"}}]},\"scopeSpans\":[{\"scope\":{\"name\":\"test\"},\"spans\":[{\"traceId\":\"abcdef1234567890abcdef1234567890\",\"spanId\":\"abcdef12345678\",\"name\":\"test\",\"kind\":1,\"startTimeUnixNano\":\"$START_NS\",\"endTimeUnixNano\":\"$END_NS\",\"status\":{\"code\":1}}]}]}]}"
# Expected: {"partialSuccess":{}}
# Span appears in Last9 Traces within ~2 min
```

---

## Troubleshooting

- **No data at all**

  ACA OTel config is wrong. Check three fields: endpoint (no `https://` prefix), `--insecure false`, and the correct `Authorization` header.

- **401 errors**

  Wrong header format. Must be `Authorization=Basic <base64>`, not `username=X password=Y`.

- **Connection refused**

  `--insecure true` (the default) was used. Must pass `--insecure false`.

- **Traces only, no logs (Java)**

  Missing env var. Add `OTEL_LOGS_EXPORTER=otlp`.

- **Traces only, no logs (Node.js)**

  Using `console.log` without the shim. Add the console shim to your entry file.

- **Nothing from React SPA**

  Wrong SDK. Use the Last9 RUM SDK, not OTel browser packages.

- **Wrong service name in Last9**

  `OTEL_SERVICE_NAME` is not set. Add the env var to your container app spec.

- **Java logs missing MDC fields**

  Missing env var. Add `OTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES=*`.

Please get in touch with us on [Discord](https://discord.com/invite/Q3p2EEucx9) or [Email](mailto:support@last9.io) if you have any questions.
