Skip to content

Commit 46ea73b

Browse files
committed
feat(target allocator): bridge Prometheus-only metrics into OTLP, add resource, shutdown, validation
- Export self-telemetry over OTLP with the same metric set as /metrics by bridging the default Prometheus registry (SD internals, Go/process collectors) into the OTLP reader via the Prometheus bridge; SDK metrics use a dedicated registry to avoid double counting, and /metrics serves the union. - Attach a resource with a default service.name (overridable via OTEL_* env). - Shut down the meter provider on exit to flush the final export. - Expand ${env:VAR} in OTLP header/endpoint values so secrets (API tokens) can be sourced from a Secret via pod env instead of being written into the ConfigMap. - Accept both a full URL and host:port endpoint for gRPC (consistent with HTTP). - Validate telemetry config in the TA binary for standalone use. - Unit + end-to-end export tests, config-file README docs, changelog entry.
1 parent 20275b9 commit 46ea73b

9 files changed

Lines changed: 393 additions & 12 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
2+
change_type: enhancement
3+
4+
# The name of the component, or a single word describing the area of concern, (e.g. collector, target allocator, auto-instrumentation, opamp, github action)
5+
component: target allocator
6+
7+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
8+
note: "Support exporting the TargetAllocator's self-telemetry metrics via OTLP, in addition to the Prometheus /metrics endpoint."
9+
10+
# One or more tracking issues related to the change
11+
issues: [5047]
12+
13+
# (Optional) One or more lines of additional information to render under the primary note.
14+
# These lines will be padded with 2 spaces and then inserted directly into the document.
15+
# Use pipe (|) for multiline entries.
16+
subtext: |
17+
Configure it under `spec.targetAllocator.telemetry.metrics.otlp` (OpenTelemetryCollector CR) or
18+
`spec.telemetry.metrics.otlp` (TargetAllocator CR), with endpoint, protocol (grpc/http), temporality,
19+
headers, TLS and export interval/timeout. Metrics registered directly on the Prometheus registry
20+
(Prometheus service discovery, Go runtime and process collectors) are bridged into the OTLP export
21+
so the Prometheus endpoint and OTLP expose the same metric set.

cmd/otel-allocator/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,40 @@ The Target Allocator uses a configuration file (by default under `/conf/targetal
2929
| `https` | Whether to expose the target allocator endpoint over https | | |
3030
| `allow_insecure_auth_secrets` | Serve auth secret values over plain HTTP without mTLS | `false` | `ALLOW_INSECURE_AUTH_SECRETS` |
3131
| `collector_not_ready_grace_period` | Wait time before assigning jobs to a new collector. | 30s | |
32+
| `telemetry` | Self-telemetry configuration, including optional OTLP metric export (see below)| | |
3233

3334
Additional configuration options are present under [./internal/config/config.go](./internal/config/config.go).
3435

36+
### Self-telemetry
37+
38+
The Target Allocator always exposes its own metrics on the Prometheus `/metrics` endpoint. It can
39+
additionally push them over OTLP by configuring `telemetry.metrics.otlp`. When set, an OTLP exporter
40+
is added alongside the Prometheus endpoint; the metrics registered directly on the Prometheus registry
41+
(Prometheus service discovery internals, Go runtime and process collectors) are bridged into the OTLP
42+
export so both surfaces expose the same metric set. The `service.name` resource attribute defaults to
43+
`target-allocator` and can be overridden with the standard `OTEL_SERVICE_NAME` /
44+
`OTEL_RESOURCE_ATTRIBUTES` environment variables.
45+
46+
```yaml
47+
telemetry:
48+
metrics:
49+
otlp:
50+
endpoint: https://example.com:4318 # base URL (HTTP, /v1/metrics appended) or host:port (gRPC)
51+
protocol: http # "grpc" (default) or "http"
52+
temporality: delta # "cumulative" (default), "delta", or "lowmemory"
53+
export_interval: 30s # default 60s
54+
timeout: 15s # default 10s
55+
insecure: false # disable TLS (local dev)
56+
headers:
57+
Authorization: "Api-Token ${env:DT_API_TOKEN}"
58+
```
59+
60+
Header values support `${env:VAR}` references, which are expanded from the Target Allocator's
61+
process environment at runtime. Use this to keep secrets (such as an API token) out of the
62+
ConfigMap: reference an environment variable here and source that variable from a Secret on the
63+
Target Allocator pod. The `endpoint` field supports the same expansion. Both the `grpc` and `http`
64+
protocols accept either a full URL (with scheme) or a bare `host:port` endpoint.
65+
3566
## Even Distribution of Prometheus Targets
3667

3768
The Target Allocator’s first job is to discover targets to scrape and OTel Collectors to allocate targets to. Then it can distribute the targets it discovers among the Collectors. The Collectors in turn query the Target Allocator for Metrics endpoints to scrape, and then the Collectors’ [Prometheus Receivers](https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/receiver/prometheusreceiver/README.md) scrape the Metrics targets.

cmd/otel-allocator/internal/config/config.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,30 @@ func ValidateConfig(config *Config) error {
475475
if len(config.PrometheusCR.AllowNamespaces) != 0 && len(config.PrometheusCR.DenyNamespaces) != 0 {
476476
return errors.New("only one of allowNamespaces or denyNamespaces can be set")
477477
}
478+
return validateTelemetry(config.Telemetry)
479+
}
480+
481+
// validateTelemetry validates the self-telemetry configuration. The operator sets these
482+
// values from a validated CRD, but the Target Allocator can also be run standalone with a
483+
// config file, so we validate here as well for a clear error instead of a runtime failure.
484+
func validateTelemetry(t TelemetryConfig) error {
485+
otlp := t.Metrics.OTLP
486+
if otlp == nil {
487+
return nil
488+
}
489+
if otlp.Endpoint == "" {
490+
return errors.New("telemetry.metrics.otlp.endpoint must be set")
491+
}
492+
switch otlp.Protocol {
493+
case "", "grpc", "http":
494+
default:
495+
return fmt.Errorf("telemetry.metrics.otlp.protocol must be 'grpc' or 'http', got %q", otlp.Protocol)
496+
}
497+
switch otlp.Temporality {
498+
case "", "cumulative", "delta", "lowmemory":
499+
default:
500+
return fmt.Errorf("telemetry.metrics.otlp.temporality must be 'cumulative', 'delta', or 'lowmemory', got %q", otlp.Temporality)
501+
}
478502
return nil
479503
}
480504

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package config
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestValidateTelemetry(t *testing.T) {
13+
otlp := func(o OTLPExporterConfig) TelemetryConfig {
14+
return TelemetryConfig{Metrics: MetricsConfig{OTLP: &o}}
15+
}
16+
17+
tests := []struct {
18+
name string
19+
cfg TelemetryConfig
20+
wantErr bool
21+
}{
22+
{name: "empty is valid", cfg: TelemetryConfig{}},
23+
{name: "valid grpc delta", cfg: otlp(OTLPExporterConfig{Protocol: "grpc", Endpoint: "example.com:4317", Temporality: "delta"})},
24+
{name: "valid http defaults", cfg: otlp(OTLPExporterConfig{Endpoint: "http://gw:4318"})},
25+
{name: "missing endpoint", cfg: otlp(OTLPExporterConfig{Protocol: "grpc"}), wantErr: true},
26+
{name: "bad protocol", cfg: otlp(OTLPExporterConfig{Protocol: "thrift", Endpoint: "gw:4317"}), wantErr: true},
27+
{name: "bad temporality", cfg: otlp(OTLPExporterConfig{Endpoint: "gw:4317", Temporality: "gauge"}), wantErr: true},
28+
}
29+
for _, tt := range tests {
30+
t.Run(tt.name, func(t *testing.T) {
31+
err := validateTelemetry(tt.cfg)
32+
if tt.wantErr {
33+
assert.Error(t, err)
34+
} else {
35+
assert.NoError(t, err)
36+
}
37+
})
38+
}
39+
}

cmd/otel-allocator/internal/server/server.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/gin-gonic/gin"
2222
"github.com/go-logr/logr"
2323
"github.com/goccy/go-json"
24+
"github.com/prometheus/client_golang/prometheus"
2425
"github.com/prometheus/client_golang/prometheus/promhttp"
2526
promcommconfig "github.com/prometheus/common/config"
2627
promconfig "github.com/prometheus/prometheus/config"
@@ -62,10 +63,22 @@ type Server struct {
6263
ScrapeConfigMarshalledSecretResponse []byte
6364
httpDuration metric.Float64Histogram
6465
allowInsecureAuthSecrets bool
66+
// metricsGatherer, when set, is used to serve the /metrics endpoint. Defaults to
67+
// the global Prometheus default gatherer.
68+
metricsGatherer prometheus.Gatherer
6569
}
6670

6771
type Option func(*Server)
6872

73+
// WithMetricsGatherer sets the Prometheus gatherer used to serve /metrics. This allows
74+
// the caller to combine multiple registries (e.g. the default registry plus a dedicated
75+
// OTel SDK registry) into a single endpoint.
76+
func WithMetricsGatherer(gatherer prometheus.Gatherer) Option {
77+
return func(s *Server) {
78+
s.metricsGatherer = gatherer
79+
}
80+
}
81+
6982
// Option to create an additional https server with mTLS configuration.
7083
// Used for getting the scrape config with real secret values.
7184
func WithTLSConfig(tlsConfig *tls.Config, httpsListenAddr string) Option {
@@ -83,6 +96,15 @@ func WithInsecureAuthSecrets() Option {
8396
}
8497
}
8598

99+
// metricsHandler returns the HTTP handler serving /metrics. It uses the configured
100+
// gatherer if one was provided, otherwise the global Prometheus default.
101+
func (s *Server) metricsHandler() http.Handler {
102+
if s.metricsGatherer != nil {
103+
return promhttp.HandlerFor(s.metricsGatherer, promhttp.HandlerOpts{})
104+
}
105+
return promhttp.Handler()
106+
}
107+
86108
func (s *Server) setRouter(router *gin.Engine) {
87109
router.Use(gin.Recovery())
88110
router.UseRawPath = true
@@ -100,7 +122,11 @@ func (s *Server) setRouter(router *gin.Engine) {
100122
router.GET("/scrape_configs", s.ScrapeConfigsHandler)
101123
router.GET("/jobs", s.JobsHandler)
102124
router.GET("/jobs/:job_id/targets", s.TargetsHandler)
103-
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
125+
// The handler is resolved per request so that the gatherer configured via
126+
// WithMetricsGatherer (applied after the router is built) is honored.
127+
router.GET("/metrics", func(c *gin.Context) {
128+
s.metricsHandler().ServeHTTP(c.Writer, c.Request)
129+
})
104130
router.GET("/livez", s.LivenessProbeHandler)
105131
router.GET("/readyz", s.ReadinessProbeHandler)
106132
registerPprof(router.Group("/debug/pprof/"))

cmd/otel-allocator/main.go

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,24 @@ import (
99
"fmt"
1010
"os"
1111
"os/signal"
12+
"regexp"
1213
"strings"
1314
"syscall"
1415

1516
"github.com/oklog/run"
1617
monitoringclient "github.com/prometheus-operator/prometheus-operator/pkg/client/versioned"
1718
"github.com/prometheus/client_golang/prometheus"
1819
"github.com/prometheus/prometheus/discovery"
20+
prometheusbridge "go.opentelemetry.io/contrib/bridges/prometheus"
1921
"go.opentelemetry.io/otel"
2022
"go.opentelemetry.io/otel/attribute"
21-
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
2223
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
2324
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
25+
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
2426
"go.opentelemetry.io/otel/metric"
2527
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
28+
"go.opentelemetry.io/otel/sdk/resource"
29+
semconv "go.opentelemetry.io/otel/semconv/v1.27.0"
2630
"k8s.io/client-go/kubernetes"
2731
_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
2832
ctrl "sigs.k8s.io/controller-runtime"
@@ -84,7 +88,14 @@ func main() {
8488
os.Exit(1)
8589
}
8690

87-
metricExporter, promErr := otelprom.New()
91+
// The OTel SDK metrics are exported to Prometheus through a dedicated registry
92+
// rather than the default one. This keeps them separate from the metrics that are
93+
// registered directly on the default Prometheus registry (Prometheus service
94+
// discovery internals, Go runtime and process collectors). That separation lets the
95+
// OTLP reader below pull those Prometheus-only metrics via the Prometheus bridge
96+
// without double-counting the SDK metrics, which it already collects natively.
97+
sdkRegistry := prometheus.NewRegistry()
98+
metricExporter, promErr := otelprom.New(otelprom.WithRegisterer(sdkRegistry))
8899
if promErr != nil {
89100
panic(promErr)
90101
}
@@ -96,11 +107,23 @@ func main() {
96107
setupLog.Error(otlpErr, "Failed to create OTLP metric reader")
97108
os.Exit(1)
98109
}
99-
meterProviderOpts = append(meterProviderOpts, sdkmetric.WithReader(otlpReader))
110+
telemetryResource, resErr := telemetryResource(ctx)
111+
if resErr != nil {
112+
setupLog.Error(resErr, "Failed to build telemetry resource")
113+
os.Exit(1)
114+
}
115+
meterProviderOpts = append(meterProviderOpts, sdkmetric.WithReader(otlpReader), sdkmetric.WithResource(telemetryResource))
100116
}
101117

102118
meterProvider := sdkmetric.NewMeterProvider(meterProviderOpts...)
103119
otel.SetMeterProvider(meterProvider)
120+
defer func() {
121+
// Flush and close exporters (notably the OTLP reader) on shutdown so the final
122+
// batch of metrics is delivered.
123+
if shutdownErr := meterProvider.Shutdown(context.Background()); shutdownErr != nil {
124+
setupLog.Error(shutdownErr, "Error shutting down meter provider")
125+
}
126+
}()
104127

105128
allocatorPrehook = prehook.New(cfg.FilterStrategy, log)
106129
allocator, allocErr := allocation.New(cfg.AllocationStrategy, log, allocation.WithFilter(allocatorPrehook), allocation.WithFallbackStrategy(cfg.AllocationFallbackStrategy))
@@ -123,6 +146,10 @@ func main() {
123146
if cfg.AllowInsecureAuthSecrets {
124147
httpOptions = append(httpOptions, server.WithInsecureAuthSecrets())
125148
}
149+
// Serve /metrics from both the default registry (Prometheus service discovery, Go
150+
// runtime and process collectors) and the dedicated SDK registry (OTel SDK metrics),
151+
// preserving the full metric set that used to be exposed via the default registry alone.
152+
httpOptions = append(httpOptions, server.WithMetricsGatherer(prometheus.Gatherers{prometheus.DefaultGatherer, sdkRegistry}))
126153
srv, serverErr := server.NewServer(log, allocator, cfg.ListenAddr, httpOptions...)
127154
if serverErr != nil {
128155
panic(serverErr)
@@ -316,18 +343,48 @@ func main() {
316343
setupLog.Info("Target allocator exited.")
317344
}
318345

346+
// envRefPattern matches ${env:VAR} references, mirroring the OTel Collector's
347+
// environment-variable substitution syntax.
348+
var envRefPattern = regexp.MustCompile(`\$\{env:([a-zA-Z_][a-zA-Z0-9_]*)\}`)
349+
350+
// expandEnvRefs replaces ${env:VAR} occurrences in s with the value of the
351+
// environment variable VAR (empty if unset). This lets sensitive header values
352+
// (e.g. an API token) be sourced from the pod environment / a Secret at runtime
353+
// rather than being written in plaintext into the Target Allocator ConfigMap.
354+
func expandEnvRefs(s string) string {
355+
return envRefPattern.ReplaceAllStringFunc(s, func(match string) string {
356+
name := envRefPattern.FindStringSubmatch(match)[1]
357+
return os.Getenv(name)
358+
})
359+
}
360+
361+
// expandHeaders returns a copy of headers with ${env:VAR} references expanded in
362+
// every value.
363+
func expandHeaders(headers map[string]string) map[string]string {
364+
if len(headers) == 0 {
365+
return nil
366+
}
367+
expanded := make(map[string]string, len(headers))
368+
for k, v := range headers {
369+
expanded[k] = expandEnvRefs(v)
370+
}
371+
return expanded
372+
}
373+
319374
// newOTLPMetricReader creates a PeriodicExportingMetricReader backed by either
320375
// an OTLP/gRPC or OTLP/HTTP exporter depending on cfg.Protocol.
321376
func newOTLPMetricReader(ctx context.Context, cfg *config.OTLPExporterConfig) (sdkmetric.Reader, error) {
322377
temporality := temporalitySelector(cfg.Temporality)
323378
readerOpts := periodicReaderOptions(cfg)
379+
endpoint := expandEnvRefs(cfg.Endpoint)
380+
headers := expandHeaders(cfg.Headers)
324381
switch cfg.Protocol {
325382
case "http":
326383
// WithEndpointURL does not append /v1/metrics automatically. We do it here
327384
// so the endpoint convention matches the OTel collector's otlphttp exporter
328385
// (where users specify the base URL, not the full signal path).
329386
// Skip if the caller already included the signal path.
330-
endpoint := strings.TrimRight(cfg.Endpoint, "/")
387+
endpoint = strings.TrimRight(endpoint, "/")
331388
if !strings.HasSuffix(endpoint, "/v1/metrics") {
332389
endpoint += "/v1/metrics"
333390
}
@@ -338,8 +395,8 @@ func newOTLPMetricReader(ctx context.Context, cfg *config.OTLPExporterConfig) (s
338395
if cfg.Insecure {
339396
opts = append(opts, otlpmetrichttp.WithInsecure())
340397
}
341-
if len(cfg.Headers) > 0 {
342-
opts = append(opts, otlpmetrichttp.WithHeaders(cfg.Headers))
398+
if len(headers) > 0 {
399+
opts = append(opts, otlpmetrichttp.WithHeaders(headers))
343400
}
344401
exp, err := otlpmetrichttp.New(ctx, opts...)
345402
if err != nil {
@@ -348,14 +405,21 @@ func newOTLPMetricReader(ctx context.Context, cfg *config.OTLPExporterConfig) (s
348405
return sdkmetric.NewPeriodicReader(exp, readerOpts...), nil
349406
default: // "grpc"
350407
opts := []otlpmetricgrpc.Option{
351-
otlpmetricgrpc.WithEndpoint(cfg.Endpoint),
352408
otlpmetricgrpc.WithTemporalitySelector(temporality),
353409
}
410+
// Accept both a full URL (with scheme, consistent with the HTTP protocol and
411+
// the collector's otlp exporter) and a bare host:port. WithEndpointURL requires
412+
// a scheme; WithEndpoint expects host:port.
413+
if strings.Contains(endpoint, "://") {
414+
opts = append(opts, otlpmetricgrpc.WithEndpointURL(endpoint))
415+
} else {
416+
opts = append(opts, otlpmetricgrpc.WithEndpoint(endpoint))
417+
}
354418
if cfg.Insecure {
355419
opts = append(opts, otlpmetricgrpc.WithInsecure())
356420
}
357-
if len(cfg.Headers) > 0 {
358-
opts = append(opts, otlpmetricgrpc.WithHeaders(cfg.Headers))
421+
if len(headers) > 0 {
422+
opts = append(opts, otlpmetricgrpc.WithHeaders(headers))
359423
}
360424
exp, err := otlpmetricgrpc.New(ctx, opts...)
361425
if err != nil {
@@ -367,7 +431,15 @@ func newOTLPMetricReader(ctx context.Context, cfg *config.OTLPExporterConfig) (s
367431

368432
// periodicReaderOptions builds PeriodicReaderOptions from the export interval and timeout config.
369433
func periodicReaderOptions(cfg *config.OTLPExporterConfig) []sdkmetric.PeriodicReaderOption {
370-
var opts []sdkmetric.PeriodicReaderOption
434+
opts := []sdkmetric.PeriodicReaderOption{
435+
// Bridge the metrics registered directly on the default Prometheus registry
436+
// (Prometheus service discovery internals, Go runtime and process collectors)
437+
// into the OTLP export. Without this, those metrics would be visible on the
438+
// Prometheus /metrics endpoint but missing from OTLP. The SDK's own metrics live
439+
// on a separate registry and are collected natively by this reader, so bridging
440+
// the default registry does not double-count them.
441+
sdkmetric.WithProducer(prometheusbridge.NewMetricProducer()),
442+
}
371443
if cfg.ExportInterval > 0 {
372444
opts = append(opts, sdkmetric.WithInterval(cfg.ExportInterval))
373445
}
@@ -377,6 +449,19 @@ func periodicReaderOptions(cfg *config.OTLPExporterConfig) []sdkmetric.PeriodicR
377449
return opts
378450
}
379451

452+
// telemetryResource builds the OpenTelemetry resource attached to exported metrics.
453+
// It sets a default service.name and honors the standard OTEL_SERVICE_NAME /
454+
// OTEL_RESOURCE_ATTRIBUTES environment variables, so additional attributes (e.g. k8s.*)
455+
// can be injected without code changes.
456+
func telemetryResource(ctx context.Context) (*resource.Resource, error) {
457+
return resource.New(ctx,
458+
resource.WithTelemetrySDK(),
459+
resource.WithAttributes(semconv.ServiceName("target-allocator")),
460+
// Applied last so environment configuration wins on conflict.
461+
resource.WithFromEnv(),
462+
)
463+
}
464+
380465
// temporalitySelector maps a config string to an SDK TemporalitySelector.
381466
// "delta" → all instruments delta; "lowmemory" → delta for counters/histograms,
382467
// cumulative for gauges; anything else → cumulative (SDK default).

0 commit comments

Comments
 (0)