Skip to content

Commit ae355c3

Browse files
committed
feat: Add OTEL-native Container Insights log pipelines (application, host)
Adds opt-in OTEL log collection to the existing CloudWatch Agent DaemonSet. No new K8s resources created — logs config is injected into the agent's otelConfig when otelContainerInsights.logs=true. Pipelines: - logs/cw_k8s_ci_v0_app: container logs via filelog + container_log_parser - logs/cw_k8s_ci_v0_node: host OS logs (/var/log/messages, dmesg, secure) Key design decisions: - Shares k8sattributes processor instances with metrics (one API watch) - cwlogsprovision extension pre-creates log groups/streams at startup - Log groups: /aws/otel/containerinsights/<cluster>/{application,host} - Simple config: enabled=false ignores everything under otelContainerInsights - No cross-flag validation with FluentBit (deferred to v7.0.0) - Dataplane logs deferred (no journald/aws-node collection) Depends on: - amazon-contributing/opentelemetry-collector-contrib#533 - aws/amazon-cloudwatch-agent#2113
1 parent 9ee115e commit ae355c3

25 files changed

Lines changed: 1306 additions & 32 deletions

File tree

.github/workflows/amazon-cloudwatch-observability-integration-test.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ jobs:
6868
- feature-targeted-agent-config-override
6969
- otlp-custom-otel-config
7070
- otlp-disabled
71+
- otlp-logs-disabled
72+
- otlp-hybrid-metrics-fluentbit
73+
- otlp-logs-dual-publish
7174
steps:
7275
- uses: actions/checkout@v3
7376

charts/amazon-cloudwatch-observability/templates/_helpers.tpl

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,36 @@ Logic:
109109
{{- define "cloudwatch-agent.build-default-otel-config" -}}
110110
{{- $agentName := .agentName -}}
111111
{{- $ctx := .context -}}
112+
{{- /*
113+
Flag validation — enforces the 10 valid states from the CI flag state matrix.
114+
Four flags control CI behavior:
115+
- containerInsights.enabled (ECI) — legacy Container Insights metrics
116+
- containerLogs.enabled (FB) — FluentBit log pipeline
117+
- otelContainerInsights.enabled — OTEL Container Insights (metrics)
118+
- otelContainerInsights.logs — OTEL log pipelines
119+
120+
Two cross-flag rules:
121+
1. otelCI.logs=true requires otelCI.enabled=true
122+
(OTEL logs run inside the OTEL CI pipeline)
123+
2. When otelCI.enabled=true, containerLogs=true requires containerInsights=true
124+
(FluentBit is legacy telemetry; it must pair with legacy ECI metrics,
125+
never with OTEL metrics alone)
126+
*/ -}}
127+
{{- if not (kindIs "bool" $ctx.Values.containerInsights.enabled) }}
128+
{{- fail "containerInsights.enabled must be a boolean (true/false)" }}
129+
{{- end }}
130+
{{- if not (kindIs "bool" $ctx.Values.containerLogs.enabled) }}
131+
{{- fail "containerLogs.enabled must be a boolean (true/false)" }}
132+
{{- end }}
112133
{{- if not (kindIs "bool" $ctx.Values.otelContainerInsights.enabled) }}
113134
{{- fail "otelContainerInsights.enabled must be a boolean (true/false)" }}
114135
{{- end }}
136+
{{- if not (kindIs "bool" $ctx.Values.otelContainerInsights.logs) }}
137+
{{- fail "otelContainerInsights.logs must be a boolean (true/false)" }}
138+
{{- end }}
139+
{{- if and (not $ctx.Values.otelContainerInsights.enabled) $ctx.Values.otelContainerInsights.logs }}
140+
{{- fail "otelContainerInsights.logs=true requires otelContainerInsights.enabled=true" }}
141+
{{- end }}
115142
{{- if not $ctx.Values.otelContainerInsights.enabled -}}
116143
{}
117144
{{- else if eq $ctx.Values.otelContainerInsights.targetAgent $agentName -}}

charts/amazon-cloudwatch-observability/templates/linux/_otel-container-insights-config.tpl

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ extensions:
33
sigv4auth/cw_k8s_ci_v0_metrics_dest:
44
region: {{ .Values.region }}
55
service: monitoring
6+
{{- if .Values.otelContainerInsights.logs }}
7+
sigv4auth/cw_k8s_ci_v0_logs_dest:
8+
region: {{ .Values.region }}
9+
service: logs
10+
cwlogsprovision/cw_k8s_ci_v0_logs:
11+
region: {{ .Values.region }}
12+
log_groups:
13+
- name: "/aws/otel/containerinsights/{{ .Values.clusterName }}/application"
14+
streams: ["${env:K8S_NODE_NAME}-application"]
15+
- name: "/aws/otel/containerinsights/{{ .Values.clusterName }}/host"
16+
streams: ["${env:K8S_NODE_NAME}-host"]
17+
{{- end }}
618

719
receivers:
820
{{- if .Values.nodeExporter.enabled }}
@@ -126,6 +138,52 @@ receivers:
126138
k8s.node.uptime:
127139
enabled: true
128140

141+
{{- if .Values.otelContainerInsights.logs }}
142+
# ── CI Logs receivers ──
143+
filelog/cw_k8s_ci_v0_app:
144+
include:
145+
- /var/log/containers/*.log
146+
exclude:
147+
- /var/log/containers/cloudwatch-agent*
148+
- /var/log/containers/fluent-bit*
149+
# aws-node and kube-proxy are dataplane components — excluded to match
150+
# FluentBit's existing Exclude_Path behavior.
151+
- /var/log/containers/aws-node*
152+
- /var/log/containers/kube-proxy*
153+
start_at: end
154+
include_file_path: true
155+
include_file_name: false
156+
max_concurrent_files: 100
157+
operators:
158+
- type: regex_parser
159+
id: extract_metadata_from_filepath
160+
regex: '^.*\/(?P<pod>[^_]+)_(?P<namespace>[^_]+)_(?P<container>.+)-[a-f0-9]{64}\.log$'
161+
parse_from: attributes["log.file.path"]
162+
parse_to: resource
163+
- type: move
164+
from: resource.pod
165+
to: resource["k8s.pod.name"]
166+
- type: move
167+
from: resource.namespace
168+
to: resource["k8s.namespace.name"]
169+
- type: move
170+
from: resource.container
171+
to: resource["k8s.container.name"]
172+
- id: parse_container_log
173+
type: container_log_parser
174+
175+
filelog/cw_k8s_ci_v0_node:
176+
include:
177+
- /var/log/messages
178+
- /var/log/dmesg
179+
- /var/log/secure
180+
start_at: end
181+
include_file_path: true
182+
include_file_name: false
183+
max_concurrent_files: 100
184+
185+
{{- end }}
186+
129187
processors:
130188
filter/cw_k8s_ci_v0_scrape_metadata:
131189
error_mode: ignore
@@ -537,6 +595,103 @@ processors:
537595
- delete_key(attributes, "instance_id") where attributes["instance_id"] != nil
538596
- delete_key(attributes, "volume_id") where attributes["volume_id"] != nil
539597
598+
{{- if .Values.otelContainerInsights.logs }}
599+
# ── CI Logs processors ──
600+
transform/cw_k8s_ci_v0_logs_set_workload:
601+
error_mode: ignore
602+
log_statements:
603+
- context: resource
604+
statements:
605+
# Derive k8s.workload.name and k8s.workload.type — matches metrics'
606+
# transform/cw_k8s_ci_v0_set_workload exactly so metrics and logs use
607+
# the same workload identity for the same pod.
608+
- set(attributes["k8s.workload.name"], attributes["k8s.deployment.name"]) where attributes["k8s.deployment.name"] != nil
609+
- set(attributes["k8s.workload.type"], "Deployment") where attributes["k8s.deployment.name"] != nil
610+
- set(attributes["k8s.workload.name"], attributes["k8s.statefulset.name"]) where attributes["k8s.workload.name"] == nil and attributes["k8s.statefulset.name"] != nil
611+
- set(attributes["k8s.workload.type"], "StatefulSet") where attributes["k8s.statefulset.name"] != nil and attributes["k8s.workload.type"] == nil
612+
- set(attributes["k8s.workload.name"], attributes["k8s.daemonset.name"]) where attributes["k8s.workload.name"] == nil and attributes["k8s.daemonset.name"] != nil
613+
- set(attributes["k8s.workload.type"], "DaemonSet") where attributes["k8s.daemonset.name"] != nil and attributes["k8s.workload.type"] == nil
614+
- set(attributes["k8s.workload.name"], attributes["k8s.job.name"]) where attributes["k8s.workload.name"] == nil and attributes["k8s.job.name"] != nil
615+
- set(attributes["k8s.workload.type"], "Job") where attributes["k8s.job.name"] != nil and attributes["k8s.workload.type"] == nil
616+
- set(attributes["k8s.workload.name"], attributes["k8s.cronjob.name"]) where attributes["k8s.workload.name"] == nil and attributes["k8s.cronjob.name"] != nil
617+
- set(attributes["k8s.workload.type"], "CronJob") where attributes["k8s.cronjob.name"] != nil and attributes["k8s.workload.type"] == nil
618+
- set(attributes["k8s.workload.name"], attributes["k8s.replicaset.name"]) where attributes["k8s.workload.name"] == nil and attributes["k8s.replicaset.name"] != nil
619+
- set(attributes["k8s.workload.type"], "ReplicaSet") where attributes["k8s.replicaset.name"] != nil and attributes["k8s.workload.type"] == nil
620+
# Derive service.name from k8s.workload.name (OTEL logs semconv).
621+
# Logs need service.name; metrics use k8s.workload.name directly.
622+
- set(attributes["service.name"], attributes["k8s.workload.name"]) where attributes["service.name"] == nil and attributes["k8s.workload.name"] != nil
623+
624+
transform/cw_k8s_ci_v0_logs_set_cluster_name:
625+
error_mode: ignore
626+
log_statements:
627+
- context: resource
628+
statements:
629+
- set(attributes["k8s.cluster.name"], "{{ .Values.clusterName }}")
630+
- set(attributes["k8s.node.name"], "${env:K8S_NODE_NAME}")
631+
632+
resourcedetection/cw_k8s_ci_v0_logs:
633+
detectors: [eks, ec2]
634+
ec2:
635+
resource_attributes:
636+
host.id: { enabled: true }
637+
host.type: { enabled: true }
638+
host.name: { enabled: true }
639+
host.image.id: { enabled: true }
640+
cloud.provider: { enabled: true }
641+
cloud.platform: { enabled: true }
642+
cloud.region: { enabled: true }
643+
cloud.availability_zone: { enabled: true }
644+
cloud.account.id: { enabled: true }
645+
646+
transform/cw_k8s_ci_v0_logs_set_cloud_resource_id:
647+
error_mode: ignore
648+
log_statements:
649+
- context: resource
650+
statements:
651+
- set(attributes["cloud.resource_id"], Concat(["arn:aws:eks:", attributes["cloud.region"], ":", attributes["cloud.account.id"], ":cluster/", attributes["k8s.cluster.name"]], ""))
652+
where attributes["cloud.region"] != nil and attributes["cloud.account.id"] != nil and attributes["k8s.cluster.name"] != nil
653+
654+
transform/cw_k8s_ci_v0_logs_clear_schema_url:
655+
error_mode: ignore
656+
log_statements:
657+
- context: resource
658+
statements:
659+
- set(resource.schema_url, "")
660+
661+
# Scope transforms — tag each logs pipeline with cloudwatch.source/solution/pipeline
662+
# for backend attribution. Matches the metrics pipeline's transform/set_scope_*
663+
# processors so logs are attributed identically to metrics.
664+
# scope.name is intentionally not set — the metrics pipeline sets it only for
665+
# pipelines with a well-known source library (e.g., github.com/google/cadvisor).
666+
# filelog receivers don't have an equivalent upstream library, so scope.name is
667+
# omitted (matches metrics' set_scope_efa / set_scope_ebs_csi /
668+
# set_scope_kubeletstats which also omit scope.name).
669+
transform/cw_k8s_ci_v0_logs_set_scope_app:
670+
error_mode: ignore
671+
log_statements:
672+
- context: scope
673+
statements:
674+
- set(scope.schema_url, "")
675+
- set(attributes["cloudwatch.source"], "cloudwatch-agent")
676+
- set(attributes["cloudwatch.solution"], "k8s-otel-container-insights")
677+
- set(attributes["cloudwatch.pipeline"], "application-logs")
678+
679+
transform/cw_k8s_ci_v0_logs_set_scope_host:
680+
error_mode: ignore
681+
log_statements:
682+
- context: scope
683+
statements:
684+
- set(scope.schema_url, "")
685+
- set(attributes["cloudwatch.source"], "cloudwatch-agent")
686+
- set(attributes["cloudwatch.solution"], "k8s-otel-container-insights")
687+
- set(attributes["cloudwatch.pipeline"], "host-logs")
688+
689+
batch/cw_k8s_ci_v0_logs_dest:
690+
send_batch_size: 500
691+
send_batch_max_size: 500
692+
timeout: 5s
693+
{{- end }}
694+
540695
exporters:
541696
otlphttp/cw_k8s_ci_v0_metrics_dest:
542697
endpoint: {{ if .Values.otelContainerInsights.cloudwatchMetricsEndpoint }}{{ .Values.otelContainerInsights.cloudwatchMetricsEndpoint | quote }}{{ else }}"https://monitoring.{{ .Values.region }}.amazonaws.com:443"{{ end }}
@@ -545,9 +700,52 @@ exporters:
545700
auth:
546701
authenticator: sigv4auth/cw_k8s_ci_v0_metrics_dest
547702
703+
{{- if .Values.otelContainerInsights.logs }}
704+
otlphttp/cw_k8s_ci_v0_app_logs_dest:
705+
endpoint: {{ if .Values.otelContainerInsights.cloudwatchLogsEndpoint }}{{ .Values.otelContainerInsights.cloudwatchLogsEndpoint | quote }}{{ else }}"https://logs.{{ .Values.region }}.amazonaws.com:443"{{ end }}
706+
# compression: none matches FluentBit's current behavior (the aws-for-fluent-bit
707+
# cloudwatch_logs plugin does not compress by default), so customers migrating
708+
# from FluentBit see no bandwidth bill change. Enabling compression: gzip is
709+
# available as an opt-in bandwidth optimization (~5–10× reduction for typical
710+
# container logs) at the cost of +25% agent CPU — customers who raise the CPU
711+
# limit accordingly can improve on FluentBit's bandwidth cost. See OTELify CI
712+
# Logs Pipeline Optimizations doc for details.
713+
compression: none
714+
headers:
715+
x-aws-log-group: "/aws/otel/containerinsights/{{ .Values.clusterName }}/application"
716+
x-aws-log-stream: "${env:K8S_NODE_NAME}-application"
717+
sending_queue:
718+
queue_size: 500
719+
num_consumers: 10
720+
tls:
721+
insecure: false
722+
auth:
723+
authenticator: sigv4auth/cw_k8s_ci_v0_logs_dest
724+
725+
otlphttp/cw_k8s_ci_v0_node_logs_dest:
726+
endpoint: {{ if .Values.otelContainerInsights.cloudwatchLogsEndpoint }}{{ .Values.otelContainerInsights.cloudwatchLogsEndpoint | quote }}{{ else }}"https://logs.{{ .Values.region }}.amazonaws.com:443"{{ end }}
727+
# See app_logs_dest comment for compression tradeoff rationale.
728+
compression: none
729+
headers:
730+
x-aws-log-group: "/aws/otel/containerinsights/{{ .Values.clusterName }}/host"
731+
x-aws-log-stream: "${env:K8S_NODE_NAME}-host"
732+
sending_queue:
733+
queue_size: 500
734+
num_consumers: 10
735+
tls:
736+
insecure: false
737+
auth:
738+
authenticator: sigv4auth/cw_k8s_ci_v0_logs_dest
739+
740+
{{- end }}
741+
548742
service:
549743
extensions:
550744
- sigv4auth/cw_k8s_ci_v0_metrics_dest
745+
{{- if .Values.otelContainerInsights.logs }}
746+
- sigv4auth/cw_k8s_ci_v0_logs_dest
747+
- cwlogsprovision/cw_k8s_ci_v0_logs
748+
{{- end }}
551749
pipelines:
552750
{{- if .Values.nodeExporter.enabled }}
553751
metrics/cw_k8s_ci_v0_node_exporter:
@@ -693,4 +891,42 @@ service:
693891
exporters:
694892
- otlphttp/cw_k8s_ci_v0_metrics_dest
695893
894+
{{- if .Values.otelContainerInsights.logs }}
895+
# ── CI Logs pipelines ──
896+
logs/cw_k8s_ci_v0_app:
897+
receivers: [filelog/cw_k8s_ci_v0_app]
898+
processors:
899+
- transform/cw_k8s_ci_v0_logs_set_cluster_name
900+
- resourcedetection/cw_k8s_ci_v0_logs
901+
- transform/cw_k8s_ci_v0_logs_set_cloud_resource_id
902+
- k8sattributes/cw_k8s_ci_v0_node
903+
- k8sattributes/cw_k8s_ci_v0_pod
904+
- transform/cw_k8s_ci_v0_logs_set_scope_app
905+
- transform/cw_k8s_ci_v0_logs_clear_schema_url
906+
- transform/cw_k8s_ci_v0_logs_set_workload
907+
- batch/cw_k8s_ci_v0_logs_dest
908+
exporters:
909+
- otlphttp/cw_k8s_ci_v0_app_logs_dest
910+
911+
# ── CI Logs: Host pipeline ──
912+
# Intentionally omits k8sattributes/pod and set_workload — host logs
913+
# (/var/log/messages, /var/log/dmesg, /var/log/secure) come from the node OS
914+
# and have no pod/workload context to enrich from. k8sattributes/node adds
915+
# node-level labels; cluster + cloud attributes apply as with other pipelines.
916+
# service.name is intentionally not set — host logs are node-level, not
917+
# service-level. Customers query host logs by k8s.node.name + log group.
918+
logs/cw_k8s_ci_v0_node:
919+
receivers: [filelog/cw_k8s_ci_v0_node]
920+
processors:
921+
- transform/cw_k8s_ci_v0_logs_set_cluster_name
922+
- resourcedetection/cw_k8s_ci_v0_logs
923+
- transform/cw_k8s_ci_v0_logs_set_cloud_resource_id
924+
- k8sattributes/cw_k8s_ci_v0_node
925+
- transform/cw_k8s_ci_v0_logs_set_scope_host
926+
- transform/cw_k8s_ci_v0_logs_clear_schema_url
927+
- batch/cw_k8s_ci_v0_logs_dest
928+
exporters:
929+
- otlphttp/cw_k8s_ci_v0_node_logs_dest
930+
{{- end }}
931+
696932
{{- end -}}

charts/amazon-cloudwatch-observability/templates/linux/cloudwatch-agent-custom-resource.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,15 @@ spec:
148148
- mountPath: /var/log/pods
149149
name: criologs
150150
readOnly: true
151+
{{- if and $.Values.otelContainerInsights.enabled $.Values.otelContainerInsights.logs }}
152+
# /var/log covers the paths the OTEL log pipelines need: /var/log/containers/*.log
153+
# (app container logs), /var/log/messages, /var/log/dmesg, /var/log/secure
154+
# (host logs). The existing criologs mount on /var/log/pods is retained for
155+
# the existing metrics pipelines.
156+
- mountPath: /var/log
157+
name: varlog
158+
readOnly: true
159+
{{- end }}
151160
- mountPath: /var/lib/docker
152161
name: varlibdocker
153162
readOnly: true
@@ -202,6 +211,11 @@ spec:
202211
- hostPath:
203212
path: /var/log/pods
204213
name: criologs
214+
{{- if and $.Values.otelContainerInsights.enabled $.Values.otelContainerInsights.logs }}
215+
- hostPath:
216+
path: /var/log
217+
name: varlog
218+
{{- end }}
205219
- hostPath:
206220
path: /sys
207221
name: sys

0 commit comments

Comments
 (0)