From 3c0833d3b32d6ad2750e25979b4594b01e51aab6 Mon Sep 17 00:00:00 2001 From: Praveen K B Date: Sun, 2 Aug 2026 12:45:26 +0530 Subject: [PATCH] feat(metrics): add opt-in Traefik scraping --- README.md | 13 ++++ api/v1alpha1/parseableconfig_types.go | 29 +++++++- api/v1alpha1/zz_generated.deepcopy.go | 28 ++++++++ ...bility.parseable.com_parseableconfigs.yaml | 46 +++++++++++++ .../controller/parseableconfig_controller.go | 34 +++++++++- internal/controller/traefik_metrics_test.go | 68 +++++++++++++++++++ 6 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 internal/controller/traefik_metrics_test.go diff --git a/README.md b/README.md index 412c648..eb4d0f9 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,21 @@ Once the `ParseableConfig` CR is applied, PAI automatically creates the followin ### Metrics - **Pod metrics**: Container CPU, memory, network via `kubeletstats` and `k8s_cluster` receivers - **Node metrics**: Node-level CPU, memory, disk, network via `kubeletstats` receiver +- **Traefik metrics**: Optional built-in Prometheus scrape of Traefik pods on `:9100/metrics` - Namespace filtering via `namespaceSelector` +Enable Traefik metrics explicitly: + +```yaml +spec: + metrics: + traefik: + enabled: true + targetDataset: traefik-metrics +``` + +PAI selects pods labeled `app.kubernetes.io/name=traefik`. The scrape is disabled by default. Use `port`, `uri`, or `namespaceSelector` under `traefik` when your deployment differs from the defaults. + ### Events - Kubernetes events collected via `k8sobjects` receiver in watch mode - Namespace filtering via `namespaceSelector` diff --git a/api/v1alpha1/parseableconfig_types.go b/api/v1alpha1/parseableconfig_types.go index f9aa624..1f00f8c 100644 --- a/api/v1alpha1/parseableconfig_types.go +++ b/api/v1alpha1/parseableconfig_types.go @@ -230,12 +230,39 @@ type ScrapeConfig struct { PodSelector map[string]string `json:"podSelector,omitempty"` } +// TraefikMetricsConfig configures the built-in Traefik Prometheus scrape. +// PAI discovers pods with app.kubernetes.io/name=traefik and scrapes +// /metrics on port 9100. +type TraefikMetricsConfig struct { + // Enabled controls whether Traefik metrics are collected. + Enabled bool `json:"enabled"` + + // TargetDataset is the Parseable dataset for Traefik metrics. + TargetDataset string `json:"targetDataset,omitempty"` + + // Headers are additional HTTP headers for the exporter. Overrides global headers with the same key. + Headers map[string]string `json:"headers,omitempty"` + + // NamespaceSelector limits Traefik pod discovery to matching namespaces. + NamespaceSelector NamespaceSelector `json:"namespaceSelector,omitempty"` + + // Port is the Traefik metrics port. Defaults to 9100 when omitted. + Port int32 `json:"port,omitempty"` + + // URI is the Traefik metrics path. Defaults to /metrics when omitted. + URI string `json:"uri,omitempty"` +} + // MetricsConfig defines metrics configuration. ClusterMetrics enables built-in -// kubelet/cluster metrics; ScrapeConfigs adds Prometheus-style scrape pipelines. +// kubelet/cluster metrics; Traefik enables built-in Traefik discovery; +// ScrapeConfigs adds custom Prometheus-style scrape pipelines. type MetricsConfig struct { // ClusterMetrics toggles built-in node/pod/cluster metrics via kubeletstats + k8s_cluster receivers ClusterMetrics *ClusterMetricsConfig `json:"clusterMetrics,omitempty"` + // Traefik enables the built-in Traefik Prometheus scrape. + Traefik *TraefikMetricsConfig `json:"traefik,omitempty"` + // ScrapeConfigs is a list of Prometheus-style scrape pipelines ScrapeConfigs []ScrapeConfig `json:"scrapeConfigs,omitempty"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 6979c9a..b783161 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -223,6 +223,11 @@ func (in *MetricsConfig) DeepCopyInto(out *MetricsConfig) { *out = new(ClusterMetricsConfig) (*in).DeepCopyInto(*out) } + if in.Traefik != nil { + in, out := &in.Traefik, &out.Traefik + *out = new(TraefikMetricsConfig) + (*in).DeepCopyInto(*out) + } if in.ScrapeConfigs != nil { in, out := &in.ScrapeConfigs, &out.ScrapeConfigs *out = make([]ScrapeConfig, len(*in)) @@ -506,6 +511,29 @@ func (in *TracesConfig) DeepCopy() *TracesConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TraefikMetricsConfig) DeepCopyInto(out *TraefikMetricsConfig) { + *out = *in + if in.Headers != nil { + in, out := &in.Headers, &out.Headers + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + in.NamespaceSelector.DeepCopyInto(&out.NamespaceSelector) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TraefikMetricsConfig. +func (in *TraefikMetricsConfig) DeepCopy() *TraefikMetricsConfig { + if in == nil { + return nil + } + out := new(TraefikMetricsConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkloadInstrumentationStatus) DeepCopyInto(out *WorkloadInstrumentationStatus) { *out = *in diff --git a/config/crd/bases/observability.parseable.com_parseableconfigs.yaml b/config/crd/bases/observability.parseable.com_parseableconfigs.yaml index 787a81d..5cdbac4 100644 --- a/config/crd/bases/observability.parseable.com_parseableconfigs.yaml +++ b/config/crd/bases/observability.parseable.com_parseableconfigs.yaml @@ -306,6 +306,52 @@ spec: - uri type: object type: array + traefik: + description: Traefik enables the built-in Traefik Prometheus scrape. + properties: + enabled: + description: Enabled controls whether Traefik metrics are + collected. + type: boolean + headers: + additionalProperties: + type: string + description: Headers are additional HTTP headers for the exporter. + Overrides global headers with the same key. + type: object + namespaceSelector: + description: NamespaceSelector limits Traefik pod discovery + to matching namespaces. + properties: + mode: + description: Mode specifies whether to include or exclude + the listed namespaces + enum: + - include + - exclude + type: string + namespaces: + description: Namespaces is the list of namespace names + items: + type: string + type: array + type: object + port: + description: Port is the Traefik metrics port. Defaults to + 9100 when omitted. + format: int32 + type: integer + targetDataset: + description: TargetDataset is the Parseable dataset for Traefik + metrics. + type: string + uri: + description: URI is the Traefik metrics path. Defaults to + /metrics when omitted. + type: string + required: + - enabled + type: object type: object paused: description: |- diff --git a/internal/controller/parseableconfig_controller.go b/internal/controller/parseableconfig_controller.go index f3e9de9..69a723c 100644 --- a/internal/controller/parseableconfig_controller.go +++ b/internal/controller/parseableconfig_controller.go @@ -866,7 +866,7 @@ func (r *ParseableConfigReconciler) ensureMetricsEventsCollector(ctx context.Con if anyClusterMetricEnabled(config.Spec.Metrics.ClusterMetrics) { metricsEnabled = true } - for _, sc := range config.Spec.Metrics.ScrapeConfigs { + for _, sc := range effectiveScrapeConfigs(config.Spec.Metrics) { if sc.Name != "" && sc.TargetDataset != "" && sc.Port > 0 { metricsEnabled = true break @@ -1045,7 +1045,7 @@ func (r *ParseableConfigReconciler) buildMetricsEventsCollectorConfig( // Per-scrape-entry Prometheus pipelines with Kubernetes pod service discovery. if config.Spec.Metrics != nil { - for _, sc := range config.Spec.Metrics.ScrapeConfigs { + for _, sc := range effectiveScrapeConfigs(config.Spec.Metrics) { id := sanitizeName(sc.Name) if id == "" || sc.TargetDataset == "" || sc.Port <= 0 { continue @@ -1223,6 +1223,36 @@ func sanitizePromLabel(s string) string { return b.String() } +// effectiveScrapeConfigs expands opt-in built-in integrations into the same +// generic scrape representation used by custom scrapeConfigs. +func effectiveScrapeConfigs(metrics *observabilityv1alpha1.MetricsConfig) []observabilityv1alpha1.ScrapeConfig { + if metrics == nil { + return nil + } + + configs := make([]observabilityv1alpha1.ScrapeConfig, 0, len(metrics.ScrapeConfigs)+1) + if traefik := metrics.Traefik; traefik != nil && traefik.Enabled && traefik.TargetDataset != "" { + port := traefik.Port + if port <= 0 { + port = 9100 + } + uri := traefik.URI + if uri == "" { + uri = "/metrics" + } + configs = append(configs, observabilityv1alpha1.ScrapeConfig{ + Name: "traefik", + URI: uri, + Port: port, + TargetDataset: traefik.TargetDataset, + Headers: traefik.Headers, + NamespaceSelector: traefik.NamespaceSelector, + PodSelector: map[string]string{"app.kubernetes.io/name": "traefik"}, + }) + } + return append(configs, metrics.ScrapeConfigs...) +} + // anyClusterMetricEnabled reports whether at least one built-in cluster-metrics // receiver is enabled with a target dataset to ship to. func anyClusterMetricEnabled(cm *observabilityv1alpha1.ClusterMetricsConfig) bool { diff --git a/internal/controller/traefik_metrics_test.go b/internal/controller/traefik_metrics_test.go new file mode 100644 index 0000000..7c2ba58 --- /dev/null +++ b/internal/controller/traefik_metrics_test.go @@ -0,0 +1,68 @@ +package controller + +import ( + "testing" + + observabilityv1alpha1 "github.com/parseable/pai/api/v1alpha1" +) + +func TestEffectiveScrapeConfigsTraefikDisabledByDefault(t *testing.T) { + configs := effectiveScrapeConfigs(&observabilityv1alpha1.MetricsConfig{}) + if len(configs) != 0 { + t.Fatalf("expected no scrape configs, got %d", len(configs)) + } +} + +func TestEffectiveScrapeConfigsTraefikDefaults(t *testing.T) { + configs := effectiveScrapeConfigs(&observabilityv1alpha1.MetricsConfig{ + Traefik: &observabilityv1alpha1.TraefikMetricsConfig{ + Enabled: true, + TargetDataset: "traefik-metrics", + }, + }) + + if len(configs) != 1 { + t.Fatalf("expected one scrape config, got %d", len(configs)) + } + got := configs[0] + if got.Name != "traefik" || got.Port != 9100 || got.URI != "/metrics" { + t.Fatalf("unexpected Traefik defaults: %#v", got) + } + if got.TargetDataset != "traefik-metrics" { + t.Fatalf("unexpected dataset: %q", got.TargetDataset) + } + if got.PodSelector["app.kubernetes.io/name"] != "traefik" { + t.Fatalf("unexpected pod selector: %#v", got.PodSelector) + } +} + +func TestEffectiveScrapeConfigsTraefikOverrides(t *testing.T) { + metrics := &observabilityv1alpha1.MetricsConfig{ + Traefik: &observabilityv1alpha1.TraefikMetricsConfig{ + Enabled: true, + TargetDataset: "edge-metrics", + Port: 9200, + URI: "custom-metrics", + Headers: map[string]string{"X-P-Team": "platform"}, + NamespaceSelector: observabilityv1alpha1.NamespaceSelector{ + Mode: "include", + Namespaces: []string{"edge"}, + }, + }, + ScrapeConfigs: []observabilityv1alpha1.ScrapeConfig{{ + Name: "custom", Port: 8080, URI: "/metrics", TargetDataset: "custom-metrics", + }}, + } + + configs := effectiveScrapeConfigs(metrics) + if len(configs) != 2 { + t.Fatalf("expected built-in and custom scrape configs, got %d", len(configs)) + } + got := configs[0] + if got.Port != 9200 || got.URI != "custom-metrics" || got.TargetDataset != "edge-metrics" { + t.Fatalf("overrides not preserved: %#v", got) + } + if got.Headers["X-P-Team"] != "platform" || len(got.NamespaceSelector.Namespaces) != 1 { + t.Fatalf("headers or namespace selector not preserved: %#v", got) + } +}