Merge branch 'main' into feature/test-validation-with-dots

Signed-off-by: Miroslav Šedivý <sedivy.miro@gmail.com>
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 8f03551..95fb827 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -93,15 +93,14 @@
           matrix:
             parameters:
               go_version:
-                - "1.21"
-                - "1.22"
                 - "1.23"
+                - "1.24"
       - test-assets:
           name: assets-go-<< matrix.go_version >>
           matrix:
             parameters:
               go_version:
-                - "1.23"
+                - "1.24"
       - style:
           name: style
-          go_version: "1.23"
+          go_version: "1.24"
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index def9007..d5d9ca2 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -25,15 +25,17 @@
     steps:
       - name: Checkout repository
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
-      - name: Install Go
-        uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
         with:
-          go-version: 1.23.x
+          persist-credentials: false
+      - name: Install Go
+        uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
+        with:
+          go-version: 1.24.x
       - name: Install snmp_exporter/generator dependencies
         run: sudo apt-get update && sudo apt-get -y install libsnmp-dev
         if: github.repository == 'prometheus/snmp_exporter'
       - name: Lint
-        uses: golangci/golangci-lint-action@ec5d18412c0aeab7936cb16880d708ba2a64e1ae # v6.2.0
+        uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
         with:
           args: --verbose
-          version: v1.63.4
+          version: v2.2.1
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 4793ce7..e7437b3 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -43,7 +43,7 @@
       # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
       # format to the repository Actions tab.
       - name: "Upload artifact"
-        uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # v3.1.0
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
         with:
           name: SARIF file
           path: results.sarif
diff --git a/.golangci.yml b/.golangci.yml
index e2f3e94..02bf7fb 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,46 +1,188 @@
+version: "2"
+linters:
+  # Keep this list sorted alphabetically
+  enable:
+    - depguard
+    - errorlint
+    - exptostd
+    - fatcontext
+    - gocritic
+    - godot
+    - govet
+    - loggercheck
+    - misspell
+    - nilnesserr
+    # TODO(bwplotka): Enable once https://github.com/golangci/golangci-lint/issues/3228 is fixed.
+    # - nolintlint
+    - perfsprint
+    - predeclared
+    - revive
+    - sloglint
+    - testifylint
+    - unconvert
+    - unused
+    - usestdlibvars
+    - whitespace
+  exclusions:
+    generated: lax
+    presets:
+      - comments
+      - common-false-positives
+      - legacy
+      - std-error-handling
+    paths:
+      - third_party$
+      - builtin$
+      - examples$
+    rules:
+      - linters:
+          - errcheck
+        # Taken from the default exclusions in v1.
+        text: Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv). is not checked
+      - linters:
+          - govet
+        # We use many Seek methods that do not follow the usual pattern.
+        text: "stdmethods: method Seek.* should have signature Seek"
+      - linters:
+          - revive
+        # We have stopped at some point to write doc comments on exported symbols.
+        # TODO(beorn7): Maybe we should enforce this again?
+        text: exported (.+) should have comment( \(or a comment on this block\))? or be unexported
+      - linters:
+          - gocritic
+        text: "appendAssign"
+      - linters:
+          - errcheck
+        path: _test.go
+      - linters:
+          - errorlint
+        path: "tsdb/head_wal.go"
+      - linters:
+          - godot
+        source: "^// ==="
+    warn-unused: true
+  settings:
+    depguard:
+      rules:
+        main:
+          deny:
+            - pkg: "github.com/go-kit/kit/log"
+              desc: "Use github.com/go-kit/log instead of github.com/go-kit/kit/log"
+            - pkg: "io/ioutil"
+              desc: "Use corresponding 'os' or 'io' functions instead."
+            - pkg: "github.com/pkg/errors"
+              desc: "Use 'errors' or 'fmt' instead of github.com/pkg/errors"
+            - pkg: "gzip"
+              desc: "Use github.com/klauspost/compress instead of gzip"
+            - pkg: "zlib"
+              desc: "Use github.com/klauspost/compress instead of zlib"
+            - pkg: "golang.org/x/exp/slices"
+              desc: "Use 'slices' instead."
+    errcheck:
+      exclude-functions:
+        # Don't flag lines such as "io.Copy(io.Discard, resp.Body)".
+        - io.Copy
+        # The next two are used in HTTP handlers, any error is handled by the server itself.
+        - io.WriteString
+        - (net/http.ResponseWriter).Write
+        # No need to check for errors on server's shutdown.
+        - (*net/http.Server).Shutdown
+        # Never check for rollback errors as Rollback() is called when a previous error was detected.
+        - (github.com/prometheus/prometheus/storage.Appender).Rollback
+    govet:
+      disable:
+        - shadow
+        - fieldalignment
+      enable-all: true
+    perfsprint:
+      # Optimizes even if it requires an int or uint type cast.
+      int-conversion: true
+      # Optimizes into `err.Error()` even if it is only equivalent for non-nil errors.
+      err-error: true
+      # Optimizes `fmt.Errorf`.
+      errorf: true
+      # Optimizes `fmt.Sprintf` with only one argument.
+      sprintf1: true
+      # Optimizes into strings concatenation.
+      strconcat: false
+    revive:
+      # By default, revive will enable only the linting rules that are named in the configuration file.
+      # So, it's needed to explicitly enable all required rules here.
+      rules:
+        # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md
+        - name: blank-imports
+        - name: comment-spacings
+        - name: context-as-argument
+          arguments:
+            # Allow functions with test or bench signatures.
+            - allowTypesBefore: '*testing.T,testing.TB'
+        - name: context-keys-type
+        - name: dot-imports
+        - name: early-return
+          arguments:
+            - "preserveScope"
+        # A lot of false positives: incorrectly identifies channel draining as "empty code block".
+        # See https://github.com/mgechev/revive/issues/386
+        - name: empty-block
+          disabled: true
+        - name: error-naming
+        - name: error-return
+        - name: error-strings
+        - name: errorf
+        - name: exported
+        - name: increment-decrement
+        - name: indent-error-flow
+          arguments:
+            - "preserveScope"
+        - name: package-comments
+          # TODO(beorn7): Currently, we have a lot of missing package doc comments. Maybe we should have them.
+          disabled: true
+        - name: range
+        - name: receiver-naming
+        - name: redefines-builtin-id
+        - name: superfluous-else
+          arguments:
+            - "preserveScope"
+        - name: time-naming
+        - name: unexported-return
+        - name: unreachable-code
+        - name: unused-parameter
+        - name: unused-receiver
+        - name: var-declaration
+        - name: var-naming
+    testifylint:
+      enable-all: true
+      disable:
+        - float-compare
+        - go-require
+      formatter:
+        require-f-funcs: true
 issues:
   max-issues-per-linter: 0
   max-same-issues: 0
-linters:
+output:
+  show-stats: false
+run:
+  timeout: 15m
+formatters:
   enable:
-    - errcheck
-    - errorlint
+    - gci
     - gofumpt
     - goimports
-    - gosimple
-    - govet
-    - ineffassign
-    - misspell
-    - perfsprint
-    - revive
-    - staticcheck
-    - testifylint
-    - unused
-linters-settings:
-  goimports:
-    local-prefixes: github.com/prometheus/common
-  perfsprint:
-    # Optimizes even if it requires an int or uint type cast.
-    int-conversion: true
-    # Optimizes into `err.Error()` even if it is only equivalent for non-nil errors.
-    err-error: true
-    # Optimizes `fmt.Errorf`.
-    errorf: true
-    # Optimizes `fmt.Sprintf` with only one argument.
-    sprintf1: true
-    # Optimizes into strings concatenation.
-    strconcat: false
-  revive:
-    rules:
-      # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unused-parameter
-      - name: unused-parameter
-        severity: warning
-        disabled: true
-  testifylint:
-    disable:
-      - go-require
-    enable-all: true
-    formatter:
-      require-f-funcs: true
-run:
-  timeout: 5m
\ No newline at end of file
+  settings:
+    gci:
+      sections:
+        - standard
+        - default
+        - prefix(github.com/prometheus/common)
+    gofumpt:
+      extra-rules: true
+    goimports:
+      local-prefixes:
+        - github.com/prometheus/common
+  exclusions:
+    generated: lax
+    paths:
+      - third_party$
+      - builtin$
+      - examples$
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..2c47511
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,61 @@
+# Changelog
+
+## main / unreleased
+
+### What's Changed
+
+* [ENHANCEMENT] Create CHANGELOG.md for easier communication of library changes, especially possible breaking changes. https://github.com/prometheus/common/pull/833
+
+## v0.66.1 / 2025-09-05
+
+This release has no functional changes, it just drops the dependencies `github.com/grafana/regexp` and `go.uber.org/atomic` and replaces `gopkg.in/yaml.v2` with `go.yaml.in/yaml/v2` (a drop-in replacement).
+
+### What's Changed
+* Revert "Use github.com/grafana/regexp instead of regexp" by @aknuds1 in https://github.com/prometheus/common/pull/835
+* Move to supported version of yaml parser by @dims in https://github.com/prometheus/common/pull/834
+* Revert "Use go.uber.org/atomic instead of sync/atomic (#825)" by @aknuds1 in https://github.com/prometheus/common/pull/838
+
+**Full Changelog**: https://github.com/prometheus/common/compare/v1.20.99...v0.66.1
+
+## v0.66.0 / 2025-09-02
+
+### ⚠️ Breaking Changes ⚠️
+
+* A default-constructed TextParser will be invalid. It must have a valid `scheme` set, so users should use the NewTextParser function to create a valid TextParser. Otherwise parsing will panic with "Invalid name validation scheme requested: unset".
+
+### What's Changed
+* model: add constants for type and unit labels. by @bwplotka in https://github.com/prometheus/common/pull/801
+* model.ValidationScheme: Support encoding as YAML by @aknuds1 in https://github.com/prometheus/common/pull/799
+* fix(promslog): always print time.Duration values as go duration strings by @tjhop in https://github.com/prometheus/common/pull/798
+* Add `ValidationScheme` methods `IsValidMetricName` and `IsValidLabelName` by @aknuds1 in https://github.com/prometheus/common/pull/806
+* Fix delimited proto not escaped correctly by @thampiotr in https://github.com/prometheus/common/pull/809
+* Decoder: Remove use of global name validation and add validation by @ywwg in https://github.com/prometheus/common/pull/808
+* ValidationScheme implements pflag.Value and json.Marshaler/Unmarshaler interfaces by @juliusmh in https://github.com/prometheus/common/pull/807
+* expfmt: Add NewTextParser function by @aknuds1 in https://github.com/prometheus/common/pull/816
+
+* Enable the godot linter by @aknuds1 in https://github.com/prometheus/common/pull/821
+* Enable usestdlibvars linter by @aknuds1 in https://github.com/prometheus/common/pull/820
+* Enable unconvert linter by @aknuds1 in https://github.com/prometheus/common/pull/819
+* Enable the fatcontext linter by @aknuds1 in https://github.com/prometheus/common/pull/822
+* Enable gocritic linter by @aknuds1 in https://github.com/prometheus/common/pull/818
+* Use go.uber.org/atomic instead of sync/atomic by @aknuds1 in https://github.com/prometheus/common/pull/825
+* Enable revive rule unused-parameter by @aknuds1 in https://github.com/prometheus/common/pull/824
+* Enable revive rules by @aknuds1 in https://github.com/prometheus/common/pull/823
+* Synchronize common files from prometheus/prometheus by @prombot in https://github.com/prometheus/common/pull/802
+* Synchronize common files from prometheus/prometheus by @prombot in https://github.com/prometheus/common/pull/803
+* Sync .golangci.yml with prometheus/prometheus by @aknuds1 in https://github.com/prometheus/common/pull/817
+* ci: update upload-actions by @ywwg in https://github.com/prometheus/common/pull/814
+* docs: fix typo in expfmt.Negotiate by @wmcram in https://github.com/prometheus/common/pull/813
+* build(deps): bump golang.org/x/net from 0.40.0 to 0.41.0 by @dependabot[bot] in https://github.com/prometheus/common/pull/800
+* build(deps): bump golang.org/x/net from 0.41.0 to 0.42.0 by @dependabot[bot] in https://github.com/prometheus/common/pull/810
+* build(deps): bump github.com/stretchr/testify from 1.10.0 to 1.11.1 in /assets by @dependabot[bot] in https://github.com/prometheus/common/pull/826
+* build(deps): bump google.golang.org/protobuf from 1.36.6 to 1.36.8 by @dependabot[bot] in https://github.com/prometheus/common/pull/830
+* build(deps): bump golang.org/x/net from 0.42.0 to 0.43.0 by @dependabot[bot] in https://github.com/prometheus/common/pull/829
+* build(deps): bump github.com/stretchr/testify from 1.10.0 to 1.11.1 by @dependabot[bot] in https://github.com/prometheus/common/pull/827
+
+### New Contributors
+* @aknuds1 made their first contribution in https://github.com/prometheus/common/pull/799
+* @thampiotr made their first contribution in https://github.com/prometheus/common/pull/809
+* @wmcram made their first contribution in https://github.com/prometheus/common/pull/813
+* @juliusmh made their first contribution in https://github.com/prometheus/common/pull/807
+
diff --git a/Makefile.common b/Makefile.common
index d1576bb..1f4c902 100644
--- a/Makefile.common
+++ b/Makefile.common
@@ -61,7 +61,8 @@
 SKIP_GOLANGCI_LINT :=
 GOLANGCI_LINT :=
 GOLANGCI_LINT_OPTS ?=
-GOLANGCI_LINT_VERSION ?= v1.63.4
+GOLANGCI_LINT_VERSION ?= v2.2.1
+GOLANGCI_FMT_OPTS ?=
 # golangci-lint only supports linux, darwin and windows platforms on i386/amd64/arm64.
 # windows isn't included here because of the path separator being different.
 ifeq ($(GOHOSTOS),$(filter $(GOHOSTOS),linux darwin))
@@ -138,7 +139,7 @@
 update-go-deps:
 	@echo ">> updating Go dependencies"
 	@for m in $$($(GO) list -mod=readonly -m -f '{{ if and (not .Indirect) (not .Main)}}{{.Path}}{{end}}' all); do \
-		$(GO) get -d $$m; \
+		$(GO) get $$m; \
 	done
 	$(GO) mod tidy
 
@@ -156,9 +157,13 @@
 	@mkdir -p $@
 
 .PHONY: common-format
-common-format:
+common-format: $(GOLANGCI_LINT)
 	@echo ">> formatting code"
 	$(GO) fmt $(pkgs)
+ifdef GOLANGCI_LINT
+	@echo ">> formatting code with golangci-lint"
+	$(GOLANGCI_LINT) fmt $(GOLANGCI_FMT_OPTS)
+endif
 
 .PHONY: common-vet
 common-vet:
@@ -248,8 +253,8 @@
 	cp $(PROMU_TMP)/promu-$(PROMU_VERSION).$(GO_BUILD_PLATFORM)/promu $(FIRST_GOPATH)/bin/promu
 	rm -r $(PROMU_TMP)
 
-.PHONY: proto
-proto:
+.PHONY: common-proto
+common-proto:
 	@echo ">> generating code from proto files"
 	@./scripts/genproto.sh
 
diff --git a/RELEASE.md b/RELEASE.md
index ba63828..066a36e 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -11,7 +11,17 @@
 
 ## How to cut an individual release
 
-There is no automated process for cutting a release in `prometheus/common`. A manual release using GitHub's release feature via [this link](https://github.com/prometheus/prometheus/releases/new) is the best way to go. The tag name must be prefixed with a `v` e.g. `v0.53.0` and then you can use the "Generate release notes" button to generate the release note automagically ✨. No need to create a discussion or mark it a pre-release, please do mark it as the latest release if needed.
+There is no automated process for cutting a release in `prometheus/common`.
+The primary trigger for announcing a release is pushing a new version tag.
+
+NOTE: As soon as a new tag is created, many downstream projects will automatically create pull requests to update their dependency of prom/common.
+Make sure the release is ready to go, with an updated changelog including notices of any breaking changes, before pushing a new tag.
+
+Here are the basic steps:
+
+1. Update VERSION, applying the next logical version number, without any `v` prefix (e.g.  `0.53.0`).
+2. Update CHANGELOG.md, applying the new version number (this time including the `v` prefix, e.g. `v0.53.0`) and date to the changes listed under ``## main / unreleased`, and commit those changes to the main branch.
+2. Use GitHub's release feature via [this link](https://github.com/prometheus/prometheus/releases/new) to apply a new tag. The tag name must be prefixed with a `v` e.g. `v0.53.0` and then use the "Generate release notes" button to generate the release notes automagically ✨. No need to create a discussion or mark it a pre-release, please do make sure it is marked as the latest release.
 
 ## Versioning strategy
 
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..d3072c4
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.66.1
\ No newline at end of file
diff --git a/assets/go.mod b/assets/go.mod
index 6e1afe3..0977f6e 100644
--- a/assets/go.mod
+++ b/assets/go.mod
@@ -1,8 +1,8 @@
 module github.com/prometheus/common/assets
 
-go 1.21
+go 1.23.0
 
-require github.com/stretchr/testify v1.10.0
+require github.com/stretchr/testify v1.11.1
 
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
diff --git a/assets/go.sum b/assets/go.sum
index 713a0b4..c4c1710 100644
--- a/assets/go.sum
+++ b/assets/go.sum
@@ -2,8 +2,8 @@
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/config/config.go b/config/config.go
index 7588da5..ff54cdd 100644
--- a/config/config.go
+++ b/config/config.go
@@ -30,7 +30,7 @@
 // MarshalSecretValue if set to true will expose Secret type
 // through the marshal interfaces. Useful for outside projects
 // that load and marshal the Prometheus config.
-var MarshalSecretValue bool = false
+var MarshalSecretValue = false
 
 // MarshalYAML implements the yaml.Marshaler interface for Secrets.
 func (s Secret) MarshalYAML() (interface{}, error) {
diff --git a/config/config_test.go b/config/config_test.go
index f62b266..9b0d048 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -21,7 +21,7 @@
 	"testing"
 
 	"github.com/stretchr/testify/require"
-	"gopkg.in/yaml.v2"
+	"go.yaml.in/yaml/v2"
 )
 
 func TestJSONMarshalSecret(t *testing.T) {
diff --git a/config/http_config.go b/config/http_config.go
index 6380908..4e5ff92 100644
--- a/config/http_config.go
+++ b/config/http_config.go
@@ -32,11 +32,11 @@
 	"time"
 
 	conntrack "github.com/mwitkow/go-conntrack"
+	"go.yaml.in/yaml/v2"
 	"golang.org/x/net/http/httpproxy"
 	"golang.org/x/net/http2"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2/clientcredentials"
-	"gopkg.in/yaml.v2"
 )
 
 var (
@@ -72,7 +72,7 @@
 
 func (tv *TLSVersion) UnmarshalYAML(unmarshal func(interface{}) error) error {
 	var s string
-	err := unmarshal((*string)(&s))
+	err := unmarshal(&s)
 	if err != nil {
 		return err
 	}
@@ -225,7 +225,7 @@
 // MarshalJSON implements the json.Marshaler interface for URL.
 func (u URL) MarshalJSON() ([]byte, error) {
 	if u.URL != nil {
-		return json.Marshal(u.URL.String())
+		return json.Marshal(u.String())
 	}
 	return []byte("null"), nil
 }
@@ -245,13 +245,13 @@
 	ProxyConfig     `yaml:",inline"`
 }
 
-// UnmarshalYAML implements the yaml.Unmarshaler interface
+// UnmarshalYAML implements the yaml.Unmarshaler interface.
 func (o *OAuth2) UnmarshalYAML(unmarshal func(interface{}) error) error {
 	type plain OAuth2
 	if err := unmarshal((*plain)(o)); err != nil {
 		return err
 	}
-	return o.ProxyConfig.Validate()
+	return o.Validate()
 }
 
 // UnmarshalJSON implements the json.Marshaler interface for URL.
@@ -260,7 +260,7 @@
 	if err := json.Unmarshal(data, (*plain)(o)); err != nil {
 		return err
 	}
-	return o.ProxyConfig.Validate()
+	return o.Validate()
 }
 
 // SetDirectory joins any relative file paths with dir.
@@ -346,7 +346,7 @@
 	var zero T
 	for _, value := range values {
 		if value != zero {
-			count += 1
+			count++
 		}
 	}
 	return count
@@ -363,7 +363,7 @@
 	if (c.BasicAuth != nil || c.OAuth2 != nil) && (len(c.BearerToken) > 0 || len(c.BearerTokenFile) > 0) {
 		return errors.New("at most one of basic_auth, oauth2, bearer_token & bearer_token_file must be configured")
 	}
-	if c.BasicAuth != nil && nonZeroCount(string(c.BasicAuth.Username) != "", c.BasicAuth.UsernameFile != "", c.BasicAuth.UsernameRef != "") > 1 {
+	if c.BasicAuth != nil && nonZeroCount(c.BasicAuth.Username != "", c.BasicAuth.UsernameFile != "", c.BasicAuth.UsernameRef != "") > 1 {
 		return errors.New("at most one of basic_auth username, username_file & username_ref must be configured")
 	}
 	if c.BasicAuth != nil && nonZeroCount(string(c.BasicAuth.Password) != "", c.BasicAuth.PasswordFile != "", c.BasicAuth.PasswordRef != "") > 1 {
@@ -423,7 +423,7 @@
 	return nil
 }
 
-// UnmarshalYAML implements the yaml.Unmarshaler interface
+// UnmarshalYAML implements the yaml.Unmarshaler interface.
 func (c *HTTPClientConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
 	type plain HTTPClientConfig
 	*c = DefaultHTTPClientConfig
@@ -542,8 +542,14 @@
 	opts.secretManager = s.secretManager
 }
 
+// SecretManagerOption is an option for providing a SecretManager.
+type SecretManagerOption interface {
+	TLSConfigOption
+	HTTPClientOption
+}
+
 // WithSecretManager allows setting the secret manager.
-func WithSecretManager(manager SecretManager) *secretManagerOption {
+func WithSecretManager(manager SecretManager) SecretManagerOption {
 	return &secretManagerOption{
 		secretManager: manager,
 	}
@@ -604,8 +610,8 @@
 		// The only timeout we care about is the configured scrape timeout.
 		// It is applied on request. So we leave out any timings here.
 		var rt http.RoundTripper = &http.Transport{
-			Proxy:                 cfg.ProxyConfig.Proxy(),
-			ProxyConnectHeader:    cfg.ProxyConfig.GetProxyConnectHeader(),
+			Proxy:                 cfg.Proxy(),
+			ProxyConnectHeader:    cfg.GetProxyConnectHeader(),
 			MaxIdleConns:          20000,
 			MaxIdleConnsPerHost:   1000, // see https://github.com/golang/go/issues/13801
 			DisableKeepAlives:     !opts.keepAlivesEnabled,
@@ -726,11 +732,11 @@
 	return s.text, nil
 }
 
-func (s *InlineSecret) Description() string {
+func (*InlineSecret) Description() string {
 	return "inline"
 }
 
-func (s *InlineSecret) Immutable() bool {
+func (*InlineSecret) Immutable() bool {
 	return true
 }
 
@@ -742,7 +748,7 @@
 	return &FileSecret{file: file}
 }
 
-func (s *FileSecret) Fetch(ctx context.Context) (string, error) {
+func (s *FileSecret) Fetch(context.Context) (string, error) {
 	fileBytes, err := os.ReadFile(s.file)
 	if err != nil {
 		return "", fmt.Errorf("unable to read file %s: %w", s.file, err)
@@ -754,7 +760,7 @@
 	return "file " + s.file
 }
 
-func (s *FileSecret) Immutable() bool {
+func (*FileSecret) Immutable() bool {
 	return false
 }
 
@@ -772,7 +778,7 @@
 	return "ref " + s.ref
 }
 
-func (s *refSecret) Immutable() bool {
+func (*refSecret) Immutable() bool {
 	return false
 }
 
@@ -914,8 +920,8 @@
 	tlsTransport := func(tlsConfig *tls.Config) (http.RoundTripper, error) {
 		return &http.Transport{
 			TLSClientConfig:       tlsConfig,
-			Proxy:                 rt.config.ProxyConfig.Proxy(),
-			ProxyConnectHeader:    rt.config.ProxyConfig.GetProxyConnectHeader(),
+			Proxy:                 rt.config.Proxy(),
+			ProxyConnectHeader:    rt.config.GetProxyConnectHeader(),
 			DisableKeepAlives:     !rt.opts.keepAlivesEnabled,
 			MaxIdleConns:          20,
 			MaxIdleConnsPerHost:   1, // see https://github.com/golang/go/issues/13801
@@ -1224,7 +1230,7 @@
 		}
 	}
 
-	keySecret, err := toSecret(secretManager, Secret(c.Key), c.KeyFile, c.KeyRef)
+	keySecret, err := toSecret(secretManager, c.Key, c.KeyFile, c.KeyRef)
 	if err != nil {
 		return nil, fmt.Errorf("unable to use client key: %w", err)
 	}
@@ -1362,9 +1368,9 @@
 	}
 
 	t.mtx.RLock()
-	equal := bytes.Equal(caHash[:], t.hashCAData) &&
-		bytes.Equal(certHash[:], t.hashCertData) &&
-		bytes.Equal(keyHash[:], t.hashKeyData)
+	equal := bytes.Equal(caHash, t.hashCAData) &&
+		bytes.Equal(certHash, t.hashCertData) &&
+		bytes.Equal(keyHash, t.hashKeyData)
 	rt := t.rt
 	t.mtx.RUnlock()
 	if equal {
@@ -1387,9 +1393,9 @@
 
 	t.mtx.Lock()
 	t.rt = rt
-	t.hashCAData = caHash[:]
-	t.hashCertData = certHash[:]
-	t.hashKeyData = keyHash[:]
+	t.hashCAData = caHash
+	t.hashCertData = certHash
+	t.hashKeyData = keyHash
 	t.mtx.Unlock()
 
 	return rt.RoundTrip(req)
@@ -1508,7 +1514,7 @@
 		}
 		return
 	}
-	if c.ProxyURL.URL != nil && c.ProxyURL.URL.String() != "" {
+	if c.ProxyURL.URL != nil && c.ProxyURL.String() != "" {
 		if c.NoProxy == "" {
 			c.proxyFunc = http.ProxyURL(c.ProxyURL.URL)
 			return
diff --git a/config/http_config_test.go b/config/http_config_test.go
index 58d13b0..c885359 100644
--- a/config/http_config_test.go
+++ b/config/http_config_test.go
@@ -36,7 +36,7 @@
 	"time"
 
 	"github.com/stretchr/testify/require"
-	"gopkg.in/yaml.v2"
+	"go.yaml.in/yaml/v2"
 )
 
 const (
@@ -181,7 +181,7 @@
 					InsecureSkipVerify: true,
 				},
 			},
-			handler: func(w http.ResponseWriter, r *http.Request) {
+			handler: func(w http.ResponseWriter, _ *http.Request) {
 				fmt.Fprint(w, ExpectedMessage)
 			},
 		},
@@ -195,7 +195,7 @@
 					InsecureSkipVerify: false,
 				},
 			},
-			handler: func(w http.ResponseWriter, r *http.Request) {
+			handler: func(w http.ResponseWriter, _ *http.Request) {
 				fmt.Fprint(w, ExpectedMessage)
 			},
 		},
@@ -369,13 +369,14 @@
 			},
 			handler: func(w http.ResponseWriter, r *http.Request) {
 				username, password, ok := r.BasicAuth()
-				if !ok {
+				switch {
+				case !ok:
 					fmt.Fprintf(w, "The Authorization header wasn't set")
-				} else if ExpectedUsername != username {
+				case ExpectedUsername != username:
 					fmt.Fprintf(w, "The expected username (%s) differs from the obtained username (%s).", ExpectedUsername, username)
-				} else if ExpectedPassword != password {
+				case ExpectedPassword != password:
 					fmt.Fprintf(w, "The expected password (%s) differs from the obtained password (%s).", ExpectedPassword, password)
-				} else {
+				default:
 					fmt.Fprint(w, ExpectedMessage)
 				}
 			},
@@ -705,7 +706,7 @@
 
 	// Normal flow.
 	bearerAuthRoundTripper := NewAuthorizationCredentialsRoundTripper("Bearer", NewInlineSecret(BearerToken), fakeRoundTripper)
-	request, _ := http.NewRequest("GET", "/hitchhiker", nil)
+	request, _ := http.NewRequest(http.MethodGet, "/hitchhiker", nil)
 	request.Header.Set("User-Agent", "Douglas Adams mind")
 	_, err := bearerAuthRoundTripper.RoundTrip(request)
 	if err != nil {
@@ -714,7 +715,7 @@
 
 	// Should honor already Authorization header set.
 	bearerAuthRoundTripperShouldNotModifyExistingAuthorization := NewAuthorizationCredentialsRoundTripper("Bearer", NewInlineSecret(newBearerToken), fakeRoundTripper)
-	request, _ = http.NewRequest("GET", "/hitchhiker", nil)
+	request, _ = http.NewRequest(http.MethodGet, "/hitchhiker", nil)
 	request.Header.Set("Authorization", ExpectedBearer)
 	_, err = bearerAuthRoundTripperShouldNotModifyExistingAuthorization.RoundTrip(request)
 	if err != nil {
@@ -733,7 +734,7 @@
 
 	// Normal flow.
 	bearerAuthRoundTripper := NewAuthorizationCredentialsRoundTripper("Bearer", &FileSecret{file: BearerTokenFile}, fakeRoundTripper)
-	request, _ := http.NewRequest("GET", "/hitchhiker", nil)
+	request, _ := http.NewRequest(http.MethodGet, "/hitchhiker", nil)
 	request.Header.Set("User-Agent", "Douglas Adams mind")
 	_, err := bearerAuthRoundTripper.RoundTrip(request)
 	if err != nil {
@@ -742,7 +743,7 @@
 
 	// Should honor already Authorization header set.
 	bearerAuthRoundTripperShouldNotModifyExistingAuthorization := NewAuthorizationCredentialsRoundTripper("Bearer", &FileSecret{file: MissingBearerTokenFile}, fakeRoundTripper)
-	request, _ = http.NewRequest("GET", "/hitchhiker", nil)
+	request, _ = http.NewRequest(http.MethodGet, "/hitchhiker", nil)
 	request.Header.Set("Authorization", ExpectedBearer)
 	_, err = bearerAuthRoundTripperShouldNotModifyExistingAuthorization.RoundTrip(request)
 	if err != nil {
@@ -932,7 +933,7 @@
 	data map[string]string
 }
 
-func (m *secretManager) Fetch(ctx context.Context, secretRef string) (string, error) {
+func (m *secretManager) Fetch(_ context.Context, secretRef string) (string, error) {
 	secretData, ok := m.data[secretRef]
 	if !ok {
 		return "", fmt.Errorf("unknown secret %s", secretRef)
@@ -1043,7 +1044,7 @@
 
 	ca, cert, key := filepath.Join(tmpDir, "ca"), filepath.Join(tmpDir, "cert"), filepath.Join(tmpDir, "key")
 
-	handler := func(w http.ResponseWriter, r *http.Request) {
+	handler := func(w http.ResponseWriter, _ *http.Request) {
 		fmt.Fprint(w, ExpectedMessage)
 	}
 	testServer, err := newTestServer(handler)
@@ -1161,7 +1162,7 @@
 }
 
 func TestTLSRoundTripper_Inline(t *testing.T) {
-	handler := func(w http.ResponseWriter, r *http.Request) {
+	handler := func(w http.ResponseWriter, _ *http.Request) {
 		fmt.Fprint(w, ExpectedMessage)
 	}
 	testServer, err := newTestServer(handler)
@@ -1283,7 +1284,7 @@
 
 	ca, cert, key := filepath.Join(tmpDir, "ca"), filepath.Join(tmpDir, "cert"), filepath.Join(tmpDir, "key")
 
-	handler := func(w http.ResponseWriter, r *http.Request) {
+	handler := func(w http.ResponseWriter, _ *http.Request) {
 		fmt.Fprint(w, ExpectedMessage)
 	}
 	testServer, err := newTestServer(handler)
@@ -1401,7 +1402,7 @@
 	theError    error
 }
 
-func (rt *roundTrip) RoundTrip(r *http.Request) (*http.Response, error) {
+func (rt *roundTrip) RoundTrip(*http.Request) (*http.Response, error) {
 	return rt.theResponse, rt.theError
 }
 
@@ -1748,7 +1749,7 @@
 	}
 }
 
-// checks if u equals to &url.URL{}
+// checks if u equals to &url.URL{}.
 func isEmptyNonNilURL(u *url.URL) bool {
 	return u != nil && *u == url.URL{}
 }
@@ -1874,7 +1875,7 @@
 	defer os.RemoveAll(tmpDir)
 	ca, cert, key := filepath.Join(tmpDir, "ca"), filepath.Join(tmpDir, "cert"), filepath.Join(tmpDir, "key")
 
-	handler := func(w http.ResponseWriter, r *http.Request) {
+	handler := func(w http.ResponseWriter, _ *http.Request) {
 		fmt.Fprint(w, ExpectedMessage)
 	}
 	testServer, err := newTestServer(handler)
@@ -2104,7 +2105,7 @@
 				os.Setenv("NO_PROXY", tc.noProxyEnv)
 			}
 
-			req := httptest.NewRequest("GET", tc.targetURL, nil)
+			req := httptest.NewRequest(http.MethodGet, tc.targetURL, nil)
 
 			proxyFunc := proxyConfig.Proxy()
 			resultURL, err := proxyFunc(req)
diff --git a/config/tls_config_test.go b/config/tls_config_test.go
index b43a1a1..f02c87f 100644
--- a/config/tls_config_test.go
+++ b/config/tls_config_test.go
@@ -26,7 +26,7 @@
 	"testing"
 
 	"github.com/stretchr/testify/require"
-	"gopkg.in/yaml.v2"
+	"go.yaml.in/yaml/v2"
 )
 
 // LoadTLSConfig parses the given file into a tls.Config.
diff --git a/expfmt/bench_test.go b/expfmt/bench_test.go
index 4f691b3..1acb199 100644
--- a/expfmt/bench_test.go
+++ b/expfmt/bench_test.go
@@ -22,13 +22,14 @@
 	"os"
 	"testing"
 
-	"google.golang.org/protobuf/encoding/protodelim"
-
 	dto "github.com/prometheus/client_model/go"
 	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/encoding/protodelim"
+
+	"github.com/prometheus/common/model"
 )
 
-var parser TextParser
+var parser = TextParser{scheme: model.UTF8Validation}
 
 // Benchmarks to show how much penalty text format parsing actually inflicts.
 //
diff --git a/expfmt/decode.go b/expfmt/decode.go
index 1448439..7b76237 100644
--- a/expfmt/decode.go
+++ b/expfmt/decode.go
@@ -70,19 +70,34 @@
 	return FmtUnknown
 }
 
-// NewDecoder returns a new decoder based on the given input format.
-// If the input format does not imply otherwise, a text format decoder is returned.
+// NewDecoder returns a new decoder based on the given input format. Metric
+// names are validated based on the provided Format -- if the format requires
+// escaping, raditional Prometheues validity checking is used. Otherwise, names
+// are checked for UTF-8 validity. Supported formats include delimited protobuf
+// and Prometheus text format.  For historical reasons, this decoder fallbacks
+// to classic text decoding for any other format. This decoder does not fully
+// support OpenMetrics although it may often succeed due to the similarities
+// between the formats. This decoder may not support the latest features of
+// Prometheus text format and is not intended for high-performance applications.
+// See: https://github.com/prometheus/common/issues/812
 func NewDecoder(r io.Reader, format Format) Decoder {
+	scheme := model.LegacyValidation
+	if format.ToEscapingScheme() == model.NoEscaping {
+		scheme = model.UTF8Validation
+	}
 	switch format.FormatType() {
 	case TypeProtoDelim:
-		return &protoDecoder{r: bufio.NewReader(r)}
+		return &protoDecoder{r: bufio.NewReader(r), s: scheme}
+	case TypeProtoText, TypeProtoCompact:
+		return &errDecoder{err: fmt.Errorf("format %s not supported for decoding", format)}
 	}
-	return &textDecoder{r: r}
+	return &textDecoder{r: r, s: scheme}
 }
 
 // protoDecoder implements the Decoder interface for protocol buffers.
 type protoDecoder struct {
 	r protodelim.Reader
+	s model.ValidationScheme
 }
 
 // Decode implements the Decoder interface.
@@ -93,7 +108,7 @@
 	if err := opts.UnmarshalFrom(d.r, v); err != nil {
 		return err
 	}
-	if !model.IsValidMetricName(model.LabelValue(v.GetName())) {
+	if !d.s.IsValidMetricName(v.GetName()) {
 		return fmt.Errorf("invalid metric name %q", v.GetName())
 	}
 	for _, m := range v.GetMetric() {
@@ -107,7 +122,7 @@
 			if !model.LabelValue(l.GetValue()).IsValid() {
 				return fmt.Errorf("invalid label value %q", l.GetValue())
 			}
-			if !model.LabelName(l.GetName()).IsValid() {
+			if !d.s.IsValidLabelName(l.GetName()) {
 				return fmt.Errorf("invalid label name %q", l.GetName())
 			}
 		}
@@ -115,10 +130,20 @@
 	return nil
 }
 
+// errDecoder is an error-state decoder that always returns the same error.
+type errDecoder struct {
+	err error
+}
+
+func (d *errDecoder) Decode(*dto.MetricFamily) error {
+	return d.err
+}
+
 // textDecoder implements the Decoder interface for the text protocol.
 type textDecoder struct {
 	r    io.Reader
 	fams map[string]*dto.MetricFamily
+	s    model.ValidationScheme
 	err  error
 }
 
@@ -126,7 +151,7 @@
 func (d *textDecoder) Decode(v *dto.MetricFamily) error {
 	if d.err == nil {
 		// Read all metrics in one shot.
-		var p TextParser
+		p := NewTextParser(d.s)
 		d.fams, d.err = p.TextToMetricFamilies(d.r)
 		// If we don't get an error, store io.EOF for the end.
 		if d.err == nil {
diff --git a/expfmt/decode_test.go b/expfmt/decode_test.go
index 10b12b6..baa761d 100644
--- a/expfmt/decode_test.go
+++ b/expfmt/decode_test.go
@@ -80,7 +80,10 @@
 	)
 
 	dec := &SampleDecoder{
-		Dec: &textDecoder{r: strings.NewReader(in)},
+		Dec: &textDecoder{
+			s: model.UTF8Validation,
+			r: strings.NewReader(in),
+		},
 		Opts: &DecodeOptions{
 			Timestamp: ts,
 		},
@@ -361,7 +364,7 @@
 
 	for i, scenario := range scenarios {
 		dec := &SampleDecoder{
-			Dec: &protoDecoder{r: strings.NewReader(scenario.in)},
+			Dec: &protoDecoder{r: strings.NewReader(scenario.in), s: model.LegacyValidation},
 			Opts: &DecodeOptions{
 				Timestamp: testTime,
 			},
@@ -369,7 +372,6 @@
 
 		var all model.Vector
 		for {
-			model.NameValidationScheme = model.LegacyValidation
 			var smpls model.Vector
 			err := dec.Decode(&smpls)
 			if err != nil && errors.Is(err, io.EOF) {
@@ -377,9 +379,8 @@
 			}
 			if scenario.legacyNameFail {
 				require.Errorf(t, err, "Expected error when decoding without UTF-8 support enabled but got none")
-				model.NameValidationScheme = model.UTF8Validation
 				dec = &SampleDecoder{
-					Dec: &protoDecoder{r: strings.NewReader(scenario.in)},
+					Dec: &protoDecoder{r: strings.NewReader(scenario.in), s: model.UTF8Validation},
 					Opts: &DecodeOptions{
 						Timestamp: testTime,
 					},
diff --git a/expfmt/encode.go b/expfmt/encode.go
index d7f3d76..73c24df 100644
--- a/expfmt/encode.go
+++ b/expfmt/encode.go
@@ -18,14 +18,12 @@
 	"io"
 	"net/http"
 
+	"github.com/munnerz/goautoneg"
+	dto "github.com/prometheus/client_model/go"
 	"google.golang.org/protobuf/encoding/protodelim"
 	"google.golang.org/protobuf/encoding/prototext"
 
 	"github.com/prometheus/common/model"
-
-	"github.com/munnerz/goautoneg"
-
-	dto "github.com/prometheus/client_model/go"
 )
 
 // Encoder types encode metric families into an underlying wire protocol.
@@ -61,7 +59,7 @@
 // appropriate accepted type is found, FmtText is returned (which is the
 // Prometheus text format). This function will never negotiate FmtOpenMetrics,
 // as the support is still experimental. To include the option to negotiate
-// FmtOpenMetrics, use NegotiateOpenMetrics.
+// FmtOpenMetrics, use NegotiateIncludingOpenMetrics.
 func Negotiate(h http.Header) Format {
 	escapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String())))
 	for _, ac := range goautoneg.ParseAccept(h.Get(hdrAccept)) {
@@ -153,7 +151,7 @@
 	case TypeProtoDelim:
 		return encoderCloser{
 			encode: func(v *dto.MetricFamily) error {
-				_, err := protodelim.MarshalTo(w, v)
+				_, err := protodelim.MarshalTo(w, model.EscapeMetricFamily(v, escapingScheme))
 				return err
 			},
 			close: func() error { return nil },
diff --git a/expfmt/encode_test.go b/expfmt/encode_test.go
index d91faaa..6a80162 100644
--- a/expfmt/encode_test.go
+++ b/expfmt/encode_test.go
@@ -18,11 +18,12 @@
 	"net/http"
 	"testing"
 
+	dto "github.com/prometheus/client_model/go"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	"google.golang.org/protobuf/proto"
 
 	"github.com/prometheus/common/model"
-
-	dto "github.com/prometheus/client_model/go"
 )
 
 func TestNegotiate(t *testing.T) {
@@ -97,7 +98,7 @@
 	}
 }
 
-func TestNegotiateOpenMetrics(t *testing.T) {
+func TestNegotiateIncludingOpenMetrics(t *testing.T) {
 	acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily"
 	tests := []struct {
 		name              string
@@ -309,8 +310,32 @@
 }
 
 func TestEscapedEncode(t *testing.T) {
-	var buff bytes.Buffer
-	delimEncoder := NewEncoder(&buff, FmtProtoDelim+"; escaping=underscores")
+	tests := []struct {
+		name   string
+		format Format
+	}{
+		{
+			name:   "ProtoDelim",
+			format: FmtProtoDelim,
+		},
+		{
+			name:   "ProtoDelim with escaping underscores",
+			format: FmtProtoDelim + "; escaping=underscores",
+		},
+		{
+			name:   "ProtoCompact",
+			format: FmtProtoCompact,
+		},
+		{
+			name:   "ProtoText",
+			format: FmtProtoText,
+		},
+		{
+			name:   "Text",
+			format: FmtText,
+		},
+	}
+
 	metric := &dto.MetricFamily{
 		Name: proto.String("foo.metric"),
 		Type: dto.MetricType_UNTYPED.Enum(),
@@ -334,61 +359,109 @@
 		},
 	}
 
-	err := delimEncoder.Encode(metric)
-	if err != nil {
-		t.Errorf("unexpected error during encode: %s", err.Error())
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var buff bytes.Buffer
+			encoder := NewEncoder(&buff, tt.format)
+			err := encoder.Encode(metric)
+			require.NoError(t, err)
+
+			s := buff.String()
+			assert.NotContains(t, s, "foo.metric")
+			assert.Contains(t, s, "foo_metric")
+			assert.NotContains(t, s, "dotted.label.name")
+			assert.Contains(t, s, "dotted_label_name")
+			assert.Contains(t, s, "my.label.value")
+		})
+	}
+}
+
+func TestDottedEncode(t *testing.T) {
+	//nolint:staticcheck
+	model.NameValidationScheme = model.UTF8Validation
+	metric := &dto.MetricFamily{
+		Name: proto.String("foo.metric"),
+		Type: dto.MetricType_COUNTER.Enum(),
+		Metric: []*dto.Metric{
+			{
+				Counter: &dto.Counter{
+					Value: proto.Float64(1.234),
+				},
+			},
+			{
+				Label: []*dto.LabelPair{
+					{
+						Name:  proto.String("dotted.label.name"),
+						Value: proto.String("my.label.value"),
+					},
+				},
+				Counter: &dto.Counter{
+					Value: proto.Float64(8),
+				},
+			},
+		},
 	}
 
-	out := buff.Bytes()
-	if len(out) == 0 {
-		t.Errorf("expected the output bytes buffer to be non-empty")
+	scenarios := []struct {
+		format           Format
+		expectMetricName string
+		expectLabelName  string
+	}{
+		{
+			format:           FmtProtoDelim,
+			expectMetricName: "foo_metric",
+			expectLabelName:  "dotted_label_name",
+		},
+		{
+			format:           FmtProtoDelim.WithEscapingScheme(model.NoEscaping),
+			expectMetricName: "foo.metric",
+			expectLabelName:  "dotted.label.name",
+		},
+		{
+			format:           FmtProtoDelim.WithEscapingScheme(model.DotsEscaping),
+			expectMetricName: "foo_dot_metric",
+			expectLabelName:  "dotted_dot_label_dot_name",
+		},
+		{
+			format:           FmtText,
+			expectMetricName: "foo_metric",
+			expectLabelName:  "dotted_label_name",
+		},
+		{
+			format:           FmtText.WithEscapingScheme(model.NoEscaping),
+			expectMetricName: "foo.metric",
+			expectLabelName:  "dotted.label.name",
+		},
+		{
+			format:           FmtText.WithEscapingScheme(model.DotsEscaping),
+			expectMetricName: "foo_dot_metric",
+			expectLabelName:  "dotted_dot_label_dot_name",
+		},
+		// common library does not support proto text or open metrics parsing so we
+		// do not test those here.
 	}
 
-	buff.Reset()
+	for i, scenario := range scenarios {
+		out := bytes.NewBuffer(make([]byte, 0))
+		enc := NewEncoder(out, scenario.format)
+		err := enc.Encode(metric)
+		if err != nil {
+			t.Errorf("%d. error: %s", i, err)
+			continue
+		}
 
-	compactEncoder := NewEncoder(&buff, FmtProtoCompact)
-	err = compactEncoder.Encode(metric)
-	if err != nil {
-		t.Errorf("unexpected error during encode: %s", err.Error())
-	}
-
-	out = buff.Bytes()
-	if len(out) == 0 {
-		t.Errorf("expected the output bytes buffer to be non-empty")
-	}
-
-	buff.Reset()
-
-	protoTextEncoder := NewEncoder(&buff, FmtProtoText)
-	err = protoTextEncoder.Encode(metric)
-	if err != nil {
-		t.Errorf("unexpected error during encode: %s", err.Error())
-	}
-
-	out = buff.Bytes()
-	if len(out) == 0 {
-		t.Errorf("expected the output bytes buffer to be non-empty")
-	}
-
-	buff.Reset()
-
-	textEncoder := NewEncoder(&buff, FmtText)
-	err = textEncoder.Encode(metric)
-	if err != nil {
-		t.Errorf("unexpected error during encode: %s", err.Error())
-	}
-
-	out = buff.Bytes()
-	if len(out) == 0 {
-		t.Errorf("expected the output bytes buffer to be non-empty")
-	}
-
-	expected := `# TYPE foo_metric untyped
-foo_metric 1.234
-foo_metric{dotted_label_name="my.label.value"} 8
-`
-
-	if string(out) != expected {
-		t.Errorf("expected TextEncoder to return %s, but got %s instead", expected, string(out))
+		dec := NewDecoder(bytes.NewReader(out.Bytes()), scenario.format)
+		var gotFamily dto.MetricFamily
+		err = dec.Decode(&gotFamily)
+		if err != nil {
+			t.Errorf("%v: unexpected error during decode: %s", scenario.format, err.Error())
+		}
+		if gotFamily.GetName() != scenario.expectMetricName {
+			t.Errorf("%v: incorrect encoded metric name, want %v, got %v", scenario.format, scenario.expectMetricName, gotFamily.GetName())
+		}
+		lName := gotFamily.GetMetric()[1].Label[0].GetName()
+		if lName != scenario.expectLabelName {
+			t.Errorf("%v: incorrect encoded label name, want %v, got %v", scenario.format, scenario.expectLabelName, lName)
+		}
 	}
 }
diff --git a/expfmt/expfmt.go b/expfmt/expfmt.go
index b268865..c34c7de 100644
--- a/expfmt/expfmt.go
+++ b/expfmt/expfmt.go
@@ -36,9 +36,11 @@
 	ProtoType     = `application/vnd.google.protobuf`
 	ProtoProtocol = `io.prometheus.client.MetricFamily`
 	// Deprecated: Use expfmt.NewFormat(expfmt.TypeProtoCompact) instead.
-	ProtoFmt                 = ProtoType + "; proto=" + ProtoProtocol + ";"
-	OpenMetricsType          = `application/openmetrics-text`
+	ProtoFmt        = ProtoType + "; proto=" + ProtoProtocol + ";"
+	OpenMetricsType = `application/openmetrics-text`
+	//nolint:revive // Allow for underscores.
 	OpenMetricsVersion_0_0_1 = "0.0.1"
+	//nolint:revive // Allow for underscores.
 	OpenMetricsVersion_1_0_0 = "1.0.0"
 
 	// The Content-Type values for the different wire protocols. Do not do direct
@@ -54,8 +56,10 @@
 	// Deprecated: Use expfmt.NewFormat(expfmt.TypeProtoCompact) instead.
 	FmtProtoCompact Format = ProtoFmt + ` encoding=compact-text`
 	// Deprecated: Use expfmt.NewFormat(expfmt.TypeOpenMetrics) instead.
+	//nolint:revive // Allow for underscores.
 	FmtOpenMetrics_1_0_0 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_1_0_0 + `; charset=utf-8`
 	// Deprecated: Use expfmt.NewFormat(expfmt.TypeOpenMetrics) instead.
+	//nolint:revive // Allow for underscores.
 	FmtOpenMetrics_0_0_1 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_0_0_1 + `; charset=utf-8`
 )
 
@@ -188,8 +192,8 @@
 // Format contains a escaping=allow-utf-8 term, it will select NoEscaping. If a valid
 // "escaping" term exists, that will be used. Otherwise, the global default will
 // be returned.
-func (format Format) ToEscapingScheme() model.EscapingScheme {
-	for _, p := range strings.Split(string(format), ";") {
+func (f Format) ToEscapingScheme() model.EscapingScheme {
+	for _, p := range strings.Split(string(f), ";") {
 		toks := strings.Split(p, "=")
 		if len(toks) != 2 {
 			continue
diff --git a/expfmt/expfmt_test.go b/expfmt/expfmt_test.go
index d9373bc..7ac054f 100644
--- a/expfmt/expfmt_test.go
+++ b/expfmt/expfmt_test.go
@@ -16,14 +16,12 @@
 import (
 	"testing"
 
-	"github.com/prometheus/common/model"
-
 	"github.com/stretchr/testify/require"
+
+	"github.com/prometheus/common/model"
 )
 
-// Test Format to Escapting Scheme conversion
-// Path: expfmt/expfmt_test.go
-// Compare this snippet from expfmt/expfmt.go:
+// Test Format to Escaping Scheme conversion.
 func TestToFormatType(t *testing.T) {
 	tests := []struct {
 		format   Format
diff --git a/expfmt/fuzz.go b/expfmt/fuzz.go
index dfac962..0290f6a 100644
--- a/expfmt/fuzz.go
+++ b/expfmt/fuzz.go
@@ -17,7 +17,11 @@
 
 package expfmt
 
-import "bytes"
+import (
+	"bytes"
+
+	"github.com/prometheus/common/model"
+)
 
 // Fuzz text metric parser with with github.com/dvyukov/go-fuzz:
 //
@@ -26,9 +30,8 @@
 //
 // Further input samples should go in the folder fuzz/corpus.
 func Fuzz(in []byte) int {
-	parser := TextParser{}
+	parser := NewTextParser(model.UTF8Validation)
 	_, err := parser.TextToMetricFamilies(bytes.NewReader(in))
-
 	if err != nil {
 		return 0
 	}
diff --git a/expfmt/openmetrics_create.go b/expfmt/openmetrics_create.go
index a21ed4e..8dbf6d0 100644
--- a/expfmt/openmetrics_create.go
+++ b/expfmt/openmetrics_create.go
@@ -22,11 +22,10 @@
 	"strconv"
 	"strings"
 
+	dto "github.com/prometheus/client_model/go"
 	"google.golang.org/protobuf/types/known/timestamppb"
 
 	"github.com/prometheus/common/model"
-
-	dto "github.com/prometheus/client_model/go"
 )
 
 type encoderOption struct {
@@ -249,7 +248,7 @@
 
 	// Finally the samples, one line for each.
 	if metricType == dto.MetricType_COUNTER && strings.HasSuffix(name, "_total") {
-		compliantName = compliantName + "_total"
+		compliantName += "_total"
 	}
 	for _, metric := range in.Metric {
 		switch metricType {
@@ -477,7 +476,7 @@
 	if name != "" {
 		// If the name does not pass the legacy validity check, we must put the
 		// metric name inside the braces, quoted.
-		if !model.IsValidLegacyMetricName(name) {
+		if !model.LegacyValidation.IsValidMetricName(name) {
 			metricInsideBraces = true
 			err := w.WriteByte(separator)
 			written++
@@ -641,11 +640,11 @@
 		if err != nil {
 			return written, err
 		}
-		err = (*e).Timestamp.CheckValid()
+		err = e.Timestamp.CheckValid()
 		if err != nil {
 			return written, err
 		}
-		ts := (*e).Timestamp.AsTime()
+		ts := e.Timestamp.AsTime()
 		// TODO(beorn7): Format this directly from components of ts to
 		// avoid overflow/underflow and precision issues of the float
 		// conversion.
diff --git a/expfmt/openmetrics_create_test.go b/expfmt/openmetrics_create_test.go
index a81bfed..eb9a9d3 100644
--- a/expfmt/openmetrics_create_test.go
+++ b/expfmt/openmetrics_create_test.go
@@ -20,11 +20,10 @@
 	"testing"
 	"time"
 
-	"google.golang.org/protobuf/proto"
-	"google.golang.org/protobuf/types/known/timestamppb"
-
 	dto "github.com/prometheus/client_model/go"
 	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
 
 	"github.com/prometheus/common/model"
 )
diff --git a/expfmt/text_create.go b/expfmt/text_create.go
index 4b86434..c4e9c1b 100644
--- a/expfmt/text_create.go
+++ b/expfmt/text_create.go
@@ -22,9 +22,9 @@
 	"strings"
 	"sync"
 
-	"github.com/prometheus/common/model"
-
 	dto "github.com/prometheus/client_model/go"
+
+	"github.com/prometheus/common/model"
 )
 
 // enhancedWriter has all the enhanced write functions needed here. bufio.Writer
@@ -354,7 +354,7 @@
 	if name != "" {
 		// If the name does not pass the legacy validity check, we must put the
 		// metric name inside the braces.
-		if !model.IsValidLegacyMetricName(name) {
+		if !model.LegacyValidation.IsValidMetricName(name) {
 			metricInsideBraces = true
 			err := w.WriteByte(separator)
 			written++
@@ -498,7 +498,7 @@
 // writeName writes a string as-is if it complies with the legacy naming
 // scheme, or escapes it in double quotes if not.
 func writeName(w enhancedWriter, name string) (int, error) {
-	if model.IsValidLegacyMetricName(name) {
+	if model.LegacyValidation.IsValidMetricName(name) {
 		return w.WriteString(name)
 	}
 	var written int
diff --git a/expfmt/text_create_test.go b/expfmt/text_create_test.go
index bb1c8f7..967c46a 100644
--- a/expfmt/text_create_test.go
+++ b/expfmt/text_create_test.go
@@ -19,10 +19,9 @@
 	"strings"
 	"testing"
 
-	"google.golang.org/protobuf/proto"
-
 	dto "github.com/prometheus/client_model/go"
 	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/proto"
 
 	"github.com/prometheus/common/model"
 )
diff --git a/expfmt/text_parse.go b/expfmt/text_parse.go
index b4607fe..8f2edde 100644
--- a/expfmt/text_parse.go
+++ b/expfmt/text_parse.go
@@ -78,6 +78,14 @@
 	// These indicate if the metric name from the current line being parsed is inside
 	// braces and if that metric name was found respectively.
 	currentMetricIsInsideBraces, currentMetricInsideBracesIsPresent bool
+	// scheme sets the desired ValidationScheme for names. Defaults to the invalid
+	// UnsetValidation.
+	scheme model.ValidationScheme
+}
+
+// NewTextParser returns a new TextParser with the provided nameValidationScheme.
+func NewTextParser(nameValidationScheme model.ValidationScheme) TextParser {
+	return TextParser{scheme: nameValidationScheme}
 }
 
 // TextToMetricFamilies reads 'in' as the simple and flat text-based exchange
@@ -126,6 +134,7 @@
 
 func (p *TextParser) reset(in io.Reader) {
 	p.metricFamiliesByName = map[string]*dto.MetricFamily{}
+	p.currentLabelPairs = nil
 	if p.buf == nil {
 		p.buf = bufio.NewReader(in)
 	} else {
@@ -216,6 +225,9 @@
 		return nil
 	}
 	p.setOrCreateCurrentMF()
+	if p.err != nil {
+		return nil
+	}
 	if p.skipBlankTab(); p.err != nil {
 		return nil // Unexpected end of input.
 	}
@@ -244,6 +256,9 @@
 		return nil
 	}
 	p.setOrCreateCurrentMF()
+	if p.err != nil {
+		return nil
+	}
 	// Now is the time to fix the type if it hasn't happened yet.
 	if p.currentMF.Type == nil {
 		p.currentMF.Type = dto.MetricType_UNTYPED.Enum()
@@ -311,6 +326,9 @@
 			switch p.currentByte {
 			case ',':
 				p.setOrCreateCurrentMF()
+				if p.err != nil {
+					return nil
+				}
 				if p.currentMF.Type == nil {
 					p.currentMF.Type = dto.MetricType_UNTYPED.Enum()
 				}
@@ -319,6 +337,10 @@
 				return p.startLabelName
 			case '}':
 				p.setOrCreateCurrentMF()
+				if p.err != nil {
+					p.currentLabelPairs = nil
+					return nil
+				}
 				if p.currentMF.Type == nil {
 					p.currentMF.Type = dto.MetricType_UNTYPED.Enum()
 				}
@@ -341,25 +363,30 @@
 	p.currentLabelPair = &dto.LabelPair{Name: proto.String(p.currentToken.String())}
 	if p.currentLabelPair.GetName() == string(model.MetricNameLabel) {
 		p.parseError(fmt.Sprintf("label name %q is reserved", model.MetricNameLabel))
+		p.currentLabelPairs = nil
+		return nil
+	}
+	if !p.scheme.IsValidLabelName(p.currentLabelPair.GetName()) {
+		p.parseError(fmt.Sprintf("invalid label name %q", p.currentLabelPair.GetName()))
+		p.currentLabelPairs = nil
 		return nil
 	}
 	// Special summary/histogram treatment. Don't add 'quantile' and 'le'
 	// labels to 'real' labels.
-	if !(p.currentMF.GetType() == dto.MetricType_SUMMARY && p.currentLabelPair.GetName() == model.QuantileLabel) &&
-		!(p.currentMF.GetType() == dto.MetricType_HISTOGRAM && p.currentLabelPair.GetName() == model.BucketLabel) {
+	if (p.currentMF.GetType() != dto.MetricType_SUMMARY || p.currentLabelPair.GetName() != model.QuantileLabel) &&
+		(p.currentMF.GetType() != dto.MetricType_HISTOGRAM || p.currentLabelPair.GetName() != model.BucketLabel) {
 		p.currentLabelPairs = append(p.currentLabelPairs, p.currentLabelPair)
 	}
 	// Check for duplicate label names.
 	labels := make(map[string]struct{})
 	for _, l := range p.currentLabelPairs {
 		lName := l.GetName()
-		if _, exists := labels[lName]; !exists {
-			labels[lName] = struct{}{}
-		} else {
+		if _, exists := labels[lName]; exists {
 			p.parseError(fmt.Sprintf("duplicate label names for metric %q", p.currentMF.GetName()))
 			p.currentLabelPairs = nil
 			return nil
 		}
+		labels[lName] = struct{}{}
 	}
 	return p.startLabelValue
 }
@@ -440,7 +467,8 @@
 	// When we are here, we have read all the labels, so for the
 	// special case of a summary/histogram, we can finally find out
 	// if the metric already exists.
-	if p.currentMF.GetType() == dto.MetricType_SUMMARY {
+	switch p.currentMF.GetType() {
+	case dto.MetricType_SUMMARY:
 		signature := model.LabelsToSignature(p.currentLabels)
 		if summary := p.summaries[signature]; summary != nil {
 			p.currentMetric = summary
@@ -448,7 +476,7 @@
 			p.summaries[signature] = p.currentMetric
 			p.currentMF.Metric = append(p.currentMF.Metric, p.currentMetric)
 		}
-	} else if p.currentMF.GetType() == dto.MetricType_HISTOGRAM {
+	case dto.MetricType_HISTOGRAM:
 		signature := model.LabelsToSignature(p.currentLabels)
 		if histogram := p.histograms[signature]; histogram != nil {
 			p.currentMetric = histogram
@@ -456,7 +484,7 @@
 			p.histograms[signature] = p.currentMetric
 			p.currentMF.Metric = append(p.currentMF.Metric, p.currentMetric)
 		}
-	} else {
+	default:
 		p.currentMF.Metric = append(p.currentMF.Metric, p.currentMetric)
 	}
 	if p.readTokenUntilWhitespace(); p.err != nil {
@@ -805,6 +833,10 @@
 	p.currentIsHistogramCount = false
 	p.currentIsHistogramSum = false
 	name := p.currentToken.String()
+	if !p.scheme.IsValidMetricName(name) {
+		p.parseError(fmt.Sprintf("invalid metric name %q", name))
+		return
+	}
 	if p.currentMF = p.metricFamiliesByName[name]; p.currentMF != nil {
 		return
 	}
diff --git a/expfmt/text_parse_test.go b/expfmt/text_parse_test.go
index fac60ba..f9c6d4c 100644
--- a/expfmt/text_parse_test.go
+++ b/expfmt/text_parse_test.go
@@ -21,8 +21,27 @@
 
 	dto "github.com/prometheus/client_model/go"
 	"google.golang.org/protobuf/proto"
+
+	"github.com/prometheus/common/model"
 )
 
+func TestNewTextParser(t *testing.T) {
+	p := NewTextParser(model.UTF8Validation)
+	if p.scheme != model.UTF8Validation {
+		t.Errorf("expected NewTextParser to return a TextParser with scheme %s - got %s", model.UTF8Validation, p.scheme)
+	}
+
+	p = NewTextParser(model.LegacyValidation)
+	if p.scheme != model.LegacyValidation {
+		t.Errorf("expected NewTextParser to return a TextParser with scheme %s - got %s", model.LegacyValidation, p.scheme)
+	}
+
+	p = NewTextParser(model.UnsetValidation)
+	if p.scheme != model.UnsetValidation {
+		t.Errorf("expected NewTextParser to return a TextParser with scheme %s - got %s", model.UnsetValidation, p.scheme)
+	}
+}
+
 func testTextParse(t testing.TB) {
 	scenarios := []struct {
 		in  string
@@ -191,10 +210,10 @@
   my_summary{n1="val1",n2="val2",quantile="-12.34",} NaN
 # some
 # funny comments
-# HELP 
+# HELP
 # HELP
 # HELP my_summary
-# HELP my_summary 
+# HELP my_summary
 `,
 			out: []*dto.MetricFamily{
 				{
@@ -682,20 +701,22 @@
 
 func testTextParseError(t testing.TB) {
 	scenarios := []struct {
-		in  string
-		err string
+		in      string
+		errUTF8 string
+		// if errLegacy is blank, it is assumed to be the same as errUTF8
+		errLegacy string
 	}{
 		// 0: No new-line at end of input.
 		{
 			in: `
 bla 3.14
 blubber 42`,
-			err: "text format parsing error in line 3: unexpected end of input stream",
+			errUTF8: "text format parsing error in line 3: unexpected end of input stream",
 		},
 		// 1: Invalid escape sequence in label value.
 		{
-			in:  `metric{label="\t"} 3.14`,
-			err: "text format parsing error in line 1: invalid escape sequence",
+			in:      `metric{label="\t"} 3.14`,
+			errUTF8: "text format parsing error in line 1: invalid escape sequence",
 		},
 		// 2: Newline in label value.
 		{
@@ -703,27 +724,27 @@
 metric{label="new
 line"} 3.14
 `,
-			err: `text format parsing error in line 2: label value "new" contains unescaped new-line`,
+			errUTF8: `text format parsing error in line 2: label value "new" contains unescaped new-line`,
 		},
 		// 3:
 		{
-			in:  `metric{@="bla"} 3.14`,
-			err: "text format parsing error in line 1: invalid label name for metric",
+			in:      `metric{@="bla"} 3.14`,
+			errUTF8: "text format parsing error in line 1: invalid label name for metric",
 		},
 		// 4:
 		{
-			in:  `metric{__name__="bla"} 3.14`,
-			err: `text format parsing error in line 1: label name "__name__" is reserved`,
+			in:      `metric{__name__="bla"} 3.14`,
+			errUTF8: `text format parsing error in line 1: label name "__name__" is reserved`,
 		},
 		// 5:
 		{
-			in:  `metric{label+="bla"} 3.14`,
-			err: "text format parsing error in line 1: expected '=' after label name",
+			in:      `metric{label+="bla"} 3.14`,
+			errUTF8: "text format parsing error in line 1: expected '=' after label name",
 		},
 		// 6:
 		{
-			in:  `metric{label=bla} 3.14`,
-			err: "text format parsing error in line 1: expected '\"' at start of label value",
+			in:      `metric{label=bla} 3.14`,
+			errUTF8: "text format parsing error in line 1: expected '\"' at start of label value",
 		},
 		// 7:
 		{
@@ -731,30 +752,30 @@
 # TYPE metric summary
 metric{quantile="bla"} 3.14
 `,
-			err: "text format parsing error in line 3: expected float as value for 'quantile' label",
+			errUTF8: "text format parsing error in line 3: expected float as value for 'quantile' label",
 		},
 		// 8:
 		{
-			in:  `metric{label="bla"+} 3.14`,
-			err: "text format parsing error in line 1: unexpected end of label value",
+			in:      `metric{label="bla"+} 3.14`,
+			errUTF8: "text format parsing error in line 1: unexpected end of label value",
 		},
 		// 9:
 		{
 			in: `metric{label="bla"} 3.14 2.72
 `,
-			err: "text format parsing error in line 1: expected integer as timestamp",
+			errUTF8: "text format parsing error in line 1: expected integer as timestamp",
 		},
 		// 10:
 		{
 			in: `metric{label="bla"} 3.14 2 3
 `,
-			err: "text format parsing error in line 1: spurious string after timestamp",
+			errUTF8: "text format parsing error in line 1: spurious string after timestamp",
 		},
 		// 11:
 		{
 			in: `metric{label="bla"} blubb
 `,
-			err: "text format parsing error in line 1: expected float as value",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 12:
 		{
@@ -762,7 +783,7 @@
 # HELP metric one
 # HELP metric two
 `,
-			err: "text format parsing error in line 3: second HELP line for metric name",
+			errUTF8: "text format parsing error in line 3: second HELP line for metric name",
 		},
 		// 13:
 		{
@@ -770,7 +791,7 @@
 # TYPE metric counter
 # TYPE metric untyped
 `,
-			err: `text format parsing error in line 3: second TYPE line for metric name "metric", or TYPE reported after samples`,
+			errUTF8: `text format parsing error in line 3: second TYPE line for metric name "metric", or TYPE reported after samples`,
 		},
 		// 14:
 		{
@@ -778,31 +799,31 @@
 metric 4.12
 # TYPE metric counter
 `,
-			err: `text format parsing error in line 3: second TYPE line for metric name "metric", or TYPE reported after samples`,
+			errUTF8: `text format parsing error in line 3: second TYPE line for metric name "metric", or TYPE reported after samples`,
 		},
 		// 14:
 		{
 			in: `
 # TYPE metric bla
 `,
-			err: "text format parsing error in line 2: unknown metric type",
+			errUTF8: "text format parsing error in line 2: unknown metric type",
 		},
 		// 15:
 		{
 			in: `
 # TYPE met-ric
 `,
-			err: "text format parsing error in line 2: invalid metric name in comment",
+			errUTF8: "text format parsing error in line 2: invalid metric name in comment",
 		},
 		// 16:
 		{
-			in:  `@invalidmetric{label="bla"} 3.14 2`,
-			err: "text format parsing error in line 1: invalid metric name",
+			in:      `@invalidmetric{label="bla"} 3.14 2`,
+			errUTF8: "text format parsing error in line 1: invalid metric name",
 		},
 		// 17:
 		{
-			in:  `{label="bla"} 3.14 2`,
-			err: "text format parsing error in line 1: invalid metric name",
+			in:      `{label="bla"} 3.14 2`,
+			errUTF8: "text format parsing error in line 1: invalid metric name",
 		},
 		// 18:
 		{
@@ -810,67 +831,67 @@
 # TYPE metric histogram
 metric_bucket{le="bla"} 3.14
 `,
-			err: "text format parsing error in line 3: expected float as value for 'le' label",
+			errUTF8: "text format parsing error in line 3: expected float as value for 'le' label",
 		},
 		// 19: Invalid UTF-8 in label value.
 		{
-			in:  "metric{l=\"\xbd\"} 3.14\n",
-			err: "text format parsing error in line 1: invalid label value \"\\xbd\"",
+			in:      "metric{l=\"\xbd\"} 3.14\n",
+			errUTF8: "text format parsing error in line 1: invalid label value \"\\xbd\"",
 		},
 		// 20: Go 1.13 sometimes allows underscores in numbers.
 		{
-			in:  "foo 1_2\n",
-			err: "text format parsing error in line 1: expected float as value",
+			in:      "foo 1_2\n",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 21: Go 1.13 supports hex floating point.
 		{
-			in:  "foo 0x1p-3\n",
-			err: "text format parsing error in line 1: expected float as value",
+			in:      "foo 0x1p-3\n",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 22: Check for various other literals variants, just in case.
 		{
-			in:  "foo 0x1P-3\n",
-			err: "text format parsing error in line 1: expected float as value",
+			in:      "foo 0x1P-3\n",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 23:
 		{
-			in:  "foo 0B1\n",
-			err: "text format parsing error in line 1: expected float as value",
+			in:      "foo 0B1\n",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 24:
 		{
-			in:  "foo 0O1\n",
-			err: "text format parsing error in line 1: expected float as value",
+			in:      "foo 0O1\n",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 25:
 		{
-			in:  "foo 0X1\n",
-			err: "text format parsing error in line 1: expected float as value",
+			in:      "foo 0X1\n",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 26:
 		{
-			in:  "foo 0x1\n",
-			err: "text format parsing error in line 1: expected float as value",
+			in:      "foo 0x1\n",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 27:
 		{
-			in:  "foo 0b1\n",
-			err: "text format parsing error in line 1: expected float as value",
+			in:      "foo 0b1\n",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 28:
 		{
-			in:  "foo 0o1\n",
-			err: "text format parsing error in line 1: expected float as value",
+			in:      "foo 0o1\n",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 29:
 		{
-			in:  "foo 0x1\n",
-			err: "text format parsing error in line 1: expected float as value",
+			in:      "foo 0x1\n",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 30:
 		{
-			in:  "foo 0x1\n",
-			err: "text format parsing error in line 1: expected float as value",
+			in:      "foo 0x1\n",
+			errUTF8: "text format parsing error in line 1: expected float as value",
 		},
 		// 31: Check histogram label.
 		{
@@ -878,7 +899,7 @@
 # TYPE metric histogram
 metric_bucket{le="0x1p-3"} 3.14
 `,
-			err: "text format parsing error in line 3: expected float as value for 'le' label",
+			errUTF8: "text format parsing error in line 3: expected float as value for 'le' label",
 		},
 		// 32: Check quantile label.
 		{
@@ -886,27 +907,28 @@
 # TYPE metric summary
 metric{quantile="0x1p-3"} 3.14
 `,
-			err: "text format parsing error in line 3: expected float as value for 'quantile' label",
+			errUTF8: "text format parsing error in line 3: expected float as value for 'quantile' label",
 		},
 		// 33: Check duplicate label.
 		{
-			in:  `metric{label="bla",label="bla"} 3.14`,
-			err: "text format parsing error in line 1: duplicate label names for metric",
+			in:      `metric{label="bla",label="bla"} 3.14`,
+			errUTF8: "text format parsing error in line 1: duplicate label names for metric",
 		},
 		// 34: Multiple quoted metric names.
 		{
-			in:  `{"one.name","another.name"} 3.14`,
-			err: "text format parsing error in line 1: multiple metric names",
+			in:        `{"one.name","another.name"} 3.14`,
+			errUTF8:   "text format parsing error in line 1: multiple metric names",
+			errLegacy: `text format parsing error in line 1: invalid metric name "one.name"`,
 		},
 		// 35: Invalid escape sequence in quoted metric name.
 		{
-			in:  `{"a\xc5z",label="bla"} 3.14`,
-			err: "text format parsing error in line 1: invalid escape sequence",
+			in:      `{"a\xc5z",label="bla"} 3.14`,
+			errUTF8: "text format parsing error in line 1: invalid escape sequence",
 		},
 		// 36: Unexpected end of quoted metric name.
 		{
-			in:  `{"metric.name".label="bla"} 3.14`,
-			err: "text format parsing error in line 1: unexpected end of metric name",
+			in:      `{"metric.name".label="bla"} 3.14`,
+			errUTF8: "text format parsing error in line 1: unexpected end of metric name",
 		},
 		// 37: Invalid escape sequence in quoted metric name.
 		{
@@ -914,7 +936,7 @@
 # TYPE "metric.name\t" counter
 {"metric.name\t",label="bla"} 3.14
 `,
-			err: "text format parsing error in line 2: invalid escape sequence",
+			errUTF8: "text format parsing error in line 2: invalid escape sequence",
 		},
 		// 38: Newline in quoted metric name.
 		{
@@ -924,7 +946,7 @@
 {"metric
 name",label="bla"} 3.14
 `,
-			err: `text format parsing error in line 2: metric name "metric" contains unescaped new-line`,
+			errUTF8: `text format parsing error in line 2: metric name "metric" contains unescaped new-line`,
 		},
 		// 39: Newline in quoted label name.
 		{
@@ -932,21 +954,55 @@
 {"metric.name","new
 line"="bla"} 3.14
 `,
-			err: `text format parsing error in line 2: label name "new" contains unescaped new-line`,
+			errUTF8:   `text format parsing error in line 2: label name "new" contains unescaped new-line`,
+			errLegacy: `text format parsing error in line 2: invalid metric name "metric.name"`,
+		},
+		// 40: dotted name fails legacy validation.
+		{
+			in: `{"metric.name",foo="bla"} 3.14
+`,
+			errUTF8:   ``,
+			errLegacy: `text format parsing error in line 1: invalid metric name "metric.name"`,
+		},
+		{
+			in: `metric_name{"foo"="bar", "dotted.label"="bla"} 3.14
+`,
+			errUTF8:   ``,
+			errLegacy: `text format parsing error in line 1: invalid label name "dotted.label"`,
 		},
 	}
 	for i, scenario := range scenarios {
+		parser.scheme = model.UTF8Validation
 		_, err := parser.TextToMetricFamilies(strings.NewReader(scenario.in))
 		if err == nil {
-			t.Errorf("%d. expected error, got nil", i)
-			continue
-		}
-		if expected, got := scenario.err, err.Error(); strings.Index(got, expected) != 0 {
+			if scenario.errUTF8 != "" {
+				t.Errorf("%d. expected error, got nil", i)
+			}
+		} else if expected, got := scenario.errUTF8, err.Error(); strings.Index(got, expected) != 0 {
 			t.Errorf(
 				"%d. expected error starting with %q, got %q",
 				i, expected, got,
 			)
 		}
+
+		parser.scheme = model.LegacyValidation
+		_, err = parser.TextToMetricFamilies(strings.NewReader(scenario.in))
+		if err == nil {
+			if scenario.errLegacy != "" {
+				t.Errorf("%d. expected error, got nil", i)
+			}
+		} else {
+			expected := scenario.errUTF8
+			if scenario.errLegacy != "" {
+				expected = scenario.errLegacy
+			}
+			if got := err.Error(); strings.Index(got, expected) != 0 {
+				t.Errorf(
+					"%d. expected error starting with %q, got %q",
+					i, expected, got,
+				)
+			}
+		}
 	}
 }
 
@@ -962,7 +1018,7 @@
 
 func TestTextParserStartOfLine(t *testing.T) {
 	t.Run("EOF", func(t *testing.T) {
-		p := TextParser{}
+		p := NewTextParser(model.UTF8Validation)
 		in := strings.NewReader("")
 		p.reset(in)
 		fn := p.startOfLine()
@@ -975,7 +1031,7 @@
 	})
 
 	t.Run("OtherError", func(t *testing.T) {
-		p := TextParser{}
+		p := NewTextParser(model.UTF8Validation)
 		in := &errReader{err: errors.New("unexpected error")}
 		p.reset(in)
 		fn := p.startOfLine()
@@ -992,6 +1048,6 @@
 	err error
 }
 
-func (r *errReader) Read(p []byte) (int, error) {
+func (r *errReader) Read([]byte) (int, error) {
 	return 0, r.err
 }
diff --git a/go.mod b/go.mod
index 5d2f2cb..a91acac 100644
--- a/go.mod
+++ b/go.mod
@@ -1,19 +1,21 @@
 module github.com/prometheus/common
 
-go 1.21
+go 1.23.0
+
+toolchain go1.24.1
 
 require (
 	github.com/alecthomas/kingpin/v2 v2.4.0
-	github.com/google/go-cmp v0.6.0
+	github.com/google/go-cmp v0.7.0
 	github.com/julienschmidt/httprouter v1.3.0
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822
 	github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f
-	github.com/prometheus/client_model v0.6.1
-	github.com/stretchr/testify v1.10.0
-	golang.org/x/net v0.34.0
-	golang.org/x/oauth2 v0.25.0
-	google.golang.org/protobuf v1.36.3
-	gopkg.in/yaml.v2 v2.4.0
+	github.com/prometheus/client_model v0.6.2
+	github.com/stretchr/testify v1.11.1
+	go.yaml.in/yaml/v2 v2.4.2
+	golang.org/x/net v0.43.0
+	golang.org/x/oauth2 v0.30.0
+	google.golang.org/protobuf v1.36.8
 )
 
 require (
@@ -27,10 +29,48 @@
 	github.com/prometheus/procfs v0.15.1 // indirect
 	github.com/rogpeppe/go-internal v1.10.0 // indirect
 	github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
-	golang.org/x/sys v0.29.0 // indirect
-	golang.org/x/text v0.21.0 // indirect
+	golang.org/x/sys v0.35.0 // indirect
+	golang.org/x/text v0.28.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
 
-retract v0.50.0 // Critical bug in counter suffixes, please read issue https://github.com/prometheus/common/issues/605
+retract (
+	v1.20.99 // This tag is needed to retract accidental tags below, but is retracted directly.
+	v1.20.3 // Tags pushed accidentally, see https://github.com/prometheus/common/issues/831
+	v1.20.3
+	v1.20.2
+	v1.20.1
+	v1.20.0
+	v1.19.1
+	v1.19.0
+	v1.18.0
+	v1.17.0
+	v1.16.0
+	v1.15.1
+	v1.15.0
+	v1.14.0
+	v1.13.1
+	v1.13.0
+	v1.12.2
+	v1.12.1
+	v1.12.0
+	v1.11.1
+	v1.11.0
+	v1.10.0
+	v1.9.0
+	v1.8.0
+	v1.7.1
+	v1.7.0
+	v1.6.0
+	v1.5.1
+	v1.5.0
+	v1.4.1
+	v1.4.0
+	v1.3.0
+	v1.2.1
+	v1.2.0
+	v1.1.0
+	v1.0.0
+	v0.50.0 // Critical bug in counter suffixes, please read issue https://github.com/prometheus/common/issues/605
+)
diff --git a/go.sum b/go.sum
index 1750431..654a301 100644
--- a/go.sum
+++ b/go.sum
@@ -9,8 +9,8 @@
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
 github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
 github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
@@ -31,33 +31,33 @@
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
 github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
-github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
-github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
 github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
 github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
-golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
-golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
-golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
-golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
-golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
-golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
-google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
+golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/model/alert.go b/model/alert.go
index bd3a39e..460f554 100644
--- a/model/alert.go
+++ b/model/alert.go
@@ -65,7 +65,7 @@
 	return a.ResolvedAt(time.Now())
 }
 
-// ResolvedAt returns true off the activity interval ended before
+// ResolvedAt returns true iff the activity interval ended before
 // the given timestamp.
 func (a *Alert) ResolvedAt(ts time.Time) bool {
 	if a.EndsAt.IsZero() {
diff --git a/model/labels.go b/model/labels.go
index f4a3876..dfeb34b 100644
--- a/model/labels.go
+++ b/model/labels.go
@@ -32,6 +32,12 @@
 	// MetricNameLabel is the label name indicating the metric name of a
 	// timeseries.
 	MetricNameLabel = "__name__"
+	// MetricTypeLabel is the label name indicating the metric type of
+	// timeseries as per the PROM-39 proposal.
+	MetricTypeLabel = "__type__"
+	// MetricUnitLabel is the label name indicating the metric unit of
+	// timeseries as per the PROM-39 proposal.
+	MetricUnitLabel = "__unit__"
 
 	// SchemeLabel is the name of the label that holds the scheme on which to
 	// scrape a target.
@@ -100,33 +106,21 @@
 // IsValid returns true iff the name matches the pattern of LabelNameRE when
 // NameValidationScheme is set to LegacyValidation, or valid UTF-8 if
 // NameValidationScheme is set to UTF8Validation.
+//
+// Deprecated: This method should not be used and may be removed in the future.
+// Use [ValidationScheme.IsValidLabelName] instead.
 func (ln LabelName) IsValid() bool {
-	if len(ln) == 0 {
-		return false
-	}
-	switch NameValidationScheme {
-	case LegacyValidation:
-		return ln.IsValidLegacy()
-	case UTF8Validation:
-		return utf8.ValidString(string(ln))
-	default:
-		panic(fmt.Sprintf("Invalid name validation scheme requested: %d", NameValidationScheme))
-	}
+	return NameValidationScheme.IsValidLabelName(string(ln))
 }
 
 // IsValidLegacy returns true iff name matches the pattern of LabelNameRE for
 // legacy names. It does not use LabelNameRE for the check but a much faster
 // hardcoded implementation.
+//
+// Deprecated: This method should not be used and may be removed in the future.
+// Use [LegacyValidation.IsValidLabelName] instead.
 func (ln LabelName) IsValidLegacy() bool {
-	if len(ln) == 0 {
-		return false
-	}
-	for i, b := range ln {
-		if !((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || (b >= '0' && b <= '9' && i > 0)) {
-			return false
-		}
-	}
-	return true
+	return LegacyValidation.IsValidLabelName(string(ln))
 }
 
 // UnmarshalYAML implements the yaml.Unmarshaler interface.
diff --git a/model/labels_test.go b/model/labels_test.go
index 70a9128..31717b4 100644
--- a/model/labels_test.go
+++ b/model/labels_test.go
@@ -14,6 +14,7 @@
 package model
 
 import (
+	"fmt"
 	"sort"
 	"testing"
 )
@@ -90,9 +91,9 @@
 	}
 }
 
-func TestLabelNameIsValid(t *testing.T) {
+func TestValidationScheme_IsLabelNameValid(t *testing.T) {
 	scenarios := []struct {
-		ln          LabelName
+		ln          string
 		legacyValid bool
 		utf8Valid   bool
 	}{
@@ -146,20 +147,39 @@
 			legacyValid: false,
 			utf8Valid:   true,
 		},
+		{
+			ln:          "",
+			legacyValid: false,
+			utf8Valid:   false,
+		},
 	}
-
 	for _, s := range scenarios {
-		NameValidationScheme = LegacyValidation
-		if s.ln.IsValid() != s.legacyValid {
-			t.Errorf("Expected %v for %q using legacy IsValid method", s.legacyValid, s.ln)
-		}
-		if LabelNameRE.MatchString(string(s.ln)) != s.legacyValid {
-			t.Errorf("Expected %v for %q using legacy regexp match", s.legacyValid, s.ln)
-		}
-		NameValidationScheme = UTF8Validation
-		if s.ln.IsValid() != s.utf8Valid {
-			t.Errorf("Expected %v for %q using UTF-8 IsValid method", s.legacyValid, s.ln)
-		}
+		t.Run(fmt.Sprintf("%s,%t,%t", s.ln, s.legacyValid, s.utf8Valid), func(t *testing.T) {
+			if LegacyValidation.IsValidLabelName(s.ln) != s.legacyValid {
+				t.Errorf("Expected %v for %q using LegacyValidation.IsValidLabelName", s.legacyValid, s.ln)
+			}
+			if LabelNameRE.MatchString(s.ln) != s.legacyValid {
+				t.Errorf("Expected %v for %q using legacy regexp match", s.legacyValid, s.ln)
+			}
+			if UTF8Validation.IsValidLabelName(s.ln) != s.utf8Valid {
+				t.Errorf("Expected %v for %q using UTF8Validation.IsValidLabelName", s.utf8Valid, s.ln)
+			}
+
+			// Test deprecated functions.
+			origScheme := NameValidationScheme
+			t.Cleanup(func() {
+				NameValidationScheme = origScheme
+			})
+			NameValidationScheme = LegacyValidation
+			labelName := LabelName(s.ln)
+			if labelName.IsValid() != s.legacyValid {
+				t.Errorf("Expected %v for %q using legacy IsValid method", s.legacyValid, s.ln)
+			}
+			NameValidationScheme = UTF8Validation
+			if labelName.IsValid() != s.utf8Valid {
+				t.Errorf("Expected %v for %q using UTF-8 IsValid method", s.utf8Valid, s.ln)
+			}
+		})
 	}
 }
 
diff --git a/model/labelset.go b/model/labelset.go
index d0ad88d..9de47b2 100644
--- a/model/labelset.go
+++ b/model/labelset.go
@@ -114,10 +114,10 @@
 }
 
 // Merge is a helper function to non-destructively merge two label sets.
-func (l LabelSet) Merge(other LabelSet) LabelSet {
-	result := make(LabelSet, len(l))
+func (ls LabelSet) Merge(other LabelSet) LabelSet {
+	result := make(LabelSet, len(ls))
 
-	for k, v := range l {
+	for k, v := range ls {
 		result[k] = v
 	}
 
@@ -140,7 +140,7 @@
 }
 
 // UnmarshalJSON implements the json.Unmarshaler interface.
-func (l *LabelSet) UnmarshalJSON(b []byte) error {
+func (ls *LabelSet) UnmarshalJSON(b []byte) error {
 	var m map[LabelName]LabelValue
 	if err := json.Unmarshal(b, &m); err != nil {
 		return err
@@ -153,6 +153,6 @@
 			return fmt.Errorf("%q is not a valid label name", ln)
 		}
 	}
-	*l = LabelSet(m)
+	*ls = LabelSet(m)
 	return nil
 }
diff --git a/model/metric.go b/model/metric.go
index 5766107..3feebf3 100644
--- a/model/metric.go
+++ b/model/metric.go
@@ -14,6 +14,7 @@
 package model
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"regexp"
@@ -23,17 +24,30 @@
 	"unicode/utf8"
 
 	dto "github.com/prometheus/client_model/go"
+	"go.yaml.in/yaml/v2"
 	"google.golang.org/protobuf/proto"
 )
 
 var (
-	// NameValidationScheme determines the method of name validation to be used by
-	// all calls to IsValidMetricName() and LabelName IsValid(). Setting UTF-8
-	// mode in isolation from other components that don't support UTF-8 may result
-	// in bugs or other undefined behavior. This value can be set to
-	// LegacyValidation during startup if a binary is not UTF-8-aware binaries. To
-	// avoid need for locking, this value should be set once, ideally in an
-	// init(), before multiple goroutines are started.
+	// NameValidationScheme determines the global default method of the name
+	// validation to be used by all calls to IsValidMetricName() and LabelName
+	// IsValid().
+	//
+	// Deprecated: This variable should not be used and might be removed in the
+	// far future. If you wish to stick to the legacy name validation use
+	// `IsValidLegacyMetricName()` and `LabelName.IsValidLegacy()` methods
+	// instead. This variable is here as an escape hatch for emergency cases,
+	// given the recent change from `LegacyValidation` to `UTF8Validation`, e.g.,
+	// to delay UTF-8 migrations in time or aid in debugging unforeseen results of
+	// the change. In such a case, a temporary assignment to `LegacyValidation`
+	// value in the `init()` function in your main.go or so, could be considered.
+	//
+	// Historically we opted for a global variable for feature gating different
+	// validation schemes in operations that were not otherwise easily adjustable
+	// (e.g. Labels yaml unmarshaling). That could have been a mistake, a separate
+	// Labels structure or package might have been a better choice. Given the
+	// change was made and many upgraded the common already, we live this as-is
+	// with this warning and learning for the future.
 	NameValidationScheme = UTF8Validation
 
 	// NameEscapingScheme defines the default way that names will be escaped when
@@ -50,16 +64,151 @@
 type ValidationScheme int
 
 const (
-	// LegacyValidation is a setting that requirets that metric and label names
+	// UnsetValidation represents an undefined ValidationScheme.
+	// Should not be used in practice.
+	UnsetValidation ValidationScheme = iota
+
+	// LegacyValidation is a setting that requires that all metric and label names
 	// conform to the original Prometheus character requirements described by
 	// MetricNameRE and LabelNameRE.
-	LegacyValidation ValidationScheme = iota
+	LegacyValidation
 
 	// UTF8Validation only requires that metric and label names be valid UTF-8
 	// strings.
 	UTF8Validation
 )
 
+var _ interface {
+	yaml.Marshaler
+	yaml.Unmarshaler
+	json.Marshaler
+	json.Unmarshaler
+	fmt.Stringer
+} = new(ValidationScheme)
+
+// String returns the string representation of s.
+func (s ValidationScheme) String() string {
+	switch s {
+	case UnsetValidation:
+		return "unset"
+	case LegacyValidation:
+		return "legacy"
+	case UTF8Validation:
+		return "utf8"
+	default:
+		panic(fmt.Errorf("unhandled ValidationScheme: %d", s))
+	}
+}
+
+// MarshalYAML implements the yaml.Marshaler interface.
+func (s ValidationScheme) MarshalYAML() (any, error) {
+	switch s {
+	case UnsetValidation:
+		return "", nil
+	case LegacyValidation, UTF8Validation:
+		return s.String(), nil
+	default:
+		panic(fmt.Errorf("unhandled ValidationScheme: %d", s))
+	}
+}
+
+// UnmarshalYAML implements the yaml.Unmarshaler interface.
+func (s *ValidationScheme) UnmarshalYAML(unmarshal func(any) error) error {
+	var scheme string
+	if err := unmarshal(&scheme); err != nil {
+		return err
+	}
+	return s.Set(scheme)
+}
+
+// MarshalJSON implements the json.Marshaler interface.
+func (s ValidationScheme) MarshalJSON() ([]byte, error) {
+	switch s {
+	case UnsetValidation:
+		return json.Marshal("")
+	case UTF8Validation, LegacyValidation:
+		return json.Marshal(s.String())
+	default:
+		return nil, fmt.Errorf("unhandled ValidationScheme: %d", s)
+	}
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface.
+func (s *ValidationScheme) UnmarshalJSON(bytes []byte) error {
+	var repr string
+	if err := json.Unmarshal(bytes, &repr); err != nil {
+		return err
+	}
+	return s.Set(repr)
+}
+
+// Set implements the pflag.Value interface.
+func (s *ValidationScheme) Set(text string) error {
+	switch text {
+	case "":
+		// Don't change the value.
+	case LegacyValidation.String():
+		*s = LegacyValidation
+	case UTF8Validation.String():
+		*s = UTF8Validation
+	default:
+		return fmt.Errorf("unrecognized ValidationScheme: %q", text)
+	}
+	return nil
+}
+
+// IsValidMetricName returns whether metricName is valid according to s.
+func (s ValidationScheme) IsValidMetricName(metricName string) bool {
+	switch s {
+	case LegacyValidation:
+		if len(metricName) == 0 {
+			return false
+		}
+		for i, b := range metricName {
+			if !isValidLegacyRune(b, i) {
+				return false
+			}
+		}
+		return true
+	case UTF8Validation:
+		if len(metricName) == 0 {
+			return false
+		}
+		return utf8.ValidString(metricName)
+	default:
+		panic(fmt.Sprintf("Invalid name validation scheme requested: %s", s.String()))
+	}
+}
+
+// IsValidLabelName returns whether labelName is valid according to s.
+func (s ValidationScheme) IsValidLabelName(labelName string) bool {
+	switch s {
+	case LegacyValidation:
+		if len(labelName) == 0 {
+			return false
+		}
+		for i, b := range labelName {
+			// TODO: Apply De Morgan's law. Make sure there are tests for this.
+			if !((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || (b >= '0' && b <= '9' && i > 0)) { //nolint:staticcheck
+				return false
+			}
+		}
+		return true
+	case UTF8Validation:
+		if len(labelName) == 0 {
+			return false
+		}
+		return utf8.ValidString(labelName)
+	default:
+		panic(fmt.Sprintf("Invalid name validation scheme requested: %s", s))
+	}
+}
+
+// Type implements the pflag.Value interface.
+func (ValidationScheme) Type() string {
+	return "validationScheme"
+}
+
 type EscapingScheme int
 
 const (
@@ -89,7 +238,7 @@
 	// Accept header, the default NameEscapingScheme will be used.
 	EscapingKey = "escaping"
 
-	// Possible values for Escaping Key:
+	// Possible values for Escaping Key.
 	AllowUTF8         = "allow-utf-8" // No escaping required.
 	EscapeUnderscores = "underscores"
 	EscapeDots        = "dots"
@@ -163,34 +312,22 @@
 // IsValidMetricName returns true iff name matches the pattern of MetricNameRE
 // for legacy names, and iff it's valid UTF-8 if the UTF8Validation scheme is
 // selected.
+//
+// Deprecated: This function should not be used and might be removed in the future.
+// Use [ValidationScheme.IsValidMetricName] instead.
 func IsValidMetricName(n LabelValue) bool {
-	switch NameValidationScheme {
-	case LegacyValidation:
-		return IsValidLegacyMetricName(string(n))
-	case UTF8Validation:
-		if len(n) == 0 {
-			return false
-		}
-		return utf8.ValidString(string(n))
-	default:
-		panic(fmt.Sprintf("Invalid name validation scheme requested: %d", NameValidationScheme))
-	}
+	return NameValidationScheme.IsValidMetricName(string(n))
 }
 
 // IsValidLegacyMetricName is similar to IsValidMetricName but always uses the
 // legacy validation scheme regardless of the value of NameValidationScheme.
 // This function, however, does not use MetricNameRE for the check but a much
 // faster hardcoded implementation.
+//
+// Deprecated: This function should not be used and might be removed in the future.
+// Use [LegacyValidation.IsValidMetricName] instead.
 func IsValidLegacyMetricName(n string) bool {
-	if len(n) == 0 {
-		return false
-	}
-	for i, b := range n {
-		if !isValidLegacyRune(b, i) {
-			return false
-		}
-	}
-	return true
+	return LegacyValidation.IsValidMetricName(n)
 }
 
 // EscapeMetricFamily escapes the given metric names and labels with the given
@@ -298,13 +435,14 @@
 	case DotsEscaping:
 		// Do not early return for legacy valid names, we still escape underscores.
 		for i, b := range name {
-			if b == '_' {
+			switch {
+			case b == '_':
 				escaped.WriteString("__")
-			} else if b == '.' {
+			case b == '.':
 				escaped.WriteString("_dot_")
-			} else if isValidLegacyRune(b, i) {
+			case isValidLegacyRune(b, i):
 				escaped.WriteRune(b)
-			} else {
+			default:
 				escaped.WriteString("__")
 			}
 		}
@@ -315,13 +453,14 @@
 		}
 		escaped.WriteString("U__")
 		for i, b := range name {
-			if b == '_' {
+			switch {
+			case b == '_':
 				escaped.WriteString("__")
-			} else if isValidLegacyRune(b, i) {
+			case isValidLegacyRune(b, i):
 				escaped.WriteRune(b)
-			} else if !utf8.ValidRune(b) {
+			case !utf8.ValidRune(b):
 				escaped.WriteString("_FFFD_")
-			} else {
+			default:
 				escaped.WriteRune('_')
 				escaped.WriteString(strconv.FormatInt(int64(b), 16))
 				escaped.WriteRune('_')
@@ -333,7 +472,7 @@
 	}
 }
 
-// lower function taken from strconv.atoi
+// lower function taken from strconv.atoi.
 func lower(c byte) byte {
 	return c | ('x' - 'X')
 }
@@ -397,11 +536,12 @@
 				}
 				r := lower(escapedName[i])
 				utf8Val *= 16
-				if r >= '0' && r <= '9' {
+				switch {
+				case r >= '0' && r <= '9':
 					utf8Val += uint(r) - '0'
-				} else if r >= 'a' && r <= 'f' {
+				case r >= 'a' && r <= 'f':
 					utf8Val += uint(r) - 'a' + 10
-				} else {
+				default:
 					return name
 				}
 				i++
diff --git a/model/metric_test.go b/model/metric_test.go
index 5209a64..8a8bec9 100644
--- a/model/metric_test.go
+++ b/model/metric_test.go
@@ -14,11 +14,17 @@
 package model
 
 import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
 	dto "github.com/prometheus/client_model/go"
+	"github.com/stretchr/testify/require"
+	"go.yaml.in/yaml/v2"
 	"google.golang.org/protobuf/proto"
 )
 
@@ -89,9 +95,206 @@
 	}
 }
 
-func TestMetricNameIsLegacyValid(t *testing.T) {
+func TestValidationScheme(t *testing.T) {
+	var scheme ValidationScheme
+	require.Equal(t, UnsetValidation, scheme)
+}
+
+func TestValidationScheme_String(t *testing.T) {
+	for _, tc := range []struct {
+		name   string
+		scheme ValidationScheme
+		want   string
+	}{
+		{
+			name:   "Unset",
+			scheme: UnsetValidation,
+			want:   "unset",
+		},
+		{
+			name:   "Legacy",
+			scheme: LegacyValidation,
+			want:   "legacy",
+		},
+		{
+			name:   "UTF8",
+			scheme: UTF8Validation,
+			want:   "utf8",
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			require.Equal(t, tc.want, tc.scheme.String())
+		})
+	}
+}
+
+func TestValidationScheme_MarshalYAML(t *testing.T) {
+	for _, tc := range []struct {
+		name   string
+		scheme ValidationScheme
+		want   string
+	}{
+		{
+			name:   "Unset",
+			scheme: UnsetValidation,
+			want:   `""`,
+		},
+		{
+			name:   "Legacy",
+			scheme: LegacyValidation,
+			want:   "legacy",
+		},
+		{
+			name:   "UTF8",
+			scheme: UTF8Validation,
+			want:   "utf8",
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			marshaled, err := yaml.Marshal(tc.scheme)
+			require.NoError(t, err)
+			require.Equal(t, tc.want, strings.TrimSpace(string(marshaled)))
+		})
+	}
+}
+
+func TestValidationScheme_UnmarshalYAML(t *testing.T) {
+	for _, tc := range []struct {
+		name      string
+		input     string
+		want      ValidationScheme
+		wantError error
+	}{
+		{
+			name:  "Unset empty input",
+			input: "",
+			want:  UnsetValidation,
+		},
+		{
+			name:  "Unset quoted input",
+			input: `""`,
+			want:  UnsetValidation,
+		},
+		{
+			name:  "Legacy",
+			input: "legacy",
+			want:  LegacyValidation,
+		},
+		{
+			name:  "UTF8",
+			input: "utf8",
+			want:  UTF8Validation,
+		},
+		{
+			name:      "Invalid",
+			input:     "invalid",
+			wantError: errors.New(`unrecognized ValidationScheme: "invalid"`),
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			scheme := UnsetValidation
+			err := yaml.Unmarshal([]byte(tc.input), &scheme)
+			if tc.wantError == nil {
+				require.NoError(t, err)
+				require.Equal(t, tc.want, scheme)
+			} else {
+				require.EqualError(t, err, tc.wantError.Error())
+			}
+		})
+	}
+}
+
+func TestValidationScheme_UnmarshalJSON(t *testing.T) {
+	testCases := []struct {
+		name    string
+		input   string
+		want    ValidationScheme
+		wantErr bool
+	}{
+		{
+			name:    "invalid",
+			input:   `invalid`,
+			wantErr: true,
+		},
+		{
+			name:  "empty",
+			input: `""`,
+			want:  UnsetValidation,
+		},
+		{
+			name:  "legacy validation",
+			input: `"legacy"`,
+			want:  LegacyValidation,
+		},
+		{
+			name:  "utf8 validation",
+			input: `"utf8"`,
+			want:  UTF8Validation,
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			var got ValidationScheme
+			err := json.Unmarshal([]byte(tc.input), &got)
+			if tc.wantErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			require.Equal(t, tc.want, got)
+
+			output, err := json.Marshal(got)
+			require.NoError(t, err)
+			require.Equal(t, tc.input, string(output))
+		})
+	}
+}
+
+func TestValidationScheme_Set(t *testing.T) {
+	testCases := []struct {
+		name    string
+		input   string
+		want    ValidationScheme
+		wantErr bool
+	}{
+		{
+			name:    "invalid",
+			input:   `invalid`,
+			wantErr: true,
+		},
+		{
+			name:  "empty",
+			input: ``,
+			want:  UnsetValidation,
+		},
+		{
+			name:  "legacy validation",
+			input: `legacy`,
+			want:  LegacyValidation,
+		},
+		{
+			name:  "utf8 validation",
+			input: `utf8`,
+			want:  UTF8Validation,
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			var got ValidationScheme
+			err := got.Set(tc.input)
+			if tc.wantErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			require.Equal(t, tc.want, got)
+		})
+	}
+}
+
+func TestValidationScheme_IsMetricNameValid(t *testing.T) {
 	scenarios := []struct {
-		mn          LabelValue
+		mn          string
 		legacyValid bool
 		utf8Valid   bool
 	}{
@@ -151,19 +354,35 @@
 			utf8Valid:   true,
 		},
 	}
-
 	for _, s := range scenarios {
-		NameValidationScheme = LegacyValidation
-		if IsValidMetricName(s.mn) != s.legacyValid {
-			t.Errorf("Expected %v for %q using legacy IsValidMetricName method", s.legacyValid, s.mn)
-		}
-		if MetricNameRE.MatchString(string(s.mn)) != s.legacyValid {
-			t.Errorf("Expected %v for %q using regexp matching", s.legacyValid, s.mn)
-		}
-		NameValidationScheme = UTF8Validation
-		if IsValidMetricName(s.mn) != s.utf8Valid {
-			t.Errorf("Expected %v for %q using utf-8 IsValidMetricName method", s.legacyValid, s.mn)
-		}
+		t.Run(fmt.Sprintf("%s,%t,%t", s.mn, s.legacyValid, s.utf8Valid), func(t *testing.T) {
+			if LegacyValidation.IsValidMetricName(s.mn) != s.legacyValid {
+				t.Errorf("Expected %v for %q using LegacyValidation.IsValidMetricName", s.legacyValid, s.mn)
+			}
+			if MetricNameRE.MatchString(s.mn) != s.legacyValid {
+				t.Errorf("Expected %v for %q using regexp matching", s.legacyValid, s.mn)
+			}
+			if UTF8Validation.IsValidMetricName(s.mn) != s.utf8Valid {
+				t.Errorf("Expected %v for %q using UTF8Validation.IsValidMetricName", s.utf8Valid, s.mn)
+			}
+
+			// Test deprecated functions.
+			if IsValidLegacyMetricName(s.mn) != s.legacyValid {
+				t.Errorf("Expected %v for %q using IsValidLegacyMetricNames", s.legacyValid, s.mn)
+			}
+			origScheme := NameValidationScheme
+			t.Cleanup(func() {
+				NameValidationScheme = origScheme
+			})
+			NameValidationScheme = LegacyValidation
+			if IsValidMetricName(LabelValue(s.mn)) != s.legacyValid {
+				t.Errorf("Expected %v for %q using legacy IsValidMetricName", s.legacyValid, s.mn)
+			}
+			NameValidationScheme = UTF8Validation
+			if IsValidMetricName(LabelValue(s.mn)) != s.utf8Valid {
+				t.Errorf("Expected %v for %q using utf-8 IsValidMetricName method", s.utf8Valid, s.mn)
+			}
+		})
 	}
 }
 
diff --git a/model/time.go b/model/time.go
index 5727452..1730b0f 100644
--- a/model/time.go
+++ b/model/time.go
@@ -126,14 +126,14 @@
 	p := strings.Split(string(b), ".")
 	switch len(p) {
 	case 1:
-		v, err := strconv.ParseInt(string(p[0]), 10, 64)
+		v, err := strconv.ParseInt(p[0], 10, 64)
 		if err != nil {
 			return err
 		}
 		*t = Time(v * second)
 
 	case 2:
-		v, err := strconv.ParseInt(string(p[0]), 10, 64)
+		v, err := strconv.ParseInt(p[0], 10, 64)
 		if err != nil {
 			return err
 		}
@@ -143,7 +143,7 @@
 		if prec < 0 {
 			p[1] = p[1][:dotPrecision]
 		} else if prec > 0 {
-			p[1] = p[1] + strings.Repeat("0", prec)
+			p[1] += strings.Repeat("0", prec)
 		}
 
 		va, err := strconv.ParseInt(p[1], 10, 32)
@@ -170,15 +170,15 @@
 // This type should not propagate beyond the scope of input/output processing.
 type Duration time.Duration
 
-// Set implements pflag/flag.Value
+// Set implements pflag/flag.Value.
 func (d *Duration) Set(s string) error {
 	var err error
 	*d, err = ParseDuration(s)
 	return err
 }
 
-// Type implements pflag.Value
-func (d *Duration) Type() string {
+// Type implements pflag.Value.
+func (*Duration) Type() string {
 	return "duration"
 }
 
@@ -201,6 +201,7 @@
 
 // ParseDuration parses a string into a time.Duration, assuming that a year
 // always has 365d, a week always has 7d, and a day always has 24h.
+// Negative durations are not supported.
 func ParseDuration(s string) (Duration, error) {
 	switch s {
 	case "0":
@@ -253,18 +254,36 @@
 			return 0, errors.New("duration out of range")
 		}
 	}
+
 	return Duration(dur), nil
 }
 
+// ParseDurationAllowNegative is like ParseDuration but also accepts negative durations.
+func ParseDurationAllowNegative(s string) (Duration, error) {
+	if s == "" || s[0] != '-' {
+		return ParseDuration(s)
+	}
+
+	d, err := ParseDuration(s[1:])
+
+	return -d, err
+}
+
 func (d Duration) String() string {
 	var (
-		ms = int64(time.Duration(d) / time.Millisecond)
-		r  = ""
+		ms   = int64(time.Duration(d) / time.Millisecond)
+		r    = ""
+		sign = ""
 	)
+
 	if ms == 0 {
 		return "0s"
 	}
 
+	if ms < 0 {
+		sign, ms = "-", -ms
+	}
+
 	f := func(unit string, mult int64, exact bool) {
 		if exact && ms%mult != 0 {
 			return
@@ -286,7 +305,7 @@
 	f("s", 1000, false)
 	f("ms", 1, false)
 
-	return r
+	return sign + r
 }
 
 // MarshalJSON implements the json.Marshaler interface.
diff --git a/model/time_test.go b/model/time_test.go
index 70f3839..a4e9069 100644
--- a/model/time_test.go
+++ b/model/time_test.go
@@ -68,70 +68,171 @@
 }
 
 func TestParseDuration(t *testing.T) {
-	cases := []struct {
-		in  string
-		out time.Duration
+	type testCase struct {
+		in              string
+		out             time.Duration
+		expectedString  string
+		allowedNegative bool
+	}
 
-		expectedString string
-	}{
+	baseCases := []testCase{
 		{
-			in:             "0",
-			out:            0,
-			expectedString: "0s",
-		}, {
-			in:             "0w",
-			out:            0,
-			expectedString: "0s",
-		}, {
-			in:  "0s",
-			out: 0,
-		}, {
-			in:  "324ms",
-			out: 324 * time.Millisecond,
-		}, {
-			in:  "3s",
-			out: 3 * time.Second,
-		}, {
-			in:  "5m",
-			out: 5 * time.Minute,
-		}, {
-			in:  "1h",
-			out: time.Hour,
-		}, {
-			in:  "4d",
-			out: 4 * 24 * time.Hour,
-		}, {
-			in:  "4d1h",
-			out: 4*24*time.Hour + time.Hour,
-		}, {
-			in:             "14d",
-			out:            14 * 24 * time.Hour,
-			expectedString: "2w",
-		}, {
-			in:  "3w",
-			out: 3 * 7 * 24 * time.Hour,
-		}, {
-			in:             "3w2d1h",
-			out:            3*7*24*time.Hour + 2*24*time.Hour + time.Hour,
-			expectedString: "23d1h",
-		}, {
-			in:  "10y",
-			out: 10 * 365 * 24 * time.Hour,
+			in:              "0",
+			out:             0,
+			expectedString:  "0s",
+			allowedNegative: false,
+		},
+		{
+			in:              "0w",
+			out:             0,
+			expectedString:  "0s",
+			allowedNegative: false,
+		},
+		{
+			in:              "0s",
+			out:             0,
+			expectedString:  "",
+			allowedNegative: false,
+		},
+		{
+			in:              "324ms",
+			out:             324 * time.Millisecond,
+			expectedString:  "",
+			allowedNegative: false,
+		},
+		{
+			in:              "3s",
+			out:             3 * time.Second,
+			expectedString:  "",
+			allowedNegative: false,
+		},
+		{
+			in:              "5m",
+			out:             5 * time.Minute,
+			expectedString:  "",
+			allowedNegative: false,
+		},
+		{
+			in:              "1h",
+			out:             time.Hour,
+			expectedString:  "",
+			allowedNegative: false,
+		},
+		{
+			in:              "4d",
+			out:             4 * 24 * time.Hour,
+			expectedString:  "",
+			allowedNegative: false,
+		},
+		{
+			in:              "4d1h",
+			out:             4*24*time.Hour + time.Hour,
+			expectedString:  "",
+			allowedNegative: false,
+		},
+		{
+			in:              "14d",
+			out:             14 * 24 * time.Hour,
+			expectedString:  "2w",
+			allowedNegative: false,
+		},
+		{
+			in:              "3w",
+			out:             3 * 7 * 24 * time.Hour,
+			expectedString:  "",
+			allowedNegative: false,
+		},
+		{
+			in:              "3w2d1h",
+			out:             3*7*24*time.Hour + 2*24*time.Hour + time.Hour,
+			expectedString:  "23d1h",
+			allowedNegative: false,
+		},
+		{
+			in:              "10y",
+			out:             10 * 365 * 24 * time.Hour,
+			expectedString:  "",
+			allowedNegative: false,
 		},
 	}
 
-	for _, c := range cases {
-		d, err := ParseDuration(c.in)
+	negativeCases := []testCase{
+		{
+			in:              "-3s",
+			out:             -3 * time.Second,
+			expectedString:  "",
+			allowedNegative: true,
+		},
+		{
+			in:              "-5m",
+			out:             -5 * time.Minute,
+			expectedString:  "",
+			allowedNegative: true,
+		},
+		{
+			in:              "-1h",
+			out:             -1 * time.Hour,
+			expectedString:  "",
+			allowedNegative: true,
+		},
+		{
+			in:              "-2d",
+			out:             -2 * 24 * time.Hour,
+			expectedString:  "",
+			allowedNegative: true,
+		},
+		{
+			in:              "-1w",
+			out:             -7 * 24 * time.Hour,
+			expectedString:  "",
+			allowedNegative: true,
+		},
+		{
+			in:              "-3w2d1h",
+			out:             -(3*7*24*time.Hour + 2*24*time.Hour + time.Hour),
+			expectedString:  "-23d1h",
+			allowedNegative: true,
+		},
+		{
+			in:              "-10y",
+			out:             -10 * 365 * 24 * time.Hour,
+			expectedString:  "",
+			allowedNegative: true,
+		},
+	}
+
+	for _, c := range baseCases {
+		c.allowedNegative = true
+		negativeCases = append(negativeCases, c)
+	}
+
+	allCases := append(baseCases, negativeCases...)
+
+	for _, c := range allCases {
+		var (
+			d   Duration
+			err error
+		)
+
+		if c.allowedNegative {
+			d, err = ParseDurationAllowNegative(c.in)
+		} else {
+			d, err = ParseDuration(c.in)
+		}
+
 		if err != nil {
 			t.Errorf("Unexpected error on input %q", c.in)
 		}
+
 		if time.Duration(d) != c.out {
 			t.Errorf("Expected %v but got %v", c.out, d)
 		}
+
 		expectedString := c.expectedString
-		if c.expectedString == "" {
+		if expectedString == "" {
 			expectedString = c.in
 		}
+
 		if d.String() != expectedString {
 			t.Errorf("Expected duration string %q but got %q", c.in, d.String())
 		}
@@ -307,7 +408,6 @@
 	cases := []string{
 		"1",
 		"1y1m1d",
-		"-1w",
 		"1.5d",
 		"d",
 		"294y",
@@ -322,7 +422,6 @@
 		if err == nil {
 			t.Errorf("Expected error on input %s", c)
 		}
-
 	}
 }
 
diff --git a/model/value.go b/model/value.go
index 8050637..a9995a3 100644
--- a/model/value.go
+++ b/model/value.go
@@ -191,7 +191,8 @@
 }
 
 func (ss SampleStream) MarshalJSON() ([]byte, error) {
-	if len(ss.Histograms) > 0 && len(ss.Values) > 0 {
+	switch {
+	case len(ss.Histograms) > 0 && len(ss.Values) > 0:
 		v := struct {
 			Metric     Metric                `json:"metric"`
 			Values     []SamplePair          `json:"values"`
@@ -202,7 +203,7 @@
 			Histograms: ss.Histograms,
 		}
 		return json.Marshal(&v)
-	} else if len(ss.Histograms) > 0 {
+	case len(ss.Histograms) > 0:
 		v := struct {
 			Metric     Metric                `json:"metric"`
 			Histograms []SampleHistogramPair `json:"histograms"`
@@ -211,7 +212,7 @@
 			Histograms: ss.Histograms,
 		}
 		return json.Marshal(&v)
-	} else {
+	default:
 		v := struct {
 			Metric Metric       `json:"metric"`
 			Values []SamplePair `json:"values"`
@@ -258,7 +259,7 @@
 // MarshalJSON implements json.Marshaler.
 func (s Scalar) MarshalJSON() ([]byte, error) {
 	v := strconv.FormatFloat(float64(s.Value), 'f', -1, 64)
-	return json.Marshal([...]interface{}{s.Timestamp, string(v)})
+	return json.Marshal([...]interface{}{s.Timestamp, v})
 }
 
 // UnmarshalJSON implements json.Unmarshaler.
@@ -349,9 +350,9 @@
 func (m Matrix) Less(i, j int) bool { return m[i].Metric.Before(m[j].Metric) }
 func (m Matrix) Swap(i, j int)      { m[i], m[j] = m[j], m[i] }
 
-func (mat Matrix) String() string {
-	matCp := make(Matrix, len(mat))
-	copy(matCp, mat)
+func (m Matrix) String() string {
+	matCp := make(Matrix, len(m))
+	copy(matCp, m)
 	sort.Sort(matCp)
 
 	strs := make([]string, len(matCp))
diff --git a/model/value_histogram.go b/model/value_histogram.go
index 895e6a3..91ce5b7 100644
--- a/model/value_histogram.go
+++ b/model/value_histogram.go
@@ -86,22 +86,22 @@
 	return s == o || (s.Boundaries == o.Boundaries && s.Lower == o.Lower && s.Upper == o.Upper && s.Count == o.Count)
 }
 
-func (b HistogramBucket) String() string {
+func (s HistogramBucket) String() string {
 	var sb strings.Builder
-	lowerInclusive := b.Boundaries == 1 || b.Boundaries == 3
-	upperInclusive := b.Boundaries == 0 || b.Boundaries == 3
+	lowerInclusive := s.Boundaries == 1 || s.Boundaries == 3
+	upperInclusive := s.Boundaries == 0 || s.Boundaries == 3
 	if lowerInclusive {
 		sb.WriteRune('[')
 	} else {
 		sb.WriteRune('(')
 	}
-	fmt.Fprintf(&sb, "%g,%g", b.Lower, b.Upper)
+	fmt.Fprintf(&sb, "%g,%g", s.Lower, s.Upper)
 	if upperInclusive {
 		sb.WriteRune(']')
 	} else {
 		sb.WriteRune(')')
 	}
-	fmt.Fprintf(&sb, ":%v", b.Count)
+	fmt.Fprintf(&sb, ":%v", s.Count)
 	return sb.String()
 }
 
diff --git a/model/value_type.go b/model/value_type.go
index 726c50e..078910f 100644
--- a/model/value_type.go
+++ b/model/value_type.go
@@ -66,8 +66,8 @@
 	return nil
 }
 
-func (e ValueType) String() string {
-	switch e {
+func (et ValueType) String() string {
+	switch et {
 	case ValNone:
 		return "<ValNone>"
 	case ValScalar:
diff --git a/promslog/flag/flag.go b/promslog/flag/flag.go
index 85c67b2..40187cf 100644
--- a/promslog/flag/flag.go
+++ b/promslog/flag/flag.go
@@ -40,7 +40,7 @@
 var FormatFlagHelp = "Output format of log messages. One of: [" + strings.Join(promslog.FormatFlagOptions, ", ") + "]"
 
 // AddFlags adds the flags used by this package to the Kingpin application.
-// To use the default Kingpin application, call AddFlags(kingpin.CommandLine)
+// To use the default Kingpin application, call AddFlags(kingpin.CommandLine).
 func AddFlags(a *kingpin.Application, config *promslog.Config) {
 	config.Level = promslog.NewLevel()
 	a.Flag(LevelFlagName, LevelFlagHelp).
diff --git a/promslog/slog.go b/promslog/slog.go
index f9f8996..f5b9e98 100644
--- a/promslog/slog.go
+++ b/promslog/slog.go
@@ -76,6 +76,11 @@
 	return nil
 }
 
+// Level returns the value of the logging level as an slog.Level.
+func (l *Level) Level() slog.Level {
+	return l.lvl.Level()
+}
+
 // String returns the current level.
 func (l *Level) String() string {
 	switch l.lvl.Level() {
@@ -133,7 +138,7 @@
 	return nil
 }
 
-// Config is a struct containing configurable settings for the logger
+// Config is a struct containing configurable settings for the logger.
 type Config struct {
 	Level  *Level
 	Format *Format
@@ -142,7 +147,7 @@
 }
 
 func newGoKitStyleReplaceAttrFunc(lvl *Level) func(groups []string, a slog.Attr) slog.Attr {
-	return func(groups []string, a slog.Attr) slog.Attr {
+	return func(_ []string, a slog.Attr) slog.Attr {
 		key := a.Key
 		switch key {
 		case slog.TimeKey, "ts":
@@ -192,6 +197,13 @@
 			}
 		default:
 		}
+
+		// Ensure time.Duration values are _always_ formatted as a Go
+		// duration string (ie, "1d2h3m").
+		if v, ok := a.Value.Any().(time.Duration); ok {
+			a.Value = slog.StringValue(v.String())
+		}
+
 		return a
 	}
 }
@@ -200,9 +212,8 @@
 	key := a.Key
 	switch key {
 	case slog.TimeKey:
-		if t, ok := a.Value.Any().(time.Time); ok {
-			a.Value = slog.TimeValue(t.UTC())
-		} else {
+		// Note that we do not change the timezone to UTC anymore.
+		if _, ok := a.Value.Any().(time.Time); !ok {
 			// If we can't cast the any from the value to a
 			// time.Time, it means the caller logged
 			// another attribute with a key of `time`.
@@ -234,6 +245,13 @@
 		}
 	default:
 	}
+
+	// Ensure time.Duration values are _always_ formatted as a Go duration
+	// string (ie, "1d2h3m").
+	if v, ok := a.Value.Any().(time.Duration); ok {
+		a.Value = slog.StringValue(v.String())
+	}
+
 	return a
 }
 
@@ -267,5 +285,5 @@
 // NewNopLogger is a convenience function to return an slog.Logger that writes
 // to io.Discard.
 func NewNopLogger() *slog.Logger {
-	return slog.New(slog.NewTextHandler(io.Discard, nil))
+	return New(&Config{Writer: io.Discard})
 }
diff --git a/promslog/slog_test.go b/promslog/slog_test.go
index ea4e176..0ea1a31 100644
--- a/promslog/slog_test.go
+++ b/promslog/slog_test.go
@@ -21,9 +21,10 @@
 	"regexp"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/require"
-	"gopkg.in/yaml.v2"
+	"go.yaml.in/yaml/v2"
 )
 
 var (
@@ -94,6 +95,33 @@
 	return counters
 }
 
+func TestDurationValues(t *testing.T) {
+	dur, err := time.ParseDuration("1m30s")
+	require.NoError(t, err)
+
+	tests := map[string]struct {
+		logFormat string
+		want      string
+	}{
+		"logfmt_duration_testing": {want: "duration_raw=1m30s duration_string=1m30s", logFormat: "logfmt"},
+		"json_duration_testing":   {want: "\"duration_raw\":\"1m30s\",\"duration_string\":\"1m30s\"", logFormat: "json"},
+	}
+
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			var buf bytes.Buffer
+			config := &Config{
+				Writer: &buf,
+				Format: NewFormat(),
+			}
+			require.NoError(t, config.Format.Set(tc.logFormat))
+			logger := New(config)
+			logger.Info("duration testing", "duration_raw", dur, "duration_string", dur.String())
+			require.Contains(t, buf.String(), tc.want)
+		})
+	}
+}
+
 func TestDynamicLevels(t *testing.T) {
 	var buf bytes.Buffer
 	wantedLevelCounts := map[string]int{"info": 1, "debug": 1}
diff --git a/route/route.go b/route/route.go
index e89fe7e..18039fa 100644
--- a/route/route.go
+++ b/route/route.go
@@ -78,7 +78,7 @@
 		defer cancel()
 
 		for _, p := range params {
-			ctx = context.WithValue(ctx, param(p.Key), p.Value)
+			ctx = context.WithValue(ctx, param(p.Key), p.Value) //nolint:fatcontext
 		}
 		h(w, req.WithContext(ctx))
 	}
diff --git a/route/route_test.go b/route/route_test.go
index 24977b3..e20e1db 100644
--- a/route/route_test.go
+++ b/route/route_test.go
@@ -24,7 +24,7 @@
 func TestRedirect(t *testing.T) {
 	router := New().WithPrefix("/test/prefix")
 	w := httptest.NewRecorder()
-	r, err := http.NewRequest("GET", "http://localhost:9090/foo", nil)
+	r, err := http.NewRequest(http.MethodGet, "http://localhost:9090/foo", nil)
 	require.NoErrorf(t, err, "Error building test request: %s", err)
 
 	router.Redirect(w, r, "/some/endpoint", http.StatusFound)
@@ -37,20 +37,20 @@
 
 func TestContext(t *testing.T) {
 	router := New()
-	router.Get("/test/:foo/", func(w http.ResponseWriter, r *http.Request) {
+	router.Get("/test/:foo/", func(_ http.ResponseWriter, r *http.Request) {
 		want := "bar"
 		got := Param(r.Context(), "foo")
 		require.Equalf(t, want, got, "Unexpected context value: want %q, got %q", want, got)
 	})
 
-	r, err := http.NewRequest("GET", "http://localhost:9090/test/bar/", nil)
+	r, err := http.NewRequest(http.MethodGet, "http://localhost:9090/test/bar/", nil)
 	require.NoErrorf(t, err, "Error building test request: %s", err)
 	router.ServeHTTP(nil, r)
 }
 
 func TestContextWithValue(t *testing.T) {
 	router := New()
-	router.Get("/test/:foo/", func(w http.ResponseWriter, r *http.Request) {
+	router.Get("/test/:foo/", func(_ http.ResponseWriter, r *http.Request) {
 		want := "bar"
 		got := Param(r.Context(), "foo")
 		require.Equalf(t, want, got, "Unexpected context value: want %q, got %q", want, got)
@@ -62,7 +62,7 @@
 		require.Equalf(t, want, got, "Unexpected context value: want %q, got %q", want, got)
 	})
 
-	r, err := http.NewRequest("GET", "http://localhost:9090/test/bar/", nil)
+	r, err := http.NewRequest(http.MethodGet, "http://localhost:9090/test/bar/", nil)
 	require.NoErrorf(t, err, "Error building test request: %s", err)
 	params := map[string]string{
 		"lorem": "ipsum",
@@ -71,7 +71,7 @@
 
 	ctx := r.Context()
 	for p, v := range params {
-		ctx = WithParam(ctx, p, v)
+		ctx = WithParam(ctx, p, v) //nolint:fatcontext
 	}
 	r = r.WithContext(ctx)
 	router.ServeHTTP(nil, r)
@@ -79,13 +79,13 @@
 
 func TestContextWithoutValue(t *testing.T) {
 	router := New()
-	router.Get("/test", func(w http.ResponseWriter, r *http.Request) {
+	router.Get("/test", func(_ http.ResponseWriter, r *http.Request) {
 		want := ""
 		got := Param(r.Context(), "foo")
 		require.Equalf(t, want, got, "Unexpected context value: want %q, got %q", want, got)
 	})
 
-	r, err := http.NewRequest("GET", "http://localhost:9090/test", nil)
+	r, err := http.NewRequest(http.MethodGet, "http://localhost:9090/test", nil)
 	require.NoErrorf(t, err, "Error building test request: %s", err)
 	router.ServeHTTP(nil, r)
 }
@@ -109,9 +109,9 @@
 	}
 
 	for _, c := range cases {
-		c.router.Get("/foo", func(w http.ResponseWriter, r *http.Request) {})
+		c.router.Get("/foo", func(_ http.ResponseWriter, _ *http.Request) {})
 
-		r, err := http.NewRequest("GET", "http://localhost:9090/foo", nil)
+		r, err := http.NewRequest(http.MethodGet, "http://localhost:9090/foo", nil)
 		require.NoErrorf(t, err, "Error building test request: %s", err)
 		c.router.ServeHTTP(nil, r)
 		require.Equalf(t, c.want, got, "Unexpected value: want %q, got %q", c.want, got)
@@ -149,12 +149,12 @@
 	}
 
 	for _, c := range cases {
-		c.router.Get("/foo", func(w http.ResponseWriter, r *http.Request) {})
+		c.router.Get("/foo", func(_ http.ResponseWriter, _ *http.Request) {})
 
-		r, err := http.NewRequest("GET", "http://localhost:9090/foo", nil)
+		r, err := http.NewRequest(http.MethodGet, "http://localhost:9090/foo", nil)
 		require.NoErrorf(t, err, "Error building test request: %s", err)
 		c.router.ServeHTTP(nil, r)
-		require.Equalf(t, len(c.want), len(got), "Unexpected value: want %q, got %q", c.want, got)
+		require.Lenf(t, got, len(c.want), "Unexpected value: want %q, got %q", c.want, got)
 		for i, v := range c.want {
 			require.Equalf(t, v, got[i], "Unexpected value: want %q, got %q", c.want, got)
 		}
diff --git a/server/static_file_server_test.go b/server/static_file_server_test.go
index 9aa8d76..ae8bf59 100644
--- a/server/static_file_server_test.go
+++ b/server/static_file_server_test.go
@@ -23,7 +23,7 @@
 
 type dummyFileSystem struct{}
 
-func (fs dummyFileSystem) Open(path string) (http.File, error) {
+func (dummyFileSystem) Open(string) (http.File, error) {
 	return http.Dir(".").Open(".")
 }
 
@@ -68,7 +68,7 @@
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
 			rr := httptest.NewRecorder()
-			req, err := http.NewRequest("GET", "http://localhost/"+c.path, nil)
+			req, err := http.NewRequest(http.MethodGet, "http://localhost/"+c.path, nil)
 			require.NoError(t, err)
 
 			s := StaticFileServer(dummyFileSystem{})