You are the final reviewer for Phase B of an implementation plan: a backwards-compatible bump of the existing `otel-collector` Terraform internal-module.

This task is **review-only** — no file modifications, no git operations, no terraform apply.

## What was changed in Phase B

Read the current state of these files in `/Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector/`:

- `variables.tf` — should have NEW additive variables `downstream_endpoint` (string, default `""`) and `downstream_protocol` (string, default `"otlp/grpc"`, with a validation block).
- `otelconfig.tf` — should be refactored to conditionally render either `exporters.datadog/exporter` (default) or `exporters.otlp` (when `downstream_endpoint != ""`). Pipeline exporter lists swap based on the same conditional.
- `main.tf` — `kubernetes_manifest.otel-collector` (the ExternalSecret resource for the Datadog API key) should have `count = var.downstream_endpoint == "" ? 1 : 0`. The `extraEnvsFrom` block on the processor helm release should be a conditional list (mounting the Datadog secret in default mode, empty list in downstream mode).
- `examples/downstream/` — three new files (versions.tf, main.tf, README.md) demonstrating downstream mode.
- `README.md` — newly created module docs.
- `lbconfig.tf` — should NOT have semantic changes (only auto-formatting fixes if any).

## Verification checks to run

- [ ] **Check 1: Backwards compatibility — direct mode is byte-identical to before.**

  Reproduce the ad-hoc plan-render verification from Task B4. Build a tempdir, instantiate the module with NO `downstream_endpoint` (or `= ""`). Inspect the rendered processor helm release config:
  - `exporters.datadog/exporter.api.site` = `"datadoghq.com"` (unchanged)
  - `exporters.datadog/exporter.api.key` = `"${env:DD_API_KEY}"` (unchanged escape)
  - `exporters.datadog/exporter.sending_queue.storage` = `"file_storage/persistent_queue"` (unchanged)
  - Pipelines: traces=`[datadog/connector, datadog/exporter]`, logs=`[datadog/exporter]`, metrics=`[datadog/exporter]` (unchanged)
  - `extraEnvsFrom` contains `secretRef.name = "otel-collector"` (unchanged)
  - `kubernetes_manifest.otel-collector[0]` planned for create (count=1) (unchanged)

- [ ] **Check 2: Downstream mode renders correctly.**

  Same tempdir technique with `downstream_endpoint = "http://vector.vector.svc.cluster.local:4317"`:
  - `exporters.otlp.endpoint` = the URL passed
  - `exporters.otlp.tls.insecure` = `true`
  - `exporters.otlp.sending_queue.enabled` = `true`, `queue_size` = `5000`
  - `exporters.otlp.retry_on_failure` block present with the expected values
  - `exporters.datadog/exporter` ABSENT
  - Pipelines: traces=`[datadog/connector, otlp]`, logs=`[otlp]`, metrics=`[otlp]`
  - `extraEnvsFrom` empty list `[]`
  - `kubernetes_manifest.otel-collector` NOT planned (count=0)

- [ ] **Check 3: `terraform fmt -check -recursive`** at module root. Exit 0.

- [ ] **Check 4: `terraform validate`** at module root. `Success! The configuration is valid.`

- [ ] **Check 5: examples/downstream validate.** Inside `examples/downstream/`, run `terraform init -backend=false && terraform validate`. Both must pass.

- [ ] **Check 6: Spot-check `lbconfig.tf` for semantic drift.** The LB tier should be untouched. Compare against any older version available via `git diff v1.0.5 -- lbconfig.tf` IF and ONLY IF you're absolutely sure that `git diff` is read-only and won't touch state. If you don't want to risk it, skip this check and report `lbconfig.tf` was reformatted but not semantically changed (per B3/B4 implementer reports).

- [ ] **Check 7: Spot-check `otelconfig.tf` for unintended drift.** The `connectors`, `processors`, `receivers`, `extensions`, `service.telemetry`, and pipeline `processors` lists should match what was there pre-Phase B. The only changes should be: exporters map (now derived from a local), and the three pipeline exporter lists (now derived from locals).

## Things to flag in the report

- Any check that fails or returns unexpected output.
- Any sign that perf-tuning values (`send_batch_max_size`, etc.) on the `otel-batch-size` branch were touched (they should NOT have been — those belong to the unmerged perf work).
- Confirmation that the cherry-pick path onto a v1.0.5 feature branch will be clean (i.e., the changes don't intermingle with perf-tuning lines).
- Any concerns or open questions for Sir Charles.

## Constraints

- **DO NOT run any git commands** that mutate state. `git diff` and `git log` are READ-ONLY and acceptable for verification, but no checkout, branch, commit, push, etc.
- **DO NOT** push, tag, open MRs.
- **DO NOT** run `terraform apply`.
- **DO NOT** modify any files. This is review-only.

## Report Format

- **Status:** APPROVED | ISSUES_FOUND | BLOCKED
- Each of the 7 checks: PASS / FAIL with one-line evidence.
- Any other concerns or notes for Sir Charles
- Recommendation for what to do next (open MR, fix issues, etc.)


I'll review the Phase B implementation systematically. Let me start by reading all the relevant files.


Bash

List files in otel-collector module


.rw-r--r-- steve.briggs staff  70 B  Mon Feb  2 14:16:18 2026 .gitignore
.rw-r--r-- steve.briggs staff 1.2 KB Mon Feb  2 14:16:18 2026 netpol.tf
.rw-r--r-- steve.briggs staff 1.6 KB Mon Feb  2 14:16:18 2026 outputs.tf
drwxr-xr-x steve.briggs staff  96 B  Tue Feb  3 11:35:45 2026 .idea
drwxr-xr-x steve.briggs staff 448 B  Thu Mar  5 16:48:00 2026 .git
drwxr-xr-x steve.briggs staff 224 B  Tue May  5 12:32:56 2026 ..
.rw-r--r-- steve.briggs staff 4.0 KB Tue May  5 14:10:09 2026 variables.tf
drwxr-xr-x steve.briggs staff 128 B  Tue May  5 14:10:36 2026 .terraform
.rw-r--r-- steve.briggs staff 463 B  Tue May  5 14:10:36 2026 .terraform.lock.hcl
.rw-r--r-- steve.briggs staff 2.9 KB Tue May  5 14:13:36 2026 lbconfig.tf
.rw-r--r-- steve.briggs staff 6.9 KB Tue May  5 14:13:36 2026 main.tf
.rw-r--r-- steve.briggs staff 3.2 KB Tue May  5 14:17:09 2026 otelconfig.tf
drwxr-xr-x steve.briggs staff  96 B  Tue May  5 14:30:41 2026 examples
.rw-r--r-- steve.briggs staff 6.7 KB Tue May  5 14:34:37 2026 README.md
drwxr-xr-x steve.briggs staff 480 B  Tue May  5 14:34:37 2026 .


1	# ---------------------------------------------------------------------------------------------------------------------
2	# REQUIRED PARAMETERS
3	# These variables are configurable, and need to be set expicitly.
4	# ---------------------------------------------------------------------------------------------------------------------
5	variable "chart_version" {
6	  description = "Version of otel collector chart"
7	  type        = string
8	}
9
10	variable "image_tag_version" {
11	  description = "Version of otel collector image tag"
12	  type        = string
13	}
14
15	# ---------------------------------------------------------------------------------------------------------------------
16	# OPTIONAL PARAMETERS
17	# These variables are configurable, but have reasonable defaults set.
18	# ---------------------------------------------------------------------------------------------------------------------
19	variable "name" {
20	  description = "Name for created resources"
21	  type        = string
22	  default     = "otel-collector"
23	}
24
25	variable "otel_image" {
26	  description = "Helm Value: image.X, optional override to specify image repository & tag"
27	  type        = string
28	  default     = "registry.ic-ops.net/cache-docker-hub/otel/opentelemetry-collector-contrib"
29	}
30
31	variable "otellb_image" {
32	  description = "Helm Value: image.X, optional override to specify image repository & tag"
33	  type        = string
34	  default     = "registry.ic-ops.net/cache-docker-hub/otel/opentelemetry-collector"
35	}
36
37	variable "otel_lb_replicas" {
38	  description = "number of otel loadbalancer replicas to create (statefulset)"
39	  type        = number
40	  default     = 3
41	}
42
43	variable "otel_collector_replicas" {
44	  description = "number of replicas to create for the otel collector processor"
45	  type        = number
46	  default     = 3
47	}
48
49	variable "otel_sa_name" {
50	  description = "Service Account name to be used for otel collector statefulset resources"
51	  type        = string
52	  default     = "otel-collector-sa"
53	}
54
55	variable "otellb_sa_name" {
56	  description = "Service Account name to be used for otel collector deployment resources"
57	  type        = string
58	  default     = "otel-collector-lb-sa"
59	}
60
61	variable "otel_pv_size" {
62	  description = "Size of the Persistent Volume to be used for otel collector statefulset"
63	  type        = string
64	  default     = "5Gi"
65	}
66
67	variable "otel_storage_class_name" {
68	  description = "Storage Class name to be used for otel collector statefulset Persistent Volume"
69	  type        = string
70	  default     = "ebs-encrypted"
71	}
72	variable "sa_annotations" {
73	  description = "Helm Value: A map providing annotations to apply to the otel collector ServiceAccount"
74	  type        = map(string)
75	  default     = {}
76	}
77
78	variable "namespace" {
79	  description = "Namespace to be used for creation of otel collector resources"
80	  type        = string
81	  default     = "opentelemetry"
82	}
83
84	variable "annotations" {
85	  description = "A map providing annotations to apply to the otel collector pods"
86	  type        = map(string)
87	  default     = {}
88	}
89
90	variable "extra_args" {
91	  description = "A list of additional arguments (CLI args) to apply to the otel collector container"
92	  type        = list(string)
93	  default     = []
94	}
95
96	variable "otellb_extra_args" {
97	  description = "A list of additional arguments (CLI args) to apply to the otel collector loadbalancer container"
98	  type        = list(string)
99	  default     = []
100	}
101
102	variable "tags" {
103	  description = "Map of key-vaue pairs to tag created resources with"
104	  type        = map(any)
105	  default     = {}
106	}
107
108	variable "downstream_endpoint" {
109	  description = "When non-empty, swap the terminal exporter from datadog to OTLP, pointed at this endpoint. Used to route through Vector for redaction."
110	  type        = string
111	  default     = ""
112	}
113
114	variable "downstream_protocol" {
115	  description = "Protocol for downstream OTLP exporter. Currently only otlp/grpc supported."
116	  type        = string
117	  default     = "otlp/grpc"
118	  validation {
119	    condition     = contains(["otlp/grpc", "otlp/http"], var.downstream_protocol)
120	    error_message = "downstream_protocol must be one of: otlp/grpc, otlp/http"
121	  }
122	}
123


Good, `variables.tf` looks correct. Now let me read the other key files in parallel.


1	locals {
2	  # Pipeline exporter lists swap based on mode
3	  traces_exporters  = var.downstream_endpoint == "" ? ["datadog/connector", "datadog/exporter"] : ["datadog/connector", "otlp"]
4	  logs_exporters    = var.downstream_endpoint == "" ? ["datadog/exporter"] : ["otlp"]
5	  metrics_exporters = var.downstream_endpoint == "" ? ["datadog/exporter"] : ["otlp"]
6
7	  # Shared structural skeleton — receivers, processors, connectors, extensions, service
8	  # are identical in both modes; only exporters differs.
9	  _otel_base = {
10	    receivers = {
11	      otlp = {
12	        protocols = {
13	          grpc = {
14	            endpoint = "0.0.0.0:4317"
15	          }
16	          http = {
17	            endpoint = "0.0.0.0:4318"
18	          }
19	        }
20	      }
21	    }
22
23	    processors = {
24	      batch = {
25	        timeout             = "10s"
26	        send_batch_size     = 10
27	        send_batch_max_size = 75
28	      }
29	    }
30
31	    connectors = {
32	      "datadog/connector" = {}
33	    }
34
35	    extensions = {
36	      health_check = {
37	        endpoint = "$${env:MY_POD_IP}:13133"
38	      }
39	      "file_storage/persistent_queue" = {
40	        directory = "/var/lib/storage/queue"
41	      }
42	    }
43
44	    service = {
45	      extensions = ["health_check", "file_storage/persistent_queue"]
46	      pipelines = {
47	        traces = {
48	          receivers  = ["otlp"]
49	          processors = []
50	          exporters  = local.traces_exporters
51	        }
52
53	        logs = {
54	          receivers  = ["otlp"]
55	          processors = []
56	          exporters  = local.logs_exporters
57	        }
58
59	        metrics = {
60	          receivers  = ["otlp", "datadog/connector"]
61	          processors = []
62	          exporters  = local.metrics_exporters
63	        }
64	      }
65	      telemetry = {
66	        logs = {
67	          encoding = "json"
68	        }
69	      }
70	    }
71	  }
72
73	  # Direct (Datadog) exporter — active when downstream_endpoint is empty
74	  _exporters_direct = {
75	    exporters = {
76	      "datadog/exporter" = {
77	        api = {
78	          site = "datadoghq.com"
79	          key  = "$${env:DD_API_KEY}"
80	        }
81	        sending_queue = {
82	          enabled = true
83	          storage = "file_storage/persistent_queue"
84	        }
85	      }
86	    }
87	  }
88
89	  # Downstream (OTLP/Vector) exporter — active when downstream_endpoint is set
90	  _exporters_downstream = {
91	    exporters = {
92	      otlp = {
93	        endpoint = var.downstream_endpoint
94	        tls = {
95	          insecure = true
96	        }
97	        sending_queue = {
98	          enabled    = true
99	          queue_size = 5000
100	        }
101	        retry_on_failure = {
102	          enabled          = true
103	          initial_interval = "5s"
104	          max_interval     = "30s"
105	          max_elapsed_time = "300s"
106	        }
107	      }
108	    }
109	  }
110
111	  # Encode each exporter block to JSON first so the ternary operates on
112	  # uniform string types, bypassing Terraform's structural type check on
113	  # objects with different attribute names.
114	  _exporters_direct_json     = jsonencode(local._exporters_direct)
115	  _exporters_downstream_json = jsonencode(local._exporters_downstream)
116
117	  # merge() combines the shared base with the active exporter block.
118	  otel_config = merge(
119	    local._otel_base,
120	    jsondecode(
121	      var.downstream_endpoint == "" ? local._exporters_direct_json : local._exporters_downstream_json
122	    )
123	  )
124	}
125


1	/**
2	*
3	* # OTEL Collector (K8s Addon)
4	* Deploys otel collector via terraform.
5	* ## Created Resources
6	* 1. otel collector.
7	*
8	*/
9	terraform {
10	  required_version = ">= 1.3.0"
11
12	  required_providers {
13	    helm = {
14	      source  = "hashicorp/helm"
15	      version = "~> 2.0"
16	    }
17	    kubernetes = {
18	      source  = "hashicorp/kubernetes"
19	      version = "~> 2.0"
20	    }
21	  }
22	}
23
24	resource "kubernetes_namespace" "this" {
25	  metadata {
26	    name = var.namespace
27
28	    #labels = merge(var.tags, { "sourceModule" = "/internal-modules/kubernetes/monitoring/otel-collector" })
29
30	    annotations = var.annotations
31	  }
32	}
33
34	resource "kubernetes_manifest" "otel-collector" {
35	  count = var.downstream_endpoint == "" ? 1 : 0
36
37	  manifest = {
38	    "apiVersion" = "external-secrets.io/v1"
39	    "kind"       = "ExternalSecret"
40	    "metadata" = {
41	      "name"      = "otel-collector"
42	      "namespace" = var.namespace
43	    }
44	    "spec" = {
45	      "refreshInterval" = "1h0m0s"
46	      "secretStoreRef" = {
47	        "name" = "vault"
48	        "kind" = "ClusterSecretStore"
49	      }
50	      "target" = {
51	        "name"           = "otel-collector"
52	        "creationPolicy" = "Owner"
53	      }
54	      "data" = [
55	        {
56	          "secretKey" = "DD_API_KEY"
57	          "remoteRef" = {
58	            "key"      = "secret/data/saas/infrastructure/datadog-api-key"
59	            "property" = "apiKey"
60	          }
61	        },
62	      ]
63	    }
64	  }
65	  depends_on = [kubernetes_namespace.this]
66	}
67
68
69	resource "helm_release" "otel_collector_lb" {
70	  #name      = var.name
71	  name             = "otel-collector-lb"
72	  namespace        = var.namespace
73	  create_namespace = false
74	  repository       = "https://open-telemetry.github.io/opentelemetry-helm-charts/"
75	  chart            = "opentelemetry-collector"
76	  version          = var.chart_version
77
78	  values = [yamlencode({
79	    mode = "deployment"
80	    image = {
81	      repository = var.otellb_image
82	      tag        = var.image_tag_version
83	    }
84	    replicaCount = var.otel_lb_replicas
85
86	    nameOverride = "collector"
87
88	    annotations = var.annotations
89
90	    podLabels = {
91	      "ic-ops.net/otel"           = "collector"
92	      "app.kubernetes.io/part-of" = "opentelemetry-collector"
93	    }
94
95	    #serviceAccount = {
96	    #  create = true,
97	    #  name   = var.otellb_sa_name,
98	    #  annotations = var.sa_annotations
99	    #}
100
101	    presets = {
102	      # enables the k8sattributesprocessor and adds it to the traces, metrics, and logs pipelines
103	      kubernetesAttributes = {
104	        enabled                  = true
105	        extractAllPodLabels      = false
106	        extractAllPodAnnotations = true
107	      }
108	    }
109
110	    serviceAccount = {
111	      create      = true,
112	      name        = var.otellb_sa_name
113	      annotations = var.sa_annotations
114	    }
115
116	    command = {
117	      extraArgs = var.otellb_extra_args
118	    }
119
120	    ports = {
121	      otlp = {
122	        enabled       = true
123	        containerPort = 4317
124	        servicePort   = 4317
125	        protocol      = "TCP"
126	        appProtocol   = "grpc"
127	      }
128	      otlp-http = {
129	        enabled       = true
130	        containerPort = 4318
131	        servicePort   = 4318
132	        protocol      = "TCP"
133	      }
134	      jaegar-compact = {
135	        enabled = false
136	      }
137	      jaeger-grpc = {
138	        enabled = false
139	      }
140	      jaeger-thrift = {
141	        enabled = false
142	      }
143	      zipkin = {
144	        enabled = false
145	      }
146	    }
147
148	    config = local.lb_config
149	  })]
150
151	  depends_on = [
152	    kubernetes_namespace.this,
153	    kubernetes_manifest.otel-collector,
154	    helm_release.otel_collector_processor
155	  ]
156	}
157
158	resource "helm_release" "otel_collector_processor" {
159	  #name      = var.name
160	  name             = "otel-collector-processor"
161	  namespace        = var.namespace
162	  create_namespace = false
163	  repository       = "https://open-telemetry.github.io/opentelemetry-helm-charts/"
164	  chart            = "opentelemetry-collector"
165	  version          = var.chart_version
166
167	  values = [yamlencode({
168	    mode = "statefulset"
169	    image = {
170	      repository = var.otel_image
171	      tag        = var.image_tag_version
172	    }
173	    statefulset = {
174	      persistentVolumeClaimRetentionPolicy = {
175	        enabled     = true
176	        whenDeleted = "Delete"
177	        whenScaled  = "Delete"
178	      }
179
180	      volumeClaimTemplates = [
181	        {
182	          metadata = {
183	            name = "otel-collector-data"
184	          }
185	          spec = {
186	            accessModes = ["ReadWriteOnce"]
187	            resources = {
188	              requests = {
189	                storage = var.otel_pv_size
190	              }
191	            }
192	            //storageClassName = var.otel_storage_class_name
193	          }
194	        }
195	      ]
196	    }
197
198	    extraVolumeMounts = [{
199	      name      = "otel-collector-data"
200	      mountPath = "/var/lib/storage/queue"
201	    }]
202
203	    initContainers = [{
204	      name    = "init-fs"
205	      image   = "busybox:latest"
206	      command = ["sh", "-c", "chown -R 10001: /var/lib/storage/queue"]
207	      volumeMounts = [{
208	        name      = "otel-collector-data"
209	        mountPath = "/var/lib/storage/queue"
210	      }]
211	    }]
212
213	    nameOverride = "processor"
214
215	    replicaCount = var.otel_collector_replicas
216
217	    service = {
218	      type      = "ClusterIP"
219	      clusterIP = "None" # Headless service for statefulset
220	    }
221
222	    annotations = var.annotations
223	    podLabels = {
224	      "ic-ops.net/otel"           = "processor"
225	      "app.kubernetes.io/part-of" = "opentelemetry-collector"
226	    }
227
228	    serviceAccount = {
229	      create      = true,
230	      name        = var.otel_sa_name,
231	      annotations = var.sa_annotations
232	    }
233
234	    command = {
235	      extraArgs = var.extra_args
236	    }
237
238	    extraEnvsFrom = var.downstream_endpoint == "" ? [{
239	      secretRef = {
240	        name = "otel-collector"
241	      }
242	    }] : []
243
244	    #presets = {
245	    # enables the k8sattributesprocessor and adds it to the traces, metrics, and logs pipelines
246	    #kubernetesAttributes = {
247	    #  enabled = true
248	    #  extractAllPodLabels = true
249	    #  extractAllPodAnnotations = true
250	    #}
251	    #}
252
253	    ports = {
254	      otlp = {
255	        enabled       = true
256	        containerPort = 4317
257	        servicePort   = 4317
258	        protocol      = "TCP"
259	        appProtocol   = "grpc"
260	      }
261	      otlp-http = {
262	        enabled       = true
263	        containerPort = 4318
264	        servicePort   = 4318
265	        protocol      = "TCP"
266	      }
267	      jaegar-compact = {
268	        enabled = false
269	      }
270	      jaeger-grpc = {
271	        enabled = false
272	      }
273	      jaeger-thrift = {
274	        enabled = false
275	      }
276	      zipkin = {
277	        enabled = false
278	      }
279	    }
280
281	    config = local.otel_config
282	  })]
283
284	  depends_on = [
285	    kubernetes_namespace.this,
286	    kubernetes_manifest.otel-collector
287	  ]
288
289	  lifecycle {
290	    create_before_destroy = false
291	  }
292	}
293
294	module "netpol" {
295	  source = "git@gitlab.infrastructure.immuta.io:infrastructure/terraform/internal-modules/kubernetes/security/network-policy.git?ref=v1.1.0"
296
297	  name             = join("-", [var.name, "netpol"])
298	  namespace        = var.namespace
299	  network_policies = local.netpols
300
301	  tags = merge(var.tags, { "sourceModule" = "/internal-modules/kubernetes/monitoring/otel-collector" })
302
303	  depends_on = [
304	    kubernetes_namespace.this
305	  ]
306	}
307


1	locals {
2	  lb_config = {
3	    receivers = {
4	      otlp = {
5	        protocols = {
6	          grpc = {
7	            endpoint = "0.0.0.0:4317"
8	          }
9	          http = {
10	            endpoint = "0.0.0.0:4318"
11	          }
12	        }
13	      }
14	    }
15
16	    processors = {
17	      k8sattributes = {
18	        auth_type = "serviceAccount"
19	        extract = {
20	          metadata = [
21	            "k8s.namespace.name",
22	            "k8s.deployment.name",
23	            "k8s.statefulset.name",
24	            "k8s.daemonset.name",
25	            "k8s.cronjob.name",
26	            "k8s.job.name",
27	            "k8s.node.name",
28	            "k8s.pod.name",
29	            "k8s.pod.uid",
30	            "k8s.pod.start_time"
31	          ]
32	          labels = [{
33	            key_regex = "tags\\.datadoghq\\.com/(.*)$"
34	            tag_name  = "$$1"
35	            from      = "pod"
36	          }]
37	        }
38	        passthrough = false
39	        pod_association = [
40	          {
41	            sources = [{
42	              from = "resource_attribute"
43	              name = "k8s.pod.ip"
44	            }]
45	          },
46	          {
47	            sources = [{
48	              from = "resource_attribute"
49	              name = "k8s.pod.uid"
50	            }]
51	          },
52	          {
53	            sources = [{
54	              from = "connection"
55	            }]
56	          }
57	        ]
58	      }
59	    }
60
61	    exporters = {
62	      loadbalancing = {
63	        routing_key = "traceID"
64	        protocol = {
65	          otlp = {
66	            # all options from the OTLP exporter are supported
67	            # except the endpoint
68	            timeout = "2s"
69	            tls = {
70	              insecure = true
71	            }
72	          }
73	        }
74	        resolver = {
75	          # Notice to config a headless service DNS in Kubernetes
76	          dns = {
77	            hostname = "otel-collector-processor.${var.namespace}.svc.cluster.local"
78	          }
79	        }
80	      }
81	      #metrics loadbalancing
82	      "loadbalancing/metrics" = {
83	        routing_key = "service"
84	        protocol = {
85	          otlp = {
86	            # all options from the OTLP exporter are supported
87	            # except the endpoint
88	            timeout = "2s"
89	            tls = {
90	              insecure = true
91	            }
92	          }
93	        }
94	        resolver = {
95	          # Notice to config a headless service DNS in Kubernetes
96	          dns = {
97	            hostname = "otel-collector-processor.${var.namespace}.svc.cluster.local"
98	          }
99	        }
100	      }
101	    }
102
103	    service = {
104	      pipelines = {
105	        traces = {
106	          receivers  = ["otlp"]
107	          processors = ["k8sattributes"]
108	          exporters  = ["loadbalancing"]
109	        }
110
111	        logs = {
112	          receivers  = ["otlp"]
113	          processors = ["k8sattributes"]
114	          exporters  = ["loadbalancing"]
115	        }
116
117	        metrics = {
118	          receivers  = ["otlp"]
119	          processors = ["k8sattributes"]
120	          exporters  = ["loadbalancing/metrics"]
121	        }
122	      }
123	      telemetry = {
124	        logs = {
125	          encoding = "json"
126	        }
127	      }
128	    }
129	  }
130	}
131


Now let me read the examples directory and README.


List examples directory


drwxr-xr-x steve.briggs staff  96 B Tue May  5 14:30:41 2026 .
drwxr-xr-x steve.briggs staff 224 B Tue May  5 14:31:51 2026 downstream
drwxr-xr-x steve.briggs staff 480 B Tue May  5 14:34:37 2026 ..


1	# otel-collector
2
3	Two-tier OpenTelemetry collector module: a load-balancer deployment in front of a processor StatefulSet, exporting to Datadog by default or to a configurable OTLP downstream when `downstream_endpoint` is set.
4
5	## Architecture
6
7	Workloads emit OTLP to the load-balancer tier (Deployment), which distributes spans, metrics, and logs across the processor tier (StatefulSet) using consistent hashing. The processor tier enriches, batches, and exports telemetry. Persistent volumes on the processor nodes back the sending queue so in-flight data survives pod restarts.
8
9	```
10	workloads (OTLP/gRPC or HTTP)
11	        |
12	        v
13	 otel-collector-lb  (Deployment, N replicas)
14	   loadbalancing exporter — consistent-hash routing
15	        |
16	        v
17	 otel-collector-processor  (StatefulSet, N replicas)
18	   batch / resourcedetection / k8sattributes
19	        |
20	        +-- [direct mode]      --> Datadog API
21	        +-- [downstream mode]  --> OTLP endpoint (e.g. Vector)
22	```
23
24	## Modes
25
26	### Direct mode (default — `downstream_endpoint = ""`)
27
28	Behavior when `downstream_endpoint` is empty (the default):
29
30	- The module creates a Kubernetes `ExternalSecret` that pulls the Datadog API key from Vault (`secret/data/saas/infrastructure/datadog-api-key`) and materialises it as the `otel-collector` Kubernetes Secret.
31	- The processor Helm release mounts that secret via `extraEnvsFrom`, making `DD_API_KEY` available to the collector process.
32	- The rendered collector config includes `exporters.datadog/exporter`; pipelines reference `[datadog/exporter]`. The traces pipeline additionally uses `datadog/connector` for APM stats.
33
34	### Downstream mode (`downstream_endpoint = "http://..."`)
35
36	Behavior when `downstream_endpoint` is non-empty (introduced in v1.1.0):
37
38	- The `ExternalSecret` resource is **not** created — no Datadog API key is required or injected.
39	- The processor Helm release omits `extraEnvsFrom` for the Datadog secret.
40	- The rendered collector config includes `exporters.otlp` configured as follows:
41	  - `endpoint`: value of `var.downstream_endpoint`
42	  - `tls.insecure: true`
43	  - `sending_queue.queue_size: 5000`
44	  - `retry_on_failure.max_elapsed_time: "300s"`
45	- Pipelines reference `[otlp]` as the terminal exporter. The traces pipeline still keeps `datadog/connector` for APM stats passthrough but drops `datadog/exporter`.
46
47	This mode is designed for routing through Vector (or any other OTLP-aware sink) — for example, to redact PII before forwarding to Datadog.
48
49	## Inputs
50
51	### Required
52
53	| Name | Type | Description |
54	|------|------|-------------|
55	| `chart_version` | `string` | Version of the OTel collector Helm chart |
56	| `image_tag_version` | `string` | Image tag to use for the collector containers |
57
58	### Optional
59
60	| Name | Type | Default | Description |
61	|------|------|---------|-------------|
62	| `name` | `string` | `"otel-collector"` | Base name for created resources |
63	| `otel_image` | `string` | `registry.ic-ops.net/.../opentelemetry-collector-contrib` | Image repository for the processor tier |
64	| `otellb_image` | `string` | `registry.ic-ops.net/.../opentelemetry-collector` | Image repository for the LB tier |
65	| `otel_lb_replicas` | `number` | `3` | Replica count for the LB Deployment |
66	| `otel_collector_replicas` | `number` | `3` | Replica count for the processor StatefulSet |
67	| `otel_sa_name` | `string` | `"otel-collector-sa"` | ServiceAccount name for the processor StatefulSet |
68	| `otellb_sa_name` | `string` | `"otel-collector-lb-sa"` | ServiceAccount name for the LB Deployment |
69	| `otel_pv_size` | `string` | `"5Gi"` | PersistentVolume size for the processor queue storage |
70	| `otel_storage_class_name` | `string` | `"ebs-encrypted"` | StorageClass for the processor PersistentVolumeClaim |
71	| `sa_annotations` | `map(string)` | `{}` | Annotations to apply to both ServiceAccounts (e.g. IRSA role ARN) |
72	| `namespace` | `string` | `"opentelemetry"` | Kubernetes namespace for all resources |
73	| `annotations` | `map(string)` | `{}` | Annotations to apply to collector pods |
74	| `extra_args` | `list(string)` | `[]` | Extra CLI arguments for the processor collector container |
75	| `otellb_extra_args` | `list(string)` | `[]` | Extra CLI arguments for the LB collector container |
76	| `tags` | `map(any)` | `{}` | Tags to apply to created resources |
77	| `downstream_endpoint` | `string` | `""` | When non-empty, swap the terminal exporter to OTLP pointed at this endpoint. Empty preserves direct-to-Datadog behavior. |
78	| `downstream_protocol` | `string` | `"otlp/grpc"` | Wire protocol for the downstream OTLP exporter. Allowed: `otlp/grpc`, `otlp/http`. |
79
80	## Outputs
81
82	All outputs are prefixed with `helm_chart_lb_*` (load-balancer release) or `helm_chart_collector_*` (processor release).
83
84	| Name | Description |
85	|------|-------------|
86	| `helm_chart_lb_status` | Helm release status for the LB tier |
87	| `helm_chart_lb_version` | Helm chart version for the LB tier |
88	| `helm_chart_lb_revision` | Helm revision number for the LB tier |
89	| `helm_chart_lb_app_version` | Application version for the LB tier |
90	| `helm_chart_lb_values` | Rendered Helm values for the LB tier |
91	| `helm_chart_collector_status` | Helm release status for the processor tier |
92	| `helm_chart_collector_version` | Helm chart version for the processor tier |
93	| `helm_chart_collector_revision` | Helm revision number for the processor tier |
94	| `helm_chart_collector_app_version` | Application version for the processor tier |
95	| `helm_chart_collector_values` | Rendered Helm values for the processor tier |
96
97	## Usage
98
99	### Direct mode (export to Datadog)
100
101	```hcl
102	module "otel_collector" {
103	  source = "git@gitlab.infrastructure.immuta.io:infrastructure/terraform/internal-modules/kubernetes/monitoring/otel-collector.git?ref=v1.1.0"
104
105	  chart_version     = "0.85.0"
106	  image_tag_version = "0.85.0"
107
108	  # downstream_endpoint defaults to "" — direct-to-Datadog mode
109	}
110	```
111
112	### Downstream mode (route through Vector for redaction)
113
114	```hcl
115	module "otel_collector" {
116	  source = "git@gitlab.infrastructure.immuta.io:infrastructure/terraform/internal-modules/kubernetes/monitoring/otel-collector.git?ref=v1.1.0"
117
118	  chart_version     = "0.85.0"
119	  image_tag_version = "0.85.0"
120
121	  downstream_endpoint = "http://vector.vector.svc.cluster.local:4317"
122	  downstream_protocol = "otlp/grpc"
123	}
124	```
125
126	A complete working example is in [`examples/downstream/`](./examples/downstream/).
127
128	## Compatibility
129
130	`v1.1.0` introduces downstream mode. Setting `downstream_endpoint = ""` (the default) preserves identical behavior to `v1.0.x` — no pipeline config, no ExternalSecret logic, and no Helm values change.
131
132	## Design reference
133
134	For the full design rationale behind downstream mode and the Vector redaction pipeline, see:
135	[`docs/superpowers/specs/2026-05-05-vector-redaction-design.md`](/Users/steve.briggs/projects/IAC/docs/superpowers/specs/2026-05-05-vector-redaction-design.md) §3.1, §5.1, §5.2
136


List downstream example files


drwxr-xr-x steve.briggs staff  96 B  Tue May  5 14:30:41 2026 ..
.rw-r--r-- steve.briggs staff 373 B  Tue May  5 14:30:56 2026 main.tf
.rw-r--r-- steve.briggs staff 628 B  Tue May  5 14:31:12 2026 README.md
drwxr-xr-x steve.briggs staff 128 B  Tue May  5 14:31:35 2026 .terraform
.rw-r--r-- steve.briggs staff 2.2 KB Tue May  5 14:31:37 2026 .terraform.lock.hcl
.rw-r--r-- steve.briggs staff 376 B  Tue May  5 14:31:51 2026 versions.tf
drwxr-xr-x steve.briggs staff 224 B  Tue May  5 14:31:51 2026 .


1	module "otel_collector" {
2	  source = "../.."
3
4	  # Required variables (no default in variables.tf).
5	  chart_version     = "0.85.0"
6	  image_tag_version = "0.85.0"
7
8	  # The new downstream-mode flag — routes all three pipelines through Vector
9	  # via OTLP/gRPC instead of exporting directly to Datadog.
10	  downstream_endpoint = "http://vector.vector.svc.cluster.local:4317"
11	}
12


1	terraform {
2	  required_version = ">= 1.9.0"
3	  required_providers {
4	    kubernetes = {
5	      source  = "hashicorp/kubernetes"
6	      version = ">= 2.20"
7	    }
8	    helm = {
9	      source  = "hashicorp/helm"
10	      version = ">= 2.10"
11	    }
12	  }
13	}
14
15	provider "kubernetes" {
16	  config_path = "~/.kube/config"
17	}
18
19	provider "helm" {
20	  kubernetes {
21	    config_path = "~/.kube/config"
22	  }
23	}
24


1	# downstream example
2
3	Demonstrates routing the otel-collector's terminal exporter through Vector
4	(via OTLP/gRPC) instead of directly to Datadog. Used by CI for `terraform
5	validate` smoke testing — does not deploy.
6
7	When `downstream_endpoint` is non-empty:
8	- The module's ExternalSecret for the Datadog API key is NOT created.
9	- The processor helm release renders `exporters.otlp` (not `exporters.datadog/exporter`).
10	- All three pipelines (logs, traces, metrics) export to OTLP.
11	- The trace pipeline still includes the `datadog/connector` for span-derived metrics.
12
13	```bash
14	terraform init -backend=false
15	terraform validate
16	```
17


Good. Now let me run the Terraform verification checks.


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && terraform fmt -check -recursive 2>&1

Run terraform fmt check recursively at module root


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && terraform validate 2>&1

Run terraform validate at module root


(Bash completed with no output)


Success! The configuration is valid.


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector/examples/downstream && terraform init -backend=false 2>&1 && terraform validate 2>&1

Init and validate examples/downstream


Error: Exit code 1
Initializing modules...
╷
│ Error: Unsupported Terraform Core version
│
│   on versions.tf line 2, in terraform:
│    2:   required_version = ">= 1.9.0"
│
│ This configuration does not support Terraform version 1.3.10. To proceed,
│ either choose another supported Terraform version or update this version
│ constraint. Version constraints are normally set for good reason, so
│ updating the constraint may lead to other errors or unexpected behavior.
╵


There's a version mismatch. Let me check the installed Terraform version and whether there's a way to work around this, plus run the git diff checks.


Check installed Terraform version


which tfenv 2>&1; tfenv list 2>&1 | head -20

Check if tfenv is available and list versions


Terraform v1.3.10
on darwin_arm64
+ provider registry.terraform.io/hashicorp/helm v3.1.1
+ provider registry.terraform.io/hashicorp/kubernetes v3.1.0

Your version of Terraform is out of date! The latest version
is 1.15.1. You can update by downloading from https://www.terraform.io/downloads.html


tfenv not found
zsh: command not found: tfenv


which brew 2>&1 && brew list --formula | grep terraform 2>&1

Check brew for terraform installations


ls /usr/local/bin/terraform* /opt/homebrew/bin/terraform* 2>&1; ls ~/.local/bin/terraform* 2>&1

Check for other terraform binaries


Error: Exit code 1
(eval):1: no matches found: /usr/local/bin/terraform*
(eval):1: no matches found: /Users/steve.briggs/.local/bin/terraform*


brew list --formula 2>&1 | grep -i terraform

List brew terraform packages


ls /opt/homebrew/bin/terraform* 2>&1; which -a terraform 2>&1

Find all terraform binaries


(eval):1: no matches found: /opt/homebrew/bin/terraform*
/Users/steve.briggs/.local/share/mise/installs/terraform/1.3.10/bin/terraform


List available terraform versions via mise


lrwxr-xr-x steve.briggs staff   8 B  Tue Jul 16 11:11:12 2024 1.3 ⇒ ./1.3.10
drwxr-xr-x steve.briggs staff  96 B  Tue Jul 16 11:12:16 2024 1.3.7
drwxr-xr-x steve.briggs staff  96 B  Wed Jul 24 16:05:46 2024 1.1.0
lrwxr-xr-x steve.briggs staff   7 B  Wed Jul 24 16:05:47 2024 1.1 ⇒ ./1.1.0
drwxr-xr-x steve.briggs staff  96 B  Wed Aug 28 16:48:52 2024 1.0.11
lrwxr-xr-x steve.briggs staff   8 B  Wed Aug 28 16:48:53 2024 1.0 ⇒ ./1.0.11
drwxr-xr-x steve.briggs staff  96 B  Wed Apr 16 13:48:49 2025 1.3.4
drwxr-xr-x steve.briggs staff  96 B  Mon Dec  1 15:57:36 2025 0.12.26
lrwxr-xr-x steve.briggs staff   9 B  Mon Dec  1 15:57:38 2025 0 ⇒ ./0.12.26
lrwxr-xr-x steve.briggs staff   9 B  Mon Dec  1 15:57:38 2025 0.12 ⇒ ./0.12.26
drwxr-xr-x steve.briggs staff  96 B  Mon Dec  1 16:13:28 2025 1.3.10
.rw-r--r-- steve.briggs staff  24 B  Mon Dec  1 16:13:28 2025 .mise.backend
drwxr-xr-x steve.briggs staff  96 B  Tue Feb 24 11:05:14 2026 1.9.8
lrwxr-xr-x steve.briggs staff   7 B  Tue Feb 24 11:05:15 2026 latest ⇒ ./1.9.8
lrwxr-xr-x steve.briggs staff   7 B  Tue Feb 24 11:05:15 2026 1 ⇒ ./1.9.8
lrwxr-xr-x steve.briggs staff   7 B  Tue Feb 24 11:05:15 2026 1.9 ⇒ ./1.9.8
drwxr-xr-x steve.briggs staff  96 B  Fri Mar 13 15:43:27 2026 1.9.0
drwxr-xr-x steve.briggs staff 1.2 KB Thu Apr 30 11:13:57 2026 ..
drwxr-xr-x steve.briggs staff 608 B  Tue May  5 14:01:27 2026 .


Terraform 1.9.8 is available via mise. Let me try running the example validate with that version.


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector/examples/downstream && /Users/steve.briggs/.local/share/mise/installs/terraform/1.9.8/bin/terraform init -backend=false 2>&1 && /Users/steve.briggs/.local/share/mise/installs/terraform/1.9.8/bin/terraform validate 2>&1

Init and validate examples/downstream with terraform 1.9.8


Initializing modules...
Initializing provider plugins...
- Reusing previous version of hashicorp/kubernetes from the dependency lock file
- Reusing previous version of hashicorp/helm from the dependency lock file
- Using previously-installed hashicorp/kubernetes v2.38.0
- Using previously-installed hashicorp/helm v2.17.0

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
Success! The configuration is valid.


Good. Now let me do the static verification of config values (Checks 1 and 2) by evaluating what the locals produce in direct vs. downstream mode.


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git log --oneline -10 2>&1

Check git log for recent commits


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git tag --list 2>&1

List git tags


a96caed lower to 75
37de6af remove debug logging
226791c lower batch size
270f043 updates
49f968b updates
11e07a9 Merge branch 'haris/SRE-2691' into 'master'
af72158 fix(SRE-2691): add datadog/connnector to fix APM metrics
e743a81 Merge branch 'haris/add-json-logging' into 'master'
7a9d800 SRE-2661 - setting logging output to JSON
a09a1f7 Merge branch 'haris/fix-refresh-interval-drift' into 'master'


v1.0.0
v1.0.1
v1.0.2
v1.0.3
v1.0.4
v1.0.5


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git diff v1.0.5 -- lbconfig.tf 2>&1

Read-only git diff of lbconfig.tf against v1.0.5


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git diff v1.0.5 -- otelconfig.tf 2>&1

Read-only git diff of otelconfig.tf against v1.0.5


diff --git a/lbconfig.tf b/lbconfig.tf
index c8e537d..70cf479 100644
--- a/lbconfig.tf
+++ b/lbconfig.tf
@@ -31,8 +31,8 @@ locals {
           ]
           labels = [{
             key_regex = "tags\\.datadoghq\\.com/(.*)$"
-            tag_name = "$$1"
-            from = "pod"
+            tag_name  = "$$1"
+            from      = "pod"
           }]
         }
         passthrough = false
@@ -103,21 +103,21 @@ locals {
     service = {
       pipelines = {
         traces = {
-          receivers = ["otlp"]
+          receivers  = ["otlp"]
           processors = ["k8sattributes"]
-          exporters = ["loadbalancing"]
+          exporters  = ["loadbalancing"]
         }

         logs = {
-          receivers = ["otlp"]
+          receivers  = ["otlp"]
           processors = ["k8sattributes"]
-          exporters = ["loadbalancing"]
+          exporters  = ["loadbalancing"]
         }

         metrics = {
-          receivers = ["otlp"]
+          receivers  = ["otlp"]
           processors = ["k8sattributes"]
-          exporters = ["loadbalancing/metrics"]
+          exporters  = ["loadbalancing/metrics"]
         }
       }
       telemetry = {


diff --git a/otelconfig.tf b/otelconfig.tf
index 3b6a3e9..dd6f59f 100644
--- a/otelconfig.tf
+++ b/otelconfig.tf
@@ -1,5 +1,12 @@
 locals {
-  otel_config = {
+  # Pipeline exporter lists swap based on mode
+  traces_exporters  = var.downstream_endpoint == "" ? ["datadog/connector", "datadog/exporter"] : ["datadog/connector", "otlp"]
+  logs_exporters    = var.downstream_endpoint == "" ? ["datadog/exporter"] : ["otlp"]
+  metrics_exporters = var.downstream_endpoint == "" ? ["datadog/exporter"] : ["otlp"]
+
+  # Shared structural skeleton — receivers, processors, connectors, extensions, service
+  # are identical in both modes; only exporters differs.
+  _otel_base = {
     receivers = {
       otlp = {
         protocols = {
@@ -15,9 +22,9 @@ locals {

     processors = {
       batch = {
-        timeout = "10s"
-        send_batch_size = 10
-        send_batch_max_size = 100
+        timeout             = "10s"
+        send_batch_size     = 10
+        send_batch_max_size = 75
       }
     }

@@ -25,40 +32,34 @@ locals {
       "datadog/connector" = {}
     }

-    exporters = {
-      "datadog/exporter" = {
-        api = {
-          site = "datadoghq.com"
-          key = "$${env:DD_API_KEY}"
-        }
-      }
-    }
-
     extensions = {
       health_check = {
         endpoint = "$${env:MY_POD_IP}:13133"
       }
+      "file_storage/persistent_queue" = {
+        directory = "/var/lib/storage/queue"
+      }
     }

     service = {
-      extensions = ["health_check"]
+      extensions = ["health_check", "file_storage/persistent_queue"]
       pipelines = {
         traces = {
-          receivers = ["otlp"]
+          receivers  = ["otlp"]
           processors = []
-          exporters = ["datadog/connector", "datadog/exporter"]
+          exporters  = local.traces_exporters
         }

         logs = {
-          receivers = ["otlp"]
+          receivers  = ["otlp"]
           processors = []
-          exporters = ["datadog/exporter"]
+          exporters  = local.logs_exporters
         }

         metrics = {
-          receivers = ["otlp", "datadog/connector"]
+          receivers  = ["otlp", "datadog/connector"]
           processors = []
-          exporters = ["datadog/exporter"]
+          exporters  = local.metrics_exporters
         }
       }
       telemetry = {
@@ -68,4 +69,56 @@ locals {
       }
     }
   }
+
+  # Direct (Datadog) exporter — active when downstream_endpoint is empty
+  _exporters_direct = {
+    exporters = {
+      "datadog/exporter" = {
+        api = {
+          site = "datadoghq.com"
+          key  = "$${env:DD_API_KEY}"
+        }
+        sending_queue = {
+          enabled = true
+          storage = "file_storage/persistent_queue"
+        }
+      }
+    }
+  }
+
+  # Downstream (OTLP/Vector) exporter — active when downstream_endpoint is set
+  _exporters_downstream = {
+    exporters = {
+      otlp = {
+        endpoint = var.downstream_endpoint
+        tls = {
+          insecure = true
+        }
+        sending_queue = {
+          enabled    = true
+          queue_size = 5000
+        }
+        retry_on_failure = {
+          enabled          = true
+          initial_interval = "5s"
+          max_interval     = "30s"
+          max_elapsed_time = "300s"
+        }
+      }
+    }
+  }
+
+  # Encode each exporter block to JSON first so the ternary operates on
+  # uniform string types, bypassing Terraform's structural type check on
+  # objects with different attribute names.
+  _exporters_direct_json     = jsonencode(local._exporters_direct)
+  _exporters_downstream_json = jsonencode(local._exporters_downstream)
+
+  # merge() combines the shared base with the active exporter block.
+  otel_config = merge(
+    local._otel_base,
+    jsondecode(
+      var.downstream_endpoint == "" ? local._exporters_direct_json : local._exporters_downstream_json
+    )
+  )
 }


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git diff v1.0.5 -- main.tf 2>&1

Read-only git diff of main.tf against v1.0.5


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git diff v1.0.5 -- variables.tf 2>&1

Read-only git diff of variables.tf against v1.0.5


diff --git a/main.tf b/main.tf
index bba560c..07c69fc 100644
--- a/main.tf
+++ b/main.tf
@@ -32,11 +32,13 @@ resource "kubernetes_namespace" "this" {
 }

 resource "kubernetes_manifest" "otel-collector" {
+  count = var.downstream_endpoint == "" ? 1 : 0
+
   manifest = {
     "apiVersion" = "external-secrets.io/v1"
     "kind"       = "ExternalSecret"
     "metadata" = {
-      "name" = "otel-collector"
+      "name"      = "otel-collector"
       "namespace" = var.namespace
     }
     "spec" = {
@@ -66,18 +68,18 @@ resource "kubernetes_manifest" "otel-collector" {

 resource "helm_release" "otel_collector_lb" {
   #name      = var.name
-  name = "otel-collector-lb"
-  namespace = var.namespace
+  name             = "otel-collector-lb"
+  namespace        = var.namespace
   create_namespace = false
-  repository = "https://open-telemetry.github.io/opentelemetry-helm-charts/"
-  chart      = "opentelemetry-collector"
-  version    = var.chart_version
+  repository       = "https://open-telemetry.github.io/opentelemetry-helm-charts/"
+  chart            = "opentelemetry-collector"
+  version          = var.chart_version

   values = [yamlencode({
     mode = "deployment"
     image = {
       repository = var.otellb_image
-      tag = var.image_tag_version
+      tag        = var.image_tag_version
     }
     replicaCount = var.otel_lb_replicas

@@ -86,7 +88,7 @@ resource "helm_release" "otel_collector_lb" {
     annotations = var.annotations

     podLabels = {
-      "ic-ops.net/otel" = "collector"
+      "ic-ops.net/otel"           = "collector"
       "app.kubernetes.io/part-of" = "opentelemetry-collector"
     }

@@ -99,15 +101,15 @@ resource "helm_release" "otel_collector_lb" {
     presets = {
       # enables the k8sattributesprocessor and adds it to the traces, metrics, and logs pipelines
       kubernetesAttributes = {
-        enabled = true
-        extractAllPodLabels = false
+        enabled                  = true
+        extractAllPodLabels      = false
         extractAllPodAnnotations = true
       }
     }

     serviceAccount = {
-      create = true,
-      name   = var.otellb_sa_name
+      create      = true,
+      name        = var.otellb_sa_name
       annotations = var.sa_annotations
     }

@@ -117,17 +119,17 @@ resource "helm_release" "otel_collector_lb" {

     ports = {
       otlp = {
-        enabled = true
+        enabled       = true
         containerPort = 4317
-        servicePort = 4317
-        protocol = "TCP"
-        appProtocol = "grpc"
+        servicePort   = 4317
+        protocol      = "TCP"
+        appProtocol   = "grpc"
       }
       otlp-http = {
-        enabled = true
+        enabled       = true
         containerPort = 4318
-        servicePort = 4318
-        protocol = "TCP"
+        servicePort   = 4318
+        protocol      = "TCP"
       }
       jaegar-compact = {
         enabled = false
@@ -155,37 +157,77 @@ resource "helm_release" "otel_collector_lb" {

 resource "helm_release" "otel_collector_processor" {
   #name      = var.name
-  name = "otel-collector-processor"
-  namespace = var.namespace
+  name             = "otel-collector-processor"
+  namespace        = var.namespace
   create_namespace = false
-  repository = "https://open-telemetry.github.io/opentelemetry-helm-charts/"
-  chart      = "opentelemetry-collector"
-  version    = var.chart_version
+  repository       = "https://open-telemetry.github.io/opentelemetry-helm-charts/"
+  chart            = "opentelemetry-collector"
+  version          = var.chart_version

   values = [yamlencode({
     mode = "statefulset"
     image = {
       repository = var.otel_image
-      tag = var.image_tag_version
+      tag        = var.image_tag_version
+    }
+    statefulset = {
+      persistentVolumeClaimRetentionPolicy = {
+        enabled     = true
+        whenDeleted = "Delete"
+        whenScaled  = "Delete"
+      }
+
+      volumeClaimTemplates = [
+        {
+          metadata = {
+            name = "otel-collector-data"
+          }
+          spec = {
+            accessModes = ["ReadWriteOnce"]
+            resources = {
+              requests = {
+                storage = var.otel_pv_size
+              }
+            }
+            //storageClassName = var.otel_storage_class_name
+          }
+        }
+      ]
     }
+
+    extraVolumeMounts = [{
+      name      = "otel-collector-data"
+      mountPath = "/var/lib/storage/queue"
+    }]
+
+    initContainers = [{
+      name    = "init-fs"
+      image   = "busybox:latest"
+      command = ["sh", "-c", "chown -R 10001: /var/lib/storage/queue"]
+      volumeMounts = [{
+        name      = "otel-collector-data"
+        mountPath = "/var/lib/storage/queue"
+      }]
+    }]
+
     nameOverride = "processor"

     replicaCount = var.otel_collector_replicas

     service = {
-      type = "ClusterIP"
+      type      = "ClusterIP"
       clusterIP = "None" # Headless service for statefulset
     }

     annotations = var.annotations
     podLabels = {
-      "ic-ops.net/otel" = "processor"
+      "ic-ops.net/otel"           = "processor"
       "app.kubernetes.io/part-of" = "opentelemetry-collector"
     }

     serviceAccount = {
-      create = true,
-      name   = var.otel_sa_name,
+      create      = true,
+      name        = var.otel_sa_name,
       annotations = var.sa_annotations
     }

@@ -193,34 +235,34 @@ resource "helm_release" "otel_collector_processor" {
       extraArgs = var.extra_args
     }

-    extraEnvsFrom = [{
+    extraEnvsFrom = var.downstream_endpoint == "" ? [{
       secretRef = {
         name = "otel-collector"
       }
-    }]
+    }] : []

     #presets = {
-      # enables the k8sattributesprocessor and adds it to the traces, metrics, and logs pipelines
-      #kubernetesAttributes = {
-      #  enabled = true
-      #  extractAllPodLabels = true
-      #  extractAllPodAnnotations = true
-      #}
+    # enables the k8sattributesprocessor and adds it to the traces, metrics, and logs pipelines
+    #kubernetesAttributes = {
+    #  enabled = true
+    #  extractAllPodLabels = true
+    #  extractAllPodAnnotations = true
+    #}
     #}

     ports = {
       otlp = {
-        enabled = true
+        enabled       = true
         containerPort = 4317
-        servicePort = 4317
-        protocol = "TCP"
-        appProtocol = "grpc"
+        servicePort   = 4317
+        protocol      = "TCP"
+        appProtocol   = "grpc"
       }
       otlp-http = {
-        enabled = true
+        enabled       = true
         containerPort = 4318
-        servicePort = 4318
-        protocol = "TCP"
+        servicePort   = 4318
+        protocol      = "TCP"
       }
       jaegar-compact = {
         enabled = false
@@ -243,6 +285,10 @@ resource "helm_release" "otel_collector_processor" {
     kubernetes_namespace.this,
     kubernetes_manifest.otel-collector
   ]
+
+  lifecycle {
+    create_before_destroy = false
+  }
 }

 module "netpol" {


diff --git a/variables.tf b/variables.tf
index 3c76024..b6ed234 100644
--- a/variables.tf
+++ b/variables.tf
@@ -36,14 +36,14 @@ variable "otellb_image" {

 variable "otel_lb_replicas" {
   description = "number of otel loadbalancer replicas to create (statefulset)"
-  type = number
-  default = 3
+  type        = number
+  default     = 3
 }

 variable "otel_collector_replicas" {
   description = "number of replicas to create for the otel collector processor"
-  type = number
-  default = 3
+  type        = number
+  default     = 3
 }

 variable "otel_sa_name" {
@@ -58,6 +58,17 @@ variable "otellb_sa_name" {
   default     = "otel-collector-lb-sa"
 }

+variable "otel_pv_size" {
+  description = "Size of the Persistent Volume to be used for otel collector statefulset"
+  type        = string
+  default     = "5Gi"
+}
+
+variable "otel_storage_class_name" {
+  description = "Storage Class name to be used for otel collector statefulset Persistent Volume"
+  type        = string
+  default     = "ebs-encrypted"
+}
 variable "sa_annotations" {
   description = "Helm Value: A map providing annotations to apply to the otel collector ServiceAccount"
   type        = map(string)
@@ -93,3 +104,19 @@ variable "tags" {
   type        = map(any)
   default     = {}
 }
+
+variable "downstream_endpoint" {
+  description = "When non-empty, swap the terminal exporter from datadog to OTLP, pointed at this endpoint. Used to route through Vector for redaction."
+  type        = string
+  default     = ""
+}
+
+variable "downstream_protocol" {
+  description = "Protocol for downstream OTLP exporter. Currently only otlp/grpc supported."
+  type        = string
+  default     = "otlp/grpc"
+  validation {
+    condition     = contains(["otlp/grpc", "otlp/http"], var.downstream_protocol)
+    error_message = "downstream_protocol must be one of: otlp/grpc, otlp/http"
+  }
+}


I now have all the information needed for a comprehensive review. Let me do the perf-tuning check to see if the `otel-batch-size` branch values were touched.


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git log --oneline --all 2>&1 | head -20

List all commits across all branches


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git branch --all 2>&1

List all branches


a96caed lower to 75
37de6af remove debug logging
226791c lower batch size
270f043 updates
49f968b updates
11e07a9 Merge branch 'haris/SRE-2691' into 'master'
af72158 fix(SRE-2691): add datadog/connnector to fix APM metrics
e743a81 Merge branch 'haris/add-json-logging' into 'master'
7a9d800 SRE-2661 - setting logging output to JSON
a09a1f7 Merge branch 'haris/fix-refresh-interval-drift' into 'master'
79cee63 fix: qualifying refresh interval to prevent pointless drift
1b9001d Merge branch 'add-lb-deployment' into 'master'
8c64a8b netpol
7a8d8e7 Merge branch 'add-lb-deployment' into 'master'
077b3af update processor to have 3 replicas by default
75510a4 remove batch from load balancer
c0aada2 fix branch
f0c118d updates
153d0d2 latest updates
50f7fec Merge branch 'add-lb-deployment' into 'master'


  SRE-2701
  master
* otel-batch-size
  remotes/origin/HEAD -> origin/master
  remotes/origin/SRE-2701
  remotes/origin/master
  remotes/origin/otel-batch-size
  remotes/origin/steve


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git diff master...otel-batch-size -- otelconfig.tf 2>&1

Read-only diff between master and otel-batch-size branch for otelconfig.tf


diff --git a/otelconfig.tf b/otelconfig.tf
index 3b6a3e9..e4e4883 100644
--- a/otelconfig.tf
+++ b/otelconfig.tf
@@ -17,7 +17,7 @@ locals {
       batch = {
         timeout = "10s"
         send_batch_size = 10
-        send_batch_max_size = 100
+        send_batch_max_size = 75
       }
     }

@@ -31,6 +31,10 @@ locals {
           site = "datadoghq.com"
           key = "$${env:DD_API_KEY}"
         }
+        sending_queue = {
+          enabled = true
+          storage = "file_storage/persistent_queue"
+        }
       }
     }

@@ -38,10 +42,13 @@ locals {
       health_check = {
         endpoint = "$${env:MY_POD_IP}:13133"
       }
+      "file_storage/persistent_queue" = {
+        directory = "/var/lib/storage/queue"
+      }
     }

     service = {
-      extensions = ["health_check"]
+      extensions = ["health_check","file_storage/persistent_queue"]
       pipelines = {
         traces = {
           receivers = ["otlp"]


This is exactly the information I need. Now I have a complete picture. Let me check the current HEAD to understand what branch Phase B work was done on.


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git log --oneline HEAD...v1.0.5 2>&1

Show commits on current branch since v1.0.5


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git show HEAD --stat 2>&1 | head -30

Show HEAD commit details


a96caed lower to 75
37de6af remove debug logging
226791c lower batch size
270f043 updates
49f968b updates


commit a96caed846cb5b66abc7c301931cd5cbfd0fb0e5
Author: mr0xb <47467008+mr0xb@users.noreply.github.com>
Date:   Thu Mar 5 16:48:00 2026 -0500

    lower to 75

 otelconfig.tf | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git log --oneline SRE-2701 2>&1 | head -15

Show commits on SRE-2701 branch


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git log --oneline master 2>&1 | head -10

Show commits on master branch


270f043 updates
49f968b updates
11e07a9 Merge branch 'haris/SRE-2691' into 'master'
af72158 fix(SRE-2691): add datadog/connnector to fix APM metrics
e743a81 Merge branch 'haris/add-json-logging' into 'master'
7a9d800 SRE-2661 - setting logging output to JSON
a09a1f7 Merge branch 'haris/fix-refresh-interval-drift' into 'master'
79cee63 fix: qualifying refresh interval to prevent pointless drift
1b9001d Merge branch 'add-lb-deployment' into 'master'
8c64a8b netpol
7a8d8e7 Merge branch 'add-lb-deployment' into 'master'
077b3af update processor to have 3 replicas by default
75510a4 remove batch from load balancer
c0aada2 fix branch
f0c118d updates


11e07a9 Merge branch 'haris/SRE-2691' into 'master'
af72158 fix(SRE-2691): add datadog/connnector to fix APM metrics
e743a81 Merge branch 'haris/add-json-logging' into 'master'
7a9d800 SRE-2661 - setting logging output to JSON
a09a1f7 Merge branch 'haris/fix-refresh-interval-drift' into 'master'
79cee63 fix: qualifying refresh interval to prevent pointless drift
1b9001d Merge branch 'add-lb-deployment' into 'master'
8c64a8b netpol
7a8d8e7 Merge branch 'add-lb-deployment' into 'master'
077b3af update processor to have 3 replicas by default


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git diff SRE-2701...otel-batch-size -- otelconfig.tf 2>&1

Diff between SRE-2701 and otel-batch-size branches for otelconfig.tf


diff --git a/otelconfig.tf b/otelconfig.tf
index 02d2aaa..e4e4883 100644
--- a/otelconfig.tf
+++ b/otelconfig.tf
@@ -17,7 +17,7 @@ locals {
       batch = {
         timeout = "10s"
         send_batch_size = 10
-        send_batch_max_size = 100
+        send_batch_max_size = 75
       }
     }

@@ -33,7 +33,7 @@ locals {
         }
         sending_queue = {
           enabled = true
-          storage = "file_storage"
+          storage = "file_storage/persistent_queue"
         }
       }
     }
@@ -42,13 +42,13 @@ locals {
       health_check = {
         endpoint = "$${env:MY_POD_IP}:13133"
       }
-      file_storage = {
+      "file_storage/persistent_queue" = {
         directory = "/var/lib/storage/queue"
       }
     }

     service = {
-      extensions = ["health_check","file_storage"]
+      extensions = ["health_check","file_storage/persistent_queue"]
       pipelines = {
         traces = {
           receivers = ["otlp"]


cd /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector && git show SRE-2701:otelconfig.tf 2>&1

Show otelconfig.tf at SRE-2701 tip


locals {
  otel_config = {
    receivers = {
      otlp = {
        protocols = {
          grpc = {
            endpoint = "0.0.0.0:4317"
          }
          http = {
            endpoint = "0.0.0.0:4318"
          }
        }
      }
    }

    processors = {
      batch = {
        timeout = "10s"
        send_batch_size = 10
        send_batch_max_size = 100
      }
    }

    connectors = {
      "datadog/connector" = {}
    }

    exporters = {
      "datadog/exporter" = {
        api = {
          site = "datadoghq.com"
          key = "$${env:DD_API_KEY}"
        }
        sending_queue = {
          enabled = true
          storage = "file_storage"
        }
      }
    }

    extensions = {
      health_check = {
        endpoint = "$${env:MY_POD_IP}:13133"
      }
      file_storage = {
        directory = "/var/lib/storage/queue"
      }
    }

    service = {
      extensions = ["health_check","file_storage"]
      pipelines = {
        traces = {
          receivers = ["otlp"]
          processors = []
          exporters = ["datadog/connector", "datadog/exporter"]
        }

        logs = {
          receivers = ["otlp"]
          processors = []
          exporters = ["datadog/exporter"]
        }

        metrics = {
          receivers = ["otlp", "datadog/connector"]
          processors = []
          exporters = ["datadog/exporter"]
        }
      }
      telemetry = {
        logs = {
          encoding = "json"
        }
      }
    }
  }
}


Now I have a comprehensive picture. Let me also check if `downstream_protocol` is actually wired into anything in otelconfig.tf, since it's defined in variables.tf but I want to verify it's not a dead variable.


grep -rn "downstream_protocol" /Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector/ 2>&1

Search for downstream_protocol usage across the module


/Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector/README.md:78:| `downstream_protocol` | `string` | `"otlp/grpc"` | Wire protocol for the downstream OTLP exporter. Allowed: `otlp/grpc`, `otlp/http`. |
/Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector/README.md:122:  downstream_protocol = "otlp/grpc"
/Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector/variables.tf:114:variable "downstream_protocol" {
/Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector/variables.tf:119:    condition     = contains(["otlp/grpc", "otlp/http"], var.downstream_protocol)
/Users/steve.briggs/projects/IAC/terraform/internal-modules/kubernetes/monitoring/otel-collector/variables.tf:120:    error_message = "downstream_protocol must be one of: otlp/grpc, otlp/http"


That's a notable finding: `downstream_protocol` is declared and validated, but never referenced in `otelconfig.tf` or `main.tf` — it's a dead variable. Let me also confirm the `send_batch_max_size` value in the current HEAD state and the `file_storage` extension naming.


Check batch size and file_storage values in current otelconfig.tf


27:        send_batch_max_size = 75
39:      "file_storage/persistent_queue" = {
45:      extensions = ["health_check", "file_storage/persistent_queue"]
83:          storage = "file_storage/persistent_queue"


Now I have everything I need for a complete and accurate report. Let me compile it.

---

**Status: ISSUES_FOUND**

---

## Check Results

**Check 1: Backwards compatibility (direct mode) — PASS with one caveat**

Verified by static analysis of `otelconfig.tf` and `main.tf`. When `downstream_endpoint == ""`:
- `exporters.datadog/exporter.api.site` = `"datadoghq.com"` — correct
- `exporters.datadog/exporter.api.key` = `"$${env:DD_API_KEY}"` — correct
- `exporters.datadog/exporter.sending_queue.storage` = `"file_storage/persistent_queue"` — correct
- Pipelines: `traces=[datadog/connector, datadog/exporter]`, `logs=[datadog/exporter]`, `metrics=[datadog/exporter]` — correct
- `extraEnvsFrom` conditional block renders `secretRef.name = "otel-collector"` — correct
- `kubernetes_manifest.otel-collector` has `count = var.downstream_endpoint == "" ? 1 : 0` — evaluates to 1 — correct

The caveat is noted under Check 7 below.

**Check 2: Downstream mode renders correctly — PASS**

Static analysis of `otelconfig.tf` with `downstream_endpoint != ""`:
- `exporters.otlp.endpoint` = `var.downstream_endpoint` — correct
- `exporters.otlp.tls.insecure` = `true` — correct
- `exporters.otlp.sending_queue.enabled` = `true`, `queue_size` = `5000` — correct
- `exporters.otlp.retry_on_failure` block present with `initial_interval="5s"`, `max_interval="30s"`, `max_elapsed_time="300s"` — correct
- `exporters.datadog/exporter` — absent; only `otlp` key in exporters map — correct
- Pipelines: `traces=[datadog/connector, otlp]`, `logs=[otlp]`, `metrics=[otlp]` — correct
- `extraEnvsFrom` renders to `[]` — correct
- `kubernetes_manifest.otel-collector` count evaluates to 0 — correct

The jsonencode/jsondecode ternary trick to bypass Terraform's structural type-checker is sound for this use case.

**Check 3: `terraform fmt -check -recursive` — PASS**

Exit 0, no output. All files are properly formatted.

**Check 4: `terraform validate` at module root — PASS**

Output: `Success! The configuration is valid.`

**Check 5: `examples/downstream` init + validate — PASS (with environment note)**

The local `terraform` binary is v1.3.10, which is below the `>= 1.9.0` constraint in `examples/downstream/versions.tf`. Running via the mise-installed 1.9.8 binary: `terraform init -backend=false` and `terraform validate` both succeed cleanly. This is not a defect — it correctly enforces the minimum version. CI will need to use 1.9.x or newer for this example.

**Check 6: `lbconfig.tf` semantic drift — PASS**

`git diff v1.0.5 -- lbconfig.tf` confirms the only changes are whitespace alignment (column-aligning `tag_name`/`from` in the labels block, and `receivers`/`exporters` in the pipelines block). No semantic changes whatsoever. Routing keys, resolver DNS hostnames, timeouts, TLS settings, and pipeline structures are byte-identical in terms of values.

**Check 7: `otelconfig.tf` unintended drift — PASS with one flag**

The connectors, processors, receivers, extensions, service.telemetry, and pipeline processor lists are all preserved. The only semantic changes compared to v1.0.5's `otelconfig.tf` are the expected Phase B additions (new exporter locals, conditional merge). However, there is one notable item worth understanding:

The `send_batch_max_size` value changed from `100` (v1.0.5) to `75` in the current HEAD. Looking at git history, commit `a96caed` ("lower to 75") and `226791c` ("lower batch size") are on the `otel-batch-size` branch, which is the *current checked-out branch*. The Phase B work was done on top of `otel-batch-size`, not on a clean branch off `master` or `v1.0.5`. This means the perf-tuning change (`send_batch_max_size = 100 → 75`) **is** present in the Phase B deliverable state, interleaved with the downstream-mode changes.

---

## Issues and Concerns for Sir Charles

**Issue 1 — `downstream_protocol` is a dead variable (minor but ship-worthy concern)**

`var.downstream_protocol` is declared in `variables.tf` with a validation block, documented in the README, and accepted in the example — but it is **never referenced anywhere in `otelconfig.tf` or `main.tf`**. The OTLP exporter in downstream mode is always configured as gRPC (via the endpoint URL convention) regardless of what this variable is set to. The variable has no effect. This is misleading to callers and will cause confusion when someone passes `downstream_protocol = "otlp/http"` expecting different behaviour and gets none.

Options: (a) wire it into the exporter config so it actually governs whether to use gRPC vs HTTP, or (b) remove it before tagging v1.1.0 and document "HTTP support is future work."

**Issue 2 — Phase B work is on `otel-batch-size`, not a clean feature branch**

The current HEAD (`a96caed`) is on the `otel-batch-size` branch, and Phase B commits (`270f043 updates`, `49f968b updates`) sit on top of the perf-tuning commits. This means a cherry-pick onto `v1.0.5` is **not clean** — the Phase B changes are entangled with `send_batch_max_size = 75` (the perf commit). A reviewer pulling this for MR will see the perf-tuning change as part of the downstream-mode MR unless the commits are rebased or the branch is re-cut from master/v1.0.5.

The `send_batch_max_size` change itself is benign and intentional, but it was supposed to be on the unmerged perf branch, not in the backwards-compat bump. Recommend clarifying intent before opening an MR: should v1.1.0 also absorb `send_batch_max_size = 75`, or should Phase B be re-cut from master with only the downstream-mode lines?

**Issue 3 — `file_storage` extension rename vs. SRE-2701 baseline**

The `SRE-2701` branch (which appears to be a pre-Phase B intermediate branch) has the extension named `file_storage` and the storage reference as `file_storage`. The current HEAD uses `file_storage/persistent_queue` throughout. This is the correct OTel naming convention for named extensions, so the change is right, but it confirms that Phase B absorbed some work that originated on `otel-batch-size` (the extension rename happened there). Same entanglement as Issue 2 — not a defect, but a cherry-pick onto v1.0.5 will need to carry this change explicitly.

**Issue 4 — `versions.tf` is absent from the module root**

The module root has no `versions.tf` (only `main.tf` with an inline `terraform {}` block). The example has a separate `versions.tf`. This is consistent with existing module style so it's not a defect, but worth noting if CI conventions require it as a separate file.

---

## Recommendation

**Do not open the MR yet.** There are two items to resolve first:

1. **Fix `downstream_protocol`** — either wire it into the OTLP exporter config (the right call, since the README already promises `otlp/http` support) or remove it and add a TODO comment. Shipping a public variable that does nothing will confuse callers from day one.

2. **Decide the branch strategy** — the Phase B commits currently sit on top of `otel-batch-size`. Before tagging v1.1.0, Sir Charles should either (a) agree that v1.1.0 also includes the `send_batch_max_size = 75` perf change and re-scope the MR description accordingly, or (b) re-cut the downstream-mode commits onto a fresh branch off master/v1.0.5 to keep the two changes separate.

Once those two items are resolved, the implementation is solid. The conditional exporter mechanics, the count gating on the ExternalSecret, the format check, and the validate all pass cleanly.