Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Top‑level packages worth knowing:
- `cmd/alertmanager/` — main binary entry point (`main.go`); thin wrapper that parses flags and calls `app`.
- `app/` — embeddable Alertmanager runtime extracted from `cmd/alertmanager`. Owns the process lifecycle (`New`/`Start`/`Stop`/`Reload`/`Run`), subsystem wiring (`setup`), config-reload subgraph (`reloader`), listeners and `Options`. Lets tests and other binaries run Alertmanager in‑process. See https://github.com/prometheus/alertmanager/issues/406.
- `cmd/amtool/` — CLI for interacting with the Alertmanager API.
- `api/` — HTTP API. `api/v2/` is the active API; `api/v1_deprecation_router.go` only returns deprecation responses.
- `api/` — HTTP API. `api/v2/` is the active REST API; `api/connect/` is the experimental ConnectRPC API mounted under `/api/`; `api/v1_deprecation_router.go` only returns deprecation responses.
Comment thread
siavashs marked this conversation as resolved.
- `cli/` — `amtool` command implementations.
- `cluster/` — HA gossip clustering (memberlist-based).
- `config/` — YAML config parsing, validation, secrets, coordinator.
Expand Down Expand Up @@ -98,6 +98,7 @@ goreman start
- Errors: wrap with `fmt.Errorf("...: %w", err)` and check with `errors.Is`/`errors.As` (`errorlint`).
- Keep package‑level documentation up to date (`revive: package-comments`).
- Tests live next to the code as `*_test.go`. Larger integration tests live under `test/`. The `notify/test` package provides shared testing helpers for notifier integrations.
- Use Ginkgo/Gomega for new Connect API tests under `api/connect/` and `test/e2e/`. Existing shared API and v2 tests retain their current testing style.

## When changing the API

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## main / (unreleased)

* [CHANGE] eventrecorder: The output data format now uses the new `events/v2` schema. This is a breaking change: alert labels, alert annotations, group labels, silence annotations, and muted-alert labels are JSON maps, and protobuf consumers must use the new schema.
* [FEATURE] api: Add an experimental ConnectRPC API served alongside `/api/v2/` under the version-neutral `/api/` prefix, exposing the Connect, gRPC, and gRPC-Web protocols plus the gRPC Health Checking Protocol and server reflection. The first service is `status.v3alpha.StatusService`.

## 0.34.0 / 2026-08-16

Expand Down
136 changes: 117 additions & 19 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"errors"
"fmt"
"log/slog"
"mime"
"net/http"
"runtime"
"strings"
Expand All @@ -30,6 +31,7 @@ import (
"github.com/prometheus/common/route"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

apiconnect "github.com/prometheus/alertmanager/api/connect"
apiv2 "github.com/prometheus/alertmanager/api/v2"
"github.com/prometheus/alertmanager/cluster"
"github.com/prometheus/alertmanager/config"
Expand All @@ -42,6 +44,7 @@ import (
// API represents all APIs of Alertmanager.
type API struct {
v2 *apiv2.API
connect *apiconnect.API
deprecationRouter *V1DeprecationRouter

requestDuration *prometheus.HistogramVec
Expand All @@ -64,13 +67,14 @@ type Options struct {
GroupMutedFunc func(routeID, groupKey string) ([]string, bool)
// Peer from the gossip cluster. If nil, no clustering will be used.
Peer cluster.ClusterPeer
// Timeout for all HTTP connections. The zero value (and negative
// values) result in no timeout.
// Timeout for HTTP requests and Connect unary RPCs. The zero value (and
// negative values) result in no timeout.
Timeout time.Duration
// Concurrency limit for GET requests. The zero value (and negative
// values) result in a limit of GOMAXPROCS or 8, whichever is
// larger. Status code 503 is served for GET requests that would exceed
// the concurrency limit.
// Concurrency limit for GET requests and, independently, Connect unary
// RPCs and streams. The zero value (and negative values) result in a
// limit of GOMAXPROCS or 8, whichever is larger. Status code 503 is served
// for GET requests that would exceed the concurrency limit; Connect calls
// receive ResourceExhausted.
Concurrency int
// Logger is used for logging, if nil, no logging will happen.
Logger *slog.Logger
Expand Down Expand Up @@ -116,6 +120,7 @@ func New(opts Options) (*API, error) {
concurrency = max(runtime.GOMAXPROCS(0), 8)
}

// The Connect API is always mounted alongside API v2.
v2, err := apiv2.NewAPI(
opts.Alerts,
opts.GroupFunc,
Expand All @@ -128,6 +133,12 @@ func New(opts Options) (*API, error) {
if err != nil {
return nil, err
}
connect := apiconnect.NewAPI(apiconnect.Options{
Peer: opts.Peer,
UnaryConcurrency: concurrency,
StreamConcurrency: concurrency,
UnaryTimeout: opts.Timeout,
})

requestsInFlight := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "alertmanager_http_requests_in_flight",
Expand All @@ -151,6 +162,7 @@ func New(opts Options) (*API, error) {
return &API{
deprecationRouter: NewV1DeprecationRouter(l.With("version", "v1")),
v2: v2,
connect: connect,
requestDuration: opts.RequestDuration,
requestsInFlight: requestsInFlight,
concurrencyLimitExceeded: concurrencyLimitExceeded,
Expand All @@ -162,21 +174,41 @@ func New(opts Options) (*API, error) {
// Register API. As APIv2 works on the http.Handler level, this method also creates a new
// http.ServeMux and then uses it to register both the provided router (to
// handle "/") and APIv2 (to handle "<routePrefix>/api/v2"). The method returns
// the newly created http.ServeMux. If a timeout has been set on construction of
// API, it is enforced for all HTTP request going through this mux. The same is
// true for the concurrency limit, with the exception that it is only applied to
// GET requests.
// the newly created http.ServeMux. Configured timeouts apply to regular HTTP
// requests and Connect unary RPCs. Regular HTTP GETs and Connect RPCs use
// independent concurrency limits; streams also have a separate limit.
func (api *API) Register(r *route.Router, routePrefix string) *http.ServeMux {
// TODO(gotjosh) API V1 was removed as of version 0.27, when we reach 1.0.0 we should removed these deprecation warnings.
api.deprecationRouter.Register(r.WithPrefix("/api/v1"))

mux := http.NewServeMux()
mux.Handle("/", api.limitHandler(r))
connectHandler := api.connect.Handler()
// ConnectRPC procedure paths are the only bounded label values on the
// Connect/gRPC surface; any other path yields a 404 and must not be
// recorded verbatim, or a client could inflate metric/trace cardinality.
servicePrefixes := api.connect.ServicePrefixes()
// Native gRPC is served at the server root, so match against the raw
// request path (no mount prefix).
grpcHandler := api.instrumentConnectHandler("", servicePrefixes, connectHandler)
webHandler := api.limitHandler(r)
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isGRPCRequest(r) {
grpcHandler.ServeHTTP(w, r)
return
}
webHandler.ServeHTTP(w, r)
}))

apiPrefix := ""
if routePrefix != "/" {
apiPrefix = routePrefix
}

// The v1 deprecation routes live on the base router r (mounted at "/").
// Re-register them under the more specific /api/v1/ prefix so the
// version-neutral Connect /api/ catch-all below does not shadow them.
mux.Handle(apiPrefix+"/api/v1/", api.limitHandler(r))

mux.Handle(
apiPrefix+"/api/v2/",
api.instrumentHandler(
Expand All @@ -190,17 +222,60 @@ func (api *API) Register(r *route.Router, routePrefix string) *http.ServeMux {
),
)

// Connect and gRPC-Web procedures are fully-qualified and carry their own
// service version (e.g. /status.v3alpha.StatusService/GetStatus), so mount them
// behind a version-neutral /api/ prefix. Native gRPC remains at the root so
// standard clients do not need path-prefix support. The more specific
// /api/v1/ and /api/v2/ patterns above win via longest-prefix matching.
mux.Handle(
apiPrefix+"/api/",
api.instrumentConnectHandler(
apiPrefix+"/api",
servicePrefixes,
http.StripPrefix(apiPrefix+"/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isGRPCRequest(r) {
http.NotFound(w, r)
return
}
connectHandler.ServeHTTP(w, r)
})),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
),
)

return mux
}

func isGRPCRequest(r *http.Request) bool {
if r.ProtoMajor != 2 {
return false
}
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
return err == nil && (mediaType == "application/grpc" || strings.HasPrefix(mediaType, "application/grpc+"))
}

// Update config and resolve timeout of each API. APIv2 also needs
// setAlertStatus to be updated.
func (api *API) Update(cfg *config.Config, setAlertStatus func(ctx context.Context, labels model.LabelSet)) {
api.v2.Update(cfg, setAlertStatus)
if api.v2 != nil {
api.v2.Update(cfg, setAlertStatus)
}
if api.connect != nil {
api.connect.Update(cfg)
}
}

func (api *API) limitHandler(h http.Handler) http.Handler {
concLimiter := http.HandlerFunc(func(rsp http.ResponseWriter, req *http.Request) {
limited := api.concurrencyLimitHandler(h)
if api.timeout <= 0 {
return limited
}
return http.TimeoutHandler(limited, api.timeout, fmt.Sprintf(
"Exceeded configured timeout of %v.\n", api.timeout,
))
}

func (api *API) concurrencyLimitHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(rsp http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet { // Only limit concurrency of GETs.
select {
case api.inFlightSem <- struct{}{}: // All good, carry on.
Expand All @@ -219,12 +294,6 @@ func (api *API) limitHandler(h http.Handler) http.Handler {
}
h.ServeHTTP(rsp, req)
})
if api.timeout <= 0 {
return concLimiter
}
return http.TimeoutHandler(concLimiter, api.timeout, fmt.Sprintf(
"Exceeded configured timeout of %v.\n", api.timeout,
))
}

func (api *API) instrumentHandler(prefix string, h http.Handler) http.Handler {
Expand All @@ -240,3 +309,32 @@ func (api *API) instrumentHandler(prefix string, h http.Handler) http.Handler {
).ServeHTTP(w, r)
})
}

// unmatchedRPCLabel is the placeholder handler label and trace span name used
// for Connect/gRPC requests whose path does not correspond to a registered
// service. Collapsing these to a single value keeps clients from inflating
// metric and trace cardinality by hitting arbitrary paths.
const unmatchedRPCLabel = "unmatched"

// instrumentConnectHandler is like instrumentHandler but bounds label and
// span cardinality for the Connect/gRPC surface. Requests whose path (after
// stripping mountPrefix) matches a registered service are recorded under that
// service's prefix; the trailing method segment is intentionally dropped so a
// client cannot inflate cardinality by appending arbitrary (and 404-ing)
// method names. Everything else collapses to unmatchedRPCLabel.
func (api *API) instrumentConnectHandler(mountPrefix string, servicePrefixes []string, h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
procedure, _ := strings.CutPrefix(r.URL.Path, mountPrefix)
label := unmatchedRPCLabel
for _, p := range servicePrefixes {
if strings.HasPrefix(procedure, p) {
label = p
break
}
}
promhttp.InstrumentHandlerDuration(
api.requestDuration.MustCurryWith(prometheus.Labels{"handler": label}),
otelhttp.NewHandler(h, label),
).ServeHTTP(w, r)
})
}
Loading
Loading