Skip to content

OpenTelemetry Collector

OtelCollector deploys an OpenTelemetry Collector agent on any Kubernetes cluster. It needs nothing from a cloud provider. On GKE, GkeOtelCollector builds the same agent with a Google Cloud exporter and Workload Identity.

The usual production shape adds a gateway: an agent on every node forwards to a few central replicas, which is where tail sampling, spanmetrics and export to a backend happen. OtelCollectorGateway deploys that tier, and gatewayExporter() points an agent at it.

The collector config is declared with the otel lexicon and rendered with collectorYaml() into the config.yaml key of a ConfigMap. The container ports, the Service ports and the probes are read back from that config, so they follow whatever config is passed in.

import { OtelCollector } from "@intentius/chant-lexicon-k8s";
import { OtlpExporter, type TLSClientSettings } from "@intentius/chant-lexicon-otel";
const plaintext: TLSClientSettings = { insecure: true };
export const collector = OtelCollector({
exporters: [new OtlpExporter({ name: "tempo", endpoint: "tempo.observability.svc:4317", tls: plaintext })],
});
MemberKindNotes
daemonSetDaemonSetOne collector per node, the config mounted at /etc/otel/config.yaml
serviceServiceinternalTrafficPolicy: Local, so each pod’s traffic stays on its own node
serviceAccountServiceAccountNamed <name>-sa
clusterRoleClusterRoleWhat k8sattributes and the config’s receivers read from the Kubernetes API; see RBAC
clusterRoleBindingClusterRoleBindingBinds the role to the service account
configMapConfigMapThe rendered collector config, named <name>-config
endpointsRole, endpointsRoleBindingRole, RoleBindingOnly when the config has a loadbalancing exporter with the k8s resolver; one pair per namespace, see agents to the gateway

Pods send OTLP to otel-collector.observability.svc:4317 (gRPC) or :4318 (HTTP) with the default name and namespace.

The ClusterRole is the one GkeOtelCollector grants too. It holds what the k8sattributes processor reads, so an agent can add pod metadata without extra rules, plus what the config’s Kubernetes receivers read. The rules follow each component’s README at collector-contrib v0.130.0, the version the otel lexicon pins.

ComponentRules
k8sattributes (always)get, list, watch on pods, namespaces and nodes, and on apps replicasets
k8sattributes extracting labels or annotations from: deploymentadds apps deployments
kubeletstatsget on nodes/stats
kubeletstats with extra_metadata_labels, or a *_request_utilization or *_limit_utilization metric enabledadds get on nodes/proxy
kubeletstats with k8s_api_configadds get on persistentvolumeclaims and persistentvolumes

Rules in defaults.clusterRole.rules are appended, for a component the table does not cover.

A per-node agent reads the node it runs on: the otel lexicon’s NodeAgent puts Kubernetes metadata on its node’s pods, scrapes the node’s metrics and reads its pods’ logs. The DaemonSet gives the config what its components read from the node, worked out from the config the way the RBAC is. Only components a pipeline runs count, and a config that reads nothing from the node, like the default one, gets none of it. Each row follows the component’s README at collector-contrib v0.130.0.

ComponentThe DaemonSet gets
k8sattributes with filter.node_from_env_varThat variable set from spec.nodeName with the downward API (“Deployment scenarios”)
kubeletstats with ${env:VAR} in node, or in endpoint where VAR ends in NODE_NAMEVAR from spec.nodeName (“Service Account Authentication Example”); an endpoint variable ending in NODE_IP or HOST_IP gets status.hostIP
hostmetrics with root_pathThe host root mounted read-only at root_path, with HostToContainer propagation (“Collecting host metrics from inside a container”)
filelogEach directory its include patterns read, mounted read-only from the host at the same path: the fixed part of the pattern, so /var/log/pods/*/*/*.log mounts /var/log/pods. Reading /var/log/containers also mounts /var/log/pods, where its symlinks point

The kubelet’s container logs are owned by root. With logAccess: "group", the default, the container keeps running as user 10001 and the pod joins group 0 (supplementalGroups: [0]), which reads them where the runtime writes them group-readable, as containerd does (mode 0640). For a runtime that writes them readable by root only, logAccess: "root" runs the container as user 0, with the rest of its security context unchanged. Neither applies to a config that reads no logs from the node.

So NodeAgent runs as it is:

import { OtelCollector } from "@intentius/chant-lexicon-k8s";
import { NodeAgent, OtlpExporter } from "@intentius/chant-lexicon-otel";
const gateway = new OtlpExporter({ name: "gateway", endpoint: "otel-gateway.observability.svc:4317", tls: { insecure: true } });
const agent = NodeAgent({ exporters: [gateway], kubeletStats: true });
export const collector = OtelCollector({ config: Object.values(agent.members) });

The DaemonSet sets K8S_NODE_NAME, mounts the host root at /hostfs and /var/log/pods, both read-only, and the ClusterRole gets nodes/stats for kubeletstats. WK8605 reports a workload that runs such a config without them. The example project is lexicons/k8s/examples/otel-node-agent.

Newer collector releases renamed some components and kept the old names as aliases: k8s_attributes, kubelet_stats, host_metrics, file_log and load_balancing among them. The RBAC and node access tables above, the gateway’s k8s resolver Roles, and the WK8602 and WK8605 checks treat each new name as the old one. A hand-written config with kubelet_stats gets nodes/stats like one with kubeletstats. The otel lexicon’s import docs list all twelve. Configs chant builds keep the old names.

PropDefaultMeaning
nameotel-collectorName of the agent and its resources
namespaceobservabilityNamespace
exportersone debug exporterWhere the default config sends telemetry
signalstraces, metrics and logsWhich signals get a pipeline in the default config
confignoneA full config as otel entities; replaces the default, and exporters and signals are ignored
imagethe contrib image at the otel lexicon’s pinned versionCollector image
labelsnoneExtra labels on every resource
logAccessgroupHow a config with a filelog receiver reads the node’s root-owned container logs: group adds the pod to group 0, root runs the container as user 0; see node access
cpuRequest, memoryRequest100m, 256MiContainer requests
cpuLimit, memoryLimit500m, 512MiContainer limits
defaultsnonePer-member overrides for each member above

Without config, the composite uses otlpCollector() from the otel lexicon. It has an otlp receiver on 4317 and 4318, memory_limiter then batch, the given exporters, and a health_check extension on 13133, with one pipeline per signal.

Pass config to declare every component yourself. The composite renders exactly the entities it is given.

import { OtelCollector } from "@intentius/chant-lexicon-k8s";
import { Pipeline } from "@intentius/chant-lexicon-otel";
import { otlp, memoryLimiter, batch, tempo, debug, health } from "./components";
const processors = [memoryLimiter, batch];
export const collector = OtelCollector({
config: [
health,
new Pipeline({ signal: "traces", receivers: [otlp], processors, exporters: [tempo] }),
new Pipeline({ signal: "logs", receivers: [otlp], processors, exporters: [debug] }),
],
});

Each receiver endpoint becomes a container port and a Service port, named after the receiver and its protocol (otlp-grpc, otlp-http). When the service enables a health_check extension that listens on an address other than localhost, its port is added to the container as health, and the liveness and readiness probes call it. A config without one gets no probes.

The container runs as user 10001 with a read-only root filesystem and every capability dropped, unless logAccess: "root" applies (see node access).

The container runs with imagePullPolicy: IfNotPresent, since the default image is pinned to a version.

The example project is lexicons/k8s/examples/otel-collector.

OtelCollectorGateway runs the same collector container as a Deployment with replicas pods, and puts two Services in front of it: a ClusterIP Service that agents send to, and a headless Service (<name>-headless) whose DNS name and Endpoints list every ready pod. The config, ports and probes work as they do for OtelCollector.

import { OtelCollectorGateway } from "@intentius/chant-lexicon-k8s";
import { Pipeline } from "@intentius/chant-lexicon-otel";
import { otlp, sampling, batch, tempo, health } from "./components";
export const gateway = OtelCollectorGateway({
name: "otel-gateway",
namespace: "observability",
replicas: 3,
config: [
health,
new Pipeline({ signal: "traces", receivers: [otlp], processors: [sampling, batch], exporters: [tempo] }),
],
});
MemberKindNotes
deploymentDeploymentreplicas pods, the config mounted at /etc/otel/config.yaml
serviceServiceClusterIP, the ports the config listens on (receivers and the prometheus exporter)
headlessServiceServiceclusterIP: None, named <name>-headless, the same ports
serviceAccountServiceAccountNamed <name>-sa
configMapConfigMapThe rendered collector config, named <name>-config
podDisruptionBudgetPodDisruptionBudgetWith more than one replica: maxUnavailable: 1
clusterRole, clusterRoleBindingClusterRole, ClusterRoleBindingOnly when clusterRules is set
leaseRole, leaseRoleBindingRole, RoleBindingOnly when the config enables a k8s_leader_elector extension; named <name>-leases, in its lease_namespace

The gateway reads nothing from the Kubernetes API by default, so it gets no ClusterRole. A config that does, such as one with a k8s_cluster receiver or a k8sattributes processor, passes the rules it needs as clusterRules.

A multi-replica gateway running k8s_cluster puts the receiver behind a k8s_leader_elector extension (the otel lexicon’s K8sLeaderElectorExtension), so only the replica holding a Lease collects; see WK8603. When service.extensions enables one, the gateway adds a Role on leases in coordination.k8s.io, in the extension’s lease_namespace, bound to its ServiceAccount, with the verbs the extension’s README suggests at collector-contrib v0.130.0 (get, list, watch, create, update, patch, delete). Electors with Leases in several namespaces get one Role and RoleBinding in each: the first namespace’s are leaseRole and leaseRoleBinding, and each further one’s are leaseRoleIn<Namespace> and leaseRoleBindingIn<Namespace>, with the namespace in PascalCase (team-a gives leaseRoleInTeamA). defaults.leaseRole and defaults.leaseRoleBinding apply to each.

PropDefaultMeaning
nameotel-gatewayName of the gateway and its resources
namespaceobservabilityNamespace
replicas2Number of collector pods
config, exporters, signalsthe default configAs for OtelCollector
clusterRulesnoneRBAC rules for a ClusterRole bound to the gateway’s ServiceAccount
imagethe contrib image at the otel lexicon’s pinned versionCollector image
labelsnoneExtra labels on every resource
cpuRequest, memoryRequest200m, 512MiContainer requests
cpuLimit, memoryLimit1, 1GiContainer limits
defaultsnonePer-member overrides, keyed by the member names above

gatewayExporter(gateway, options) returns an otel exporter whose endpoint is read from the gateway composite: its Services’ names, its namespace, and the port its config listens on. Put it in the agent’s pipelines like any other exporter. Renaming the gateway, moving it to another namespace or changing its receiver port moves the agent with it.

import { OtelCollector, gatewayExporter } from "@intentius/chant-lexicon-k8s";
import { Pipeline } from "@intentius/chant-lexicon-otel";
import { gateway } from "./gateway";
import { otlp, batch } from "./agent-components";
export const agent = OtelCollector({
name: "otel-agent",
config: [
new Pipeline({ signal: "traces", receivers: [otlp], processors: [batch], exporters: [gatewayExporter(gateway, { loadBalance: true })] }),
new Pipeline({ signal: "metrics", receivers: [otlp], processors: [batch], exporters: [gatewayExporter(gateway)] }),
],
});

By default the exporter is otlp/gateway, sending to <name>.<namespace>.svc:<port> through the ClusterIP Service. gRPC keeps a connection open to one pod, so each agent sticks to one replica; that is fine for metrics and logs, and for traces when nothing on the gateway needs to see a whole trace.

With protocol: "http" it is otlphttp/gateway, sending OTLP over HTTP to http://<name>.<namespace>.svc:<port> on the gateway’s otlp-http port. A tls without insecure makes the scheme https:// and is passed on without insecure. The loadbalancing exporter sends OTLP over gRPC only at the pinned collector version, so protocol: "http" with loadBalance is refused.

With loadBalance: true it is loadbalancing/gateway, routing by trace id to the headless Service, so every span of a trace reaches the same replica. A multi-replica gateway that runs tail_sampling or spanmetrics needs this; otherwise each replica decides on a fragment of the trace.

OptionDefaultMeaning
namegatewayExporter name: otlp/<name>, otlphttp/<name> or loadbalancing/<name>
protocolgrpcgrpc for an otlp exporter, http for an otlphttp exporter
loadBalancefalseUse a loadbalancing exporter on the headless Service
resolverk8sk8s watches the headless Service’s Endpoints (<name>-headless.<namespace>); dns resolves <name>-headless.<namespace>.svc
routingKeytraceIDWhat the loadbalancing exporter hashes on
portotlp-grpc, or otlp-http with protocol: "http"The gateway Service port to send to, by name
tls{ insecure: true }TLS for the connection; plaintext inside the cluster by default

The loadbalancing exporter runs in the agent, so the k8s resolver needs the agent to read the gateway’s Endpoints. When an agent’s config has a loadbalancing exporter with the k8s resolver, whether from gatewayExporter or written by hand, OtelCollector adds a Role and RoleBinding named <agent>-endpoints in the resolved Service’s namespace. They grant the agent’s ServiceAccount get, list and watch on Endpoints, which the resolver watches at the pinned collector version, and on EndpointSlices, which later versions watch. A config whose k8s resolvers point at several namespaces gets one Role and RoleBinding in each: the first namespace’s are endpointsRole and endpointsRoleBinding, and each further one’s are endpointsRoleIn<Namespace> and endpointsRoleBindingIn<Namespace> (team-a gives endpointsRoleInTeamA). defaults.endpointsRole and defaults.endpointsRoleBinding apply to each.

OtelOperatorCollector: the OpenTelemetry Operator’s custom resource

Section titled “OtelOperatorCollector: the OpenTelemetry Operator’s custom resource”

OtelOperatorCollector builds the same collector for a cluster that runs the OpenTelemetry Operator. Instead of a DaemonSet, ConfigMap and Service it returns one OpenTelemetryCollector (opentelemetry.io/v1beta1, generated from the operator’s v0.160.0 CRDs as K8s::OpenTelemetry::OpenTelemetryCollector), plus a ServiceAccount, a ClusterRole and a ClusterRoleBinding with the rules OtelCollector works out from the config. The operator makes the workload <name>-collector and its Services.

import { OtelOperatorCollector } from "@intentius/chant-lexicon-k8s";
export const collector = OtelOperatorCollector({
mode: "deployment",
replicas: 2,
config: [/* otel lexicon entities */],
});

spec.config is the built config as an object, which v1beta1 declares as a map. mode is daemonset (the default), deployment or statefulset; replicas applies to the last two. spec.ports, spec.env, spec.volumes, spec.volumeMounts and the log group in spec.podSecurityContext come from the config the way OtelCollector reads them back for its DaemonSet.

An object has no comment lines, so the # chant: header that records custom-component pins and semconv use goes in the otel.chant.dev/header annotation, one line per entry. chant import puts those lines back on top of the config when it reads the resource, and imports spec.config as otel declarations referenced through collectorConfig([...]), which gives an object where collectorYaml would give text.

WK8604 runs the otel config checks over spec.config, naming the resource in each finding. WK8601 to WK8603 and WK8605 read spec.mode, spec.replicas (raised to spec.autoscaler.maxReplicas), spec.env and spec.volumeMounts; see the placement checks.

The collector config is a string inside a ConfigMap, and whether it runs once per node or as several replicas is on another document. Both composites write annotations that join them, so a post-synth check can tell, from the built YAML alone, where each config runs and how agents reach the gateway.

AnnotationOnValue
otel.chant.dev/roleConfigMap, workloadagent (a DaemonSet) or gateway (a Deployment)
otel.chant.dev/workloadConfigMapThe workload that mounts it, DaemonSet/<name> or Deployment/<name>, in the same namespace
otel.chant.dev/configworkloadThe name of the ConfigMap holding its config.yaml
otel.chant.dev/gatewaysagent ConfigMap and DaemonSetComma-separated <namespace>/<gateway>=<routing> for each gateway an exporter from gatewayExporter points at, with routing loadbalancing or service

The replica count is the workload’s own spec.replicas. The keys are exported as OTEL_COLLECTOR_ANNOTATIONS. GkeOtelCollector writes none of them, since its output is fixed; its DaemonSet still names its ConfigMap in its config volume. The placement checks WK8601 to WK8603 read these annotations to catch tail sampling on a per-node agent, a multi-replica tail sampling gateway reached without loadbalancing, and a k8s_cluster receiver in every collector copy. A check that joins an agent to its gateway only sees both when they are in the same build root.

The example project is lexicons/k8s/examples/otel-gateway: an agent and a two-replica gateway with tail sampling, in the observability namespace. lexicons/k8s/examples/otel-gateway.e2e.test.ts builds it, applies it to a k3d cluster, sends traces to the agent with telemetrygen, and checks that the gateway received all of them, with no trace split across the two replicas. It runs when Docker, k3d and kubectl are available and skips otherwise.