From 6af523c29068aeebd6e28fd924038b0384946ffe Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Wed, 27 Aug 2025 17:23:24 +0200
Subject: [PATCH] groupware: jmap: add metrics
---
pkg/jmap/jmap_api.go | 27 ++++++++---------
pkg/jmap/jmap_client.go | 8 +++---
pkg/jmap/jmap_http.go | 62 +++++++++++++++++++++++++++++++++++-----
pkg/jmap/jmap_model.go | 2 +-
pkg/jmap/jmap_session.go | 32 +++++++++++++++++++++
5 files changed, 106 insertions(+), 25 deletions(-)
diff --git a/pkg/jmap/jmap_api.go b/pkg/jmap/jmap_api.go
index bf996e1dc..97a6ec983 100644
--- a/pkg/jmap/jmap_api.go
+++ b/pkg/jmap/jmap_api.go
@@ -22,19 +22,20 @@ type BlobClient interface {
}
const (
- logOperation = "operation"
- logUsername = "username"
- logAccountId = "account-id"
- logMailboxId = "mailbox-id"
- logFetchBodies = "fetch-bodies"
- logOffset = "offset"
- logLimit = "limit"
- logApiUrl = "apiurl"
- logDownloadUrl = "downloadurl"
- logBlobId = "blobId"
- logUploadUrl = "downloadurl"
- logSessionState = "session-state"
- logSince = "since"
+ logHttpStatusCode = "status"
+ logOperation = "operation"
+ logUsername = "username"
+ logAccountId = "account-id"
+ logMailboxId = "mailbox-id"
+ logFetchBodies = "fetch-bodies"
+ logOffset = "offset"
+ logLimit = "limit"
+ logApiUrl = "apiurl"
+ logDownloadUrl = "downloadurl"
+ logBlobId = "blobId"
+ logUploadUrl = "downloadurl"
+ logSessionState = "session-state"
+ logSince = "since"
defaultAccountId = "*"
)
diff --git a/pkg/jmap/jmap_client.go b/pkg/jmap/jmap_client.go
index 3b487c840..3097b6e1c 100644
--- a/pkg/jmap/jmap_client.go
+++ b/pkg/jmap/jmap_client.go
@@ -8,7 +8,7 @@ import (
)
type Client struct {
- wellKnown SessionClient
+ session SessionClient
api ApiClient
blob BlobClient
sessionEventListeners *eventListeners[SessionEventListener]
@@ -19,9 +19,9 @@ func (j *Client) Close() error {
return j.api.Close()
}
-func NewClient(wellKnown SessionClient, api ApiClient, blob BlobClient) Client {
+func NewClient(sessionClient SessionClient, api ApiClient, blob BlobClient) Client {
return Client{
- wellKnown: wellKnown,
+ session: sessionClient,
api: api,
blob: blob,
sessionEventListeners: newEventListeners[SessionEventListener](),
@@ -40,7 +40,7 @@ func (j *Client) onSessionOutdated(session *Session, newSessionState SessionStat
// Retrieve JMAP well-known data from the Stalwart server and create a Session from that.
func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Error) {
- wk, err := j.wellKnown.GetSession(username, logger)
+ wk, err := j.session.GetSession(username, logger)
if err != nil {
return Session{}, err
}
diff --git a/pkg/jmap/jmap_http.go b/pkg/jmap/jmap_http.go
index 13851d2f3..d7830ca4e 100644
--- a/pkg/jmap/jmap_http.go
+++ b/pkg/jmap/jmap_http.go
@@ -10,6 +10,8 @@ import (
"net/url"
"strconv"
+ "github.com/prometheus/client_golang/prometheus"
+
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/version"
)
@@ -20,6 +22,7 @@ type HttpJmapApiClient struct {
masterUser string
masterPassword string
userAgent string
+ metrics HttpJmapApiClientMetrics
}
var (
@@ -34,13 +37,20 @@ func bearer(req *http.Request, token string) {
}
*/
-func NewHttpJmapApiClient(baseurl url.URL, client *http.Client, masterUser string, masterPassword string) *HttpJmapApiClient {
+type HttpJmapApiClientMetrics struct {
+ SuccessfulRequestPerEndpointCounter *prometheus.CounterVec
+ FailedRequestPerEndpointCounter *prometheus.CounterVec
+ FailedRequestStatusPerEndpointCounter *prometheus.CounterVec
+}
+
+func NewHttpJmapApiClient(baseurl url.URL, client *http.Client, masterUser string, masterPassword string, metrics HttpJmapApiClientMetrics) *HttpJmapApiClient {
return &HttpJmapApiClient{
baseurl: baseurl,
client: client,
masterUser: masterUser,
masterPassword: masterPassword,
userAgent: "OpenCloud/" + version.GetString(),
+ metrics: metrics,
}
}
@@ -67,11 +77,12 @@ func (h *HttpJmapApiClient) auth(username string, _ *log.Logger, req *http.Reque
}
func (h *HttpJmapApiClient) GetSession(username string, logger *log.Logger) (SessionResponse, Error) {
- wellKnownUrl := h.baseurl.JoinPath(".well-known", "jmap").String()
+ sessionUrl := h.baseurl.JoinPath(".well-known", "jmap")
+ sessionUrlStr := sessionUrl.String()
- req, err := http.NewRequest(http.MethodGet, wellKnownUrl, nil)
+ req, err := http.NewRequest(http.MethodGet, sessionUrlStr, nil)
if err != nil {
- logger.Error().Err(err).Msgf("failed to create GET request for %v", wellKnownUrl)
+ logger.Error().Err(err).Msgf("failed to create GET request for %v", sessionUrl)
return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err}
}
h.auth(username, logger, req)
@@ -79,13 +90,23 @@ func (h *HttpJmapApiClient) GetSession(username string, logger *log.Logger) (Ses
res, err := h.client.Do(req)
if err != nil {
- logger.Error().Err(err).Msgf("failed to perform GET %v", wellKnownUrl)
+ if h.metrics.FailedRequestPerEndpointCounter != nil {
+ h.metrics.FailedRequestPerEndpointCounter.WithLabelValues(endpointOf(sessionUrl)).Inc()
+ }
+ logger.Error().Err(err).Msgf("failed to perform GET %v", sessionUrl)
return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err}
}
if res.StatusCode < 200 || res.StatusCode > 299 {
- logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 200")
+ if h.metrics.FailedRequestStatusPerEndpointCounter != nil {
+ h.metrics.FailedRequestStatusPerEndpointCounter.WithLabelValues(endpointOf(sessionUrl), strconv.Itoa(res.StatusCode)).Inc()
+ }
+ logger.Error().Str(logHttpStatusCode, res.Status).Msg("HTTP response status code is not 200")
return SessionResponse{}, SimpleError{code: JmapErrorServerResponse, err: fmt.Errorf("JMAP API response status is %v", res.Status)}
}
+ if h.metrics.SuccessfulRequestPerEndpointCounter != nil {
+ h.metrics.SuccessfulRequestPerEndpointCounter.WithLabelValues(endpointOf(sessionUrl)).Inc()
+ }
+
if res.Body != nil {
defer func(Body io.ReadCloser) {
err := Body.Close()
@@ -104,7 +125,7 @@ func (h *HttpJmapApiClient) GetSession(username string, logger *log.Logger) (Ses
var data SessionResponse
err = json.Unmarshal(body, &data)
if err != nil {
- logger.Error().Str("url", wellKnownUrl).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response")
+ logger.Error().Str("url", sessionUrlStr).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response")
return SessionResponse{}, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
}
@@ -131,10 +152,16 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses
res, err := h.client.Do(req)
if err != nil {
+ if h.metrics.FailedRequestPerEndpointCounter != nil {
+ h.metrics.FailedRequestPerEndpointCounter.WithLabelValues(session.JmapEndpoint).Inc()
+ }
logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl)
return nil, SimpleError{code: JmapErrorSendingRequest, err: err}
}
if res.StatusCode < 200 || res.StatusCode > 299 {
+ if h.metrics.FailedRequestStatusPerEndpointCounter != nil {
+ h.metrics.FailedRequestStatusPerEndpointCounter.WithLabelValues(session.JmapEndpoint, strconv.Itoa(res.StatusCode)).Inc()
+ }
logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx")
return nil, SimpleError{code: JmapErrorServerResponse, err: err}
}
@@ -146,6 +173,9 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses
}
}(res.Body)
}
+ if h.metrics.SuccessfulRequestPerEndpointCounter != nil {
+ h.metrics.SuccessfulRequestPerEndpointCounter.WithLabelValues(session.JmapEndpoint).Inc()
+ }
body, err := io.ReadAll(res.Body)
if err != nil {
@@ -168,10 +198,16 @@ func (h *HttpJmapApiClient) UploadBinary(ctx context.Context, logger *log.Logger
res, err := h.client.Do(req)
if err != nil {
+ if h.metrics.FailedRequestPerEndpointCounter != nil {
+ h.metrics.FailedRequestPerEndpointCounter.WithLabelValues(session.UploadEndpoint).Inc()
+ }
logger.Error().Err(err).Msgf("failed to perform POST %v", uploadUrl)
return UploadedBlob{}, SimpleError{code: JmapErrorSendingRequest, err: err}
}
if res.StatusCode < 200 || res.StatusCode > 299 {
+ if h.metrics.FailedRequestStatusPerEndpointCounter != nil {
+ h.metrics.FailedRequestStatusPerEndpointCounter.WithLabelValues(session.UploadEndpoint, strconv.Itoa(res.StatusCode)).Inc()
+ }
logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx")
return UploadedBlob{}, SimpleError{code: JmapErrorServerResponse, err: err}
}
@@ -183,6 +219,9 @@ func (h *HttpJmapApiClient) UploadBinary(ctx context.Context, logger *log.Logger
}
}(res.Body)
}
+ if h.metrics.SuccessfulRequestPerEndpointCounter != nil {
+ h.metrics.SuccessfulRequestPerEndpointCounter.WithLabelValues(session.UploadEndpoint).Inc()
+ }
responseBody, err := io.ReadAll(res.Body)
if err != nil {
@@ -211,6 +250,9 @@ func (h *HttpJmapApiClient) DownloadBinary(ctx context.Context, logger *log.Logg
res, err := h.client.Do(req)
if err != nil {
+ if h.metrics.FailedRequestPerEndpointCounter != nil {
+ h.metrics.FailedRequestPerEndpointCounter.WithLabelValues(session.DownloadEndpoint).Inc()
+ }
logger.Error().Err(err).Msgf("failed to perform GET %v", downloadUrl)
return nil, SimpleError{code: JmapErrorSendingRequest, err: err}
}
@@ -218,9 +260,15 @@ func (h *HttpJmapApiClient) DownloadBinary(ctx context.Context, logger *log.Logg
return nil, nil
}
if res.StatusCode < 200 || res.StatusCode > 299 {
+ if h.metrics.FailedRequestStatusPerEndpointCounter != nil {
+ h.metrics.FailedRequestStatusPerEndpointCounter.WithLabelValues(session.DownloadEndpoint, strconv.Itoa(res.StatusCode)).Inc()
+ }
logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx")
return nil, SimpleError{code: JmapErrorServerResponse, err: err}
}
+ if h.metrics.SuccessfulRequestPerEndpointCounter != nil {
+ h.metrics.SuccessfulRequestPerEndpointCounter.WithLabelValues(session.DownloadEndpoint).Inc()
+ }
sizeStr := res.Header.Get("Content-Length")
size := -1
diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go
index eabd567d7..9aa1d6df7 100644
--- a/pkg/jmap/jmap_model.go
+++ b/pkg/jmap/jmap_model.go
@@ -231,7 +231,7 @@ type SessionAccount struct {
IsPersonal bool `json:"isPersonal"`
// This is true if the entire account is read-only.
IsReadOnly bool `json:"isReadOnly"`
- AccountCapabilities SessionAccountCapabilities `json:"accountCapabilities,omitempty"`
+ AccountCapabilities SessionAccountCapabilities `json:"accountCapabilities"`
}
type SessionCoreCapabilities struct {
diff --git a/pkg/jmap/jmap_session.go b/pkg/jmap/jmap_session.go
index 0138943e3..12013724a 100644
--- a/pkg/jmap/jmap_session.go
+++ b/pkg/jmap/jmap_session.go
@@ -2,6 +2,7 @@ package jmap
import (
"errors"
+ "fmt"
"net/url"
"github.com/opencloud-eu/opencloud/pkg/log"
@@ -36,12 +37,18 @@ type Session struct {
// The base URL to use for JMAP operations towards Stalwart
JmapUrl url.URL
+ // An identifier of the JmapUrl to use in metrics and tracing
+ JmapEndpoint string
// The upload URL template
UploadUrlTemplate string
+ // An identifier of the UploadUrlTemplate to use in metrics and tracing
+ UploadEndpoint string
// The upload URL template
DownloadUrlTemplate string
+ // An identifier of the DownloadUrlTemplate to use in metrics and tracing
+ DownloadEndpoint string
SessionResponse
}
@@ -68,20 +75,28 @@ func newSession(sessionResponse SessionResponse) (Session, Error) {
if err != nil {
return Session{}, invalidSessionResponseErrorInvalidApiUrl
}
+ apiEndpoint := endpointOf(apiUrl)
+
uploadUrl := sessionResponse.UploadUrl
if uploadUrl == "" {
return Session{}, invalidSessionResponseErrorMissingUploadUrl
}
+ uploadEndpoint := toEndpoint(uploadUrl)
+
downloadUrl := sessionResponse.DownloadUrl
if downloadUrl == "" {
return Session{}, invalidSessionResponseErrorMissingDownloadUrl
}
+ downloadEndpoint := toEndpoint(downloadUrl)
return Session{
Username: username,
JmapUrl: *apiUrl,
+ JmapEndpoint: apiEndpoint,
UploadUrlTemplate: uploadUrl,
+ UploadEndpoint: uploadEndpoint,
DownloadUrlTemplate: downloadUrl,
+ DownloadEndpoint: downloadEndpoint,
SessionResponse: sessionResponse,
}, nil
}
@@ -117,3 +132,20 @@ func (s Session) DecorateLogger(l log.Logger) *log.Logger {
Str(logApiUrl, s.ApiUrl).
Str(logSessionState, string(s.State)))
}
+
+func endpointOf(u *url.URL) string {
+ if u != nil {
+ return fmt.Sprintf("%s://%s", u.Scheme, u.Host)
+ } else {
+ return ""
+ }
+}
+
+func toEndpoint(str string) string {
+ u, err := url.Parse(str)
+ if err == nil {
+ return endpointOf(u)
+ } else {
+ return str
+ }
+}