OpenTelemetry for Java workloads¶
A Brewlet workload is an ordinary java process in an ordinary runc sandbox, so
OpenTelemetry instrumentation
works as it does elsewhere. What differs is where the agent JAR comes from
and how the -javaagent flag reaches the JVM. A Brewlet image carries only
the application: it has no Dockerfile or base image into which to copy an
agent, and some familiar Kubernetes injection patterns do not apply.
This page describes the supported approaches, recommends a default, and lists the patterns that do not work with the current Brewlet handler.
Related: Observability & day‑2 · Resource requests, limits & JVM tuning · JDK management · Runtime metrics (Brewlet's own telemetry, which is separate from application telemetry).
Validation scope
The approaches marked as working below were checked manually on a
disposable local kind cluster. Each one exported spans with the expected
service.name to an OpenTelemetry Collector. That run used the Java agent
2.x line, Spring Boot 4 and a Temurin 21 node JDK. It is not part of the
automated end-to-end suite. Re-check these approaches against your own JDK,
agent and framework versions.
Choosing an approach¶
| Approach | Who owns the agent | Works with | Recommendation |
|---|---|---|---|
| Agent in the node JDK image | Platform team | JavaApplication, raw Pods |
Recommended for fleets with a shared agent version. |
| Agent packaged in the application image | Application team | JavaApplication, raw Pods |
Recommended when each team chooses its own agent version or extensions. |
| In-application SDK or framework starter | Application team | Any | Good for manual or framework-native instrumentation without an agent. |
| Kubernetes image volume | Platform or application team | Raw Pods and Deployments only | Works; use when you need raw Pod manifests anyway. |
hostPath volume |
Node administrator | Raw Pods and Deployments only | Works, but is not recommended. |
| Runtime self-attach | Application team | Any | Avoid. The JDK is phasing out dynamic agent loading. |
| Init container, sidecar or OpenTelemetry Operator injection | — | — | Not supported by the current handler. |
In every approach except the SDK/starter, the JVM needs the
-javaagent:<path> flag. Deliver it through
jvm.args where possible.
Keep the OpenTelemetry Collector in its own Pods, such as a Deployment or
DaemonSet that uses the normal container runtime rather than
runtimeClassName: brewlet. Point workloads at it with the standard OTEL_*
environment variables (common configuration).
Common configuration¶
The OpenTelemetry Java agent and SDK read the standard
OTEL_* environment variables.
Set them in the deployment (spec.env on a JavaApplication, or the container
env on a raw Pod), not in the artifact's launch configuration
(why).
env:
- name: OTEL_SERVICE_NAME
value: orders
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://otel-collector.observability:4318
- name: OTEL_EXPORTER_OTLP_PROTOCOL
value: http/protobuf
- name: POD_NAME
valueFrom: { fieldRef: { fieldPath: metadata.name } }
- name: POD_NAMESPACE
valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
- name: OTEL_RESOURCE_ATTRIBUTES
value: k8s.pod.name=$(POD_NAME),k8s.namespace.name=$(POD_NAMESPACE)
JavaApplication.spec.env accepts standard Kubernetes EnvVar entries, so
Downward API valueFrom references and $(VAR) expansion work.
Agent in the node JDK image¶
The platform team builds a JDK source image that also contains the agent, and
declares it as a separate JDK distribution. Brewlet copies the complete image
userland to each node and uses it as the workload's root filesystem. A file
at /opt/otel/opentelemetry-javaagent.jar in the source image therefore
appears at that path inside every workload that selects this distribution.
FROM docker.io/library/eclipse-temurin@sha256:<reviewed-temurin-21-digest>
COPY opentelemetry-javaagent.jar /opt/otel/opentelemetry-javaagent.jar
RUN chmod 0644 /opt/otel/opentelemetry-javaagent.jar
Push that image, then add it alongside the plain JDK. Give it a distinct
distribution name
(multiple JDKs of the same feature):
provisioner:
jdks:
- distribution: temurin
feature: 21
source:
image: docker.io/library/eclipse-temurin@sha256:<reviewed-temurin-21-digest>
javaHome: /opt/java/openjdk
- distribution: temurin-otel
feature: 21
source:
image: registry.example.com/platform/temurin-otel@sha256:<reviewed-digest>
javaHome: /opt/java/openjdk
Applications opt in by selecting that distribution and passing the flag:
apiVersion: apps.brewlet.sh/v1alpha1
kind: JavaApplication
metadata: { name: orders }
spec:
artifact:
image: registry.example.com/demo/orders@sha256:REPLACE_WITH_IMAGE_DIGEST
jvm:
version: 21
distribution: temurin-otel
args: ["-javaagent:/opt/otel/opentelemetry-javaagent.jar"]
env:
- { name: OTEL_SERVICE_NAME, value: orders }
- { name: OTEL_EXPORTER_OTLP_ENDPOINT, value: "http://otel-collector.observability:4318" }
On a raw Pod, use the brewlet.sh/jdk: "temurin-otel-21" annotation and the
brewlet.sh/jvm-args annotation (a JSON array of strings) instead.
Why this is a good default for a fleet:
- The application image stays JAR-only. No team has to repackage to adopt or upgrade the agent.
- The platform team governs the agent the same way as the JDK: by reviewing and pinning a digest. Agent upgrades follow the JDK patching workflow. Running pods keep their current root until they restart.
- Workloads that do not pass
-javaagentare unaffected by the extra file.
Trade-offs: the agent version is coupled to the JDK root, so every workload on
that distribution moves together. Name the distribution explicitly. A bare
version: 21 request may select either root (see
JDK selection).
Agent packaged in the application image¶
The application team attaches the agent to its own image as a class-path layer.
Class-path layers are unpacked read-only under /app/lib:
mkdir -p otel-layer
cp opentelemetry-javaagent.jar otel-layer/
tar -C otel-layer -cf otel-agent-layer.tar opentelemetry-javaagent.jar
brewlet push ./target/orders.jar demo/orders:1.0.0 \
--store ./oci --classpath-layer otel-agent-layer.tar
Then reference it from the deployment:
This keeps the agent version, and any agent extensions, under the application team's control and in the same signed, digest-addressed image as the code.
Caveats:
- It was validated with the default
jarentry mode (java -jar), where/app/libis not on the application class path. Inclasspathentry mode with alib/*wildcard, the agent JAR would also land on the application class path, which the OpenTelemetry project advises against. In that mode, prefer the node JDK image approach. - The turnkey
brewlet push --appcdsoption cannot be combined with--classpath-layer. Also see AppCDS interaction.
In-application SDK or framework starter¶
Frameworks can export telemetry without an agent. For example, Spring Boot's
spring-boot-starter-opentelemetry exports traces over OTLP when configured
through the deployment environment:
env:
- { name: OTEL_SERVICE_NAME, value: orders }
- { name: MANAGEMENT_OPENTELEMETRY_TRACING_EXPORT_OTLP_ENDPOINT, value: "http://otel-collector.observability:4318/v1/traces" }
- { name: MANAGEMENT_TRACING_SAMPLING_PROBABILITY, value: "1.0" }
Nothing Brewlet-specific is required: the dependencies are part of the JAR. The trade-off is coverage. You get what the framework and your code instrument, rather than the agent's broad library auto-instrumentation.
Kubernetes image volume¶
On clusters that support Kubernetes
image volumes,
a raw Pod or Deployment can mount an agent-only OCI image read-only and pass
the flag through the brewlet.sh/jvm-args annotation:
apiVersion: v1
kind: Pod
metadata:
name: orders
annotations:
brewlet.sh/jdk: "temurin-21"
brewlet.sh/jvm-args: '["-javaagent:/otel/javaagent.jar"]'
spec:
runtimeClassName: brewlet
containers:
- name: orders
image: registry.example.com/demo/orders@sha256:REPLACE_WITH_IMAGE_DIGEST
volumeMounts:
- { name: otel-agent, mountPath: /otel, readOnly: true }
volumes:
- name: otel-agent
image:
reference: registry.example.com/platform/otel-agent@sha256:<digest>
pullPolicy: IfNotPresent
JavaApplication does not expose volumes or pod annotations. Use this approach
only when you already manage raw Pods or Deployments.
hostPath volume¶
Mounting an agent that an administrator placed on each node through a
hostPath volume also works with raw Pods. It is not recommended: nothing
tracks, pins or reconciles the file, and hostPath is often restricted by Pod
Security admission. Use the node JDK image
approach to get the same result through the provisioner.
Delivering the -javaagent flag¶
Prefer jvm.args (spec.jvm.args, or brewlet.sh/jvm-args on a raw Pod).
Brewlet passes these directly on the launcher command line, just before the
entrypoint (details).
JAVA_TOOL_OPTIONS in env also works, and is common in APM vendor
instructions. Be aware that:
- the JVM prints
Picked up JAVA_TOOL_OPTIONS: …to stderr on every start; - it can also apply to other JVMs started in the container, such as a
jcmdrun throughkubectl exec; and - when a
JavaApplicationsets bothjvm.argsandJAVA_TOOL_OPTIONS, it reportsJVMArgsApplied=Truewith reasonEnvOptionsOverlapand emits a warning event. This is informational: both are applied, andjvm.argswin on conflict.
The artifact's launch configuration has no free-form JVM argument field, so the agent flag cannot be embedded in the image. That is deliberate: observability agents are deployment tuning, not application code.
Keep OTEL settings out of the artifact¶
The artifact launch configuration can carry env entries. When the same
variable is set both in the artifact and in the deployment, the artifact
value wins. For example, an artifact OTEL_SERVICE_NAME silently overrides
the OTEL_SERVICE_NAME in JavaApplication.spec.env. Keep all OTEL_*
settings, endpoints and resource attributes in the deployment, where
operators can change them per environment.
Avoid runtime self-attach¶
Libraries such as opentelemetry-runtime-attach load the agent into the
running JVM from main(). This works on Brewlet, but it relies on dynamic
agent loading, which
JEP 451 is phasing out. Since JDK 21 the JVM
prints WARNING: A Java agent has been loaded dynamically …. A future JDK
will disallow dynamic loading by default, and the attach fails at startup
today when run with -XX:-EnableDynamicAgentLoading. Self-attach also needs
a writable temporary directory to extract the agent.
Loading the agent at startup with -javaagent is unaffected by JEP 451 and
produces no such warning. Use one of the approaches above instead.
Patterns that do not work¶
The current Brewlet handler treats every non-sandbox container in a
runtimeClassName: brewlet Pod as a Brewlet application image. An ordinary
container image in the same Pod fails to start. Tag references are rejected
with must be digest-pinned, and digest-pinned ordinary images fail while
parsing their JVM launch configuration. As a result, these patterns are not
supported:
- Init containers that copy an agent into a shared
emptyDir. - Collector or agent sidecars in the application Pod. Run the Collector as
a separate
DeploymentorDaemonSetinstead. - OpenTelemetry Operator auto-instrumentation through the
instrumentation.opentelemetry.io/inject-javaannotation. The operator injects an init container, so the Pod stays inInit:RunContainerError. Do not apply the injection annotation to namespaces that run Brewlet workloads. The operator's Collector management is unaffected.
This is the same limitation that applies to ordinary-image ephemeral debug containers (Deploying workloads).
AppCDS interaction¶
The Java agent appends itself to the bootstrap class path. With class data sharing enabled, the JVM then prints:
The workload still runs. However, shared-archive benefits for application classes, including a shipped AppCDS archive, should be expected to shrink. Measure startup with the agent enabled before relying on AppCDS gains for instrumented workloads.
Verifying instrumentation¶
Check that the flag reached the JVM (requires a JDK, not a JRE, in the selected root):
kubectl exec <pod> -- jcmd 1 VM.command_line
kubectl logs <pod> | grep -i 'opentelemetry-javaagent - version'
Then confirm that spans with the expected service.name arrive at the
Collector, for example through its debug exporter output.