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.
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.
Prerequisites
- Azure Container Apps environment (any region)
- Last9 account — get OTLP credentials from the integrations page
- Azure CLI with
containerappextension 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. Use the gRPC endpoint (format: host:port, no https:// prefix).
-
Add Last9 as an OTLP destination
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 trueThree fields that commonly cause failures:
Field ❌ Wrong ✅ Correct --endpointhttps://otlp-aps1.last9.io:443otlp-aps1.last9.io:443--insecuretrue(the default)false--headersusername=X password=YAuthorization=Basic <base64> -
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:4317OTEL_EXPORTER_OTLP_PROTOCOL=grpcOTEL_RESOURCE_ATTRIBUTES=<ACA container and environment metadata>
Step 2: Instrument Your App
-
Install packages
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 -
Change the startup command in your 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.
-
Set environment variables on the container app
OTEL_SERVICE_NAME=your-service-nameOTEL_TRACES_EXPORTER=otlpOTEL_METRICS_EXPORTER=otlpOTEL_LOGS_EXPORTER=otlpOTEL_TRACES_SAMPLER=always_on
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:
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);};-
Download the OTel Java agent
# Java 11+ — use the latest releasecurl -L https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar \-o opentelemetry-javaagent.jar# Java 8 — must use v1.32.0 specificallycurl -L https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.32.0/opentelemetry-javaagent.jar \-o opentelemetry-javaagent.jar -
Add the agent to your Dockerfile
CMD ["java", "-javaagent:/app/opentelemetry-javaagent.jar", "-jar", "app.jar"]Alternatively, use the
JAVA_TOOL_OPTIONSenvironment variable on the container app — no Dockerfile change needed:JAVA_TOOL_OPTIONS=-javaagent:/app/opentelemetry-javaagent.jar -
Set environment variables on the container app
OTEL_SERVICE_NAME=your-service-nameOTEL_TRACES_EXPORTER=otlpOTEL_METRICS_EXPORTER=otlpOTEL_LOGS_EXPORTER=otlpOTEL_TRACES_SAMPLER=always_onOTEL_RESOURCE_PROVIDERS_AZURE_ENABLED=trueOTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES=*
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():
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 runs Node.js on the server. Use the Node.js OTel SDK via Next.js’s built-in instrumentation hook.
-
Install packages
Same as Node.js, plus:
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 -
Create
instrumentation.tsin project rootexport 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();}} -
Enable the instrumentation hook
// next.config.jsmodule.exports = {experimental: {instrumentationHook: true, // Next.js 13.4–14.x; remove for Next.js 15+},}; -
Set environment variables on the container app
OTEL_SERVICE_NAME=your-nextjs-appOTEL_TRACES_EXPORTER=otlpOTEL_METRICS_EXPORTER=otlpOTEL_LOGS_EXPORTER=otlpOTEL_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.
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.
-
Load the SDK in
index.html<!-- Get the current SRI hash from app.last9.io → Discover → Applications → Setup --><scriptsrc="https://cdn.last9.io/rum-sdk/builds/stable/v2/l9.umd.js"integrity="sha384-<hash-from-setup-page>"crossorigin="anonymous"></script> -
Initialize in your app
// React — App.js or App.tsximport { 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:
- Services — your
OTEL_SERVICE_NAMEshould appear - Traces — filter by service name
- Logs — filter by
service.nameattribute - Metrics — search
http.server.request.duration(HTTP),process.runtime.nodejs.*(Node.js),jvm.*(Java) - Applications — for React SPA browser monitoring
Smoke test
Validate the ACA environment config before deploying app changes:
START_NS=$(date +%s)000000000END_NS=$(date +%s)100000000curl -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 minTroubleshooting
-
No data at all
ACA OTel config is wrong. Check three fields: endpoint (no
https://prefix),--insecure false, and the correctAuthorizationheader. -
401 errors
Wrong header format. Must be
Authorization=Basic <base64>, notusername=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.logwithout 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_NAMEis 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 or Email if you have any questions.