groupware: add body tracing to WS, fix metrics registrations, add operation identifiers for metrics

This commit is contained in:
Pascal Bleser
2026-07-02 20:56:02 +02:00
parent 17d4dd3a5b
commit d3ce94aa26
19 changed files with 335 additions and 214 deletions

View File

@@ -26,7 +26,7 @@ func (c Context) WithContext(newContext context.Context) Context {
}
type ApiClient interface {
Command(request Request, ctx Context) (io.ReadCloser, Language, Error)
Command(operation Operation, request Request, ctx Context) (io.ReadCloser, Language, Error)
io.Closer
}
@@ -50,8 +50,8 @@ type SessionClient interface {
}
type BlobClient interface {
UploadBinary(uploadUrl string, endpoint string, contentType string, content io.Reader, ctx Context) (UploadedBlob, Language, Error)
DownloadBinary(downloadUrl string, endpoint string, ctx Context) (*BlobDownload, Language, Error)
UploadBinary(uploadUrl string, operation Operation, endpoint string, contentType string, content io.Reader, ctx Context) (UploadedBlob, Language, Error)
DownloadBinary(downloadUrl string, operation Operation, endpoint string, ctx Context) (*BlobDownload, Language, Error)
io.Closer
}

View File

@@ -25,7 +25,7 @@ func (j *Client) GetBlobMetadata(accountId AccountId, ids []string, ctx Context)
return ZeroResultV[BlobGetResponse](), jerr
}
return command(j, ctx, cmd, func(body *Response) (BlobGetResponse, State, Error) {
return command(j, Operation("GetBlobMetadata"), ctx, cmd, func(body *Response) (BlobGetResponse, State, Error) {
var response BlobGetResponse
err := retrieveGet(ctx, body, get, "0", &response)
if err != nil {
@@ -48,7 +48,7 @@ func (j *Client) UploadBlobStream(accountId AccountId, contentType string, body
uploadUrl := strings.NewReplacer(
"{accountId}", url.PathEscape(string(accountId)),
).Replace(ctx.Session.UploadUrlTemplate)
return j.blob.UploadBinary(uploadUrl, ctx.Session.UploadEndpoint, contentType, body, ctx)
return j.blob.UploadBinary(uploadUrl, Operation("UploadBlobStream"), ctx.Session.UploadEndpoint, contentType, body, ctx)
}
func (j *Client) DownloadBlobStream(accountId AccountId, blobId string, name string, typ string, ctx Context) (*BlobDownload, Language, error) { //NOSONAR
@@ -61,7 +61,7 @@ func (j *Client) DownloadBlobStream(accountId AccountId, blobId string, name str
"{type}", url.PathEscape(typ),
).Replace(ctx.Session.DownloadUrlTemplate)
logger = log.From(logger.With().Str(logDownloadUrl, downloadUrl).Str(logBlobId, blobId))
return j.blob.DownloadBinary(downloadUrl, ctx.Session.DownloadEndpoint, ctx)
return j.blob.DownloadBinary(downloadUrl, Operation("DownloadBlobStream"), ctx.Session.DownloadEndpoint, ctx)
}
func (j *Client) UploadBlob(accountId AccountId, data []byte, contentType string, ctx Context) (Result[UploadedBlobWithHash], error) {
@@ -97,7 +97,7 @@ func (j *Client) UploadBlob(accountId AccountId, data []byte, contentType string
return ZeroResultV[UploadedBlobWithHash](), jerr
}
return command(j, ctx, cmd, func(body *Response) (UploadedBlobWithHash, State, Error) {
return command(j, Operation("UploadBlob"), ctx, cmd, func(body *Response) (UploadedBlobWithHash, State, Error) {
var uploadResponse BlobUploadResponse
err := retrieveUpload(ctx, body, upload, "0", &uploadResponse)
if err != nil {

View File

@@ -27,7 +27,7 @@ func (j *Client) GetBootstrap(accountIds []AccountId, ctx Context) (Result[map[A
if err != nil {
return ZeroResultV[map[AccountId]AccountBootstrapResult](), err
}
return command(j, ctx, cmd, func(body *Response) (map[AccountId]AccountBootstrapResult, State, Error) {
return command(j, Operation("GetBootstrap"), ctx, cmd, func(body *Response) (map[AccountId]AccountBootstrapResult, State, Error) {
identityPerAccount := map[AccountId][]Identity{}
quotaPerAccount := map[AccountId][]Quota{}
identityStatesPerAccount := map[AccountId]State{}

View File

@@ -13,7 +13,7 @@ func (j *Client) ParseICalendarBlob(accountId AccountId, blobIds []string, ctx C
return ZeroResultV[CalendarEventParseResponse](), err
}
return command(j, ctx, cmd, func(body *Response) (CalendarEventParseResponse, State, Error) {
return command(j, Operation("ParseICalendarBlob"), ctx, cmd, func(body *Response) (CalendarEventParseResponse, State, Error) {
var response CalendarEventParseResponse
err = retrieveParse(ctx, body, parse, "0", &response)
if err != nil {

View File

@@ -110,7 +110,7 @@ func (j *Client) GetChanges(accountId AccountId, stateMap StateMap, maxChanges u
return ZeroResultV[ObjectChanges](), err
}
return command(j, ctx, cmd, func(body *Response) (ObjectChanges, State, Error) {
return command(j, Operation("GetChanges"), ctx, cmd, func(body *Response) (ObjectChanges, State, Error) {
changes := ObjectChanges{
MaxChanges: maxChanges,
}

View File

@@ -53,7 +53,7 @@ func (j *Client) GetEmails(accountId AccountId, ids []string, //NOSONAR
if err != nil {
return ZeroResultV[EmailGetResponse](), err
}
return command(j, ctx, cmd, func(body *Response) (EmailGetResponse, State, Error) {
return command(j, Operation("GetEmails"), ctx, cmd, func(body *Response) (EmailGetResponse, State, Error) {
if markAsSeen {
var markResponse EmailSetResponse
err = retrieveSet(ctx, body, markEmails, "0", &markResponse)
@@ -91,7 +91,7 @@ func (j *Client) GetEmailBlobId(accountId AccountId, id string, ctx Context) (Re
if err != nil {
return ZeroResultV[string](), err
}
return command(j, ctx, cmd, func(body *Response) (string, State, Error) {
return command(j, Operation("GetEmailBlobId"), ctx, cmd, func(body *Response) (string, State, Error) {
var response EmailGetResponse
err = retrieveGet(ctx, body, get, "0", &response)
if err != nil {
@@ -175,7 +175,7 @@ func (j *Client) GetAllEmailsInMailbox(accountId AccountId, mailboxId string, //
return ZeroResultV[*EmailSearchResults](), err
}
return command(j, ctx, cmd, func(body *Response) (*EmailSearchResults, State, Error) {
return command(j, Operation("GetAllEmailsInMailbox"), ctx, cmd, func(body *Response) (*EmailSearchResults, State, Error) {
var queryResponse EmailQueryResponse
err = retrieveQuery(ctx, body, query, "0", &queryResponse)
if err != nil {
@@ -262,7 +262,7 @@ func (j *Client) GetEmailChanges(accountId AccountId,
return ZeroResultV[EmailChanges](), err
}
return command(j, ctx, cmd, func(body *Response) (EmailChanges, State, Error) {
return command(j, Operation("GetEmailChanges"), ctx, cmd, func(body *Response) (EmailChanges, State, Error) {
var changesResponse EmailChangesResponse
err = retrieveChanges(ctx, body, changes, "0", &changesResponse)
if err != nil {
@@ -361,7 +361,7 @@ func (j *Client) QueryEmailSnippets(accountIds []AccountId, //NOSONAR
return ZeroResultV[map[AccountId]EmailSnippetSearchResults](), err
}
return command(j, ctx, cmd, func(body *Response) (map[AccountId]EmailSnippetSearchResults, State, Error) {
return command(j, Operation("QueryEmailSnippets"), ctx, cmd, func(body *Response) (map[AccountId]EmailSnippetSearchResults, State, Error) {
results := make(map[AccountId]EmailSnippetSearchResults, len(uniqueAccountIds))
states := make(map[AccountId]State, len(uniqueAccountIds))
for _, accountId := range uniqueAccountIds {
@@ -473,7 +473,7 @@ func (j *Client) QueryEmails(accountIds []AccountId,
return ZeroResultV[map[AccountId]EmailSearchResults](), err
}
return command(j, ctx, cmd, func(body *Response) (map[AccountId]EmailSearchResults, State, Error) {
return command(j, Operation("QueryEmails"), ctx, cmd, func(body *Response) (map[AccountId]EmailSearchResults, State, Error) {
results := make(map[AccountId]EmailSearchResults, len(uniqueAccountIds))
queryStates := map[AccountId]State{}
for _, accountId := range uniqueAccountIds {
@@ -568,7 +568,7 @@ func (j *Client) QueryEmailsWithSnippets(accountIds []AccountId, //NOSONAR
return ZeroResultV[map[AccountId]EmailQueryWithSnippetsResult](), err
}
return command(j, ctx, cmd, func(body *Response) (map[AccountId]EmailQueryWithSnippetsResult, State, Error) {
return command(j, Operation("QueryEmailsWithSnippets"), ctx, cmd, func(body *Response) (map[AccountId]EmailQueryWithSnippetsResult, State, Error) {
result := make(map[AccountId]EmailQueryWithSnippetsResult, len(uniqueAccountIds))
for _, accountId := range uniqueAccountIds {
var queryResponse EmailQueryResponse
@@ -662,7 +662,7 @@ func (j *Client) ImportEmail(accountId AccountId, data []byte, ctx Context) (Res
return ZeroResultV[UploadedEmail](), err
}
return command(j, ctx, cmd, func(body *Response) (UploadedEmail, State, Error) {
return command(j, Operation("ImportEmail"), ctx, cmd, func(body *Response) (UploadedEmail, State, Error) {
var uploadResponse BlobUploadResponse
err = retrieveResponseMatchParameters(ctx, body, CommandBlobUpload, "0", &uploadResponse)
if err != nil {
@@ -720,7 +720,7 @@ func (j *Client) CreateEmail(accountId AccountId, email EmailChange, replaceId s
return ZeroResultV[*Email](), err
}
return command(j, ctx, cmd, func(body *Response) (*Email, State, Error) {
return command(j, Operation("CreateEmail"), ctx, cmd, func(body *Response) (*Email, State, Error) {
var setResponse EmailSetResponse
err = retrieveResponseMatchParameters(ctx, body, CommandEmailSet, "0", &setResponse)
if err != nil {
@@ -767,7 +767,7 @@ func (j *Client) UpdateEmails(accountId AccountId, updates map[string]PatchObjec
return ZeroResultV[map[string]*Email](), err
}
return command(j, ctx, cmd, func(body *Response) (map[string]*Email, State, Error) {
return command(j, Operation("UpdateEmails"), ctx, cmd, func(body *Response) (map[string]*Email, State, Error) {
var setResponse EmailSetResponse
err = retrieveSet(ctx, body, set, "0", &setResponse)
if err != nil {
@@ -881,7 +881,7 @@ func (j *Client) SubmitEmail(accountId AccountId, identityId string, emailId str
return ZeroResultV[EmailSubmission](), err
}
return command(j, ctx, cmd, func(body *Response) (EmailSubmission, State, Error) {
return command(j, Operation("SubmitEmail"), ctx, cmd, func(body *Response) (EmailSubmission, State, Error) {
var submissionResponse EmailSubmissionSetResponse
err = retrieveSet(ctx, body, submit, "0", &submissionResponse)
if err != nil {
@@ -939,7 +939,7 @@ func (j *Client) GetEmailSubmissionStatus(accountId AccountId, submissionIds []s
return ZeroResultV[EmailSubmissionGetResponse](), err
}
return command(j, ctx, cmd, func(body *Response) (EmailSubmissionGetResponse, State, Error) {
return command(j, Operation("GetEmailSubmissionStatus"), ctx, cmd, func(body *Response) (EmailSubmissionGetResponse, State, Error) {
var response EmailSubmissionGetResponse
err = retrieveGet(ctx, body, get, "0", &response)
if err != nil {
@@ -981,7 +981,7 @@ func (j *Client) EmailsInThread(accountId AccountId, threadId string,
return ZeroResultV[[]Email](), err
}
return command(j, ctx, cmd, func(body *Response) ([]Email, State, Error) {
return command(j, Operation("EmailsInThread"), ctx, cmd, func(body *Response) ([]Email, State, Error) {
var emailsResponse EmailGetResponse
err = retrieveGet(ctx, body, get, "1", &emailsResponse)
if err != nil {
@@ -1070,7 +1070,7 @@ func (j *Client) QueryEmailSummaries(accountIds []AccountId, //NOSONAR
return ZeroResultV[map[AccountId]EmailsSummary](), err
}
return command(j, ctx, cmd, func(body *Response) (map[AccountId]EmailsSummary, State, Error) {
return command(j, Operation("QueryEmailSummaries"), ctx, cmd, func(body *Response) (map[AccountId]EmailsSummary, State, Error) {
resp := map[AccountId]EmailsSummary{}
for _, accountId := range uniqueAccountIds {
var queryResponse EmailQueryResponse

View File

@@ -55,7 +55,7 @@ func (j *Client) GetIdentitiesAndMailboxes(mailboxAccountId AccountId, accountId
if err != nil {
return ZeroResultV[IdentitiesAndMailboxesGetResponse](), err
}
return command(j, ctx, cmd, func(body *Response) (IdentitiesAndMailboxesGetResponse, State, Error) {
return command(j, Operation("GetIdentitiesAndMailboxes"), ctx, cmd, func(body *Response) (IdentitiesAndMailboxesGetResponse, State, Error) {
identities := make(map[AccountId][]Identity, len(uniqueAccountIds))
stateByAccountId := make(map[AccountId]State, len(uniqueAccountIds))
notFound := []string{}

View File

@@ -55,7 +55,7 @@ func (j *Client) SearchMailboxes(accountIds []AccountId, filter MailboxFilterEle
return ZeroResultV[map[AccountId][]Mailbox](), err
}
return command(j, ctx, cmd, func(body *Response) (map[AccountId][]Mailbox, State, Error) {
return command(j, Operation("SearchMailboxes"), ctx, cmd, func(body *Response) (map[AccountId][]Mailbox, State, Error) {
resp := map[AccountId][]Mailbox{}
stateByAccountid := map[AccountId]State{}
for _, accountId := range uniqueAccountIds {
@@ -89,7 +89,7 @@ func (j *Client) SearchMailboxIdsPerRole(accountIds []AccountId, roles []string,
return ZeroResultV[map[AccountId]map[string]string](), err
}
return command(j, ctx, cmd, func(body *Response) (map[AccountId]map[string]string, State, Error) {
return command(j, Operation("SearchMailboxIdsPerRole"), ctx, cmd, func(body *Response) (map[AccountId]map[string]string, State, Error) {
resp := map[AccountId]map[string]string{}
stateByAccountid := map[AccountId]State{}
for _, accountId := range uniqueAccountIds {
@@ -228,7 +228,7 @@ func (j *Client) GetInboxNameForMultipleAccounts(accountIds []AccountId, ctx Con
return ZeroResultV[map[AccountId]string](), err
}
return command(j, ctx, cmd, func(body *Response) (map[AccountId]string, State, Error) {
return command(j, Operation("GetInboxNameForMultipleAccounts"), ctx, cmd, func(body *Response) (map[AccountId]string, State, Error) {
resp := make(map[AccountId]string, n)
stateByAccountId := make(map[AccountId]State, n)
for _, accountId := range uniqueAccountIds {

View File

@@ -93,7 +93,7 @@ func (j *Client) GetObjects(accountId AccountId, //NOSONAR
return ZeroResultV[Objects](), err
}
return command(j, ctx, cmd, func(body *Response) (Objects, State, Error) {
return command(j, Operation("GetObjects"), ctx, cmd, func(body *Response) (Objects, State, Error) {
objs := Objects{}
states := map[string]State{}

View File

@@ -98,7 +98,7 @@ func (j *Client) SetVacationResponse(accountId AccountId, vacation VacationRespo
if err != nil {
return ZeroResultV[VacationResponse](), err
}
return command(j, ctx, cmd, func(body *Response) (VacationResponse, State, Error) {
return command(j, Operation("SetVacationResponse"), ctx, cmd, func(body *Response) (VacationResponse, State, Error) {
var setResponse VacationResponseSetResponse
err = retrieveSet(ctx, body, set, "0", &setResponse)
if err != nil {

View File

@@ -662,7 +662,7 @@ func createJmapClient(container *testcontainers.DockerContainer, ctx context.Con
api := NewHttpJmapClient(&jh, auth, eventListener, true, 8192, true, 8192)
wscf, err := NewHttpWsClientFactory(wsd, auth, logger, eventListener)
wscf, err := NewHttpWsClientFactory(wsd, auth, logger, eventListener, true, 8192)
if err != nil {
return Client{}, nil, nil, err
}
@@ -676,6 +676,10 @@ type ContextPasswordAuthHttpJmapClientAuthenticator struct {
var _ HttpJmapClientAuthenticator = &ContextPasswordAuthHttpJmapClientAuthenticator{}
func (h *ContextPasswordAuthHttpJmapClientAuthenticator) GetId() string {
return "context"
}
func (h *ContextPasswordAuthHttpJmapClientAuthenticator) Authenticate(ctx context.Context, username string, _ *oclog.Logger, req *http.Request) Error {
password := ctx.Value(h.key).(string)
req.SetBasicAuth(username, password)

View File

@@ -10,7 +10,6 @@ import (
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"slices"
"strconv"
@@ -42,62 +41,64 @@ var (
)
const (
logEndpoint = "endpoint"
logUri = "uri"
logMethod = "method"
logHttpStatus = "status"
logHttpStatusCode = "status-code"
logHttpUrl = "url"
logProto = "proto"
logProtoJmap = "jmap"
logProtoJmapWs = "jmapws"
logType = "type"
logTypeRequest = "request"
logTypeResponse = "response"
logTypePush = "push"
logDuration = "duration"
logBodyTruncated = "truncated"
logEndpoint = "endpoint"
logUri = "uri"
logMethod = "method"
logHttpStatus = "status"
logHttpStatusCode = "status-code"
logHttpUrl = "url"
logProto = "proto"
logProtoJmap = "jmap"
logProtoJmapWs = "jmapws"
logType = "type"
logTypeRequest = "request"
logTypeResponse = "response"
logTypePush = "push"
logDuration = "duration"
logBodyTruncated = "truncated"
logAuthenticatorId = "auth-id"
)
// Record JMAP HTTP execution events that may occur, e.g. using metrics.
type HttpJmapApiClientEventListener interface {
OnSuccessfulRequest(endpoint string, status int)
OnFailedRequest(endpoint string, err error)
OnFailedRequestWithStatus(endpoint string, status int)
OnResponseBodyReadingError(endpoint string, err error)
OnResponseBodyUnmarshallingError(endpoint string, err error)
OnSuccessfulWsRequest(endpoint string, status int)
OnFailedWsHandshakeRequestWithStatus(endpoint string, status int)
OnSuccessfulRequest(endpoint string, op Operation, status int)
OnFailedRequest(endpoint string, op Operation, err error)
OnFailedRequestWithStatus(endpoint string, op Operation, status int)
OnResponseBodyReadingError(endpoint string, op Operation, err error)
OnResponseBodyUnmarshallingError(endpoint string, op Operation, err error)
OnSuccessfulWsRequest(endpoint string, op Operation, status int)
OnFailedWsHandshakeRequestWithStatus(endpoint string, op Operation, status int)
}
type nullHttpJmapApiClientEventListener struct {
}
func (l nullHttpJmapApiClientEventListener) OnSuccessfulRequest(endpoint string, status int) {
func (l nullHttpJmapApiClientEventListener) OnSuccessfulRequest(endpoint string, op Operation, status int) {
// null implementation does nothing
}
func (l nullHttpJmapApiClientEventListener) OnFailedRequest(endpoint string, err error) {
func (l nullHttpJmapApiClientEventListener) OnFailedRequest(endpoint string, op Operation, err error) {
// null implementation does nothing
}
func (l nullHttpJmapApiClientEventListener) OnFailedRequestWithStatus(endpoint string, status int) {
func (l nullHttpJmapApiClientEventListener) OnFailedRequestWithStatus(endpoint string, op Operation, status int) {
// null implementation does nothing
}
func (l nullHttpJmapApiClientEventListener) OnResponseBodyReadingError(endpoint string, err error) {
func (l nullHttpJmapApiClientEventListener) OnResponseBodyReadingError(endpoint string, op Operation, err error) {
// null implementation does nothing
}
func (l nullHttpJmapApiClientEventListener) OnResponseBodyUnmarshallingError(endpoint string, err error) {
func (l nullHttpJmapApiClientEventListener) OnResponseBodyUnmarshallingError(endpoint string, op Operation, err error) {
// null implementation does nothing
}
func (l nullHttpJmapApiClientEventListener) OnSuccessfulWsRequest(endpoint string, status int) {
func (l nullHttpJmapApiClientEventListener) OnSuccessfulWsRequest(endpoint string, op Operation, status int) {
// null implementation does nothing
}
func (l nullHttpJmapApiClientEventListener) OnFailedWsHandshakeRequestWithStatus(endpoint string, status int) {
func (l nullHttpJmapApiClientEventListener) OnFailedWsHandshakeRequestWithStatus(endpoint string, op Operation, status int) {
// null implementation does nothing
}
var _ HttpJmapApiClientEventListener = nullHttpJmapApiClientEventListener{}
type HttpJmapClientAuthenticator interface {
GetId() string
Authenticate(ctx context.Context, username string, logger *log.Logger, req *http.Request) Error
AuthenticateWS(ctx context.Context, username string, logger *log.Logger, headers http.Header) Error
}
@@ -130,6 +131,10 @@ func (h *MasterAuthHttpJmapClientAuthenticator) auth(username string, headers ht
headers.Set("Authorization", sb.String())
}
func (h *MasterAuthHttpJmapClientAuthenticator) GetId() string {
return "master"
}
func (h *MasterAuthHttpJmapClientAuthenticator) Authenticate(_ context.Context, username string, _ *log.Logger, req *http.Request) Error {
h.auth(username, req.Header)
return nil
@@ -177,8 +182,8 @@ func (e AuthenticationError) Unwrap() error {
return e.Err
}
func (h *HttpJmapClient) auth(ctx context.Context, username string, logger *log.Logger, req *http.Request) Error {
return h.authenticator.Authenticate(ctx, username, logger, req)
func (h *HttpJmapClient) auth(ctx context.Context, username string, logger *log.Logger, req *http.Request) (string, Error) {
return h.authenticator.GetId(), h.authenticator.Authenticate(ctx, username, logger, req)
}
var (
@@ -188,33 +193,16 @@ var (
func (h *HttpJmapClient) beforeRequest(_ context.Context, logger *log.Logger, _ string, endpoint string, req *http.Request) Error {
l := logger.Trace()
if h.traceRequests && l.Enabled() {
var logBuilder bytes.Buffer
for key, values := range req.Header {
if key == "Authorization" {
continue
}
for _, value := range values {
fmt.Fprintf(&logBuilder, "%s: %s\n", key, value)
if err := dumpHttpRequest(req, h.traceMaxRequestBodySize, func(method string, uri string, content string, truncated bool) {
if truncated {
l = l.Bool(logBodyTruncated, true)
}
l.Str(logMethod, req.Method).Str(logUri, req.URL.String()).
Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest).
Msg(content)
}); err != nil {
return jmapError(err, JmapErrorRequestTracing)
}
if h.traceMaxRequestBodySize >= 0 && req.Body != nil && req.Body != http.NoBody {
peekBuffer := new(bytes.Buffer)
limitedReader := io.LimitReader(req.Body, h.traceMaxRequestBodySize)
tee := io.TeeReader(limitedReader, peekBuffer)
if _, err := io.Copy(io.Discard, tee); err != nil {
return jmapError(fmt.Errorf("failed to peek at the request body for tracing: %v", err), JmapErrorRequestTracing)
} else {
logBuilder.Write(peekBuffer.Bytes())
if int64(peekBuffer.Len()) >= h.traceMaxRequestBodySize {
l = l.Bool(logBodyTruncated, true)
}
fullBodyReader := io.MultiReader(peekBuffer, req.Body)
req.Body = io.NopCloser(fullBodyReader)
}
}
l.Str(logMethod, req.Method).Str(logUri, req.URL.String()).
Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest).
Msg(logBuilder.String())
}
return nil
}
@@ -234,48 +222,15 @@ func (c httpResponseReadCloser) Close() error {
func (h *HttpJmapClient) response(_ context.Context, logger *log.Logger, _ string, endpoint string, duration time.Duration, req *http.Request, resp *http.Response) (io.ReadCloser, error) {
l := logger.Trace()
if h.traceResponses && l.Enabled() {
var logBuilder bytes.Buffer
for key, values := range resp.Header {
for _, value := range values {
fmt.Fprintf(&logBuilder, "%s: %s\n", key, value)
}
}
var peekBuffer *bytes.Buffer = nil
if h.traceMaxResponseBodySize > 0 {
peekBuffer = new(bytes.Buffer)
limitedReader := io.LimitReader(resp.Body, h.traceMaxResponseBodySize)
tee := io.TeeReader(limitedReader, peekBuffer)
if _, err := io.Copy(io.Discard, tee); err != nil {
if resp.Body != nil {
if err := resp.Body.Close(); err != nil {
logger.Error().Err(err).Msg("failed to close response body") //NOSONAR
}
}
return nil, err
}
if int64(peekBuffer.Len()) >= h.traceMaxResponseBodySize {
l = l.Bool(logBodyTruncated, true)
}
logBuilder.Write(peekBuffer.Bytes())
}
l.Str(logProto, logProtoJmap).Str(logType, logTypeResponse).Str(logEndpoint, endpoint).
Str(logMethod, req.Method).Str(logUri, req.URL.String()).
Str(logHttpStatus, log.SafeString(resp.Status)).Int(logHttpStatusCode, resp.StatusCode).
Dur(logDuration, duration).
Msg(logBuilder.String())
if peekBuffer != nil {
return httpResponseReadCloser{
body: resp.Body,
Reader: io.MultiReader(peekBuffer, resp.Body),
}, nil
} else {
return resp.Body, nil
}
return dumpHttpResponse(req, resp, h.traceMaxResponseBodySize, func(method, uri, content string, truncated bool) {
l.Str(logProto, logProtoJmap).Str(logType, logTypeResponse).Str(logEndpoint, endpoint).
Str(logMethod, method).Str(logUri, uri).
Str(logHttpStatus, log.SafeString(resp.Status)).Int(logHttpStatusCode, resp.StatusCode).
Dur(logDuration, duration).
Msg(content)
}, func(err error) {
logger.Error().Err(err).Msg("failed to close response body") //NOSONAR
})
} else {
return resp.Body, nil
}
@@ -309,15 +264,17 @@ func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, us
return SessionResponse{}, err
}
if err := h.auth(ctx, username, logger, req); err != nil {
if authenticatorId, err := h.auth(ctx, username, logger, req); err != nil {
return SessionResponse{}, err
} else {
logger = log.From(logger.With().Str(logAuthenticatorId, authenticatorId))
}
before := time.Now()
res, err := h.client.Do(req)
duration := time.Since(before)
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
h.listener.OnFailedRequest(endpoint, Operation("GetSession"), err)
logger.Error().Err(err).Msgf("failed to perform GET %v", sessionUrl)
return SessionResponse{}, jmapError(err, JmapErrorInvalidHttpRequest)
}
@@ -334,29 +291,29 @@ func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, us
}()
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
h.listener.OnFailedRequestWithStatus(endpoint, Operation("GetSession"), res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 200")
return SessionResponse{}, jmapError(fmt.Errorf("JMAP API response status is %v", res.Status), JmapErrorServerResponse)
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
h.listener.OnSuccessfulRequest(endpoint, Operation("GetSession"), res.StatusCode)
if err != nil {
logger.Error().Err(err).Msg("failed to read response body") //NOSONAR
h.listener.OnResponseBodyReadingError(endpoint, err)
h.listener.OnResponseBodyReadingError(endpoint, Operation("GetSession"), err)
return SessionResponse{}, jmapError(err, JmapErrorReadingResponseBody)
}
var data SessionResponse
if err := json.NewDecoder(body).Decode(&data); err != nil {
logger.Error().Str(logHttpUrl, log.SafeString(sessionUrlStr)).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response")
h.listener.OnResponseBodyUnmarshallingError(endpoint, err)
h.listener.OnResponseBodyUnmarshallingError(endpoint, Operation("GetSession"), err)
return SessionResponse{}, jmapError(err, JmapErrorDecodingResponseBody)
}
return data, nil
}
func (h *HttpJmapClient) Command(request Request, ctx Context) (io.ReadCloser, Language, Error) { //NOSONAR
func (h *HttpJmapClient) Command(operation Operation, request Request, ctx Context) (io.ReadCloser, Language, Error) { //NOSONAR
session := ctx.Session
logger := ctx.Logger
acceptLanguage := ctx.AcceptLanguage
@@ -389,15 +346,17 @@ func (h *HttpJmapClient) Command(request Request, ctx Context) (io.ReadCloser, L
h.beforeRequest(cotx, logger, session.Username, endpoint, req)
if err := h.auth(cotx, session.Username, logger, req); err != nil {
if authenticatorId, err := h.auth(cotx, session.Username, logger, req); err != nil {
return nil, "", err
} else {
logger = log.From(logger.With().Str(logAuthenticatorId, authenticatorId))
}
before := time.Now()
res, err := h.client.Do(req)
duration := time.Since(before)
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
h.listener.OnFailedRequest(endpoint, operation, err)
logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl)
return nil, "", jmapError(err, JmapErrorSendingRequest)
}
@@ -411,22 +370,22 @@ func (h *HttpJmapClient) Command(request Request, ctx Context) (io.ReadCloser, L
language := Language(res.Header.Get("Content-Language")) //NOSONAR
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
h.listener.OnResponseBodyReadingError(endpoint, err)
h.listener.OnResponseBodyReadingError(endpoint, operation, err)
return nil, language, jmapError(err, JmapErrorServerResponse)
}
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
h.listener.OnFailedRequestWithStatus(endpoint, operation, res.StatusCode)
logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, log.SafeString(res.Status)).Msg("HTTP response status code is not 2xx") //NOSONAR
return nil, language, jmapError(err, JmapErrorServerResponse)
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
h.listener.OnSuccessfulRequest(endpoint, operation, res.StatusCode)
return body, language, nil
}
func (h *HttpJmapClient) UploadBinary(uploadUrl string, endpoint string, contentType string, body io.Reader, ctx Context) (UploadedBlob, Language, Error) { //NOSONAR
func (h *HttpJmapClient) UploadBinary(uploadUrl string, operation Operation, endpoint string, contentType string, body io.Reader, ctx Context) (UploadedBlob, Language, Error) { //NOSONAR
session := ctx.Session
logger := ctx.Logger
acceptLanguage := ctx.AcceptLanguage
@@ -446,15 +405,17 @@ func (h *HttpJmapClient) UploadBinary(uploadUrl string, endpoint string, content
}
h.beforeRequest(cotx, logger, session.Username, endpoint, req)
if err := h.auth(cotx, session.Username, logger, req); err != nil {
if authenticatorId, err := h.auth(cotx, session.Username, logger, req); err != nil {
return UploadedBlob{}, "", err
} else {
logger = log.From(logger.With().Str(logAuthenticatorId, authenticatorId))
}
before := time.Now()
res, err := h.client.Do(req)
duration := time.Since(before)
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
h.listener.OnFailedRequest(endpoint, operation, err)
logger.Error().Err(err).Msgf("failed to perform POST %v", uploadUrl)
return UploadedBlob{}, "", jmapError(err, JmapErrorSendingRequest)
}
@@ -476,28 +437,28 @@ func (h *HttpJmapClient) UploadBinary(uploadUrl string, endpoint string, content
language := Language(res.Header.Get("Content-Language"))
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
h.listener.OnResponseBodyReadingError(endpoint, err)
h.listener.OnResponseBodyReadingError(endpoint, operation, err)
return UploadedBlob{}, language, jmapError(err, JmapErrorServerResponse)
}
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
h.listener.OnFailedRequestWithStatus(endpoint, operation, res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
return UploadedBlob{}, language, jmapError(err, JmapErrorServerResponse)
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
h.listener.OnSuccessfulRequest(endpoint, operation, res.StatusCode)
var result UploadedBlob
if err := json.NewDecoder(responseBody).Decode(&result); err != nil {
logger.Error().Str(logHttpUrl, log.SafeString(uploadUrl)).Err(err).Msg("failed to decode JSON payload from the upload response")
h.listener.OnResponseBodyUnmarshallingError(endpoint, err)
h.listener.OnResponseBodyUnmarshallingError(endpoint, operation, err)
return UploadedBlob{}, language, jmapError(err, JmapErrorDecodingResponseBody)
}
return result, language, nil
}
func (h *HttpJmapClient) DownloadBinary(downloadUrl string, endpoint string, ctx Context) (*BlobDownload, Language, Error) { //NOSONAR
func (h *HttpJmapClient) DownloadBinary(downloadUrl string, operation Operation, endpoint string, ctx Context) (*BlobDownload, Language, Error) { //NOSONAR
session := ctx.Session
logger := ctx.Logger
acceptLanguage := ctx.AcceptLanguage
@@ -516,15 +477,17 @@ func (h *HttpJmapClient) DownloadBinary(downloadUrl string, endpoint string, ctx
}
h.beforeRequest(cotx, logger, session.Username, endpoint, req)
if err := h.auth(cotx, session.Username, logger, req); err != nil {
if authenticatorId, err := h.auth(cotx, session.Username, logger, req); err != nil {
return nil, "", err
} else {
logger = log.From(logger.With().Str(logAuthenticatorId, authenticatorId))
}
before := time.Now()
res, err := h.client.Do(req)
duration := time.Since(before)
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
h.listener.OnFailedRequest(endpoint, operation, err)
logger.Error().Err(err).Msgf("failed to perform GET %v", downloadUrl)
return nil, "", jmapError(err, JmapErrorSendingRequest)
}
@@ -539,7 +502,7 @@ func (h *HttpJmapClient) DownloadBinary(downloadUrl string, endpoint string, ctx
language := Language(res.Header.Get("Content-Language"))
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
h.listener.OnResponseBodyReadingError(endpoint, err)
h.listener.OnResponseBodyReadingError(endpoint, operation, err)
return nil, language, jmapError(err, JmapErrorServerResponse)
}
@@ -547,11 +510,11 @@ func (h *HttpJmapClient) DownloadBinary(downloadUrl string, endpoint string, ctx
return nil, language, nil
}
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
h.listener.OnFailedRequestWithStatus(endpoint, operation, res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
return nil, language, jmapError(err, JmapErrorServerResponse)
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
h.listener.OnSuccessfulRequest(endpoint, operation, res.StatusCode)
sizeStr := res.Header.Get("Content-Length")
size := -1
@@ -604,16 +567,20 @@ type WebSocketPushDisable struct {
}
type HttpWsClientFactory struct {
dialer *websocket.Dialer
authenticator HttpJmapClientAuthenticator
logger *log.Logger
eventListener HttpJmapApiClientEventListener
dialer *websocket.Dialer
authenticator HttpJmapClientAuthenticator
logger *log.Logger
eventListener HttpJmapApiClientEventListener
traceWsResponses bool
traceMaxResponseBodySize int64
}
var _ WsClientFactory = &HttpWsClientFactory{}
func NewHttpWsClientFactory(dialer *websocket.Dialer, authenticator HttpJmapClientAuthenticator, logger *log.Logger,
eventListener HttpJmapApiClientEventListener) (*HttpWsClientFactory, error) {
eventListener HttpJmapApiClientEventListener,
traceWsResponses bool, traceMaxResponseBodySize int64,
) (*HttpWsClientFactory, error) {
// RFC 8887: Section 4.2:
// Otherwise, the client MUST make an authenticated HTTP request [RFC7235] on the encrypted connection
// and MUST include the value "jmap" in the list of protocols for the "Sec-WebSocket-Protocol" header
@@ -621,10 +588,12 @@ func NewHttpWsClientFactory(dialer *websocket.Dialer, authenticator HttpJmapClie
dialer.Subprotocols = []string{"jmap"}
return &HttpWsClientFactory{
dialer: dialer,
authenticator: authenticator,
logger: logger,
eventListener: eventListener,
dialer: dialer,
authenticator: authenticator,
logger: logger,
eventListener: eventListener,
traceWsResponses: true,
traceMaxResponseBodySize: 4 * 1024,
}, nil
}
@@ -632,9 +601,7 @@ func (w *HttpWsClientFactory) auth(ctx context.Context, username string, logger
return w.authenticator.AuthenticateWS(ctx, username, logger, h)
}
func (w *HttpWsClientFactory) connect(ctx context.Context, sessionProvider func() (*Session, error)) (*websocket.Conn, string, string, Error) {
logger := w.logger
func (w *HttpWsClientFactory) connect(operation Operation, ctx context.Context, sessionProvider func() (*Session, error)) (*websocket.Conn, string, string, Error) {
session, err := sessionProvider()
if err != nil {
return nil, "", "", jmapError(err, JmapErrorWssFailedToRetrieveSession)
@@ -651,29 +618,49 @@ func (w *HttpWsClientFactory) connect(ctx context.Context, sessionProvider func(
u := session.WebsocketUrl
endpoint := session.WebsocketEndpoint
logger := log.From(w.logger.With().Str("username", log.SafeString(username)).Str("url", log.SafeString(u.String())))
h := http.Header{}
w.auth(ctx, username, logger, h)
w.logger.Trace().Str("username", log.SafeString(username)).Str("url", log.SafeString(u.String())).Msgf("connecting")
w.logger.Trace().Str("username", log.SafeString(username)).Str("url", log.SafeString(u.String())).Msgf("connecting") // TODO more/better attributes here for WS connection attempts
before := time.Now()
c, res, err := w.dialer.DialContext(ctx, u.String(), h)
duration := time.Since(before)
if err != nil {
return nil, "", endpoint, jmapError(err, JmapErrorFailedToEstablishWssConnection)
}
if w.logger.Trace().Enabled() {
responseBytes, err := httputil.DumpResponse(res, true)
if err == nil {
logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmapWs).Str(logType, logTypeResponse).
Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).
Msg(string(responseBytes))
var body io.ReadCloser = nil
{
l := logger.Trace()
if w.traceWsResponses && l.Enabled() {
body, err = dumpHttpResponse(nil, res, w.traceMaxResponseBodySize, func(method, uri, content string, truncated bool) {
l.Str(logProto, logProtoJmap).Str(logType, logTypeResponse).Str(logEndpoint, endpoint).
Str(logMethod, method).Str(logUri, uri).
Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).
Dur(logDuration, duration).
Msg(content)
}, func(err error) {
logger.Error().Err(err).Msg("failed to close response body") //NOSONAR
})
}
}
// we are not using the response body at all, at least for now
defer func() {
if body != nil {
if err := body.Close(); err != nil {
logger.Error().Err(err).Msg("failed to close response body") //NOSONAR
}
}
}()
if res.StatusCode != 101 {
w.eventListener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
w.eventListener.OnFailedRequestWithStatus(endpoint, operation, res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 101")
return nil, "", endpoint, jmapError(fmt.Errorf("JMAP WS API response status is %v", res.Status), JmapErrorServerResponse)
} else {
w.eventListener.OnSuccessfulWsRequest(endpoint, res.StatusCode)
w.eventListener.OnSuccessfulWsRequest(endpoint, operation, res.StatusCode)
}
// RFC 8887: Section 4.2:
@@ -748,7 +735,7 @@ func (w *HttpWsClient) readPump() { //NOSONAR
}
func (w *HttpWsClientFactory) EnableNotifications(ctx context.Context, pushState State, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error) {
c, username, endpoint, jerr := w.connect(ctx, sessionProvider)
c, username, endpoint, jerr := w.connect(Operation("EnableNotifications"), ctx, sessionProvider)
if jerr != nil {
return nil, jerr
}

View File

@@ -1243,11 +1243,11 @@ type Response struct {
// Clients may use this to detect if this object has changed and needs to be refetched.
SessionState SessionState `json:"sessionState"`
// This MUST be the string "Response".
// Only for Websockets: This MUST be the string "Response".
// The specification extends the Response object with two additional arguments when used over a WebSocket.
Type TypeOfResponse `json:"@type,omitempty"`
// MUST be returned if an identifier is included in the request (optional).
// Only for Websockets: MUST be returned if an identifier is included in the request (optional).
RequestId string `json:"requestId,omitempty"`
}

View File

@@ -22,7 +22,7 @@ func get[T Foo, GETREQ GetCommand[T], GETRESP GetResponse[T], ID any, RESP any](
return ZeroResultV[RESP](), err
}
return command(client, ctx, cmd, func(body *Response) (RESP, State, Error) {
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
var response GETRESP
err = retrieveGet(ctx, body, get, "0", &response)
if err != nil {
@@ -73,7 +73,7 @@ func getN[T Foo, ITEM any, GETREQ GetCommand[T], GETRESP GetResponse[T], ID any,
return ZeroResultV[RESP](), err
}
return command(client, ctx, cmd, func(body *Response) (RESP, State, Error) {
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
result := map[AccountId]ITEM{}
responses := map[AccountId]GETRESP{}
for _, accountId := range uniqueAccountIds {
@@ -112,7 +112,7 @@ func create[T Foo, C any, SETREQ SetCommand[T], GETREQ GetCommand[T], SETRESP Se
return ZeroResultV[*T](), err
}
return command(client, ctx, cmd, func(body *Response) (*T, State, Error) {
return command(client, Operation(name), ctx, cmd, func(body *Response) (*T, State, Error) {
var setResponse SETRESP
err = retrieveSet(ctx, body, set, "0", &setResponse)
if err != nil {
@@ -165,7 +165,7 @@ func destroy[T Foo, REQ SetCommand[T], RESP SetResponse[T]](client *Client, name
return ZeroResultV[map[string]SetError](), err
}
return command(client, ctx, cmd, func(body *Response) (map[string]SetError, State, Error) {
return command(client, Operation(name), ctx, cmd, func(body *Response) (map[string]SetError, State, Error) {
var setResponse RESP
err = retrieveSet(ctx, body, set, "0", &setResponse)
if err != nil {
@@ -213,7 +213,7 @@ func changes[T Foo, CHANGESREQ ChangesCommand[T], GETREQ GetCommand[T], CHANGESR
return ZeroResultV[RESP](), err
}
return command(client, ctx, cmd, func(body *Response) (RESP, State, Error) {
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
var changesResponse CHANGESRESP
err = retrieveChanges(ctx, body, changes, "0", &changesResponse)
if err != nil {
@@ -302,7 +302,7 @@ func changesN[T Foo, CHANGESREQ ChangesCommand[T], GETREQ GetCommand[T], CHANGES
return ZeroResultV[RESP](), err
}
return command(client, ctx, cmd, func(body *Response) (RESP, State, Error) {
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
changesItemByAccount := make(map[AccountId]CHANGESITEM, n)
stateByAccountId := make(map[AccountId]State, n)
for _, accountId := range uniqueAccountIds {
@@ -357,7 +357,7 @@ func updates[T Foo, CHANGESREQ ChangesCommand[T], GETREQ GetCommand[T], CHANGESR
return ZeroResultV[RESP](), err
}
return command(client, ctx, cmd, func(body *Response) (RESP, State, Error) {
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
var changesResponse CHANGESRESP
err = retrieveChanges(ctx, body, changes, "0", &changesResponse)
if err != nil {
@@ -405,7 +405,7 @@ func update[T Foo, CHANGES Change[T], SET SetCommand[T], GET GetCommand[T], RESP
return ZeroResultV[RESP](), err
}
return command(client, ctx, cmd, func(body *Response) (RESP, State, Error) {
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
var setResponse SETRESP
err = retrieveSet(ctx, body, update, "0", &setResponse)
if err != nil {
@@ -529,7 +529,7 @@ func queryN[T Foo, FILTER any, SORT any, QUERY QueryCommand[T /*, QUERY*/], GET
return ZeroResultV[map[AccountId]*RESP](), err
}
return command(client, ctx, cmd, func(body *Response) (map[AccountId]*RESP, State, Error) {
return command(client, Operation(name), ctx, cmd, func(body *Response) (map[AccountId]*RESP, State, Error) {
resp := map[AccountId]*RESP{}
stateByAccountId := map[AccountId]State{}
for accountId, queryParams := range accountIds {

View File

@@ -1,9 +1,12 @@
package jmap
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"slices"
"strings"
@@ -62,7 +65,10 @@ type Cmdr interface {
Hooks
}
type Operation string
func command[T any](client Cmdr, //NOSONAR
operation Operation,
ctx Context,
request Request,
mapper func(body *Response) (T, State, Error)) (Result[T], Error) {
@@ -70,7 +76,7 @@ func command[T any](client Cmdr, //NOSONAR
logger := ctx.Logger
before := time.Now()
responseBody, language, jmapErr := client.Api().Command(request, ctx)
responseBody, language, jmapErr := client.Api().Command(operation, request, ctx)
duration := time.Since(before)
if jmapErr != nil {
return ZeroResult[T](single(duration)), jmapErr
@@ -445,3 +451,81 @@ func ns(namespaces ...JmapNamespace) []JmapNamespace {
func ptr[T any | int | uint | bool | string](t T) *T {
return &t
}
func dumpHttpRequest(req *http.Request, maxBodySize int64, closure func(method string, uri string, content string, truncated bool)) error {
var logBuilder bytes.Buffer
for key, values := range req.Header {
if key == "Authorization" {
continue
}
for _, value := range values {
fmt.Fprintf(&logBuilder, "%s: %s\n", key, value)
}
}
truncated := false
if maxBodySize >= 0 && req.Body != nil && req.Body != http.NoBody {
peekBuffer := new(bytes.Buffer)
limitedReader := io.LimitReader(req.Body, maxBodySize)
tee := io.TeeReader(limitedReader, peekBuffer)
if _, err := io.Copy(io.Discard, tee); err != nil {
return fmt.Errorf("failed to peek at the request body for tracing: %v", err)
} else {
logBuilder.Write(peekBuffer.Bytes())
if int64(peekBuffer.Len()) >= maxBodySize {
truncated = true
}
fullBodyReader := io.MultiReader(peekBuffer, req.Body)
req.Body = io.NopCloser(fullBodyReader)
}
}
closure(req.Method, req.URL.String(), logBuilder.String(), truncated)
return nil
}
func dumpHttpResponse(req *http.Request, resp *http.Response, maxBodySize int64,
closure func(method string, uri string, content string, truncated bool),
closeErrorClosure func(err error)) (io.ReadCloser, error) {
var logBuilder bytes.Buffer
for key, values := range resp.Header {
for _, value := range values {
fmt.Fprintf(&logBuilder, "%s: %s\n", key, value)
}
}
var peekBuffer *bytes.Buffer = nil
truncated := false
if maxBodySize > 0 {
peekBuffer = new(bytes.Buffer)
limitedReader := io.LimitReader(resp.Body, maxBodySize)
tee := io.TeeReader(limitedReader, peekBuffer)
if _, err := io.Copy(io.Discard, tee); err != nil {
if resp.Body != nil {
if err := resp.Body.Close(); err != nil {
closeErrorClosure(err)
}
}
return nil, err
}
if int64(peekBuffer.Len()) >= maxBodySize {
truncated = true
}
logBuilder.Write(peekBuffer.Bytes())
}
if req != nil {
closure(req.Method, req.URL.String(), logBuilder.String(), truncated)
} else {
closure("", "", logBuilder.String(), truncated)
}
if peekBuffer != nil {
return httpResponseReadCloser{
body: resp.Body,
Reader: io.MultiReader(peekBuffer, resp.Body),
}, nil
} else {
return resp.Body, nil
}
}

View File

@@ -170,6 +170,14 @@ func MapMap[A comparable, B any, X comparable, Y any](m map[A]B, mapper func(A,
return r
}
func FlatMap[K comparable, V any, E any](m map[K]V, mapper func(K, V) E) []E {
r := make([]E, len(m))
for k, v := range m {
r = append(r, mapper(k, v))
}
return r
}
// Creates a map from a map, keeping each key as-is, and using the mapper
// function to determine the value to store into the resulting map.
func MapValues[K comparable, S any, T any](m map[K]S, mapper func(S) T) map[K]T {

View File

@@ -156,25 +156,26 @@ type groupwareHttpJmapApiClientMetricsRecorder struct {
var _ jmap.HttpJmapApiClientEventListener = groupwareHttpJmapApiClientMetricsRecorder{}
func (r groupwareHttpJmapApiClientMetricsRecorder) OnSuccessfulRequest(endpoint string, status int) {
func (r groupwareHttpJmapApiClientMetricsRecorder) OnSuccessfulRequest(endpoint string, op jmap.Operation, status int) {
r.m.SuccessfulRequestPerEndpointCounter.With(metrics.Endpoint(endpoint)).Inc()
r.m.OperationSpreadCounter.With(metrics.EndpointAndOperation(endpoint, op)).Inc()
}
func (r groupwareHttpJmapApiClientMetricsRecorder) OnFailedRequest(endpoint string, err error) {
func (r groupwareHttpJmapApiClientMetricsRecorder) OnFailedRequest(endpoint string, op jmap.Operation, err error) {
r.m.FailedRequestPerEndpointCounter.With(metrics.Endpoint(endpoint)).Inc()
}
func (r groupwareHttpJmapApiClientMetricsRecorder) OnFailedRequestWithStatus(endpoint string, status int) {
func (r groupwareHttpJmapApiClientMetricsRecorder) OnFailedRequestWithStatus(endpoint string, op jmap.Operation, status int) {
r.m.FailedRequestStatusPerEndpointCounter.With(metrics.EndpointAndStatus(endpoint, status)).Inc()
}
func (r groupwareHttpJmapApiClientMetricsRecorder) OnResponseBodyReadingError(endpoint string, err error) {
func (r groupwareHttpJmapApiClientMetricsRecorder) OnResponseBodyReadingError(endpoint string, op jmap.Operation, err error) {
r.m.ResponseBodyReadingErrorPerEndpointCounter.With(metrics.Endpoint(endpoint)).Inc()
}
func (r groupwareHttpJmapApiClientMetricsRecorder) OnResponseBodyUnmarshallingError(endpoint string, err error) {
func (r groupwareHttpJmapApiClientMetricsRecorder) OnResponseBodyUnmarshallingError(endpoint string, op jmap.Operation, err error) {
r.m.ResponseBodyUnmarshallingErrorPerEndpointCounter.With(metrics.Endpoint(endpoint)).Inc()
}
func (r groupwareHttpJmapApiClientMetricsRecorder) OnSuccessfulWsRequest(endpoint string, status int) {
func (r groupwareHttpJmapApiClientMetricsRecorder) OnSuccessfulWsRequest(endpoint string, op jmap.Operation, status int) {
// TODO metrics for WSS
}
func (r groupwareHttpJmapApiClientMetricsRecorder) OnFailedWsHandshakeRequestWithStatus(endpoint string, status int) {
func (r groupwareHttpJmapApiClientMetricsRecorder) OnFailedWsHandshakeRequestWithStatus(endpoint string, op jmap.Operation, status int) {
// TODO metrics for WSS
}
@@ -210,7 +211,10 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
sanitize := true // TODO make configurable
m := metrics.New(prometheusRegistry, logger)
m, err := metrics.New(prometheusRegistry, logger)
if err != nil {
return nil, GroupwareInitializationError{Message: fmt.Sprintf("failed to configure metrics: %v", err), Err: err}
}
userProvider := newRevaContextUsernameProvider()
@@ -269,7 +273,9 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
wsDialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402 insecure TLS is a configuration option for development
}
wsf, err = jmap.NewHttpWsClientFactory(wsDialer, auth, logger, jmapMetricsAdapter)
wsf, err = jmap.NewHttpWsClientFactory(wsDialer, auth, logger, jmapMetricsAdapter,
config.HTTP.TraceResponses, config.HTTP.TraceMaxResponseBodySize,
)
if err != nil {
logger.Error().Err(err).Msg("failed to create websocket client")
return nil, GroupwareInitializationError{Message: "failed to create websocket client", Err: err}

View File

@@ -89,6 +89,10 @@ var tokenMissingInRevaContext = RevaError{
err: errors.New("token is missing from Reva context"),
}
func (h *RevaBearerHttpJmapClientAuthenticator) GetId() string {
return "reva"
}
func (h *RevaBearerHttpJmapClientAuthenticator) Authenticate(ctx context.Context, _ string, logger *log.Logger, req *http.Request) jmap.Error {
token, ok := revactx.ContextGetToken(ctx)
if !ok {

View File

@@ -1,10 +1,14 @@
package metrics
import (
"fmt"
"reflect"
"strconv"
"strings"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/prometheus/client_golang/prometheus"
)
@@ -34,6 +38,7 @@ type Metrics struct {
SSEEventsCounter *prometheus.CounterVec
OutdatedSessionsCounter prometheus.Counter
OperationSpreadCounter *prometheus.CounterVec
SuccessfulRequestPerEndpointCounter *prometheus.CounterVec
FailedRequestPerEndpointCounter *prometheus.CounterVec
FailedRequestStatusPerEndpointCounter *prometheus.CounterVec
@@ -48,6 +53,7 @@ type Metrics struct {
var Labels = struct {
Endpoint string
Result string
Operation string
SessionCacheType string
RequestId string
TraceId string
@@ -57,6 +63,7 @@ var Labels = struct {
}{
Endpoint: "endpoint",
Result: "result",
Operation: "op",
SessionCacheType: "type",
RequestId: "requestID",
TraceId: "traceID",
@@ -104,7 +111,7 @@ var Values = struct {
}
// New initializes the available metrics.
func New(registerer prometheus.Registerer, logger *log.Logger) *Metrics {
func New(registerer prometheus.Registerer, logger *log.Logger) (*Metrics, error) {
m := &Metrics{
SessionCacheDesc: prometheus.NewDesc(
prometheus.BuildFQName(Namespace, Subsystem, "session_cache"),
@@ -178,11 +185,17 @@ func New(registerer prometheus.Registerer, logger *log.Logger) *Metrics {
Name: "jmap_errors_count",
Help: "Number of JMAP errors that occured",
}, []string{Labels.Endpoint, Labels.ErrorCode}),
OperationSpreadCounter: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "jmap_operations",
Help: "Spread of operations by their names",
}, []string{Labels.Endpoint, Labels.Operation}),
SuccessfulRequestPerEndpointCounter: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "jmap_requests_count",
Help: "Number of JMAP requests",
Help: "Number of JMAP requests", //NOSONAR
ConstLabels: prometheus.Labels{
Labels.Result: Values.Result.Success,
},
@@ -191,7 +204,7 @@ func New(registerer prometheus.Registerer, logger *log.Logger) *Metrics {
Namespace: Namespace,
Subsystem: Subsystem,
Name: "jmap_requests_count",
Help: "Number of JMAP requests",
Help: "Number of JMAP requests", //NOSONAR
ConstLabels: prometheus.Labels{
Labels.Result: Values.Result.Failure,
},
@@ -249,40 +262,51 @@ func New(registerer prometheus.Registerer, logger *log.Logger) *Metrics {
}, []string{Labels.Endpoint}),
}
registerAll(registerer, m, logger)
err := registerAll(registerer, m, logger)
return m
return m, err
}
func WithExemplar(obs prometheus.Observer, value float64, requestId string, traceId string) {
obs.(prometheus.ExemplarObserver).ObserveWithExemplar(value, prometheus.Labels{Labels.RequestId: requestId, Labels.TraceId: traceId})
}
func registerAll(registerer prometheus.Registerer, m any, logger *log.Logger) {
func registerAll(registerer prometheus.Registerer, m any, logger *log.Logger) error {
r := reflect.ValueOf(m)
if r.Kind() == reflect.Pointer {
r = r.Elem()
}
total := 0
succeeded := 0
failed := 0
succeeded := []string{}
failed := map[string]error{}
for i := 0; i < r.NumField(); i++ {
n := r.Type().Field(i).Name
f := r.Field(i)
v := f.Interface()
c, ok := v.(prometheus.Collector)
if ok {
switch c := v.(type) {
case prometheus.Collector:
total++
err := registerer.Register(c)
if err != nil {
failed++
logger.Warn().Err(err).Msgf("failed to register metric '%s' (%T)", n, c)
if err := registerer.Register(c); err != nil {
failed[n] = err
} else {
succeeded++
succeeded = append(succeeded, n)
}
case *prometheus.Desc, prometheus.GaugeOpts, prometheus.CounterOpts, prometheus.UntypedOpts:
// skip these
default:
failed[n] = fmt.Errorf("unsupported metric '%s' of type %T", n, c)
}
}
logger.Debug().Msgf("registered %d/%d metrics successfully (%d failed)", succeeded, total, failed)
if len(failed) > 0 {
msg := strings.Join(structs.FlatMap(failed, func(name string, err error) string {
return fmt.Sprintf("'%s' (%v)", name, err)
}), ", ")
logger.Warn().Msgf("registered %d/%d metrics successfully (%d failed): %s", len(succeeded), total, len(failed), msg)
return fmt.Errorf("failed to register metrics: %s", msg)
} else {
logger.Debug().Msgf("registered %d/%d metrics successfully (%d failed)", len(succeeded), total, len(failed))
return nil
}
}
type ConstMetricCollector struct {
@@ -343,3 +367,7 @@ func Endpoint(endpoint string) prometheus.Labels {
func EndpointAndStatus(endpoint string, status int) prometheus.Labels {
return prometheus.Labels{Labels.Endpoint: endpoint, Labels.HttpStatusCode: strconv.Itoa(status)}
}
func EndpointAndOperation(endpoint string, operation jmap.Operation) prometheus.Labels {
return prometheus.Labels{Labels.Endpoint: endpoint, Labels.Operation: string(operation)}
}