groupware: jmap: add metrics

This commit is contained in:
Pascal Bleser
2025-08-27 17:23:24 +02:00
parent 306e5a0dce
commit 6af523c290
5 changed files with 106 additions and 25 deletions

View File

@@ -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 = "*"
)

View File

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

View File

@@ -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

View File

@@ -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 {

View File

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