This is an automated email from the ASF dual-hosted git repository.

jscheffl pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 535e3cca49d Add optional OTel service to the Airflow Helm Chart 
(#64902)
535e3cca49d is described below

commit 535e3cca49dd00287cac675c6f0ea664d5549648
Author: Christos Bisias <[email protected]>
AuthorDate: Tue May 5 21:58:22 2026 +0300

    Add optional OTel service to the Airflow Helm Chart (#64902)
    
    * add otel to helm chart
    
    * use Kustomize for grafana, jaeger, prometheus
    
    * enable specific service per flag + unit test
    
    * remove grafana, jaeger and prometheus kustomization logic
    
    * traces enabled and metrics disabled, by default
    
    * remove otelCollector.enabled flag
    
    * add statsd comments about otel metrics overriding the config
    
    * make OTEL_METRIC_EXPORT_INTERVAL configurable and provide default value + 
entry in the values.schema.json
    
    * remove hardcoded value for metrics otel_port in values.yaml
    
    * add option to override the configmap
    
    * add otelCollector.args and make the config.yml file as the default 
argument
    
    * rename extraAnnotations to annotations in otel-collector-service.yaml
    
    * parameterize the readiness and liveness probe values
    
    * remove prometheus from the configmap
    
    * update the default value for OTEL_TRACES_EXPORTER
    
    * fix tests in airflow_aux + otel-collector-serviceaccount.yaml
    
    * fix spellcheck errors in docs
    
    * fix tests in security
    
    * otel collector unit tests + networkpolicy file
    
    * values.schema.json cleanup
    
    * add a minimum to all integer configs in values.schema.json
    
    * fix heading comments
    
    * change config default to ~ from empty string
    
    * fix static check error
---
 chart/templates/_helpers.yaml                      |  27 ++
 .../configmaps/otel-collector-configmap.yaml       |  73 ++++
 .../otel-collector/otel-collector-deployment.yaml  | 115 +++++
 .../otel-collector-networkpolicy.yaml              |  59 +++
 .../otel-collector/otel-collector-service.yaml     |  55 +++
 .../otel-collector-serviceaccount.yaml             |  41 ++
 chart/values.schema.json                           | 292 ++++++++++++-
 chart/values.yaml                                  | 121 +++++-
 .../doc/images/output_testing_helm-tests.svg       |   2 +-
 .../doc/images/output_testing_helm-tests.txt       |   2 +-
 .../helm_tests/airflow_aux/test_airflow_common.py  |  12 +
 .../airflow_aux/test_basic_helm_chart.py           |   9 +
 .../tests/helm_tests/otel_collector/__init__.py    |  16 +
 .../otel_collector/test_labels_deployment.py       | 113 +++++
 .../otel_collector/test_labels_networkpolicy.py    |  98 +++++
 .../otel_collector/test_labels_service.py          |  94 ++++
 .../otel_collector/test_labels_serviceaccount.py   |  94 ++++
 .../otel_collector/test_otel_collector.py          | 484 +++++++++++++++++++++
 helm-tests/tests/helm_tests/security/test_rbac.py  |  10 +
 19 files changed, 1713 insertions(+), 4 deletions(-)

diff --git a/chart/templates/_helpers.yaml b/chart/templates/_helpers.yaml
index b4ca3866720..d2aa1e093e4 100644
--- a/chart/templates/_helpers.yaml
+++ b/chart/templates/_helpers.yaml
@@ -141,6 +141,24 @@ If release name contains chart name it will be used as a 
full name.
         name: {{ template "opensearch_secret" . }}
         key: connection
   {{- end }}
+  {{- if or .Values.otelCollector.tracesEnabled 
.Values.otelCollector.metricsEnabled }}
+  - name: OTEL_SERVICE_NAME
+    value: "airflow"
+  - name: OTEL_EXPORTER_OTLP_PROTOCOL
+    value: "http/protobuf"
+  {{- end }}
+  {{- if .Values.otelCollector.tracesEnabled }}
+  - name: OTEL_TRACES_EXPORTER
+    value: "otlp"
+  - name: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
+    value: "http://{{ include "airflow.fullname" . }}-otel-collector:{{ 
.Values.ports.otelCollectorOtlpHttp }}/v1/traces"
+  {{- end }}
+  {{- if .Values.otelCollector.metricsEnabled }}
+  - name: OTEL_EXPORTER_OTLP_METRICS_ENDPOINT
+    value: "http://{{ include "airflow.fullname" . }}-otel-collector:{{ 
.Values.ports.otelCollectorOtlpHttp }}/v1/metrics"
+  - name: OTEL_METRIC_EXPORT_INTERVAL
+    value: {{ .Values.otelCollector.metricExportIntervalMs | quote }}
+  {{- end }}
 {{- end }}
 
 {{/* User defined Airflow environment variables */}}
@@ -404,6 +422,10 @@ If release name contains chart name it will be used as a 
full name.
   {{- printf "%s:%s" .Values.images.gitSync.repository 
.Values.images.gitSync.tag }}
 {{- end }}
 
+{{- define "otel_collector_image" -}}
+  {{- printf "%s:%s" .Values.images.otelCollector.repository 
.Values.images.otelCollector.tag }}
+{{- end }}
+
 {{- define "fernet_key_secret" -}}
   {{- default (printf "%s-fernet-key" (include "airflow.fullname" .)) 
.Values.fernetKeySecretName }}
 {{- end }}
@@ -680,6 +702,11 @@ server_tls_key_file = /etc/pgbouncer/server.key
   {{- include "_serviceAccountName" (merge (dict "key" "statsd") .) -}}
 {{- end }}
 
+{{/* Create the name of the OTel Collector service account to use */}}
+{{- define "otelCollector.serviceAccountName" -}}
+  {{- include "_serviceAccountName" (merge (dict "key" "otelCollector" 
"nameSuffix" "otel-collector") .) -}}
+{{- end }}
+
 {{/* Create the name of the create user job service account to use */}}
 {{- define "createUserJob.serviceAccountName" -}}
   {{- include "_serviceAccountName" (merge (dict "key" "createUserJob" 
"nameSuffix" "create-user-job") .) -}}
diff --git a/chart/templates/configmaps/otel-collector-configmap.yaml 
b/chart/templates/configmaps/otel-collector-configmap.yaml
new file mode 100644
index 00000000000..923ef40e871
--- /dev/null
+++ b/chart/templates/configmaps/otel-collector-configmap.yaml
@@ -0,0 +1,73 @@
+{{/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+*/}}
+
+###########################
+## OTel Collector ConfigMap
+###########################
+{{- if or .Values.otelCollector.tracesEnabled 
.Values.otelCollector.metricsEnabled }}
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "airflow.fullname" . }}-otel-collector
+  labels:
+    tier: airflow
+    component: config
+    release: {{ .Release.Name }}
+    chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+    heritage: {{ .Release.Service }}
+    {{- with .Values.labels }}
+      {{- toYaml . | nindent 4 }}
+    {{- end }}
+data:
+  config.yml: |
+{{- if .Values.otelCollector.config }}
+{{ tpl .Values.otelCollector.config . | indent 4 }}
+{{- else }}
+    extensions:
+      health_check:
+        endpoint: 0.0.0.0:13133
+
+    receivers:
+      otlp:
+        protocols:
+          grpc:
+            endpoint: 0.0.0.0:{{ .Values.ports.otelCollectorOtlpGrpc }}
+          http:
+            endpoint: 0.0.0.0:{{ .Values.ports.otelCollectorOtlpHttp }}
+
+    processors:
+      batch: {}
+
+    exporters:
+      logging:
+        verbosity: basic
+
+    service:
+      extensions: [health_check]
+      pipelines:
+        traces:
+          receivers: [otlp]
+          processors: [batch]
+          exporters: [logging]
+        metrics:
+          receivers: [otlp]
+          processors: [batch]
+          exporters: [logging]
+{{- end }}
+{{- end }}
diff --git a/chart/templates/otel-collector/otel-collector-deployment.yaml 
b/chart/templates/otel-collector/otel-collector-deployment.yaml
new file mode 100644
index 00000000000..45cf3fddb68
--- /dev/null
+++ b/chart/templates/otel-collector/otel-collector-deployment.yaml
@@ -0,0 +1,115 @@
+{{/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+*/}}
+
+############################
+## OTel Collector Deployment
+############################
+{{- if or .Values.otelCollector.tracesEnabled 
.Values.otelCollector.metricsEnabled }}
+{{- $nodeSelector := or .Values.otelCollector.nodeSelector 
.Values.nodeSelector }}
+{{- $affinity := or .Values.otelCollector.affinity .Values.affinity }}
+{{- $tolerations := or .Values.otelCollector.tolerations .Values.tolerations }}
+{{- $topologySpreadConstraints := or 
.Values.otelCollector.topologySpreadConstraints 
.Values.topologySpreadConstraints }}
+{{- $revisionHistoryLimit := include "airflow.revisionHistoryLimit" (list 
.Values.otelCollector.revisionHistoryLimit .Values.revisionHistoryLimit) }}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "airflow.fullname" . }}-otel-collector
+  labels:
+    tier: airflow
+    component: otel-collector
+    release: {{ .Release.Name }}
+    chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+    heritage: {{ .Release.Service }}
+    {{- if or .Values.labels .Values.otelCollector.labels }}
+      {{- mustMerge .Values.otelCollector.labels .Values.labels | toYaml | 
nindent 4 }}
+    {{- end }}
+  {{- with .Values.otelCollector.annotations }}
+  annotations: {{- toYaml . | nindent 4 }}
+  {{- end }}
+spec:
+  replicas: 1
+  {{- if ne $revisionHistoryLimit "" }}
+  revisionHistoryLimit: {{ $revisionHistoryLimit }}
+  {{- end }}
+  selector:
+    matchLabels:
+      tier: airflow
+      component: otel-collector
+      release: {{ .Release.Name }}
+  template:
+    metadata:
+      labels:
+        tier: airflow
+        component: otel-collector
+        release: {{ .Release.Name }}
+        {{- if or .Values.labels .Values.otelCollector.labels }}
+          {{- mustMerge .Values.otelCollector.labels .Values.labels | toYaml | 
nindent 8 }}
+        {{- end }}
+      annotations:
+        checksum/otel-collector-config: {{ include (print $.Template.BasePath 
"/configmaps/otel-collector-configmap.yaml") . | sha256sum }}
+        {{- with .Values.otelCollector.podAnnotations }}
+          {{- tpl (toYaml .) $ | nindent 8 }}
+        {{- end }}
+    spec:
+      nodeSelector: {{- toYaml $nodeSelector | nindent 8 }}
+      affinity: {{- toYaml $affinity | nindent 8 }}
+      tolerations: {{- toYaml $tolerations | nindent 8 }}
+      topologySpreadConstraints: {{- toYaml $topologySpreadConstraints | 
nindent 8 }}
+      terminationGracePeriodSeconds: {{ 
.Values.otelCollector.terminationGracePeriodSeconds }}
+      serviceAccountName: {{ include "otelCollector.serviceAccountName" . }}
+      {{- if .Values.otelCollector.priorityClassName }}
+      priorityClassName: {{ .Values.otelCollector.priorityClassName }}
+      {{- end }}
+      restartPolicy: Always
+      securityContext: {{- toYaml .Values.otelCollector.securityContexts.pod | 
nindent 8 }}
+      containers:
+        - name: otel-collector
+          image: {{ template "otel_collector_image" . }}
+          imagePullPolicy: {{ .Values.images.otelCollector.pullPolicy }}
+          securityContext: {{- toYaml 
.Values.otelCollector.securityContexts.container | nindent 12 }}
+          args: {{- tpl (toYaml .Values.otelCollector.args) . | nindent 12 }}
+          ports:
+            - name: otlp-grpc
+              containerPort: {{ .Values.ports.otelCollectorOtlpGrpc }}
+              protocol: TCP
+            - name: otlp-http
+              containerPort: {{ .Values.ports.otelCollectorOtlpHttp }}
+              protocol: TCP
+          livenessProbe:
+            httpGet:
+              path: /
+              port: 13133
+            initialDelaySeconds: {{ 
.Values.otelCollector.livenessProbe.initialDelaySeconds }}
+            periodSeconds: {{ 
.Values.otelCollector.livenessProbe.periodSeconds }}
+          readinessProbe:
+            httpGet:
+              path: /
+              port: 13133
+            initialDelaySeconds: {{ 
.Values.otelCollector.readinessProbe.initialDelaySeconds }}
+            periodSeconds: {{ 
.Values.otelCollector.readinessProbe.periodSeconds }}
+          resources: {{- toYaml .Values.otelCollector.resources | nindent 12 }}
+          volumeMounts:
+            - name: config
+              mountPath: /etc/otel-collector
+              readOnly: true
+      volumes:
+        - name: config
+          configMap:
+            name: {{ include "airflow.fullname" . }}-otel-collector
+{{- end }}
diff --git a/chart/templates/otel-collector/otel-collector-networkpolicy.yaml 
b/chart/templates/otel-collector/otel-collector-networkpolicy.yaml
new file mode 100644
index 00000000000..f58cb77fdec
--- /dev/null
+++ b/chart/templates/otel-collector/otel-collector-networkpolicy.yaml
@@ -0,0 +1,59 @@
+{{/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+*/}}
+
+#######################################
+## Airflow OTel Collector NetworkPolicy
+#######################################
+{{- if and .Values.networkPolicies.enabled (or 
.Values.otelCollector.tracesEnabled .Values.otelCollector.metricsEnabled) }}
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+  name: {{ include "airflow.fullname" . }}-otel-collector-policy
+  labels:
+    tier: airflow
+    component: otel-collector-policy
+    release: {{ .Release.Name }}
+    chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+    heritage: {{ .Release.Service }}
+    {{- if or .Values.labels .Values.otelCollector.labels }}
+      {{- mustMerge .Values.otelCollector.labels .Values.labels | toYaml | 
nindent 4 }}
+    {{- end }}
+spec:
+  podSelector:
+    matchLabels:
+      tier: airflow
+      component: otel-collector
+      release: {{ .Release.Name }}
+  policyTypes:
+  - Ingress
+  ingress:
+  - from:
+    - podSelector:
+        matchLabels:
+          tier: airflow
+          release: {{ .Release.Name }}
+    {{- if .Values.otelCollector.extraNetworkPolicies }}
+      {{- toYaml .Values.otelCollector.extraNetworkPolicies | nindent 4 }}
+    {{- end }}
+    ports:
+    - protocol: TCP
+      port: {{ .Values.ports.otelCollectorOtlpHttp }}
+    - protocol: TCP
+      port: {{ .Values.ports.otelCollectorOtlpGrpc }}
+{{- end }}
diff --git a/chart/templates/otel-collector/otel-collector-service.yaml 
b/chart/templates/otel-collector/otel-collector-service.yaml
new file mode 100644
index 00000000000..9d2ce3debed
--- /dev/null
+++ b/chart/templates/otel-collector/otel-collector-service.yaml
@@ -0,0 +1,55 @@
+{{/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+*/}}
+
+#########################
+## OTel Collector Service
+#########################
+{{- if or .Values.otelCollector.tracesEnabled 
.Values.otelCollector.metricsEnabled }}
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ include "airflow.fullname" . }}-otel-collector
+  labels:
+    tier: airflow
+    component: otel-collector
+    release: {{ .Release.Name }}
+    chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+    heritage: {{ .Release.Service }}
+    {{- if or .Values.labels .Values.otelCollector.labels }}
+      {{- mustMerge .Values.otelCollector.labels .Values.labels | toYaml | 
nindent 4 }}
+    {{- end }}
+  {{- with .Values.otelCollector.service.annotations }}
+  annotations: {{- toYaml . | nindent 4 }}
+  {{- end }}
+spec:
+  type: ClusterIP
+  selector:
+    tier: airflow
+    component: otel-collector
+    release: {{ .Release.Name }}
+  ports:
+    - name: otlp-grpc
+      protocol: TCP
+      port: {{ .Values.ports.otelCollectorOtlpGrpc }}
+      targetPort: {{ .Values.ports.otelCollectorOtlpGrpc }}
+    - name: otlp-http
+      protocol: TCP
+      port: {{ .Values.ports.otelCollectorOtlpHttp }}
+      targetPort: {{ .Values.ports.otelCollectorOtlpHttp }}
+{{- end }}
diff --git a/chart/templates/otel-collector/otel-collector-serviceaccount.yaml 
b/chart/templates/otel-collector/otel-collector-serviceaccount.yaml
new file mode 100644
index 00000000000..379e85a8b20
--- /dev/null
+++ b/chart/templates/otel-collector/otel-collector-serviceaccount.yaml
@@ -0,0 +1,41 @@
+{{/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+*/}}
+
+########################################
+## Airflow OTel Collector ServiceAccount
+########################################
+{{- if and (or .Values.otelCollector.tracesEnabled 
.Values.otelCollector.metricsEnabled) 
.Values.otelCollector.serviceAccount.create }}
+apiVersion: v1
+kind: ServiceAccount
+automountServiceAccountToken: {{ 
.Values.otelCollector.serviceAccount.automountServiceAccountToken }}
+metadata:
+  name: {{ include "otelCollector.serviceAccountName" . }}
+  labels:
+    tier: airflow
+    component: otel-collector
+    release: {{ .Release.Name }}
+    chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+    heritage: {{ .Release.Service }}
+    {{- if or .Values.labels .Values.otelCollector.labels }}
+      {{- mustMerge .Values.otelCollector.labels .Values.labels | toYaml | 
nindent 4 }}
+    {{- end }}
+  {{- with .Values.otelCollector.serviceAccount.annotations }}
+  annotations: {{- toYaml . | nindent 4 }}
+  {{- end }}
+{{- end }}
diff --git a/chart/values.schema.json b/chart/values.schema.json
index d5612c1ab3f..4a645d85c22 100644
--- a/chart/values.schema.json
+++ b/chart/values.schema.json
@@ -17,6 +17,7 @@
         "Flower",
         "Redis",
         "StatsD",
+        "OpenTelemetry",
         "Jobs",
         "Kubernetes",
         "Ingress",
@@ -1088,6 +1089,28 @@
                             "default": "IfNotPresent"
                         }
                     }
+                },
+                "otelCollector": {
+                    "type": "object",
+                    "additionalProperties": false,
+                    "properties": {
+                        "repository": {
+                            "type": "string"
+                        },
+                        "tag": {
+                            "type": "string"
+                        },
+                        "pullPolicy": {
+                            "type": "string",
+                            "enum": [
+                                "Always",
+                                "Never",
+                                "IfNotPresent"
+                            ],
+                            "default": "IfNotPresent"
+                        }
+                    },
+                    "description": "Configuration of the OTel Collector image."
                 }
             }
         },
@@ -8302,7 +8325,7 @@
                     }
                 },
                 "enabled": {
-                    "description": "Enable StatsD.",
+                    "description": "Enable StatsD. When 
`otelCollector.metricsEnabled` is true, [metrics] statsd_on is set to false in 
the rendered Airflow config because Airflow can only export metrics to one 
backend at a time.",
                     "type": "boolean",
                     "default": true
                 },
@@ -8611,6 +8634,263 @@
                 }
             }
         },
+        "otelCollector": {
+            "description": "OpenTelemetry Collector settings.",
+            "type": "object",
+            "x-docsSection": "OpenTelemetry",
+            "additionalProperties": false,
+            "properties": {
+                "tracesEnabled": {
+                    "description": "Send Airflow traces to the OTel Collector. 
Sets `[traces] otel_on` in the rendered Airflow config and deploys the 
collector when either `tracesEnabled` or `metricsEnabled` is true.",
+                    "type": "boolean",
+                    "default": true
+                },
+                "metricsEnabled": {
+                    "description": "Send Airflow metrics to the OTel 
Collector. Sets `[metrics] otel_on` in the rendered Airflow config and forces 
`[metrics] statsd_on` to False because Airflow can only export metrics to one 
backend at a time.",
+                    "type": "boolean",
+                    "default": false
+                },
+                "metricExportIntervalMs": {
+                    "description": "Interval (in milliseconds) at which the 
OTel SDK exports metrics to the collector. Sets the 
`OTEL_METRIC_EXPORT_INTERVAL` env var on Airflow pods.",
+                    "type": "integer",
+                    "default": 30000,
+                    "minimum": 0
+                },
+                "config": {
+                    "description": "Override the OTel Collector `config.yml`. 
When set (non-empty), this string replaces the chart's default collector 
config. The value is rendered with `tpl`, so values like `{{ 
.Values.ports.otelCollectorOtlpHttp }}` or `{{ include \"airflow.fullname\" . 
}}` can be referenced from inside the string. Leave empty to use the chart 
default.",
+                    "type": [
+                        "string",
+                        "null"
+                    ],
+                    "default": null
+                },
+                "args": {
+                    "description": "Args to pass to the OTel Collector 
container (templated).",
+                    "type": [
+                        "array",
+                        "null"
+                    ],
+                    "items": {
+                        "type": "string"
+                    },
+                    "default": [
+                        "--config=/etc/otel-collector/config.yml"
+                    ]
+                },
+                "revisionHistoryLimit": {
+                    "description": "Number of old ReplicaSets to retain.",
+                    "type": [
+                        "integer",
+                        "null"
+                    ],
+                    "default": null,
+                    "x-docsSection": "Kubernetes"
+                },
+                "annotations": {
+                    "description": "Annotations to add to the OTel Collector 
deployment.",
+                    "type": "object",
+                    "default": {},
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "terminationGracePeriodSeconds": {
+                    "description": "Grace period for the OTel Collector to 
finish after SIGTERM is sent from Kubernetes.",
+                    "type": "integer",
+                    "default": 30,
+                    "minimum": 0
+                },
+                "livenessProbe": {
+                    "description": "Liveness probe configuration for the OTel 
Collector container.",
+                    "type": "object",
+                    "additionalProperties": false,
+                    "properties": {
+                        "initialDelaySeconds": {
+                            "description": "Number of seconds after the 
container has started before the liveness probe is initiated.",
+                            "type": "integer",
+                            "default": 10,
+                            "minimum": 0
+                        },
+                        "periodSeconds": {
+                            "description": "How often (in seconds) to perform 
the liveness probe.",
+                            "type": "integer",
+                            "default": 15,
+                            "minimum": 0
+                        }
+                    }
+                },
+                "readinessProbe": {
+                    "description": "Readiness probe configuration for the OTel 
Collector container.",
+                    "type": "object",
+                    "additionalProperties": false,
+                    "properties": {
+                        "initialDelaySeconds": {
+                            "description": "Number of seconds after the 
container has started before the readiness probe is initiated.",
+                            "type": "integer",
+                            "default": 10,
+                            "minimum": 0
+                        },
+                        "periodSeconds": {
+                            "description": "How often (in seconds) to perform 
the readiness probe.",
+                            "type": "integer",
+                            "default": 15,
+                            "minimum": 0
+                        }
+                    }
+                },
+                "resources": {
+                    "description": "Resources for the OTel Collector pod.",
+                    "type": "object",
+                    "default": {},
+                    "examples": [
+                        {
+                            "limits": {
+                                "cpu": "100m",
+                                "memory": "128Mi"
+                            },
+                            "requests": {
+                                "cpu": "100m",
+                                "memory": "128Mi"
+                            }
+                        }
+                    ],
+                    "$ref": 
"#/definitions/io.k8s.api.core.v1.ResourceRequirements"
+                },
+                "service": {
+                    "description": "OTel Collector Service configuration.",
+                    "type": "object",
+                    "additionalProperties": false,
+                    "properties": {
+                        "annotations": {
+                            "description": "Annotations for the OTel Collector 
Service.",
+                            "type": "object",
+                            "default": {},
+                            "additionalProperties": {
+                                "type": "string"
+                            }
+                        }
+                    }
+                },
+                "nodeSelector": {
+                    "description": "Select certain nodes for OTel Collector 
pods.",
+                    "type": "object",
+                    "default": {},
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "affinity": {
+                    "description": "Specify scheduling constraints for OTel 
Collector pods.",
+                    "type": "object",
+                    "default": {},
+                    "$ref": "#/definitions/io.k8s.api.core.v1.Affinity"
+                },
+                "tolerations": {
+                    "description": "Specify Tolerations for OTel Collector 
pods.",
+                    "type": "array",
+                    "default": [],
+                    "items": {
+                        "type": "object",
+                        "$ref": "#/definitions/io.k8s.api.core.v1.Toleration"
+                    }
+                },
+                "topologySpreadConstraints": {
+                    "description": "Specify topology spread constraints for 
OTel Collector pods.",
+                    "type": "array",
+                    "default": [],
+                    "items": {
+                        "type": "object",
+                        "$ref": 
"#/definitions/io.k8s.api.core.v1.TopologySpreadConstraint"
+                    }
+                },
+                "priorityClassName": {
+                    "description": "Specify priority for OTel Collector pods.",
+                    "type": [
+                        "string",
+                        "null"
+                    ],
+                    "default": null
+                },
+                "labels": {
+                    "description": "Labels specific to OTel Collector objects 
and pods.",
+                    "type": "object",
+                    "default": {},
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "podAnnotations": {
+                    "description": "Annotations to add to the OTel Collector 
pods (templated).",
+                    "type": "object",
+                    "default": {},
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "securityContexts": {
+                    "description": "Security context definition for the OTel 
Collector.",
+                    "type": "object",
+                    "x-docsSection": "Kubernetes",
+                    "properties": {
+                        "pod": {
+                            "description": "Pod security context definition 
for the OTel Collector.",
+                            "type": "object",
+                            "$ref": 
"#/definitions/io.k8s.api.core.v1.PodSecurityContext",
+                            "default": {},
+                            "x-docsSection": "Kubernetes"
+                        },
+                        "container": {
+                            "description": "Container security context 
definition for the OTel Collector.",
+                            "type": "object",
+                            "$ref": 
"#/definitions/io.k8s.api.core.v1.SecurityContext",
+                            "default": {},
+                            "x-docsSection": "Kubernetes"
+                        }
+                    }
+                },
+                "extraNetworkPolicies": {
+                    "description": "Additional NetworkPolicies as needed for 
the OTel Collector. Only used when `networkPolicies.enabled` is true.",
+                    "type": "array",
+                    "items": {
+                        "type": "object",
+                        "$ref": 
"#/definitions/io.k8s.api.networking.v1.NetworkPolicyPeer"
+                    },
+                    "default": []
+                },
+                "serviceAccount": {
+                    "description": "Create ServiceAccount.",
+                    "type": "object",
+                    "properties": {
+                        "automountServiceAccountToken": {
+                            "description": "Specifies if the ServiceAccount's 
API credentials should be auto-mounted onto OTel Collector pods. Defaults to 
false because the chart's default collector config does not talk to the 
Kubernetes API.",
+                            "type": "boolean",
+                            "default": false
+                        },
+                        "create": {
+                            "description": "Specifies whether a ServiceAccount 
should be created.",
+                            "type": "boolean",
+                            "default": true
+                        },
+                        "name": {
+                            "description": "The name of the ServiceAccount to 
use. If not set and create is true, a name is generated using the release 
name.",
+                            "type": [
+                                "string",
+                                "null"
+                            ],
+                            "default": null
+                        },
+                        "annotations": {
+                            "description": "Annotations to add to the OTel 
Collector Kubernetes ServiceAccount.",
+                            "type": "object",
+                            "default": {},
+                            "additionalProperties": {
+                                "type": "string"
+                            }
+                        }
+                    }
+                }
+            }
+        },
         "pgbouncer": {
             "description": "PgBouncer settings.",
             "type": "object",
@@ -9853,6 +10133,16 @@
                     "description": "PgBouncer scrape port.",
                     "type": "integer",
                     "default": 9127
+                },
+                "otelCollectorOtlpHttp": {
+                    "description": "OTel Collector OTLP HTTP port.",
+                    "type": "integer",
+                    "default": 4318
+                },
+                "otelCollectorOtlpGrpc": {
+                    "description": "OTel Collector OTLP gRPC port.",
+                    "type": "integer",
+                    "default": 4317
                 }
             }
         },
diff --git a/chart/values.yaml b/chart/values.yaml
index 39e7a4de8aa..0df514c93ec 100644
--- a/chart/values.yaml
+++ b/chart/values.yaml
@@ -122,6 +122,10 @@ images:
     repository: registry.k8s.io/git-sync/git-sync
     tag: v4.4.2
     pullPolicy: IfNotPresent
+  otelCollector:
+    repository: otel/opentelemetry-collector-contrib
+    tag: "0.70.0"
+    pullPolicy: IfNotPresent
 
 # Select certain nodes for Airflow pods.
 nodeSelector: {}
@@ -3067,6 +3071,9 @@ statsd:
   # Add custom annotations to the StatsD ConfigMap
   configMapAnnotations: {}
 
+  # When otelCollector.metricsEnabled is true, [metrics] statsd_on is set to
+  # False in the rendered Airflow config because Airflow can only export 
metrics
+  # to one backend at a time.
   enabled: true
 
   # Max number of old ReplicaSets to retain
@@ -3179,6 +3186,111 @@ statsd:
   # Environment variables to add to StatsD container
   env: []
 
+# OpenTelemetry Collector settings
+otelCollector:
+  # Send Airflow traces to the OTel Collector (sets [traces] otel_on).
+  tracesEnabled: true
+
+  # Send Airflow metrics to the OTel Collector (sets [metrics] otel_on and 
disables statsd).
+  metricsEnabled: false
+
+  # Default value for the OTEL_METRIC_EXPORT_INTERVAL env var on Airflow pods.
+  # Interval (in milliseconds) at which the OTel SDK exports metrics to the 
collector.
+  metricExportIntervalMs: 30000
+
+  # Override the OTel Collector config.yml. When set (non-empty), this string 
replaces
+  # the chart's default collector config. The value is rendered with `tpl`, so 
you can
+  # reference values like `{{ .Values.ports.otelCollectorOtlpHttp }}` or
+  # `{{ include "airflow.fullname" . }}` from inside the string. Leave empty 
to use
+  # the chart default.
+  config: ~
+  # config: |
+  #   extensions:
+  #     health_check:
+  #       endpoint: 0.0.0.0:13133
+  #   receivers:
+  #     otlp:
+  #       protocols:
+  #         http:
+  #           endpoint: 0.0.0.0:{{ .Values.ports.otelCollectorOtlpHttp }}
+  #   processors:
+  #     batch: {}
+  #   exporters:
+  #     logging:
+  #       verbosity: basic
+  #   service:
+  #     extensions: [health_check]
+  #     pipelines:
+  #       traces:
+  #         receivers: [otlp]
+  #         processors: [batch]
+  #         exporters: [logging]
+
+  # Args to pass to the OTel Collector container (templated).
+  args:
+    - "--config=/etc/otel-collector/config.yml"
+
+  # Max number of old ReplicaSets to retain
+  revisionHistoryLimit: ~
+
+  # Annotations to add to the OTel Collector Deployment
+  annotations: {}
+
+  # Grace period for OTel Collector to finish after SIGTERM
+  terminationGracePeriodSeconds: 30
+
+  livenessProbe:
+    initialDelaySeconds: 10
+    periodSeconds: 15
+
+  readinessProbe:
+    initialDelaySeconds: 10
+    periodSeconds: 15
+
+  resources: {}
+  #   limits:
+  #     cpu: 100m
+  #     memory: 128Mi
+  #   requests:
+  #     cpu: 100m
+  #     memory: 128Mi
+
+  service:
+    annotations: {}
+
+  nodeSelector: {}
+  affinity: {}
+  tolerations: []
+  topologySpreadConstraints: []
+  priorityClassName: ~
+  labels: {}
+  podAnnotations: {}
+
+  securityContexts:
+    pod: {}
+    container: {}
+
+  # Additional ingress peers/rules for the OTel Collector NetworkPolicy.
+  # Only used when `networkPolicies.enabled` is true.
+  extraNetworkPolicies: []
+
+  serviceAccount:
+    # ref: 
https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/
+    # The default OTel Collector config does not talk to the Kubernetes API, 
so credentials
+    # are not auto-mounted. Flip to true if you override 
`otelCollector.config` to use
+    # processors that need API access (e.g. `k8sattributes`).
+    automountServiceAccountToken: false
+
+    # Specifies whether a Service Account should be created
+    create: true
+
+    # The name of the Service Account to use.
+    # If not set and `create` is 'true', a name is generated using the release 
name
+    name: ~
+
+    # Annotations to add to the OTel Collector Kubernetes ServiceAccount.
+    annotations: {}
+
 # PgBouncer settings
 pgbouncer:
   # Enable PgBouncer
@@ -3574,6 +3686,8 @@ ports:
   redisDB: 6379
   statsdIngest: 9125
   statsdScrape: 9102
+  otelCollectorOtlpHttp: 4318
+  otelCollectorOtlpGrpc: 4317
   pgbouncer: 6543
   pgbouncerScrape: 9127
   apiServer: 8080
@@ -3793,10 +3907,15 @@ config:
     remote_logging: '{{- ternary "True" "False" (or 
.Values.elasticsearch.enabled .Values.opensearch.enabled) }}'
     colored_console_log: 'False'
   metrics:
-    statsd_on: '{{ ternary "True" "False" .Values.statsd.enabled }}'
+    statsd_on: '{{ ternary "True" "False" (and .Values.statsd.enabled (not 
.Values.otelCollector.metricsEnabled)) }}'
     statsd_port: 9125
     statsd_prefix: airflow
     statsd_host: '{{ printf "%s-statsd" (include "airflow.fullname" .) }}'
+    otel_on: '{{ ternary "True" "False" .Values.otelCollector.metricsEnabled 
}}'
+    otel_host: '{{ if .Values.otelCollector.metricsEnabled }}{{ printf 
"%s-otel-collector" (include "airflow.fullname" .) }}{{ end }}'
+    otel_port: '{{ .Values.ports.otelCollectorOtlpHttp }}'
+  traces:
+    otel_on: '{{ ternary "True" "False" .Values.otelCollector.tracesEnabled }}'
   fab:
     enable_proxy_fix: 'True'
   celery:
diff --git a/dev/breeze/doc/images/output_testing_helm-tests.svg 
b/dev/breeze/doc/images/output_testing_helm-tests.svg
index d98995ed56a..f2cc5db19fb 100644
--- a/dev/breeze/doc/images/output_testing_helm-tests.svg
+++ b/dev/breeze/doc/images/output_testing_helm-tests.svg
@@ -142,7 +142,7 @@
 </text><text class="breeze-testing-helm-tests-r1" x="1464" y="117.6" 
textLength="12.2" clip-path="url(#breeze-testing-helm-tests-line-4)">
 </text><text class="breeze-testing-helm-tests-r5" x="0" y="142" 
textLength="24.4" 
clip-path="url(#breeze-testing-helm-tests-line-5)">╭─</text><text 
class="breeze-testing-helm-tests-r5" x="24.4" y="142" textLength="378.2" 
clip-path="url(#breeze-testing-helm-tests-line-5)">&#160;Flags&#160;for&#160;helms-tests&#160;command&#160;</text><text
 class="breeze-testing-helm-tests-r5" x="402.6" y="142" textLength="1037" 
clip-path="url(#breeze-testing-helm-tests-line-5)">─────────────────────────── 
[...]
 </text><text class="breeze-testing-helm-tests-r5" x="0" y="166.4" 
textLength="12.2" 
clip-path="url(#breeze-testing-helm-tests-line-6)">│</text><text 
class="breeze-testing-helm-tests-r4" x="24.4" y="166.4" textLength="244" 
clip-path="url(#breeze-testing-helm-tests-line-6)">--test-type&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-testing-helm-tests-r1" x="292.8" y="166.4" textLength="317.2" 
clip-path="url(#breeze-testing-helm-tests-line-6)">Type&#160;of&# [...]
-</text><text class="breeze-testing-helm-tests-r5" x="0" y="190.8" 
textLength="12.2" 
clip-path="url(#breeze-testing-helm-tests-line-7)">│</text><text 
class="breeze-testing-helm-tests-r6" x="292.8" y="190.8" textLength="597.8" 
clip-path="url(#breeze-testing-helm-tests-line-7)">dagprocessor&#160;|&#160;other&#160;|&#160;redis&#160;|&#160;security&#160;|&#160;statsd)</text><text
 class="breeze-testing-helm-tests-r5" x="1451.8" y="190.8" textLength="12.2" 
clip-path="url(#breeze-testing-helm-te [...]
+</text><text class="breeze-testing-helm-tests-r5" x="0" y="190.8" 
textLength="12.2" 
clip-path="url(#breeze-testing-helm-tests-line-7)">│</text><text 
class="breeze-testing-helm-tests-r6" x="292.8" y="190.8" textLength="805.2" 
clip-path="url(#breeze-testing-helm-tests-line-7)">dagprocessor&#160;|&#160;otel_collector&#160;|&#160;other&#160;|&#160;redis&#160;|&#160;security&#160;|&#160;statsd)</text><text
 class="breeze-testing-helm-tests-r5" x="1451.8" y="190.8" textLength="12.2" 
clip-path=" [...]
 </text><text class="breeze-testing-helm-tests-r5" x="0" y="215.2" 
textLength="12.2" 
clip-path="url(#breeze-testing-helm-tests-line-8)">│</text><text 
class="breeze-testing-helm-tests-r4" x="24.4" y="215.2" textLength="244" 
clip-path="url(#breeze-testing-helm-tests-line-8)">--test-timeout&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-testing-helm-tests-r1" x="292.8" y="215.2" textLength="1146.8" 
clip-path="url(#breeze-testing-helm-tests-line-8)">Test&#160;timeout&#160;in&#1 
[...]
 </text><text class="breeze-testing-helm-tests-r5" x="0" y="239.6" 
textLength="12.2" 
clip-path="url(#breeze-testing-helm-tests-line-9)">│</text><text 
class="breeze-testing-helm-tests-r5" x="292.8" y="239.6" textLength="158.6" 
clip-path="url(#breeze-testing-helm-tests-line-9)">[default:&#160;60]</text><text
 class="breeze-testing-helm-tests-r6" x="463.6" y="239.6" textLength="244" 
clip-path="url(#breeze-testing-helm-tests-line-9)">(INTEGER&#160;RANGE&#160;x&gt;=0)</text><text
 class="breeze- [...]
 </text><text class="breeze-testing-helm-tests-r5" x="0" y="264" 
textLength="12.2" 
clip-path="url(#breeze-testing-helm-tests-line-10)">│</text><text 
class="breeze-testing-helm-tests-r4" x="24.4" y="264" textLength="244" 
clip-path="url(#breeze-testing-helm-tests-line-10)">--kubernetes-version</text><text
 class="breeze-testing-helm-tests-r1" x="292.8" y="264" textLength="658.8" 
clip-path="url(#breeze-testing-helm-tests-line-10)">Kubernetes&#160;version&#160;to&#160;validate&#160;helm&#160;t
 [...]
diff --git a/dev/breeze/doc/images/output_testing_helm-tests.txt 
b/dev/breeze/doc/images/output_testing_helm-tests.txt
index 98bf55d3fd8..5331937a401 100644
--- a/dev/breeze/doc/images/output_testing_helm-tests.txt
+++ b/dev/breeze/doc/images/output_testing_helm-tests.txt
@@ -1 +1 @@
-09ffed0009ecde363528f90bc5d8ab57
+02ba09ea9213ae046fe814563630054d
diff --git a/helm-tests/tests/helm_tests/airflow_aux/test_airflow_common.py 
b/helm-tests/tests/helm_tests/airflow_aux/test_airflow_common.py
index 0ce1123c874..a46ea30bee2 100644
--- a/helm-tests/tests/helm_tests/airflow_aux/test_airflow_common.py
+++ b/helm-tests/tests/helm_tests/airflow_aux/test_airflow_common.py
@@ -349,6 +349,10 @@ class TestAirflowCommon:
             "AIRFLOW__CORE__FERNET_KEY",
             "AIRFLOW_CONN_AIRFLOW_DB",
             "AIRFLOW__CELERY__BROKER_URL",
+            "OTEL_SERVICE_NAME",
+            "OTEL_EXPORTER_OTLP_PROTOCOL",
+            "OTEL_TRACES_EXPORTER",
+            "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
         ]
         expected_vars_in_worker = ["DUMB_INIT_SETSID"] + expected_vars
         for doc in docs:
@@ -377,6 +381,10 @@ class TestAirflowCommon:
             "AIRFLOW__API__SECRET_KEY",
             "AIRFLOW__API_AUTH__JWT_SECRET",
             "AIRFLOW__CELERY__BROKER_URL",
+            "OTEL_SERVICE_NAME",
+            "OTEL_EXPORTER_OTLP_PROTOCOL",
+            "OTEL_TRACES_EXPORTER",
+            "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
         ]
         expected_vars_no_jwt = [
             "AIRFLOW_HOME",
@@ -385,6 +393,10 @@ class TestAirflowCommon:
             "AIRFLOW_CONN_AIRFLOW_DB",
             "AIRFLOW__API__SECRET_KEY",
             "AIRFLOW__CELERY__BROKER_URL",
+            "OTEL_SERVICE_NAME",
+            "OTEL_EXPORTER_OTLP_PROTOCOL",
+            "OTEL_TRACES_EXPORTER",
+            "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
         ]
         for doc in docs:
             component = doc["metadata"]["labels"]["component"]
diff --git a/helm-tests/tests/helm_tests/airflow_aux/test_basic_helm_chart.py 
b/helm-tests/tests/helm_tests/airflow_aux/test_basic_helm_chart.py
index 74b921765c7..76ebc5e87a8 100644
--- a/helm-tests/tests/helm_tests/airflow_aux/test_basic_helm_chart.py
+++ b/helm-tests/tests/helm_tests/airflow_aux/test_basic_helm_chart.py
@@ -34,6 +34,7 @@ OBJECTS_STD_NAMING = {
     ("ServiceAccount", "test-basic-airflow-redis"),
     ("ServiceAccount", "test-basic-airflow-scheduler"),
     ("ServiceAccount", "test-basic-airflow-statsd"),
+    ("ServiceAccount", "test-basic-airflow-otel-collector"),
     ("ServiceAccount", "test-basic-airflow-triggerer"),
     ("ServiceAccount", "test-basic-airflow-worker"),
     ("Secret", "test-basic-airflow-api-secret-key"),
@@ -45,6 +46,7 @@ OBJECTS_STD_NAMING = {
     ("Secret", "test-basic-postgresql"),
     ("ConfigMap", "test-basic-airflow-config"),
     ("ConfigMap", "test-basic-airflow-statsd"),
+    ("ConfigMap", "test-basic-airflow-otel-collector"),
     ("Role", "test-basic-airflow-pod-launcher-role"),
     ("Role", "test-basic-airflow-pod-log-reader-role"),
     ("RoleBinding", "test-basic-airflow-pod-launcher-rolebinding"),
@@ -52,6 +54,7 @@ OBJECTS_STD_NAMING = {
     ("Service", "test-basic-airflow-api-server"),
     ("Service", "test-basic-airflow-redis"),
     ("Service", "test-basic-airflow-statsd"),
+    ("Service", "test-basic-airflow-otel-collector"),
     ("Service", "test-basic-airflow-triggerer"),
     ("Service", "test-basic-airflow-worker"),
     ("Service", "test-basic-postgresql"),
@@ -60,6 +63,7 @@ OBJECTS_STD_NAMING = {
     ("Deployment", "test-basic-airflow-dag-processor"),
     ("Deployment", "test-basic-airflow-scheduler"),
     ("Deployment", "test-basic-airflow-statsd"),
+    ("Deployment", "test-basic-airflow-otel-collector"),
     ("StatefulSet", "test-basic-airflow-redis"),
     ("StatefulSet", "test-basic-airflow-worker"),
     ("StatefulSet", "test-basic-airflow-triggerer"),
@@ -94,6 +98,7 @@ class TestBaseChartTest:
             ("ServiceAccount", "test-basic-redis"),
             ("ServiceAccount", "test-basic-scheduler"),
             ("ServiceAccount", "test-basic-statsd"),
+            ("ServiceAccount", "test-basic-otel-collector"),
             ("ServiceAccount", "test-basic-triggerer"),
             ("ServiceAccount", "test-basic-worker"),
             ("Secret", "test-basic-api-secret-key"),
@@ -105,6 +110,7 @@ class TestBaseChartTest:
             ("Secret", "test-basic-redis-password"),
             ("ConfigMap", "test-basic-config"),
             ("ConfigMap", "test-basic-statsd"),
+            ("ConfigMap", "test-basic-otel-collector"),
             ("Role", "test-basic-pod-launcher-role"),
             ("Role", "test-basic-pod-log-reader-role"),
             ("RoleBinding", "test-basic-pod-launcher-rolebinding"),
@@ -114,12 +120,14 @@ class TestBaseChartTest:
             ("Service", "test-basic-postgresql"),
             ("Service", "test-basic-redis"),
             ("Service", "test-basic-statsd"),
+            ("Service", "test-basic-otel-collector"),
             ("Service", "test-basic-triggerer"),
             ("Service", "test-basic-worker"),
             ("Deployment", "test-basic-api-server"),
             ("Deployment", "test-basic-dag-processor"),
             ("Deployment", "test-basic-scheduler"),
             ("Deployment", "test-basic-statsd"),
+            ("Deployment", "test-basic-otel-collector"),
             ("StatefulSet", "test-basic-triggerer"),
             ("StatefulSet", "test-basic-postgresql"),
             ("StatefulSet", "test-basic-redis"),
@@ -191,6 +199,7 @@ class TestBaseChartTest:
             ("NetworkPolicy", "test-basic-pgbouncer-policy"),
             ("NetworkPolicy", "test-basic-scheduler-policy"),
             ("NetworkPolicy", "test-basic-statsd-policy"),
+            ("NetworkPolicy", "test-basic-otel-collector-policy"),
             ("NetworkPolicy", "test-basic-worker-policy"),
         ]
 
diff --git a/helm-tests/tests/helm_tests/otel_collector/__init__.py 
b/helm-tests/tests/helm_tests/otel_collector/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/helm-tests/tests/helm_tests/otel_collector/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git 
a/helm-tests/tests/helm_tests/otel_collector/test_labels_deployment.py 
b/helm-tests/tests/helm_tests/otel_collector/test_labels_deployment.py
new file mode 100644
index 00000000000..40d2e7b33d6
--- /dev/null
+++ b/helm-tests/tests/helm_tests/otel_collector/test_labels_deployment.py
@@ -0,0 +1,113 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import jmespath
+from chart_utils.helm_template_generator import render_chart
+
+
+class TestOtelCollectorDeployment:
+    """Tests OTel Collector deployment labels."""
+
+    TEMPLATE_FILE = "templates/otel-collector/otel-collector-deployment.yaml"
+
+    def test_should_add_global_labels_to_metadata(self):
+        """Test adding only .Values.labels to metadata.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {"tracesEnabled": True},
+                "labels": {"test_global_label": "test_global_label_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_global_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert jmespath.search("metadata.labels", 
docs[0])["test_global_label"] == "test_global_label_value"
+
+    def test_should_add_global_labels_to_pod_template(self):
+        """Test adding only .Values.labels to spec.template.metadata.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {"tracesEnabled": True},
+                "labels": {"test_global_label": "test_global_label_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_global_label" in 
jmespath.search("spec.template.metadata.labels", docs[0])
+        assert (
+            jmespath.search("spec.template.metadata.labels", 
docs[0])["test_global_label"]
+            == "test_global_label_value"
+        )
+
+    def test_should_add_component_specific_labels_to_pod_template(self):
+        """Test adding only .Values.otelCollector.labels to 
spec.template.metadata.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"test_component_label": 
"test_component_label_value"},
+                },
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_component_label" in 
jmespath.search("spec.template.metadata.labels", docs[0])
+        assert (
+            jmespath.search("spec.template.metadata.labels", 
docs[0])["test_component_label"]
+            == "test_component_label_value"
+        )
+
+    def 
test_should_merge_global_and_component_specific_labels_in_pod_template(self):
+        """Test adding both .Values.labels and .Values.otelCollector.labels to 
spec.template.metadata.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"test_component_label": 
"test_component_label_value"},
+                },
+                "labels": {"test_global_label": "test_global_label_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_global_label" in 
jmespath.search("spec.template.metadata.labels", docs[0])
+        assert (
+            jmespath.search("spec.template.metadata.labels", 
docs[0])["test_global_label"]
+            == "test_global_label_value"
+        )
+        assert "test_component_label" in 
jmespath.search("spec.template.metadata.labels", docs[0])
+        assert (
+            jmespath.search("spec.template.metadata.labels", 
docs[0])["test_component_label"]
+            == "test_component_label_value"
+        )
+
+    def test_component_specific_labels_should_override_global_labels(self):
+        """Test that component-specific labels take precedence over global 
labels with the same key."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"common_label": "component_value"},
+                },
+                "labels": {"common_label": "global_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "common_label" in 
jmespath.search("spec.template.metadata.labels", docs[0])
+        assert jmespath.search("spec.template.metadata.labels", 
docs[0])["common_label"] == "component_value"
diff --git 
a/helm-tests/tests/helm_tests/otel_collector/test_labels_networkpolicy.py 
b/helm-tests/tests/helm_tests/otel_collector/test_labels_networkpolicy.py
new file mode 100644
index 00000000000..57595157cc1
--- /dev/null
+++ b/helm-tests/tests/helm_tests/otel_collector/test_labels_networkpolicy.py
@@ -0,0 +1,98 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import jmespath
+from chart_utils.helm_template_generator import render_chart
+
+
+class TestOtelCollectorNetworkPolicy:
+    """Tests OTel Collector network policy labels."""
+
+    TEMPLATE_FILE = 
"templates/otel-collector/otel-collector-networkpolicy.yaml"
+
+    def test_should_add_global_labels(self):
+        """Test adding only .Values.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {"tracesEnabled": True},
+                "networkPolicies": {"enabled": True},
+                "labels": {"test_global_label": "test_global_label_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_global_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert jmespath.search("metadata.labels", 
docs[0])["test_global_label"] == "test_global_label_value"
+
+    def test_should_add_component_specific_labels(self):
+        """Test adding only .Values.otelCollector.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"test_component_label": 
"test_component_label_value"},
+                },
+                "networkPolicies": {"enabled": True},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_component_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert (
+            jmespath.search("metadata.labels", docs[0])["test_component_label"]
+            == "test_component_label_value"
+        )
+
+    def test_should_merge_global_and_component_specific_labels(self):
+        """Test adding both .Values.labels and .Values.otelCollector.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"test_component_label": 
"test_component_label_value"},
+                },
+                "networkPolicies": {"enabled": True},
+                "labels": {"test_global_label": "test_global_label_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_global_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert jmespath.search("metadata.labels", 
docs[0])["test_global_label"] == "test_global_label_value"
+        assert "test_component_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert (
+            jmespath.search("metadata.labels", docs[0])["test_component_label"]
+            == "test_component_label_value"
+        )
+
+    def test_component_specific_labels_should_override_global_labels(self):
+        """Test that component-specific labels take precedence over global 
labels with the same key."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"common_label": "component_value"},
+                },
+                "networkPolicies": {"enabled": True},
+                "labels": {"common_label": "global_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "common_label" in jmespath.search("metadata.labels", docs[0])
+        assert jmespath.search("metadata.labels", docs[0])["common_label"] == 
"component_value"
diff --git a/helm-tests/tests/helm_tests/otel_collector/test_labels_service.py 
b/helm-tests/tests/helm_tests/otel_collector/test_labels_service.py
new file mode 100644
index 00000000000..f92abc10205
--- /dev/null
+++ b/helm-tests/tests/helm_tests/otel_collector/test_labels_service.py
@@ -0,0 +1,94 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import jmespath
+from chart_utils.helm_template_generator import render_chart
+
+
+class TestOtelCollectorService:
+    """Tests OTel Collector service labels."""
+
+    TEMPLATE_FILE = "templates/otel-collector/otel-collector-service.yaml"
+
+    def test_should_add_global_labels(self):
+        """Test adding only .Values.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {"tracesEnabled": True},
+                "labels": {"test_global_label": "test_global_label_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_global_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert jmespath.search("metadata.labels", 
docs[0])["test_global_label"] == "test_global_label_value"
+
+    def test_should_add_component_specific_labels(self):
+        """Test adding only .Values.otelCollector.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"test_component_label": 
"test_component_label_value"},
+                },
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_component_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert (
+            jmespath.search("metadata.labels", docs[0])["test_component_label"]
+            == "test_component_label_value"
+        )
+
+    def test_should_merge_global_and_component_specific_labels(self):
+        """Test adding both .Values.labels and .Values.otelCollector.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"test_component_label": 
"test_component_label_value"},
+                },
+                "labels": {"test_global_label": "test_global_label_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_global_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert jmespath.search("metadata.labels", 
docs[0])["test_global_label"] == "test_global_label_value"
+        assert "test_component_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert (
+            jmespath.search("metadata.labels", docs[0])["test_component_label"]
+            == "test_component_label_value"
+        )
+
+    def test_component_specific_labels_should_override_global_labels(self):
+        """Test that component-specific labels take precedence over global 
labels with the same key."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"common_label": "component_value"},
+                },
+                "labels": {"common_label": "global_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "common_label" in jmespath.search("metadata.labels", docs[0])
+        assert jmespath.search("metadata.labels", docs[0])["common_label"] == 
"component_value"
diff --git 
a/helm-tests/tests/helm_tests/otel_collector/test_labels_serviceaccount.py 
b/helm-tests/tests/helm_tests/otel_collector/test_labels_serviceaccount.py
new file mode 100644
index 00000000000..c899670766f
--- /dev/null
+++ b/helm-tests/tests/helm_tests/otel_collector/test_labels_serviceaccount.py
@@ -0,0 +1,94 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import jmespath
+from chart_utils.helm_template_generator import render_chart
+
+
+class TestOtelCollectorServiceAccount:
+    """Tests OTel Collector service account labels."""
+
+    TEMPLATE_FILE = 
"templates/otel-collector/otel-collector-serviceaccount.yaml"
+
+    def test_should_add_global_labels(self):
+        """Test adding only .Values.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {"tracesEnabled": True},
+                "labels": {"test_global_label": "test_global_label_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_global_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert jmespath.search("metadata.labels", 
docs[0])["test_global_label"] == "test_global_label_value"
+
+    def test_should_add_component_specific_labels(self):
+        """Test adding only .Values.otelCollector.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"test_component_label": 
"test_component_label_value"},
+                },
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_component_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert (
+            jmespath.search("metadata.labels", docs[0])["test_component_label"]
+            == "test_component_label_value"
+        )
+
+    def test_should_merge_global_and_component_specific_labels(self):
+        """Test adding both .Values.labels and .Values.otelCollector.labels."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"test_component_label": 
"test_component_label_value"},
+                },
+                "labels": {"test_global_label": "test_global_label_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "test_global_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert jmespath.search("metadata.labels", 
docs[0])["test_global_label"] == "test_global_label_value"
+        assert "test_component_label" in jmespath.search("metadata.labels", 
docs[0])
+        assert (
+            jmespath.search("metadata.labels", docs[0])["test_component_label"]
+            == "test_component_label_value"
+        )
+
+    def test_component_specific_labels_should_override_global_labels(self):
+        """Test that component-specific labels take precedence over global 
labels with the same key."""
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "tracesEnabled": True,
+                    "labels": {"common_label": "component_value"},
+                },
+                "labels": {"common_label": "global_value"},
+            },
+            show_only=[self.TEMPLATE_FILE],
+        )
+
+        assert "common_label" in jmespath.search("metadata.labels", docs[0])
+        assert jmespath.search("metadata.labels", docs[0])["common_label"] == 
"component_value"
diff --git a/helm-tests/tests/helm_tests/otel_collector/test_otel_collector.py 
b/helm-tests/tests/helm_tests/otel_collector/test_otel_collector.py
new file mode 100644
index 00000000000..70ef3aec2fd
--- /dev/null
+++ b/helm-tests/tests/helm_tests/otel_collector/test_otel_collector.py
@@ -0,0 +1,484 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import jmespath
+import pytest
+import yaml
+from chart_utils.helm_template_generator import render_chart
+
+OTEL_TEMPLATES = [
+    "templates/configmaps/otel-collector-configmap.yaml",
+    "templates/otel-collector/otel-collector-deployment.yaml",
+    "templates/otel-collector/otel-collector-service.yaml",
+    "templates/otel-collector/otel-collector-serviceaccount.yaml",
+]
+
+DEPLOYMENT_TEMPLATE = "templates/otel-collector/otel-collector-deployment.yaml"
+SERVICE_TEMPLATE = "templates/otel-collector/otel-collector-service.yaml"
+CONFIGMAP_TEMPLATE = "templates/configmaps/otel-collector-configmap.yaml"
+SERVICE_ACCOUNT_TEMPLATE = 
"templates/otel-collector/otel-collector-serviceaccount.yaml"
+AIRFLOW_CONFIGMAP_TEMPLATE = "templates/configmaps/configmap.yaml"
+
+AIRFLOW_POD_TEMPLATES = [
+    "templates/api-server/api-server-deployment.yaml",
+    "templates/scheduler/scheduler-deployment.yaml",
+    "templates/workers/worker-deployment.yaml",
+    "templates/triggerer/triggerer-deployment.yaml",
+    "templates/dag-processor/dag-processor-deployment.yaml",
+]
+
+
+def _env_names(doc):
+    """Return the list of env var names from the first container of a pod 
spec."""
+    env = jmespath.search("spec.template.spec.containers[0].env", doc) or []
+    return [e["name"] for e in env]
+
+
+def _env_value(doc, name):
+    env = jmespath.search("spec.template.spec.containers[0].env", doc) or []
+    for entry in env:
+        if entry["name"] == name:
+            return entry.get("value")
+    return None
+
+
+class TestOtelCollectorResourceGating:
+    """Resource emission depends on tracesEnabled / metricsEnabled."""
+
+    def test_default_renders_all_resources(self):
+        docs = render_chart(show_only=OTEL_TEMPLATES)
+        kinds = {d["kind"] for d in docs}
+        assert kinds == {"ConfigMap", "Deployment", "Service", 
"ServiceAccount"}
+
+    def test_both_flags_disabled_renders_nothing(self):
+        docs = render_chart(
+            values={"otelCollector": {"tracesEnabled": False, 
"metricsEnabled": False}},
+            show_only=OTEL_TEMPLATES,
+        )
+        assert docs == []
+
+    def test_metrics_only_renders_all_resources(self):
+        docs = render_chart(
+            values={"otelCollector": {"tracesEnabled": False, 
"metricsEnabled": True}},
+            show_only=OTEL_TEMPLATES,
+        )
+        kinds = {d["kind"] for d in docs}
+        assert kinds == {"ConfigMap", "Deployment", "Service", 
"ServiceAccount"}
+
+
+class TestOtelCollectorDefaultConfig:
+    """The chart-rendered config.yml has the default config for the 
otel-collector."""
+
+    @staticmethod
+    def _get_config_yml(values=None):
+        docs = render_chart(values=values or {}, 
show_only=[CONFIGMAP_TEMPLATE])
+        config_yml = jmespath.search('data."config.yml"', docs[0])
+        return yaml.safe_load(config_yml)
+
+    def test_health_check_extension(self):
+        config = self._get_config_yml()
+        assert config["extensions"]["health_check"]["endpoint"] == 
"0.0.0.0:13133"
+
+    def test_otlp_receivers_on_default_ports(self):
+        config = self._get_config_yml()
+        protocols = config["receivers"]["otlp"]["protocols"]
+        assert protocols["grpc"]["endpoint"] == "0.0.0.0:4317"
+        assert protocols["http"]["endpoint"] == "0.0.0.0:4318"
+
+    def test_default_exporters_are_logging_only(self):
+        config = self._get_config_yml()
+        assert set(config["exporters"].keys()) == {"logging"}
+
+    def test_default_pipelines_only_export_to_logging(self):
+        config = self._get_config_yml()
+        pipelines = config["service"]["pipelines"]
+        assert pipelines["traces"]["exporters"] == ["logging"]
+        assert pipelines["metrics"]["exporters"] == ["logging"]
+
+
+class TestOtelCollectorConfigOverride:
+    """`otelCollector.config` replaces the default and supports `tpl`."""
+
+    def test_override_replaces_default(self):
+        override = (
+            "receivers:\n"
+            "  otlp:\n"
+            "    protocols:\n"
+            "      http:\n"
+            "        endpoint: 0.0.0.0:9999\n"
+            "exporters:\n"
+            "  logging: {}\n"
+            "service:\n"
+            "  pipelines:\n"
+            "    traces:\n"
+            "      receivers: [otlp]\n"
+            "      exporters: [logging]\n"
+        )
+        docs = render_chart(
+            values={"otelCollector": {"config": override}},
+            show_only=[CONFIGMAP_TEMPLATE],
+        )
+        rendered = yaml.safe_load(jmespath.search('data."config.yml"', 
docs[0]))
+        # The value is '0.0.0.0:9999' instead of the default '0.0.0.0:4318'.
+        assert rendered["receivers"]["otlp"]["protocols"]["http"]["endpoint"] 
== "0.0.0.0:9999"
+        # default `health_check` extension is gone in the override
+        assert "extensions" not in rendered
+
+    def test_tpl_resolves_chart_values(self):
+        override = (
+            "receivers:\n"
+            "  otlp:\n"
+            "    protocols:\n"
+            "      http:\n"
+            "        endpoint: 0.0.0.0:{{ .Values.ports.otelCollectorOtlpHttp 
}}\n"
+        )
+        docs = render_chart(
+            values={"otelCollector": {"config": override}},
+            show_only=[CONFIGMAP_TEMPLATE],
+        )
+        rendered = yaml.safe_load(jmespath.search('data."config.yml"', 
docs[0]))
+        assert rendered["receivers"]["otlp"]["protocols"]["http"]["endpoint"] 
== "0.0.0.0:4318"
+
+
+class TestOtelCollectorDeployment:
+    """Deployment-level configurability."""
+
+    def test_default_args(self):
+        docs = render_chart(show_only=[DEPLOYMENT_TEMPLATE])
+        assert jmespath.search("spec.template.spec.containers[0].args", 
docs[0]) == [
+            "--config=/etc/otel-collector/config.yml"
+        ]
+
+    def test_args_override(self):
+        custom = ["--config=/etc/otel-collector/config.yml", 
"--feature-gates=+foo"]
+        docs = render_chart(
+            values={"otelCollector": {"args": custom}},
+            show_only=[DEPLOYMENT_TEMPLATE],
+        )
+        assert jmespath.search("spec.template.spec.containers[0].args", 
docs[0]) == custom
+
+    def test_default_probes(self):
+        docs = render_chart(show_only=[DEPLOYMENT_TEMPLATE])
+        liveness = 
jmespath.search("spec.template.spec.containers[0].livenessProbe", docs[0])
+        readiness = 
jmespath.search("spec.template.spec.containers[0].readinessProbe", docs[0])
+        assert liveness["initialDelaySeconds"] == 10
+        assert liveness["periodSeconds"] == 15
+        assert liveness["httpGet"] == {"path": "/", "port": 13133}
+        assert readiness["initialDelaySeconds"] == 10
+        assert readiness["periodSeconds"] == 15
+
+    def test_probe_overrides(self):
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "livenessProbe": {"initialDelaySeconds": 30, 
"periodSeconds": 45},
+                    "readinessProbe": {"initialDelaySeconds": 5, 
"periodSeconds": 7},
+                }
+            },
+            show_only=[DEPLOYMENT_TEMPLATE],
+        )
+        liveness = 
jmespath.search("spec.template.spec.containers[0].livenessProbe", docs[0])
+        readiness = 
jmespath.search("spec.template.spec.containers[0].readinessProbe", docs[0])
+        assert liveness["initialDelaySeconds"] == 30
+        assert liveness["periodSeconds"] == 45
+        assert readiness["initialDelaySeconds"] == 5
+        assert readiness["periodSeconds"] == 7
+
+    def test_resources_default_empty(self):
+        docs = render_chart(show_only=[DEPLOYMENT_TEMPLATE])
+        assert jmespath.search("spec.template.spec.containers[0].resources", 
docs[0]) == {}
+
+    def test_resources_configurable(self):
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "resources": {
+                        "limits": {"cpu": "200m", "memory": "256Mi"},
+                        "requests": {"cpu": "100m", "memory": "128Mi"},
+                    }
+                }
+            },
+            show_only=[DEPLOYMENT_TEMPLATE],
+        )
+        resources = 
jmespath.search("spec.template.spec.containers[0].resources", docs[0])
+        assert resources == {
+            "limits": {"cpu": "200m", "memory": "256Mi"},
+            "requests": {"cpu": "100m", "memory": "128Mi"},
+        }
+
+    @pytest.mark.parametrize(
+        ("values", "expected"),
+        [
+            ({}, 30),
+            ({"otelCollector": {"terminationGracePeriodSeconds": 1200}}, 1200),
+        ],
+    )
+    def test_termination_grace_period_seconds(self, values, expected):
+        docs = render_chart(values=values, show_only=[DEPLOYMENT_TEMPLATE])
+        assert 
jmespath.search("spec.template.spec.terminationGracePeriodSeconds", docs[0]) == 
expected
+
+    @pytest.mark.parametrize(
+        ("component", "global_", "expected"),
+        [(8, 10, 8), (10, 8, 10), (8, None, 8), (None, 10, 10), (0, 10, 0), 
(None, 0, 0)],
+    )
+    def test_revision_history_limit(self, component, global_, expected):
+        values = {"otelCollector": {}}
+        if component is not None:
+            values["otelCollector"]["revisionHistoryLimit"] = component
+        if global_ is not None:
+            values["revisionHistoryLimit"] = global_
+        docs = render_chart(values=values, show_only=[DEPLOYMENT_TEMPLATE])
+        assert jmespath.search("spec.revisionHistoryLimit", docs[0]) == 
expected
+
+    def test_scheduling_constraints(self):
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "nodeSelector": {"diskType": "ssd"},
+                    "tolerations": [{"key": "k", "operator": "Equal", "value": 
"v", "effect": "NoSchedule"}],
+                    "affinity": {
+                        "nodeAffinity": {
+                            "requiredDuringSchedulingIgnoredDuringExecution": {
+                                "nodeSelectorTerms": [
+                                    {
+                                        "matchExpressions": [
+                                            {"key": "foo", "operator": "In", 
"values": ["true"]}
+                                        ]
+                                    }
+                                ]
+                            }
+                        }
+                    },
+                    "topologySpreadConstraints": [
+                        {"maxSkew": 1, "topologyKey": "zone", 
"whenUnsatisfiable": "DoNotSchedule"}
+                    ],
+                    "priorityClassName": "high-priority",
+                }
+            },
+            show_only=[DEPLOYMENT_TEMPLATE],
+        )
+        spec = jmespath.search("spec.template.spec", docs[0])
+        assert spec["nodeSelector"] == {"diskType": "ssd"}
+        assert spec["tolerations"][0]["key"] == "k"
+        assert (
+            
spec["affinity"]["nodeAffinity"]["requiredDuringSchedulingIgnoredDuringExecution"][
+                "nodeSelectorTerms"
+            ][0]["matchExpressions"][0]["key"]
+            == "foo"
+        )
+        assert spec["topologySpreadConstraints"][0]["topologyKey"] == "zone"
+        assert spec["priorityClassName"] == "high-priority"
+
+    def test_security_contexts(self):
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "securityContexts": {
+                        "pod": {"runAsUser": 2000, "fsGroup": 1000},
+                        "container": {"allowPrivilegeEscalation": False, 
"readOnlyRootFilesystem": True},
+                    }
+                }
+            },
+            show_only=[DEPLOYMENT_TEMPLATE],
+        )
+        assert jmespath.search("spec.template.spec.securityContext", docs[0]) 
== {
+            "runAsUser": 2000,
+            "fsGroup": 1000,
+        }
+        assert 
jmespath.search("spec.template.spec.containers[0].securityContext", docs[0]) == 
{
+            "allowPrivilegeEscalation": False,
+            "readOnlyRootFilesystem": True,
+        }
+
+    def test_annotations_and_pod_annotations(self):
+        docs = render_chart(
+            values={
+                "otelCollector": {
+                    "annotations": {"deploy_anno": "deploy_value"},
+                    "podAnnotations": {"pod_anno": "pod_value"},
+                }
+            },
+            show_only=[DEPLOYMENT_TEMPLATE],
+        )
+        assert jmespath.search("metadata.annotations.deploy_anno", docs[0]) == 
"deploy_value"
+        assert jmespath.search("spec.template.metadata.annotations.pod_anno", 
docs[0]) == "pod_value"
+
+    def test_image_override(self):
+        docs = render_chart(
+            values={
+                "images": {
+                    "otelCollector": {
+                        "repository": "example/custom-otel",
+                        "tag": "1.2.3",
+                        "pullPolicy": "Always",
+                    }
+                }
+            },
+            show_only=[DEPLOYMENT_TEMPLATE],
+        )
+        assert (
+            jmespath.search("spec.template.spec.containers[0].image", docs[0]) 
== "example/custom-otel:1.2.3"
+        )
+        assert 
jmespath.search("spec.template.spec.containers[0].imagePullPolicy", docs[0]) == 
"Always"
+
+
+class TestOtelCollectorService:
+    """Service-level configurability."""
+
+    def test_service_annotations(self):
+        docs = render_chart(
+            values={"otelCollector": {"service": {"annotations": {"some_anno": 
"some_value"}}}},
+            show_only=[SERVICE_TEMPLATE],
+        )
+        assert jmespath.search("metadata.annotations.some_anno", docs[0]) == 
"some_value"
+
+    def test_service_no_annotations_block_when_unset(self):
+        docs = render_chart(show_only=[SERVICE_TEMPLATE])
+        assert "annotations" not in jmespath.search("metadata", docs[0])
+
+
+class TestOtelCollectorServiceAccount:
+    """ServiceAccount-level configurability."""
+
+    def test_default_create(self):
+        docs = render_chart(show_only=[SERVICE_ACCOUNT_TEMPLATE])
+        assert docs[0]["kind"] == "ServiceAccount"
+        assert docs[0]["automountServiceAccountToken"] is False
+
+    def test_create_false_suppresses_sa(self):
+        docs = render_chart(
+            values={"otelCollector": {"serviceAccount": {"create": False}}},
+            show_only=[SERVICE_ACCOUNT_TEMPLATE],
+        )
+        assert docs == []
+
+    def test_create_false_deployment_uses_default_sa(self):
+        docs = render_chart(
+            values={"otelCollector": {"serviceAccount": {"create": False}}},
+            show_only=[DEPLOYMENT_TEMPLATE],
+        )
+        assert jmespath.search("spec.template.spec.serviceAccountName", 
docs[0]) == "default"
+
+    def test_custom_sa_name_propagates_to_deployment(self):
+        docs = render_chart(
+            values={"otelCollector": {"serviceAccount": {"name": 
"my-custom-sa"}}},
+            show_only=[DEPLOYMENT_TEMPLATE, SERVICE_ACCOUNT_TEMPLATE],
+        )
+        sa = next(d for d in docs if d["kind"] == "ServiceAccount")
+        deployment = next(d for d in docs if d["kind"] == "Deployment")
+        assert sa["metadata"]["name"] == "my-custom-sa"
+        assert jmespath.search("spec.template.spec.serviceAccountName", 
deployment) == "my-custom-sa"
+
+    def test_automount_token_override(self):
+        docs = render_chart(
+            values={"otelCollector": {"serviceAccount": 
{"automountServiceAccountToken": True}}},
+            show_only=[SERVICE_ACCOUNT_TEMPLATE],
+        )
+        assert docs[0]["automountServiceAccountToken"] is True
+
+    def test_sa_annotations(self):
+        docs = render_chart(
+            values={"otelCollector": {"serviceAccount": {"annotations": 
{"sa_anno": "sa_value"}}}},
+            show_only=[SERVICE_ACCOUNT_TEMPLATE],
+        )
+        assert jmespath.search("metadata.annotations.sa_anno", docs[0]) == 
"sa_value"
+
+
+class TestOtelCollectorAirflowEnvironment:
+    """The correct OTEL_* env vars can be found on Airflow pods based on the 
trace/metrics flags."""
+
+    @pytest.mark.parametrize("template", AIRFLOW_POD_TEMPLATES)
+    def test_default_emits_traces_env_only(self, template):
+        docs = render_chart(show_only=[template])
+        names = _env_names(docs[0])
+        assert "OTEL_SERVICE_NAME" in names
+        assert "OTEL_EXPORTER_OTLP_PROTOCOL" in names
+        assert "OTEL_TRACES_EXPORTER" in names
+        assert "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" in names
+        # Metrics env vars aren't present.
+        assert "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" not in names
+        assert "OTEL_METRIC_EXPORT_INTERVAL" not in names
+
+    @pytest.mark.parametrize("template", AIRFLOW_POD_TEMPLATES)
+    def test_metrics_enabled_emits_metrics_env(self, template):
+        docs = render_chart(
+            values={"otelCollector": {"metricsEnabled": True}},
+            show_only=[template],
+        )
+        names = _env_names(docs[0])
+        assert "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" in names
+        assert "OTEL_METRIC_EXPORT_INTERVAL" in names
+
+    @pytest.mark.parametrize("template", AIRFLOW_POD_TEMPLATES)
+    def test_both_flags_off_emits_no_otel_env(self, template):
+        docs = render_chart(
+            values={"otelCollector": {"tracesEnabled": False, 
"metricsEnabled": False}},
+            show_only=[template],
+        )
+        names = _env_names(docs[0])
+        assert not any(n.startswith("OTEL_") for n in names)
+
+    def test_metric_export_interval_value(self):
+        docs = render_chart(
+            values={"otelCollector": {"metricsEnabled": True, 
"metricExportIntervalMs": 12345}},
+            show_only=[AIRFLOW_POD_TEMPLATES[0]],
+        )
+        assert _env_value(docs[0], "OTEL_METRIC_EXPORT_INTERVAL") == "12345"
+
+
+class TestOtelCollectorAirflowConfig:
+    """Renders the correct values in airflow.cfg."""
+
+    @staticmethod
+    def _get_conf_section(cfg: str, name: str) -> str:
+        """Return the body of the specified section (everything until the next 
'[')."""
+        return cfg.split(f"[{name}]")[1].split("[")[0]
+
+    @staticmethod
+    def _get_airflow_conf(values=None):
+        docs = render_chart(values=values or {}, 
show_only=[AIRFLOW_CONFIGMAP_TEMPLATE])
+        return jmespath.search('data."airflow.cfg"', docs[0])
+
+    def test_default_traces_section_has_otel_on_true(self):
+        traces = self._get_conf_section(self._get_airflow_conf(), "traces")
+        assert "otel_on = True" in traces
+
+    def test_default_metrics_section_has_otel_off_and_statsd_on(self):
+        metrics = self._get_conf_section(self._get_airflow_conf(), "metrics")
+        assert "otel_on = False" in metrics
+        assert "statsd_on = True" in metrics
+
+    def test_traces_disabled_otel_off(self):
+        traces = self._get_conf_section(
+            self._get_airflow_conf({"otelCollector": {"tracesEnabled": 
False}}), "traces"
+        )
+        assert "otel_on = False" in traces
+
+    def test_metrics_enabled_disables_statsd_and_enables_otel(self):
+        metrics = self._get_conf_section(
+            self._get_airflow_conf({"otelCollector": {"metricsEnabled": 
True}}), "metrics"
+        )
+        assert "statsd_on = False" in metrics
+        assert "otel_on = True" in metrics
+
+    def test_metrics_enabled_with_statsd_disabled_still_disables_statsd(self):
+        cfg = self._get_airflow_conf(
+            {"statsd": {"enabled": False}, "otelCollector": {"metricsEnabled": 
True}}
+        )
+        metrics_section = cfg.split("[metrics]")[1].split("[")[0]
+        assert "statsd_on = False" in metrics_section
diff --git a/helm-tests/tests/helm_tests/security/test_rbac.py 
b/helm-tests/tests/helm_tests/security/test_rbac.py
index 09e44f5ae2f..4a4a7785b09 100644
--- a/helm-tests/tests/helm_tests/security/test_rbac.py
+++ b/helm-tests/tests/helm_tests/security/test_rbac.py
@@ -29,10 +29,12 @@ DEPLOYMENT_NO_RBAC_NO_SA_KIND_NAME_TUPLES = [
     ("Secret", "test-rbac-pgbouncer-stats"),
     ("ConfigMap", "test-rbac-config"),
     ("ConfigMap", "test-rbac-statsd"),
+    ("ConfigMap", "test-rbac-otel-collector"),
     ("Service", "test-rbac-api-server"),
     ("Service", "test-rbac-postgresql-hl"),
     ("Service", "test-rbac-postgresql"),
     ("Service", "test-rbac-statsd"),
+    ("Service", "test-rbac-otel-collector"),
     ("Service", "test-rbac-flower"),
     ("Service", "test-rbac-pgbouncer"),
     ("Service", "test-rbac-redis"),
@@ -42,6 +44,7 @@ DEPLOYMENT_NO_RBAC_NO_SA_KIND_NAME_TUPLES = [
     ("Deployment", "test-rbac-dag-processor"),
     ("Deployment", "test-rbac-scheduler"),
     ("Deployment", "test-rbac-statsd"),
+    ("Deployment", "test-rbac-otel-collector"),
     ("Deployment", "test-rbac-flower"),
     ("Deployment", "test-rbac-pgbouncer"),
     ("StatefulSet", "test-rbac-postgresql"),
@@ -79,6 +82,7 @@ SERVICE_ACCOUNT_NAME_TUPLES = [
     ("ServiceAccount", "test-rbac-database-cleanup"),
     ("ServiceAccount", "test-rbac-flower"),
     ("ServiceAccount", "test-rbac-statsd"),
+    ("ServiceAccount", "test-rbac-otel-collector"),
     ("ServiceAccount", "test-rbac-create-user-job"),
     ("ServiceAccount", "test-rbac-migrate-database-job"),
     ("ServiceAccount", "test-rbac-redis"),
@@ -95,6 +99,7 @@ CUSTOM_SERVICE_ACCOUNT_NAMES = (
     (CUSTOM_FLOWER_NAME := "TestFlower"),
     (CUSTOM_PGBOUNCER_NAME := "TestPGBouncer"),
     (CUSTOM_STATSD_NAME := "TestStatsd"),
+    (CUSTOM_OTEL_COLLECTOR_NAME := "TestOtelCollector"),
     (CUSTOM_CREATE_USER_JOBS_NAME := "TestCreateUserJob"),
     (CUSTOM_MIGRATE_DATABASE_JOBS_NAME := "TestMigrateDatabaseJob"),
     (CUSTOM_REDIS_NAME := "TestRedis"),
@@ -145,6 +150,7 @@ class TestRBAC:
                 "workers": {"serviceAccount": {"create": False}},
                 "triggerer": {"serviceAccount": {"create": False}},
                 "statsd": {"serviceAccount": {"create": False}},
+                "otelCollector": {"serviceAccount": {"create": False}},
                 "createUserJob": {"serviceAccount": {"create": False}},
                 "migrateDatabaseJob": {"serviceAccount": {"create": False}},
                 "flower": {"enabled": True, "serviceAccount": {"create": 
False}},
@@ -199,6 +205,7 @@ class TestRBAC:
                 "triggerer": {"serviceAccount": {"create": False}},
                 "flower": {"enabled": True, "serviceAccount": {"create": 
False}},
                 "statsd": {"serviceAccount": {"create": False}},
+                "otelCollector": {"serviceAccount": {"create": False}},
                 "redis": {"serviceAccount": {"create": False}},
                 "pgbouncer": {
                     "enabled": True,
@@ -281,6 +288,7 @@ class TestRBAC:
                 "triggerer": {"serviceAccount": {"name": 
CUSTOM_TRIGGERER_NAME}},
                 "flower": {"enabled": True, "serviceAccount": {"name": 
CUSTOM_FLOWER_NAME}},
                 "statsd": {"serviceAccount": {"name": CUSTOM_STATSD_NAME}},
+                "otelCollector": {"serviceAccount": {"name": 
CUSTOM_OTEL_COLLECTOR_NAME}},
                 "redis": {"serviceAccount": {"name": CUSTOM_REDIS_NAME}},
                 "postgresql": {"serviceAccount": {"create": True, "name": 
CUSTOM_POSTGRESQL_NAME}},
                 "pgbouncer": {
@@ -325,6 +333,7 @@ class TestRBAC:
                 "triggerer": {"serviceAccount": {"name": 
CUSTOM_TRIGGERER_NAME}},
                 "flower": {"enabled": True, "serviceAccount": {"name": 
CUSTOM_FLOWER_NAME}},
                 "statsd": {"serviceAccount": {"name": CUSTOM_STATSD_NAME}},
+                "otelCollector": {"serviceAccount": {"name": 
CUSTOM_OTEL_COLLECTOR_NAME}},
                 "redis": {"serviceAccount": {"name": CUSTOM_REDIS_NAME}},
                 "postgresql": {"serviceAccount": {"name": 
CUSTOM_POSTGRESQL_NAME}},
                 "pgbouncer": {
@@ -363,6 +372,7 @@ class TestRBAC:
                 "redis": {"enabled": False},
                 "flower": {"enabled": False},
                 "statsd": {"enabled": False},
+                "otelCollector": {"tracesEnabled": False, "metricsEnabled": 
False},
             },
         )
         list_of_sa_names = [


Reply via email to