Task · TSO-0066

Per-tailnet cardinality limit overrides

Description

cardinality.metric_limit and the warning/critical thresholds are process-global; in MSP mode one noisy tailnet forces raising the ceiling for all. Design decided (owner, 2026-08-30): optional per-entry limit in the tailnets: list, falling back to the global value - fits the per-tailnet Provider structure (internal/telemetry/providerset.go). Overflow/limits accounting and self-obs must stay per-tailnet attributable.

Acceptance Criteria

Definition of Done

Implementation Plan

Frozen seam (do not renegotiate)

tailnets:
  - name: alpha
    cardinality:                 # OPTIONAL per-tailnet overrides; each key falls back to the global cardinality: block. FILE-ONLY, like the rest of the list.
      metric_limit: 0            # 0 = inherit cardinality.metric_limit
      warning_threshold: 0       # 0 = inherit cardinality.warning_threshold
      critical_threshold: 0      # 0 = inherit cardinality.critical_threshold

AC#2 is the hard half — do not stop at the provider

Config-shape seams

This is a NEW STRUCTURED SHAPE inside a file-only list — the doc-0002 four-seam case:

  1. internal/config/config.go + defaults.go;
  2. config.example.yaml (the tailnets: example entry) — TestExampleConfigCoversEveryKey;
  3. config.schema.json via just gen-config-schema;
  4. deploy/helm/tailscale2otel/values.yaml + just gen-helmTestHelmValuesCoverEveryKey;
  5. env loader: NO CHANGE, and confirm TestStructSliceEnvKeysMatchesStructSliceFields still passes (it keeps structSliceEnvKeys in step with the real []struct fields). Plus just gen-envref.

Work

  1. Test-first: per-entry values override; 0 inherits each field independently; negative = unlimited for that tailnet only; the effective-value validation rejects critical < warning and warning > metric_limit AFTER fallback; a TS2OTEL_TAILNETS__0__CARDINALITY__METRIC_LIMIT variable is still a hard Load error.
  2. Then the provider wiring, then the status-page/self-obs half.
  3. Regenerate and gate with just check.

Implementation Notes

Research 2026-08-30 (Wave 1 planning, HEAD 1dd76a9)

Traced all three global values to their consumers. They are NOT wired the same way, and that asymmetry is the whole difficulty of this task.

metric_limit flows through the provider: internal/app/options.go:62 sets telemetry.Options.CardinalityLimit from cfg.Cardinality.MetricLimit; internal/telemetry/provider.go:271 passes it to metricProviderOptions -> sdkmetric.WithCardinalityLimit (internal/telemetry/processors.go:93); provider.go:329 also passes it to NewCardinalityTrackerWithLimits. NewProviderSet (internal/telemetry/providerset.go:41) builds each tailnet provider from ONE base Options template, overriding only TailnetName and InstanceID from PerTailnetOptions (providerset.go:22-25, 50-53). So a per-tailnet limit is a natural extension of PerTailnetOptions — the fan-out point already exists.

warning_threshold / critical_threshold do NOT flow through the provider at all. They are read straight off the global config at the render site: internal/app/status.go:426-427 (a.cfg.Cardinality.WarningThreshold / .CriticalThreshold). Nothing per-tailnet exists on that path. This is the trap: a lane that adds fields to PerTailnetOptions and stops will deliver a per-tailnet metric_limit and silently leave the two thresholds global, and the admin status page will keep showing the global numbers against per-tailnet series counts — a wrong reading that looks right.

Env is already handled. tailnets is in structSliceEnvKeys (internal/config/env.go:52), so any TS2OTEL_TAILNETS__0__... variable is already a hard Load error (#79). Nesting a new struct under a tailnets entry needs NO env-loader change and MUST NOT get one — the list is file-only by design.

Existing global validation to mirror per entry. CardinalityConfig (internal/config/config.go:956) documents: CriticalThreshold >= WarningThreshold, and when MetricLimit > 0 both must be <= MetricLimit. Defaults 2000 / 8000 / 10000. The per-entry rule must be checked on the EFFECTIVE (post-fallback) values, not the raw ones — an entry setting only metric_limit: 1000 while inheriting the global 2000/8000 thresholds is invalid and would otherwise pass.

Wave 1 Lane C2 started by root at 1de673f after W1; goal §6.1 negative metric_limit means per-tailnet unlimited and overrides the contradictory sentence in the task plan.

Validation: two-tailnet tests prove independent limit resolution, overflow attribution, status thresholds, and a GaugeSnapshot that cannot mix tailnets. just check passed at cd3bfa0; exact-head CI run 33312668201 concluded success.

Final Summary

Landed 175e3ce: per-tailnet metric limits and warning and critical thresholds resolve independently with attributable overflow and status. Verified by race tests, full gate, CodeRabbit correction, and CI 33312668201.

View the source file on GitHub