Repository: prometheus/common
Pull request #570 (2024-01-18 16:11:21)

UTF8: Content negotiation (encoding side)
This PR builds on https://github.com/prometheus/common/pull/537 by adding content negotation during scraping.

Scrapers can announce that they support utf-8 names by adding "validchars=utf8" in the Content-Type header. In cases where utf8 is not available, metric providers can be configured to escape names in a few different ways: values (U__ utf value escaping for perfect round-tripping), underscores (all invalid chars become _), dots (dots become _dot_, _ becomes __, all other values become __). Escaping can either be a global default (model.NameEscapingScheme) or can also be specific in Content-Type with the "escaping=" term, which can be "none" (for UTF8-compatible), "underscores", "dots", or "values".

This is still a noop for existing configurations because scrapers will not be passing the validchars or escaping key in the Content-Type header.  Existing functionality is maintained.


ywwg: This will be squashed :)
beorn7: Another thought: Maybe we don't need to change version numbers at all right now. Any escaping that is not `none` will produce valid classic text format or OMv1. So we could just document the `escaping=none` (currently `validchars=utf8`, see comment above) as an experimental feature that creates a slightly different format, that will be part of a future standard. We can later decide if we want to keep supporting the "illegal" combination of OMv1 and `escaping=none`, but that's still better than asking clients to negotiate OMv2 although OMv2 will certainly look much differently once it really exists.
ywwg: I like the idea of using escaping to also select for utf8 mode. maybe instead of "none" we call it `escaping=utf8-ok` or something like that, to make it more explicit?
ywwg: can't remember if dashes are allowed in content-type parameters though
beorn7: Yes, let's do that. I.e. only one parameter `escaping` that defines the behavior (missing = old behavior, any escaping schema = do the escaping, `allow-utf-8` or something = use the new format).

I would also say, for now, that we don't change the version numbers but "tolerate" that the client can ask for `allow-utf-8` and we pragmatically create an exposition format that is technically invalid. Just add a comment in the code that says that this is just for experimentation and that there will be new versions of the expositions formats eventually that properly describe the new syntax and should be selected whenever UTF-8 names are requested.

And yes, dashes are allowed in the content type, we use it for `charset=utf-8` and `encoding=compact-text`.

The former reminds me that we should try to be consistent and spell UTF-8 as UTF-8 where possible.
beorn7: The Go test errors seem to have to do with Go modules. Probably need to run `go mod tidy` or something.

Could you fix that and work in the ideas above? I think the code otherwise is technically fine. I'll do a final review once that's done.
ywwg: oh except for adding comments

Patch:
diff --git a/config/generate.go b/config/generate.go
index dbcf9be..0033dd7 100644
--- a/config/generate.go
+++ b/config/generate.go
@@ -71,6 +71,7 @@ func SerialNumber() *big.Int {
 	serialNumber.Add(&serial, big.NewInt(1))
 
 	return &serial
+
 }
 
 func GenerateCertificateAuthority(commonName string, parentCert *x509.Certificate, parentKey *rsa.PrivateKey) (*x509.Certificate, *rsa.PrivateKey, error) {
@@ -169,7 +170,7 @@ func writeCertificateAndKey(path string, cert *x509.Certificate, key *rsa.Privat
 		return err
 	}
 
-	if err := os.WriteFile(fmt.Sprintf("%s.crt", path), b.Bytes(), 0o644); err != nil {
+	if err := os.WriteFile(fmt.Sprintf("%s.crt", path), b.Bytes(), 0644); err != nil {
 		return err
 	}
 
@@ -178,7 +179,7 @@ func writeCertificateAndKey(path string, cert *x509.Certificate, key *rsa.Privat
 		return err
 	}
 
-	if err := os.WriteFile(fmt.Sprintf("%s.key", path), b.Bytes(), 0o644); err != nil {
+	if err := os.WriteFile(fmt.Sprintf("%s.key", path), b.Bytes(), 0644); err != nil {
 		return err
 	}
 
@@ -238,7 +239,7 @@ func main() {
 		log.Fatal(err)
 	}
 
-	if err := os.WriteFile("testdata/tls-ca-chain.pem", b.Bytes(), 0o644); err != nil {
+	if err := os.WriteFile("testdata/tls-ca-chain.pem", b.Bytes(), 0644); err != nil {
 		log.Fatal(err)
 	}
 }
diff --git a/config/http_config.go b/config/http_config.go
index f295e91..7a67a0a 100644
--- a/config/http_config.go
+++ b/config/http_config.go
@@ -378,6 +378,9 @@ func (c *HTTPClientConfig) Validate() error {
 		if len(c.OAuth2.ClientID) == 0 {
 			return fmt.Errorf("oauth2 client_id must be configured")
 		}
+		if len(c.OAuth2.ClientSecret) == 0 && len(c.OAuth2.ClientSecretFile) == 0 {
+			return fmt.Errorf("either oauth2 client_secret or client_secret_file must be configured")
+		}
 		if len(c.OAuth2.TokenURL) == 0 {
 			return fmt.Errorf("oauth2 token_url must be configured")
 		}
@@ -726,12 +729,13 @@ func (rt *oauth2RoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
 		rt.mtx.RLock()
 		changed = secret != rt.secret
 		rt.mtx.RUnlock()
-	} else {
-		// Either an inline secret or nothing (use an empty string) was provided.
-		secret = string(rt.config.ClientSecret)
 	}
 
 	if changed || rt.rt == nil {
+		if rt.config.ClientSecret != "" {
+			secret = string(rt.config.ClientSecret)
+		}
+
 		config := &clientcredentials.Config{
 			ClientID:       rt.config.ClientID,
 			ClientSecret:   secret,
diff --git a/expfmt/decode.go b/expfmt/decode.go
index a909b17..3597bfd 100644
--- a/expfmt/decode.go
+++ b/expfmt/decode.go
@@ -73,8 +73,8 @@ func ResponseFormat(h http.Header) Format {
 // 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.
 func NewDecoder(r io.Reader, format Format) Decoder {
-	switch format {
-	case FmtProtoDelim:
+	switch format.FormatType() {
+	case TypeProtoDelim:
 		return &protoDecoder{r: r}
 	}
 	return &textDecoder{r: r}
diff --git a/expfmt/encode.go b/expfmt/encode.go
index 02b7a5e..97ee673 100644
--- a/expfmt/encode.go
+++ b/expfmt/encode.go
@@ -22,6 +22,7 @@ import (
 	"google.golang.org/protobuf/encoding/prototext"
 
 	"github.com/prometheus/common/internal/bitbucket.org/ww/goautoneg"
+	"github.com/prometheus/common/model"
 
 	dto "github.com/prometheus/client_model/go"
 )
@@ -61,23 +62,32 @@ func (ec encoderCloser) Close() error {
 // as the support is still experimental. To include the option to negotiate
 // FmtOpenMetrics, use NegotiateOpenMetrics.
 func Negotiate(h http.Header) Format {
+	escapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String())))
 	for _, ac := range goautoneg.ParseAccept(h.Get(hdrAccept)) {
+		if escapeParam := ac.Params[model.EscapingKey]; escapeParam != "" {
+			switch Format(escapeParam) {
+			case model.AllowUTF8, model.EscapeUnderscores, model.EscapeDots, model.EscapeValues:
+				escapingScheme = Format(fmt.Sprintf("; escaping=%s", escapeParam))
+			default:
+				// If the escaping parameter is unknown, ignore it.
+			}
+		}
 		ver := ac.Params["version"]
 		if ac.Type+"/"+ac.SubType == ProtoType && ac.Params["proto"] == ProtoProtocol {
 			switch ac.Params["encoding"] {
 			case "delimited":
-				return FmtProtoDelim
+				return FmtProtoDelim + escapingScheme
 			case "text":
-				return FmtProtoText
+				return FmtProtoText + escapingScheme
 			case "compact-text":
-				return FmtProtoCompact
+				return FmtProtoCompact + escapingScheme
 			}
 		}
 		if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") {
-			return FmtText
+			return FmtText + escapingScheme
 		}
 	}
-	return FmtText
+	return FmtText + escapingScheme
 }
 
 // NegotiateIncludingOpenMetrics works like Negotiate but includes
@@ -85,29 +95,40 @@ func Negotiate(h http.Header) Format {
 // temporary and will disappear once FmtOpenMetrics is fully supported and as
 // such may be negotiated by the normal Negotiate function.
 func NegotiateIncludingOpenMetrics(h http.Header) Format {
+	escapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String())))
 	for _, ac := range goautoneg.ParseAccept(h.Get(hdrAccept)) {
+		if escapeParam := ac.Params[model.EscapingKey]; escapeParam != "" {
+			switch Format(escapeParam) {
+			case model.AllowUTF8, model.EscapeUnderscores, model.EscapeDots, model.EscapeValues:
+				escapingScheme = Format(fmt.Sprintf("; escaping=%s", escapeParam))
+			default:
+				// If the escaping parameter is unknown, ignore it.
+			}
+		}
 		ver := ac.Params["version"]
 		if ac.Type+"/"+ac.SubType == ProtoType && ac.Params["proto"] == ProtoProtocol {
 			switch ac.Params["encoding"] {
 			case "delimited":
-				return FmtProtoDelim
+				return FmtProtoDelim + escapingScheme
 			case "text":
-				return FmtProtoText
+				return FmtProtoText + escapingScheme
 			case "compact-text":
-				return FmtProtoCompact
+				return FmtProtoCompact + escapingScheme
 			}
 		}
 		if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") {
-			return FmtText
+			return FmtText + escapingScheme
 		}
 		if ac.Type+"/"+ac.SubType == OpenMetricsType && (ver == OpenMetricsVersion_0_0_1 || ver == OpenMetricsVersion_1_0_0 || ver == "") {
-			if ver == OpenMetricsVersion_1_0_0 {
-				return FmtOpenMetrics_1_0_0
+			switch ver {
+			case OpenMetricsVersion_1_0_0:
+				return FmtOpenMetrics_1_0_0 + escapingScheme
+			default:
+				return FmtOpenMetrics_0_0_1 + escapingScheme
 			}
-			return FmtOpenMetrics_0_0_1
 		}
 	}
-	return FmtText
+	return FmtText + escapingScheme
 }
 
 // NewEncoder returns a new encoder based on content type negotiation. All
@@ -116,9 +137,13 @@ func NegotiateIncludingOpenMetrics(h http.Header) Format {
 // for FmtOpenMetrics, but a future (breaking) release will add the Close method
 // to the Encoder interface directly. The current version of the Encoder
 // interface is kept for backwards compatibility.
+// In cases where the Format does not allow for UTF-8 names, the global
+// NameEscapingScheme will be applied.
 func NewEncoder(w io.Writer, format Format) Encoder {
-	switch format {
-	case FmtProtoDelim:
+	escapingScheme := format.ToEscapingScheme()
+
+	switch format.FormatType() {
+	case TypeProtoDelim:
 		return encoderCloser{
 			encode: func(v *dto.MetricFamily) error {
 				_, err := protodelim.MarshalTo(w, v)
@@ -126,34 +151,34 @@ func NewEncoder(w io.Writer, format Format) Encoder {
 			},
 			close: func() error { return nil },
 		}
-	case FmtProtoCompact:
+	case TypeProtoCompact:
 		return encoderCloser{
 			encode: func(v *dto.MetricFamily) error {
-				_, err := fmt.Fprintln(w, v.String())
+				_, err := fmt.Fprintln(w, model.EscapeMetricFamily(v, escapingScheme).String())
 				return err
 			},
 			close: func() error { return nil },
 		}
-	case FmtProtoText:
+	case TypeProtoText:
 		return encoderCloser{
 			encode: func(v *dto.MetricFamily) error {
-				_, err := fmt.Fprintln(w, prototext.Format(v))
+				_, err := fmt.Fprintln(w, prototext.Format(model.EscapeMetricFamily(v, escapingScheme)))
 				return err
 			},
 			close: func() error { return nil },
 		}
-	case FmtText:
+	case TypeTextPlain:
 		return encoderCloser{
 			encode: func(v *dto.MetricFamily) error {
-				_, err := MetricFamilyToText(w, v)
+				_, err := MetricFamilyToText(w, model.EscapeMetricFamily(v, escapingScheme))
 				return err
 			},
 			close: func() error { return nil },
 		}
-	case FmtOpenMetrics_0_0_1, FmtOpenMetrics_1_0_0:
+	case TypeOpenMetrics:
 		return encoderCloser{
 			encode: func(v *dto.MetricFamily) error {
-				_, err := MetricFamilyToOpenMetrics(w, v)
+				_, err := MetricFamilyToOpenMetrics(w, model.EscapeMetricFamily(v, escapingScheme))
 				return err
 			},
 			close: func() error {
diff --git a/expfmt/expfmt.go b/expfmt/expfmt.go
index d866b47..f9b6f70 100644
--- a/expfmt/expfmt.go
+++ b/expfmt/expfmt.go
@@ -14,6 +14,12 @@
 // Package expfmt contains tools for reading and writing Prometheus metrics.
 package expfmt
 
+import (
+	"strings"
+
+	"github.com/prometheus/common/model"
+)
+
 // Format specifies the HTTP content type of the different wire protocols.
 type Format string
 
@@ -33,7 +39,8 @@ const (
 	OpenMetricsVersion_0_0_1 = "0.0.1"
 	OpenMetricsVersion_1_0_0 = "1.0.0"
 
-	// The Content-Type values for the different wire protocols.
+	// The Content-Type values for the different wire protocols. Do not do direct
+	// comparisons to these constants, instead use the comparison functions.
 	FmtUnknown           Format = `<unknown>`
 	FmtText              Format = `text/plain; version=` + TextVersion + `; charset=utf-8`
 	FmtProtoDelim        Format = ProtoFmt + ` encoding=delimited`
@@ -47,3 +54,93 @@ const (
 	hdrContentType = "Content-Type"
 	hdrAccept      = "Accept"
 )
+
+// FormatType is a Go enum representing the overall category for the given
+// Format. As the number of Format permutations increases, doing basic string
+// comparisons are not feasible, so this enum captures the most useful
+// high-level attribute of the Format string.
+type FormatType int
+
+const (
+	TypeUnknown = iota
+	TypeProtoCompact
+	TypeProtoDelim
+	TypeProtoText
+	TypeTextPlain
+	TypeOpenMetrics
+)
+
+// FormatType deduces an overall FormatType for the given format.
+func (f Format) FormatType() FormatType {
+	toks := strings.Split(string(f), ";")
+	if len(toks) < 2 {
+		return TypeUnknown
+	}
+
+	params := make(map[string]string)
+	for i, t := range toks {
+		if i == 0 {
+			continue
+		}
+		args := strings.Split(t, "=")
+		if len(args) != 2 {
+			continue
+		}
+		params[strings.TrimSpace(args[0])] = strings.TrimSpace(args[1])
+	}
+
+	switch strings.TrimSpace(toks[0]) {
+	case ProtoType:
+		if params["proto"] != ProtoProtocol {
+			return TypeUnknown
+		}
+		switch params["encoding"] {
+		case "delimited":
+			return TypeProtoDelim
+		case "text":
+			return TypeProtoText
+		case "compact-text":
+			return TypeProtoCompact
+		default:
+			return TypeUnknown
+		}
+	case OpenMetricsType:
+		if params["charset"] != "utf-8" {
+			return TypeUnknown
+		}
+		return TypeOpenMetrics
+	case "text/plain":
+		v, ok := params["version"]
+		if !ok {
+			return TypeTextPlain
+		}
+		if v == TextVersion {
+			return TypeTextPlain
+		}
+		return TypeUnknown
+	default:
+		return TypeUnknown
+	}
+}
+
+// ToEscapingScheme returns an EscapingScheme depending on the Format. Iff the
+// 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), ";") {
+		toks := strings.Split(p, "=")
+		if len(toks) != 2 {
+			continue
+		}
+		key, value := strings.TrimSpace(toks[0]), strings.TrimSpace(toks[1])
+		if key == model.EscapingKey {
+			scheme, err := model.ToEscapingScheme(value)
+			if err != nil {
+				return model.NameEscapingScheme
+			}
+			return scheme
+		}
+	}
+	return model.NameEscapingScheme
+}
diff --git a/go.mod b/go.mod
index 06abd44..95321f7 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.20
 require (
 	github.com/alecthomas/kingpin/v2 v2.4.0
 	github.com/go-kit/log v0.2.1
+	github.com/google/go-cmp v0.6.0
 	github.com/julienschmidt/httprouter v1.3.0
 	github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f
 	github.com/prometheus/client_golang v1.18.0
diff --git a/go.sum b/go.sum
index 0a1ce92..c38b5a5 100644
--- a/go.sum
+++ b/go.sum
@@ -17,7 +17,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+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/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=
diff --git a/model/labels.go b/model/labels.go
index 73dafe4..3317ce2 100644
--- a/model/labels.go
+++ b/model/labels.go
@@ -172,7 +172,7 @@ func (l LabelNames) String() string {
 // A LabelValue is an associated value for a LabelName.
 type LabelValue string
 
-// IsValid returns true iff the string is a valid UTF8.
+// IsValid returns true iff the string is a valid UTF-8.
 func (lv LabelValue) IsValid() bool {
 	return utf8.ValidString(string(lv))
 }
diff --git a/model/metric.go b/model/metric.go
index 9aa0b51..0bd29b3 100644
--- a/model/metric.go
+++ b/model/metric.go
@@ -19,6 +19,25 @@ import (
 	"sort"
 	"strings"
 	"unicode/utf8"
+
+	dto "github.com/prometheus/client_model/go"
+	"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 is intended to be set by
+	// UTF-8-aware binaries as part of their startup. To avoid need for locking,
+	// this value should be set once, ideally in an init(), before multiple
+	// goroutines are started.
+	NameValidationScheme = LegacyValidation
+
+	// NameEscapingScheme defines the default way that names will be
+	// escaped when presented to systems that do not support UTF-8 names. If the
+	// Content-Type "escaping" term is specified, that will override this value.
+	NameEscapingScheme = ValueEncodingEscaping
 )
 
 // ValidationScheme is a Go enum for determining how metric and label names will
@@ -31,27 +50,52 @@ const (
 	// MetricNameRE and LabelNameRE.
 	LegacyValidation ValidationScheme = iota
 
-	// UTF8Validation only requires that metric and label names be valid UTF8
+	// UTF8Validation only requires that metric and label names be valid UTF-8
 	// strings.
 	UTF8Validation
 )
 
-var (
-	// NameValidationScheme determines the method of name validation to be used by
-	// all calls to IsValidMetricName() and LabelName IsValid(). Setting UTF8 mode
-	// in isolation from other components that don't support UTF8 may result in
-	// bugs or other undefined behavior. This value is intended to be set by
-	// UTF8-aware binaries as part of their startup. To avoid need for locking,
-	// this value should be set once, ideally in an init(), before multiple
-	// goroutines are started.
-	NameValidationScheme = LegacyValidation
+type EscapingScheme int
+
+const (
+	// NoEscaping indicates that a name will not be escaped. Unescaped names that
+	// do not conform to the legacy validity check will use a new exposition
+	// format syntax that will be officially standardized in future versions.
+	NoEscaping EscapingScheme = iota
+
+	// UnderscoreEscaping replaces all legacy-invalid characters with underscores.
+	UnderscoreEscaping
+
+	// DotsEscaping is similar to UnderscoreEscaping, except that dots are
+	// converted to `_dot_` and pre-existing underscores are converted to `__`.
+	DotsEscaping
 
-	// MetricNameRE is a regular expression matching valid metric
-	// names. Note that the IsValidMetricName function performs the same
-	// check but faster than a match with this regular expression.
-	MetricNameRE = regexp.MustCompile(`^[a-zA-Z_:][a-zA-Z0-9_:]*$`)
+	// ValueEncodingEscaping prepends the name with `U__` and replaces all invalid
+	// characters with the unicode value, surrounded by underscores. Single
+	// underscores are replaced with double underscores.
+	ValueEncodingEscaping
 )
 
+const (
+	// EscapingKey is the key in an Accept or Content-Type header that defines how
+	// metric and label names that do not conform to the legacy character
+	// requirements should be escaped when being scraped by a legacy prometheus
+	// system. If a system does not explicitly pass an escaping parameter in the
+	// Accept header, the default NameEscapingScheme will be used.
+	EscapingKey = "escaping"
+
+	// Possible values for Escaping Key:
+	AllowUTF8         = "allow-utf-8" // No escaping required.
+	EscapeUnderscores = "underscores"
+	EscapeDots        = "dots"
+	EscapeValues      = "values"
+)
+
+// MetricNameRE is a regular expression matching valid metric
+// names. Note that the IsValidMetricName function performs the same
+// check but faster than a match with this regular expression.
+var MetricNameRE = regexp.MustCompile(`^[a-zA-Z_:][a-zA-Z0-9_:]*$`)
+
 // A Metric is similar to a LabelSet, but the key difference is that a Metric is
 // a singleton and refers to one and only one stream of samples.
 type Metric LabelSet
@@ -137,9 +181,276 @@ func IsValidLegacyMetricName(n LabelValue) bool {
 		return false
 	}
 	for i, b := range n {
-		if !((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || b == ':' || (b >= '0' && b <= '9' && i > 0)) {
+		if !isValidLegacyRune(b, i) {
 			return false
 		}
 	}
 	return true
 }
+
+// EscapeMetricFamily escapes the given metric names and labels with the given
+// escaping scheme. Returns a new object that uses the same pointers to fields
+// when possible and creates new escaped versions so as not to mutate the
+// input.
+func EscapeMetricFamily(v *dto.MetricFamily, scheme EscapingScheme) *dto.MetricFamily {
+	if v == nil {
+		return nil
+	}
+
+	if scheme == NoEscaping {
+		return v
+	}
+
+	out := &dto.MetricFamily{
+		Help: v.Help,
+		Type: v.Type,
+	}
+
+	// If the name is nil, copy as-is, don't try to escape.
+	if v.Name == nil || IsValidLegacyMetricName(LabelValue(v.GetName())) {
+		out.Name = v.Name
+	} else {
+		out.Name = proto.String(EscapeName(v.GetName(), scheme))
+	}
+	for _, m := range v.Metric {
+		if !metricNeedsEscaping(m) {
+			out.Metric = append(out.Metric, m)
+			continue
+		}
+
+		escaped := &dto.Metric{
+			Gauge:       m.Gauge,
+			Counter:     m.Counter,
+			Summary:     m.Summary,
+			Untyped:     m.Untyped,
+			Histogram:   m.Histogram,
+			TimestampMs: m.TimestampMs,
+		}
+
+		for _, l := range m.Label {
+			if l.GetName() == MetricNameLabel {
+				if l.Value == nil || IsValidLegacyMetricName(LabelValue(l.GetValue())) {
+					escaped.Label = append(escaped.Label, l)
+					continue
+				}
+				escaped.Label = append(escaped.Label, &dto.LabelPair{
+					Name:  proto.String(MetricNameLabel),
+					Value: proto.String(EscapeName(l.GetValue(), scheme)),
+				})
+				continue
+			}
+			if l.Name == nil || IsValidLegacyMetricName(LabelValue(l.GetName())) {
+				escaped.Label = append(escaped.Label, l)
+				continue
+			}
+			escaped.Label = append(escaped.Label, &dto.LabelPair{
+				Name:  proto.String(EscapeName(l.GetName(), scheme)),
+				Value: l.Value,
+			})
+		}
+		out.Metric = append(out.Metric, escaped)
+	}
+	return out
+}
+
+func metricNeedsEscaping(m *dto.Metric) bool {
+	for _, l := range m.Label {
+		if l.GetName() == MetricNameLabel && !IsValidLegacyMetricName(LabelValue(l.GetValue())) {
+			return true
+		}
+		if !IsValidLegacyMetricName(LabelValue(l.GetName())) {
+			return true
+		}
+	}
+	return false
+}
+
+const (
+	lowerhex = "0123456789abcdef"
+)
+
+// EscapeName escapes the incoming name according to the provided escaping
+// scheme. Depending on the rules of escaping, this may cause no change in the
+// string that is returned. (Especially NoEscaping, which by definition is a
+// noop). This function does not do any validation of the name.
+func EscapeName(name string, scheme EscapingScheme) string {
+	if len(name) == 0 {
+		return name
+	}
+	var escaped strings.Builder
+	switch scheme {
+	case NoEscaping:
+		return name
+	case UnderscoreEscaping:
+		if IsValidLegacyMetricName(LabelValue(name)) {
+			return name
+		}
+		for i, b := range name {
+			if isValidLegacyRune(b, i) {
+				escaped.WriteRune(b)
+			} else {
+				escaped.WriteRune('_')
+			}
+		}
+		return escaped.String()
+	case DotsEscaping:
+		// Do not early return for legacy valid names, we still escape underscores.
+		for i, b := range name {
+			if b == '_' {
+				escaped.WriteString("__")
+			} else if b == '.' {
+				escaped.WriteString("_dot_")
+			} else if isValidLegacyRune(b, i) {
+				escaped.WriteRune(b)
+			} else {
+				escaped.WriteRune('_')
+			}
+		}
+		return escaped.String()
+	case ValueEncodingEscaping:
+		if IsValidLegacyMetricName(LabelValue(name)) {
+			return name
+		}
+		escaped.WriteString("U__")
+		for i, b := range name {
+			if isValidLegacyRune(b, i) {
+				escaped.WriteRune(b)
+			} else if !utf8.ValidRune(b) {
+				escaped.WriteString("_FFFD_")
+			} else if b < 0x100 {
+				escaped.WriteRune('_')
+				for s := 4; s >= 0; s -= 4 {
+					escaped.WriteByte(lowerhex[b>>uint(s)&0xF])
+				}
+				escaped.WriteRune('_')
+			} else if b < 0x10000 {
+				escaped.WriteRune('_')
+				for s := 12; s >= 0; s -= 4 {
+					escaped.WriteByte(lowerhex[b>>uint(s)&0xF])
+				}
+				escaped.WriteRune('_')
+			}
+		}
+		return escaped.String()
+	default:
+		panic(fmt.Sprintf("invalid escaping scheme %d", scheme))
+	}
+}
+
+// lower function taken from strconv.atoi
+func lower(c byte) byte {
+	return c | ('x' - 'X')
+}
+
+// UnescapeName unescapes the incoming name according to the provided escaping
+// scheme if possible. Some schemes are partially or totally non-roundtripable.
+// If any error is enountered, returns the original input.
+func UnescapeName(name string, scheme EscapingScheme) string {
+	if len(name) == 0 {
+		return name
+	}
+	switch scheme {
+	case NoEscaping:
+		return name
+	case UnderscoreEscaping:
+		// It is not possible to unescape from underscore replacement.
+		return name
+	case DotsEscaping:
+		name = strings.ReplaceAll(name, "_dot_", ".")
+		name = strings.ReplaceAll(name, "__", "_")
+		return name
+	case ValueEncodingEscaping:
+		escapedName, found := strings.CutPrefix(name, "U__")
+		if !found {
+			return name
+		}
+
+		var unescaped strings.Builder
+	TOP:
+		for i := 0; i < len(escapedName); i++ {
+			// All non-underscores are treated normally.
+			if escapedName[i] != '_' {
+				unescaped.WriteByte(escapedName[i])
+				continue
+			}
+			i++
+			if i >= len(escapedName) {
+				return name
+			}
+			// A double underscore is a single underscore.
+			if escapedName[i] == '_' {
+				unescaped.WriteByte('_')
+				continue
+			}
+			// We think we are in a UTF-8 code, process it.
+			var utf8Val uint
+			for j := 0; i < len(escapedName); j++ {
+				// This is too many characters for a utf8 value.
+				if j > 4 {
+					return name
+				}
+				// Found a closing underscore, convert to a rune, check validity, and append.
+				if escapedName[i] == '_' {
+					utf8Rune := rune(utf8Val)
+					if !utf8.ValidRune(utf8Rune) {
+						return name
+					}
+					unescaped.WriteRune(utf8Rune)
+					continue TOP
+				}
+				r := lower(escapedName[i])
+				utf8Val *= 16
+				if r >= '0' && r <= '9' {
+					utf8Val += uint(r) - '0'
+				} else if r >= 'a' && r <= 'f' {
+					utf8Val += uint(r) - 'a' + 10
+				} else {
+					return name
+				}
+				i++
+			}
+			// Didn't find closing underscore, invalid.
+			return name
+		}
+		return unescaped.String()
+	default:
+		panic(fmt.Sprintf("invalid escaping scheme %d", scheme))
+	}
+}
+
+func isValidLegacyRune(b rune, i int) bool {
+	return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || b == ':' || (b >= '0' && b <= '9' && i > 0)
+}
+
+func (e EscapingScheme) String() string {
+	switch e {
+	case NoEscaping:
+		return AllowUTF8
+	case UnderscoreEscaping:
+		return EscapeUnderscores
+	case DotsEscaping:
+		return EscapeDots
+	case ValueEncodingEscaping:
+		return EscapeValues
+	default:
+		panic(fmt.Sprintf("unknown format scheme %d", e))
+	}
+}
+
+func ToEscapingScheme(s string) (EscapingScheme, error) {
+	if s == "" {
+		return NoEscaping, fmt.Errorf("got empty string instead of escaping scheme")
+	}
+	switch s {
+	case AllowUTF8:
+		return NoEscaping, nil
+	case EscapeUnderscores:
+		return UnderscoreEscaping, nil
+	case EscapeDots:
+		return DotsEscaping, nil
+	case EscapeValues:
+		return ValueEncodingEscaping, nil
+	default:
+		return NoEscaping, fmt.Errorf("unknown format scheme " + s)
+	}
+}
diff --git a/sigv4/go.sum b/sigv4/go.sum
index 6fe5c92..974a22d 100644
--- a/sigv4/go.sum
+++ b/sigv4/go.sum
@@ -13,7 +13,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=


Test patch:
diff --git a/config/http_config_test.go b/config/http_config_test.go
index b0d3939..7eeedfe 100644
--- a/config/http_config_test.go
+++ b/config/http_config_test.go
@@ -115,6 +115,10 @@ var invalidHTTPClientConfigs = []struct {
 		httpClientConfigFile: "testdata/http.conf.oauth2-no-client-id.bad.yaml",
 		errMsg:               "oauth2 client_id must be configured",
 	},
+	{
+		httpClientConfigFile: "testdata/http.conf.oauth2-no-client-secret.bad.yaml",
+		errMsg:               "either oauth2 client_secret or client_secret_file must be configured",
+	},
 	{
 		httpClientConfigFile: "testdata/http.conf.oauth2-no-token-url.bad.yaml",
 		errMsg:               "oauth2 token_url must be configured",
@@ -419,46 +423,6 @@ func TestNewClientFromConfig(t *testing.T) {
 				}
 			},
 		},
-		{
-			clientConfig: HTTPClientConfig{
-				OAuth2: &OAuth2{
-					ClientID: "ExpectedUsername",
-					TLSConfig: TLSConfig{
-						CAFile:             TLSCAChainPath,
-						CertFile:           ClientCertificatePath,
-						KeyFile:            ClientKeyNoPassPath,
-						ServerName:         "",
-						InsecureSkipVerify: false,
-					},
-				},
-				TLSConfig: TLSConfig{
-					CAFile:             TLSCAChainPath,
-					CertFile:           ClientCertificatePath,
-					KeyFile:            ClientKeyNoPassPath,
-					ServerName:         "",
-					InsecureSkipVerify: false,
-				},
-			},
-			handler: func(w http.ResponseWriter, r *http.Request) {
-				switch r.URL.Path {
-				case "/token":
-					res, _ := json.Marshal(oauth2TestServerResponse{
-						AccessToken: ExpectedAccessToken,
-						TokenType:   "Bearer",
-					})
-					w.Header().Add("Content-Type", "application/json")
-					_, _ = w.Write(res)
-
-				default:
-					authorization := r.Header.Get("Authorization")
-					if authorization != "Bearer "+ExpectedAccessToken {
-						fmt.Fprintf(w, "Expected Authorization header %q, got %q", "Bearer "+ExpectedAccessToken, authorization)
-					} else {
-						fmt.Fprint(w, ExpectedMessage)
-					}
-				}
-			},
-		},
 		{
 			clientConfig: HTTPClientConfig{
 				OAuth2: &OAuth2{
@@ -1484,23 +1448,8 @@ type oauth2TestServerResponse struct {
 	TokenType   string `json:"token_type"`
 }
 
-type testOAuthServer struct {
-	tokenTS *httptest.Server
-	ts      *httptest.Server
-}
-
-// newTestOAuthServer returns a new test server with the expected base64 encoded client ID and secret.
-func newTestOAuthServer(t testing.TB, expectedAuth *string) testOAuthServer {
-	var previousAuth string
-	tokenTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		auth := r.Header.Get("Authorization")
-		if auth != *expectedAuth {
-			t.Fatalf("bad auth, expected %s, got %s", *expectedAuth, auth)
-		}
-		if auth == previousAuth {
-			t.Fatal("token endpoint called twice")
-		}
-		previousAuth = auth
+func TestOAuth2(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		res, _ := json.Marshal(oauth2TestServerResponse{
 			AccessToken: "12345",
 			TokenType:   "Bearer",
@@ -1508,36 +1457,7 @@ func newTestOAuthServer(t testing.TB, expectedAuth *string) testOAuthServer {
 		w.Header().Add("Content-Type", "application/json")
 		_, _ = w.Write(res)
 	}))
-	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		auth := r.Header.Get("Authorization")
-		if auth != "Bearer 12345" {
-			t.Fatalf("bad auth, expected %s, got %s", "Bearer 12345", auth)
-		}
-		fmt.Fprintln(w, "Hello, client")
-	}))
-	return testOAuthServer{
-		tokenTS: tokenTS,
-		ts:      ts,
-	}
-}
-
-func (s *testOAuthServer) url() string {
-	return s.ts.URL
-}
-
-func (s *testOAuthServer) tokenURL() string {
-	return s.tokenTS.URL
-}
-
-func (s *testOAuthServer) close() {
-	s.tokenTS.Close()
-	s.ts.Close()
-}
-
-func TestOAuth2(t *testing.T) {
-	var expectedAuth string
-	ts := newTestOAuthServer(t, &expectedAuth)
-	defer ts.close()
+	defer ts.Close()
 
 	yamlConfig := fmt.Sprintf(`
 client_id: 1
@@ -1545,20 +1465,21 @@ client_secret: 2
 scopes:
  - A
  - B
-token_url: %s
+token_url: %s/token
 endpoint_params:
  hi: hello
-`, ts.tokenURL())
+`, ts.URL)
 	expectedConfig := OAuth2{
 		ClientID:       "1",
 		ClientSecret:   "2",
 		Scopes:         []string{"A", "B"},
 		EndpointParams: map[string]string{"hi": "hello"},
-		TokenURL:       ts.tokenURL(),
+		TokenURL:       fmt.Sprintf("%s/token", ts.URL),
 	}
 
 	var unmarshalledConfig OAuth2
-	if err := yaml.Unmarshal([]byte(yamlConfig), &unmarshalledConfig); err != nil {
+	err := yaml.Unmarshal([]byte(yamlConfig), &unmarshalledConfig)
+	if err != nil {
 		t.Fatalf("Expected no error unmarshalling yaml, got %v", err)
 	}
 	if !reflect.DeepEqual(unmarshalledConfig, expectedConfig) {
@@ -1570,59 +1491,9 @@ endpoint_params:
 	client := http.Client{
 		Transport: rt,
 	}
-
-	// Default secret.
-	expectedAuth = "Basic MToy"
-	resp, err := client.Get(ts.url())
-	if err != nil {
-		t.Fatal(err)
-	}
+	resp, _ := client.Get(ts.URL)
 
 	authorization := resp.Request.Header.Get("Authorization")
-	if authorization != "Bearer 12345" {
-		t.Fatalf("Expected authorization header to be 'Bearer', got '%s'", authorization)
-	}
-
-	// Making a second request with the same secret should not re-call the token API.
-	_, err = client.Get(ts.url())
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	// Empty secret.
-	expectedAuth = "Basic MTo="
-	expectedConfig.ClientSecret = ""
-	resp, err = client.Get(ts.url())
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	authorization = resp.Request.Header.Get("Authorization")
-	if authorization != "Bearer 12345" {
-		t.Fatalf("Expected authorization header to be 'Bearer 12345', got '%s'", authorization)
-	}
-
-	// Making a second request with the same secret should not re-call the token API.
-	resp, err = client.Get(ts.url())
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	// Update secret.
-	expectedAuth = "Basic MToxMjM0NTY3"
-	expectedConfig.ClientSecret = "1234567"
-	_, err = client.Get(ts.url())
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	// Making a second request with the same secret should not re-call the token API.
-	_, err = client.Get(ts.url())
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	authorization = resp.Request.Header.Get("Authorization")
 	if authorization != "Bearer 12345" {
 		t.Fatalf("Expected authorization header to be 'Bearer 12345', got '%s'", authorization)
 	}
@@ -1672,9 +1543,33 @@ func TestOAuth2UserAgent(t *testing.T) {
 }
 
 func TestOAuth2WithFile(t *testing.T) {
-	var expectedAuth string
-	ts := newTestOAuthServer(t, &expectedAuth)
-	defer ts.close()
+	var expectedAuth *string
+	var previousAuth string
+	tokenTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		auth := r.Header.Get("Authorization")
+		if auth != *expectedAuth {
+			t.Fatalf("bad auth, expected %s, got %s", *expectedAuth, auth)
+		}
+		if auth == previousAuth {
+			t.Fatal("token endpoint called twice")
+		}
+		previousAuth = auth
+		res, _ := json.Marshal(oauth2TestServerResponse{
+			AccessToken: "12345",
+			TokenType:   "Bearer",
+		})
+		w.Header().Add("Content-Type", "application/json")
+		_, _ = w.Write(res)
+	}))
+	defer tokenTS.Close()
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		auth := r.Header.Get("Authorization")
+		if auth != "Bearer 12345" {
+			t.Fatalf("bad auth, expected %s, got %s", "Bearer 12345", auth)
+		}
+		fmt.Fprintln(w, "Hello, client")
+	}))
+	defer ts.Close()
 
 	secretFile, err := os.CreateTemp("", "oauth2_secret")
 	if err != nil {
@@ -1691,13 +1586,13 @@ scopes:
 token_url: %s
 endpoint_params:
  hi: hello
-`, secretFile.Name(), ts.tokenURL())
+`, secretFile.Name(), tokenTS.URL)
 	expectedConfig := OAuth2{
 		ClientID:         "1",
 		ClientSecretFile: secretFile.Name(),
 		Scopes:           []string{"A", "B"},
 		EndpointParams:   map[string]string{"hi": "hello"},
-		TokenURL:         ts.tokenURL(),
+		TokenURL:         tokenTS.URL,
 	}
 
 	var unmarshalledConfig OAuth2
@@ -1715,57 +1610,40 @@ endpoint_params:
 		Transport: rt,
 	}
 
-	// Empty secret file.
-	expectedAuth = "Basic MTo="
-	resp, err := client.Get(ts.url())
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	authorization := resp.Request.Header.Get("Authorization")
-	if authorization != "Bearer 12345" {
-		t.Fatalf("Expected authorization header to be 'Bearer', got '%s'", authorization)
-	}
-
-	// Making a second request with the same file content should not re-call the token API.
-	_, err = client.Get(ts.url())
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	// File populated.
-	expectedAuth = "Basic MToxMjM0NTY="
+	tk := "Basic MToxMjM0NTY="
+	expectedAuth = &tk
 	if _, err := secretFile.Write([]byte("123456")); err != nil {
 		t.Fatal(err)
 	}
-	resp, err = client.Get(ts.url())
+	resp, err := client.Get(ts.URL)
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	authorization = resp.Request.Header.Get("Authorization")
+	authorization := resp.Request.Header.Get("Authorization")
 	if authorization != "Bearer 12345" {
 		t.Fatalf("Expected authorization header to be 'Bearer 12345', got '%s'", authorization)
 	}
 
 	// Making a second request with the same file content should not re-call the token API.
-	resp, err = client.Get(ts.url())
+	resp, err = client.Get(ts.URL)
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	// Update file.
-	expectedAuth = "Basic MToxMjM0NTY3"
+	tk = "Basic MToxMjM0NTY3"
+	expectedAuth = &tk
 	if _, err := secretFile.Write([]byte("7")); err != nil {
 		t.Fatal(err)
 	}
-	_, err = client.Get(ts.url())
+
+	_, err = client.Get(ts.URL)
 	if err != nil {
 		t.Fatal(err)
 	}
 
 	// Making a second request with the same file content should not re-call the token API.
-	_, err = client.Get(ts.url())
+	_, err = client.Get(ts.URL)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/config/testdata/http.conf.oauth2-no-client-secret.bad.yaml b/config/testdata/http.conf.oauth2-no-client-secret.bad.yaml
new file mode 100644
index 0000000..774a599
--- /dev/null
+++ b/config/testdata/http.conf.oauth2-no-client-secret.bad.yaml
@@ -0,0 +1,3 @@
+oauth2:
+  client_id: "myclientid"
+  token_url: "http://auth"
diff --git a/expfmt/encode_test.go b/expfmt/encode_test.go
index 6761461..66893bc 100644
--- a/expfmt/encode_test.go
+++ b/expfmt/encode_test.go
@@ -18,8 +18,11 @@ import (
 	"net/http"
 	"testing"
 
-	dto "github.com/prometheus/client_model/go"
 	"google.golang.org/protobuf/proto"
+
+	"github.com/prometheus/common/model"
+
+	dto "github.com/prometheus/client_model/go"
 )
 
 func TestNegotiate(t *testing.T) {
@@ -32,38 +35,70 @@ func TestNegotiate(t *testing.T) {
 		{
 			name:              "delimited format",
 			acceptHeaderValue: acceptValuePrefix + ";encoding=delimited",
-			expectedFmt:       string(FmtProtoDelim),
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores",
 		},
 		{
 			name:              "text format",
 			acceptHeaderValue: acceptValuePrefix + ";encoding=text",
-			expectedFmt:       string(FmtProtoText),
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=text; escaping=underscores",
 		},
 		{
 			name:              "compact text format",
 			acceptHeaderValue: acceptValuePrefix + ";encoding=compact-text",
-			expectedFmt:       string(FmtProtoCompact),
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=underscores",
 		},
 		{
 			name:              "plain text format",
 			acceptHeaderValue: "text/plain;version=0.0.4",
-			expectedFmt:       string(FmtText),
+			expectedFmt:       "text/plain; version=0.0.4; charset=utf-8; escaping=underscores",
+		},
+		{
+			name:              "delimited format utf-8",
+			acceptHeaderValue: acceptValuePrefix + ";encoding=delimited; escaping=allow-utf-8;",
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=allow-utf-8",
+		},
+		{
+			name:              "text format utf-8",
+			acceptHeaderValue: acceptValuePrefix + ";encoding=text; escaping=allow-utf-8;",
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=text; escaping=allow-utf-8",
+		},
+		{
+			name:              "compact text format utf-8",
+			acceptHeaderValue: acceptValuePrefix + ";encoding=compact-text; escaping=allow-utf-8;",
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=allow-utf-8",
+		},
+		{
+			name:              "plain text format 0.0.4 with utf-8 not valid, falls back",
+			acceptHeaderValue: "text/plain;version=0.0.4;",
+			expectedFmt:       "text/plain; version=0.0.4; charset=utf-8; escaping=underscores",
+		},
+		{
+			name:              "plain text format 0.0.4 with utf-8 not valid, falls back",
+			acceptHeaderValue: "text/plain;version=0.0.4; escaping=values;",
+			expectedFmt:       "text/plain; version=0.0.4; charset=utf-8; escaping=values",
 		},
 	}
 
-	for _, test := range tests {
+	oldDefault := model.NameEscapingScheme
+	model.NameEscapingScheme = model.UnderscoreEscaping
+	defer func() {
+		model.NameEscapingScheme = oldDefault
+	}()
+
+	for i, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
 			h := http.Header{}
 			h.Add(hdrAccept, test.acceptHeaderValue)
 			actualFmt := string(Negotiate(h))
 			if actualFmt != test.expectedFmt {
-				t.Errorf("expected Negotiate to return format %s, but got %s instead", test.expectedFmt, actualFmt)
+				t.Errorf("case %d: expected Negotiate to return format %s, but got %s instead", i, test.expectedFmt, actualFmt)
 			}
 		})
 	}
 }
 
 func TestNegotiateOpenMetrics(t *testing.T) {
+	acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily"
 	tests := []struct {
 		name              string
 		acceptHeaderValue string
@@ -72,32 +107,93 @@ func TestNegotiateOpenMetrics(t *testing.T) {
 		{
 			name:              "OM format, no version",
 			acceptHeaderValue: "application/openmetrics-text",
-			expectedFmt:       string(FmtOpenMetrics_0_0_1),
+			expectedFmt:       "application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=values",
 		},
 		{
 			name:              "OM format, 0.0.1 version",
-			acceptHeaderValue: "application/openmetrics-text;version=0.0.1",
-			expectedFmt:       string(FmtOpenMetrics_0_0_1),
+			acceptHeaderValue: "application/openmetrics-text;version=0.0.1; escaping=underscores",
+			expectedFmt:       "application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=underscores",
 		},
 		{
 			name:              "OM format, 1.0.0 version",
 			acceptHeaderValue: "application/openmetrics-text;version=1.0.0",
-			expectedFmt:       string(FmtOpenMetrics_1_0_0),
+			expectedFmt:       "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values",
+		},
+		{
+			name:              "OM format, 0.0.1 version with utf-8 is not valid, falls back",
+			acceptHeaderValue: "application/openmetrics-text;version=0.0.1",
+			expectedFmt:       "application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=values",
+		},
+		{
+			name:              "OM format, 1.0.0 version with utf-8 is not valid, falls back",
+			acceptHeaderValue: "application/openmetrics-text;version=1.0.0; escaping=values;",
+			expectedFmt:       "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values",
 		},
 		{
 			name:              "OM format, invalid version",
 			acceptHeaderValue: "application/openmetrics-text;version=0.0.4",
-			expectedFmt:       string(FmtText),
+			expectedFmt:       "text/plain; version=0.0.4; charset=utf-8; escaping=values",
+		},
+		{
+			name:              "compact text format",
+			acceptHeaderValue: acceptValuePrefix + ";encoding=compact-text; escaping=underscores",
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=underscores",
+		},
+		{
+			name:              "plain text format",
+			acceptHeaderValue: "text/plain;version=0.0.4",
+			expectedFmt:       "text/plain; version=0.0.4; charset=utf-8; escaping=values",
+		},
+		{
+			name:              "plain text format 0.0.4",
+			acceptHeaderValue: "text/plain;version=0.0.4; escaping=allow-utf-8",
+			expectedFmt:       "text/plain; version=0.0.4; charset=utf-8; escaping=allow-utf-8",
+		},
+		{
+			name:              "delimited format utf-8",
+			acceptHeaderValue: acceptValuePrefix + ";encoding=delimited; escaping=allow-utf-8;",
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=allow-utf-8",
+		},
+		{
+			name:              "text format utf-8",
+			acceptHeaderValue: acceptValuePrefix + ";encoding=text; escaping=allow-utf-8;",
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=text; escaping=allow-utf-8",
+		},
+		{
+			name:              "compact text format utf-8",
+			acceptHeaderValue: acceptValuePrefix + ";encoding=compact-text; escaping=allow-utf-8;",
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=allow-utf-8",
+		},
+		{
+			name:              "delimited format escaped",
+			acceptHeaderValue: acceptValuePrefix + ";encoding=delimited; escaping=underscores;",
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores",
+		},
+		{
+			name:              "text format escaped",
+			acceptHeaderValue: acceptValuePrefix + ";encoding=text; escaping=underscores;",
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=text; escaping=underscores",
+		},
+		{
+			name:              "compact text format escaped",
+			acceptHeaderValue: acceptValuePrefix + ";encoding=compact-text; escaping=underscores;",
+			expectedFmt:       "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=underscores",
 		},
 	}
 
-	for _, test := range tests {
+	oldDefault := model.NameEscapingScheme
+	model.NameEscapingScheme = model.ValueEncodingEscaping
+	defer func() {
+		model.NameEscapingScheme = oldDefault
+	}()
+
+	for i, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
 			h := http.Header{}
 			h.Add(hdrAccept, test.acceptHeaderValue)
 			actualFmt := string(NegotiateIncludingOpenMetrics(h))
 			if actualFmt != test.expectedFmt {
-				t.Errorf("expected Negotiate to return format %s, but got %s instead", test.expectedFmt, actualFmt)
+				t.Errorf("case %d: expected Negotiate to return format %s, but got %s instead", i, test.expectedFmt, actualFmt)
 			}
 		})
 	}
@@ -174,3 +270,88 @@ func TestEncode(t *testing.T) {
 		t.Errorf("expected TextEncoder to return %s, but got %s instead", expected, string(out))
 	}
 }
+
+func TestEscapedEncode(t *testing.T) {
+	var buff bytes.Buffer
+	delimEncoder := NewEncoder(&buff, FmtProtoDelim+"; escaping=underscores")
+	metric := &dto.MetricFamily{
+		Name: proto.String("foo.metric"),
+		Type: dto.MetricType_UNTYPED.Enum(),
+		Metric: []*dto.Metric{
+			{
+				Untyped: &dto.Untyped{
+					Value: proto.Float64(1.234),
+				},
+			},
+			{
+				Label: []*dto.LabelPair{
+					{
+						Name:  proto.String("dotted.label.name"),
+						Value: proto.String("my.label.value"),
+					},
+				},
+				Untyped: &dto.Untyped{
+					Value: proto.Float64(8),
+				},
+			},
+		},
+	}
+
+	err := delimEncoder.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()
+
+	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 U__foo_2e_metric untyped
+U__foo_2e_metric 1.234
+U__foo_2e_metric{U__dotted_2e_label_2e_name="my.label.value"} 8
+`
+
+	if string(out) != expected {
+		t.Errorf("expected TextEncoder to return %s, but got %s instead", expected, string(out))
+	}
+}
diff --git a/expfmt/openmetrics_create_test.go b/expfmt/openmetrics_create_test.go
index 7b20165..8601834 100644
--- a/expfmt/openmetrics_create_test.go
+++ b/expfmt/openmetrics_create_test.go
@@ -24,6 +24,8 @@ import (
 	"google.golang.org/protobuf/types/known/timestamppb"
 
 	dto "github.com/prometheus/client_model/go"
+
+	"github.com/prometheus/common/model"
 )
 
 func TestCreateOpenMetrics(t *testing.T) {
@@ -32,6 +34,12 @@ func TestCreateOpenMetrics(t *testing.T) {
 		t.Error(err)
 	}
 
+	oldDefaultScheme := model.NameEscapingScheme
+	model.NameEscapingScheme = model.NoEscaping
+	defer func() {
+		model.NameEscapingScheme = oldDefaultScheme
+	}()
+
 	scenarios := []struct {
 		in  *dto.MetricFamily
 		out string
@@ -199,7 +207,7 @@ gauge_name{name_1="val with\nnew line",name_2="val with \\backslash and \"quotes
 gauge_name{name_1="Björn",name_2="佖佥"} 3.14e+42
 `,
 		},
-		// 4: Gauge, utf8, some escaping required, +Inf as value, multi-byte characters in label values.
+		// 4: Gauge, utf-8, some escaping required, +Inf as value, multi-byte characters in label values.
 		{
 			in: &dto.MetricFamily{
 				Name: proto.String("gauge.name\""),
diff --git a/expfmt/text_create_test.go b/expfmt/text_create_test.go
index 41bd408..7cf0428 100644
--- a/expfmt/text_create_test.go
+++ b/expfmt/text_create_test.go
@@ -22,9 +22,17 @@ import (
 	"google.golang.org/protobuf/proto"
 
 	dto "github.com/prometheus/client_model/go"
+
+	"github.com/prometheus/common/model"
 )
 
 func TestCreate(t *testing.T) {
+	oldDefaultScheme := model.NameEscapingScheme
+	model.NameEscapingScheme = model.NoEscaping
+	defer func() {
+		model.NameEscapingScheme = oldDefaultScheme
+	}()
+
 	scenarios := []struct {
 		in  *dto.MetricFamily
 		out string
@@ -120,7 +128,7 @@ gauge_name{name_1="val with\nnew line",name_2="val with \\backslash and \"quotes
 gauge_name{name_1="Björn",name_2="佖佥"} 3.14e+42
 `,
 		},
-		// 2: Gauge, utf8, +Inf as value, multi-byte characters in label values.
+		// 2: Gauge, utf-8, +Inf as value, multi-byte characters in label values.
 		{
 			in: &dto.MetricFamily{
 				Name: proto.String("gauge.name"),
diff --git a/model/labels_test.go b/model/labels_test.go
index 5ec94a4..2339543 100644
--- a/model/labels_test.go
+++ b/model/labels_test.go
@@ -153,7 +153,7 @@ func TestLabelNameIsValid(t *testing.T) {
 		}
 		NameValidationScheme = UTF8Validation
 		if s.ln.IsValid() != s.utf8Valid {
-			t.Errorf("Expected %v for %q using UTF8 IsValid method", s.legacyValid, s.ln)
+			t.Errorf("Expected %v for %q using UTF-8 IsValid method", s.legacyValid, s.ln)
 		}
 	}
 }
diff --git a/model/metric_test.go b/model/metric_test.go
index 60f8293..b794c04 100644
--- a/model/metric_test.go
+++ b/model/metric_test.go
@@ -13,7 +13,14 @@
 
 package model
 
-import "testing"
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	dto "github.com/prometheus/client_model/go"
+	"google.golang.org/protobuf/proto"
+)
 
 func testMetric(t testing.TB) {
 	scenarios := []struct {
@@ -150,7 +157,7 @@ func TestMetricNameIsLegacyValid(t *testing.T) {
 		}
 		NameValidationScheme = UTF8Validation
 		if IsValidMetricName(s.mn) != s.utf8Valid {
-			t.Errorf("Expected %v for %q using utf8 IsValidMetricName method", s.legacyValid, s.mn)
+			t.Errorf("Expected %v for %q using utf-8 IsValidMetricName method", s.legacyValid, s.mn)
 		}
 	}
 }
@@ -218,7 +225,423 @@ func TestMetricToString(t *testing.T) {
 		t.Run(scenario.name, func(t *testing.T) {
 			actual := scenario.input.String()
 			if actual != scenario.expected {
-				t.Errorf("expected string output %s but got %s", actual, scenario.expected)
+				t.Errorf("expected string output %s but got %s", scenario.expected, actual)
+			}
+		})
+	}
+}
+
+func TestEscapeName(t *testing.T) {
+	scenarios := []struct {
+		name                  string
+		input                 string
+		expectedUnderscores   string
+		expectedDots          string
+		expectedUnescapedDots string
+		expectedValue         string
+	}{
+		{
+			name: "empty string",
+		},
+		{
+			name:                "legacy valid name",
+			input:               "no:escaping_required",
+			expectedUnderscores: "no:escaping_required",
+			// Dots escaping will escape underscores even though it's not strictly
+			// necessary for compatibility.
+			expectedDots:          "no:escaping__required",
+			expectedUnescapedDots: "no:escaping_required",
+			expectedValue:         "no:escaping_required",
+		},
+		{
+			name:                  "name with dots",
+			input:                 "mysystem.prod.west.cpu.load",
+			expectedUnderscores:   "mysystem_prod_west_cpu_load",
+			expectedDots:          "mysystem_dot_prod_dot_west_dot_cpu_dot_load",
+			expectedUnescapedDots: "mysystem.prod.west.cpu.load",
+			expectedValue:         "U__mysystem_2e_prod_2e_west_2e_cpu_2e_load",
+		},
+		{
+			name:                  "name with dots and colon",
+			input:                 "http.status:sum",
+			expectedUnderscores:   "http_status:sum",
+			expectedDots:          "http_dot_status:sum",
+			expectedUnescapedDots: "http.status:sum",
+			expectedValue:         "U__http_2e_status:sum",
+		},
+		{
+			name:                "name with unicode characters > 0x100",
+			input:               "花火",
+			expectedUnderscores: "__",
+			expectedDots:        "__",
+			// Dots-replacement does not know the difference between two replaced
+			// characters and a single underscore.
+			expectedUnescapedDots: "_",
+			expectedValue:         "U___82b1__706b_",
+		},
+	}
+
+	for _, scenario := range scenarios {
+		t.Run(scenario.name, func(t *testing.T) {
+			got := EscapeName(scenario.input, UnderscoreEscaping)
+			if got != scenario.expectedUnderscores {
+				t.Errorf("expected string output %s but got %s", scenario.expectedUnderscores, got)
+			}
+			// Unescaping with the underscore method is a noop.
+			got = UnescapeName(got, UnderscoreEscaping)
+			if got != scenario.expectedUnderscores {
+				t.Errorf("expected unescaped string output %s but got %s", scenario.expectedUnderscores, got)
+			}
+
+			got = EscapeName(scenario.input, DotsEscaping)
+			if got != scenario.expectedDots {
+				t.Errorf("expected string output %s but got %s", scenario.expectedDots, got)
+			}
+			got = UnescapeName(got, DotsEscaping)
+			if got != scenario.expectedUnescapedDots {
+				t.Errorf("expected unescaped string output %s but got %s", scenario.expectedUnescapedDots, got)
+			}
+
+			got = EscapeName(scenario.input, ValueEncodingEscaping)
+			if got != scenario.expectedValue {
+				t.Errorf("expected string output %s but got %s", scenario.expectedValue, got)
+			}
+			// Unescaped result should always be identical to the original input.
+			got = UnescapeName(got, ValueEncodingEscaping)
+			if got != scenario.input {
+				t.Errorf("expected unescaped string output %s but got %s", scenario.input, got)
+			}
+		})
+	}
+}
+
+func TestValueUnescapeErrors(t *testing.T) {
+	scenarios := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{
+			name: "empty string",
+		},
+		{
+			name:     "basic case, no error",
+			input:    "U__no:unescapingrequired",
+			expected: "no:unescapingrequired",
+		},
+		{
+			name:     "capitals ok, no error",
+			input:    "U__capitals_2E_ok",
+			expected: "capitals.ok",
+		},
+		{
+			name:     "underscores, no error",
+			input:    "U__underscores__doubled__",
+			expected: "underscores_doubled_",
+		},
+		{
+			name:     "invalid single underscore",
+			input:    "U__underscores_doubled_",
+			expected: "U__underscores_doubled_",
+		},
+		{
+			name:     "invalid single underscore, 2",
+			input:    "U__underscores__doubled_",
+			expected: "U__underscores__doubled_",
+		},
+		{
+			name:     "giant fake utf-8 code",
+			input:    "U__my__hack_2e_attempt_872348732fabdabbab_",
+			expected: "U__my__hack_2e_attempt_872348732fabdabbab_",
+		},
+		{
+			name:     "trailing utf-8",
+			input:    "U__my__hack_2e",
+			expected: "U__my__hack_2e",
+		},
+		{
+			name:     "invalid utf-8 value",
+			input:    "U__bad__utf_2eg_",
+			expected: "U__bad__utf_2eg_",
+		},
+		{
+			name:     "surrogate utf-8 value",
+			input:    "U__bad__utf_D900_",
+			expected: "U__bad__utf_D900_",
+		},
+	}
+
+	for _, scenario := range scenarios {
+		t.Run(scenario.name, func(t *testing.T) {
+			got := UnescapeName(scenario.input, ValueEncodingEscaping)
+			if got != scenario.expected {
+				t.Errorf("expected unescaped string output %s but got %s", scenario.expected, got)
+			}
+		})
+	}
+}
+
+func TestEscapeMetricFamily(t *testing.T) {
+	scenarios := []struct {
+		name     string
+		input    *dto.MetricFamily
+		scheme   EscapingScheme
+		expected *dto.MetricFamily
+	}{
+		{
+			name:     "empty",
+			input:    &dto.MetricFamily{},
+			scheme:   ValueEncodingEscaping,
+			expected: &dto.MetricFamily{},
+		},
+		{
+			name:   "simple, no escaping needed",
+			scheme: ValueEncodingEscaping,
+			input: &dto.MetricFamily{
+				Name: proto.String("my_metric"),
+				Help: proto.String("some help text"),
+				Type: dto.MetricType_COUNTER.Enum(),
+				Metric: []*dto.Metric{
+					{
+						Counter: &dto.Counter{
+							Value: proto.Float64(34.2),
+						},
+						Label: []*dto.LabelPair{
+							{
+								Name:  proto.String("__name__"),
+								Value: proto.String("my_metric"),
+							},
+							{
+								Name:  proto.String("some_label"),
+								Value: proto.String("labelvalue"),
+							},
+						},
+					},
+				},
+			},
+			expected: &dto.MetricFamily{
+				Name: proto.String("my_metric"),
+				Help: proto.String("some help text"),
+				Type: dto.MetricType_COUNTER.Enum(),
+				Metric: []*dto.Metric{
+					{
+						Counter: &dto.Counter{
+							Value: proto.Float64(34.2),
+						},
+						Label: []*dto.LabelPair{
+							{
+								Name:  proto.String("__name__"),
+								Value: proto.String("my_metric"),
+							},
+							{
+								Name:  proto.String("some_label"),
+								Value: proto.String("labelvalue"),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:   "label name escaping needed",
+			scheme: ValueEncodingEscaping,
+			input: &dto.MetricFamily{
+				Name: proto.String("my_metric"),
+				Help: proto.String("some help text"),
+				Type: dto.MetricType_COUNTER.Enum(),
+				Metric: []*dto.Metric{
+					{
+						Counter: &dto.Counter{
+							Value: proto.Float64(34.2),
+						},
+						Label: []*dto.LabelPair{
+							{
+								Name:  proto.String("__name__"),
+								Value: proto.String("my_metric"),
+							},
+							{
+								Name:  proto.String("some.label"),
+								Value: proto.String("labelvalue"),
+							},
+						},
+					},
+				},
+			},
+			expected: &dto.MetricFamily{
+				Name: proto.String("my_metric"),
+				Help: proto.String("some help text"),
+				Type: dto.MetricType_COUNTER.Enum(),
+				Metric: []*dto.Metric{
+					{
+						Counter: &dto.Counter{
+							Value: proto.Float64(34.2),
+						},
+						Label: []*dto.LabelPair{
+							{
+								Name:  proto.String("__name__"),
+								Value: proto.String("my_metric"),
+							},
+							{
+								Name:  proto.String("U__some_2e_label"),
+								Value: proto.String("labelvalue"),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:   "counter, escaping needed",
+			scheme: ValueEncodingEscaping,
+			input: &dto.MetricFamily{
+				Name: proto.String("my.metric"),
+				Help: proto.String("some help text"),
+				Type: dto.MetricType_COUNTER.Enum(),
+				Metric: []*dto.Metric{
+					{
+						Counter: &dto.Counter{
+							Value: proto.Float64(34.2),
+						},
+						Label: []*dto.LabelPair{
+							{
+								Name:  proto.String("__name__"),
+								Value: proto.String("my.metric"),
+							},
+							{
+								Name:  proto.String("some?label"),
+								Value: proto.String("label??value"),
+							},
+						},
+					},
+				},
+			},
+			expected: &dto.MetricFamily{
+				Name: proto.String("U__my_2e_metric"),
+				Help: proto.String("some help text"),
+				Type: dto.MetricType_COUNTER.Enum(),
+				Metric: []*dto.Metric{
+					{
+						Counter: &dto.Counter{
+							Value: proto.Float64(34.2),
+						},
+						Label: []*dto.LabelPair{
+							{
+								Name:  proto.String("__name__"),
+								Value: proto.String("U__my_2e_metric"),
+							},
+							{
+								Name:  proto.String("U__some_3f_label"),
+								Value: proto.String("label??value"),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:   "gauge, escaping needed",
+			scheme: DotsEscaping,
+			input: &dto.MetricFamily{
+				Name: proto.String("unicode.and.dots.花火"),
+				Help: proto.String("some help text"),
+				Type: dto.MetricType_GAUGE.Enum(),
+				Metric: []*dto.Metric{
+					{
+						Gauge: &dto.Gauge{
+							Value: proto.Float64(34.2),
+						},
+						Label: []*dto.LabelPair{
+							{
+								Name:  proto.String("__name__"),
+								Value: proto.String("unicode.and.dots.花火"),
+							},
+							{
+								Name:  proto.String("some_label"),
+								Value: proto.String("label??value"),
+							},
+						},
+					},
+				},
+			},
+			expected: &dto.MetricFamily{
+				Name: proto.String("unicode_dot_and_dot_dots_dot___"),
+				Help: proto.String("some help text"),
+				Type: dto.MetricType_GAUGE.Enum(),
+				Metric: []*dto.Metric{
+					{
+						Gauge: &dto.Gauge{
+							Value: proto.Float64(34.2),
+						},
+						Label: []*dto.LabelPair{
+							{
+								Name:  proto.String("__name__"),
+								Value: proto.String("unicode_dot_and_dot_dots_dot___"),
+							},
+							{
+								Name:  proto.String("some_label"),
+								Value: proto.String("label??value"),
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	unexportList := []interface{}{dto.MetricFamily{}, dto.Metric{}, dto.LabelPair{}, dto.Counter{}, dto.Gauge{}}
+
+	for _, scenario := range scenarios {
+		t.Run(scenario.name, func(t *testing.T) {
+			original := proto.Clone(scenario.input)
+			got := EscapeMetricFamily(scenario.input, scenario.scheme)
+			if !cmp.Equal(scenario.expected, got, cmpopts.IgnoreUnexported(unexportList...)) {
+				t.Errorf("unexpected difference in escaped output:" + cmp.Diff(scenario.expected, got, cmpopts.IgnoreUnexported(unexportList...)))
+			}
+			if !cmp.Equal(scenario.input, original, cmpopts.IgnoreUnexported(unexportList...)) {
+				t.Errorf("input was mutated during escaping" + cmp.Diff(scenario.expected, got, cmpopts.IgnoreUnexported(unexportList...)))
+			}
+		})
+	}
+}
+
+// TestProtoFormatUnchanged checks to see if the proto format changed, in which
+// case EscapeMetricFamily will need to be updated.
+func TestProtoFormatUnchanged(t *testing.T) {
+	scenarios := []struct {
+		name         string
+		input        proto.Message
+		expectFields []string
+	}{
+		{
+			name:         "MetricFamily",
+			input:        &dto.MetricFamily{},
+			expectFields: []string{"name", "help", "type", "metric"},
+		},
+		{
+			name:         "Metric",
+			input:        &dto.Metric{},
+			expectFields: []string{"label", "gauge", "counter", "summary", "untyped", "histogram", "timestamp_ms"},
+		},
+		{
+			name:         "LabelPair",
+			input:        &dto.LabelPair{},
+			expectFields: []string{"name", "value"},
+		},
+	}
+
+	for _, scenario := range scenarios {
+		t.Run(scenario.name, func(t *testing.T) {
+			desc := scenario.input.ProtoReflect().Descriptor()
+			fields := desc.Fields()
+			if fields.Len() != len(scenario.expectFields) {
+				t.Errorf("dto.MetricFamily changed length, expected %d, got %d", len(scenario.expectFields), fields.Len())
+			}
+
+			for i := 0; i < fields.Len(); i++ {
+				got := fields.Get(i).TextName()
+				if got != scenario.expectFields[i] {
+					t.Errorf("dto.MetricFamily field mismatch, expected %s got %s", scenario.expectFields[i], got)
+				}
 			}
 		})
 	}
diff --git a/model/silence_test.go b/model/silence_test.go
index 0b5ad32..4e4508d 100644
--- a/model/silence_test.go
+++ b/model/silence_test.go
@@ -92,7 +92,7 @@ func TestMatcherValidate(t *testing.T) {
 				t.Errorf("%d. Expected error for legacy validation %q but got none", i, c.legacyErr)
 			}
 			if c.utf8Err != "" {
-				t.Errorf("%d. Expected error for utf8 validation %q but got none", i, c.utf8Err)
+				t.Errorf("%d. Expected error for utf-8 validation %q but got none", i, c.utf8Err)
 			}
 			continue
 		}
@@ -105,7 +105,7 @@ func TestMatcherValidate(t *testing.T) {
 		}
 		if utf8Err != nil {
 			if c.utf8Err == "" {
-				t.Errorf("%d. Expected no utf8 validation error but got %q", i, utf8Err)
+				t.Errorf("%d. Expected no utf-8 validation error but got %q", i, utf8Err)
 				continue
 			}
 			if !strings.Contains(utf8Err.Error(), c.utf8Err) {