Repository: prometheus/client_golang

Issue:
Proposal to Add FormatQuery Method for API
Dear Prometheus Community,

I'd like to propose the addition of a [FormatQuery](javascript:void(0)) method in the Prometheus Go client library ([client_golang](https://github.com/prometheus/client_golang)) to support the newly introduced _`/api/v1/format_query`_ HTTP API endpoint, as documented in the Prometheus [CHANGELOG](https://github.com/prometheus/prometheus/blob/main/CHANGELOG.md#2380--2022-08-16) for version 2.38.0.

Discussion:

Solution patch:
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 6b8d5f4..e2257dd 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -50,7 +50,7 @@ jobs:
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
+      uses: github/codeql-action/init@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
       with:
         languages: ${{ matrix.language }}
         # If you wish to specify custom queries, you can do so here or in a config file.
@@ -61,7 +61,7 @@ jobs:
     # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
     # If this step fails, then you should remove it and run the build manually (see below)
     - name: Autobuild
-      uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
+      uses: github/codeql-action/autobuild@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
 
     # ℹ️ Command-line programs to run using the OS shell.
     # 📚 https://git.io/JvXDl
@@ -75,4 +75,4 @@ jobs:
     #   make release
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
+      uses: github/codeql-action/analyze@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index c868525..b83fe2d 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -51,6 +51,6 @@ jobs:
 
       # Upload the results to GitHub's code scanning dashboard.
       - name: "Upload to code-scanning"
-        uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
+        uses: github/codeql-action/upload-sarif@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
         with:
           sarif_file: results.sarif
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e40775..af5bf04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
 ## Unreleased
 
+## 1.23.0 / 2025-07-30
+
+* [CHANGE] Minimum required Go version is now 1.23, only the two latest Go versions are supported from now on. #1812
+* [FEATURE] Add WrapCollectorWith and WrapCollectorWithPrefix #1766
+* [FEATURE] Add exemplars for native histograms #1686
+* [ENHANCEMENT] exp/api: Bubble up status code from writeResponse #1823
+* [ENHANCEMENT] collector/go: Update runtime metrics for Go v1.23 and v1.24 #1833
+* [BUGFIX] exp/api: client prompt return on context cancellation #1729
+
 ## 1.22.0 / 2025-04-07
 
 :warning: This release contains potential breaking change if you use experimental `zstd` support introduce in #1496 :warning:
diff --git a/VERSION b/VERSION
index 57807d6..a6c2798 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.22.0
+1.23.0
diff --git a/api/prometheus/v1/api.go b/api/prometheus/v1/api.go
index 8a72f9b..50102f6 100644
--- a/api/prometheus/v1/api.go
+++ b/api/prometheus/v1/api.go
@@ -381,6 +381,7 @@ const (
 	epRuntimeinfo     = apiPrefix + "/status/runtimeinfo"
 	epTSDB            = apiPrefix + "/status/tsdb"
 	epWalReplay       = apiPrefix + "/status/walreplay"
+	epFormatQuery     = apiPrefix + "/format_query"
 )
 
 // AlertState models the state of an alert.
@@ -475,7 +476,7 @@ type API interface {
 	// Flags returns the flag values that Prometheus was launched with.
 	Flags(ctx context.Context) (FlagsResult, error)
 	// LabelNames returns the unique label names present in the block in sorted order by given time range and matchers.
-	LabelNames(ctx context.Context, matches []string, startTime, endTime time.Time, opts ...Option) ([]string, Warnings, error)
+	LabelNames(ctx context.Context, matches []string, startTime, endTime time.Time, opts ...Option) (model.LabelNames, Warnings, error)
 	// LabelValues performs a query for the values of the given label, time range and matchers.
 	LabelValues(ctx context.Context, label string, matches []string, startTime, endTime time.Time, opts ...Option) (model.LabelValues, Warnings, error)
 	// Query performs a query for the given time.
@@ -1024,7 +1025,7 @@ func (h *httpAPI) Runtimeinfo(ctx context.Context) (RuntimeinfoResult, error) {
 	return res, err
 }
 
-func (h *httpAPI) LabelNames(ctx context.Context, matches []string, startTime, endTime time.Time, opts ...Option) ([]string, Warnings, error) {
+func (h *httpAPI) LabelNames(ctx context.Context, matches []string, startTime, endTime time.Time, opts ...Option) (model.LabelNames, Warnings, error) {
 	u := h.client.URL(epLabels, nil)
 	q := addOptionalURLParams(u.Query(), opts)
 
@@ -1042,7 +1043,7 @@ func (h *httpAPI) LabelNames(ctx context.Context, matches []string, startTime, e
 	if err != nil {
 		return nil, w, err
 	}
-	var labelNames []string
+	var labelNames model.LabelNames
 	err = json.Unmarshal(body, &labelNames)
 	return labelNames, w, err
 }
@@ -1380,6 +1381,19 @@ func (h *httpAPI) QueryExemplars(ctx context.Context, query string, startTime, e
 	return res, err
 }
 
+func (h *httpAPI) FormatQuery(ctx context.Context, query string) (string, error) {
+	u := h.client.URL(epFormatQuery, nil)
+	q := u.Query()
+	q.Set("query", query)
+
+	_, body, _, err := h.client.DoGetFallback(ctx, u, q)
+	if err != nil {
+		return "", err
+	}
+
+	return string(body), nil
+}
+
 // Warnings is an array of non critical errors
 type Warnings []string
 
diff --git a/exp/api/remote/remote_api.go b/exp/api/remote/remote_api.go
index adec5fc..afacd41 100644
--- a/exp/api/remote/remote_api.go
+++ b/exp/api/remote/remote_api.go
@@ -64,8 +64,7 @@ var defaultAPIOpts = &apiOpts{
 		Max:        10 * time.Second,
 		MaxRetries: 10,
 	},
-	client: http.DefaultClient,
-	// Hardcoded for now.
+	client:           http.DefaultClient,
 	retryOnRateLimit: true,
 	compression:      SnappyBlockCompression,
 	path:             "api/v1/write",
@@ -96,7 +95,7 @@ func WithAPIPath(path string) APIOption {
 	}
 }
 
-// WithAPIRetryOnRateLimit returns APIOption that disables retrying on rate limit status code.
+// WithAPINoRetryOnRateLimit returns APIOption that disables retrying on rate limit status code.
 func WithAPINoRetryOnRateLimit() APIOption {
 	return func(o *apiOpts) error {
 		o.retryOnRateLimit = false
@@ -373,45 +372,46 @@ type writeStorage interface {
 	// Other headers might be trimmed, depending on the configured middlewares
 	// e.g. a default SnappyMiddleware trims "Content-Encoding" and ensures that
 	// encoded body bytes are already decompressed.
-	Store(ctx context.Context, msgType WriteMessageType, req *http.Request) (_ *WriteResponse, _ error)
+	Store(req *http.Request, msgType WriteMessageType) (_ *WriteResponse, _ error)
 }
 
-type handler struct {
+type writeHandler struct {
 	store                writeStorage
 	acceptedMessageTypes MessageTypes
-	opts                 handlerOpts
+	opts                 writeHandlerOpts
 }
 
-type handlerOpts struct {
+type writeHandlerOpts struct {
 	logger      *slog.Logger
 	middlewares []func(http.Handler) http.Handler
 }
 
-// HandlerOption represents an option for the handler.
-type HandlerOption func(o *handlerOpts)
+// WriteHandlerOption represents an option for the write handler.
+type WriteHandlerOption func(o *writeHandlerOpts)
 
-// WithHandlerLogger returns HandlerOption that allows providing slog logger.
+// WithWriteHandlerLogger returns WriteHandlerOption that allows providing slog logger.
 // By default, nothing is logged.
-func WithHandlerLogger(logger *slog.Logger) HandlerOption {
-	return func(o *handlerOpts) {
+func WithWriteHandlerLogger(logger *slog.Logger) WriteHandlerOption {
+	return func(o *writeHandlerOpts) {
 		o.logger = logger
 	}
 }
 
-// WithHandlerMiddleware returns HandlerOption that allows providing middlewares.
+// WithWriteHandlerMiddlewares returns WriteHandlerOption that allows providing middlewares.
 // Multiple middlewares can be provided and will be applied in the order they are passed.
-// When using this option, SnappyDecompressorMiddleware is not applied by default so
-// it (or any other decompression middleware) needs to be added explicitly.
-func WithHandlerMiddlewares(middlewares ...func(http.Handler) http.Handler) HandlerOption {
-	return func(o *handlerOpts) {
+// This option replaces the default middlewares (SnappyDecompressorMiddleware), so if
+// you want to have handler that works with the default Remote Write 2.0 protocol,
+// SnappyDecompressorMiddleware (or any other decompression middleware) needs to be added explicitly.
+func WithWriteHandlerMiddlewares(middlewares ...func(http.Handler) http.Handler) WriteHandlerOption {
+	return func(o *writeHandlerOpts) {
 		o.middlewares = middlewares
 	}
 }
 
-// SnappyDecompressorMiddleware returns a middleware that checks if the request body is snappy-encoded and decompresses it.
+// SnappyDecodeMiddleware returns a middleware that checks if the request body is snappy-encoded and decompresses it.
 // If the request body is not snappy-encoded, it returns an error.
-// Used by default in NewRemoteWriteHandler.
-func SnappyDecompressorMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
+// Used by default in NewHandler.
+func SnappyDecodeMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
 	bufPool := sync.Pool{
 		New: func() any {
 			return bytes.NewBuffer(nil)
@@ -455,18 +455,18 @@ func SnappyDecompressorMiddleware(logger *slog.Logger) func(http.Handler) http.H
 	}
 }
 
-// NewHandler returns HTTP handler that receives Remote Write 2.0
+// NewWriteHandler returns HTTP handler that receives Remote Write 2.0
 // protocol https://prometheus.io/docs/specs/remote_write_spec_2_0/.
-func NewHandler(store writeStorage, acceptedMessageTypes MessageTypes, opts ...HandlerOption) http.Handler {
-	o := handlerOpts{
+func NewWriteHandler(store writeStorage, acceptedMessageTypes MessageTypes, opts ...WriteHandlerOption) http.Handler {
+	o := writeHandlerOpts{
 		logger:      slog.New(nopSlogHandler{}),
-		middlewares: []func(http.Handler) http.Handler{SnappyDecompressorMiddleware(slog.New(nopSlogHandler{}))},
+		middlewares: []func(http.Handler) http.Handler{SnappyDecodeMiddleware(slog.New(nopSlogHandler{}))},
 	}
 	for _, opt := range opts {
 		opt(&o)
 	}
 
-	h := &handler{
+	h := &writeHandler{
 		opts:                 o,
 		store:                store,
 		acceptedMessageTypes: acceptedMessageTypes,
@@ -513,7 +513,7 @@ func ParseProtoMsg(contentType string) (WriteMessageType, error) {
 	return WriteV1MessageType, nil
 }
 
-func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (h *writeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	if r.Method != http.MethodPost {
 		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
 		return
@@ -538,20 +538,25 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	writeResponse, storeErr := h.store.Store(r.Context(), msgType, r)
+	writeResponse, storeErr := h.store.Store(r, msgType)
+	if writeResponse == nil {
+		// User could forget to return write response; in this case we assume 0 samples
+		// were written.
+		writeResponse = NewWriteResponse()
+	}
 
-	// Set required X-Prometheus-Remote-Write-Written-* response headers, in all cases, alongwith any user-defined headers.
-	writeResponse.SetHeaders(w)
+	// Set required X-Prometheus-Remote-Write-Written-* response headers, in all cases, along with any user-defined headers.
+	writeResponse.writeHeaders(w)
 
 	if storeErr != nil {
-		if writeResponse.StatusCode() == 0 {
+		if writeResponse.statusCode == 0 {
 			writeResponse.SetStatusCode(http.StatusInternalServerError)
 		}
-		if writeResponse.StatusCode()/100 == 5 { // 5xx
-			h.opts.logger.Error("Error while storing the remote write request", "err", storeErr.Error())
+		if writeResponse.statusCode/100 == 5 { // 5xx
+			h.opts.logger.Error("Error while storing the remote write request", "err", storeErr)
 		}
-		http.Error(w, storeErr.Error(), writeResponse.StatusCode())
+		http.Error(w, storeErr.Error(), writeResponse.statusCode)
 		return
 	}
-	w.WriteHeader(writeResponse.StatusCode())
+	w.WriteHeader(writeResponse.statusCode)
 }
diff --git a/exp/api/remote/remote_headers.go b/exp/api/remote/remote_headers.go
index 9eaf4de..dfe1931 100644
--- a/exp/api/remote/remote_headers.go
+++ b/exp/api/remote/remote_headers.go
@@ -125,29 +125,19 @@ func (w *WriteResponse) SetStatusCode(code int) {
 	w.statusCode = code
 }
 
-// StatusCode returns the current HTTP status code.
-func (w *WriteResponse) StatusCode() int {
-	return w.statusCode
-}
-
 // SetExtraHeader adds additional headers to be set in the response (apart from stats headers)
 func (w *WriteResponse) SetExtraHeader(key, value string) {
 	w.extraHeaders.Set(key, value)
 }
 
-// ExtraHeaders returns all additional headers to be set in the response (apart from stats headers).
-func (w *WriteResponse) ExtraHeaders() http.Header {
-	return w.extraHeaders
-}
-
-// SetHeaders sets response headers in a given response writer.
+// writeHeaders sets response headers in a given response writer.
 // Make sure to use it before http.ResponseWriter.WriteHeader and .Write.
-func (r *WriteResponse) SetHeaders(w http.ResponseWriter) {
-	h := w.Header()
-	h.Set(writtenSamplesHeader, strconv.Itoa(r.Samples))
-	h.Set(writtenHistogramsHeader, strconv.Itoa(r.Histograms))
-	h.Set(writtenExemplarsHeader, strconv.Itoa(r.Exemplars))
-	for k, v := range r.ExtraHeaders() {
+func (w *WriteResponse) writeHeaders(rw http.ResponseWriter) {
+	h := rw.Header()
+	h.Set(writtenSamplesHeader, strconv.Itoa(w.Samples))
+	h.Set(writtenHistogramsHeader, strconv.Itoa(w.Histograms))
+	h.Set(writtenExemplarsHeader, strconv.Itoa(w.Exemplars))
+	for k, v := range w.extraHeaders {
 		for _, vv := range v {
 			h.Add(k, vv)
 		}
diff --git a/prometheus/promhttp/http.go b/prometheus/promhttp/http.go
index 763d99e..fd3c763 100644
--- a/prometheus/promhttp/http.go
+++ b/prometheus/promhttp/http.go
@@ -460,7 +460,7 @@ func httpError(rsp http.ResponseWriter, err error) {
 
 // negotiateEncodingWriter reads the Accept-Encoding header from a request and
 // selects the right compression based on an allow-list of supported
-// compressions. It returns a writer implementing the compression and an the
+// compressions. It returns a writer implementing the compression and the
 // correct value that the caller can set in the response header.
 func negotiateEncodingWriter(r *http.Request, rw io.Writer, compressions []string) (_ io.Writer, encodingHeaderValue string, closeWriter func(), _ error) {
 	if len(compressions) == 0 {



Test patch:
diff --git a/api/prometheus/v1/api_test.go b/api/prometheus/v1/api_test.go
index bacf6a0..1c51676 100644
--- a/api/prometheus/v1/api_test.go
+++ b/api/prometheus/v1/api_test.go
@@ -240,6 +240,13 @@ func TestAPIs(t *testing.T) {
 		}
 	}
 
+	doFormatQuery := func(query string) func() (interface{}, Warnings, error) {
+		return func() (interface{}, Warnings, error) {
+			v, err := promAPI.FormatQuery(context.Background(), query)
+			return v, nil, err
+		}
+	}
+
 	queryTests := []apiTest{
 		{
 			do: doQuery("2", testTime, WithTimeout(5*time.Second)),
@@ -348,7 +355,7 @@ func TestAPIs(t *testing.T) {
 			inRes:     []string{"val1", "val2"},
 			reqMethod: "POST",
 			reqPath:   "/api/v1/labels",
-			res:       []string{"val1", "val2"},
+			res:       model.LabelNames{"val1", "val2"},
 		},
 		{
 			do:         doLabelNames(nil, testTime.Add(-100*time.Hour), testTime),
@@ -356,7 +363,7 @@ func TestAPIs(t *testing.T) {
 			inWarnings: []string{"a"},
 			reqMethod:  "POST",
 			reqPath:    "/api/v1/labels",
-			res:        []string{"val1", "val2"},
+			res:        model.LabelNames{"val1", "val2"},
 		},
 
 		{
@@ -379,7 +386,7 @@ func TestAPIs(t *testing.T) {
 			inRes:     []string{"val1", "val2"},
 			reqMethod: "POST",
 			reqPath:   "/api/v1/labels",
-			res:       []string{"val1", "val2"},
+			res:       model.LabelNames{"val1", "val2"},
 		},
 
 		{
@@ -1206,6 +1213,13 @@ func TestAPIs(t *testing.T) {
 				},
 			},
 		},
+		{
+			do:        doFormatQuery("foo/bar"),
+			reqMethod: "POST",
+			reqPath:   "/api/v1/format_query",
+			inRes:     "foo / bar",
+			res:       "\"foo / bar\"",
+		},
 	}
 
 	var tests []apiTest
diff --git a/exp/api/remote/remote_api_test.go b/exp/api/remote/remote_api_test.go
index 655c503..9f982a4 100644
--- a/exp/api/remote/remote_api_test.go
+++ b/exp/api/remote/remote_api_test.go
@@ -71,8 +71,8 @@ type mockStorage struct {
 	mockErr  error
 }
 
-func (m *mockStorage) Store(_ context.Context, msgType WriteMessageType, req *http.Request) (*WriteResponse, error) {
-	w := &WriteResponse{}
+func (m *mockStorage) Store(req *http.Request, msgType WriteMessageType) (*WriteResponse, error) {
+	w := NewWriteResponse()
 	if m.mockErr != nil {
 		if m.mockCode != nil {
 			w.SetStatusCode(*m.mockCode)
@@ -147,7 +147,7 @@ func TestRemoteAPI_Write_WithHandler(t *testing.T) {
 	t.Run("success", func(t *testing.T) {
 		tLogger := slog.Default()
 		mStore := &mockStorage{}
-		srv := httptest.NewServer(NewHandler(mStore, MessageTypes{WriteV2MessageType}, WithHandlerLogger(tLogger)))
+		srv := httptest.NewServer(NewWriteHandler(mStore, MessageTypes{WriteV2MessageType}, WithWriteHandlerLogger(tLogger)))
 		t.Cleanup(srv.Close)
 
 		client, err := NewAPI(srv.URL,
@@ -182,7 +182,7 @@ func TestRemoteAPI_Write_WithHandler(t *testing.T) {
 			mockErr:  errors.New("storage error"),
 			mockCode: &mockCode,
 		}
-		srv := httptest.NewServer(NewHandler(mStore, MessageTypes{WriteV2MessageType}, WithHandlerLogger(tLogger)))
+		srv := httptest.NewServer(NewWriteHandler(mStore, MessageTypes{WriteV2MessageType}, WithWriteHandlerLogger(tLogger)))
 		t.Cleanup(srv.Close)
 
 		client, err := NewAPI(srv.URL,
diff --git a/exp/api/remote/remote_headers_test.go b/exp/api/remote/remote_headers_test.go
index 7327490..7606448 100644
--- a/exp/api/remote/remote_headers_test.go
+++ b/exp/api/remote/remote_headers_test.go
@@ -25,16 +25,16 @@ import (
 func TestWriteResponse(t *testing.T) {
 	t.Run("new response has empty headers", func(t *testing.T) {
 		resp := NewWriteResponse()
-		if len(resp.ExtraHeaders()) != 0 {
-			t.Errorf("expected empty headers, got %v", resp.ExtraHeaders())
+		if len(resp.extraHeaders) != 0 {
+			t.Errorf("expected empty headers, got %v", resp.extraHeaders)
 		}
 	})
 
-	t.Run("setters and getters", func(t *testing.T) {
+	t.Run("setters", func(t *testing.T) {
 		resp := NewWriteResponse()
 
 		resp.SetStatusCode(http.StatusOK)
-		if got := resp.StatusCode(); got != http.StatusOK {
+		if got := resp.statusCode; got != http.StatusOK {
 			t.Errorf("expected status code %d, got %d", http.StatusOK, got)
 		}
 
@@ -66,12 +66,12 @@ func TestWriteResponse(t *testing.T) {
 		}
 
 		resp.SetExtraHeader("Test-Header", "test-value")
-		if got := resp.ExtraHeaders().Get("Test-Header"); got != "test-value" {
+		if got := resp.extraHeaders.Get("Test-Header"); got != "test-value" {
 			t.Errorf("expected header value %q, got %q", "test-value", got)
 		}
 	})
 
-	t.Run("set headers on response writer", func(t *testing.T) {
+	t.Run("writeHeaders", func(t *testing.T) {
 		resp := NewWriteResponse()
 		resp.Add(WriteResponseStats{
 			Samples:    10,
@@ -82,7 +82,7 @@ func TestWriteResponse(t *testing.T) {
 		resp.SetExtraHeader("Custom-Header", "custom-value")
 
 		w := httptest.NewRecorder()
-		resp.SetHeaders(w)
+		resp.writeHeaders(w)
 
 		expectedHeaders := map[string]string{
 			"Custom-Header": "custom-value",