Skip to content

feat(otelcollector): render OTel Collector - #5106

Open
tianfeng92 wants to merge 8 commits into
tigera:masterfrom
tianfeng92:EV-6862-otel-collector
Open

feat(otelcollector): render OTel Collector#5106
tianfeng92 wants to merge 8 commits into
tigera:masterfrom
tianfeng92:EV-6862-otel-collector

Conversation

@tianfeng92

@tianfeng92 tianfeng92 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Render the OTel Collector as a StatefulSet in calico-system, configured via LogCollector.spec.otelCollector.

The collector receives logs from fluent-bit via OTLP and optionally federates Prometheus metrics, forwarding both to user-configured OTLP endpoints. It is added to the LogCollector CR rather than AdditionalStores because it is operator-managed infrastructure (StatefulSet, ConfigMap, RBAC, certs) with its own lifecycle, not a pointer to an external system.

Release Note

Add OpenTelemetry Collector component to export logs and metrics via OTLP.

Test plan

  • Unit tests for render, controller, and fluent-bit pipeline OTel output
  • Validated on MCM cluster — all three log types exporting, clean collector startup

EV-6862

Comment thread internal/controller/otelcollector_controller.go
Comment thread pkg/render/otelcollector/component.go Outdated
Comment on lines +65 to +68
DefaultMemoryLimit = "512Mi"
DefaultMemoryRequest = "128Mi"
DefaultMemoryLimitMiB = 409 // 80% of 512Mi
DefaultMemorySpikeLimitMiB = 100 // ~25% of limit_mib

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How are these memory limits decided? Are they from the vendor recommendations?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The memory_limiter processor follows the OTel Collector best practices: limit_mib should be ~80% of the container memory limit (409 ≈ 80% of 512Mi), and spike_limit_mib at ~25% of limit_mib (100 ≈ 25% of 409). This headroom lets the GC reclaim memory before the container hits OOMKill. See: https://github.com/open-telemetry/opentelemetry-collector/blob/main/processor/memorylimiterprocessor/README.md

The 512Mi container limit itself is a conservative starting point — the collector is mostly I/O bound (receiving and forwarding telemetry), so memory usage is dominated by in-flight batches rather than computation.

Comment thread pkg/render/otelcollector/component.go Outdated
@tianfeng92
tianfeng92 force-pushed the EV-6862-otel-collector branch 7 times, most recently from 8124957 to 222f3f4 Compare July 27, 2026 23:24
@tianfeng92 tianfeng92 changed the title feat(otelcollector): render OTel Collector from LogCollector.spec.otelCollector feat(otelcollector): render OTel Collector Jul 28, 2026
@tianfeng92
tianfeng92 force-pushed the EV-6862-otel-collector branch 2 times, most recently from 6dd36c8 to a8c2d9e Compare July 28, 2026 17:55
Comment thread pkg/controller/otelcollector/controller.go
Comment thread pkg/controller/otelcollector/controller.go
Comment thread pkg/render/otelcollector/component.go
Comment thread pkg/render/otelcollector/component.go
Comment thread pkg/controller/otelcollector/controller.go Outdated
Comment thread pkg/render/otelcollector/collector-config.yaml.template
Comment thread api/v1/otelcollector_types.go Outdated
Comment thread api/v1/logcollector_types.go Outdated
Comment thread pkg/render/otelcollector/component.go Outdated
Comment thread pkg/render/otelcollector/component.go Outdated
tianfeng92 and others added 4 commits August 6, 2026 14:42
…lCollector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Renames the API surface from OTel to OpenTelemetry, and reworks exporter
TLS, certificate rotation, validation and network policy.

API:
- OTel* types become OpenTelemetry*; LogCollector.spec.otelCollector becomes
  spec.openTelemetry. The file moves to api/v1/opentelemetry_types.go.
- Replace tlsInsecure with mutualTLS. tlsInsecure rendered tls.insecure,
  which disables TLS entirely rather than skipping verification, so it could
  never do what it documented. A private CA is now supplied out of band via
  the otel-collector-ca ConfigMap and a client keypair via the
  otel-collector-client-certs Secret, following the syslog/Splunk and
  external Elasticsearch conventions. Plaintext is selected by an http://
  endpoint, as with Splunk. No input disables verification.
- Add listType/listMapKey, required and default markers on exporters.

Certificate rotation:
- Roll the pod on certificate change via HashAnnotations. The config hash
  alone never changes on rotation because the config holds paths, not PEM,
  so a rotated CA would silently break fluent-bit ingest until something
  else restarted the collector.
- Watch tigera-ca-private, and reload the receiver's CA and keypair in place
  so rotation usually costs no restart.

Validation:
- Reject specs the collector cannot start from (no exporters, no data
  sources, duplicate exporter names) instead of rendering a config it
  rejects at boot and leaving status on Progressing with no reason.
- Degrade when the exporter CA or client keypair exists but is empty.
- Degrade rather than return silently when the Installation is unreadable.

Network policy:
- Pin egress to each exporter's destination and drop the blanket rule that
  allowed any host on the OTLP ports. In-cluster destinations match by
  Service, since Calico resolves Domains rules from observed DNS answers
  and those never match a ClusterIP reached via the cluster domain.
- Set Source on both ingress rules. The internal metrics port serves
  without TLS or authentication and was reachable by any pod.

Lifecycle:
- Remove the collector's resources when the feature is disabled, the
  license loses the feature, or the license expires. Nothing owns them, so
  they previously lingered, and the expiry message claimed forwarding had
  stopped while the collector kept exporting.
- Pin to a single replica. Each replica federates the same Prometheus
  targets under its own service.instance.id, duplicating every series.

Also register the receiver keypair's key usages, give the probes a boot
grace, derive the memory limiter from the container's effective memory
limit so overrides take effect, and use the current otlp_grpc/otlp_http
exporter type names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a ServiceMonitor for the collector so a silently failing export
pipeline is visible: queue depth, export failures and dropped records are
otherwise only in the pod's logs.

Gated on the collector actually being configured, and removed when it is
not. The ServiceMonitor selects a Service that only exists while the
collector does, so rendering it unconditionally leaves one behind matching
nothing once OpenTelemetry export is switched off. The monitor controller
reads LogCollector to decide, mirroring how LicenseExpired already gates
the other ServiceMonitors.

The collector's identity constants move to pkg/render so both packages can
share them: pkg/render/otelcollector imports pkg/render/monitor for the
Prometheus federation target, so monitor cannot import it back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e Collector

The field name was changed to OpenTelemetry but its comment still opened
with "configures the OpenTelemetry Collector", which is the framing the
review pushed back on: it implies the struct mirrors upstream Collector
configuration, which it does not. Describe it as configuring OpenTelemetry
export, which the operator happens to implement with a Collector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tianfeng92
tianfeng92 force-pushed the EV-6862-otel-collector branch from c612900 to fca9047 Compare August 6, 2026 22:12
@tianfeng92
tianfeng92 requested a review from pasanw August 6, 2026 23:02

@pasanw pasanw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing my comments! I let Claude lead the re-review and reviewed everything it said was addressed along with what it flagged - it helped flag some things that weren't fully addressed but it also flagged some new issues (sorry about that!)

I'll let you and @rene-dekker work through them together and decide the best way to proceed.

// ParseExternalDestination extracts the host and port from an endpoint, which may
// be a bare "host:port" or a URL. When a URL carries no explicit port the scheme's
// default is used. It reports false when no port can be determined.
func ParseExternalDestination(endpoint string) (ExternalDestination, bool) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: Glad this got pulled into a shared helper. Right now though only the OTel component uses it — Dex (dex.go, parseHostPortFromURL + resolveEgressRuleForDestination) and Guardian (inline in guardianCalicoSystemPolicy) still have their own copies of the same URL→host:port→egress-rule logic. The ask was one shared piece across all three, so it'd be good to move those two onto ParseExternalDestination too — otherwise the duplication's only half gone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair — the duplication is only half gone. I have dex and guardian converted onto ParseExternalDestination locally, but both have a much wider blast radius than otel and I'd rather not fold that into this PR's test surface. Splitting it into a follow-up so it can be reviewed on its own; this PR keeps the helper plus the one caller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shared piece exists and OTel is on it:

https://github.com/tigera/operator/blob/ba5aeb9d4/pkg/render/common/networkpolicy/networkpolicy.go#L346-L410

ParseExternalDestination handles bare host:port and URLs with scheme defaults; ExternalDestinationEntityRule picks the tightest rule available — a /32 or /128 for a literal IP, a Services match for an in-cluster <svc>.<ns>.svc name, a Domains rule for a hostname when the license carries egress-access-control, and port-only otherwise.

Moving Dex and Guardian across is going into its own PR rather than this one. Their conversion is already written (26 lines replacing 83), but they sit on the authentication and management-cluster tunnel paths, where a regression costs more than it does on log export, and each has behaviour the OTel path doesn't — Dex's 0.0.0.0/0 any-destination case, Guardian's HTTP proxy handling. Folding that in here would also widen an already large diff.

Tian is picking that up as a follow-up, so the duplication does get finished off, just not on this PR.

}
}
if c.cfg.ReceiverTLSSecret != nil {
annotations[c.cfg.ReceiverTLSSecret.HashAnnotationKey()] = c.cfg.ReceiverTLSSecret.HashAnnotationValue()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: This map hashes the config, the trusted bundle, and the receiver keypair (this line) — but not the exporter-side certs: the user-supplied ExporterCA (otel-collector-ca) and the client keypair (otel-collector-client-certs). And the exporter TLS block has no reload_interval. So it's the same rotation bug we just fixed on the receiver, reopened on the export side: if a user rotates the exporter CA or client cert, the controller re-copies the new PEM but the pod template is byte-identical, so the StatefulSet doesn't roll. otelcol only reads these files at startup, so it keeps presenting/trusting the stale cert and mTLS to the customer backend breaks until an unrelated restart. Can we fold the ExporterCA + client keypair hashes into this map too, same as the receiver keypair?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed. Both are folded into the same map now:
https://github.com/tigera/operator/blob/322d8b439/pkg/render/otelcollector/component.go#L591-L597

On reload_interval for the exporter block: I left it off deliberately. Unlike the receiver's client_ca_file_reload, these are user-managed Secrets/ConfigMaps with no rotation schedule we control, so the hash annotation is the mechanism rather than the backstop — a rotation changes the pod template and rolls the StatefulSet. Happy to add the poll too if you'd rather have both.

Comment thread pkg/render/logcollector/pipeline.go Outdated
}

// Tracks the collector's own lifecycle rather than the license's.
if mc.cfg.OpenTelemetryEnabled && !mc.cfg.LicenseExpired {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: This gate keeps the ServiceMonitor on OpenTelemetryEnabled && !LicenseExpired, but the collector is also torn down when the license just lacks the otel-collector feature (valid, not expired) — that case isn't mirrored here, so the ServiceMonitor sticks around selecting a Service that's gone, which is the dangling-monitor situation this gating was meant to avoid. Related: in monitor_controller.go:286, a transient LogCollector read error leaves openTelemetryEnabled=false, which drops into the delete branch and recreates on the next reconcile — so a brief apiserver blip flaps the monitor. Both come down to "enabled" needing to track the same conditions the otel controller uses to actually deploy the collector.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed, and chasing the second half turned up a third bug.

Feature gate mirrored, so "enabled" means licensed, not just configured:
https://github.com/tigera/operator/blob/322d8b439/pkg/controller/monitor/monitor_controller.go#L282-L295

The transient read now requeues instead of reading as "disabled":
https://github.com/tigera/operator/blob/322d8b439/pkg/controller/monitor/monitor_controller.go#L286-L292

The third one: the ServiceMonitor was rendering a target Prometheus was never allowed to reach. Nothing opened egress to the collector's metrics port, so the calico-system default-deny dropped it and the scrape just reported up=0 — no error naming the cause. Found it on a cluster; the ServiceMonitor looked correct and no otelcol_* metrics ever arrived. Now paired under the same gate:
https://github.com/tigera/operator/blob/322d8b439/pkg/render/monitor/monitor.go#L1509-L1517

Verified on a cluster after the fix: up{job="otel-collector"} went 0 -> 1 and the collector's own queue/throughput metrics started landing. There's a test asserting the ServiceMonitor and the egress rule appear and disappear together.

Comment thread pkg/controller/otelcollector/controller.go
Comment thread api/v1/opentelemetry_types.go Outdated
// Default: false
// +optional
// +kubebuilder:default=false
MutualTLS *bool `json:"mutualTLS,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude: Coming back to this after digging into the TLS side properly. The custom-CA handling is fine and matches the syslog convention, and the rendered config is correct/secure at the OTel level (verified against v0.157.0 — verification can't be turned off, and https verifies on both transports). But the mTLS client cert has a real limitation worth raising.

Right now there's one shared client cert (otel-collector-client-certs) for every exporter that turns mutualTLS on. So every backend we send to is shown the same client cert. That's fine for one backend, or if they all accept the same one — but this feature fans out to multiple external backends (Datadog, a corporate collector, etc.), and different backends usually want different client certs. With one shared cert you can't do that: one identity for all, take it or leave it.

tlsroute already solves this the flexible way — each destination points at its own cert (per-target SecretKeySelector). So this is the one spot where we diverge from the closest existing pattern.

The fix is small if we want it: leave the CA as-is (a shared bundle already trusts many backends, so nothing to change there), and swap the mutualTLS boolean for a per-exporter client-cert Secret name — "here's the Secret with this exporter's cert." The render already loops over the exporters one at a time and validates each, so it's extending that loop rather than new structure; the only new bit is mounting each exporter's Secret instead of one shared one. Bonus: it also lands us on the tlsroute convention.

Also (minor, same field): mutualTLS: true on an http:// endpoint is silently ignored — the cert block gets skipped because the endpoint is plaintext, with no warning, and you're still forced to supply the Secret. Worth at least a validation error so it isn't a silent no-op.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the limitation — one shared cert means one identity for every backend, and fanning out to several backends is exactly when that breaks down.

Deferring rather than doing it here: it changes the API shape (bool -> per-exporter Secret ref) and I'd rather land that with the tlsroute-style plumbing and its own tests.

One thing I did fix from this comment: mutualTLS: true on an http:// endpoint is no longer a silent no-op, it's a validation error:
https://github.com/tigera/operator/blob/322d8b439/pkg/controller/otelcollector/controller.go#L499-L501

Filing a follow-up for the per-exporter cert. Worth flagging it should land before GA — swapping the field afterwards is a breaking change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting my earlier reply on this thread: I said I was deferring the per-exporter client cert to a follow-up. That's no longer accurate — @rene-dekker asked for the same thing independently, so I've implemented it rather than deferring.

mutualTLS is gone. Each exporter now carries its own TLS and auth:

https://github.com/tigera/operator/blob/ba5aeb9d4/api/v1/opentelemetry_types.go#L92-L127

exporters:
  - name: datadog
    endpoint: https://otlp.datadoghq.com:4318
    protocol: http
    auth:
      headers:
        - name: DD-API-KEY
          valueFrom:
            secretKeyRef: { name: otlp-datadog-auth, key: api-key }
    tls:
      caConfigMapName: corp-ca
      clientCertSecretName: corp-client-mtls

Two notes on the implementation:

Header values are read from Secrets into environment variables and referenced as ${env:...}, so credentials never appear in the rendered ConfigMap. There's a test asserting the secret value is absent from it.

I used caConfigMapName rather than the caSecretName in Rene's sketch — a CA isn't secret, and syslog/splunk already carry user CAs as ConfigMaps. The client cert stays a Secret since it holds a private key. Happy to switch if you'd both rather it were a Secret for symmetry.

The silent no-op you flagged is now a validation error, covering the CA as well as the client cert:
https://github.com/tigera/operator/blob/ba5aeb9d4/api/v1/logcollector_types.go#L340-L350

Deployed and verified on a cluster — logs and metrics both still flowing. The per-exporter TLS and auth paths themselves are unit-tested but not yet exercised against a real TLS backend.

- Hash exporter CA and client TLS in pod annotations so cert rotation
  triggers a StatefulSet roll
- Gate OTel fluent-bit output on osType==Linux, matching other non-Linseed
  outputs
- Mirror IsFeatureActive check in monitor controller so ServiceMonitor is
  deleted when the license lacks the otel-collector feature
- Reject mutualTLS on http:// endpoints instead of silently ignoring
  the client cert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@caseydavenport caseydavenport self-assigned this Aug 10, 2026
The ServiceMonitor rendered a target the tigera-prometheus default-deny
blocked, so the scrape reported up=0 with nothing naming the cause. Add the
matching egress rule under the same gate.

Also track the collector's real deploy conditions: requeue on an unreadable
LogCollector rather than treating it as disabled, which flapped both
resources on an apiserver blip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread api/v1/opentelemetry_types.go Outdated
// Default: false
// +optional
// +kubebuilder:default=false
MutualTLS *bool `json:"mutualTLS,omitempty"`

@rene-dekker rene-dekker Aug 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Pasan's feedback in that I think that the TLS and Authn for exporters need more flexibility in their configuration. I think both authentication and transport may be set differently per sink and so each may need their own sub struct for configuration.

From what I understand there are a few ways you may want to configure them:

  • mTLS
  • TLS with headers (can be bearer token, or other headers like X-SF-Token, DD-API-KEY)
  • no TLS (leverage it with a gateway for example)

Perhaps something of this shape would work better:

  otelCollector:                                                                                                                                                                          
    exporters:                                                                                                                                                                            
      - name: datadog                                                                                                                                                                     
        endpoint: https://otlp.datadoghq.com:4318                                                                                                                                         
        protocol: http                                                                                                                                                                    
        auth:                                                                                                                                                                             
          headers:                                                                                                                                                                        
            - name: DD-API-KEY                                                                                                                                                            
              valueFrom:                                                                                                                                                                  
                secretKeyRef: { name: otlp-datadog-auth, key: api-key }                                                                                                                                                                                                                   
        tls:                                                                                                                                                                              
          caSecretName: corp-ca
          clientCertSecretName: corp-client-mtls # when set, implies tls.

Alternatively, make a transport sub struct and include both tls settings and protocol there.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another note is that Boolean fields in the API spec are not idiomatic; we should refrain from using them.

Comment thread api/v1/opentelemetry_types.go Outdated
// Default: Disabled
// +optional
// +kubebuilder:default=Disabled
Enabled *OpenTelemetryMetricsEnabled `json:"enabled,omitempty"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could end up with Enabled: Disabled, I think that reads a bit weird, maybe look at other instances in our repo for these kinds of flags, I think we use other field names, like state | status | mode.

tianfeng92 and others added 2 commits August 10, 2026 17:07
Trusted bundle: the collector rendered the shared calico-system/tigera-ca-bundle
with a CA-only set, which the Installation controller owns and fills with a
different certificate set. The component handler replaces ConfigMap data
wholesale, so the two overwrote each other every reconcile. Carry our own named
bundle, as fluent-bit already does, and add the Prometheus serving certificate
so the federation scrape verifies a BYO cert. System roots stay out: this bundle
backs the receiver's client_ca_file.

Gating: fluent-bit shipped to the collector whenever spec.openTelemetry was set,
even when the license or an invalid spec meant it was never deployed. All three
controllers now share OpenTelemetrySpec.Deployable, and the monitor controller
watches LogCollector so enabling export actually wakes it.

Egress: an endpoint with no parseable port emitted no rule at all, so the
default-deny silently dropped every export. Fall back to the protocol's default
OTLP port.

Non-cluster-host flow and DNS records carry their own tags and were never
matched, so they reached Linseed but never the OTLP backend.

Teardown probed only the StatefulSet, stranding the RBAC, ConfigMap, Service and
policy if it had already gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One shared client certificate meant one identity for every backend, and a
single CA for all of them. This feature fans out to several external backends
that generally want different client certs and different trust anchors, so
both move onto the exporter.

Each exporter now takes its own tls.caConfigMapName and tls.clientCertSecretName,
plus auth.headers for backends that authenticate with a token or a
vendor-specific header instead. Header values are read from Secrets and reach
the collector as environment variables, so credentials never land in the
rendered ConfigMap.

The operator gathers the user's per-exporter material into one object per kind,
keyed by exporter. The teardown cannot enumerate the spec once the feature is
off, so the names it has to delete stay fixed.

Drops the mutualTLS boolean, which is not the repo idiom for a toggle, and
renames metrics.enabled to metrics.state so the value no longer reads
"enabled: Disabled".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants