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 + } +}